use crate::{Result, project::canonical_uri, wire::RpcError}; use serde::Deserialize; use serde_json::{Value, json}; use std::{ collections::BTreeMap, sync::atomic::{AtomicU64, Ordering}, }; use url::Url; static GENERATION: AtomicU64 = AtomicU64::new(1); const MAX_ITEMS: usize = 8192; #[derive(Deserialize)] pub(crate) struct Capabilities { pub version: u32, pub capabilities: Value, pub methods: Vec, } struct Document { native: String, read_only: bool, } struct Item { family: &'static str, project: String, value: Value, } /// Only document identities and opaque continuations cross this boundary. TypeScript /// owns language semantics; the host owns source acquisition and write policy. pub(crate) struct Bridge { generation: u64, next: u64, items: BTreeMap, documents: BTreeMap, editors: BTreeMap, display_paths: Vec<(String, String)>, } impl Bridge { pub fn new() -> Self { Self { generation: GENERATION.fetch_add(1, Ordering::Relaxed), next: 0, items: BTreeMap::new(), documents: BTreeMap::new(), editors: BTreeMap::new(), display_paths: Vec::new(), } } pub fn insert(&mut self, uri: String, file: &str, read_only: bool) -> Result<()> { let native = Url::from_file_path(file) .map_err(|()| "Invalid compiler path")? .to_string(); self.editors.insert(native.clone(), uri.clone()); self.display_paths.push((native.clone(), uri.clone())); self.display_paths.push((file.into(), uri.clone())); if let Some((directory, _)) = file.rsplit_once("/source") { self.display_paths .push((format!("{directory}/source"), uri.clone())); } self.documents.insert(uri, Document { native, read_only }); Ok(()) } pub fn native_uri(&self, uri: &str) -> Result { let uri = canonical_uri(uri)?; if let Some(document) = self.documents.get(&uri) { return Ok(document.native.clone()); } if let Some(name) = uri.strip_prefix("avril-builtin:/typescript/") && name.starts_with("lib.") && name.ends_with(".d.ts") && name .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') { return Ok(format!("bundled:///libs/{name}")); } Err(RpcError::invalid(format!("Document not in this project: {uri}")).into()) } pub fn contains(&self, uri: &str) -> bool { self.native_uri(uri).is_ok() } fn editor_uri(&self, native: &str) -> Result { if let Some(uri) = self.editors.get(native) { return Ok(uri.clone()); } let name = native.strip_prefix("bundled:///libs/"); if let Some(name) = name.filter(|name| { name.starts_with("lib.") && name.ends_with(".d.ts") && name .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') }) { return Ok(format!("avril-builtin:/typescript/{name}")); } Err(format!("TypeScript returned an unknown document: {native}").into()) } pub fn writable(&self, uri: &str) -> Result<()> { if self.documents.get(uri).is_some_and(|d| !d.read_only) { Ok(()) } else { Err( RpcError::failed(format!("Cannot edit read-only or unknown document: {uri}")) .into(), ) } } pub fn input(&self, value: &mut Value) -> Result<()> { match value { Value::Array(values) => { for value in values { self.input(value)?; } } Value::Object(object) => { for (key, value) in object { if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri") { let uri = value .as_str() .ok_or_else(|| RpcError::invalid("Invalid document URI"))?; *value = json!(self.native_uri(uri)?); } else if key != "data" { self.input(value)?; } } } _ => {} } Ok(()) } pub fn restore(&self, method: &str, params: &mut Value) -> Result> { let family = match method { "completionItem/resolve" => "completion", "codeAction/resolve" => "action", "codeLens/resolve" => "lens", "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls" => "call", _ => return Ok(None), }; let value = if family == "call" { params .get_mut("item") .ok_or_else(|| RpcError::invalid("Missing hierarchy item"))? } else { params }; #[derive(Deserialize)] struct Token { generation: u64, id: u64, } let token: Token = serde_json::from_value(value["data"]["avril"].clone()) .map_err(|_| RpcError::modified())?; let item = self .items .get(&token.id) .filter(|item| token.generation == self.generation && item.family == family) .ok_or_else(RpcError::modified)?; *value = item.value.clone(); Ok(Some(item.project.clone())) } fn remember(&mut self, family: &'static str, project: &str, value: &mut Value) { self.next += 1; self.items.insert( self.next, Item { family, project: project.into(), value: value.clone(), }, ); if self.items.len() > MAX_ITEMS { self.items.pop_first(); } value["data"] = json!({"avril": {"generation": self.generation, "id": self.next}}); } pub fn output(&mut self, method: &str, project: &str, value: &mut Value) -> Result<()> { match method { "completionItem/resolve" | "codeAction/resolve" | "codeLens/resolve" if !value.is_null() => { let family = match method { "completionItem/resolve" => "completion", "codeAction/resolve" => "action", _ => "lens", }; self.remember(family, project, value); } "textDocument/completion" => { let items = if value.is_array() { value.as_array_mut() } else { value.get_mut("items").and_then(Value::as_array_mut) }; if let Some(items) = items { for item in items { self.remember("completion", project, item); } } } "textDocument/codeAction" | "textDocument/codeLens" | "textDocument/prepareCallHierarchy" => { let family = match method { "textDocument/codeAction" => "action", "textDocument/codeLens" => "lens", _ => "call", }; if let Some(items) = value.as_array_mut() { for item in items { self.remember(family, project, item); } } } "textDocument/inlayHint" | "workspace/symbol" => { if let Some(items) = value.as_array_mut() { for item in items { if let Some(object) = item.as_object_mut() { object.remove("data"); } } } } "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls" => { if let Some(items) = value.as_array_mut() { let key = if method == "callHierarchy/incomingCalls" { "from" } else { "to" }; for item in items { self.remember("call", project, &mut item[key]); } } } _ => {} } self.translate(value)?; self.check_edits(value) } fn translate(&mut self, value: &mut Value) -> Result<()> { match value { Value::Array(values) => { for value in values { self.translate(value)?; } } Value::Object(object) => { for (key, value) in object { match key.as_str() { "data" => {} "uri" | "targetUri" | "oldUri" | "newUri" => { *value = json!( self.editor_uri(value.as_str().ok_or("Invalid native URI")?)? ); } "changes" if value.is_object() => { let mut changes = serde_json::Map::new(); for (uri, mut edits) in std::mem::take(value.as_object_mut().unwrap()) { self.translate(&mut edits)?; changes.insert(self.editor_uri(&uri)?, edits); } *value = Value::Object(changes); } "arguments" => { if let Some(arguments) = value.as_array_mut() { for argument in arguments { if let Some(uri) = argument.as_str().filter(|s| { s.starts_with("file:") || s.starts_with("bundled:") }) { *argument = json!(self.editor_uri(uri)?); } else { self.translate(argument)?; } } } } "label" | "name" | "containerName" if value.is_string() => { self.display(value) } "contents" | "documentation" | "tooltip" | "detail" | "message" | "labelDetails" => self.display(value), _ => self.translate(value)?, } } } _ => {} } Ok(()) } pub fn display(&self, value: &mut Value) { match value { Value::String(text) => { if !text.contains("/avril-lsp/") && !text.contains("bundled:") { return; } for (path, uri) in &self.display_paths { *text = text.replace(path, uri); } *text = text.replace("bundled:///libs/", "avril-builtin:/typescript/"); } Value::Array(values) => { for value in values { self.display(value); } } Value::Object(object) => { for value in object.values_mut() { self.display(value); } } _ => {} } } fn check_edits(&self, value: &Value) -> Result<()> { match value { Value::Array(values) => { for value in values { self.check_edits(value)?; } } Value::Object(object) => { if let Some(changes) = object.get("changes").and_then(Value::as_object) { for uri in changes.keys() { self.writable(uri)?; } } if object.contains_key("edits") && let Some(uri) = value.pointer("/textDocument/uri").and_then(Value::as_str) { self.writable(uri)?; } if matches!(value["kind"].as_str(), Some("create" | "rename" | "delete")) { return Err( RpcError::failed("TypeScript file operations are not authorized").into(), ); } for (key, value) in object { if key != "data" { self.check_edits(value)?; } } } _ => {} } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn bridge() -> Bridge { let mut bridge = Bridge::new(); bridge .insert( "file:///app/main.ts".into(), "/avril-lsp/live/0/source.ts", false, ) .unwrap(); bridge .insert( "avril-https://example.test/mod.ts".into(), "/avril-lsp/live/1/source.ts", true, ) .unwrap(); bridge } #[test] fn locations_hierarchy_and_documentation_use_editor_identities() { let mut bridge = bridge(); let mut result = json!({"from": {"uri":"file:///avril-lsp/live/1/source.ts"}, "documentation":{"kind":"markdown", "value":"[link](file:///avril-lsp/live/0/source.ts#L2)"}}); bridge.output("test", "p", &mut result).unwrap(); assert_eq!(result["from"]["uri"], "avril-https://example.test/mod.ts"); assert_eq!( result["documentation"]["value"], "[link](file:///app/main.ts#L2)" ); } #[test] fn empty_completions_and_malformed_hierarchy_params_do_not_mutate_or_panic() { let mut bridge = bridge(); let mut completion = Value::Null; bridge .output("textDocument/completion", "p", &mut completion) .unwrap(); assert_eq!(completion, Value::Null); for mut params in [json!([]), json!(42), json!("bad"), Value::Null, json!({})] { let error = bridge .restore("callHierarchy/incomingCalls", &mut params) .unwrap_err(); assert_eq!(error.downcast_ref::().unwrap().code, -32602); } let mut tokens = json!({"data":[0,0,4,1,0]}); bridge .output("textDocument/semanticTokens/full", "p", &mut tokens) .unwrap(); assert_eq!(tokens["data"], json!([0, 0, 4, 1, 0])); } #[test] fn display_names_and_diagnostic_quotes_hide_compiler_paths() { let mut bridge = bridge(); let mut result = json!({ "name":"/avril-lsp/live/0/source.ts", "containerName":"/avril-lsp/live/1/source", "message":"Module '\"/avril-lsp/live/0/source\"' has no default export.", "documentation":"import(\"/avril-lsp/live/1/source\")" }); bridge.output("test", "p", &mut result).unwrap(); assert_eq!(result["name"], "file:///app/main.ts"); assert_eq!(result["containerName"], "avril-https://example.test/mod.ts"); assert!(!result.to_string().contains("/avril-lsp/")); } #[test] fn non_resolvable_results_do_not_expose_native_data_or_evict_completions() { let mut bridge = bridge(); let mut completion = json!([{"label":"x","data":{"fileName":"/avril-lsp/live/0/source.ts"}}]); bridge .output("textDocument/completion", "p", &mut completion) .unwrap(); let mut hints = json!(vec![ json!({"label":"number","data":{"internal":"private"}}); MAX_ITEMS + 1 ]); bridge .output("textDocument/inlayHint", "p", &mut hints) .unwrap(); assert!(hints[0].get("data").is_none()); assert!( bridge .restore("completionItem/resolve", &mut completion[0]) .is_ok() ); } #[test] fn opaque_data_is_bound_to_generation_and_method_and_not_client_controlled() { let mut bridge = bridge(); let original = json!({"label":"x", "data":{"fileName":"/avril-lsp/live/0/source.ts", "position":4}}); let mut result = json!({"items":[original]}); bridge .output("textDocument/completion", "p", &mut result) .unwrap(); let mut item = result["items"][0].clone(); let mut stale = item.clone(); item["label"] = json!("changed by client"); assert_eq!( bridge .restore("completionItem/resolve", &mut item) .unwrap() .as_deref(), Some("p") ); assert_eq!(item, original); assert!(bridge.restore("codeAction/resolve", &mut stale).is_err()); assert!( super::Bridge::new() .restore("completionItem/resolve", &mut stale) .is_err() ); } #[test] fn readonly_edits_are_rejected_atomically_in_both_workspace_edit_forms() { for mut result in [ json!({"changes":{"file:///avril-lsp/live/0/source.ts":[],"file:///avril-lsp/live/1/source.ts":[]}}), json!({"documentChanges":[{"textDocument":{"uri":"file:///avril-lsp/live/1/source.ts","version":null},"edits":[]}]}), ] { assert!( bridge() .output("textDocument/rename", "p", &mut result) .is_err() ); } } #[test] fn library_locations_are_readonly_and_unknown_files_never_escape() { let mut bridge = bridge(); let mut result = json!({"uri":"bundled:///libs/lib.es5.d.ts"}); bridge.output("test", "p", &mut result).unwrap(); assert_eq!(result["uri"], "avril-builtin:/typescript/lib.es5.d.ts"); assert_eq!( bridge.native_uri(result["uri"].as_str().unwrap()).unwrap(), "bundled:///libs/lib.es5.d.ts" ); assert!(bridge.writable(result["uri"].as_str().unwrap()).is_err()); assert!( bridge .output("test", "p", &mut json!({"uri":"file:///etc/passwd"})) .is_err() ); } }