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
19.9 KiB589 linesraw
1#[path = "common/http.rs"]
2#[allow(dead_code)]
3mod http;
4#[path = "common/lsp.rs"]
5#[allow(dead_code)]
6mod support;
7
8use std::fs;
9use std::process::Command;
10
11use serde_json::json;
12use sha2::Digest;
13use support::{Server, avril, backend, files};
14
15fn hover_contains(
16    server: &mut Server,
17    path: &str,
18    text: &str,
19    symbol: &str,
20    expected: &str,
21) -> bool {
22    server.hover(path, text, symbol).contains(expected)
23}
24
25#[test]
26fn live_avril_resolution_unsaved_graphs_and_disk_config_invalidation() {
27    let Some(backend) = backend() else {
28        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
29        return;
30    };
31    let dir = tempfile::tempdir().unwrap();
32    let root = dir.path();
33    let main = "import {value} from \"alias\"; import {process} from \"avril:std\"; const checked: number = value; value; process.args; throw Error(\"must not execute\");";
34    let mut config = json!({
35        "imports": {"alias": "./dep.ts"},
36        "scopes": {
37            "./a.ts": {"scoped": "./one.ts"},
38            "./b.ts": {"scoped": "./two.ts"}
39        }
40    });
41    files(
42        root,
43        &[
44            ("avril.conf", &config.to_string()),
45            ("main.ts", main),
46            ("dep.ts", "export const value = 42 as const;"),
47            ("other.ts", "export const value = 99 as const;"),
48            ("a.ts", "import {value} from \"scoped\"; value;"),
49            ("b.ts", "import {value} from \"scoped\"; value;"),
50            ("one.ts", "export const value = 1 as const;"),
51            ("two.ts", "export const value = 2 as const;"),
52            (
53                "types.js",
54                "// @ts-self-types=\"./decl.d.ts\"\nexport const declared = \"wrong\";",
55            ),
56            ("decl.d.ts", "export declare const declared: 123;"),
57            (
58                "lib/avril.conf",
59                "name:'@demo/lib', exports:{'.':'./mod.ts'}, imports:{alias:'./private.ts'}",
60            ),
61            ("lib/mod.ts", "export {value} from \"alias\";"),
62            ("lib/private.ts", "export const value = 17 as const;"),
63        ],
64    );
65    let mut server = Server::start(root, &backend, &["--offline"]);
66    server.initialize();
67    server.open("main.ts", main);
68
69    // Local aliases and embedded builtins.
70    assert!(hover_contains(
71        &mut server,
72        "main.ts",
73        main,
74        "value",
75        "value: 42"
76    ));
77    let links = server.definitions("main.ts", main, "value");
78    assert_eq!(links[0]["targetUri"], json!(server.uri("dep.ts")));
79    let links = server.definitions("main.ts", main, "args");
80    assert!(
81        links[0]["targetUri"]
82            .as_str()
83            .unwrap()
84            .starts_with("avril-builtin:")
85    );
86
87    // Unsaved dependency edits and version rejection.
88    server.open("dep.ts", "export const value = \"edited\" as const;");
89    assert!(hover_contains(
90        &mut server,
91        "main.ts",
92        main,
93        "value",
94        "value: \"edited\""
95    ));
96    assert!(
97        server
98            .diagnostics("main.ts")
99            .iter()
100            .any(|d| d["code"] == json!(2322))
101    );
102    server.change("dep.ts", "export const value = 0;", 0);
103    assert!(hover_contains(
104        &mut server,
105        "main.ts",
106        main,
107        "value",
108        "value: \"edited\""
109    ));
110    server.notify(
111        "textDocument/didClose",
112        json!({"textDocument": {"uri": server.uri("dep.ts")}}),
113    );
114    assert!(hover_contains(
115        &mut server,
116        "main.ts",
117        main,
118        "value",
119        "value: 42"
120    ));
121
122    // New unsaved imports grow the graph and follow declaration redirects.
123    let text = "import {declared} from \"./types.js\"; declared;";
124    server.change("main.ts", text, 2);
125    assert!(hover_contains(
126        &mut server,
127        "main.ts",
128        text,
129        "declared",
130        "declared: 123"
131    ));
132    let links = server.definitions("main.ts", text, "declared");
133    assert_eq!(links[0]["targetUri"], json!(server.uri("decl.d.ts")));
134    server.change("main.ts", main, 3);
135
136    // Plain files retain avril module boundaries and top-level await.
137    for value in [1, 2] {
138        let path = format!("plain{value}.ts");
139        let text = format!("const value = {value}; await Promise.resolve(value); value;");
140        files(root, &[(&path, &text)]);
141        server.open(&path, &text);
142        assert!(hover_contains(
143            &mut server,
144            &path,
145            &text,
146            "value",
147            &format!("value: {value}")
148        ));
149        assert!(server.diagnostics(&path).is_empty());
150    }
151
152    // Exact per-referrer scopes.
153    for (path, value) in [("a.ts", 1), ("b.ts", 2)] {
154        let text = fs::read_to_string(root.join(path)).unwrap();
155        server.open(path, &text);
156        assert!(hover_contains(
157            &mut server,
158            path,
159            &text,
160            "value",
161            &format!("value: {value}")
162        ));
163    }
164
165    // On-disk dependency and configuration changes need no snapshot regeneration.
166    fs::write(root.join("dep.ts"), "export const value = 43 as const;").unwrap();
167    server.eventually(|s| hover_contains(s, "main.ts", main, "value", "value: 43"));
168    config["imports"]["alias"] = json!("./other.ts");
169    fs::write(root.join("avril.conf"), config.to_string()).unwrap();
170    server.eventually(|s| hover_contains(s, "main.ts", main, "value", "value: 99"));
171
172    // Local package exports and private maps.
173    fs::write(
174        root.join("avril.conf"),
175        "deps:[{from:'./lib',name:'library'}]",
176    )
177    .unwrap();
178    let text = "import {value} from \"library\"; value;";
179    server.change("main.ts", text, 4);
180    assert!(hover_contains(
181        &mut server,
182        "main.ts",
183        text,
184        "value",
185        "value: 17"
186    ));
187    let links = server.definitions("main.ts", text, "value");
188    assert_eq!(links[0]["targetUri"], json!(server.uri("lib/private.ts")));
189
190    // Bad configuration fails closed and then recovers.
191    fs::write(root.join("avril.conf"), "imports: {").unwrap();
192    server.notify("workspace/didChangeWatchedFiles", json!({"changes": []}));
193    let text = "import {value} from \"alias\"; value;";
194    server.change("main.ts", text, 5);
195    let links = server.definitions("main.ts", text, "value");
196    assert_eq!(links.len(), 1, "{links:?}");
197    assert_eq!(links[0]["targetUri"], server.uri("main.ts"));
198    assert_eq!(
199        support::selected(text, &links[0]["targetSelectionRange"]),
200        "value"
201    );
202    assert!(
203        server
204            .diagnostics("main.ts")
205            .iter()
206            .any(|d| d["message"].as_str().unwrap().contains("avril.conf"))
207    );
208    fs::write(root.join("avril.conf"), config.to_string()).unwrap();
209    server.eventually(|s| hover_contains(s, "main.ts", text, "value", "value: 99"));
210    assert!(
211        !root.join(".avril").exists(),
212        "No editor snapshot directory"
213    );
214    assert!(
215        !root.join("avril.lock").exists(),
216        "Editor must not write lockfiles"
217    );
218    assert!(
219        !root.join("tsconfig.json").exists(),
220        "Editor must not create tsconfig files"
221    );
222    server.close();
223}
224
225#[test]
226fn ignore_files_allow_startup_and_live_rule_changes() {
227    let Some(backend) = backend() else {
228        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
229        return;
230    };
231    let dir = tempfile::tempdir().unwrap();
232    let root = dir.path();
233    let main = "export const value = 42;";
234    files(
235        root,
236        &[
237            ("main.ts", main),
238            (".gitignore", "git-ignored.ts\n"),
239            (".ignore", "ignored.ts\n"),
240            ("git-ignored.ts", "export const gitIgnoredValue = 1;"),
241            ("ignored.ts", "export const ignoredValue = 2;"),
242        ],
243    );
244    let mut server = Server::start(root, &backend, &["--offline"]);
245    server.initialize();
246    server.open("main.ts", main);
247    for ignore in [".gitignore", ".ignore"] {
248        fs::read_to_string(root.join(ignore)).unwrap();
249    }
250    assert!(server.hover("main.ts", main, "value").contains("value: 42"));
251    let symbols = server
252        .call("workspace/symbol", json!({"query": "IgnoredValue"}))
253        .unwrap();
254    assert_eq!(symbols, json!([]));
255
256    for (ignore, symbol) in [
257        (".gitignore", "gitIgnoredValue"),
258        (".ignore", "ignoredValue"),
259    ] {
260        fs::write(root.join(ignore), "\n").unwrap();
261        server.eventually(|s| {
262            let symbols = s
263                .call("workspace/symbol", json!({"query": symbol}))
264                .unwrap();
265            symbols
266                .as_array()
267                .unwrap()
268                .iter()
269                .any(|s| s["name"] == symbol)
270        });
271    }
272    server.close();
273}
274
275#[test]
276fn live_project_enforces_js_exec_authority_for_overlaid_documents() {
277    let Some(backend) = backend() else {
278        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
279        return;
280    };
281    let dir = tempfile::tempdir().unwrap();
282    let root = dir.path();
283    files(root, &[("main.ts", "export const value = 42;")]);
284    let mut server = Server::start(root, &backend, &["-0", "--offline"]);
285    server.initialize();
286    let text = "export const value = 99; value;";
287    server.open("main.ts", text);
288    let error = server
289        .call(
290            "textDocument/hover",
291            json!({
292                "textDocument":{"uri":server.uri("main.ts")},
293                "position":support::position_at(text, text.rfind("value").unwrap()),
294            }),
295        )
296        .unwrap_err();
297    assert_eq!(error.code, -32602, "{error:?}");
298    assert!(
299        server
300            .diagnostics("main.ts")
301            .iter()
302            .any(|d| d["message"].as_str().unwrap().contains("PermissionDenied"))
303    );
304    server.close();
305}
306
307#[test]
308fn live_jsx_programs_honor_scoped_configuration_and_external_extends() {
309    let Some(backend) = backend() else {
310        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
311        return;
312    };
313    let dir = tempfile::tempdir().unwrap();
314    let root = dir.path();
315    let external = tempfile::tempdir().unwrap();
316    let jsx = "export namespace JSX { interface Element {} interface IntrinsicElements { panel: {count:number} } } export declare function jsx(...args:unknown[]): JSX.Element; export declare const jsxs:typeof jsx;";
317    let main = "export const view = <panel count={1}/>; view;";
318    files(
319        root,
320        &[
321            ("avril.conf", "imports:{'ui/jsx-runtime':'./runtime.d.ts'}"),
322            (
323                "tsconfig.json",
324                &json!({"extends": external.path().join("base.json").to_str().unwrap()})
325                    .to_string(),
326            ),
327            ("main.tsx", main),
328            ("runtime.d.ts", jsx),
329            (
330                "classic/tsconfig.json",
331                &json!({"compilerOptions": {"jsx": "react", "jsxFactory": "h"}}).to_string(),
332            ),
333            (
334                "classic/main.tsx",
335                "import {view as imported} from \"../main.tsx\"; declare function h(...args:unknown[]):unknown; const view = <panel/>; view; imported;",
336            ),
337        ],
338    );
339    let base = |source: &str| {
340        fs::write(
341            external.path().join("base.json"),
342            json!({"compilerOptions": {"jsx": "react-jsx", "jsxImportSource": source}}).to_string(),
343        )
344        .unwrap();
345    };
346    base("ui");
347    let mut server = Server::start(root, &backend, &["--offline"]);
348    server.initialize();
349    server.open("main.tsx", main);
350    server.hover("main.tsx", main, "view");
351    assert!(
352        !server
353            .diagnostics("main.tsx")
354            .iter()
355            .any(|d| d["code"] == json!(2875) || d["code"] == json!(2307))
356    );
357    let classic = fs::read_to_string(root.join("classic/main.tsx")).unwrap();
358    server.open("classic/main.tsx", &classic);
359    assert!(server.hover("main.tsx", main, "view").contains("Element"));
360    server.hover("classic/main.tsx", &classic, "view");
361    let links = server.definitions("classic/main.tsx", &classic, "imported");
362    assert_eq!(links[0]["targetUri"], json!(server.uri("main.tsx")));
363    assert!(!server.diagnostics("classic/main.tsx").iter().any(|d| {
364        [2875, 2307, 2874]
365            .iter()
366            .any(|code| d["code"] == json!(code))
367    }));
368    base("missing-ui");
369    server.eventually(|s| {
370        s.hover("main.tsx", main, "view");
371        s.diagnostics("main.tsx")
372            .iter()
373            .any(|d| d["code"] == json!(2875) || d["code"] == json!(2307))
374    });
375    base("ui");
376    server.eventually(|s| s.hover("main.tsx", main, "view").contains("Element"));
377    base("missing-again");
378    server.eventually(|s| {
379        s.hover("main.tsx", main, "view");
380        s.diagnostics("main.tsx")
381            .iter()
382            .any(|d| d["code"] == json!(2875) || d["code"] == json!(2307))
383    });
384    server.close();
385}
386
387#[test]
388fn live_jsr_resolution_shares_cache_lock_vendor_integrity_and_acquisition() {
389    let Some(backend) = backend() else {
390        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
391        return;
392    };
393    let dir = tempfile::tempdir().unwrap();
394    let root = dir.path();
395    let registry = http::Http::new();
396    let sources = [
397        (
398            "mod.js",
399            "// @ts-self-types=\"./mod.d.ts\"\nexport const answer = 123;",
400        ),
401        ("mod.d.ts", "export declare const answer: 123;"),
402    ];
403    let mut manifest = serde_json::Map::new();
404    for (name, text) in sources {
405        registry.source(&format!("@demo/data/1.0.0/{name}"), text);
406        let hash = format!("{:x}", sha2::Sha256::digest(text.as_bytes()));
407        manifest.insert(
408            format!("/{name}"),
409            json!({"size": text.len(), "checksum": format!("sha256-{hash}")}),
410        );
411    }
412    registry.source(
413        "@demo/data/meta.json",
414        &json!({"versions": {"1.0.0": {}}}).to_string(),
415    );
416    registry.source(
417        "@demo/data/1.0.0_meta.json",
418        &json!({"manifest": manifest, "exports": {".": "./mod.js"}, "moduleGraph2": {}})
419            .to_string(),
420    );
421    let main = "import {answer} from \"data\"; answer;";
422    files(
423        root,
424        &[
425            ("avril.conf", "imports:{data:'jsr:@demo/data@1'}"),
426            ("main.ts", main),
427        ],
428    );
429    let flags = [
430        format!("--jsr-registry={}", registry.url),
431        format!("--cache-dir={}", root.join("cache").display()),
432    ];
433    let flag_refs: Vec<&str> = flags.iter().map(String::as_str).collect();
434    // Only explicit runtime preparation commands may write the lock.
435    let cached = Command::new(avril())
436        .current_dir(root)
437        .args(["cache"])
438        .args(&flag_refs)
439        .arg("main.ts")
440        .output()
441        .unwrap();
442    assert!(
443        cached.status.success(),
444        "{}",
445        String::from_utf8_lossy(&cached.stderr)
446    );
447    let lock = fs::read_to_string(root.join("avril.lock")).unwrap();
448
449    for mode in ["online", "offline", "vendor"] {
450        if mode == "vendor" {
451            let vendored = Command::new(avril())
452                .current_dir(root)
453                .args(["vendor"])
454                .args(&flag_refs)
455                .args(["--offline", "--frozen"])
456                .arg(format!("--out={}", root.join("vendor").display()))
457                .arg("main.ts")
458                .output()
459                .unwrap();
460            assert!(
461                vendored.status.success(),
462                "{}",
463                String::from_utf8_lossy(&vendored.stderr)
464            );
465            // Runtime vendoring covers executable inputs; supply the type artifact too.
466            files(root, &[("vendor/@demo/data/1.0.0/mod.d.ts", sources[1].1)]);
467        }
468        let before = registry.requests.lock().unwrap().len();
469        let mut options: Vec<String> = flags.to_vec();
470        options.push("--frozen".into());
471        if mode != "online" {
472            options.push("--offline".into());
473        }
474        if mode == "vendor" {
475            options.push(format!("--vendor={}", root.join("vendor").display()));
476            options.push(format!(
477                "--cache-dir={}",
478                root.join("empty-cache").display()
479            ));
480        }
481        let option_refs: Vec<&str> = options.iter().map(String::as_str).collect();
482        let mut server = Server::start(root, &backend, &option_refs);
483        server.initialize();
484        server.open("main.ts", main);
485        assert!(hover_contains(
486            &mut server,
487            "main.ts",
488            main,
489            "answer",
490            "answer: 123"
491        ));
492        let links = server.definitions("main.ts", main, "answer");
493        let target = links[0]["targetUri"].as_str().unwrap().to_owned();
494        assert!(
495            target.starts_with("avril-http:") && target.ends_with("mod.d.ts"),
496            "{target}"
497        );
498        let remote = server
499            .call("avril/readDocument", json!({"uri": target}))
500            .unwrap()
501            .as_str()
502            .unwrap()
503            .to_owned();
504        assert!(remote.contains("answer: 123"));
505        assert_eq!(
506            server
507                .call(
508                    "avril/readDocument",
509                    json!({"uri": target.replace("%40", "@")})
510                )
511                .unwrap()
512                .as_str()
513                .unwrap(),
514            remote
515        );
516        assert!(
517            server
518                .hover_uri(&target, &remote, "answer")
519                .contains("answer: 123")
520        );
521        if mode == "vendor" {
522            let path = root.join("vendor/@demo/data/1.0.0/mod.d.ts");
523            fs::write(&path, "export declare const answer: 999;").unwrap();
524            server.eventually(|s| {
525                s.hover("main.ts", main, "answer");
526                s.diagnostics_uri(&target).iter().any(|d| {
527                    let message = d["message"].as_str().unwrap().to_lowercase();
528                    message.contains("integrity")
529                        || message.contains("checksum")
530                        || message.contains("size")
531                })
532            });
533            let links = server.definitions("main.ts", main, "answer");
534            assert_eq!(links.len(), 1, "{links:?}");
535            // The verified JavaScript source remains navigable; the corrupted
536            // declaration must never supply the target or its text.
537            assert_eq!(links[0]["targetUri"], target.replace("mod.d.ts", "mod.js"));
538            assert_eq!(
539                support::selected(sources[0].1, &links[0]["targetSelectionRange"]),
540                "answer"
541            );
542            fs::write(&path, sources[1].1).unwrap();
543            server.eventually(|s| {
544                let links = s.definitions("main.ts", main, "answer");
545                links[0]["targetUri"] == json!(target) && s.diagnostics_uri(&target).is_empty()
546            });
547        }
548        assert_eq!(fs::read_to_string(root.join("avril.lock")).unwrap(), lock);
549        if mode != "online" {
550            assert_eq!(
551                registry.requests.lock().unwrap().len(),
552                before,
553                "Offline language tooling made network requests"
554            );
555        }
556        server.close();
557    }
558
559    let before = registry.requests.lock().unwrap().len();
560    let denied_options = [
561        flag_refs[0],
562        flag_refs[1],
563        &format!("--deny-js-exec={}", registry.url),
564    ];
565    let mut denied = Server::start(root, &backend, &denied_options);
566    denied.initialize();
567    denied.open("main.ts", main);
568    denied.hover("main.ts", main, "answer");
569    assert!(
570        denied
571            .diagnostics("main.ts")
572            .iter()
573            .any(|d| d["message"].as_str().unwrap().contains("PermissionDenied"))
574    );
575    let links = denied.definitions("main.ts", main, "answer");
576    assert_eq!(links.len(), 1, "{links:?}");
577    assert_eq!(links[0]["targetUri"], denied.uri("main.ts"));
578    assert_eq!(
579        support::selected(main, &links[0]["targetSelectionRange"]),
580        "answer"
581    );
582    assert_eq!(
583        registry.requests.lock().unwrap().len(),
584        before,
585        "Denied resolution made registry requests"
586    );
587    assert_eq!(fs::read_to_string(root.join("avril.lock")).unwrap(), lock);
588    denied.close();
589}