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
8.9 KiB272 linesraw
1#[path = "common/lsp.rs"]
2#[allow(dead_code)]
3mod lsp_support;
4#[path = "common/registry.rs"]
5mod registry_support;
6
7use lsp_support::{Server, backend, files, position_at};
8use registry_support::Registry;
9use serde_json::{Value, json};
10use std::path::{Path, PathBuf};
11
12fn files_below(root: &Path) -> Vec<PathBuf> {
13    let mut found = Vec::new();
14    let mut pending = vec![root.to_owned()];
15    while let Some(directory) = pending.pop() {
16        let Ok(entries) = std::fs::read_dir(directory) else {
17            continue;
18        };
19        for entry in entries.flatten() {
20            let path = entry.path();
21            if path.is_dir() {
22                pending.push(path);
23            } else {
24                found.push(path);
25            }
26        }
27    }
28    found
29}
30
31#[test]
32fn malformed_continuations_and_empty_completions_leave_the_server_usable() {
33    let Some(backend) = backend() else {
34        return;
35    };
36    let dir = tempfile::tempdir().unwrap();
37    let source = "// comment\nconst value = 42;\nvalue;";
38    files(dir.path(), &[("main.ts", source)]);
39    let mut server = Server::start(dir.path(), &backend, &[]);
40    server.initialize();
41    server.open("main.ts", source);
42    for method in ["callHierarchy/incomingCalls", "callHierarchy/outgoingCalls"] {
43        for params in [json!([]), json!(42), Value::Null] {
44            let error = server.call(method, params).unwrap_err();
45            assert_eq!(error.code, -32602, "{error:?}");
46        }
47    }
48    let uri = server.uri("main.ts");
49    assert_eq!(
50        server
51            .call(
52                "textDocument/completion",
53                json!({
54                    "textDocument":{"uri":uri},"position":{"line":0,"character":3}
55                })
56            )
57            .unwrap(),
58        Value::Null
59    );
60    let error = server
61        .call(
62            "textDocument/hover",
63            json!({
64                "textDocument":{"uri":uri},"position":{"line":-1,"character":0}
65            }),
66        )
67        .unwrap_err();
68    assert_eq!(error.code, -32602, "{error:?}");
69    assert!(server.hover("main.ts", source, "value").contains("42"));
70    server.close();
71}
72
73#[test]
74fn immutable_npm_sources_and_workspace_completions_respect_boundaries() {
75    let Some(backend) = backend() else {
76        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
77        return;
78    };
79    let registry = Registry::new();
80    registry.publish_npm(
81        "typed",
82        "1.0.0",
83        &[
84            (
85                "package.json",
86                r#"{"name":"typed","version":"1.0.0","types":"index.d.ts"}"#,
87            ),
88            ("index.d.ts", "export declare const answer: number;\n"),
89        ],
90    );
91    let dir = tempfile::tempdir().unwrap();
92    let root = dir.path();
93    let main = "import { answer } from \"typed\";\nconst local = answer;\nimport \"./\";\n";
94    files(
95        root,
96        &[
97            ("main.ts", main),
98            ("sibling.ts", "export const sibling = true;\n"),
99            ("my file.ts", "export const spacedExport = true;\n"),
100            ("café.ts", "export const unicodeExport = true;\n"),
101            (
102                "isolated/tsconfig.json",
103                r#"{"compilerOptions":{"strictNullChecks":false}}"#,
104            ),
105            ("isolated/export.ts", "export const scopedExport = true;\n"),
106            (
107                "avril.conf",
108                &json!({"imports":{"typed":"npm:typed@1.0.0"}}).to_string(),
109            ),
110        ],
111    );
112    let mut server = Server::start(
113        root,
114        &backend,
115        &[&format!("--npm-registry={}", registry.url)],
116    );
117    server.initialize();
118    server.open("main.ts", main);
119
120    let reference = main.find("answer;\nimport").unwrap();
121    let definition = server
122        .call(
123            "textDocument/definition",
124            json!({
125                "textDocument":{"uri":server.uri("main.ts")},
126                "position":position_at(main, reference + 1)
127            }),
128        )
129        .unwrap();
130    let declaration = definition
131        .as_array()
132        .and_then(|items| items.first())
133        .expect("type definition should resolve to the package declaration");
134    let declaration_uri = declaration
135        .get("targetUri")
136        .or_else(|| declaration.get("uri"))
137        .and_then(Value::as_str)
138        .unwrap_or_else(|| panic!("Missing definition URI: {definition}"));
139    assert!(
140        declaration_uri.starts_with("avril-builtin:/dependencies"),
141        "{declaration}"
142    );
143    let bytes = server
144        .call("avril/readDocument", json!({"uri":declaration_uri}))
145        .unwrap();
146    assert_eq!(bytes, json!("export declare const answer: number;\n"));
147
148    let rename = server.request(
149        "textDocument/rename",
150        json!({
151            "textDocument":{"uri":declaration_uri},
152            "position":position_at("export declare const answer: number;\n", "export declare const ".len() + 2),
153            "newName":"renamedAnswer"
154        }),
155    );
156    assert_eq!(rename["error"]["code"], json!(-32803), "{rename}");
157    assert!(
158        rename.get("result").is_none(),
159        "read-only rename must not partially edit workspace references: {rename}"
160    );
161
162    assert_eq!(
163        server
164            .call(
165                "textDocument/completion",
166                json!({
167                    "textDocument":{"uri":declaration_uri},"position":{"line":0,"character":0}
168                })
169            )
170            .unwrap(),
171        Value::Null
172    );
173    assert_eq!(server.call("textDocument/codeAction", json!({
174        "textDocument":{"uri":declaration_uri},"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}},
175        "context":{"diagnostics":[],"only":["quickfix"]}
176    })).unwrap(), json!([]));
177
178    let overlay = "export declare const poisoned: string;\n";
179    for uri in [
180        declaration_uri.to_owned(),
181        url::Url::from_file_path(
182            files_below(&root.join(".avril"))
183                .into_iter()
184                .find(|path| path.file_name().is_some_and(|name| name == "index.d.ts"))
185                .expect("materialized npm declaration"),
186        )
187        .unwrap()
188        .to_string(),
189    ] {
190        server.notify(
191            "textDocument/didOpen",
192            json!({"textDocument":{
193                "uri":uri,"languageId":"typescript","version":1,"text":overlay
194            }}),
195        );
196    }
197    let hover = server.call(
198        "textDocument/hover",
199        json!({"textDocument":{"uri":server.uri("main.ts")},"position":position_at(main, reference + 1)}),
200    ).unwrap();
201    assert!(
202        hover.to_string().contains("number"),
203        "trusted npm declaration should remain in effect: {hover}"
204    );
205    assert!(
206        !hover.to_string().contains("poisoned"),
207        "immutable overlays must be ignored: {hover}"
208    );
209    assert_eq!(
210        server
211            .call("avril/readDocument", json!({"uri":declaration_uri}))
212            .unwrap(),
213        bytes,
214        "opening either immutable URI must not replace trusted source bytes",
215    );
216
217    let start = main.find("\"./\"").unwrap() + 3;
218    let completion = server.call(
219        "textDocument/completion",
220        json!({"textDocument":{"uri":server.uri("main.ts")},"position":position_at(main, start)}),
221    ).unwrap();
222    let labels: Vec<_> = completion["items"]
223        .as_array()
224        .into_iter()
225        .flatten()
226        .filter_map(|item: &Value| item["label"].as_str())
227        .collect();
228    assert!(
229        labels.contains(&"sibling.ts"),
230        "unopened workspace sibling should be suggested: {completion}"
231    );
232    assert!(
233        labels.contains(&"my file.ts") && labels.contains(&"café.ts"),
234        "{completion}"
235    );
236    assert!(
237        !labels.iter().any(|label| label.parse::<u64>().is_ok()),
238        "compiler-internal numeric directories leaked: {completion}"
239    );
240    for (index, (symbol, specifier)) in [
241        ("spacedExport", "./my file.ts"),
242        ("unicodeExport", "./café.ts"),
243        ("scopedExport", "./isolated/export.ts"),
244    ]
245    .into_iter()
246    .enumerate()
247    {
248        let text = &symbol[..3];
249        server.change("main.ts", text, index as i64 + 2);
250        let completion = server.call("textDocument/completion", json!({
251            "textDocument":{"uri":server.uri("main.ts")}, "position":{"line":0,"character":3},
252        })).unwrap();
253        let item = completion["items"]
254            .as_array()
255            .unwrap()
256            .iter()
257            .find(|item| item["label"] == symbol)
258            .unwrap_or_else(|| panic!("Missing export {symbol}: {completion}"))
259            .clone();
260        let resolved = server.call("completionItem/resolve", item).unwrap();
261        assert!(
262            resolved["additionalTextEdits"]
263                .as_array()
264                .unwrap()
265                .iter()
266                .any(|edit| edit["newText"].as_str().unwrap().contains(specifier)),
267            "{resolved}"
268        );
269        assert!(!resolved.to_string().contains("/avril-lsp/"), "{resolved}");
270    }
271    server.close();
272}