use crate::{ Result, backend::{Backend, Callbacks}, bridge::{Bridge, Capabilities}, }; use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; use serde::Deserialize; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, path::Path, sync::Arc, }; use url::Url; /// Canonical module URIs, not compiler paths. Require falls back to import when /// no separate require edge was recorded. #[derive(Default)] pub struct ResolvedImport { pub import: Option, pub require: Option, } pub struct Source { pub uri: String, pub text: String, pub read_only: bool, } #[derive(Clone, Copy)] pub enum ScriptKind { JavaScript, TypeScript, } pub struct ProjectInput { pub sources: Vec, /// MIME-selected languages for modules whose URLs are not filenames. pub script_kinds: BTreeMap, pub compiler_options: Value, pub imports: BTreeMap<(String, String), ResolvedImport>, /// Authorized additional import spellings (referrer URI, target URI, specifier). pub import_candidates: Vec<(String, String, String)>, pub per_file_options: BTreeMap, pub diagnostics: BTreeMap>, pub type_references: BTreeMap<(String, String), String>, pub path_references: BTreeMap<(String, String), String>, pub globals: BTreeSet, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Diagnostic { pub uri: String, pub line: usize, pub character: usize, pub end_line: usize, pub end_character: usize, pub severity: u8, pub code: Option, pub source: String, pub message: String, } fn type_package_name(name: &str) -> Result { let valid_part = |part: &str| { !part.is_empty() && !matches!(part, "." | "..") && part .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) }; if let Some(scope) = name.strip_prefix('@') { let Some((scope, package)) = scope.split_once('/') else { return Err("Invalid scoped type reference name".into()); }; if !valid_part(scope) || !valid_part(package) || package.contains('/') { return Err("Invalid scoped type reference name".into()); } Ok(format!("{scope}__{package}")) } else if valid_part(name) { Ok(name.to_owned()) } else { Err("Invalid type reference name".into()) } } fn insert_path_alias(host: &mut Host, path: String, target: String) -> Result<()> { if host .aliases .get(&path) .is_some_and(|previous| previous != &target) { return Err(format!("Conflicting virtual path reference alias: {path}").into()); } host.aliases.insert(path, target); Ok(()) } fn validate_virtual_paths(host: &mut Host) -> Result<()> { for (path, target) in &host.aliases { if host.contents.contains_key(path) && path != target { return Err(format!("Path reference shadows a canonical source: {path}").into()); } } let virtual_files: BTreeSet<_> = host .contents .keys() .chain(host.aliases.keys()) .map(String::as_str) .collect(); for file in &virtual_files { for ancestor in Path::new(file).ancestors().skip(1) { if virtual_files.contains(ancestor.to_str().unwrap()) { return Err(format!( "Virtual file collides with a directory: {}", ancestor.display() ) .into()); } } } host.aliases.retain(|path, target| path != target); Ok(()) } fn lexical_path(path: &Path) -> String { use std::path::Component; let mut parts = Vec::new(); for component in path.components() { match component { Component::RootDir => parts.clear(), Component::CurDir => {} Component::ParentDir => { parts.pop(); } Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()), Component::Prefix(_) => {} } } format!("/{}", parts.join("/")) } const COMPONENT: &percent_encoding::AsciiSet = &NON_ALPHANUMERIC .remove(b'-') .remove(b'_') .remove(b'.') .remove(b'~') .remove(b'!') .remove(b'\'') .remove(b'(') .remove(b')') .remove(b'*'); fn editor_uri(uri: &str, extension: &str) -> Result { let uri = canonical_uri(uri)?; let mut url = Url::parse(&uri)?; match url.scheme() { "https" | "http" => { let scheme = url.scheme().to_owned(); // Custom-scheme query components must survive VS Code's URI codec // without collapsing an escaped delimiter into a real delimiter. if let Some(query) = url.query() { let query = utf8_percent_encode(query, COMPONENT).to_string(); url.set_query(Some(&query)); } canonical_uri(&url.to_string().replacen( &format!("{scheme}:"), &format!("avril-{scheme}:"), 1, )) } "avril" => Ok(uri.replacen("avril:", "avril-builtin:", 1)), "data" => Ok(format!( "avril-builtin:/data/{:x}{extension}", Sha256::digest(uri.as_bytes()) )), "file" => Ok(uri), _ => Err("Unsupported module URI scheme".into()), } } pub fn canonical_uri(uri: &str) -> Result { let mut url = Url::parse(uri)?; // Percent escapes in an opaque payload are source bytes, not path aliases. if url.scheme() == "data" { return Ok(url.to_string()); } let parts: Result> = url .path() .split('/') .map(|s| { Ok(utf8_percent_encode(&percent_decode_str(s).decode_utf8()?, COMPONENT).to_string()) }) .collect(); url.set_path(&parts?.join("/")); if matches!(url.scheme(), "avril-http" | "avril-https") && let Some(query) = url.query() { let query = utf8_percent_encode(&percent_decode_str(query).decode_utf8()?, COMPONENT).to_string(); url.set_query(Some(&query)); } // VS Code removes empty authorities from custom-scheme URIs. if matches!( url.scheme(), "avril" | "avril-https" | "avril-http" | "avril-builtin" ) && url.host().is_none() { Ok(url.to_string().replacen(":///", ":/", 1)) } else { Ok(url.to_string()) } } struct Module { source: Source, file: String, } struct Host { contents: BTreeMap, aliases: BTreeMap, } impl Callbacks for Host { fn call(&self, method: &str, params: Value) -> Result { if !matches!( method, "readFile" | "fileExists" | "directoryExists" | "getAccessibleEntries" | "realpath" ) { return Err(format!("Unexpected backend callback: {method}").into()); } let path = params.as_str().ok_or("Invalid filesystem callback")?; if method == "realpath" { return Ok( json!({"kind":"value","value":self.aliases.get(path).map_or(path, String::as_str)}), ); } if let Some(target) = self.aliases.get(path) { return Ok(match method { "readFile" => json!({"kind":"value","value": self.contents.get(target)}), "fileExists" => json!({"kind":"value","value":true}), _ => json!({"kind":"value","value":false}), }); } let prefix = format!("{path}/"); let mut files = BTreeSet::new(); let mut directories = BTreeSet::new(); for entry in self .aliases .range(prefix.clone()..) .map_while(|(alias, _)| alias.strip_prefix(&prefix)) { if let Some((directory, _)) = entry.split_once('/') { directories.insert(directory); } else { files.insert(entry); } } if !files.is_empty() || !directories.is_empty() { return Ok(match method { "directoryExists" => json!({"kind":"value","value":true}), "getAccessibleEntries" => { json!({"kind":"value","value":{"files":files,"directories":directories}}) } _ => json!({"kind":"value","value":false}), }); } if path == "bundled:///libs" || path.starts_with("bundled:///libs/") { return Ok(json!({"kind":"useOS"})); } Ok(match method { "readFile" => json!({"kind":"missing"}), "getAccessibleEntries" => { json!({"kind":"value","value":{"files":[],"directories":[]}}) } _ => json!({"kind":"value","value":false}), }) } } #[derive(Deserialize)] struct Snapshot { snapshot: u64, #[serde(default)] operation: Operation, } #[derive(Default, Deserialize)] struct Operation { #[serde(default, rename = "createdPrograms")] created_programs: Vec, } pub(crate) struct Project { backend: Backend, modules: BTreeMap, bridge: Bridge, module_specifiers: Vec, snapshot: u64, projects: BTreeMap, diagnostics: BTreeMap>, resolver: u64, } impl Project { pub async fn create(binary: &Path, input: ProjectInput) -> Result { let binary = std::fs::canonicalize(binary)?; let globals = input .globals .into_iter() .map(|uri| canonical_uri(&uri)) .collect::>>()?; let script_kinds = input .script_kinds .into_iter() .map(|(uri, kind)| Ok((canonical_uri(&uri)?, kind))) .collect::>>()?; let mut modules = BTreeMap::new(); let mut bridge = Bridge::new(); let mut files = BTreeMap::new(); let mut host = Host { contents: BTreeMap::new(), aliases: BTreeMap::new(), }; for mut source in input.sources { source.uri = canonical_uri(&source.uri)?; let uri = Url::parse(&source.uri)?; let extension = match script_kinds.get(&source.uri) { Some(ScriptKind::JavaScript) => ".js", Some(ScriptKind::TypeScript) => ".ts", None => [ ".d.mts", ".d.cts", ".d.ts", ".tsx", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".ts", ".js", ] .into_iter() .find(|ext| uri.path().ends_with(ext)) .unwrap_or(".ts"), }; let editor_uri = if source.read_only && uri.scheme() == "file" { canonical_uri(&format!("avril-builtin:/dependencies{}", uri.path()))? } else { editor_uri(&source.uri, extension)? }; let directory = format!("/avril-lsp/live/{}", modules.len()); let file = format!("{directory}/source{extension}"); if files.insert(source.uri.clone(), file.clone()).is_some() { return Err("Duplicate module identity".into()); } host.contents.insert(file.clone(), source.text.clone()); bridge.insert(editor_uri.clone(), &file, source.read_only)?; if modules .insert(editor_uri.clone(), Module { source, file }) .is_some() { return Err("Duplicate editor URI".into()); } } for (kind, references) in [ ("type", input.type_references), ("path", input.path_references), ] { for ((referrer, reference), target) in references { let referrer = canonical_uri(&referrer)?; let target = canonical_uri(&target)?; let containing_file = files .get(&referrer) .ok_or_else(|| format!("Unknown {kind}-reference referrer: {referrer}"))?; let target_file = files .get(&target) .ok_or_else(|| format!("Unknown {kind}-reference target: {target}"))? .clone(); let directory = Path::new(containing_file) .parent() .ok_or("Invalid virtual source path")?; let alias = if kind == "type" { directory .join("node_modules/@types") .join(type_package_name(&reference)?) .join("index.d.ts") } else { directory.join(reference) }; insert_path_alias(&mut host, lexical_path(&alias), target_file)?; } } validate_virtual_paths(&mut host)?; let diagnostics = input .diagnostics .into_iter() .map(|(uri, messages)| { let canonical = canonical_uri(&uri)?; let editor = modules .iter() .find(|(_, module)| module.source.uri == canonical) .map(|(uri, _)| uri.clone()) .map_or_else( || { if canonical.starts_with("data:") { Ok(canonical) } else { editor_uri(&uri, ".ts") } }, Ok, )?; Ok((editor, messages)) }) .collect::>()?; let mut resolutions = Vec::new(); let mut module_specifiers = Vec::new(); // Keep existing import spellings ahead of require-only spellings for auto-imports. for mode in [None, Some(1)] { for ((from, specifier), targets) in &input.imports { let target = if mode.is_some() { &targets.require } else { &targets.import }; let Some(target) = target else { continue }; let from = canonical_uri(from)?; let file = files.get(&from).ok_or("Unknown import referrer")?; let directory = Path::new(file).parent().ok_or("Invalid compiler path")?; let target = canonical_uri(target)?; let target = files.get(&target).ok_or("Unknown import target")?; let mut entry = json!({ "moduleName": specifier, "containingDirectory": directory, "result": {"resolvedFileName": target}, }); // TypeScript's CommonJS mode overrides the default import edge. if let Some(mode) = mode { entry["resolutionMode"] = json!(mode); } resolutions.push(entry); module_specifiers.push(json!({"from": file, "to": target, "specifier": specifier})); } } for (from, target, specifier) in input.import_candidates { let from = canonical_uri(&from)?; let target = canonical_uri(&target)?; let from = files .get(&from) .ok_or("Unknown import candidate referrer")?; let target = files .get(&target) .ok_or("Unknown import candidate target")?; module_specifiers.push(json!({"from": from, "to": target, "specifier": specifier})); } let backend = Backend::start(&binary, Arc::new(host)).await?; let mut result = Self { backend, modules, bridge, module_specifiers, snapshot: 0, projects: BTreeMap::new(), diagnostics, resolver: 0, }; let init = result .initialize( input.compiler_options, input.per_file_options, globals, resolutions, ) .await; if let Err(error) = init { let _ = result.close().await; return Err(error); } Ok(result) } async fn initialize( &mut self, options: Value, per_file: BTreeMap, global_uris: BTreeSet, resolutions: Vec, ) -> Result<()> { self.backend.call::("initialize", json!({})).await?; let mut groups: BTreeMap)> = BTreeMap::new(); for module in self.modules.values() { let mut settings = options.clone(); if let Some(scoped) = per_file.get(&module.source.uri) { settings .as_object_mut() .ok_or("Compiler options must be an object")? .extend( scoped .as_object() .ok_or("Compiler options must be an object")? .clone(), ); } groups .entry(settings.to_string()) .or_insert_with(|| (settings, Vec::new())) .1 .push(module.file.clone()); } let builtins: Vec<_> = self .modules .values() .filter(|m| m.source.uri.starts_with("avril:")) .map(|m| m.file.clone()) .collect(); let globals: Vec<_> = self .modules .values() .filter(|module| global_uris.contains(&module.source.uri)) .map(|module| module.file.clone()) .collect(); let mut programs = Vec::new(); let mut program_files = Vec::new(); for (settings, mut roots) in groups.into_values() { let parsed: Value = self .backend .call( "parseJsonConfigFileContent", json!({ "configDirectory": "/avril-lsp", "json": {"compilerOptions": settings, "files": roots} }), ) .await?; if parsed["errors"].as_array().is_none_or(|a| !a.is_empty()) { return Err(format!("Invalid compiler options: {}", parsed["errors"]).into()); } // Custom resolution is authoritative, independently of compiler options. if self.resolver == 0 { self.resolver = self .backend .call( "createModuleResolver", json!({ "compilerOptions": parsed["options"], "moduleResolutions": {"fallback": "unresolved", "entries": resolutions} }), ) .await?; } program_files.push(roots.clone()); roots.extend( builtins .iter() .chain(&globals) .filter(|file| !roots.contains(file)) .cloned() .collect::>(), ); programs.push(json!({"rootFiles": roots, "compilerOptions": parsed["options"], "options": {"moduleResolver": self.resolver}})); } let contents: BTreeMap<_, _> = self .modules .values() .map(|m| (&m.file, &m.source.text)) .collect(); let snapshot: Snapshot = self .backend .call( "createSnapshot", json!({ "fileSystem": {"kind": "layer", "files": contents}, "createPrograms": programs, "userPreferences": { "includeCompletionsForModuleExports": true, "includeCompletionsForImportStatements": true, "includeAutomaticOptionalChainCompletions": true, "includeCompletionsWithInsertText": true, "includeCompletionsWithSnippetText": true, "includeCompletionsWithClassMemberSnippets": true, "includeCompletionsWithObjectLiteralMethodSnippets": true, "jsxAttributeCompletionStyle": "auto" } }), ) .await?; self.snapshot = snapshot.snapshot; if snapshot.operation.created_programs.len() != program_files.len() { return Err("Backend did not create all projects".into()); } for (files, project) in program_files .into_iter() .zip(snapshot.operation.created_programs) { for file in files { self.projects.insert(file, project.clone()); } } Ok(()) } fn params(&self, uri: &str, mut fields: Value) -> Value { fields["snapshot"] = json!(self.snapshot); fields["project"] = json!(self.projects[&self.modules[uri].file]); fields } pub fn contains(&self, uri: &str) -> bool { self.bridge.contains(uri) } pub fn is_read_only(&self, uri: &str) -> bool { self.modules.iter().any(|(editor, module)| { (editor == uri || module.source.uri == uri) && module.source.read_only }) || uri.starts_with("avril-builtin:/typescript/") } pub fn read_document(&self, uri: &str) -> Result<&str> { let m = self .modules .get(uri) .filter(|m| m.source.read_only) .ok_or("Unknown read-only document")?; Ok(&m.source.text) } pub fn error_uris(&self) -> impl Iterator { self.diagnostics.keys() } async fn diagnostic_list(&self, uri: &str) -> Result> { let original = self .modules .get(uri) .map_or(uri, |module| module.source.uri.as_str()); let mut diagnostics: Vec<_> = self .diagnostics .get(uri) .into_iter() .flatten() .map(|message| Diagnostic { uri: original.to_owned(), line: 0, character: 0, end_line: 0, end_character: 0, severity: 1, code: None, source: "avril-resolver".into(), message: message.clone(), }) .collect(); let Some(module) = self.modules.get(uri) else { return Ok(diagnostics); }; for method in ["getSyntacticDiagnostics", "getSemanticDiagnostics"] { let values: Vec = self .backend .call(method, self.params(uri, json!({"files":[module.file]}))) .await?; for mut d in values { self.bridge.display(&mut d); if !d["startPosition"].is_null() && !d["endPosition"].is_null() { diagnostics.push(Diagnostic { uri: original.to_owned(), line: d["startPosition"]["line"].as_u64().unwrap_or_default() as usize, character: d["startPosition"]["character"].as_u64().unwrap_or_default() as usize, end_line: d["endPosition"]["line"].as_u64().unwrap_or_default() as usize, end_character: d["endPosition"]["character"].as_u64().unwrap_or_default() as usize, severity: if d["category"] == 1 { 1 } else { 2 }, code: d["code"].as_u64(), source: "avril".into(), message: { let mut message = d["text"].as_str().unwrap_or_default().to_owned(); let mut pending = vec![(&d, 0)]; while let Some((diagnostic, depth)) = pending.pop() { if depth > 0 { message.push_str(&format!( "\n{}{}", " ".repeat(depth), diagnostic["text"].as_str().unwrap_or_default() )); } if let Some(chain) = diagnostic["messageChain"].as_array() { pending .extend(chain.iter().rev().map(|item| (item, depth + 1))); } } message }, }); } } } Ok(diagnostics) } pub async fn diagnostics( &mut self, uri: &str, client: &Value, preferences: &Value, ) -> Result { let mut values = if self.modules.contains_key(uri) { let mut report = self .language_service( "textDocument/diagnostic", json!({"textDocument":{"uri":uri}}), client, preferences, ) .await?; report["items"] .take() .as_array() .cloned() .ok_or("Invalid native diagnostics")? } else { Vec::new() }; for message in self.diagnostics.get(uri).into_iter().flatten() { values.push(json!({ "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}, "severity":1,"source":"avril-resolver","message":message, })); } Ok(json!(values)) } pub async fn all_diagnostics(&self) -> Result> { // Resolver diagnostics can belong to modules whose sources were // unavailable, so they have no entry in the compiler program. let uris: BTreeSet<_> = self.modules.keys().chain(self.diagnostics.keys()).collect(); let mut diagnostics = Vec::new(); for uri in uris { diagnostics.extend(self.diagnostic_list(uri).await?); } Ok(diagnostics) } pub async fn capabilities(&self, client: &Value) -> Result { let result: Capabilities = self.backend.call("avril/lspCapabilities", json!({"capabilities":client})).await .map_err(|error| format!("The language server requires Avril's patched TypeScript backend; see lib/lsp/typescript/README.md: {error}"))?; if result.version != 1 { return Err(format!( "Unsupported Avril TypeScript bridge version: {}", result.version ) .into()); } Ok(result) } pub async fn language_service( &mut self, method: &str, mut params: Value, client: &Value, preferences: &Value, ) -> Result { // VS Code sends null for absent signature-context fields; the pinned // native decoder requires optional fields to be omitted instead. if method == "textDocument/signatureHelp" && let Some(context) = params.get_mut("context").and_then(Value::as_object_mut) { context.retain(|key, value| { !matches!(key.as_str(), "triggerCharacter" | "activeSignatureHelp") || !value.is_null() }); } let context = params .pointer("/textDocument/uri") .and_then(Value::as_str) .map(canonical_uri) .transpose()?; if matches!( method, "textDocument/completion" | "textDocument/codeAction" ) && context.as_ref().is_some_and(|uri| self.is_read_only(uri)) { return Ok(if method == "textDocument/codeAction" { json!([]) } else { Value::Null }); } if matches!( method, "textDocument/formatting" | "textDocument/rangeFormatting" | "textDocument/onTypeFormatting" | "textDocument/rename" | "textDocument/prepareRename" ) { self.bridge .writable(context.as_deref().ok_or("Missing document URI")?)?; } let project = if let Some(project) = self.bridge.restore(method, &mut params)? { project } else { self.bridge.input(&mut params)?; context .as_ref() .and_then(|uri| self.modules.get(uri)) .and_then(|module| self.projects.get(&module.file)) .or_else(|| self.projects.values().next()) .cloned() .unwrap_or_default() }; let projects: BTreeSet<_> = self.projects.values().collect(); if method == "avril/readDocument" { return self .backend .call( method, json!({ "snapshot":self.snapshot, "project":project, "projects":projects, "textDocument": params["textDocument"], }), ) .await; } let mut result = self .backend .call( "avril/lsp", json!({ "snapshot":self.snapshot, "project":project, "projects":projects, "method":method, "params":params, "capabilities":client, "moduleSpecifiers":self.module_specifiers, "preferences":preferences, }), ) .await?; self.bridge.output(method, &project, &mut result)?; Ok(result) } pub fn failed(&self) -> bool { self.backend.failed() } pub async fn terminate(&mut self) -> Result<()> { self.backend.close().await } pub async fn close(&mut self) -> Result<()> { let release = tokio::time::timeout(std::time::Duration::from_secs(1), async { if self.snapshot != 0 { self.backend .call::("release", json!({"snapshot":self.snapshot})) .await?; self.snapshot = 0; } if self.resolver != 0 { self.backend .call::("releaseModuleResolver", json!({"resolver":self.resolver})) .await?; self.resolver = 0; } Ok::<_, crate::Error>(()) }) .await .unwrap_or_else(|_| Err("Timed out releasing TypeScript handles".into())); let close = self.backend.close().await; release.and(close) } } #[cfg(test)] mod tests { use super::*; fn new_host() -> Host { Host { contents: BTreeMap::new(), aliases: BTreeMap::new(), } } #[test] fn virtual_paths_reject_alias_and_directory_collisions() { let mut host = new_host(); insert_path_alias( &mut host, "/virtual/shared.d.ts".into(), "/target/a.d.ts".into(), ) .unwrap(); assert!( insert_path_alias( &mut host, "/virtual/shared.d.ts".into(), "/target/b.d.ts".into() ) .is_err() ); let mut host = new_host(); host.contents .insert("/virtual/source.d.ts".into(), "".into()); insert_path_alias( &mut host, "/virtual/source.d.ts".into(), "/target.d.ts".into(), ) .unwrap(); assert!(validate_virtual_paths(&mut host).is_err()); let mut host = new_host(); insert_path_alias( &mut host, "/virtual/node_modules/@types/x/index.d.ts/child.d.ts".into(), "/target.d.ts".into(), ) .unwrap(); insert_path_alias( &mut host, "/virtual/node_modules/@types/x/index.d.ts".into(), "/other.d.ts".into(), ) .unwrap(); assert!(validate_virtual_paths(&mut host).is_err()); let mut host = new_host(); host.contents .insert("/virtual/source.d.ts".into(), "".into()); insert_path_alias( &mut host, "/virtual/source.d.ts".into(), "/virtual/source.d.ts".into(), ) .unwrap(); validate_virtual_paths(&mut host).unwrap(); assert!(host.aliases.is_empty()); } #[test] fn type_reference_names_are_safe_single_package_names() { assert_eq!(type_package_name("node").unwrap(), "node"); assert_eq!(type_package_name("@scope/pkg").unwrap(), "scope__pkg"); assert_eq!(type_package_name("@types/foo").unwrap(), "types__foo"); for invalid in [ "", "/absolute", "../escape", "foo/bar", "@scope/../x", "@scope/a/b", "bad\\name", "bad\nname", ] { assert!(type_package_name(invalid).is_err(), "{invalid:?}"); } } #[test] fn virtual_reference_filesystem_does_not_read_host_paths() { let host = new_host(); let exists = host.call("fileExists", json!("/etc/passwd")).unwrap(); assert_eq!(exists, json!({"kind":"value","value":false})); let contents = host.call("readFile", json!("/etc/passwd")).unwrap(); assert_eq!(contents, json!({"kind":"missing"})); } #[test] fn editor_uris_preserve_transport_and_builtin_identity() { assert_eq!( editor_uri("https://example.test/mod.ts", ".ts").unwrap(), "avril-https://example.test/mod.ts" ); assert_eq!( editor_uri("http://example.test/mod.ts", ".ts").unwrap(), "avril-http://example.test/mod.ts" ); assert_eq!( editor_uri("avril:/runtime/stdlib/std/sync.d.ts", ".d.ts").unwrap(), "avril-builtin:/runtime/stdlib/std/sync.d.ts" ); assert_eq!( editor_uri("file:///app/main.ts", ".ts").unwrap(), "file:///app/main.ts" ); assert!(editor_uri("npm:unsupported", ".ts").is_err()); } #[tokio::test] async fn references_and_globals_resolve_through_canonical_virtual_files() { let Ok(binary) = std::env::var("AVRIL_TEST_TYPESCRIPT") else { return; }; let uri = |path: &str| format!("file:///project/{path}"); let shared = uri("types/shared.d.cts"); let shared_esm = uri("types/shared.d.mts"); let path_types = uri("types/path.d.ts"); let first = uri("src/first.ts"); let second = uri("src/second.ts"); let unbound = uri("src/unbound.ts"); let global = uri("types/global.d.ts"); let input = ProjectInput { script_kinds: BTreeMap::new(), sources: vec![ Source { uri: shared.clone(), text: "import type {Mode} from 'mode'; declare global { const sharedOnce: unique symbol; const requireMode: Mode; }".into(), read_only: true, }, Source { uri: shared_esm.clone(), text: "import type {Mode} from 'mode'; declare global { const importMode: Mode; }".into(), read_only: true, }, Source { uri: uri("types/import.d.ts"), text: "export type Mode = 'import';".into(), read_only: true, }, Source { uri: uri("types/require.d.ts"), text: "export type Mode = 'require';".into(), read_only: true, }, Source { uri: path_types.clone(), text: "interface FromPath { value: string }".into(), read_only: true, }, Source { uri: first.clone(), text: "/// \n/// \n/// \nconst a: FromPath = { value: \"ok\" }; const s: typeof sharedOnce = sharedOnce; const c: 'require' = requireMode; const e: 'import' = importMode;".into(), read_only: false, }, Source { uri: second.clone(), text: "const b: typeof sharedOnce = sharedOnce; const g: string = globalValue;".into(), read_only: false, }, Source { uri: unbound.clone(), text: "/// \nimport type {Mode} from 'shared';".into(), read_only: false, }, Source { uri: global.clone(), text: "declare const globalValue: string;".into(), read_only: true, }, ], compiler_options: json!({"strict":true,"noEmit":true,"module":"preserve","moduleResolution":"bundler"}), imports: [shared.clone(), shared_esm.clone()].into_iter().map(|from| { ((from, "mode".into()), ResolvedImport { import: Some(uri("types/import.d.ts")), require: Some(uri("types/require.d.ts")), }) }).collect(), import_candidates: Vec::new(), per_file_options: BTreeMap::from([(second.clone(), json!({"strictNullChecks":false}))]), diagnostics: BTreeMap::from([(second.clone(), vec!["Unresolved explicit reference".into()])]), type_references: BTreeMap::from([ ((first.clone(), "shared".into()), shared.clone()), ((first.clone(), "shared-esm".into()), shared_esm), ((unbound.clone(), "shared".into()), shared), ]), path_references: BTreeMap::from([((first.clone(), "../types/path.d.ts".into()), path_types)]), globals: BTreeSet::from([global]), }; let diagnostics = crate::check(Path::new(&binary), input).await.unwrap(); assert!( diagnostics .iter() .any(|diagnostic| diagnostic.source == "avril-resolver") ); assert!( diagnostics .iter() .any(|diagnostic| { diagnostic.uri == second && diagnostic.code == Some(2304) }), "reference-scoped declaration leaked: {diagnostics:#?}" ); assert!( diagnostics .iter() .any(|diagnostic| diagnostic.uri == unbound && diagnostic.code == Some(2307)), "a type-reference binding must not authorize a module import: {diagnostics:#?}" ); assert!( diagnostics .iter() .filter(|diagnostic| diagnostic.source == "avril" && diagnostic.uri != second && !(diagnostic.uri == unbound && diagnostic.code == Some(2307))) .all(|diagnostic| diagnostic.severity != 1), "{diagnostics:#?}" ); } #[test] fn remote_queries_survive_editor_uri_encoding_without_collapsing_delimiters() { let uri = editor_uri("https://example.test/mod.ts?target=deno&dev", ".ts").unwrap(); assert_eq!(uri, "avril-https://example.test/mod.ts?target%3Ddeno%26dev"); assert_eq!(canonical_uri(&uri).unwrap(), uri); assert_eq!( canonical_uri("avril-https://example.test/mod.ts?target=deno&dev").unwrap(), uri ); assert_ne!( editor_uri("https://example.test/mod.ts?target%3Ddeno%26dev", ".ts").unwrap(), uri ); } #[test] fn uri_normalization_preserves_segments() { assert_eq!( canonical_uri("avril-builtin:///runtime/stdlib/std/sync.d.ts").unwrap(), canonical_uri("avril-builtin:/runtime/stdlib/std/sync.d.ts").unwrap() ); assert_eq!( canonical_uri("avril-https://jsr.io/@example/mod.d.ts").unwrap(), canonical_uri("avril-https://jsr.io/%40example/mod.d.ts").unwrap() ); assert_ne!( canonical_uri("file:///a%2fb.ts").unwrap(), canonical_uri("file:///a/b.ts").unwrap() ); } }