char/avril

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

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

Charlotte Somexperiment: use native typescript lsp supportc7a9569

main
18.3 KiB500 linesraw
1use crate::{Result, project::canonical_uri, wire::RpcError};
2use serde::Deserialize;
3use serde_json::{Value, json};
4use std::{
5    collections::BTreeMap,
6    sync::atomic::{AtomicU64, Ordering},
7};
8use url::Url;
9
10static GENERATION: AtomicU64 = AtomicU64::new(1);
11const MAX_ITEMS: usize = 8192;
12
13#[derive(Deserialize)]
14pub(crate) struct Capabilities {
15    pub version: u32,
16    pub capabilities: Value,
17    pub methods: Vec<String>,
18}
19
20struct Document {
21    native: String,
22    read_only: bool,
23}
24struct Item {
25    family: &'static str,
26    project: String,
27    value: Value,
28}
29
30/// Only document identities and opaque continuations cross this boundary. TypeScript
31/// owns language semantics; the host owns source acquisition and write policy.
32pub(crate) struct Bridge {
33    generation: u64,
34    next: u64,
35    items: BTreeMap<u64, Item>,
36    documents: BTreeMap<String, Document>,
37    editors: BTreeMap<String, String>,
38    display_paths: Vec<(String, String)>,
39}
40impl Bridge {
41    pub fn new() -> Self {
42        Self {
43            generation: GENERATION.fetch_add(1, Ordering::Relaxed),
44            next: 0,
45            items: BTreeMap::new(),
46            documents: BTreeMap::new(),
47            editors: BTreeMap::new(),
48            display_paths: Vec::new(),
49        }
50    }
51    pub fn insert(&mut self, uri: String, file: &str, read_only: bool) -> Result<()> {
52        let native = Url::from_file_path(file)
53            .map_err(|()| "Invalid compiler path")?
54            .to_string();
55        self.editors.insert(native.clone(), uri.clone());
56        self.display_paths.push((native.clone(), uri.clone()));
57        self.display_paths.push((file.into(), uri.clone()));
58        if let Some((directory, _)) = file.rsplit_once("/source") {
59            self.display_paths
60                .push((format!("{directory}/source"), uri.clone()));
61        }
62        self.documents.insert(uri, Document { native, read_only });
63        Ok(())
64    }
65    pub fn native_uri(&self, uri: &str) -> Result<String> {
66        let uri = canonical_uri(uri)?;
67        if let Some(document) = self.documents.get(&uri) {
68            return Ok(document.native.clone());
69        }
70        if let Some(name) = uri.strip_prefix("avril-builtin:/typescript/")
71            && name.starts_with("lib.")
72            && name.ends_with(".d.ts")
73            && name
74                .bytes()
75                .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
76        {
77            return Ok(format!("bundled:///libs/{name}"));
78        }
79        Err(RpcError::invalid(format!("Document not in this project: {uri}")).into())
80    }
81    pub fn contains(&self, uri: &str) -> bool {
82        self.native_uri(uri).is_ok()
83    }
84    fn editor_uri(&self, native: &str) -> Result<String> {
85        if let Some(uri) = self.editors.get(native) {
86            return Ok(uri.clone());
87        }
88        let name = native.strip_prefix("bundled:///libs/");
89        if let Some(name) = name.filter(|name| {
90            name.starts_with("lib.")
91                && name.ends_with(".d.ts")
92                && name
93                    .bytes()
94                    .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
95        }) {
96            return Ok(format!("avril-builtin:/typescript/{name}"));
97        }
98        Err(format!("TypeScript returned an unknown document: {native}").into())
99    }
100    pub fn writable(&self, uri: &str) -> Result<()> {
101        if self.documents.get(uri).is_some_and(|d| !d.read_only) {
102            Ok(())
103        } else {
104            Err(
105                RpcError::failed(format!("Cannot edit read-only or unknown document: {uri}"))
106                    .into(),
107            )
108        }
109    }
110    pub fn input(&self, value: &mut Value) -> Result<()> {
111        match value {
112            Value::Array(values) => {
113                for value in values {
114                    self.input(value)?;
115                }
116            }
117            Value::Object(object) => {
118                for (key, value) in object {
119                    if matches!(key.as_str(), "uri" | "targetUri" | "oldUri" | "newUri") {
120                        let uri = value
121                            .as_str()
122                            .ok_or_else(|| RpcError::invalid("Invalid document URI"))?;
123                        *value = json!(self.native_uri(uri)?);
124                    } else if key != "data" {
125                        self.input(value)?;
126                    }
127                }
128            }
129            _ => {}
130        }
131        Ok(())
132    }
133    pub fn restore(&self, method: &str, params: &mut Value) -> Result<Option<String>> {
134        let family = match method {
135            "completionItem/resolve" => "completion",
136            "codeAction/resolve" => "action",
137            "codeLens/resolve" => "lens",
138            "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls" => "call",
139            _ => return Ok(None),
140        };
141        let value = if family == "call" {
142            params
143                .get_mut("item")
144                .ok_or_else(|| RpcError::invalid("Missing hierarchy item"))?
145        } else {
146            params
147        };
148        #[derive(Deserialize)]
149        struct Token {
150            generation: u64,
151            id: u64,
152        }
153        let token: Token = serde_json::from_value(value["data"]["avril"].clone())
154            .map_err(|_| RpcError::modified())?;
155        let item = self
156            .items
157            .get(&token.id)
158            .filter(|item| token.generation == self.generation && item.family == family)
159            .ok_or_else(RpcError::modified)?;
160        *value = item.value.clone();
161        Ok(Some(item.project.clone()))
162    }
163    fn remember(&mut self, family: &'static str, project: &str, value: &mut Value) {
164        self.next += 1;
165        self.items.insert(
166            self.next,
167            Item {
168                family,
169                project: project.into(),
170                value: value.clone(),
171            },
172        );
173        if self.items.len() > MAX_ITEMS {
174            self.items.pop_first();
175        }
176        value["data"] = json!({"avril": {"generation": self.generation, "id": self.next}});
177    }
178    pub fn output(&mut self, method: &str, project: &str, value: &mut Value) -> Result<()> {
179        match method {
180            "completionItem/resolve" | "codeAction/resolve" | "codeLens/resolve"
181                if !value.is_null() =>
182            {
183                let family = match method {
184                    "completionItem/resolve" => "completion",
185                    "codeAction/resolve" => "action",
186                    _ => "lens",
187                };
188                self.remember(family, project, value);
189            }
190            "textDocument/completion" => {
191                let items = if value.is_array() {
192                    value.as_array_mut()
193                } else {
194                    value.get_mut("items").and_then(Value::as_array_mut)
195                };
196                if let Some(items) = items {
197                    for item in items {
198                        self.remember("completion", project, item);
199                    }
200                }
201            }
202            "textDocument/codeAction"
203            | "textDocument/codeLens"
204            | "textDocument/prepareCallHierarchy" => {
205                let family = match method {
206                    "textDocument/codeAction" => "action",
207                    "textDocument/codeLens" => "lens",
208                    _ => "call",
209                };
210                if let Some(items) = value.as_array_mut() {
211                    for item in items {
212                        self.remember(family, project, item);
213                    }
214                }
215            }
216            "textDocument/inlayHint" | "workspace/symbol" => {
217                if let Some(items) = value.as_array_mut() {
218                    for item in items {
219                        if let Some(object) = item.as_object_mut() {
220                            object.remove("data");
221                        }
222                    }
223                }
224            }
225            "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls" => {
226                if let Some(items) = value.as_array_mut() {
227                    let key = if method == "callHierarchy/incomingCalls" {
228                        "from"
229                    } else {
230                        "to"
231                    };
232                    for item in items {
233                        self.remember("call", project, &mut item[key]);
234                    }
235                }
236            }
237            _ => {}
238        }
239        self.translate(value)?;
240        self.check_edits(value)
241    }
242    fn translate(&mut self, value: &mut Value) -> Result<()> {
243        match value {
244            Value::Array(values) => {
245                for value in values {
246                    self.translate(value)?;
247                }
248            }
249            Value::Object(object) => {
250                for (key, value) in object {
251                    match key.as_str() {
252                        "data" => {}
253                        "uri" | "targetUri" | "oldUri" | "newUri" => {
254                            *value = json!(
255                                self.editor_uri(value.as_str().ok_or("Invalid native URI")?)?
256                            );
257                        }
258                        "changes" if value.is_object() => {
259                            let mut changes = serde_json::Map::new();
260                            for (uri, mut edits) in std::mem::take(value.as_object_mut().unwrap()) {
261                                self.translate(&mut edits)?;
262                                changes.insert(self.editor_uri(&uri)?, edits);
263                            }
264                            *value = Value::Object(changes);
265                        }
266                        "arguments" => {
267                            if let Some(arguments) = value.as_array_mut() {
268                                for argument in arguments {
269                                    if let Some(uri) = argument.as_str().filter(|s| {
270                                        s.starts_with("file:") || s.starts_with("bundled:")
271                                    }) {
272                                        *argument = json!(self.editor_uri(uri)?);
273                                    } else {
274                                        self.translate(argument)?;
275                                    }
276                                }
277                            }
278                        }
279                        "label" | "name" | "containerName" if value.is_string() => {
280                            self.display(value)
281                        }
282                        "contents" | "documentation" | "tooltip" | "detail" | "message"
283                        | "labelDetails" => self.display(value),
284                        _ => self.translate(value)?,
285                    }
286                }
287            }
288            _ => {}
289        }
290        Ok(())
291    }
292    pub fn display(&self, value: &mut Value) {
293        match value {
294            Value::String(text) => {
295                if !text.contains("/avril-lsp/") && !text.contains("bundled:") {
296                    return;
297                }
298                for (path, uri) in &self.display_paths {
299                    *text = text.replace(path, uri);
300                }
301                *text = text.replace("bundled:///libs/", "avril-builtin:/typescript/");
302            }
303            Value::Array(values) => {
304                for value in values {
305                    self.display(value);
306                }
307            }
308            Value::Object(object) => {
309                for value in object.values_mut() {
310                    self.display(value);
311                }
312            }
313            _ => {}
314        }
315    }
316    fn check_edits(&self, value: &Value) -> Result<()> {
317        match value {
318            Value::Array(values) => {
319                for value in values {
320                    self.check_edits(value)?;
321                }
322            }
323            Value::Object(object) => {
324                if let Some(changes) = object.get("changes").and_then(Value::as_object) {
325                    for uri in changes.keys() {
326                        self.writable(uri)?;
327                    }
328                }
329                if object.contains_key("edits")
330                    && let Some(uri) = value.pointer("/textDocument/uri").and_then(Value::as_str)
331                {
332                    self.writable(uri)?;
333                }
334                if matches!(value["kind"].as_str(), Some("create" | "rename" | "delete")) {
335                    return Err(
336                        RpcError::failed("TypeScript file operations are not authorized").into(),
337                    );
338                }
339                for (key, value) in object {
340                    if key != "data" {
341                        self.check_edits(value)?;
342                    }
343                }
344            }
345            _ => {}
346        }
347        Ok(())
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    fn bridge() -> Bridge {
355        let mut bridge = Bridge::new();
356        bridge
357            .insert(
358                "file:///app/main.ts".into(),
359                "/avril-lsp/live/0/source.ts",
360                false,
361            )
362            .unwrap();
363        bridge
364            .insert(
365                "avril-https://example.test/mod.ts".into(),
366                "/avril-lsp/live/1/source.ts",
367                true,
368            )
369            .unwrap();
370        bridge
371    }
372    #[test]
373    fn locations_hierarchy_and_documentation_use_editor_identities() {
374        let mut bridge = bridge();
375        let mut result = json!({"from": {"uri":"file:///avril-lsp/live/1/source.ts"},
376            "documentation":{"kind":"markdown", "value":"[link](file:///avril-lsp/live/0/source.ts#L2)"}});
377        bridge.output("test", "p", &mut result).unwrap();
378        assert_eq!(result["from"]["uri"], "avril-https://example.test/mod.ts");
379        assert_eq!(
380            result["documentation"]["value"],
381            "[link](file:///app/main.ts#L2)"
382        );
383    }
384    #[test]
385    fn empty_completions_and_malformed_hierarchy_params_do_not_mutate_or_panic() {
386        let mut bridge = bridge();
387        let mut completion = Value::Null;
388        bridge
389            .output("textDocument/completion", "p", &mut completion)
390            .unwrap();
391        assert_eq!(completion, Value::Null);
392        for mut params in [json!([]), json!(42), json!("bad"), Value::Null, json!({})] {
393            let error = bridge
394                .restore("callHierarchy/incomingCalls", &mut params)
395                .unwrap_err();
396            assert_eq!(error.downcast_ref::<RpcError>().unwrap().code, -32602);
397        }
398        let mut tokens = json!({"data":[0,0,4,1,0]});
399        bridge
400            .output("textDocument/semanticTokens/full", "p", &mut tokens)
401            .unwrap();
402        assert_eq!(tokens["data"], json!([0, 0, 4, 1, 0]));
403    }
404
405    #[test]
406    fn display_names_and_diagnostic_quotes_hide_compiler_paths() {
407        let mut bridge = bridge();
408        let mut result = json!({
409            "name":"/avril-lsp/live/0/source.ts",
410            "containerName":"/avril-lsp/live/1/source",
411            "message":"Module '\"/avril-lsp/live/0/source\"' has no default export.",
412            "documentation":"import(\"/avril-lsp/live/1/source\")"
413        });
414        bridge.output("test", "p", &mut result).unwrap();
415        assert_eq!(result["name"], "file:///app/main.ts");
416        assert_eq!(result["containerName"], "avril-https://example.test/mod.ts");
417        assert!(!result.to_string().contains("/avril-lsp/"));
418    }
419
420    #[test]
421    fn non_resolvable_results_do_not_expose_native_data_or_evict_completions() {
422        let mut bridge = bridge();
423        let mut completion =
424            json!([{"label":"x","data":{"fileName":"/avril-lsp/live/0/source.ts"}}]);
425        bridge
426            .output("textDocument/completion", "p", &mut completion)
427            .unwrap();
428        let mut hints = json!(vec![
429            json!({"label":"number","data":{"internal":"private"}});
430            MAX_ITEMS + 1
431        ]);
432        bridge
433            .output("textDocument/inlayHint", "p", &mut hints)
434            .unwrap();
435        assert!(hints[0].get("data").is_none());
436        assert!(
437            bridge
438                .restore("completionItem/resolve", &mut completion[0])
439                .is_ok()
440        );
441    }
442
443    #[test]
444    fn opaque_data_is_bound_to_generation_and_method_and_not_client_controlled() {
445        let mut bridge = bridge();
446        let original =
447            json!({"label":"x", "data":{"fileName":"/avril-lsp/live/0/source.ts", "position":4}});
448        let mut result = json!({"items":[original]});
449        bridge
450            .output("textDocument/completion", "p", &mut result)
451            .unwrap();
452        let mut item = result["items"][0].clone();
453        let mut stale = item.clone();
454        item["label"] = json!("changed by client");
455        assert_eq!(
456            bridge
457                .restore("completionItem/resolve", &mut item)
458                .unwrap()
459                .as_deref(),
460            Some("p")
461        );
462        assert_eq!(item, original);
463        assert!(bridge.restore("codeAction/resolve", &mut stale).is_err());
464        assert!(
465            super::Bridge::new()
466                .restore("completionItem/resolve", &mut stale)
467                .is_err()
468        );
469    }
470    #[test]
471    fn readonly_edits_are_rejected_atomically_in_both_workspace_edit_forms() {
472        for mut result in [
473            json!({"changes":{"file:///avril-lsp/live/0/source.ts":[],"file:///avril-lsp/live/1/source.ts":[]}}),
474            json!({"documentChanges":[{"textDocument":{"uri":"file:///avril-lsp/live/1/source.ts","version":null},"edits":[]}]}),
475        ] {
476            assert!(
477                bridge()
478                    .output("textDocument/rename", "p", &mut result)
479                    .is_err()
480            );
481        }
482    }
483    #[test]
484    fn library_locations_are_readonly_and_unknown_files_never_escape() {
485        let mut bridge = bridge();
486        let mut result = json!({"uri":"bundled:///libs/lib.es5.d.ts"});
487        bridge.output("test", "p", &mut result).unwrap();
488        assert_eq!(result["uri"], "avril-builtin:/typescript/lib.es5.d.ts");
489        assert_eq!(
490            bridge.native_uri(result["uri"].as_str().unwrap()).unwrap(),
491            "bundled:///libs/lib.es5.d.ts"
492        );
493        assert!(bridge.writable(result["uri"].as_str().unwrap()).is_err());
494        assert!(
495            bridge
496                .output("test", "p", &mut json!({"uri":"file:///etc/passwd"}))
497                .is_err()
498        );
499    }
500}