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
14.8 KiB422 linesraw
1#[path = "common/lsp.rs"]
2#[allow(dead_code)]
3mod support;
4
5use serde_json::{Value, json};
6use support::{Server, backend, files, position_at};
7
8fn request(uri: &str, text: &str, offset: usize) -> Value {
9    json!({
10        "textDocument": {"uri": uri},
11        "position": position_at(text, offset)
12    })
13}
14
15#[test]
16fn native_language_service_capabilities_and_requests() {
17    let Some(backend) = backend() else {
18        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
19        return;
20    };
21    let dir = tempfile::tempdir().unwrap();
22    let root = dir.path();
23    let source = [
24        "import { process } from \"avril:std\";",
25        "export interface Shape { area(): number }",
26        "export class Square implements Shape {",
27        "  constructor(public size: number) {}",
28        "  area() { return this.size ** 2; }",
29        "}",
30        "export class LargeSquare extends Square {}",
31        "const emoji = \"😀\";",
32        "const square:Shape=new Square(2);",
33        "function inferred() { return square.area(); }",
34        "square.area();",
35        "const markup = <div>{square.area()}</div>;",
36        "process.args;",
37    ]
38    .join("\r\n");
39    files(
40        root,
41        &[
42            ("main.tsx", &source),
43            (
44                "avril.conf",
45                &json!({
46                    "scopes": {
47                        "./a.ts": {"scoped": "./one.ts"},
48                        "./b.ts": {"scoped": "./two.ts"}
49                    }
50                })
51                .to_string(),
52            ),
53            ("a.ts", "import {value} from \"scoped\"; value.aaa;"),
54            ("b.ts", "import {value} from \"scoped\"; value.bbb;"),
55            ("one.ts", "export const value = { aaa: 1 } as const;"),
56            ("two.ts", "export const value = { bbb: 2 } as const;"),
57        ],
58    );
59    let mut server = Server::start(root, &backend, &["--offline"]);
60    let initialized = server.initialize_params(json!({
61        "initializationOptions": {
62            "preferences": {
63                "includeInlayParameterNameHints": "all",
64                "includeInlayFunctionLikeReturnTypeHints": true,
65                "includeInlayVariableTypeHints": true
66            }
67        },
68        "capabilities": {
69            "textDocument": {
70            "definition": {"linkSupport": true},
71            "completion": {
72                "completionItem": {
73                    "snippetSupport": true,
74                    "insertReplaceSupport": true,
75                    "resolveSupport": {"properties": ["documentation", "detail"]}
76                }
77            },
78            "semanticTokens": {
79                "requests": {"full": true, "range": true},
80                "tokenTypes": ["namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator"],
81                "tokenModifiers": ["declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary"],
82                "formats": ["relative"]
83            },
84            "inlayHint": {"dynamicRegistration": true}
85            },
86            "workspace": {"workspaceFolders": true}
87        }
88    }));
89    let caps = &initialized["capabilities"];
90    for name in [
91        "hoverProvider",
92        "completionProvider",
93        "signatureHelpProvider",
94        "definitionProvider",
95        "typeDefinitionProvider",
96        "implementationProvider",
97        "callHierarchyProvider",
98        "referencesProvider",
99        "documentHighlightProvider",
100        "renameProvider",
101        "documentSymbolProvider",
102        "workspaceSymbolProvider",
103        "foldingRangeProvider",
104        "selectionRangeProvider",
105        "documentFormattingProvider",
106        "documentRangeFormattingProvider",
107        "semanticTokensProvider",
108        "inlayHintProvider",
109        "codeActionProvider",
110        "linkedEditingRangeProvider",
111    ] {
112        assert!(
113            caps.get(name).is_some_and(|value| !value.is_null()),
114            "Missing native capability {name}: {caps}"
115        );
116    }
117    assert_eq!(caps["completionProvider"]["resolveProvider"], json!(true));
118    assert!(caps.get("typeHierarchyProvider").is_none());
119    server.open_as("main.tsx", &source, 1, "typescriptreact");
120    server.notify(
121        "workspace/didChangeConfiguration",
122        json!({"settings":{"preferences":{
123            "includeInlayParameterNameHints":"all",
124            "includeInlayFunctionLikeReturnTypeHints":true,
125            "includeInlayVariableTypeHints":true
126        }}}),
127    );
128
129    let uri = server.uri("main.tsx");
130    let completion_offset = source.find("square.area").unwrap() + "square.".len();
131    let completions = server.complete("main.tsx", &source, completion_offset, Some("."));
132    let area = completions["items"]
133        .as_array()
134        .and_then(|items| items.iter().find(|item| item["label"] == "area"))
135        .expect("Native member completion should include area");
136    assert!(
137        area.get("data").is_some(),
138        "Resolve data must be opaque but present: {area}"
139    );
140    let resolved = server.call("completionItem/resolve", area.clone()).unwrap();
141    assert_eq!(resolved["label"], "area");
142    server.change("main.tsx", &format!("{source}\n"), 2);
143    let stale = server.request("completionItem/resolve", area.clone());
144    assert_eq!(stale["error"]["code"], json!(-32801), "{stale}");
145    let square = source.rfind("square.area").unwrap();
146    let params = request(&uri, &source, square + 2);
147    let hover = server.call("textDocument/hover", params.clone()).unwrap();
148    assert!(
149        !hover.is_null(),
150        "Native hover should describe the selected member"
151    );
152    let type_definition = server
153        .call("textDocument/typeDefinition", params.clone())
154        .unwrap();
155    assert!(
156        type_definition
157            .as_array()
158            .is_some_and(|locations| !locations.is_empty()),
159        "{type_definition}"
160    );
161    let implementations = server
162        .call(
163            "textDocument/implementation",
164            json!({"textDocument":{"uri":uri},"position":position_at(&source, source.find("area(): number").unwrap() + 1)}),
165        )
166        .unwrap();
167    assert!(
168        implementations
169            .as_array()
170            .is_some_and(|locations| !locations.is_empty()),
171        "{implementations}"
172    );
173    let call_hierarchy = server
174        .call(
175            "textDocument/prepareCallHierarchy",
176            json!({"textDocument":{"uri":uri},"position":position_at(&source, square + "square.".len() + 1)}),
177        )
178        .unwrap();
179    let call_item = call_hierarchy
180        .as_array()
181        .and_then(|items| items.first())
182        .expect("Call hierarchy should prepare area");
183    let incoming = server
184        .call("callHierarchy/incomingCalls", json!({"item":call_item}))
185        .unwrap();
186    assert!(
187        incoming.as_array().is_some_and(|calls| !calls.is_empty()),
188        "{incoming}"
189    );
190    let references = server
191        .call(
192            "textDocument/references",
193            json!({"textDocument":{"uri":uri},"position":position_at(&source, square + 2),"context":{"includeDeclaration":true}}),
194        )
195        .unwrap();
196    assert!(
197        references
198            .as_array()
199            .is_some_and(|locations| locations.len() >= 2),
200        "{references}"
201    );
202
203    let a_text = std::fs::read_to_string(root.join("a.ts")).unwrap();
204    let b_text = std::fs::read_to_string(root.join("b.ts")).unwrap();
205    let one_text = std::fs::read_to_string(root.join("one.ts")).unwrap();
206    let two_text = std::fs::read_to_string(root.join("two.ts")).unwrap();
207    for (path, text) in [
208        ("a.ts", a_text.as_str()),
209        ("b.ts", b_text.as_str()),
210        ("one.ts", one_text.as_str()),
211        ("two.ts", two_text.as_str()),
212    ] {
213        server.open(path, text);
214    }
215    let refs = server
216        .call(
217            "textDocument/references",
218            json!({"textDocument":{"uri":server.uri("one.ts")},"position":position_at(&one_text, one_text.find("value").unwrap() + 1),"context":{"includeDeclaration":true}}),
219        )
220        .unwrap();
221    let ref_uris: Vec<_> = refs
222        .as_array()
223        .unwrap()
224        .iter()
225        .map(|location| location["uri"].as_str().unwrap())
226        .collect();
227    assert!(ref_uris.contains(&server.uri("one.ts").as_str()), "{refs}");
228    assert!(ref_uris.contains(&server.uri("a.ts").as_str()), "{refs}");
229    assert!(!ref_uris.contains(&server.uri("two.ts").as_str()), "{refs}");
230    assert!(!ref_uris.contains(&server.uri("b.ts").as_str()), "{refs}");
231    let cross_project_rename = server
232        .call(
233            "textDocument/rename",
234            json!({"textDocument":{"uri":server.uri("one.ts")},"position":position_at(&one_text, one_text.find("value").unwrap() + 1),"newName":"renamedValue"}),
235        )
236        .unwrap();
237    let changed_uris: Vec<_> = cross_project_rename["changes"]
238        .as_object()
239        .expect("rename changes")
240        .keys()
241        .map(String::as_str)
242        .collect();
243    assert!(
244        changed_uris.contains(&server.uri("one.ts").as_str()),
245        "{cross_project_rename}"
246    );
247    assert!(
248        changed_uris.contains(&server.uri("a.ts").as_str()),
249        "{cross_project_rename}"
250    );
251    assert!(
252        !changed_uris.contains(&server.uri("two.ts").as_str()),
253        "{cross_project_rename}"
254    );
255    assert!(
256        !changed_uris.contains(&server.uri("b.ts").as_str()),
257        "{cross_project_rename}"
258    );
259    let highlights = server
260        .call("textDocument/documentHighlight", params.clone())
261        .unwrap();
262    assert!(!highlights.as_array().unwrap().is_empty(), "{highlights}");
263
264    let rename = server
265        .call(
266            "textDocument/prepareRename",
267            json!({"textDocument":{"uri":uri},"position":position_at(&source, square + 2)}),
268        )
269        .unwrap();
270    assert!(
271        !rename.is_null(),
272        "prepareRename should return the native rename range"
273    );
274    let edit = server
275        .call(
276            "textDocument/rename",
277            json!({"textDocument":{"uri":uri},"position":position_at(&source, square + 2),"newName":"measure"}),
278        )
279        .unwrap();
280    assert!(
281        edit["changes"]
282            .as_object()
283            .is_some_and(|changes| !changes.is_empty())
284            || edit["documentChanges"]
285                .as_array()
286                .is_some_and(|changes| !changes.is_empty()),
287        "{edit}"
288    );
289    let readonly_rename = server.request(
290        "textDocument/rename",
291        json!({
292            "textDocument":{"uri":uri},
293            "position":position_at(&source, source.rfind("args").unwrap() + 1),
294            "newName":"arguments"
295        }),
296    );
297    assert!(
298        readonly_rename.get("error").is_some(),
299        "Read-only builtin rename must fail atomically: {readonly_rename}"
300    );
301
302    let symbols = server
303        .call(
304            "textDocument/documentSymbol",
305            json!({"textDocument":{"uri":uri}}),
306        )
307        .unwrap();
308    assert!(
309        symbols
310            .as_array()
311            .is_some_and(|items| items.iter().any(|item| item["name"] == "Square")),
312        "{symbols}"
313    );
314    let workspace_symbols = server
315        .call("workspace/symbol", json!({"query":"LargeSquare"}))
316        .unwrap();
317    assert!(
318        workspace_symbols
319            .as_array()
320            .is_some_and(|items| items.iter().any(|item| item["name"] == "LargeSquare")),
321        "{workspace_symbols}"
322    );
323    let folding = server
324        .call(
325            "textDocument/foldingRange",
326            json!({"textDocument":{"uri":uri}}),
327        )
328        .unwrap();
329    assert!(!folding.as_array().unwrap().is_empty(), "{folding}");
330    let selections = server
331        .call(
332            "textDocument/selectionRange",
333            json!({"textDocument":{"uri":uri},"positions":[position_at(&source, square + 2)]}),
334        )
335        .unwrap();
336    assert_eq!(selections.as_array().unwrap().len(), 1);
337
338    let full_range =
339        json!({"start":position_at(&source, 0),"end":position_at(&source, source.len())});
340    let formatting = server
341        .call(
342            "textDocument/formatting",
343            json!({"textDocument":{"uri":uri},"options":{"tabSize":2,"insertSpaces":true}}),
344        )
345        .unwrap();
346    assert!(
347        formatting.as_array().is_some_and(|edits| !edits.is_empty()),
348        "{formatting}"
349    );
350    let range_formatting = server
351        .call(
352            "textDocument/rangeFormatting",
353            json!({
354                "textDocument":{"uri":uri},
355                "range":full_range,
356                "options":{"tabSize":2,"insertSpaces":true}
357            }),
358        )
359        .unwrap();
360    assert!(range_formatting.as_array().is_some(), "{range_formatting}");
361    let semantic = server
362        .call(
363            "textDocument/semanticTokens/full",
364            json!({"textDocument":{"uri":uri}}),
365        )
366        .unwrap();
367    assert!(
368        semantic["data"]
369            .as_array()
370            .is_some_and(|data| !data.is_empty()),
371        "{semantic}"
372    );
373    let ranged = server
374        .call(
375            "textDocument/semanticTokens/range",
376            json!({"textDocument":{"uri":uri},"range":full_range}),
377        )
378        .unwrap();
379    assert!(!ranged["data"].as_array().unwrap().is_empty(), "{ranged}");
380    let hints = server
381        .call(
382            "textDocument/inlayHint",
383            json!({"textDocument":{"uri":uri},"range":full_range}),
384        )
385        .unwrap();
386    assert!(
387        hints.as_array().is_some_and(|hints| !hints.is_empty()),
388        "Configured native inlay preferences should produce hints: {hints}"
389    );
390    let actions = server
391        .call(
392            "textDocument/codeAction",
393            json!({"textDocument":{"uri":uri},"range":full_range,"context":{"diagnostics":[]}}),
394        )
395        .unwrap();
396    assert!(actions.as_array().is_some(), "{actions}");
397
398    let unsupported_hierarchy = server.request(
399        "textDocument/prepareTypeHierarchy",
400        json!({"textDocument":{"uri":uri},"position":position_at(&source, source.find("Square implements").unwrap() + 2)}),
401    );
402    assert_eq!(
403        unsupported_hierarchy["error"]["code"],
404        json!(-32601),
405        "Pinned TypeScript has protocol types but no type-hierarchy implementation: {unsupported_hierarchy}"
406    );
407
408    let linked_source = "const same = <div></div>;";
409    server.change("main.tsx", linked_source, 3);
410    let linked = server
411        .call(
412            "textDocument/linkedEditingRange",
413            json!({"textDocument":{"uri":uri},"position":position_at(linked_source, linked_source.find("div").unwrap() + 1)}),
414        )
415        .unwrap();
416    assert!(
417        linked["ranges"]
418            .as_array()
419            .is_some_and(|ranges| ranges.len() >= 2),
420        "{linked}"
421    );
422}