#[path = "common/lsp.rs"] #[allow(dead_code)] mod support; use serde_json::{Value, json}; use support::{Server, backend, files}; fn items(result: &Value) -> &[Value] { assert!( !result.to_string().contains("/avril-lsp/"), "Completion leaked a compiler-internal filename: {result}" ); result["items"].as_array().expect("CompletionList.items") } fn find<'a>(items: &'a [Value], label: &str) -> &'a Value { items .iter() .find(|item| item["label"] == label) .unwrap_or_else(|| panic!("Missing completion {label}: {items:?}")) } fn labels(items: &[Value]) -> Vec<&str> { items .iter() .map(|item| item["label"].as_str().unwrap()) .collect() } fn has_no_items(result: &Value) -> bool { result.is_null() || result.as_array().is_some_and(|items| items.is_empty()) || result["items"] .as_array() .is_some_and(|items| items.is_empty()) } #[test] fn native_completions_resolve_and_follow_the_live_project_graph() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let main = [ "import { record } from \"alias\";", "import { process } from \"avril:std\";", "import \"./a.ts\";", "import \"./b.ts\";", "import \"./second.ts\";", "import \"./auto.ts\";", "import \"./named.ts\";", "import \"./astral.ts\";", "import \"./dangle.ts\";", "import \"./fsimp.ts\";", "import \"./element.ts\";", "import \"./union.ts\";", "import \"./tmpl.ts\";", "const localName = 1;", "record.hello;", "process.args;", "loc;", ] .join("\n"); files( root, &[ ("main.ts", &main), ( "avril.conf", &json!({ "imports": {"alias": "./dep.ts"}, "scopes": { "./a.ts": {"scoped": "./one.ts"}, "./b.ts": {"scoped": "./two.ts"} } }) .to_string(), ), ( "dep.ts", "export const record: { hello: number; extra?: number; \"not-valid\": number } = { hello: 1, \"not-valid\": 2 };", ), ("one.ts", "export const value = { aaa: 1 } as const;"), ("two.ts", "export const value = { bbb: 2 } as const;"), ("a.ts", "import {value} from \"scoped\"; value.aaa;"), ("b.ts", "import {value} from \"scoped\"; value.bbb;"), ("second.ts", "export const farAwayName = 1;"), ("dangle.ts", "import { record } from \"alias\";\nrecord."), ("fsimp.ts", "import { read } from \"avril:fs\";"), ( "element.ts", "import { record } from \"alias\";\nrecord[\"\"]", ), ( "union.ts", "declare function take(flag: \"alpha\" | \"beta\"): void;\ntake(\"\");", ), ( "tmpl.ts", "import { record } from \"alias\";\nconst view = `${record.}`;", ), ("auto.ts", "import { record } from \"alias\";\nfar;\n"), ("named.ts", "import { } from \"alias\";\n"), ("astral.ts", "const placeholder = 0;"), ], ); let mut server = Server::start(root, &backend, &["--offline"]); server.initialize_with(json!({ "textDocument": { "completion": { "completionItem": { "snippetSupport": true, "insertReplaceSupport": true } } } })); server.open("main.ts", &main); let offset = main.find("record.hello;").unwrap() + "record.".len(); let result = server.complete("main.ts", &main, offset, None); let members = items(&result); let hello = find(members, "hello"); assert_eq!(hello["kind"], json!(5)); let optional = find(members, "extra?"); assert_eq!(optional["filterText"], "extra"); let triggered = server.complete("main.ts", &main, offset, Some(".")); assert!(labels(items(&triggered)).contains(&"hello")); let resolved = server .call("completionItem/resolve", hello.clone()) .unwrap(); assert_eq!(resolved["label"], "hello"); assert!( resolved.get("detail").is_some(), "Native completion resolve should add detail: {resolved}" ); let stale = hello.clone(); let edited = "export const record: { hello: number; extra?: number; added: boolean } = { hello: 1, added: true };"; server.open("dep.ts", edited); let updated_result = server.complete("main.ts", &main, offset, None); let updated = items(&updated_result); assert!(labels(updated).contains(&"added")); let stale = server.request("completionItem/resolve", stale); assert_eq!(stale["error"]["code"], json!(-32801), "{stale}"); server.notify( "textDocument/didClose", json!({"textDocument": {"uri": server.uri("dep.ts")}}), ); assert!(!labels(items(&server.complete("main.ts", &main, offset, None))).contains(&"added")); let builtin_result = server.complete( "main.ts", &main, main.find("process.args;").unwrap() + "process.".len(), None, ); let builtin = items(&builtin_result); assert!(labels(builtin).contains(&"args")); assert!(labels(builtin).contains(&"cwd")); for (path, present, absent) in [("a.ts", "aaa", "bbb"), ("b.ts", "bbb", "aaa")] { let text = std::fs::read_to_string(root.join(path)).unwrap(); server.open(path, &text); let result = server.complete( path, &text, text.find("value.").unwrap() + "value.".len(), None, ); assert!(labels(items(&result)).contains(&present)); assert!(!labels(items(&result)).contains(&absent)); } let astral = "const localName = 1;\r\nconst e = \"😀\"; loc;\r\n"; server.open("astral.ts", astral); let result = server.complete( "astral.ts", astral, astral.find("loc;").unwrap() + "loc".len(), None, ); let local = find(items(&result), "localName"); assert_eq!( local["textEdit"]["insert"]["start"]["line"], json!(1), "{local}" ); assert_eq!(local["textEdit"]["insert"]["start"]["character"], json!(16)); assert_eq!(local["textEdit"]["replace"]["end"]["character"], json!(19)); server.open("second.ts", "export const farAwayName = 1;"); let auto = "import { record } from \"alias\";\nfar;\n"; server.open("auto.ts", auto); let result = server.complete("auto.ts", auto, auto.find("far;").unwrap() + 3, None); let imported = find(items(&result), "farAwayName"); let resolved_import = server .call("completionItem/resolve", imported.clone()) .unwrap(); assert!( !resolved_import.to_string().contains("/avril-lsp/"), "Resolved auto-import leaked a synthetic source: {resolved_import}" ); let import_edits = resolved_import["additionalTextEdits"] .as_array() .expect("Auto-import edits should be attached during completion resolve"); assert!( import_edits.iter().any(|edit| { edit["newText"] .as_str() .is_some_and(|text| text.contains("farAwayName") && text.contains("./second.ts")) }), "Resolved auto-import should use the graph's canonical specifier ./second.ts: {resolved_import}" ); let named = "import { } from \"alias\";\n"; server.open("named.ts", named); let import_result = server.complete("named.ts", named, "import { ".len(), None); let imports = items(&import_result); assert!(labels(imports).contains(&"record")); let fs_import = "import { read } from \"avril:fs\";"; server.open("fsimp.ts", fs_import); let result = server.complete( "fsimp.ts", fs_import, fs_import.find("read }").unwrap() + "read".len(), None, ); assert!(labels(items(&result)).contains(&"readTextFile")); let element = "import { record } from \"alias\";\nrecord[\"\"];"; server.open("element.ts", element); let result = server.complete( "element.ts", element, element.find("\"\"").unwrap() + 1, None, ); assert!( labels(items(&result)) .iter() .any(|label| label.contains("not-valid")), "String-literal property completion should preserve non-identifier keys" ); let union = "declare function take(flag: \"alpha\" | \"beta\"): void;\ntake(\"\");"; server.open("union.ts", union); let result = server.complete("union.ts", union, union.find("\"\"").unwrap() + 1, None); assert!( labels(items(&result)) .iter() .any(|label| label.contains("alpha")), "Native literal-union completion should expose its allowed values" ); let template = "import { record } from \"alias\";\nconst view = `${record.}`;"; server.open("tmpl.ts", template); let result = server.complete( "tmpl.ts", template, template.find("record.").unwrap() + "record.".len(), None, ); assert!(labels(items(&result)).contains(&"hello")); let quiet = "// record.hello;\nconst s = \"record.hello;\";\n"; server.change("main.ts", quiet, 2); assert!(has_no_items(&server.complete( "main.ts", quiet, "// record.".len(), None ))); assert!(has_no_items(&server.complete( "main.ts", quiet, quiet.find("\"record.").unwrap() + "\"record.".len(), None ))); let missing = server.request( "textDocument/completion", json!({ "textDocument": {"uri": server.uri("missing.ts")}, "position": {"line": 0, "character": 1} }), ); assert_eq!(missing["error"]["code"], json!(-32602), "{missing}"); assert!( missing["error"]["message"] .as_str() .unwrap() .contains("Document not in this project") ); let dangling = "import { record } from \"alias\";\nrecord."; server.open("dangle.ts", dangling); assert!( labels(items(&server.complete( "dangle.ts", dangling, dangling.len(), None ))) .contains(&"hello") ); server.close(); }