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
10.5 KiB309 linesraw
1#[path = "common/lsp.rs"]
2#[allow(dead_code)]
3mod support;
4
5use serde_json::{Value, json};
6use support::{Server, backend, files};
7
8fn items(result: &Value) -> &[Value] {
9    assert!(
10        !result.to_string().contains("/avril-lsp/"),
11        "Completion leaked a compiler-internal filename: {result}"
12    );
13    result["items"].as_array().expect("CompletionList.items")
14}
15
16fn find<'a>(items: &'a [Value], label: &str) -> &'a Value {
17    items
18        .iter()
19        .find(|item| item["label"] == label)
20        .unwrap_or_else(|| panic!("Missing completion {label}: {items:?}"))
21}
22
23fn labels(items: &[Value]) -> Vec<&str> {
24    items
25        .iter()
26        .map(|item| item["label"].as_str().unwrap())
27        .collect()
28}
29
30fn has_no_items(result: &Value) -> bool {
31    result.is_null()
32        || result.as_array().is_some_and(|items| items.is_empty())
33        || result["items"]
34            .as_array()
35            .is_some_and(|items| items.is_empty())
36}
37
38#[test]
39fn native_completions_resolve_and_follow_the_live_project_graph() {
40    let Some(backend) = backend() else {
41        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
42        return;
43    };
44    let dir = tempfile::tempdir().unwrap();
45    let root = dir.path();
46    let main = [
47        "import { record } from \"alias\";",
48        "import { process } from \"avril:std\";",
49        "import \"./a.ts\";",
50        "import \"./b.ts\";",
51        "import \"./second.ts\";",
52        "import \"./auto.ts\";",
53        "import \"./named.ts\";",
54        "import \"./astral.ts\";",
55        "import \"./dangle.ts\";",
56        "import \"./fsimp.ts\";",
57        "import \"./element.ts\";",
58        "import \"./union.ts\";",
59        "import \"./tmpl.ts\";",
60        "const localName = 1;",
61        "record.hello;",
62        "process.args;",
63        "loc;",
64    ]
65    .join("\n");
66    files(
67        root,
68        &[
69            ("main.ts", &main),
70            (
71                "avril.conf",
72                &json!({
73                    "imports": {"alias": "./dep.ts"},
74                    "scopes": {
75                        "./a.ts": {"scoped": "./one.ts"},
76                        "./b.ts": {"scoped": "./two.ts"}
77                    }
78                })
79                .to_string(),
80            ),
81            (
82                "dep.ts",
83                "export const record: { hello: number; extra?: number; \"not-valid\": number } = { hello: 1, \"not-valid\": 2 };",
84            ),
85            ("one.ts", "export const value = { aaa: 1 } as const;"),
86            ("two.ts", "export const value = { bbb: 2 } as const;"),
87            ("a.ts", "import {value} from \"scoped\"; value.aaa;"),
88            ("b.ts", "import {value} from \"scoped\"; value.bbb;"),
89            ("second.ts", "export const farAwayName = 1;"),
90            ("dangle.ts", "import { record } from \"alias\";\nrecord."),
91            ("fsimp.ts", "import { read } from \"avril:fs\";"),
92            (
93                "element.ts",
94                "import { record } from \"alias\";\nrecord[\"\"]",
95            ),
96            (
97                "union.ts",
98                "declare function take(flag: \"alpha\" | \"beta\"): void;\ntake(\"\");",
99            ),
100            (
101                "tmpl.ts",
102                "import { record } from \"alias\";\nconst view = `${record.}`;",
103            ),
104            ("auto.ts", "import { record } from \"alias\";\nfar;\n"),
105            ("named.ts", "import {  } from \"alias\";\n"),
106            ("astral.ts", "const placeholder = 0;"),
107        ],
108    );
109    let mut server = Server::start(root, &backend, &["--offline"]);
110    server.initialize_with(json!({
111        "textDocument": {
112            "completion": {
113                "completionItem": {
114                    "snippetSupport": true,
115                    "insertReplaceSupport": true
116                }
117            }
118        }
119    }));
120    server.open("main.ts", &main);
121
122    let offset = main.find("record.hello;").unwrap() + "record.".len();
123    let result = server.complete("main.ts", &main, offset, None);
124    let members = items(&result);
125    let hello = find(members, "hello");
126    assert_eq!(hello["kind"], json!(5));
127    let optional = find(members, "extra?");
128    assert_eq!(optional["filterText"], "extra");
129    let triggered = server.complete("main.ts", &main, offset, Some("."));
130    assert!(labels(items(&triggered)).contains(&"hello"));
131
132    let resolved = server
133        .call("completionItem/resolve", hello.clone())
134        .unwrap();
135    assert_eq!(resolved["label"], "hello");
136    assert!(
137        resolved.get("detail").is_some(),
138        "Native completion resolve should add detail: {resolved}"
139    );
140    let stale = hello.clone();
141    let edited = "export const record: { hello: number; extra?: number; added: boolean } = { hello: 1, added: true };";
142    server.open("dep.ts", edited);
143    let updated_result = server.complete("main.ts", &main, offset, None);
144    let updated = items(&updated_result);
145    assert!(labels(updated).contains(&"added"));
146    let stale = server.request("completionItem/resolve", stale);
147    assert_eq!(stale["error"]["code"], json!(-32801), "{stale}");
148    server.notify(
149        "textDocument/didClose",
150        json!({"textDocument": {"uri": server.uri("dep.ts")}}),
151    );
152    assert!(!labels(items(&server.complete("main.ts", &main, offset, None))).contains(&"added"));
153
154    let builtin_result = server.complete(
155        "main.ts",
156        &main,
157        main.find("process.args;").unwrap() + "process.".len(),
158        None,
159    );
160    let builtin = items(&builtin_result);
161    assert!(labels(builtin).contains(&"args"));
162    assert!(labels(builtin).contains(&"cwd"));
163
164    for (path, present, absent) in [("a.ts", "aaa", "bbb"), ("b.ts", "bbb", "aaa")] {
165        let text = std::fs::read_to_string(root.join(path)).unwrap();
166        server.open(path, &text);
167        let result = server.complete(
168            path,
169            &text,
170            text.find("value.").unwrap() + "value.".len(),
171            None,
172        );
173        assert!(labels(items(&result)).contains(&present));
174        assert!(!labels(items(&result)).contains(&absent));
175    }
176
177    let astral = "const localName = 1;\r\nconst e = \"😀\"; loc;\r\n";
178    server.open("astral.ts", astral);
179    let result = server.complete(
180        "astral.ts",
181        astral,
182        astral.find("loc;").unwrap() + "loc".len(),
183        None,
184    );
185    let local = find(items(&result), "localName");
186    assert_eq!(
187        local["textEdit"]["insert"]["start"]["line"],
188        json!(1),
189        "{local}"
190    );
191    assert_eq!(local["textEdit"]["insert"]["start"]["character"], json!(16));
192    assert_eq!(local["textEdit"]["replace"]["end"]["character"], json!(19));
193
194    server.open("second.ts", "export const farAwayName = 1;");
195    let auto = "import { record } from \"alias\";\nfar;\n";
196    server.open("auto.ts", auto);
197    let result = server.complete("auto.ts", auto, auto.find("far;").unwrap() + 3, None);
198    let imported = find(items(&result), "farAwayName");
199    let resolved_import = server
200        .call("completionItem/resolve", imported.clone())
201        .unwrap();
202    assert!(
203        !resolved_import.to_string().contains("/avril-lsp/"),
204        "Resolved auto-import leaked a synthetic source: {resolved_import}"
205    );
206    let import_edits = resolved_import["additionalTextEdits"]
207        .as_array()
208        .expect("Auto-import edits should be attached during completion resolve");
209    assert!(
210        import_edits.iter().any(|edit| {
211            edit["newText"]
212                .as_str()
213                .is_some_and(|text| text.contains("farAwayName") && text.contains("./second.ts"))
214        }),
215        "Resolved auto-import should use the graph's canonical specifier ./second.ts: {resolved_import}"
216    );
217
218    let named = "import {  } from \"alias\";\n";
219    server.open("named.ts", named);
220    let import_result = server.complete("named.ts", named, "import { ".len(), None);
221    let imports = items(&import_result);
222    assert!(labels(imports).contains(&"record"));
223    let fs_import = "import { read } from \"avril:fs\";";
224    server.open("fsimp.ts", fs_import);
225    let result = server.complete(
226        "fsimp.ts",
227        fs_import,
228        fs_import.find("read }").unwrap() + "read".len(),
229        None,
230    );
231    assert!(labels(items(&result)).contains(&"readTextFile"));
232
233    let element = "import { record } from \"alias\";\nrecord[\"\"];";
234    server.open("element.ts", element);
235    let result = server.complete(
236        "element.ts",
237        element,
238        element.find("\"\"").unwrap() + 1,
239        None,
240    );
241    assert!(
242        labels(items(&result))
243            .iter()
244            .any(|label| label.contains("not-valid")),
245        "String-literal property completion should preserve non-identifier keys"
246    );
247
248    let union = "declare function take(flag: \"alpha\" | \"beta\"): void;\ntake(\"\");";
249    server.open("union.ts", union);
250    let result = server.complete("union.ts", union, union.find("\"\"").unwrap() + 1, None);
251    assert!(
252        labels(items(&result))
253            .iter()
254            .any(|label| label.contains("alpha")),
255        "Native literal-union completion should expose its allowed values"
256    );
257
258    let template = "import { record } from \"alias\";\nconst view = `${record.}`;";
259    server.open("tmpl.ts", template);
260    let result = server.complete(
261        "tmpl.ts",
262        template,
263        template.find("record.").unwrap() + "record.".len(),
264        None,
265    );
266    assert!(labels(items(&result)).contains(&"hello"));
267
268    let quiet = "// record.hello;\nconst s = \"record.hello;\";\n";
269    server.change("main.ts", quiet, 2);
270    assert!(has_no_items(&server.complete(
271        "main.ts",
272        quiet,
273        "// record.".len(),
274        None
275    )));
276    assert!(has_no_items(&server.complete(
277        "main.ts",
278        quiet,
279        quiet.find("\"record.").unwrap() + "\"record.".len(),
280        None
281    )));
282    let missing = server.request(
283        "textDocument/completion",
284        json!({
285            "textDocument": {"uri": server.uri("missing.ts")},
286            "position": {"line": 0, "character": 1}
287        }),
288    );
289    assert_eq!(missing["error"]["code"], json!(-32602), "{missing}");
290    assert!(
291        missing["error"]["message"]
292            .as_str()
293            .unwrap()
294            .contains("Document not in this project")
295    );
296
297    let dangling = "import { record } from \"alias\";\nrecord.";
298    server.open("dangle.ts", dangling);
299    assert!(
300        labels(items(&server.complete(
301            "dangle.ts",
302            dangling,
303            dangling.len(),
304            None
305        )))
306        .contains(&"hello")
307    );
308    server.close();
309}