#[path = "common/lsp.rs"] #[allow(dead_code)] mod lsp_support; #[path = "common/registry.rs"] mod registry_support; use lsp_support::{Server, backend, files, position_at}; use registry_support::Registry; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; fn files_below(root: &Path) -> Vec { let mut found = Vec::new(); let mut pending = vec![root.to_owned()]; while let Some(directory) = pending.pop() { let Ok(entries) = std::fs::read_dir(directory) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { pending.push(path); } else { found.push(path); } } } found } #[test] fn malformed_continuations_and_empty_completions_leave_the_server_usable() { let Some(backend) = backend() else { return; }; let dir = tempfile::tempdir().unwrap(); let source = "// comment\nconst value = 42;\nvalue;"; files(dir.path(), &[("main.ts", source)]); let mut server = Server::start(dir.path(), &backend, &[]); server.initialize(); server.open("main.ts", source); for method in ["callHierarchy/incomingCalls", "callHierarchy/outgoingCalls"] { for params in [json!([]), json!(42), Value::Null] { let error = server.call(method, params).unwrap_err(); assert_eq!(error.code, -32602, "{error:?}"); } } let uri = server.uri("main.ts"); assert_eq!( server .call( "textDocument/completion", json!({ "textDocument":{"uri":uri},"position":{"line":0,"character":3} }) ) .unwrap(), Value::Null ); let error = server .call( "textDocument/hover", json!({ "textDocument":{"uri":uri},"position":{"line":-1,"character":0} }), ) .unwrap_err(); assert_eq!(error.code, -32602, "{error:?}"); assert!(server.hover("main.ts", source, "value").contains("42")); server.close(); } #[test] fn immutable_npm_sources_and_workspace_completions_respect_boundaries() { let Some(backend) = backend() else { eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set"); return; }; let registry = Registry::new(); registry.publish_npm( "typed", "1.0.0", &[ ( "package.json", r#"{"name":"typed","version":"1.0.0","types":"index.d.ts"}"#, ), ("index.d.ts", "export declare const answer: number;\n"), ], ); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let main = "import { answer } from \"typed\";\nconst local = answer;\nimport \"./\";\n"; files( root, &[ ("main.ts", main), ("sibling.ts", "export const sibling = true;\n"), ("my file.ts", "export const spacedExport = true;\n"), ("café.ts", "export const unicodeExport = true;\n"), ( "isolated/tsconfig.json", r#"{"compilerOptions":{"strictNullChecks":false}}"#, ), ("isolated/export.ts", "export const scopedExport = true;\n"), ( "avril.conf", &json!({"imports":{"typed":"npm:typed@1.0.0"}}).to_string(), ), ], ); let mut server = Server::start( root, &backend, &[&format!("--npm-registry={}", registry.url)], ); server.initialize(); server.open("main.ts", main); let reference = main.find("answer;\nimport").unwrap(); let definition = server .call( "textDocument/definition", json!({ "textDocument":{"uri":server.uri("main.ts")}, "position":position_at(main, reference + 1) }), ) .unwrap(); let declaration = definition .as_array() .and_then(|items| items.first()) .expect("type definition should resolve to the package declaration"); let declaration_uri = declaration .get("targetUri") .or_else(|| declaration.get("uri")) .and_then(Value::as_str) .unwrap_or_else(|| panic!("Missing definition URI: {definition}")); assert!( declaration_uri.starts_with("avril-builtin:/dependencies"), "{declaration}" ); let bytes = server .call("avril/readDocument", json!({"uri":declaration_uri})) .unwrap(); assert_eq!(bytes, json!("export declare const answer: number;\n")); let rename = server.request( "textDocument/rename", json!({ "textDocument":{"uri":declaration_uri}, "position":position_at("export declare const answer: number;\n", "export declare const ".len() + 2), "newName":"renamedAnswer" }), ); assert_eq!(rename["error"]["code"], json!(-32803), "{rename}"); assert!( rename.get("result").is_none(), "read-only rename must not partially edit workspace references: {rename}" ); assert_eq!( server .call( "textDocument/completion", json!({ "textDocument":{"uri":declaration_uri},"position":{"line":0,"character":0} }) ) .unwrap(), Value::Null ); assert_eq!(server.call("textDocument/codeAction", json!({ "textDocument":{"uri":declaration_uri},"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}, "context":{"diagnostics":[],"only":["quickfix"]} })).unwrap(), json!([])); let overlay = "export declare const poisoned: string;\n"; for uri in [ declaration_uri.to_owned(), url::Url::from_file_path( files_below(&root.join(".avril")) .into_iter() .find(|path| path.file_name().is_some_and(|name| name == "index.d.ts")) .expect("materialized npm declaration"), ) .unwrap() .to_string(), ] { server.notify( "textDocument/didOpen", json!({"textDocument":{ "uri":uri,"languageId":"typescript","version":1,"text":overlay }}), ); } let hover = server.call( "textDocument/hover", json!({"textDocument":{"uri":server.uri("main.ts")},"position":position_at(main, reference + 1)}), ).unwrap(); assert!( hover.to_string().contains("number"), "trusted npm declaration should remain in effect: {hover}" ); assert!( !hover.to_string().contains("poisoned"), "immutable overlays must be ignored: {hover}" ); assert_eq!( server .call("avril/readDocument", json!({"uri":declaration_uri})) .unwrap(), bytes, "opening either immutable URI must not replace trusted source bytes", ); let start = main.find("\"./\"").unwrap() + 3; let completion = server.call( "textDocument/completion", json!({"textDocument":{"uri":server.uri("main.ts")},"position":position_at(main, start)}), ).unwrap(); let labels: Vec<_> = completion["items"] .as_array() .into_iter() .flatten() .filter_map(|item: &Value| item["label"].as_str()) .collect(); assert!( labels.contains(&"sibling.ts"), "unopened workspace sibling should be suggested: {completion}" ); assert!( labels.contains(&"my file.ts") && labels.contains(&"café.ts"), "{completion}" ); assert!( !labels.iter().any(|label| label.parse::().is_ok()), "compiler-internal numeric directories leaked: {completion}" ); for (index, (symbol, specifier)) in [ ("spacedExport", "./my file.ts"), ("unicodeExport", "./café.ts"), ("scopedExport", "./isolated/export.ts"), ] .into_iter() .enumerate() { let text = &symbol[..3]; server.change("main.ts", text, index as i64 + 2); let completion = server.call("textDocument/completion", json!({ "textDocument":{"uri":server.uri("main.ts")}, "position":{"line":0,"character":3}, })).unwrap(); let item = completion["items"] .as_array() .unwrap() .iter() .find(|item| item["label"] == symbol) .unwrap_or_else(|| panic!("Missing export {symbol}: {completion}")) .clone(); let resolved = server.call("completionItem/resolve", item).unwrap(); assert!( resolved["additionalTextEdits"] .as_array() .unwrap() .iter() .any(|edit| edit["newText"].as_str().unwrap().contains(specifier)), "{resolved}" ); assert!(!resolved.to_string().contains("/avril-lsp/"), "{resolved}"); } server.close(); }