char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsupport data urls1c8ffed

main
40.5 KiB1102 linesraw
1use crate::{
2    Result,
3    backend::{Backend, Callbacks},
4    bridge::{Bridge, Capabilities},
5};
6use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
7use serde::Deserialize;
8use serde_json::{Value, json};
9use sha2::{Digest, Sha256};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    path::Path,
13    sync::Arc,
14};
15use url::Url;
16
17/// Canonical module URIs, not compiler paths. Require falls back to import when
18/// no separate require edge was recorded.
19#[derive(Default)]
20pub struct ResolvedImport {
21    pub import: Option<String>,
22    pub require: Option<String>,
23}
24
25pub struct Source {
26    pub uri: String,
27    pub text: String,
28    pub read_only: bool,
29}
30#[derive(Clone, Copy)]
31pub enum ScriptKind {
32    JavaScript,
33    TypeScript,
34}
35
36pub struct ProjectInput {
37    pub sources: Vec<Source>,
38    /// MIME-selected languages for modules whose URLs are not filenames.
39    pub script_kinds: BTreeMap<String, ScriptKind>,
40    pub compiler_options: Value,
41    pub imports: BTreeMap<(String, String), ResolvedImport>,
42    /// Authorized additional import spellings (referrer URI, target URI, specifier).
43    pub import_candidates: Vec<(String, String, String)>,
44    pub per_file_options: BTreeMap<String, Value>,
45    pub diagnostics: BTreeMap<String, Vec<String>>,
46    pub type_references: BTreeMap<(String, String), String>,
47    pub path_references: BTreeMap<(String, String), String>,
48    pub globals: BTreeSet<String>,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Diagnostic {
53    pub uri: String,
54    pub line: usize,
55    pub character: usize,
56    pub end_line: usize,
57    pub end_character: usize,
58    pub severity: u8,
59    pub code: Option<u64>,
60    pub source: String,
61    pub message: String,
62}
63
64fn type_package_name(name: &str) -> Result<String> {
65    let valid_part = |part: &str| {
66        !part.is_empty()
67            && !matches!(part, "." | "..")
68            && part
69                .bytes()
70                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
71    };
72    if let Some(scope) = name.strip_prefix('@') {
73        let Some((scope, package)) = scope.split_once('/') else {
74            return Err("Invalid scoped type reference name".into());
75        };
76        if !valid_part(scope) || !valid_part(package) || package.contains('/') {
77            return Err("Invalid scoped type reference name".into());
78        }
79        Ok(format!("{scope}__{package}"))
80    } else if valid_part(name) {
81        Ok(name.to_owned())
82    } else {
83        Err("Invalid type reference name".into())
84    }
85}
86
87fn insert_path_alias(host: &mut Host, path: String, target: String) -> Result<()> {
88    if host
89        .aliases
90        .get(&path)
91        .is_some_and(|previous| previous != &target)
92    {
93        return Err(format!("Conflicting virtual path reference alias: {path}").into());
94    }
95    host.aliases.insert(path, target);
96    Ok(())
97}
98
99fn validate_virtual_paths(host: &mut Host) -> Result<()> {
100    for (path, target) in &host.aliases {
101        if host.contents.contains_key(path) && path != target {
102            return Err(format!("Path reference shadows a canonical source: {path}").into());
103        }
104    }
105    let virtual_files: BTreeSet<_> = host
106        .contents
107        .keys()
108        .chain(host.aliases.keys())
109        .map(String::as_str)
110        .collect();
111    for file in &virtual_files {
112        for ancestor in Path::new(file).ancestors().skip(1) {
113            if virtual_files.contains(ancestor.to_str().unwrap()) {
114                return Err(format!(
115                    "Virtual file collides with a directory: {}",
116                    ancestor.display()
117                )
118                .into());
119            }
120        }
121    }
122    host.aliases.retain(|path, target| path != target);
123    Ok(())
124}
125
126fn lexical_path(path: &Path) -> String {
127    use std::path::Component;
128    let mut parts = Vec::new();
129    for component in path.components() {
130        match component {
131            Component::RootDir => parts.clear(),
132            Component::CurDir => {}
133            Component::ParentDir => {
134                parts.pop();
135            }
136            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
137            Component::Prefix(_) => {}
138        }
139    }
140    format!("/{}", parts.join("/"))
141}
142
143const COMPONENT: &percent_encoding::AsciiSet = &NON_ALPHANUMERIC
144    .remove(b'-')
145    .remove(b'_')
146    .remove(b'.')
147    .remove(b'~')
148    .remove(b'!')
149    .remove(b'\'')
150    .remove(b'(')
151    .remove(b')')
152    .remove(b'*');
153
154fn editor_uri(uri: &str, extension: &str) -> Result<String> {
155    let uri = canonical_uri(uri)?;
156    let mut url = Url::parse(&uri)?;
157    match url.scheme() {
158        "https" | "http" => {
159            let scheme = url.scheme().to_owned();
160            // Custom-scheme query components must survive VS Code's URI codec
161            // without collapsing an escaped delimiter into a real delimiter.
162            if let Some(query) = url.query() {
163                let query = utf8_percent_encode(query, COMPONENT).to_string();
164                url.set_query(Some(&query));
165            }
166            canonical_uri(&url.to_string().replacen(
167                &format!("{scheme}:"),
168                &format!("avril-{scheme}:"),
169                1,
170            ))
171        }
172        "avril" => Ok(uri.replacen("avril:", "avril-builtin:", 1)),
173        "data" => Ok(format!(
174            "avril-builtin:/data/{:x}{extension}",
175            Sha256::digest(uri.as_bytes())
176        )),
177        "file" => Ok(uri),
178        _ => Err("Unsupported module URI scheme".into()),
179    }
180}
181
182pub fn canonical_uri(uri: &str) -> Result<String> {
183    let mut url = Url::parse(uri)?;
184    // Percent escapes in an opaque payload are source bytes, not path aliases.
185    if url.scheme() == "data" {
186        return Ok(url.to_string());
187    }
188    let parts: Result<Vec<_>> = url
189        .path()
190        .split('/')
191        .map(|s| {
192            Ok(utf8_percent_encode(&percent_decode_str(s).decode_utf8()?, COMPONENT).to_string())
193        })
194        .collect();
195    url.set_path(&parts?.join("/"));
196    if matches!(url.scheme(), "avril-http" | "avril-https")
197        && let Some(query) = url.query()
198    {
199        let query =
200            utf8_percent_encode(&percent_decode_str(query).decode_utf8()?, COMPONENT).to_string();
201        url.set_query(Some(&query));
202    }
203    // VS Code removes empty authorities from custom-scheme URIs.
204    if matches!(
205        url.scheme(),
206        "avril" | "avril-https" | "avril-http" | "avril-builtin"
207    ) && url.host().is_none()
208    {
209        Ok(url.to_string().replacen(":///", ":/", 1))
210    } else {
211        Ok(url.to_string())
212    }
213}
214
215struct Module {
216    source: Source,
217    file: String,
218}
219struct Host {
220    contents: BTreeMap<String, String>,
221    aliases: BTreeMap<String, String>,
222}
223impl Callbacks for Host {
224    fn call(&self, method: &str, params: Value) -> Result<Value> {
225        if !matches!(
226            method,
227            "readFile" | "fileExists" | "directoryExists" | "getAccessibleEntries" | "realpath"
228        ) {
229            return Err(format!("Unexpected backend callback: {method}").into());
230        }
231        let path = params.as_str().ok_or("Invalid filesystem callback")?;
232        if method == "realpath" {
233            return Ok(
234                json!({"kind":"value","value":self.aliases.get(path).map_or(path, String::as_str)}),
235            );
236        }
237        if let Some(target) = self.aliases.get(path) {
238            return Ok(match method {
239                "readFile" => json!({"kind":"value","value": self.contents.get(target)}),
240                "fileExists" => json!({"kind":"value","value":true}),
241                _ => json!({"kind":"value","value":false}),
242            });
243        }
244        let prefix = format!("{path}/");
245        let mut files = BTreeSet::new();
246        let mut directories = BTreeSet::new();
247        for entry in self
248            .aliases
249            .range(prefix.clone()..)
250            .map_while(|(alias, _)| alias.strip_prefix(&prefix))
251        {
252            if let Some((directory, _)) = entry.split_once('/') {
253                directories.insert(directory);
254            } else {
255                files.insert(entry);
256            }
257        }
258        if !files.is_empty() || !directories.is_empty() {
259            return Ok(match method {
260                "directoryExists" => json!({"kind":"value","value":true}),
261                "getAccessibleEntries" => {
262                    json!({"kind":"value","value":{"files":files,"directories":directories}})
263                }
264                _ => json!({"kind":"value","value":false}),
265            });
266        }
267        if path == "bundled:///libs" || path.starts_with("bundled:///libs/") {
268            return Ok(json!({"kind":"useOS"}));
269        }
270        Ok(match method {
271            "readFile" => json!({"kind":"missing"}),
272            "getAccessibleEntries" => {
273                json!({"kind":"value","value":{"files":[],"directories":[]}})
274            }
275            _ => json!({"kind":"value","value":false}),
276        })
277    }
278}
279#[derive(Deserialize)]
280struct Snapshot {
281    snapshot: u64,
282    #[serde(default)]
283    operation: Operation,
284}
285#[derive(Default, Deserialize)]
286struct Operation {
287    #[serde(default, rename = "createdPrograms")]
288    created_programs: Vec<String>,
289}
290pub(crate) struct Project {
291    backend: Backend,
292    modules: BTreeMap<String, Module>,
293    bridge: Bridge,
294    module_specifiers: Vec<Value>,
295    snapshot: u64,
296    projects: BTreeMap<String, String>,
297    diagnostics: BTreeMap<String, Vec<String>>,
298    resolver: u64,
299}
300impl Project {
301    pub async fn create(binary: &Path, input: ProjectInput) -> Result<Self> {
302        let binary = std::fs::canonicalize(binary)?;
303        let globals = input
304            .globals
305            .into_iter()
306            .map(|uri| canonical_uri(&uri))
307            .collect::<Result<BTreeSet<_>>>()?;
308        let script_kinds = input
309            .script_kinds
310            .into_iter()
311            .map(|(uri, kind)| Ok((canonical_uri(&uri)?, kind)))
312            .collect::<Result<BTreeMap<_, _>>>()?;
313        let mut modules = BTreeMap::new();
314        let mut bridge = Bridge::new();
315        let mut files = BTreeMap::new();
316        let mut host = Host {
317            contents: BTreeMap::new(),
318            aliases: BTreeMap::new(),
319        };
320        for mut source in input.sources {
321            source.uri = canonical_uri(&source.uri)?;
322            let uri = Url::parse(&source.uri)?;
323            let extension = match script_kinds.get(&source.uri) {
324                Some(ScriptKind::JavaScript) => ".js",
325                Some(ScriptKind::TypeScript) => ".ts",
326                None => [
327                    ".d.mts", ".d.cts", ".d.ts", ".tsx", ".jsx", ".mts", ".cts", ".mjs", ".cjs",
328                    ".ts", ".js",
329                ]
330                .into_iter()
331                .find(|ext| uri.path().ends_with(ext))
332                .unwrap_or(".ts"),
333            };
334            let editor_uri = if source.read_only && uri.scheme() == "file" {
335                canonical_uri(&format!("avril-builtin:/dependencies{}", uri.path()))?
336            } else {
337                editor_uri(&source.uri, extension)?
338            };
339            let directory = format!("/avril-lsp/live/{}", modules.len());
340            let file = format!("{directory}/source{extension}");
341            if files.insert(source.uri.clone(), file.clone()).is_some() {
342                return Err("Duplicate module identity".into());
343            }
344            host.contents.insert(file.clone(), source.text.clone());
345            bridge.insert(editor_uri.clone(), &file, source.read_only)?;
346            if modules
347                .insert(editor_uri.clone(), Module { source, file })
348                .is_some()
349            {
350                return Err("Duplicate editor URI".into());
351            }
352        }
353        for (kind, references) in [
354            ("type", input.type_references),
355            ("path", input.path_references),
356        ] {
357            for ((referrer, reference), target) in references {
358                let referrer = canonical_uri(&referrer)?;
359                let target = canonical_uri(&target)?;
360                let containing_file = files
361                    .get(&referrer)
362                    .ok_or_else(|| format!("Unknown {kind}-reference referrer: {referrer}"))?;
363                let target_file = files
364                    .get(&target)
365                    .ok_or_else(|| format!("Unknown {kind}-reference target: {target}"))?
366                    .clone();
367                let directory = Path::new(containing_file)
368                    .parent()
369                    .ok_or("Invalid virtual source path")?;
370                let alias = if kind == "type" {
371                    directory
372                        .join("node_modules/@types")
373                        .join(type_package_name(&reference)?)
374                        .join("index.d.ts")
375                } else {
376                    directory.join(reference)
377                };
378                insert_path_alias(&mut host, lexical_path(&alias), target_file)?;
379            }
380        }
381        validate_virtual_paths(&mut host)?;
382        let diagnostics = input
383            .diagnostics
384            .into_iter()
385            .map(|(uri, messages)| {
386                let canonical = canonical_uri(&uri)?;
387                let editor = modules
388                    .iter()
389                    .find(|(_, module)| module.source.uri == canonical)
390                    .map(|(uri, _)| uri.clone())
391                    .map_or_else(
392                        || {
393                            if canonical.starts_with("data:") {
394                                Ok(canonical)
395                            } else {
396                                editor_uri(&uri, ".ts")
397                            }
398                        },
399                        Ok,
400                    )?;
401                Ok((editor, messages))
402            })
403            .collect::<Result<_>>()?;
404        let mut resolutions = Vec::new();
405        let mut module_specifiers = Vec::new();
406        // Keep existing import spellings ahead of require-only spellings for auto-imports.
407        for mode in [None, Some(1)] {
408            for ((from, specifier), targets) in &input.imports {
409                let target = if mode.is_some() {
410                    &targets.require
411                } else {
412                    &targets.import
413                };
414                let Some(target) = target else { continue };
415                let from = canonical_uri(from)?;
416                let file = files.get(&from).ok_or("Unknown import referrer")?;
417                let directory = Path::new(file).parent().ok_or("Invalid compiler path")?;
418                let target = canonical_uri(target)?;
419                let target = files.get(&target).ok_or("Unknown import target")?;
420                let mut entry = json!({
421                    "moduleName": specifier,
422                    "containingDirectory": directory,
423                    "result": {"resolvedFileName": target},
424                });
425                // TypeScript's CommonJS mode overrides the default import edge.
426                if let Some(mode) = mode {
427                    entry["resolutionMode"] = json!(mode);
428                }
429                resolutions.push(entry);
430                module_specifiers.push(json!({"from": file, "to": target, "specifier": specifier}));
431            }
432        }
433        for (from, target, specifier) in input.import_candidates {
434            let from = canonical_uri(&from)?;
435            let target = canonical_uri(&target)?;
436            let from = files
437                .get(&from)
438                .ok_or("Unknown import candidate referrer")?;
439            let target = files
440                .get(&target)
441                .ok_or("Unknown import candidate target")?;
442            module_specifiers.push(json!({"from": from, "to": target, "specifier": specifier}));
443        }
444        let backend = Backend::start(&binary, Arc::new(host)).await?;
445        let mut result = Self {
446            backend,
447            modules,
448            bridge,
449            module_specifiers,
450            snapshot: 0,
451            projects: BTreeMap::new(),
452            diagnostics,
453            resolver: 0,
454        };
455        let init = result
456            .initialize(
457                input.compiler_options,
458                input.per_file_options,
459                globals,
460                resolutions,
461            )
462            .await;
463        if let Err(error) = init {
464            let _ = result.close().await;
465            return Err(error);
466        }
467        Ok(result)
468    }
469    async fn initialize(
470        &mut self,
471        options: Value,
472        per_file: BTreeMap<String, Value>,
473        global_uris: BTreeSet<String>,
474        resolutions: Vec<Value>,
475    ) -> Result<()> {
476        self.backend.call::<Value>("initialize", json!({})).await?;
477        let mut groups: BTreeMap<String, (Value, Vec<String>)> = BTreeMap::new();
478        for module in self.modules.values() {
479            let mut settings = options.clone();
480            if let Some(scoped) = per_file.get(&module.source.uri) {
481                settings
482                    .as_object_mut()
483                    .ok_or("Compiler options must be an object")?
484                    .extend(
485                        scoped
486                            .as_object()
487                            .ok_or("Compiler options must be an object")?
488                            .clone(),
489                    );
490            }
491            groups
492                .entry(settings.to_string())
493                .or_insert_with(|| (settings, Vec::new()))
494                .1
495                .push(module.file.clone());
496        }
497        let builtins: Vec<_> = self
498            .modules
499            .values()
500            .filter(|m| m.source.uri.starts_with("avril:"))
501            .map(|m| m.file.clone())
502            .collect();
503        let globals: Vec<_> = self
504            .modules
505            .values()
506            .filter(|module| global_uris.contains(&module.source.uri))
507            .map(|module| module.file.clone())
508            .collect();
509        let mut programs = Vec::new();
510        let mut program_files = Vec::new();
511        for (settings, mut roots) in groups.into_values() {
512            let parsed: Value = self
513                .backend
514                .call(
515                    "parseJsonConfigFileContent",
516                    json!({
517                        "configDirectory": "/avril-lsp",
518                        "json": {"compilerOptions": settings, "files": roots}
519                    }),
520                )
521                .await?;
522            if parsed["errors"].as_array().is_none_or(|a| !a.is_empty()) {
523                return Err(format!("Invalid compiler options: {}", parsed["errors"]).into());
524            }
525            // Custom resolution is authoritative, independently of compiler options.
526            if self.resolver == 0 {
527                self.resolver = self
528                    .backend
529                    .call(
530                        "createModuleResolver",
531                        json!({
532                            "compilerOptions": parsed["options"],
533                            "moduleResolutions": {"fallback": "unresolved", "entries": resolutions}
534                        }),
535                    )
536                    .await?;
537            }
538            program_files.push(roots.clone());
539            roots.extend(
540                builtins
541                    .iter()
542                    .chain(&globals)
543                    .filter(|file| !roots.contains(file))
544                    .cloned()
545                    .collect::<Vec<_>>(),
546            );
547            programs.push(json!({"rootFiles": roots, "compilerOptions": parsed["options"], "options": {"moduleResolver": self.resolver}}));
548        }
549        let contents: BTreeMap<_, _> = self
550            .modules
551            .values()
552            .map(|m| (&m.file, &m.source.text))
553            .collect();
554        let snapshot: Snapshot = self
555            .backend
556            .call(
557                "createSnapshot",
558                json!({
559                    "fileSystem": {"kind": "layer", "files": contents},
560                    "createPrograms": programs,
561                    "userPreferences": {
562                        "includeCompletionsForModuleExports": true,
563                        "includeCompletionsForImportStatements": true,
564                        "includeAutomaticOptionalChainCompletions": true,
565                        "includeCompletionsWithInsertText": true,
566                        "includeCompletionsWithSnippetText": true,
567                        "includeCompletionsWithClassMemberSnippets": true,
568                        "includeCompletionsWithObjectLiteralMethodSnippets": true,
569                        "jsxAttributeCompletionStyle": "auto"
570                    }
571                }),
572            )
573            .await?;
574        self.snapshot = snapshot.snapshot;
575        if snapshot.operation.created_programs.len() != program_files.len() {
576            return Err("Backend did not create all projects".into());
577        }
578        for (files, project) in program_files
579            .into_iter()
580            .zip(snapshot.operation.created_programs)
581        {
582            for file in files {
583                self.projects.insert(file, project.clone());
584            }
585        }
586        Ok(())
587    }
588    fn params(&self, uri: &str, mut fields: Value) -> Value {
589        fields["snapshot"] = json!(self.snapshot);
590        fields["project"] = json!(self.projects[&self.modules[uri].file]);
591        fields
592    }
593    pub fn contains(&self, uri: &str) -> bool {
594        self.bridge.contains(uri)
595    }
596    pub fn is_read_only(&self, uri: &str) -> bool {
597        self.modules.iter().any(|(editor, module)| {
598            (editor == uri || module.source.uri == uri) && module.source.read_only
599        }) || uri.starts_with("avril-builtin:/typescript/")
600    }
601    pub fn read_document(&self, uri: &str) -> Result<&str> {
602        let m = self
603            .modules
604            .get(uri)
605            .filter(|m| m.source.read_only)
606            .ok_or("Unknown read-only document")?;
607        Ok(&m.source.text)
608    }
609    pub fn error_uris(&self) -> impl Iterator<Item = &String> {
610        self.diagnostics.keys()
611    }
612    async fn diagnostic_list(&self, uri: &str) -> Result<Vec<Diagnostic>> {
613        let original = self
614            .modules
615            .get(uri)
616            .map_or(uri, |module| module.source.uri.as_str());
617        let mut diagnostics: Vec<_> = self
618            .diagnostics
619            .get(uri)
620            .into_iter()
621            .flatten()
622            .map(|message| Diagnostic {
623                uri: original.to_owned(),
624                line: 0,
625                character: 0,
626                end_line: 0,
627                end_character: 0,
628                severity: 1,
629                code: None,
630                source: "avril-resolver".into(),
631                message: message.clone(),
632            })
633            .collect();
634        let Some(module) = self.modules.get(uri) else {
635            return Ok(diagnostics);
636        };
637        for method in ["getSyntacticDiagnostics", "getSemanticDiagnostics"] {
638            let values: Vec<Value> = self
639                .backend
640                .call(method, self.params(uri, json!({"files":[module.file]})))
641                .await?;
642            for mut d in values {
643                self.bridge.display(&mut d);
644                if !d["startPosition"].is_null() && !d["endPosition"].is_null() {
645                    diagnostics.push(Diagnostic {
646                        uri: original.to_owned(),
647                        line: d["startPosition"]["line"].as_u64().unwrap_or_default() as usize,
648                        character: d["startPosition"]["character"].as_u64().unwrap_or_default()
649                            as usize,
650                        end_line: d["endPosition"]["line"].as_u64().unwrap_or_default() as usize,
651                        end_character: d["endPosition"]["character"].as_u64().unwrap_or_default()
652                            as usize,
653                        severity: if d["category"] == 1 { 1 } else { 2 },
654                        code: d["code"].as_u64(),
655                        source: "avril".into(),
656                        message: {
657                            let mut message = d["text"].as_str().unwrap_or_default().to_owned();
658                            let mut pending = vec![(&d, 0)];
659                            while let Some((diagnostic, depth)) = pending.pop() {
660                                if depth > 0 {
661                                    message.push_str(&format!(
662                                        "\n{}{}",
663                                        "  ".repeat(depth),
664                                        diagnostic["text"].as_str().unwrap_or_default()
665                                    ));
666                                }
667                                if let Some(chain) = diagnostic["messageChain"].as_array() {
668                                    pending
669                                        .extend(chain.iter().rev().map(|item| (item, depth + 1)));
670                                }
671                            }
672                            message
673                        },
674                    });
675                }
676            }
677        }
678        Ok(diagnostics)
679    }
680    pub async fn diagnostics(
681        &mut self,
682        uri: &str,
683        client: &Value,
684        preferences: &Value,
685    ) -> Result<Value> {
686        let mut values = if self.modules.contains_key(uri) {
687            let mut report = self
688                .language_service(
689                    "textDocument/diagnostic",
690                    json!({"textDocument":{"uri":uri}}),
691                    client,
692                    preferences,
693                )
694                .await?;
695            report["items"]
696                .take()
697                .as_array()
698                .cloned()
699                .ok_or("Invalid native diagnostics")?
700        } else {
701            Vec::new()
702        };
703        for message in self.diagnostics.get(uri).into_iter().flatten() {
704            values.push(json!({
705                "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}},
706                "severity":1,"source":"avril-resolver","message":message,
707            }));
708        }
709        Ok(json!(values))
710    }
711    pub async fn all_diagnostics(&self) -> Result<Vec<Diagnostic>> {
712        // Resolver diagnostics can belong to modules whose sources were
713        // unavailable, so they have no entry in the compiler program.
714        let uris: BTreeSet<_> = self.modules.keys().chain(self.diagnostics.keys()).collect();
715        let mut diagnostics = Vec::new();
716        for uri in uris {
717            diagnostics.extend(self.diagnostic_list(uri).await?);
718        }
719        Ok(diagnostics)
720    }
721    pub async fn capabilities(&self, client: &Value) -> Result<Capabilities> {
722        let result: Capabilities = self.backend.call("avril/lspCapabilities", json!({"capabilities":client})).await
723            .map_err(|error| format!("The language server requires Avril's patched TypeScript backend; see lib/lsp/typescript/README.md: {error}"))?;
724        if result.version != 1 {
725            return Err(format!(
726                "Unsupported Avril TypeScript bridge version: {}",
727                result.version
728            )
729            .into());
730        }
731        Ok(result)
732    }
733
734    pub async fn language_service(
735        &mut self,
736        method: &str,
737        mut params: Value,
738        client: &Value,
739        preferences: &Value,
740    ) -> Result<Value> {
741        // VS Code sends null for absent signature-context fields; the pinned
742        // native decoder requires optional fields to be omitted instead.
743        if method == "textDocument/signatureHelp"
744            && let Some(context) = params.get_mut("context").and_then(Value::as_object_mut)
745        {
746            context.retain(|key, value| {
747                !matches!(key.as_str(), "triggerCharacter" | "activeSignatureHelp")
748                    || !value.is_null()
749            });
750        }
751        let context = params
752            .pointer("/textDocument/uri")
753            .and_then(Value::as_str)
754            .map(canonical_uri)
755            .transpose()?;
756        if matches!(
757            method,
758            "textDocument/completion" | "textDocument/codeAction"
759        ) && context.as_ref().is_some_and(|uri| self.is_read_only(uri))
760        {
761            return Ok(if method == "textDocument/codeAction" {
762                json!([])
763            } else {
764                Value::Null
765            });
766        }
767        if matches!(
768            method,
769            "textDocument/formatting"
770                | "textDocument/rangeFormatting"
771                | "textDocument/onTypeFormatting"
772                | "textDocument/rename"
773                | "textDocument/prepareRename"
774        ) {
775            self.bridge
776                .writable(context.as_deref().ok_or("Missing document URI")?)?;
777        }
778        let project = if let Some(project) = self.bridge.restore(method, &mut params)? {
779            project
780        } else {
781            self.bridge.input(&mut params)?;
782            context
783                .as_ref()
784                .and_then(|uri| self.modules.get(uri))
785                .and_then(|module| self.projects.get(&module.file))
786                .or_else(|| self.projects.values().next())
787                .cloned()
788                .unwrap_or_default()
789        };
790        let projects: BTreeSet<_> = self.projects.values().collect();
791        if method == "avril/readDocument" {
792            return self
793                .backend
794                .call(
795                    method,
796                    json!({
797                        "snapshot":self.snapshot, "project":project, "projects":projects,
798                        "textDocument": params["textDocument"],
799                    }),
800                )
801                .await;
802        }
803        let mut result = self
804            .backend
805            .call(
806                "avril/lsp",
807                json!({
808                    "snapshot":self.snapshot, "project":project, "projects":projects,
809                    "method":method, "params":params, "capabilities":client,
810                    "moduleSpecifiers":self.module_specifiers, "preferences":preferences,
811                }),
812            )
813            .await?;
814        self.bridge.output(method, &project, &mut result)?;
815        Ok(result)
816    }
817
818    pub fn failed(&self) -> bool {
819        self.backend.failed()
820    }
821
822    pub async fn terminate(&mut self) -> Result<()> {
823        self.backend.close().await
824    }
825    pub async fn close(&mut self) -> Result<()> {
826        let release = tokio::time::timeout(std::time::Duration::from_secs(1), async {
827            if self.snapshot != 0 {
828                self.backend
829                    .call::<Value>("release", json!({"snapshot":self.snapshot}))
830                    .await?;
831                self.snapshot = 0;
832            }
833            if self.resolver != 0 {
834                self.backend
835                    .call::<Value>("releaseModuleResolver", json!({"resolver":self.resolver}))
836                    .await?;
837                self.resolver = 0;
838            }
839            Ok::<_, crate::Error>(())
840        })
841        .await
842        .unwrap_or_else(|_| Err("Timed out releasing TypeScript handles".into()));
843        let close = self.backend.close().await;
844        release.and(close)
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    fn new_host() -> Host {
852        Host {
853            contents: BTreeMap::new(),
854            aliases: BTreeMap::new(),
855        }
856    }
857
858    #[test]
859    fn virtual_paths_reject_alias_and_directory_collisions() {
860        let mut host = new_host();
861        insert_path_alias(
862            &mut host,
863            "/virtual/shared.d.ts".into(),
864            "/target/a.d.ts".into(),
865        )
866        .unwrap();
867        assert!(
868            insert_path_alias(
869                &mut host,
870                "/virtual/shared.d.ts".into(),
871                "/target/b.d.ts".into()
872            )
873            .is_err()
874        );
875
876        let mut host = new_host();
877        host.contents
878            .insert("/virtual/source.d.ts".into(), "".into());
879        insert_path_alias(
880            &mut host,
881            "/virtual/source.d.ts".into(),
882            "/target.d.ts".into(),
883        )
884        .unwrap();
885        assert!(validate_virtual_paths(&mut host).is_err());
886
887        let mut host = new_host();
888        insert_path_alias(
889            &mut host,
890            "/virtual/node_modules/@types/x/index.d.ts/child.d.ts".into(),
891            "/target.d.ts".into(),
892        )
893        .unwrap();
894        insert_path_alias(
895            &mut host,
896            "/virtual/node_modules/@types/x/index.d.ts".into(),
897            "/other.d.ts".into(),
898        )
899        .unwrap();
900        assert!(validate_virtual_paths(&mut host).is_err());
901
902        let mut host = new_host();
903        host.contents
904            .insert("/virtual/source.d.ts".into(), "".into());
905        insert_path_alias(
906            &mut host,
907            "/virtual/source.d.ts".into(),
908            "/virtual/source.d.ts".into(),
909        )
910        .unwrap();
911        validate_virtual_paths(&mut host).unwrap();
912        assert!(host.aliases.is_empty());
913    }
914
915    #[test]
916    fn type_reference_names_are_safe_single_package_names() {
917        assert_eq!(type_package_name("node").unwrap(), "node");
918        assert_eq!(type_package_name("@scope/pkg").unwrap(), "scope__pkg");
919        assert_eq!(type_package_name("@types/foo").unwrap(), "types__foo");
920        for invalid in [
921            "",
922            "/absolute",
923            "../escape",
924            "foo/bar",
925            "@scope/../x",
926            "@scope/a/b",
927            "bad\\name",
928            "bad\nname",
929        ] {
930            assert!(type_package_name(invalid).is_err(), "{invalid:?}");
931        }
932    }
933
934    #[test]
935    fn virtual_reference_filesystem_does_not_read_host_paths() {
936        let host = new_host();
937        let exists = host.call("fileExists", json!("/etc/passwd")).unwrap();
938        assert_eq!(exists, json!({"kind":"value","value":false}));
939        let contents = host.call("readFile", json!("/etc/passwd")).unwrap();
940        assert_eq!(contents, json!({"kind":"missing"}));
941    }
942
943    #[test]
944    fn editor_uris_preserve_transport_and_builtin_identity() {
945        assert_eq!(
946            editor_uri("https://example.test/mod.ts", ".ts").unwrap(),
947            "avril-https://example.test/mod.ts"
948        );
949        assert_eq!(
950            editor_uri("http://example.test/mod.ts", ".ts").unwrap(),
951            "avril-http://example.test/mod.ts"
952        );
953        assert_eq!(
954            editor_uri("avril:/runtime/stdlib/std/sync.d.ts", ".d.ts").unwrap(),
955            "avril-builtin:/runtime/stdlib/std/sync.d.ts"
956        );
957        assert_eq!(
958            editor_uri("file:///app/main.ts", ".ts").unwrap(),
959            "file:///app/main.ts"
960        );
961        assert!(editor_uri("npm:unsupported", ".ts").is_err());
962    }
963    #[tokio::test]
964    async fn references_and_globals_resolve_through_canonical_virtual_files() {
965        let Ok(binary) = std::env::var("AVRIL_TEST_TYPESCRIPT") else {
966            return;
967        };
968        let uri = |path: &str| format!("file:///project/{path}");
969        let shared = uri("types/shared.d.cts");
970        let shared_esm = uri("types/shared.d.mts");
971        let path_types = uri("types/path.d.ts");
972        let first = uri("src/first.ts");
973        let second = uri("src/second.ts");
974        let unbound = uri("src/unbound.ts");
975        let global = uri("types/global.d.ts");
976        let input = ProjectInput {
977            script_kinds: BTreeMap::new(),
978            sources: vec![
979                Source {
980                    uri: shared.clone(),
981                    text: "import type {Mode} from 'mode'; declare global { const sharedOnce: unique symbol; const requireMode: Mode; }".into(),
982                    read_only: true,
983                },
984                Source {
985                    uri: shared_esm.clone(),
986                    text: "import type {Mode} from 'mode'; declare global { const importMode: Mode; }".into(),
987                    read_only: true,
988                },
989                Source {
990                    uri: uri("types/import.d.ts"),
991                    text: "export type Mode = 'import';".into(),
992                    read_only: true,
993                },
994                Source {
995                    uri: uri("types/require.d.ts"),
996                    text: "export type Mode = 'require';".into(),
997                    read_only: true,
998                },
999                Source {
1000                    uri: path_types.clone(),
1001                    text: "interface FromPath { value: string }".into(),
1002                    read_only: true,
1003                },
1004                Source {
1005                    uri: first.clone(),
1006                    text: "/// <reference types=\"shared\" />\n/// <reference types=\"shared-esm\" />\n/// <reference path=\"../types/path.d.ts\" />\nconst a: FromPath = { value: \"ok\" }; const s: typeof sharedOnce = sharedOnce; const c: 'require' = requireMode; const e: 'import' = importMode;".into(),
1007                    read_only: false,
1008                },
1009                Source {
1010                    uri: second.clone(),
1011                    text: "const b: typeof sharedOnce = sharedOnce; const g: string = globalValue;".into(),
1012                    read_only: false,
1013                },
1014                Source {
1015                    uri: unbound.clone(),
1016                    text: "/// <reference types=\"shared\" />\nimport type {Mode} from 'shared';".into(),
1017                    read_only: false,
1018                },
1019                Source {
1020                    uri: global.clone(),
1021                    text: "declare const globalValue: string;".into(),
1022                    read_only: true,
1023                },
1024            ],
1025            compiler_options: json!({"strict":true,"noEmit":true,"module":"preserve","moduleResolution":"bundler"}),
1026            imports: [shared.clone(), shared_esm.clone()].into_iter().map(|from| {
1027                ((from, "mode".into()), ResolvedImport {
1028                    import: Some(uri("types/import.d.ts")),
1029                    require: Some(uri("types/require.d.ts")),
1030                })
1031            }).collect(),
1032            import_candidates: Vec::new(),
1033            per_file_options: BTreeMap::from([(second.clone(), json!({"strictNullChecks":false}))]),
1034            diagnostics: BTreeMap::from([(second.clone(), vec!["Unresolved explicit reference".into()])]),
1035            type_references: BTreeMap::from([
1036                ((first.clone(), "shared".into()), shared.clone()),
1037                ((first.clone(), "shared-esm".into()), shared_esm),
1038                ((unbound.clone(), "shared".into()), shared),
1039            ]),
1040            path_references: BTreeMap::from([((first.clone(), "../types/path.d.ts".into()), path_types)]),
1041            globals: BTreeSet::from([global]),
1042        };
1043        let diagnostics = crate::check(Path::new(&binary), input).await.unwrap();
1044        assert!(
1045            diagnostics
1046                .iter()
1047                .any(|diagnostic| diagnostic.source == "avril-resolver")
1048        );
1049        assert!(
1050            diagnostics
1051                .iter()
1052                .any(|diagnostic| { diagnostic.uri == second && diagnostic.code == Some(2304) }),
1053            "reference-scoped declaration leaked: {diagnostics:#?}"
1054        );
1055        assert!(
1056            diagnostics
1057                .iter()
1058                .any(|diagnostic| diagnostic.uri == unbound && diagnostic.code == Some(2307)),
1059            "a type-reference binding must not authorize a module import: {diagnostics:#?}"
1060        );
1061        assert!(
1062            diagnostics
1063                .iter()
1064                .filter(|diagnostic| diagnostic.source == "avril"
1065                    && diagnostic.uri != second
1066                    && !(diagnostic.uri == unbound && diagnostic.code == Some(2307)))
1067                .all(|diagnostic| diagnostic.severity != 1),
1068            "{diagnostics:#?}"
1069        );
1070    }
1071
1072    #[test]
1073    fn remote_queries_survive_editor_uri_encoding_without_collapsing_delimiters() {
1074        let uri = editor_uri("https://example.test/mod.ts?target=deno&dev", ".ts").unwrap();
1075        assert_eq!(uri, "avril-https://example.test/mod.ts?target%3Ddeno%26dev");
1076        assert_eq!(canonical_uri(&uri).unwrap(), uri);
1077        assert_eq!(
1078            canonical_uri("avril-https://example.test/mod.ts?target=deno&dev").unwrap(),
1079            uri
1080        );
1081        assert_ne!(
1082            editor_uri("https://example.test/mod.ts?target%3Ddeno%26dev", ".ts").unwrap(),
1083            uri
1084        );
1085    }
1086
1087    #[test]
1088    fn uri_normalization_preserves_segments() {
1089        assert_eq!(
1090            canonical_uri("avril-builtin:///runtime/stdlib/std/sync.d.ts").unwrap(),
1091            canonical_uri("avril-builtin:/runtime/stdlib/std/sync.d.ts").unwrap()
1092        );
1093        assert_eq!(
1094            canonical_uri("avril-https://jsr.io/@example/mod.d.ts").unwrap(),
1095            canonical_uri("avril-https://jsr.io/%40example/mod.d.ts").unwrap()
1096        );
1097        assert_ne!(
1098            canonical_uri("file:///a%2fb.ts").unwrap(),
1099            canonical_uri("file:///a/b.ts").unwrap()
1100        );
1101    }
1102}