char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somunify public module registration and declarations6534c3e

main
37.8 KiB1086 linesraw
1//! Independent regression probe of the stock native TypeScript API contract,
2//! speaking framed JSON-RPC directly to the backend instead of reusing the
3//! adapter under test. Set AVRIL_TEST_TYPESCRIPT to the backend's absolute
4//! path; the probe is skipped otherwise.
5
6use std::cell::RefCell;
7use std::collections::{BTreeMap, HashMap};
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Child, ChildStdin, Command, Stdio};
11use std::rc::Rc;
12use std::sync::Mutex;
13use std::sync::mpsc::{Receiver, channel};
14use std::time::{Duration, Instant};
15
16use serde_json::{Value, json};
17
18fn backend() -> Option<PathBuf> {
19    let path = std::env::var_os("AVRIL_TEST_TYPESCRIPT")?;
20    if path.is_empty() {
21        return None;
22    }
23    Some(PathBuf::from(path))
24}
25
26fn write_frame(writer: &mut impl Write, message: &Value) -> std::io::Result<()> {
27    let body = serde_json::to_vec(message).unwrap();
28    write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
29    writer.write_all(&body)?;
30    writer.flush()
31}
32
33fn read_frame(reader: &mut impl BufRead) -> std::io::Result<Option<Value>> {
34    let mut length = None;
35    loop {
36        let mut line = String::new();
37        if reader.read_line(&mut line)? == 0 {
38            return Ok(None);
39        }
40        let line = line.trim_end_matches(['\r', '\n']);
41        if line.is_empty() {
42            break;
43        }
44        if let Some((name, value)) = line.split_once(':')
45            && name.trim().eq_ignore_ascii_case("Content-Length")
46        {
47            length = Some(value.trim().parse::<usize>().unwrap());
48        }
49    }
50    let mut body = vec![0; length.unwrap()];
51    reader.read_exact(&mut body)?;
52    Ok(Some(serde_json::from_slice(&body).unwrap()))
53}
54
55#[derive(Debug)]
56enum CallError {
57    Rpc { code: i64, message: String },
58    Failed(String),
59}
60
61impl std::fmt::Display for CallError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            CallError::Rpc { code, message } => write!(f, "RPC {code}: {message}"),
65            CallError::Failed(message) => write!(f, "{message}"),
66        }
67    }
68}
69
70type Callback = Box<dyn FnMut(&Value) -> Result<Value, String>>;
71
72struct ApiServer {
73    child: Child,
74    stdin: Option<Mutex<ChildStdin>>,
75    messages: Receiver<Value>,
76    callbacks: HashMap<String, Callback>,
77    next_id: u64,
78    failed: Option<String>,
79}
80
81impl ApiServer {
82    fn start(binary: &Path, callbacks: &[&str]) -> ApiServer {
83        let mut child = Command::new(binary)
84            .env_clear()
85            .args([
86                "--api",
87                "--async",
88                "--cwd",
89                "/avril-lsp",
90                "--callbacks",
91                &callbacks.join(","),
92            ])
93            .stdin(Stdio::piped())
94            .stdout(Stdio::piped())
95            .stderr(Stdio::inherit())
96            .spawn()
97            .unwrap();
98        let stdin = Some(Mutex::new(child.stdin.take().unwrap()));
99        let mut stdout = BufReader::new(child.stdout.take().unwrap());
100        let (tx, messages) = channel();
101        std::thread::spawn(move || {
102            while let Ok(Some(message)) = read_frame(&mut stdout) {
103                if tx.send(message).is_err() {
104                    return;
105                }
106            }
107        });
108        ApiServer {
109            child,
110            stdin,
111            messages,
112            callbacks: HashMap::new(),
113            next_id: 0,
114            failed: None,
115        }
116    }
117
118    fn send(&self, message: &Value) {
119        write_frame(&mut *self.stdin.as_ref().unwrap().lock().unwrap(), message).unwrap();
120    }
121
122    fn call(&mut self, method: &str, params: Value) -> Result<Value, CallError> {
123        if let Some(error) = &self.failed {
124            return Err(CallError::Failed(error.clone()));
125        }
126        self.next_id += 1;
127        let id = self.next_id;
128        self.send(&json!({"id": id, "method": method, "params": params}));
129        let deadline = Instant::now() + Duration::from_secs(30);
130        loop {
131            let remaining = deadline.saturating_duration_since(Instant::now());
132            if remaining.is_zero() {
133                self.failed = Some(format!("Timed out waiting for {method}"));
134                return Err(CallError::Failed(self.failed.clone().unwrap()));
135            }
136            match self.messages.recv_timeout(remaining) {
137                Ok(message) => {
138                    if let Some(callback_method) = message["method"].as_str() {
139                        // Callbacks may arrive while a request is in flight;
140                        // answering them inline keeps the reader progressing.
141                        let callback_id = message["id"].clone();
142                        let response = match self.callbacks.get_mut(callback_method) {
143                            Some(callback) => match callback(&message["params"]) {
144                                Ok(result) => json!({"id": callback_id, "result": result}),
145                                Err(error) => json!({
146                                    "id": callback_id,
147                                    "error": {"code": -32603, "message": error}
148                                }),
149                            },
150                            None => json!({
151                                "id": callback_id,
152                                "error": {
153                                    "code": -32603,
154                                    "message": format!("Unknown callback: {callback_method}")
155                                }
156                            }),
157                        };
158                        self.send(&response);
159                    } else if message["id"].as_u64() == Some(id) {
160                        if let Some(error) = message.get("error") {
161                            return Err(CallError::Rpc {
162                                code: error["code"].as_i64().unwrap_or_default(),
163                                message: error["message"].as_str().unwrap_or_default().to_owned(),
164                            });
165                        }
166                        return Ok(message["result"].clone());
167                    }
168                }
169                Err(_) => {
170                    self.failed = Some("API server closed stdout".into());
171                    return Err(CallError::Failed(self.failed.clone().unwrap()));
172                }
173            }
174        }
175    }
176
177    fn close(mut self) {
178        drop(self.stdin.take());
179        let deadline = Instant::now() + Duration::from_secs(2);
180        loop {
181            match self.child.try_wait().unwrap() {
182                Some(status) => {
183                    assert!(status.success(), "API server exited with {status}");
184                    return;
185                }
186                None if Instant::now() >= deadline => {
187                    self.child.kill().unwrap();
188                    self.child.wait().unwrap();
189                    panic!("API server did not exit");
190                }
191                None => std::thread::sleep(Duration::from_millis(10)),
192            }
193        }
194    }
195}
196
197impl Drop for ApiServer {
198    fn drop(&mut self) {
199        let _ = self.child.kill();
200        let _ = self.child.wait();
201    }
202}
203
204fn version(binary: &Path) -> String {
205    let output = Command::new(binary).arg("--version").output().unwrap();
206    assert!(output.status.success(), "Cannot run backend");
207    String::from_utf8(output.stdout).unwrap().trim().to_owned()
208}
209
210#[test]
211fn capabilities() {
212    let Some(binary) = backend() else {
213        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
214        return;
215    };
216    println!("{}", version(&binary));
217    let mut api = ApiServer::start(&binary, &[]);
218    println!("initialize: {}", api.call("initialize", json!({})).unwrap());
219    match api.call("createModuleResolver", json!({"compilerOptions": {}})) {
220        Ok(resolver) => {
221            api.call("releaseModuleResolver", json!({"resolver": resolver}))
222                .unwrap();
223            println!("Custom module-resolution API: available");
224        }
225        Err(CallError::Rpc { message, .. }) => {
226            assert!(
227                message.contains("unknown API method \"createModuleResolver\""),
228                "Unexpected capability failure: {message}"
229            );
230            println!("Custom module-resolution API: unavailable");
231        }
232        Err(error) => panic!("Unexpected capability failure: {error}"),
233    }
234    api.close();
235}
236
237struct Fixture {
238    original_to_file: BTreeMap<String, String>,
239    directory_to_original: BTreeMap<String, String>,
240    files: BTreeMap<String, String>,
241}
242
243impl Fixture {
244    fn add(&mut self, original: &str, source: &str) -> String {
245        let extension = if original.ends_with(".d.ts") {
246            ".d.ts"
247        } else if original.ends_with(".tsx") {
248            ".tsx"
249        } else {
250            ".ts"
251        };
252        let directory = format!("/avril-lsp/modules/{}", self.original_to_file.len());
253        let file = format!("{directory}/source{extension}");
254        self.original_to_file
255            .insert(original.to_owned(), file.clone());
256        self.directory_to_original
257            .insert(directory, original.to_owned());
258        self.files.insert(file.clone(), source.to_owned());
259        file
260    }
261}
262
263fn utf16_index(line: &str, byte_index: usize) -> usize {
264    line[..byte_index].encode_utf16().count()
265}
266
267fn layer(files: Vec<(String, String)>) -> Value {
268    let mut map = serde_json::Map::new();
269    for (name, text) in files {
270        map.insert(name, json!(text));
271    }
272    Value::Object(map)
273}
274
275#[test]
276fn native_api_contract() {
277    let Some(binary) = backend() else {
278        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
279        return;
280    };
281    println!("{}", version(&binary));
282    let lib_directory = binary.parent().unwrap().to_path_buf();
283    let fs_callbacks = [
284        "readFile",
285        "fileExists",
286        "directoryExists",
287        "getAccessibleEntries",
288    ];
289    let mut callback_names: Vec<String> = fs_callbacks.iter().map(ToString::to_string).collect();
290    callback_names.extend(
291        [
292            "realpath:identity",
293            "stat:fakeStat",
294            "writeFile:error",
295            "removeFile:error",
296        ]
297        .iter()
298        .map(ToString::to_string),
299    );
300    let callback_refs: Vec<&str> = callback_names.iter().map(String::as_str).collect();
301    let mut api = ApiServer::start(&binary, &callback_refs);
302
303    let fs_calls = Rc::new(RefCell::new(Vec::<(String, String, bool)>::new()));
304    for method in fs_callbacks {
305        let fs_calls = fs_calls.clone();
306        let lib_directory = lib_directory.clone();
307        api.callbacks.insert(
308            method.to_owned(),
309            Box::new(move |params| {
310                let path = params
311                    .as_str()
312                    .ok_or_else(|| format!("Unexpected filesystem callback: {method}"))?;
313                let library_name = path
314                    .strip_prefix(&format!("{}/", lib_directory.display()))
315                    .filter(|name| {
316                        name.starts_with("lib.")
317                            && name.ends_with(".d.ts")
318                            && name
319                                .bytes()
320                                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-'))
321                    });
322                let allowed = library_name.is_some()
323                    || Path::new(path) == lib_directory
324                    || path.starts_with("bundled:///libs/")
325                    || path == "bundled:///libs";
326                fs_calls
327                    .borrow_mut()
328                    .push((method.to_owned(), path.to_owned(), allowed));
329                if allowed {
330                    return Ok(json!({"kind": "useOS"}));
331                }
332                if method == "readFile" {
333                    return Ok(json!({"kind": "missing"}));
334                }
335                Ok(json!({
336                    "kind": "value",
337                    "value": if method == "getAccessibleEntries" {
338                        json!({"files": [], "directories": []})
339                    } else {
340                        json!(false)
341                    }
342                }))
343            }),
344        );
345    }
346
347    let mut snapshots: Vec<u64> = Vec::new();
348    let mut resolvers: Vec<u64> = Vec::new();
349    let mut passed: Vec<String> = Vec::new();
350    let pass = |passed: &mut Vec<String>, message: &str| {
351        passed.push(message.to_owned());
352        println!("PASS {message}");
353    };
354
355    println!("initialize: {}", api.call("initialize", json!({})).unwrap());
356    let raw_options = json!({
357        "strict": true, "noEmit": true, "skipLibCheck": true, "target": "esnext",
358        "module": "esnext", "moduleResolution": "bundler",
359        "allowImportingTsExtensions": true, "jsx": "react-jsx",
360        "jsxImportSource": "@fixture/ui", "types": []
361    });
362    let mut fixture = Fixture {
363        original_to_file: BTreeMap::new(),
364        directory_to_original: BTreeMap::new(),
365        files: BTreeMap::new(),
366    };
367    let main_uri = "file:///workspace/app/main.tsx";
368    let main_source = [
369        "import { process } from \"avril:std\";",
370        "import { answer } from \"jsr:@fixture/data\";",
371        "import { label } from \"./local.ts\";",
372        "const emoji = \"😀\"; const wrong: number = \"bad\";",
373        "const view = <panel count=\"wrong\" />;",
374        "process.args.push(\"x\");",
375        "export const result = { answer, label, view };",
376    ]
377    .join("\n");
378    let main = fixture.add(main_uri, &main_source);
379    let a_uri = "file:///workspace/app/a.ts";
380    let b_uri = "file:///workspace/app/b.ts";
381    let a = fixture.add(
382        a_uri,
383        "import { value } from \"scoped\"; export const result: \"A\" = value;",
384    );
385    let b = fixture.add(
386        b_uri,
387        "import { value } from \"scoped\"; export const result: \"B\" = value;",
388    );
389    let completion_source = "import { process } from \"avril:std\"; process.args;";
390    let completion = fixture.add("file:///workspace/app/completion.ts", completion_source);
391    fixture.add(
392        "file:///workspace/deps/a.ts",
393        "export const value = \"A\" as const;",
394    );
395    fixture.add(
396        "file:///workspace/deps/b.ts",
397        "export const value = \"B\" as const;",
398    );
399    fixture.add(
400        "file:///workspace/app/local.ts",
401        "export const label = \"local\";",
402    );
403    fixture.add(
404        "https://jsr.io/@fixture/data/1.0.0/mod.d.ts",
405        "export declare const answer: 42;",
406    );
407    fixture.add(
408        "file:///workspace/ui/jsx-runtime.d.ts",
409        "\nexport namespace JSX {\n  interface Element {}\n  interface IntrinsicElements { panel: { count: number } }\n}\nexport declare function jsx(...args: unknown[]): JSX.Element;\nexport declare const jsxs: typeof jsx;\nexport declare const Fragment: unique symbol;\n",
410    );
411
412    // The production catalog includes both handwritten and generated declarations.
413    let mut roots = vec![main.clone(), a.clone(), b.clone(), completion.clone()];
414    for (name, text) in avril_runtime::stdlib::declarations() {
415        roots.push(fixture.add(&format!("avril:///lib/{name}"), text));
416    }
417
418    let fixture = Rc::new(RefCell::new(fixture));
419    let calls = Rc::new(RefCell::new(Vec::<Value>::new()));
420    let parse_options = |api: &mut ApiServer, compiler_options: &Value, roots: &[String]| {
421        // The wire protocol wants normalized enum values, not tsconfig spellings.
422        let parsed = api
423            .call(
424                "parseJsonConfigFileContent",
425                json!({
426                    "json": {"compilerOptions": compiler_options, "files": roots},
427                    "configDirectory": "/avril-lsp"
428                }),
429            )
430            .unwrap();
431        assert!(
432            parsed["errors"].as_array().unwrap().is_empty(),
433            "Invalid compiler options: {}",
434            parsed["errors"]
435        );
436        parsed["options"].clone()
437    };
438    let options = parse_options(&mut api, &raw_options, &roots);
439
440    // Resolution policy is pinned to the resolver, including for older snapshots.
441    let register_resolver = |api: &mut ApiServer,
442                             resolvers: &mut Vec<u64>,
443                             calls: &Rc<RefCell<Vec<Value>>>,
444                             fixture: &Rc<RefCell<Fixture>>,
445                             compiler_options: &Value,
446                             swap_scopes: bool|
447     -> u64 {
448        let callback = format!("avril/resolve/{}", resolvers.len());
449        let calls = calls.clone();
450        let fixture = fixture.clone();
451        api.callbacks.insert(
452            callback.clone(),
453            Box::new(move |params| {
454                calls.borrow_mut().push(params.clone());
455                let fixture = fixture.borrow();
456                let original = fixture
457                    .directory_to_original
458                    .get(params["containingDirectory"].as_str().unwrap());
459                let resolved = match params["moduleName"].as_str().unwrap() {
460                    "scoped" => {
461                        let (dep_a, dep_b) = (
462                            &fixture.original_to_file["file:///workspace/deps/a.ts"],
463                            &fixture.original_to_file["file:///workspace/deps/b.ts"],
464                        );
465                        match (original.map(String::as_str), swap_scopes) {
466                            (Some("file:///workspace/app/a.ts"), false) => Some(dep_a),
467                            (Some("file:///workspace/app/a.ts"), true) => Some(dep_b),
468                            (Some("file:///workspace/app/b.ts"), false) => Some(dep_b),
469                            (Some("file:///workspace/app/b.ts"), true) => Some(dep_a),
470                            _ => None,
471                        }
472                    }
473                    "jsr:@fixture/data" => Some(
474                        &fixture.original_to_file["https://jsr.io/@fixture/data/1.0.0/mod.d.ts"],
475                    ),
476                    "@fixture/ui/jsx-runtime" => {
477                        Some(&fixture.original_to_file["file:///workspace/ui/jsx-runtime.d.ts"])
478                    }
479                    "./local.ts"
480                        if original.map(String::as_str)
481                            == Some("file:///workspace/app/main.tsx") =>
482                    {
483                        Some(&fixture.original_to_file["file:///workspace/app/local.ts"])
484                    }
485                    _ => None,
486                };
487                Ok(match resolved {
488                    Some(file) => json!({"resolvedFileName": file}),
489                    None => Value::Null,
490                })
491            }),
492        );
493        let id = api
494            .call(
495                "createModuleResolver",
496                json!({
497                    "compilerOptions": compiler_options,
498                    "resolveModuleNameCallback": callback
499                }),
500            )
501            .unwrap()
502            .as_u64()
503            .unwrap();
504        resolvers.push(id);
505        id
506    };
507
508    let (main_f, a_f, b_f) = (main.clone(), a.clone(), b.clone());
509    let files_snapshot = fixture.borrow().files.clone();
510    let mut resolver_id =
511        register_resolver(&mut api, &mut resolvers, &calls, &fixture, &options, false);
512    let _ = resolver_id;
513    let mut snapshot = api
514        .call(
515            "createSnapshot",
516            json!({
517                "fileSystem": {"kind": "layer", "files": files_snapshot},
518                "createPrograms": [{
519                    "rootFiles": roots.clone(), "compilerOptions": options,
520                    "options": {"moduleResolver": resolver_id}
521                }]
522            }),
523        )
524        .unwrap();
525    snapshots.push(snapshot["snapshot"].as_u64().unwrap());
526    let project = snapshot["operation"]["createdPrograms"][0]
527        .as_str()
528        .unwrap()
529        .to_owned();
530    assert!(
531        snapshot["projects"][0]["parsedCommandLine"]["errors"]
532            .as_array()
533            .unwrap()
534            .is_empty(),
535        "Invalid compiler options"
536    );
537    let initial = snapshot.clone();
538
539    // Helper closures over the API.
540    macro_rules! diagnostics {
541        ($snapshot:expr, $selected:expr) => {
542            api.call(
543                "getSemanticDiagnostics",
544                json!({
545                    "snapshot": $snapshot["snapshot"], "project": project,
546                    "files": $selected
547                }),
548            )
549            .unwrap()
550        };
551    }
552
553    let first = diagnostics!(snapshot, [main_f.clone(), a_f.clone(), b_f.clone()]);
554    let first = first.as_array().unwrap();
555    assert_eq!(first.len(), 3, "Expected exactly three intentional errors");
556    assert_eq!(
557        first.iter().filter(|d| d["code"] == json!(2322)).count(),
558        2,
559        "Missing assignment/JSX diagnostics"
560    );
561    assert!(
562        first.iter().any(|d| d["code"] == json!(2339)),
563        "Actual avril declarations did not enforce readonly process.args"
564    );
565    assert!(
566        first.iter().all(|d| d["fileName"] == json!(main_f)),
567        "Scoped resolution or declarations failed"
568    );
569    pass(
570        &mut passed,
571        "unsaved TSX + actual avril declarations + local/JSR-shaped fixture imports",
572    );
573    {
574        let calls = calls.borrow();
575        let fixture = fixture.borrow();
576        assert!(
577            calls
578                .iter()
579                .any(|c| c["moduleName"] == json!("@fixture/ui/jsx-runtime")),
580            "JSX implicit import bypassed resolver"
581        );
582        for uri in [a_uri, b_uri] {
583            assert!(
584                calls.iter().any(|c| {
585                    fixture
586                        .directory_to_original
587                        .get(c["containingDirectory"].as_str().unwrap())
588                        .map(String::as_str)
589                        == Some(uri)
590                        && c["moduleName"] == json!("scoped")
591                }),
592                "No {uri} callback"
593            );
594        }
595    }
596    pass(
597        &mut passed,
598        "per-file scopes via compiler-only directories; original source unmodified",
599    );
600    let unicode = first
601        .iter()
602        .find(|d| d["startPosition"]["line"] == json!(3))
603        .unwrap();
604    let line = main_source.lines().nth(3).unwrap();
605    assert_eq!(
606        unicode["startPosition"]["character"].as_u64().unwrap() as usize,
607        utf16_index(line, line.find("wrong").unwrap()),
608        "Diagnostic position is not UTF-16"
609    );
610    pass(
611        &mut passed,
612        "diagnostic UTF-16 positions after an astral character",
613    );
614
615    let completions = api
616        .call(
617            "getCompletionsAtPosition",
618            json!({
619                "snapshot": snapshot["snapshot"], "project": project,
620                "file": fixture.borrow().original_to_file["file:///workspace/app/completion.ts"],
621                "position": completion_source.rfind("process.").unwrap() + "process.".len()
622            }),
623        )
624        .unwrap();
625    let names: Vec<&str> = completions["entries"]
626        .as_array()
627        .unwrap()
628        .iter()
629        .map(|entry| entry["name"].as_str().unwrap())
630        .collect();
631    assert!(names.contains(&"args"), "Missing process.args completion");
632    assert!(names.contains(&"cwd"), "Missing process.cwd completion");
633    pass(&mut passed, "member completion from real avril types");
634
635    let position = utf16_index(&main_source, main_source.rfind("answer").unwrap());
636    let ty = api
637        .call(
638            "getTypeAtPosition",
639            json!({
640                "snapshot": snapshot["snapshot"], "project": project,
641                "file": main_f, "position": position
642            }),
643        )
644        .unwrap();
645    let type_text = api
646        .call(
647            "typeToString",
648            json!({
649                "snapshot": snapshot["snapshot"], "project": project, "type": ty["id"]
650            }),
651        )
652        .unwrap();
653    assert_eq!(type_text, json!("42"), "Unexpected type text: {type_text}");
654    let symbol = api
655        .call(
656            "getSymbolAtPosition",
657            json!({
658                "snapshot": snapshot["snapshot"], "project": project,
659                "file": main_f, "position": position
660            }),
661        )
662        .unwrap();
663    assert!(
664        !symbol["declarations"].as_array().unwrap().is_empty(),
665        "Missing symbol declaration handles"
666    );
667    pass(
668        &mut passed,
669        "hover building blocks: type text and symbol/declaration handles",
670    );
671
672    let alias = api
673        .call(
674            "getSymbolAtPosition",
675            json!({
676                "snapshot": snapshot["snapshot"], "project": project,
677                "file": main_f, "position": main_source.find("answer").unwrap()
678            }),
679        )
680        .unwrap();
681    let target = api
682        .call(
683            "getAliasedSymbol",
684            json!({
685                "snapshot": snapshot["snapshot"], "project": project,
686                "symbol": alias["reference"]
687            }),
688        )
689        .unwrap();
690    let remote_file =
691        fixture.borrow().original_to_file["https://jsr.io/@fixture/data/1.0.0/mod.d.ts"].clone();
692    assert!(
693        target["declarations"]
694            .as_array()
695            .unwrap()
696            .iter()
697            .any(|handle| handle
698                .as_str()
699                .unwrap()
700                .ends_with(&format!(".{remote_file}"))),
701        "Import alias did not lead to remote declaration"
702    );
703    let local_references = api
704        .call(
705            "getReferencesToSymbolInFile",
706            json!({
707                "snapshot": snapshot["snapshot"], "project": project,
708                "file": main_f, "symbol": alias["reference"]
709            }),
710        )
711        .unwrap();
712    let local_references = local_references.as_array().unwrap();
713    assert!(
714        local_references.len() >= 2,
715        "Missing import/use reference handles"
716    );
717    let references = api
718        .call(
719            "getReferencedSymbolsForNode",
720            json!({
721                "snapshot": snapshot["snapshot"], "project": project,
722                "node": local_references[0],
723                "position": main_source.find("answer").unwrap()
724            }),
725        )
726        .unwrap();
727    let mut handles: Vec<String> = Vec::new();
728    for entry in references.as_array().unwrap() {
729        handles.push(entry["definition"].as_str().unwrap().to_owned());
730        handles.extend(
731            entry["references"]
732                .as_array()
733                .unwrap()
734                .iter()
735                .map(|h| h.as_str().unwrap().to_owned()),
736        );
737    }
738    let handle_original = |handle: &str| -> String {
739        let mut parts = handle.splitn(3, '.');
740        parts.next().unwrap();
741        parts.next().unwrap();
742        let file = parts.next().unwrap();
743        fixture
744            .borrow()
745            .original_to_file
746            .iter()
747            .find(|(_, candidate)| *candidate == file)
748            .map(|(original, _)| original.clone())
749            .unwrap_or_else(|| panic!("Unknown compiler file: {file}"))
750    };
751    let reference_originals: Vec<String> = handles.iter().map(|h| handle_original(h)).collect();
752    assert!(reference_originals.iter().any(|u| u == main_uri));
753    assert!(
754        reference_originals
755            .iter()
756            .any(|u| u == "https://jsr.io/@fixture/data/1.0.0/mod.d.ts")
757    );
758    pass(
759        &mut passed,
760        "cross-file definition/reference handles map back to original module URIs",
761    );
762
763    let fixed = main_source
764        .replace("number = \"bad\"", "number = 1")
765        .replace("count=\"wrong\"", "count={1}")
766        .replace("process.args.push(\"x\")", "process.args.includes(\"x\")");
767    snapshot = api
768        .call(
769            "updateSnapshot",
770            json!({
771                "snapshot": snapshot["snapshot"],
772                "changes": {
773                    "fileSystem": {"kind": "layer", "files": layer(vec![(main_f.clone(), fixed)])},
774                    "ensurePrograms": [project]
775                }
776            }),
777        )
778        .unwrap();
779    snapshots.push(snapshot["snapshot"].as_u64().unwrap());
780    assert_eq!(
781        diagnostics!(snapshot, [main_f.clone(), a_f.clone(), b_f.clone()])
782            .as_array()
783            .unwrap()
784            .len(),
785        0,
786        "Unsaved fixes did not clear diagnostics"
787    );
788    assert_eq!(
789        diagnostics!(initial, [main_f.clone(), a_f.clone(), b_f.clone()])
790            .as_array()
791            .unwrap()
792            .len(),
793        3,
794        "Old snapshot changed after edit"
795    );
796    pass(
797        &mut passed,
798        "incremental unsaved edits; old snapshot remains queryable and unchanged",
799    );
800
801    let b_edited = format!(
802        "{}\nimport {{ result as bypass }} from \"{}\"; void bypass;",
803        fixture.borrow().files[&b_f],
804        a_f
805    );
806    let denied = api
807        .call(
808            "updateSnapshot",
809            json!({
810                "snapshot": snapshot["snapshot"],
811                "changes": {
812                    "fileSystem": {"kind": "layer", "files": layer(vec![(b_f.clone(), b_edited)])},
813                    "ensurePrograms": [project]
814                }
815            }),
816        )
817        .unwrap();
818    snapshots.push(denied["snapshot"].as_u64().unwrap());
819    let denied_diagnostics = diagnostics!(denied, [main_f.clone(), a_f.clone(), b_f.clone()]);
820    let denied_diagnostics = denied_diagnostics.as_array().unwrap();
821    assert_eq!(
822        denied_diagnostics.len(),
823        1,
824        "Null resolver result fell back to an existing compiler file"
825    );
826    assert_eq!(denied_diagnostics[0]["code"], json!(2307));
827    assert_eq!(
828        diagnostics!(snapshot, [main_f.clone(), a_f.clone(), b_f.clone()])
829            .as_array()
830            .unwrap()
831            .len(),
832        0,
833        "Branch snapshot modified its parent"
834    );
835    pass(
836        &mut passed,
837        "unmapped imports stay unresolved even when the target exists in the virtual filesystem",
838    );
839
840    resolver_id = register_resolver(&mut api, &mut resolvers, &calls, &fixture, &options, true);
841    snapshot = api
842        .call(
843            "updateSnapshot",
844            json!({
845                "snapshot": snapshot["snapshot"],
846                "changes": {
847                    "reconfigurePrograms": [{
848                        "id": project, "rootFiles": roots, "compilerOptions": options,
849                        "options": {"moduleResolver": resolver_id}
850                    }]
851                }
852            }),
853        )
854        .unwrap();
855    snapshots.push(snapshot["snapshot"].as_u64().unwrap());
856    let remapped = diagnostics!(snapshot, [main_f.clone(), a_f.clone(), b_f.clone()]);
857    let remapped = remapped.as_array().unwrap();
858    assert_eq!(
859        remapped.len(),
860        2,
861        "Resolver replacement did not invalidate scoped imports"
862    );
863    assert!(
864        remapped.iter().all(|d| {
865            d["code"] == json!(2322) && (d["fileName"] == json!(a_f) || d["fileName"] == json!(b_f))
866        }),
867        "Resolver replacement did not invalidate scoped imports"
868    );
869    assert_eq!(
870        diagnostics!(initial, [main_f.clone(), a_f.clone(), b_f.clone()])
871            .as_array()
872            .unwrap()
873            .len(),
874        3,
875        "Resolver replacement affected the old snapshot"
876    );
877    pass(
878        &mut passed,
879        "import-map change: new resolver + reconfiguration invalidates cached resolutions",
880    );
881
882    let raw_options_dev = json!({
883        "strict": true, "noEmit": true, "skipLibCheck": true, "target": "esnext",
884        "module": "esnext", "moduleResolution": "bundler",
885        "allowImportingTsExtensions": true, "jsx": "react-jsxdev",
886        "jsxImportSource": "@fixture/dev-ui", "types": []
887    });
888    let development_options = parse_options(&mut api, &raw_options_dev, &roots);
889    resolver_id = register_resolver(
890        &mut api,
891        &mut resolvers,
892        &calls,
893        &fixture,
894        &development_options,
895        true,
896    );
897    snapshot = api
898        .call(
899            "updateSnapshot",
900            json!({
901                "snapshot": snapshot["snapshot"],
902                "changes": {
903                    "reconfigurePrograms": [{
904                        "id": project, "rootFiles": roots,
905                        "compilerOptions": development_options,
906                        "options": {"moduleResolver": resolver_id}
907                    }]
908                }
909            }),
910        )
911        .unwrap();
912    snapshots.push(snapshot["snapshot"].as_u64().unwrap());
913    let jsx_diagnostics = diagnostics!(snapshot, [main_f.clone(), a_f.clone(), b_f.clone()]);
914    {
915        let calls = calls.borrow();
916        assert!(
917            calls
918                .iter()
919                .any(|c| c["moduleName"] == json!("@fixture/dev-ui/jsx-dev-runtime")),
920            "JSX settings did not update implicit resolution"
921        );
922    }
923    assert!(
924        jsx_diagnostics.as_array().unwrap().iter().any(|d| {
925            d["fileName"] == json!(main_f)
926                && d["text"].as_str().unwrap().contains("jsx-dev-runtime")
927        }),
928        "Missing runtime was not diagnosed"
929    );
930    pass(
931        &mut passed,
932        "JSX configuration change re-resolves the generated runtime import",
933    );
934
935    // Negative control: same-directory filenames lose per-file scope identity.
936    let raw_calls = Rc::new(RefCell::new(Vec::<Value>::new()));
937    {
938        let raw_calls = raw_calls.clone();
939        let dep_a = fixture.borrow().original_to_file["file:///workspace/deps/a.ts"].clone();
940        api.callbacks.insert(
941            "avril/directory-control".to_owned(),
942            Box::new(move |params| {
943                if params["moduleName"] != json!("scoped") {
944                    return Ok(Value::Null);
945                }
946                raw_calls.borrow_mut().push(params.clone());
947                Ok(json!({"resolvedFileName": dep_a}))
948            }),
949        );
950    }
951    let control_resolver = api
952        .call(
953            "createModuleResolver",
954            json!({
955                "compilerOptions": options,
956                "resolveModuleNameCallback": "avril/directory-control"
957            }),
958        )
959        .unwrap()
960        .as_u64()
961        .unwrap();
962    resolvers.push(control_resolver);
963    let raw_a = "/avril-lsp/unmapped/a.ts";
964    let raw_b = "/avril-lsp/unmapped/b.ts";
965    let (a_source, b_source, dep_a_source) = {
966        let fixture = fixture.borrow();
967        (
968            fixture.files[&a_f].clone(),
969            fixture.files[&b_f].clone(),
970            fixture.files[&fixture.original_to_file["file:///workspace/deps/a.ts"]].clone(),
971        )
972    };
973    let dep_a_file = fixture.borrow().original_to_file["file:///workspace/deps/a.ts"].clone();
974    let control = api
975        .call(
976            "createSnapshot",
977            json!({
978                "fileSystem": {"kind": "layer", "files": layer(vec![
979                    (raw_a.to_owned(), a_source),
980                    (raw_b.to_owned(), b_source),
981                    (dep_a_file.clone(), dep_a_source),
982                ])},
983                "createPrograms": [{
984                    "rootFiles": [raw_a, raw_b], "compilerOptions": options,
985                    "options": {"moduleResolver": control_resolver}
986                }]
987            }),
988        )
989        .unwrap();
990    snapshots.push(control["snapshot"].as_u64().unwrap());
991    let control_diagnostics = api
992        .call(
993            "getSemanticDiagnostics",
994            json!({
995                "snapshot": control["snapshot"],
996                "project": control["operation"]["createdPrograms"][0]
997            }),
998        )
999        .unwrap();
1000    let control_diagnostics = control_diagnostics.as_array().unwrap();
1001    assert_eq!(
1002        control_diagnostics.len(),
1003        1,
1004        "Directory-only control did not reproduce the scope collision"
1005    );
1006    assert_eq!(control_diagnostics[0]["code"], json!(2322));
1007    assert_eq!(control_diagnostics[0]["fileName"], json!(raw_b));
1008    {
1009        let raw_calls = raw_calls.borrow();
1010        assert!(!raw_calls.is_empty());
1011        assert!(
1012            raw_calls.iter().all(|call| {
1013                call["containingDirectory"] == json!("/avril-lsp/unmapped")
1014                    && call.get("containingFile").is_none()
1015            }),
1016            "Callback now exposes a full referrer; reassess virtual directories"
1017        );
1018    }
1019    pass(
1020        &mut passed,
1021        "negative control: original same-directory filenames lose per-file scope identity",
1022    );
1023    {
1024        let fs_calls = fs_calls.borrow();
1025        assert!(
1026            fs_calls
1027                .iter()
1028                .any(|(method, _, allowed)| method == "readFile" && *allowed),
1029            "No standard library reads observed"
1030        );
1031        assert!(
1032            fs_calls.iter().any(|(_, _, allowed)| !allowed),
1033            "No out-of-library probes observed"
1034        );
1035    }
1036    pass(
1037        &mut passed,
1038        "filesystem callbacks serve standard libraries and deny other host-file probes",
1039    );
1040
1041    for method in [
1042        "getQuickInfoAtPosition",
1043        "getDefinitionAtPosition",
1044        "findRenameLocations",
1045        "textDocument/hover",
1046        "textDocument/definition",
1047        "textDocument/rename",
1048    ] {
1049        match api.call(
1050            method,
1051            json!({
1052                "snapshot": snapshot["snapshot"], "project": project,
1053                "file": main_f, "position": position
1054            }),
1055        ) {
1056            Err(CallError::Rpc { message, .. }) => {
1057                assert!(
1058                    message.contains("unknown API method"),
1059                    "Unexpected failure probing {method}: {message}"
1060                );
1061                println!("GAP {method}: not exposed by the API server");
1062            }
1063            other => panic!("Unexpectedly supported {method}: {other:?}"),
1064        }
1065    }
1066    pass(
1067        &mut passed,
1068        "feature-gap probes distinguish absent methods from project/type errors",
1069    );
1070    println!(
1071        "\n{} checks passed; {} resolver callbacks, {} retained snapshots.",
1072        passed.len(),
1073        calls.borrow().len(),
1074        snapshots.len()
1075    );
1076
1077    for snapshot_id in snapshots.iter().rev() {
1078        api.call("release", json!({"snapshot": snapshot_id}))
1079            .unwrap();
1080    }
1081    for resolver in &resolvers {
1082        api.call("releaseModuleResolver", json!({"resolver": resolver}))
1083            .unwrap();
1084    }
1085    api.close();
1086}