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
21.5 KiB653 linesraw
1use super::*;
2use avril_runtime::caps::{Category, Initial};
3use std::fs;
4
5#[path = "../../tests/common/http.rs"]
6mod http;
7#[path = "../../tests/common/registry.rs"]
8mod registry;
9
10fn options(root: &Path) -> Options {
11    Options {
12        jsr_registry: url::Url::parse("https://example.invalid/").unwrap(),
13        npm_registry: url::Url::parse("https://example.invalid/").unwrap(),
14        cache: root.join("cache"),
15        lock: root.join("avril.lock"),
16        project_root: root.to_owned(),
17        offline: false,
18        frozen: false,
19        update: false,
20        ephemeral_lock: false,
21        vendor: None,
22    }
23}
24
25fn refresh(root: &Path, options: Options, entries: &[&str]) -> ProjectInput {
26    refresh_with_caps(
27        root,
28        options,
29        entries,
30        Capabilities::new(Initial::new(false), root.to_owned()),
31    )
32}
33
34fn refresh_with_caps(
35    root: &Path,
36    options: Options,
37    entries: &[&str],
38    caps: Arc<Capabilities>,
39) -> ProjectInput {
40    let mut project = LiveProject {
41        root: root.to_owned(),
42        caps,
43        options,
44        explicit_lock: false,
45        automatic_types: true,
46        dirty: Arc::new(AtomicBool::new(false)),
47        rewatch: Arc::new(AtomicBool::new(false)),
48        watcher: Some(notify::recommended_watcher(|_| {}).unwrap()),
49        watched: BTreeMap::new(),
50        tracked_config: Arc::new(Mutex::new(BTreeSet::new())),
51    };
52    let documents = entries
53        .iter()
54        .map(|entry| {
55            let path = root.join(entry);
56            (uri(&path), fs::read_to_string(path).unwrap())
57        })
58        .collect();
59    registry::runtime_handle()
60        .block_on(project.refresh(documents))
61        .unwrap()
62}
63
64fn uri(path: &Path) -> String {
65    avril_lsp::canonical_uri(url::Url::from_file_path(path).unwrap().as_str()).unwrap()
66}
67
68fn resolve(input: &ProjectInput, referrer: &str, specifier: &str) -> Option<String> {
69    input
70        .imports
71        .get(&(referrer.into(), specifier.into()))
72        .and_then(|targets| targets.import.clone())
73}
74
75fn files(root: &Path, files: &[(&str, &str)]) {
76    for (name, source) in files {
77        let path = root.join(name);
78        fs::create_dir_all(path.parent().unwrap()).unwrap();
79        fs::write(path, source).unwrap();
80    }
81}
82
83#[test]
84fn workspace_roots_and_auto_imports_keep_resolution_authoritative() {
85    let dir = tempfile::tempdir().unwrap();
86    let root = dir.path();
87    files(
88        root,
89        &[
90            ("main.ts", "export const main = 0;"),
91            ("target.ts", "export const target = 1;"),
92            ("other.ts", "export const other = 2;"),
93            ("ignored.ts", "throw Error('ignored');"),
94            (
95                "node_modules/pkg/index.ts",
96                "throw Error('not a workspace root');",
97            ),
98            (".gitignore", "ignored.ts\n"),
99            ("avril.conf", "imports: {'./target.ts': './other.ts'}"),
100        ],
101    );
102    let input = refresh(root, options(root), &["main.ts"]);
103    assert!(
104        input
105            .sources
106            .iter()
107            .any(|s| s.uri == uri(&root.join("other.ts")))
108    );
109    assert!(
110        !input
111            .sources
112            .iter()
113            .any(|s| s.uri == uri(&root.join("ignored.ts")))
114    );
115    assert!(!input.sources.iter().any(|s| s.uri.contains("node_modules")));
116    let edges = &input.import_candidates;
117    assert!(edges.contains(&(
118        uri(&root.join("main.ts")),
119        uri(&root.join("other.ts")),
120        "./target.ts".into()
121    )));
122    assert!(!edges.contains(&(
123        uri(&root.join("main.ts")),
124        uri(&root.join("target.ts")),
125        "./target.ts".into()
126    )));
127}
128
129#[test]
130fn restricted_projects_do_not_discover_unopened_roots() {
131    let dir = tempfile::tempdir().unwrap();
132    files(
133        dir.path(),
134        &[
135            ("main.ts", "export const main = 0;"),
136            ("unopened.ts", "export const unopened = 1;"),
137        ],
138    );
139    let caps = Capabilities::new(Initial::new(true), dir.path().to_owned());
140    let input = refresh_with_caps(dir.path(), options(dir.path()), &[], caps);
141    assert!(!input.sources.iter().any(|s| s.uri.starts_with("file:")));
142}
143
144#[test]
145fn declarations_preserve_package_private_versions_without_writing_project_files() {
146    let server = registry::Registry::new();
147    server.package("@test/types", "1.0.0", &[
148        ("types.d.ts", "import type {Base} from './base.d.ts'; export interface Value extends Base { first: string; }"),
149        ("base.d.ts", "export interface Base { id: number; }"),
150    ], &[(".", "./types.d.ts")]);
151    server.package(
152        "@test/types",
153        "2.0.0",
154        &[("types.d.ts", "export interface Value { second: number; }")],
155        &[(".", "./types.d.ts")],
156    );
157    let dir = tempfile::tempdir().unwrap();
158    files(
159        dir.path(),
160        &[
161            ("app/avril.conf", "imports: {dep: 'jsr:@test/types@1'}"),
162            ("lib/avril.conf", "imports: {dep: 'jsr:@test/types@2'}"),
163            (
164                "app/main.ts",
165                "import type {Value} from 'dep'; import {second} from '../lib/mod.ts'; export const first: Value = {id: 1, first: 'ok'}; throw Error('must not execute');",
166            ),
167            (
168                "lib/mod.ts",
169                "import type {Value} from 'dep'; export const second: Value = {second: 2};",
170            ),
171        ],
172    );
173    let app = dir.path().join("app");
174    let input = refresh(&app, server.options(&app), &["main.ts"]);
175    let first = resolve(&input, &uri(&app.join("main.ts")), "dep").unwrap();
176    let second = resolve(&input, &uri(&dir.path().join("lib/mod.ts")), "dep").unwrap();
177    assert_eq!(
178        first,
179        avril_lsp::canonical_uri(&format!("{}@test/types/1.0.0/types.d.ts", server.url)).unwrap()
180    );
181    assert_eq!(
182        second,
183        avril_lsp::canonical_uri(&format!("{}@test/types/2.0.0/types.d.ts", server.url)).unwrap()
184    );
185    let base = resolve(&input, &first, "./base.d.ts").unwrap();
186    assert!(
187        input
188            .sources
189            .iter()
190            .any(|source| source.uri == base && source.read_only)
191    );
192    assert!(input.diagnostics.is_empty());
193    for path in ["avril.lock", "tsconfig.json", ".avril"] {
194        assert!(!app.join(path).exists());
195    }
196    server.requests.lock().unwrap().clear();
197    let mut options = server.options(&app);
198    options.offline = true;
199    let offline = refresh(&app, options, &["main.ts"]);
200    assert_eq!(
201        resolve(&offline, &uri(&app.join("main.ts")), "dep"),
202        Some(first)
203    );
204    assert!(server.requests.lock().unwrap().is_empty());
205}
206
207#[test]
208fn bad_library_metadata_does_not_abort_or_inherit_consumer_aliases() {
209    let dir = tempfile::tempdir().unwrap();
210    files(
211        dir.path(),
212        &[
213            ("app/avril.conf", "imports: {helper: './helper.ts'}"),
214            (
215                "app/main.ts",
216                "import type {Value} from 'helper'; import '../lib/mod.ts'; export const value: Value = 'app';",
217            ),
218            ("app/helper.ts", "export type Value = string;"),
219            ("lib/avril.conf", "imports: []"),
220            (
221                "lib/mod.ts",
222                "import type {Value} from 'helper'; export type LibraryValue = Value;",
223            ),
224        ],
225    );
226    let app = dir.path().join("app");
227    let input = refresh(&app, options(&app), &["main.ts"]);
228    let library = uri(&dir.path().join("lib/mod.ts"));
229    assert!(
230        input.diagnostics[&library]
231            .iter()
232            .any(|error| error.contains("Expected an object"))
233    );
234    assert_eq!(resolve(&input, &library, "helper"), None);
235    assert_eq!(
236        resolve(&input, &uri(&app.join("main.ts")), "helper"),
237        Some(uri(&app.join("helper.ts"))),
238    );
239}
240
241#[test]
242fn denied_type_override_is_not_hidden_by_an_ambient_module() {
243    let dir = tempfile::tempdir().unwrap();
244    let root = dir.path().join("project");
245    let outside = dir.path().join("outside.d.ts");
246    files(
247        dir.path(),
248        &[
249            ("project/avril.conf", "{}"),
250            (
251                "project/main.ts",
252                "import './ambient.d.ts'; import type {Value} from 'alias'; export type Main = Value;",
253            ),
254            (
255                "project/ambient.d.ts",
256                "declare module 'alias' { export interface Value {} }",
257            ),
258            ("outside.d.ts", "export interface Value {}"),
259        ],
260    );
261    let ambient = refresh(&root, options(&root), &["main.ts"]);
262    assert!(ambient.diagnostics.is_empty(), "{:?}", ambient.diagnostics);
263    files(
264        dir.path(),
265        &[("project/avril.conf", "types: {imports: {alias: null}}")],
266    );
267    let blocked = refresh(&root, options(&root), &["main.ts"]);
268    assert!(
269        blocked
270            .diagnostics
271            .contains_key(&uri(&root.join("main.ts"))),
272        "{:?}",
273        blocked.diagnostics
274    );
275    files(
276        dir.path(),
277        &[(
278            "project/avril.conf",
279            "types: {imports: {alias: '../outside.d.ts'}}",
280        )],
281    );
282    let mut initial = Initial::new(true);
283    initial
284        .grant(Category::JsExec, false, root.to_str().unwrap(), &root)
285        .unwrap();
286    let caps = Capabilities::new(initial, root.clone());
287    let input = refresh_with_caps(&root, options(&root), &["main.ts"], caps);
288    let main = uri(&root.join("main.ts"));
289    assert!(
290        input.diagnostics[&main]
291            .iter()
292            .any(|error| error.contains("PermissionDenied")),
293        "{:?}",
294        input.diagnostics
295    );
296    assert!(
297        !input
298            .sources
299            .iter()
300            .any(|source| source.uri == uri(&outside))
301    );
302}
303
304#[test]
305fn type_queries_references_jsdoc_and_redirects_survive_missing_dependencies() {
306    let server = registry::Registry::new();
307    server.package(
308        "@test/js",
309        "1.0.0",
310        &[
311            (
312                "mod.js",
313                "/* @ts-self-types=\"./mod.d.ts\" */ export const value = 'js';",
314            ),
315            ("mod.d.ts", "export const value: 'declared';"),
316        ],
317        &[(".", "./mod.js")],
318    );
319    let dir = tempfile::tempdir().unwrap();
320    files(
321        dir.path(),
322        &[
323            ("avril.conf", "{}"),
324            ("ref.d.ts", "export interface Reference {}"),
325            ("query.ts", "export const value = 1;"),
326            ("lazy.ts", "export const value = 2;"),
327            ("jsdoc.d.ts", "export interface Doc {}"),
328            ("param.d.ts", "export interface Param {}"),
329            ("returns.d.ts", "export interface Result {}"),
330            ("imported.d.ts", "export interface Imported {}"),
331            (
332                "main.ts",
333                "/// <reference path=\"./ref.d.ts\"/>\nimport {value} from 'jsr:@test/js@1'; import type {Missing} from 'package:missing'; import type {Absent} from './absent.d.ts'; type Query = typeof import('./query.ts'); const load = () => import('./lazy.ts'); /** @type {import('./jsdoc.d.ts').Doc} */ let doc; /** @param {import('./param.d.ts').Param} value\n * @returns {import('./returns.d.ts').Result}\n * The result type.\n * @example\n * import('example-only-library');\n * ```ts\n * import('fenced-example-library');\n * ```\n * @import { Imported } from './imported.d.ts'\n */ function example(value) {} throw Error('must not run');",
334            ),
335        ],
336    );
337    let input = refresh(dir.path(), server.options(dir.path()), &["main.ts"]);
338    let main = uri(&dir.path().join("main.ts"));
339    assert!(
340        resolve(&input, &main, "jsr:@test/js@1")
341            .unwrap()
342            .ends_with("/mod.d.ts")
343    );
344    for name in [
345        "ref.d.ts",
346        "query.ts",
347        "lazy.ts",
348        "jsdoc.d.ts",
349        "param.d.ts",
350        "returns.d.ts",
351        "imported.d.ts",
352    ] {
353        let target = uri(&dir.path().join(name));
354        assert_eq!(
355            resolve(&input, &main, &format!("./{name}")),
356            Some(target.clone())
357        );
358        assert!(input.sources.iter().any(|source| source.uri == target));
359    }
360    assert!(
361        input.diagnostics[&main]
362            .iter()
363            .any(|error| error.contains("Unsupported"))
364    );
365    assert!(resolve(&input, &main, "./absent.d.ts").is_none());
366    assert!(resolve(&input, &main, "example-only-library").is_none());
367    assert!(resolve(&input, &main, "fenced-example-library").is_none());
368    assert!(
369        input
370            .diagnostics
371            .values()
372            .flatten()
373            .any(|error| error.contains("absent.d.ts"))
374    );
375}
376
377#[test]
378fn jsx_collects_implicit_runtime_types_and_preserves_user_config() {
379    let server = registry::Registry::new();
380    server.package(
381        "@test/ui",
382        "1.0.0",
383        &[
384            (
385                "jsx-runtime.js",
386                "/** @ts-self-types=\"./jsx-runtime.d.ts\" */ export const jsx = () => {};",
387            ),
388            (
389                "jsx-runtime.d.ts",
390                "export const jsx: (tag: string, props: object) => object;",
391            ),
392        ],
393        &[("./jsx-runtime", "./jsx-runtime.js")],
394    );
395    let dir = tempfile::tempdir().unwrap();
396    let config = r#"{"compilerOptions":{"jsx":"react-jsx","jsxImportSource":"jsr:@test/ui@1"}}"#;
397    files(
398        dir.path(),
399        &[
400            ("avril.conf", "{}"),
401            ("tsconfig.json", config),
402            ("types.d.ts", "export interface Props {title: string}"),
403            (
404                "main.tsx",
405                "import type {Props} from './types.d.ts'; const props: Props = {title:'hello'}; export const node = <p {...props} />;",
406            ),
407        ],
408    );
409    let input = refresh(dir.path(), server.options(dir.path()), &["main.tsx"]);
410    let main = uri(&dir.path().join("main.tsx"));
411    assert!(
412        resolve(&input, &main, "jsr:@test/ui@1/jsx-runtime")
413            .unwrap()
414            .ends_with("jsx-runtime.d.ts")
415    );
416    assert_eq!(
417        resolve(&input, &main, "./types.d.ts"),
418        Some(uri(&dir.path().join("types.d.ts")))
419    );
420    assert_eq!(
421        input.per_file_options[&main]["jsxImportSource"],
422        "jsr:@test/ui@1"
423    );
424    assert!(input.diagnostics.is_empty());
425    assert_eq!(
426        fs::read_to_string(dir.path().join("tsconfig.json")).unwrap(),
427        config
428    );
429}
430
431#[test]
432fn preserved_jsx_and_bad_configs_do_not_abort_live_generations() {
433    let dir = tempfile::tempdir().unwrap();
434    files(
435        dir.path(),
436        &[
437            ("avril.conf", "{}"),
438            ("main.tsx", "export const node = <p />;"),
439        ],
440    );
441    let main = uri(&dir.path().join("main.tsx"));
442    for mode in ["preserve", "react-native"] {
443        fs::write(
444            dir.path().join("tsconfig.json"),
445            format!(r#"{{"compilerOptions":{{"jsx":"{mode}"}}}}"#),
446        )
447        .unwrap();
448        let input = refresh(dir.path(), options(dir.path()), &["main.tsx"]);
449        assert_eq!(input.per_file_options[&main]["jsx"], mode);
450        assert!(input.diagnostics.is_empty());
451    }
452    files(
453        dir.path(),
454        &[("tsconfig.json", r#"{"extends":"./absent.json"}"#)],
455    );
456    let input = refresh(dir.path(), options(dir.path()), &["main.tsx"]);
457    assert!(
458        input.diagnostics[&main]
459            .iter()
460            .any(|error| error.contains("Cannot find tsconfig"))
461    );
462    files(dir.path(), &[("avril.conf", "deps: [")]);
463    let input = refresh(dir.path(), options(dir.path()), &["main.tsx"]);
464    assert!(!input.diagnostics[&main].is_empty());
465    assert!(input.sources.iter().any(|source| source.uri == main));
466}
467
468#[test]
469fn avril_conf_jsx_defaults_follow_edits_within_their_package() {
470    let dir = tempfile::tempdir().unwrap();
471    files(
472        dir.path(),
473        &[
474            ("avril.conf", "compilerOptions: {jsxImportSource: 'first'}"),
475            ("main.tsx", "export const node = <p />;"),
476            ("lib/avril.conf", "{}"),
477            ("lib/widget.tsx", "export const widget = <b />;"),
478        ],
479    );
480    let main = uri(&dir.path().join("main.tsx"));
481    let widget = uri(&dir.path().join("lib/widget.tsx"));
482    for (config, tsconfig, expected) in [
483        ("compilerOptions: {jsxImportSource: 'first'}", None, "first"),
484        (
485            "compilerOptions: {jsxImportSource: 'second'}",
486            None,
487            "second",
488        ),
489        (
490            "compilerOptions: {jsxImportSource: 'second'}",
491            Some(r#"{"compilerOptions":{"jsxImportSource":"ts"}}"#),
492            "ts",
493        ),
494    ] {
495        files(dir.path(), &[("avril.conf", config)]);
496        if let Some(tsconfig) = tsconfig {
497            files(dir.path(), &[("tsconfig.json", tsconfig)]);
498        }
499        let input = refresh(
500            dir.path(),
501            options(dir.path()),
502            &["main.tsx", "lib/widget.tsx"],
503        );
504        assert_eq!(input.per_file_options[&main]["jsxImportSource"], expected);
505        assert!(
506            input.per_file_options[&widget]
507                .get("jsxImportSource")
508                .is_none()
509        );
510    }
511}
512
513#[test]
514fn remote_redirects_use_final_bases_and_missing_package_names_are_diagnostics() {
515    let http = http::Http::new();
516    let dir = tempfile::tempdir().unwrap();
517    http.redirect("start", "/nested/mod.ts");
518    http.source("nested/mod.ts", "export {value} from './dep.ts';");
519    http.source("nested/dep.ts", "export const value: number = 42;");
520    files(
521        dir.path(),
522        &[
523            ("avril.conf", "{}"),
524            (
525                "main.ts",
526                &format!("import {{value}} from '{}';", http.name("start")),
527            ),
528        ],
529    );
530    let input = refresh(dir.path(), options(dir.path()), &["main.ts"]);
531    let main = uri(&dir.path().join("main.ts"));
532    let remote = resolve(&input, &main, &http.name("start")).unwrap();
533    let dependency = resolve(&input, &remote, "./dep.ts").unwrap();
534    assert!(dependency.ends_with("/nested/dep.ts"));
535    assert!(
536        input
537            .sources
538            .iter()
539            .any(|source| source.uri == dependency && source.read_only)
540    );
541    files(
542        dir.path(),
543        &[(
544            "avril.conf",
545            &format!("deps: ['{}']", http.name("missing/")),
546        )],
547    );
548    let input = refresh(dir.path(), options(dir.path()), &["main.ts"]);
549    assert!(
550        input.diagnostics[&main]
551            .iter()
552            .any(|error| error.contains("Cannot read the name"))
553    );
554    assert!(input.sources.iter().any(|source| source.uri == main));
555}
556
557#[test]
558fn remote_package_ownership_is_stable_in_both_import_orders() {
559    let http = http::Http::new();
560    let dir = tempfile::tempdir().unwrap();
561    http.source(
562        "pkg/child/avril.conf",
563        "name: 'child', exports: './mod.ts', imports: {helper: './helper.ts'}",
564    );
565    http.source("pkg/child/mod.ts", "export {value} from 'helper';");
566    http.source("pkg/child/helper.ts", "export const value = 7;");
567    http.source("pkg/wrong.ts", "throw Error('parent map leaked');");
568    files(
569        dir.path(),
570        &[
571            (
572                "avril.conf",
573                &format!(
574                    "deps: ['{}', '{}']",
575                    http.name("pkg/"),
576                    http.name("pkg/child/")
577                ),
578            ),
579            ("x.ts", "import * as a from 'outer'; export {a};"),
580            ("y.ts", "import * as b from 'child'; export {b};"),
581        ],
582    );
583    for nested in [true, false] {
584        http.source("pkg/avril.conf", &format!(
585            "name: 'outer', exports: './child/mod.ts', imports: {{helper: './wrong.ts'}}, deps: [{}]",
586            if nested {"{from: './child/', name: 'nested'}"} else {""},
587        ));
588        for imports in [
589            "import './x.ts'; import './y.ts';",
590            "import './y.ts'; import './x.ts';",
591        ] {
592            files(dir.path(), &[("main.ts", imports)]);
593            let cache = dir.path().join("cache");
594            if cache.exists() {
595                fs::remove_dir_all(cache).unwrap();
596            }
597            let input = refresh(dir.path(), options(dir.path()), &["main.ts"]);
598            if nested {
599                assert!(input.diagnostics.is_empty(), "{:?}", input.diagnostics);
600                assert_eq!(
601                    resolve(&input, &http.name("pkg/child/mod.ts"), "helper"),
602                    Some(http.name("pkg/child/helper.ts")),
603                );
604            } else {
605                assert!(
606                    input
607                        .diagnostics
608                        .values()
609                        .flatten()
610                        .any(|error| error.contains("Conflicting remote package ownership"))
611                );
612            }
613        }
614    }
615}
616
617#[test]
618fn registry_prefixed_configs_are_rejected_without_fetching_or_writing_locks() {
619    let http = http::Http::new();
620    let dir = tempfile::tempdir().unwrap();
621    let mut options = options(dir.path());
622    options.jsr_registry = http.url.clone();
623    let lock = format!(
624        "version: 1\njsrRegistry: \"{}\"\nspecifiers: {{}}\npackages: {{}}\nremote: {{}}\n",
625        http.url
626    );
627    files(
628        dir.path(),
629        &[("avril.lock", &lock), ("main.ts", "import 'pkg';")],
630    );
631    for dependency in [
632        format!("'{}'", http.name("pkg/")),
633        format!("{{from: '{}', name: 'pkg'}}", http.name("pkg/")),
634    ] {
635        files(
636            dir.path(),
637            &[("avril.conf", &format!("deps: [{dependency}]"))],
638        );
639        let input = refresh(dir.path(), options.clone(), &["main.ts"]);
640        assert!(
641            input
642                .diagnostics
643                .values()
644                .flatten()
645                .any(|error| error.contains("configured JSR registry"))
646        );
647        assert!(http.requests.lock().unwrap().is_empty());
648        assert_eq!(
649            fs::read_to_string(dir.path().join("avril.lock")).unwrap(),
650            lock
651        );
652    }
653}