#[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}"
);
}