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
12.9 KiB399 linesraw
1#[path = "common/lsp.rs"]
2#[allow(dead_code)]
3mod support;
4
5use std::fs;
6use std::io::{BufReader, Cursor};
7use std::path::PathBuf;
8
9use serde_json::{Value, json};
10use support::{Server, backend, hover_text, position_at, read_frame, selected, write_frame};
11
12fn query(uri: &str, source: &str, word: &str, last: bool) -> Value {
13    let offset = if last {
14        source.rfind(word).unwrap()
15    } else {
16        source.find(word).unwrap()
17    } + 1;
18    json!({
19        "textDocument": {"uri": uri},
20        "position": position_at(source, offset)
21    })
22}
23
24#[test]
25fn framing_survives_split_utf8_and_coalesced_messages() {
26    let mut bytes = Vec::new();
27    write_frame(
28        &mut bytes,
29        &json!({"jsonrpc": "2.0", "id": 1, "result": "πŸ˜€"}),
30    )
31    .unwrap();
32    write_frame(
33        &mut bytes,
34        &json!({"jsonrpc": "2.0", "id": 2, "result": "next"}),
35    )
36    .unwrap();
37    // A one-byte buffer forces every frame to arrive in pieces.
38    let mut split = BufReader::with_capacity(1, Cursor::new(bytes.clone()));
39    let mut results = Vec::new();
40    while let Some(message) = read_frame(&mut split).unwrap() {
41        results.push(message["result"].clone());
42    }
43    assert_eq!(results, [json!("πŸ˜€"), json!("next")]);
44    let mut coalesced = BufReader::new(Cursor::new(bytes));
45    let mut results = Vec::new();
46    while let Some(message) = read_frame(&mut coalesced).unwrap() {
47        results.push(message["result"].clone());
48    }
49    assert_eq!(results, [json!("πŸ˜€"), json!("next")]);
50}
51
52#[test]
53fn native_backend_through_real_lsp_messages() {
54    let Some(backend) = backend() else {
55        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
56        return;
57    };
58    let dir = tempfile::Builder::new()
59        .prefix("avril lsp # % ΓΌ-")
60        .tempdir()
61        .unwrap();
62    let root = dir.path();
63    let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../lib/lsp/tests/fixture");
64    for name in [
65        "avril.conf",
66        "tsconfig.json",
67        "main.tsx",
68        "dep file.ts",
69        "a.ts",
70        "b.ts",
71        "scope-a.ts",
72        "scope-b.ts",
73        "jsx-runtime.d.ts",
74        "data.d.ts",
75    ] {
76        fs::write(root.join(name), fs::read(fixture.join(name)).unwrap()).unwrap();
77    }
78    let mut server = Server::start(root, &backend, &["--offline"]);
79    let main = server.uri("main.tsx");
80    let dep = server.uri("dep file.ts");
81    let text = fs::read_to_string(root.join("main.tsx")).unwrap();
82    let dep_text = fs::read_to_string(root.join("dep file.ts")).unwrap();
83
84    let initialize = server
85        .call(
86            "initialize",
87            json!({"capabilities": {"textDocument": {"definition": {"linkSupport": true}}}}),
88        )
89        .unwrap();
90    assert_eq!(
91        initialize["capabilities"]["positionEncoding"],
92        json!("utf-16")
93    );
94    assert!(initialize["capabilities"].get("renameProvider").is_some());
95    assert!(
96        initialize["capabilities"]
97            .pointer("/workspace/fileOperations")
98            .is_none()
99    );
100    assert_eq!(
101        initialize["capabilities"]["completionProvider"]["resolveProvider"],
102        json!(true)
103    );
104    assert!(
105        initialize["capabilities"]
106            .get("signatureHelpProvider")
107            .is_some()
108    );
109    server.notify("initialized", json!({}));
110    let unsupported = server
111        .call("workspace/willRenameFiles", json!({"files": []}))
112        .unwrap_err();
113    assert_eq!(unsupported.code, -32601);
114    server.open_as("main.tsx", &text, 1, "typescriptreact");
115
116    // Hover, aliases, shorthand, original URI and exact Unicode ranges.
117    let hover = server
118        .call(
119            "textDocument/hover",
120            query(&main, &text, "localValue", true),
121        )
122        .unwrap();
123    assert!(hover_text(&hover).contains("localValue: 42"));
124    assert_eq!(selected(&text, &hover["range"]), "localValue");
125    let links = server
126        .call(
127            "textDocument/definition",
128            query(&main, &text, "localValue", false),
129        )
130        .unwrap();
131    assert_eq!(links.as_array().unwrap().len(), 1);
132    assert_eq!(links[0]["targetUri"], json!(dep));
133    assert_eq!(
134        selected(&dep_text, &links[0]["targetSelectionRange"]),
135        "value"
136    );
137    assert_eq!(
138        links[0]["targetSelectionRange"]["start"],
139        position_at(&dep_text, dep_text.find("value").unwrap())
140    );
141    assert!(!links.to_string().contains("/avril-lsp/"));
142
143    // Original declaration aliases and read-only builtin declarations.
144    let links = server
145        .call(
146            "textDocument/definition",
147            query(&main, &text, "answer", false),
148        )
149        .unwrap();
150    let declaration = &links[0];
151    assert_eq!(declaration["targetUri"], json!(server.uri("data.d.ts")));
152    let data_text = fs::read_to_string(root.join("data.d.ts")).unwrap();
153    assert_eq!(
154        selected(&data_text, &declaration["targetSelectionRange"]),
155        "answer"
156    );
157    let links = server
158        .call("textDocument/definition", query(&main, &text, "args", true))
159        .unwrap();
160    let builtin = &links[0];
161    let builtin_uri = builtin["targetUri"].as_str().unwrap();
162    assert!(
163        builtin_uri.ends_with("/runtime/stdlib/std.d.ts"),
164        "{builtin_uri}"
165    );
166    let builtin_text = if builtin_uri.starts_with("file:") {
167        let path = url::Url::parse(builtin_uri)
168            .unwrap()
169            .to_file_path()
170            .unwrap();
171        fs::read_to_string(path).unwrap()
172    } else {
173        server
174            .call("avril/readDocument", json!({"uri": builtin_uri}))
175            .unwrap()
176            .as_str()
177            .unwrap()
178            .to_owned()
179    };
180    assert_eq!(
181        selected(&builtin_text, &builtin["targetSelectionRange"]),
182        "args"
183    );
184
185    // Split builtin declarations retain cross-module types and globals.
186    let source = [
187        "import { copy, type CopyOptions } from \"avril:fs/sync\";",
188        "import { normalize, type ParsedPath } from \"avril:path/win32\";",
189        "import { stringify } from \"avril:fjson\";",
190        "import { test, type TestContext } from \"avril:test\";",
191        "test(\"typed\", async (t) => { const passed: boolean = await t.step(\"step\", () => {}); });",
192        "const contextName = (context: TestContext): string => context.name;",
193        "const serialized: string = stringify({ answer: 42 });",
194        "const options: CopyOptions = { dereference: true };",
195        "const parsed: ParsedPath = { root: \"\", dir: \"\", base: \"\", name: \"\", ext: \"\" };",
196        "copy(\"a\", normalize(\"b\"), options);",
197        "const main: boolean = import.meta.main;",
198        "const invalid: CopyOptions = { dereference: \"wrong\" };",
199        "export { parsed, main, invalid };",
200    ]
201    .join("\n");
202    fs::write(root.join("builtins.ts"), &source).unwrap();
203    server.open("builtins.ts", &source);
204    for (symbol, file) in [
205        ("copy", "fs/sync.d.ts"),
206        ("normalize", "path/win32.d.ts"),
207        ("CopyOptions", "fs.d.ts"),
208        ("stringify", "fjson.d.ts"),
209        ("test", "test.d.ts"),
210        ("TestContext", "test.d.ts"),
211    ] {
212        let links = server
213            .call(
214                "textDocument/definition",
215                query(&server.uri("builtins.ts"), &source, symbol, true),
216            )
217            .unwrap();
218        let link = &links[0];
219        let target = link["targetUri"].as_str().unwrap();
220        assert!(
221            target.ends_with(&format!("/runtime/stdlib/{file}")),
222            "{target}"
223        );
224        let declaration = server
225            .call("avril/readDocument", json!({"uri": target}))
226            .unwrap()
227            .as_str()
228            .unwrap()
229            .to_owned();
230        assert_eq!(
231            selected(&declaration, &link["targetSelectionRange"]),
232            symbol
233        );
234    }
235    let codes: Vec<Value> = server
236        .diagnostics("builtins.ts")
237        .iter()
238        .map(|diagnostic| diagnostic["code"].clone())
239        .collect();
240    assert_eq!(codes.iter().filter(|code| **code == json!(2322)).count(), 1);
241    server.notify(
242        "textDocument/didClose",
243        json!({"textDocument": {"uri": server.uri("builtins.ts")}}),
244    );
245
246    // File-specific scopes survive editor URI mapping.
247    for (name, target) in [("a.ts", "scope-a.ts"), ("b.ts", "scope-b.ts")] {
248        let source = fs::read_to_string(root.join(name)).unwrap();
249        server.open(name, &source);
250        let links = server.definitions(name, &source, "value");
251        assert_eq!(links[0]["targetUri"], json!(server.uri(target)));
252    }
253
254    // Unsaved dependency edits update hover, locations and dependent diagnostics.
255    let changed = "\r\n// πŸ˜€ unsaved\r\nexport const value = \"edited\" as const;\r\n";
256    server.open("dep file.ts", &dep_text);
257    server.change("dep file.ts", changed, 2);
258    assert!(
259        server
260            .hover("main.tsx", &text, "localValue")
261            .contains("localValue: \"edited\"")
262    );
263    let links = server
264        .call(
265            "textDocument/definition",
266            query(&main, &text, "localValue", false),
267        )
268        .unwrap();
269    assert_eq!(
270        links[0]["targetSelectionRange"]["start"],
271        position_at(changed, changed.find("value").unwrap())
272    );
273    assert!(
274        server
275            .diagnostics("main.tsx")
276            .iter()
277            .any(|diagnostic| diagnostic["code"] == json!(2322))
278    );
279    // Stale versions are rejected; closing reverts to the on-disk source.
280    server.change("dep file.ts", &dep_text, 1);
281    assert!(
282        server
283            .hover("main.tsx", &text, "localValue")
284            .contains("localValue: \"edited\"")
285    );
286    server.notify(
287        "textDocument/didClose",
288        json!({"textDocument": {"uri": dep}}),
289    );
290    assert!(
291        server
292            .hover("main.tsx", &text, "localValue")
293            .contains("localValue: 42")
294    );
295
296    // Escaped identifier ranges and re-export targets come from native ASTs.
297    let escaped = "export const \\u0076alue = 99 as const;\n";
298    server.open_version("dep file.ts", escaped, 1);
299    let links = server
300        .call(
301            "textDocument/definition",
302            query(&main, &text, "localValue", false),
303        )
304        .unwrap();
305    assert_eq!(
306        selected(escaped, &links[0]["targetSelectionRange"]),
307        "\\u0076alue"
308    );
309    assert!(
310        server
311            .hover("main.tsx", &text, "localValue")
312            .contains("localValue: 99")
313    );
314    server.change(
315        "dep file.ts",
316        "export { value } from \"./scope-a.ts\";\n",
317        2,
318    );
319    let links = server
320        .call(
321            "textDocument/definition",
322            query(&main, &text, "localValue", false),
323        )
324        .unwrap();
325    assert_eq!(links[0]["targetUri"], json!(server.uri("scope-a.ts")));
326    server.notify(
327        "textDocument/didClose",
328        json!({"textDocument": {"uri": dep}}),
329    );
330
331    // Namespace hover does not expose compiler-only paths.
332    let source = format!("{text}\nimport * as dep from \"./dep file.ts\"; dep;");
333    server.change("main.tsx", &source, 2);
334    let hover = server
335        .call("textDocument/hover", query(&main, &source, "dep", true))
336        .unwrap();
337    let value = hover_text(&hover);
338    assert!(value.contains("./dep file.ts"), "{value}");
339    assert!(!value.contains("/avril-lsp/"), "{value}");
340
341    // Comments return no navigation or suggestions; unknown files are rejected.
342    let comments = format!("// localValue\n{text}");
343    server.change("main.tsx", &comments, 3);
344    assert_eq!(
345        server
346            .call(
347                "textDocument/hover",
348                query(&main, &comments, "localValue", false)
349            )
350            .unwrap(),
351        Value::Null
352    );
353    let completions = server
354        .call(
355            "textDocument/completion",
356            query(&main, &comments, "localValue", false),
357        )
358        .unwrap();
359    assert!(
360        completions.is_null()
361            || completions["items"]
362                .as_array()
363                .is_some_and(|items| items.is_empty()),
364        "{completions}"
365    );
366    let unknown = server.request(
367        "textDocument/hover",
368        query("file:///not-in-project.ts", "value", "value", true),
369    );
370    assert_eq!(unknown["error"]["code"], json!(-32602), "{unknown}");
371    assert!(
372        unknown["error"]["message"]
373            .as_str()
374            .unwrap()
375            .contains("Document not in this project")
376    );
377    let unknown = server.request(
378        "textDocument/completion",
379        query("file:///not-in-project.ts", "value", "value", true),
380    );
381    assert_eq!(unknown["error"]["code"], json!(-32602), "{unknown}");
382    assert!(
383        unknown["error"]["message"]
384            .as_str()
385            .unwrap()
386            .contains("Document not in this project")
387    );
388    assert_eq!(
389        server
390            .call(
391                "textDocument/definition",
392                query(&main, &comments, "localValue", false)
393            )
394            .unwrap(),
395        json!([])
396    );
397
398    server.close();
399}