#[path = "common/lsp.rs"] #[allow(dead_code)] mod support; use serde_json::{Value, json}; use support::{Server, backend, files, position_at}; fn request(uri: &str, text: &str, offset: usize) -> Value { json!({ "textDocument": {"uri": uri}, "position": position_at(text, offset) }) } #[test] fn native_language_service_capabilities_and_requests() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let source = [ "import { process } from \"avril:std\";", "export interface Shape { area(): number }", "export class Square implements Shape {", " constructor(public size: number) {}", " area() { return this.size ** 2; }", "}", "export class LargeSquare extends Square {}", "const emoji = \"😀\";", "const square:Shape=new Square(2);", "function inferred() { return square.area(); }", "square.area();", "const markup =
{square.area()}
;", "process.args;", ] .join("\r\n"); files( root, &[ ("main.tsx", &source), ( "avril.conf", &json!({ "scopes": { "./a.ts": {"scoped": "./one.ts"}, "./b.ts": {"scoped": "./two.ts"} } }) .to_string(), ), ("a.ts", "import {value} from \"scoped\"; value.aaa;"), ("b.ts", "import {value} from \"scoped\"; value.bbb;"), ("one.ts", "export const value = { aaa: 1 } as const;"), ("two.ts", "export const value = { bbb: 2 } as const;"), ], ); let mut server = Server::start(root, &backend, &["--offline"]); let initialized = server.initialize_params(json!({ "initializationOptions": { "preferences": { "includeInlayParameterNameHints": "all", "includeInlayFunctionLikeReturnTypeHints": true, "includeInlayVariableTypeHints": true } }, "capabilities": { "textDocument": { "definition": {"linkSupport": true}, "completion": { "completionItem": { "snippetSupport": true, "insertReplaceSupport": true, "resolveSupport": {"properties": ["documentation", "detail"]} } }, "semanticTokens": { "requests": {"full": true, "range": true}, "tokenTypes": ["namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator"], "tokenModifiers": ["declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary"], "formats": ["relative"] }, "inlayHint": {"dynamicRegistration": true} }, "workspace": {"workspaceFolders": true} } })); let caps = &initialized["capabilities"]; for name in [ "hoverProvider", "completionProvider", "signatureHelpProvider", "definitionProvider", "typeDefinitionProvider", "implementationProvider", "callHierarchyProvider", "referencesProvider", "documentHighlightProvider", "renameProvider", "documentSymbolProvider", "workspaceSymbolProvider", "foldingRangeProvider", "selectionRangeProvider", "documentFormattingProvider", "documentRangeFormattingProvider", "semanticTokensProvider", "inlayHintProvider", "codeActionProvider", "linkedEditingRangeProvider", ] { assert!( caps.get(name).is_some_and(|value| !value.is_null()), "Missing native capability {name}: {caps}" ); } assert_eq!(caps["completionProvider"]["resolveProvider"], json!(true)); assert!(caps.get("typeHierarchyProvider").is_none()); server.open_as("main.tsx", &source, 1, "typescriptreact"); server.notify( "workspace/didChangeConfiguration", json!({"settings":{"preferences":{ "includeInlayParameterNameHints":"all", "includeInlayFunctionLikeReturnTypeHints":true, "includeInlayVariableTypeHints":true }}}), ); let uri = server.uri("main.tsx"); let completion_offset = source.find("square.area").unwrap() + "square.".len(); let completions = server.complete("main.tsx", &source, completion_offset, Some(".")); let area = completions["items"] .as_array() .and_then(|items| items.iter().find(|item| item["label"] == "area")) .expect("Native member completion should include area"); assert!( area.get("data").is_some(), "Resolve data must be opaque but present: {area}" ); let resolved = server.call("completionItem/resolve", area.clone()).unwrap(); assert_eq!(resolved["label"], "area"); server.change("main.tsx", &format!("{source}\n"), 2); let stale = server.request("completionItem/resolve", area.clone()); assert_eq!(stale["error"]["code"], json!(-32801), "{stale}"); let square = source.rfind("square.area").unwrap(); let params = request(&uri, &source, square + 2); let hover = server.call("textDocument/hover", params.clone()).unwrap(); assert!( !hover.is_null(), "Native hover should describe the selected member" ); let type_definition = server .call("textDocument/typeDefinition", params.clone()) .unwrap(); assert!( type_definition .as_array() .is_some_and(|locations| !locations.is_empty()), "{type_definition}" ); let implementations = server .call( "textDocument/implementation", json!({"textDocument":{"uri":uri},"position":position_at(&source, source.find("area(): number").unwrap() + 1)}), ) .unwrap(); assert!( implementations .as_array() .is_some_and(|locations| !locations.is_empty()), "{implementations}" ); let call_hierarchy = server .call( "textDocument/prepareCallHierarchy", json!({"textDocument":{"uri":uri},"position":position_at(&source, square + "square.".len() + 1)}), ) .unwrap(); let call_item = call_hierarchy .as_array() .and_then(|items| items.first()) .expect("Call hierarchy should prepare area"); let incoming = server .call("callHierarchy/incomingCalls", json!({"item":call_item})) .unwrap(); assert!( incoming.as_array().is_some_and(|calls| !calls.is_empty()), "{incoming}" ); let references = server .call( "textDocument/references", json!({"textDocument":{"uri":uri},"position":position_at(&source, square + 2),"context":{"includeDeclaration":true}}), ) .unwrap(); assert!( references .as_array() .is_some_and(|locations| locations.len() >= 2), "{references}" ); let a_text = std::fs::read_to_string(root.join("a.ts")).unwrap(); let b_text = std::fs::read_to_string(root.join("b.ts")).unwrap(); let one_text = std::fs::read_to_string(root.join("one.ts")).unwrap(); let two_text = std::fs::read_to_string(root.join("two.ts")).unwrap(); for (path, text) in [ ("a.ts", a_text.as_str()), ("b.ts", b_text.as_str()), ("one.ts", one_text.as_str()), ("two.ts", two_text.as_str()), ] { server.open(path, text); } let refs = server .call( "textDocument/references", json!({"textDocument":{"uri":server.uri("one.ts")},"position":position_at(&one_text, one_text.find("value").unwrap() + 1),"context":{"includeDeclaration":true}}), ) .unwrap(); let ref_uris: Vec<_> = refs .as_array() .unwrap() .iter() .map(|location| location["uri"].as_str().unwrap()) .collect(); assert!(ref_uris.contains(&server.uri("one.ts").as_str()), "{refs}"); assert!(ref_uris.contains(&server.uri("a.ts").as_str()), "{refs}"); assert!(!ref_uris.contains(&server.uri("two.ts").as_str()), "{refs}"); assert!(!ref_uris.contains(&server.uri("b.ts").as_str()), "{refs}"); let cross_project_rename = server .call( "textDocument/rename", json!({"textDocument":{"uri":server.uri("one.ts")},"position":position_at(&one_text, one_text.find("value").unwrap() + 1),"newName":"renamedValue"}), ) .unwrap(); let changed_uris: Vec<_> = cross_project_rename["changes"] .as_object() .expect("rename changes") .keys() .map(String::as_str) .collect(); assert!( changed_uris.contains(&server.uri("one.ts").as_str()), "{cross_project_rename}" ); assert!( changed_uris.contains(&server.uri("a.ts").as_str()), "{cross_project_rename}" ); assert!( !changed_uris.contains(&server.uri("two.ts").as_str()), "{cross_project_rename}" ); assert!( !changed_uris.contains(&server.uri("b.ts").as_str()), "{cross_project_rename}" ); let highlights = server .call("textDocument/documentHighlight", params.clone()) .unwrap(); assert!(!highlights.as_array().unwrap().is_empty(), "{highlights}"); let rename = server .call( "textDocument/prepareRename", json!({"textDocument":{"uri":uri},"position":position_at(&source, square + 2)}), ) .unwrap(); assert!( !rename.is_null(), "prepareRename should return the native rename range" ); let edit = server .call( "textDocument/rename", json!({"textDocument":{"uri":uri},"position":position_at(&source, square + 2),"newName":"measure"}), ) .unwrap(); assert!( edit["changes"] .as_object() .is_some_and(|changes| !changes.is_empty()) || edit["documentChanges"] .as_array() .is_some_and(|changes| !changes.is_empty()), "{edit}" ); let readonly_rename = server.request( "textDocument/rename", json!({ "textDocument":{"uri":uri}, "position":position_at(&source, source.rfind("args").unwrap() + 1), "newName":"arguments" }), ); assert!( readonly_rename.get("error").is_some(), "Read-only builtin rename must fail atomically: {readonly_rename}" ); let symbols = server .call( "textDocument/documentSymbol", json!({"textDocument":{"uri":uri}}), ) .unwrap(); assert!( symbols .as_array() .is_some_and(|items| items.iter().any(|item| item["name"] == "Square")), "{symbols}" ); let workspace_symbols = server .call("workspace/symbol", json!({"query":"LargeSquare"})) .unwrap(); assert!( workspace_symbols .as_array() .is_some_and(|items| items.iter().any(|item| item["name"] == "LargeSquare")), "{workspace_symbols}" ); let folding = server .call( "textDocument/foldingRange", json!({"textDocument":{"uri":uri}}), ) .unwrap(); assert!(!folding.as_array().unwrap().is_empty(), "{folding}"); let selections = server .call( "textDocument/selectionRange", json!({"textDocument":{"uri":uri},"positions":[position_at(&source, square + 2)]}), ) .unwrap(); assert_eq!(selections.as_array().unwrap().len(), 1); let full_range = json!({"start":position_at(&source, 0),"end":position_at(&source, source.len())}); let formatting = server .call( "textDocument/formatting", json!({"textDocument":{"uri":uri},"options":{"tabSize":2,"insertSpaces":true}}), ) .unwrap(); assert!( formatting.as_array().is_some_and(|edits| !edits.is_empty()), "{formatting}" ); let range_formatting = server .call( "textDocument/rangeFormatting", json!({ "textDocument":{"uri":uri}, "range":full_range, "options":{"tabSize":2,"insertSpaces":true} }), ) .unwrap(); assert!(range_formatting.as_array().is_some(), "{range_formatting}"); let semantic = server .call( "textDocument/semanticTokens/full", json!({"textDocument":{"uri":uri}}), ) .unwrap(); assert!( semantic["data"] .as_array() .is_some_and(|data| !data.is_empty()), "{semantic}" ); let ranged = server .call( "textDocument/semanticTokens/range", json!({"textDocument":{"uri":uri},"range":full_range}), ) .unwrap(); assert!(!ranged["data"].as_array().unwrap().is_empty(), "{ranged}"); let hints = server .call( "textDocument/inlayHint", json!({"textDocument":{"uri":uri},"range":full_range}), ) .unwrap(); assert!( hints.as_array().is_some_and(|hints| !hints.is_empty()), "Configured native inlay preferences should produce hints: {hints}" ); let actions = server .call( "textDocument/codeAction", json!({"textDocument":{"uri":uri},"range":full_range,"context":{"diagnostics":[]}}), ) .unwrap(); assert!(actions.as_array().is_some(), "{actions}"); let unsupported_hierarchy = server.request( "textDocument/prepareTypeHierarchy", json!({"textDocument":{"uri":uri},"position":position_at(&source, source.find("Square implements").unwrap() + 2)}), ); assert_eq!( unsupported_hierarchy["error"]["code"], json!(-32601), "Pinned TypeScript has protocol types but no type-hierarchy implementation: {unsupported_hierarchy}" ); let linked_source = "const same =
;"; server.change("main.tsx", linked_source, 3); let linked = server .call( "textDocument/linkedEditingRange", json!({"textDocument":{"uri":uri},"position":position_at(linked_source, linked_source.find("div").unwrap() + 1)}), ) .unwrap(); assert!( linked["ranges"] .as_array() .is_some_and(|ranges| ranges.len() >= 2), "{linked}" ); }