#[path = "common/lsp.rs"] #[allow(dead_code)] mod support; use std::fs; use std::io::{BufReader, Cursor}; use std::path::PathBuf; use serde_json::{Value, json}; use support::{Server, backend, hover_text, position_at, read_frame, selected, write_frame}; fn query(uri: &str, source: &str, word: &str, last: bool) -> Value { let offset = if last { source.rfind(word).unwrap() } else { source.find(word).unwrap() } + 1; json!({ "textDocument": {"uri": uri}, "position": position_at(source, offset) }) } #[test] fn framing_survives_split_utf8_and_coalesced_messages() { let mut bytes = Vec::new(); write_frame( &mut bytes, &json!({"jsonrpc": "2.0", "id": 1, "result": "πŸ˜€"}), ) .unwrap(); write_frame( &mut bytes, &json!({"jsonrpc": "2.0", "id": 2, "result": "next"}), ) .unwrap(); // A one-byte buffer forces every frame to arrive in pieces. let mut split = BufReader::with_capacity(1, Cursor::new(bytes.clone())); let mut results = Vec::new(); while let Some(message) = read_frame(&mut split).unwrap() { results.push(message["result"].clone()); } assert_eq!(results, [json!("πŸ˜€"), json!("next")]); let mut coalesced = BufReader::new(Cursor::new(bytes)); let mut results = Vec::new(); while let Some(message) = read_frame(&mut coalesced).unwrap() { results.push(message["result"].clone()); } assert_eq!(results, [json!("πŸ˜€"), json!("next")]); } #[test] fn native_backend_through_real_lsp_messages() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::Builder::new() .prefix("avril lsp # % ΓΌ-") .tempdir() .unwrap(); let root = dir.path(); let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../lib/lsp/tests/fixture"); for name in [ "avril.conf", "tsconfig.json", "main.tsx", "dep file.ts", "a.ts", "b.ts", "scope-a.ts", "scope-b.ts", "jsx-runtime.d.ts", "data.d.ts", ] { fs::write(root.join(name), fs::read(fixture.join(name)).unwrap()).unwrap(); } let mut server = Server::start(root, &backend, &["--offline"]); let main = server.uri("main.tsx"); let dep = server.uri("dep file.ts"); let text = fs::read_to_string(root.join("main.tsx")).unwrap(); let dep_text = fs::read_to_string(root.join("dep file.ts")).unwrap(); let initialize = server .call( "initialize", json!({"capabilities": {"textDocument": {"definition": {"linkSupport": true}}}}), ) .unwrap(); assert_eq!( initialize["capabilities"]["positionEncoding"], json!("utf-16") ); assert!(initialize["capabilities"].get("renameProvider").is_some()); assert!( initialize["capabilities"] .pointer("/workspace/fileOperations") .is_none() ); assert_eq!( initialize["capabilities"]["completionProvider"]["resolveProvider"], json!(true) ); assert!( initialize["capabilities"] .get("signatureHelpProvider") .is_some() ); server.notify("initialized", json!({})); let unsupported = server .call("workspace/willRenameFiles", json!({"files": []})) .unwrap_err(); assert_eq!(unsupported.code, -32601); server.open_as("main.tsx", &text, 1, "typescriptreact"); // Hover, aliases, shorthand, original URI and exact Unicode ranges. let hover = server .call( "textDocument/hover", query(&main, &text, "localValue", true), ) .unwrap(); assert!(hover_text(&hover).contains("localValue: 42")); assert_eq!(selected(&text, &hover["range"]), "localValue"); let links = server .call( "textDocument/definition", query(&main, &text, "localValue", false), ) .unwrap(); assert_eq!(links.as_array().unwrap().len(), 1); assert_eq!(links[0]["targetUri"], json!(dep)); assert_eq!( selected(&dep_text, &links[0]["targetSelectionRange"]), "value" ); assert_eq!( links[0]["targetSelectionRange"]["start"], position_at(&dep_text, dep_text.find("value").unwrap()) ); assert!(!links.to_string().contains("/avril-lsp/")); // Original declaration aliases and read-only builtin declarations. let links = server .call( "textDocument/definition", query(&main, &text, "answer", false), ) .unwrap(); let declaration = &links[0]; assert_eq!(declaration["targetUri"], json!(server.uri("data.d.ts"))); let data_text = fs::read_to_string(root.join("data.d.ts")).unwrap(); assert_eq!( selected(&data_text, &declaration["targetSelectionRange"]), "answer" ); let links = server .call("textDocument/definition", query(&main, &text, "args", true)) .unwrap(); let builtin = &links[0]; let builtin_uri = builtin["targetUri"].as_str().unwrap(); assert!( builtin_uri.ends_with("/runtime/stdlib/std.d.ts"), "{builtin_uri}" ); let builtin_text = if builtin_uri.starts_with("file:") { let path = url::Url::parse(builtin_uri) .unwrap() .to_file_path() .unwrap(); fs::read_to_string(path).unwrap() } else { server .call("avril/readDocument", json!({"uri": builtin_uri})) .unwrap() .as_str() .unwrap() .to_owned() }; assert_eq!( selected(&builtin_text, &builtin["targetSelectionRange"]), "args" ); // Split builtin declarations retain cross-module types and globals. let source = [ "import { copy, type CopyOptions } from \"avril:fs/sync\";", "import { normalize, type ParsedPath } from \"avril:path/win32\";", "import { stringify } from \"avril:fjson\";", "import { test, type TestContext } from \"avril:test\";", "test(\"typed\", async (t) => { const passed: boolean = await t.step(\"step\", () => {}); });", "const contextName = (context: TestContext): string => context.name;", "const serialized: string = stringify({ answer: 42 });", "const options: CopyOptions = { dereference: true };", "const parsed: ParsedPath = { root: \"\", dir: \"\", base: \"\", name: \"\", ext: \"\" };", "copy(\"a\", normalize(\"b\"), options);", "const main: boolean = import.meta.main;", "const invalid: CopyOptions = { dereference: \"wrong\" };", "export { parsed, main, invalid };", ] .join("\n"); fs::write(root.join("builtins.ts"), &source).unwrap(); server.open("builtins.ts", &source); for (symbol, file) in [ ("copy", "fs/sync.d.ts"), ("normalize", "path/win32.d.ts"), ("CopyOptions", "fs.d.ts"), ("stringify", "fjson.d.ts"), ("test", "test.d.ts"), ("TestContext", "test.d.ts"), ] { let links = server .call( "textDocument/definition", query(&server.uri("builtins.ts"), &source, symbol, true), ) .unwrap(); let link = &links[0]; let target = link["targetUri"].as_str().unwrap(); assert!( target.ends_with(&format!("/runtime/stdlib/{file}")), "{target}" ); let declaration = server .call("avril/readDocument", json!({"uri": target})) .unwrap() .as_str() .unwrap() .to_owned(); assert_eq!( selected(&declaration, &link["targetSelectionRange"]), symbol ); } let codes: Vec = server .diagnostics("builtins.ts") .iter() .map(|diagnostic| diagnostic["code"].clone()) .collect(); assert_eq!(codes.iter().filter(|code| **code == json!(2322)).count(), 1); server.notify( "textDocument/didClose", json!({"textDocument": {"uri": server.uri("builtins.ts")}}), ); // File-specific scopes survive editor URI mapping. for (name, target) in [("a.ts", "scope-a.ts"), ("b.ts", "scope-b.ts")] { let source = fs::read_to_string(root.join(name)).unwrap(); server.open(name, &source); let links = server.definitions(name, &source, "value"); assert_eq!(links[0]["targetUri"], json!(server.uri(target))); } // Unsaved dependency edits update hover, locations and dependent diagnostics. let changed = "\r\n// πŸ˜€ unsaved\r\nexport const value = \"edited\" as const;\r\n"; server.open("dep file.ts", &dep_text); server.change("dep file.ts", changed, 2); assert!( server .hover("main.tsx", &text, "localValue") .contains("localValue: \"edited\"") ); let links = server .call( "textDocument/definition", query(&main, &text, "localValue", false), ) .unwrap(); assert_eq!( links[0]["targetSelectionRange"]["start"], position_at(changed, changed.find("value").unwrap()) ); assert!( server .diagnostics("main.tsx") .iter() .any(|diagnostic| diagnostic["code"] == json!(2322)) ); // Stale versions are rejected; closing reverts to the on-disk source. server.change("dep file.ts", &dep_text, 1); assert!( server .hover("main.tsx", &text, "localValue") .contains("localValue: \"edited\"") ); server.notify( "textDocument/didClose", json!({"textDocument": {"uri": dep}}), ); assert!( server .hover("main.tsx", &text, "localValue") .contains("localValue: 42") ); // Escaped identifier ranges and re-export targets come from native ASTs. let escaped = "export const \\u0076alue = 99 as const;\n"; server.open_version("dep file.ts", escaped, 1); let links = server .call( "textDocument/definition", query(&main, &text, "localValue", false), ) .unwrap(); assert_eq!( selected(escaped, &links[0]["targetSelectionRange"]), "\\u0076alue" ); assert!( server .hover("main.tsx", &text, "localValue") .contains("localValue: 99") ); server.change( "dep file.ts", "export { value } from \"./scope-a.ts\";\n", 2, ); let links = server .call( "textDocument/definition", query(&main, &text, "localValue", false), ) .unwrap(); assert_eq!(links[0]["targetUri"], json!(server.uri("scope-a.ts"))); server.notify( "textDocument/didClose", json!({"textDocument": {"uri": dep}}), ); // Namespace hover does not expose compiler-only paths. let source = format!("{text}\nimport * as dep from \"./dep file.ts\"; dep;"); server.change("main.tsx", &source, 2); let hover = server .call("textDocument/hover", query(&main, &source, "dep", true)) .unwrap(); let value = hover_text(&hover); assert!(value.contains("./dep file.ts"), "{value}"); assert!(!value.contains("/avril-lsp/"), "{value}"); // Comments return no navigation or suggestions; unknown files are rejected. let comments = format!("// localValue\n{text}"); server.change("main.tsx", &comments, 3); assert_eq!( server .call( "textDocument/hover", query(&main, &comments, "localValue", false) ) .unwrap(), Value::Null ); let completions = server .call( "textDocument/completion", query(&main, &comments, "localValue", false), ) .unwrap(); assert!( completions.is_null() || completions["items"] .as_array() .is_some_and(|items| items.is_empty()), "{completions}" ); let unknown = server.request( "textDocument/hover", query("file:///not-in-project.ts", "value", "value", true), ); assert_eq!(unknown["error"]["code"], json!(-32602), "{unknown}"); assert!( unknown["error"]["message"] .as_str() .unwrap() .contains("Document not in this project") ); let unknown = server.request( "textDocument/completion", query("file:///not-in-project.ts", "value", "value", true), ); assert_eq!(unknown["error"]["code"], json!(-32602), "{unknown}"); assert!( unknown["error"]["message"] .as_str() .unwrap() .contains("Document not in this project") ); assert_eq!( server .call( "textDocument/definition", query(&main, &comments, "localValue", false) ) .unwrap(), json!([]) ); server.close(); }