Files
openidea/crates/ide-lsp/tests/mock_server.rs
l0ng-ai bf2427bd3a feat(java): selectable Java formatting profiles (#680)
Java formatting comes from jdtls' Eclipse formatter, whose defaults differ from the IntelliJ IDEA code style. Settings › Editor › Code Style › Java now offers a formatting profile: Default (unchanged), IntelliJ IDEA, or a custom Eclipse formatter XML (absolute or project-relative path).

The IntelliJ IDEA profile is a bundled Eclipse formatter XML that follows IDEA's default Java code style item by item (indents, aligned multi-line parameters, annotation placement, else/catch/finally on the same line, array initializers, lambdas, blank lines, comments not reflowed). Manual line breaks are kept; lines longer than 120 columns wrap where necessary, which differs from IDEA's default of not wrapping. The profile reaches a running jdtls through didChangeConfiguration without a restart; an unreadable custom file shows a warning and leaves code intelligence running. Code style changes reconfigure language servers without scheduling Kotlin bridge builds.

Also drops two catalog entries whose texts are no longer used.

Fixes #649
2026-11-01 18:26:24 +08:00

2839 lines
127 KiB
Rust

//! Protocol tests against in-memory mock language servers.
use ide_core::event::ServerStatus;
use ide_core::{DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit};
use ide_jsonrpc::{Handler, NoopHandler, Peer};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::types::*;
use ide_lsp::{LaunchError, Launcher, LspManager};
use ide_text::AppliedChange;
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
// ------------------------------------------------------------------ mock server
#[derive(Default)]
struct MockState {
caps: Mutex<Value>,
missing: std::sync::atomic::AtomicBool,
launches: AtomicUsize,
notifications: Mutex<Vec<(String, Value)>>,
requests: Mutex<Vec<(String, Value)>>,
/// uri → text as reconstructed by the mock from didOpen/didChange.
docs: Mutex<HashMap<String, String>>,
responses: Mutex<HashMap<String, Value>>,
errors: Mutex<HashMap<String, (i64, String)>>,
/// Computes a response from the request and the open documents (before `responses`).
responder: Mutex<Option<Responder>>,
peer: Mutex<Option<Peer>>,
}
type Responder = Arc<dyn Fn(&MockState, &str, &Value) -> Option<Value> + Send + Sync>;
impl MockState {
fn notified(&self, method: &str) -> Vec<Value> {
self.notifications.lock().iter().filter(|(m, _)| m == method).map(|(_, p)| p.clone()).collect()
}
fn requested(&self, method: &str) -> Vec<Value> {
self.requests.lock().iter().filter(|(m, _)| m == method).map(|(_, p)| p.clone()).collect()
}
fn respond(&self, method: &str, v: Value) {
self.responses.lock().insert(method.to_owned(), v);
}
fn peer(&self) -> Peer {
self.peer.lock().clone().expect("server running")
}
}
fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize {
let mut offset = 0;
for (i, l) in text.split('\n').enumerate() {
if i as u64 == line {
let mut units = 0u64;
for (b, c) in l.char_indices() {
if units >= col {
return offset + b;
}
units += c.len_utf16() as u64;
}
return offset + l.len();
}
offset += l.len() + 1;
}
text.len()
}
struct MockHandler(Arc<MockState>);
impl Handler for MockHandler {
fn on_notification(&self, method: &str, params: Value) {
let s = &self.0;
// Held throughout: a test that sees the effect (a document, the peer gone) also sees the record.
let mut log = s.notifications.lock();
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
match method {
"textDocument/didOpen" => {
s.docs.lock().insert(uri, params["textDocument"]["text"].as_str().unwrap().to_owned());
}
"textDocument/didChange" => {
let mut docs = s.docs.lock();
let text = docs.get_mut(&uri).expect("didChange before didOpen");
for change in params["contentChanges"].as_array().unwrap() {
let new = change["text"].as_str().unwrap();
match change.get("range") {
Some(r) => {
let a = utf16_to_byte(
text,
r["start"]["line"].as_u64().unwrap(),
r["start"]["character"].as_u64().unwrap(),
);
let b = utf16_to_byte(
text,
r["end"]["line"].as_u64().unwrap(),
r["end"]["character"].as_u64().unwrap(),
);
text.replace_range(a..b, new);
}
None => *text = new.to_owned(),
}
}
}
"textDocument/didClose" => {
s.docs.lock().remove(&uri);
}
"exit" => {
if let Some(p) = s.peer.lock().take() {
p.kill();
}
}
_ => {}
}
log.push((method.to_owned(), params));
}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
let s = &self.0;
s.requests.lock().push((method.to_owned(), params.clone()));
match method {
"initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })),
"shutdown" => Ok(Value::Null),
_ => {
if let Some(error) = s.errors.lock().get(method).cloned() {
return Err(error);
}
let responder = s.responder.lock().clone();
if let Some(v) = responder.and_then(|f| f(s, method, &params)) {
return Ok(v);
}
Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null))
}
}
}
}
#[derive(Default)]
struct MockLauncher {
servers: Mutex<HashMap<String, Arc<MockState>>>,
}
impl MockLauncher {
fn state(&self, name: &str) -> Arc<MockState> {
self.servers.lock().entry(name.to_owned()).or_default().clone()
}
}
impl Launcher for MockLauncher {
fn launch(
&self,
config: &LanguageServerConfig,
_root: &Path,
handler: Arc<dyn Handler>,
_on_stderr: Box<dyn Fn(String) + Send + Sync>,
) -> Result<Peer, LaunchError> {
let state = self.state(&config.name);
if state.missing.load(Ordering::SeqCst) {
return Err(LaunchError::Missing);
}
state.launches.fetch_add(1, Ordering::SeqCst);
state.docs.lock().clear();
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone())));
*state.peer.lock() = Some(server);
let (cr, cw) = tokio::io::split(client_io);
Ok(Peer::connect(cr, cw, handler))
}
}
// ------------------------------------------------------------------ harness
fn incremental_caps() -> Value {
json!({
"textDocumentSync": { "openClose": true, "change": 2, "save": { "includeText": true } },
"completionProvider": { "resolveProvider": true, "triggerCharacters": ["."] },
"hoverProvider": true,
"definitionProvider": true,
"referencesProvider": true,
"codeActionProvider": { "resolveProvider": true },
"renameProvider": { "prepareProvider": true },
"documentFormattingProvider": true,
"executeCommandProvider": { "commands": ["mock.run"] },
"semanticTokensProvider": { "legend": { "tokenTypes": ["function", "variable"], "tokenModifiers": ["declaration"] }, "full": true },
"codeLensProvider": { "resolveProvider": true },
"callHierarchyProvider": true,
"inlayHintProvider": true
})
}
struct Harness {
_dir: tempfile::TempDir,
root: PathBuf,
mgr: Arc<LspManager>,
launcher: Arc<MockLauncher>,
events: Arc<Mutex<Vec<IdeEvent>>>,
lang: LanguageId,
}
fn server_cfg(name: &str) -> LanguageServerConfig {
LanguageServerConfig {
name: name.into(),
command: name.into(),
args: vec![],
env: Default::default(),
root_markers: vec!["mock.toml".into()],
require_root_marker: false,
initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })),
settings: None,
install_hint: Some(format!("install {name}")),
connect: None,
fallback: false,
max_java: None,
launch_error: None,
tsserver_project: false,
}
}
fn harness(servers: &[(&str, Value)]) -> Harness {
harness_with_fallbacks(servers, &[])
}
/// `fallbacks`: servers configured as alternatives to the ones listed before them.
fn harness_with_fallbacks(servers: &[(&str, Value)], fallbacks: &[&str]) -> Harness {
harness_for("mock", "mk", servers, fallbacks)
}
fn harness_for(lang_id: &str, ext: &str, servers: &[(&str, Value)], fallbacks: &[&str]) -> Harness {
harness_configured(lang_id, ext, servers, |cfg| cfg.fallback = fallbacks.contains(&cfg.name.as_str()))
}
/// `adjust` changes each server's configuration.
fn harness_configured(
lang_id: &str,
ext: &str,
servers: &[(&str, Value)],
adjust: impl Fn(&mut LanguageServerConfig),
) -> Harness {
let dir = tempfile::tempdir().unwrap();
// Edits and references report canonical paths.
let root = dunce::canonicalize(dir.path()).unwrap();
let lang = LanguageConfig {
id: LanguageId::new(lang_id),
name: "Mock".into(),
extensions: vec![ext.into()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let launcher = Arc::new(MockLauncher::default());
for (name, caps) in servers {
*launcher.state(name).caps.lock() = caps.clone();
}
let events = Arc::new(Mutex::new(Vec::new()));
let sink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(lang_id) }
}
impl Harness {
fn open(&self, name: &str, text: &str) -> (DocId, PathBuf) {
let path = self.root.join(name);
std::fs::write(&path, text).unwrap();
let doc = DocId::next();
self.mgr.did_open(doc, &path, &self.lang, 1, text);
(doc, path)
}
fn statuses(&self, name: &str) -> Vec<ServerStatus> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServer { name: n, status, .. } if n == name => Some(*status),
_ => None,
})
.collect()
}
/// Whether every instance of `name` was reported as removed from the server list.
fn removed(&self, name: &str) -> bool {
let events = self.events.lock();
let ids: HashSet<ServerId> = events
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServer { server, name: n, .. } if n == name => Some(*server),
_ => None,
})
.collect();
!ids.is_empty()
&& ids.iter().all(|id| {
events.iter().any(|e| matches!(e, IdeEvent::LanguageServerRemoved { server } if server == id))
})
}
async fn wait_status(&self, name: &str, status: ServerStatus) {
eventually(|| self.statuses(name).last() == Some(&status)).await;
}
}
/// Waits for `f`; the bound only keeps a broken test from hanging (machines under load are slow).
async fn eventually(mut f: impl FnMut() -> bool) {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
while !f() {
assert!(std::time::Instant::now() < deadline, "condition not reached in time");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
/// Holds mock request handlers until the test opens it.
#[derive(Default)]
struct Gate {
entered: std::sync::atomic::AtomicBool,
open: std::sync::atomic::AtomicBool,
}
impl Gate {
/// Called from a handler: blocks until [`Gate::open`] (or a generous bound).
fn hold(&self) {
self.entered.store(true, Ordering::SeqCst);
let deadline = std::time::Instant::now() + Duration::from_secs(30);
while !self.open.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
}
fn entered(&self) -> bool {
self.entered.load(Ordering::SeqCst)
}
fn open(&self) {
self.open.store(true, Ordering::SeqCst);
}
}
fn uri_of(path: &Path) -> String {
ide_lsp::path_to_uri_string(path)
}
fn rng(l0: u32, c0: u32, l1: u32, c1: u32) -> Range {
Range::new(Pos::new(l0, c0), Pos::new(l1, c1))
}
fn change(range: Range, text: &str) -> AppliedChange {
AppliedChange { range, text: text.into() }
}
// ------------------------------------------------------------------ tests
#[tokio::test(flavor = "multi_thread")]
async fn linked_projects_initialize_separate_language_server_roots() {
let h = harness(&[("mock", incremental_caps())]);
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let roots = vec![first.path().to_path_buf(), second.path().to_path_buf()];
h.mgr.set_content_roots(roots.clone());
for root in &roots {
let file = root.join("same.mk");
std::fs::write(&file, "hello").unwrap();
h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "hello");
}
let server = h.launcher.state("mock");
eventually(|| server.requested("initialize").len() == 2).await;
let mut actual: Vec<_> =
server.requested("initialize").iter().map(|r| r["rootUri"].as_str().unwrap().to_owned()).collect();
let mut expected: Vec<_> = roots.iter().map(|p| uri_of(p)).collect();
actual.sort();
expected.sort();
assert_eq!(actual, expected);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn initialize_and_incremental_sync() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "fn main() {\n}\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let init = &s.requested("initialize")[0];
assert_eq!(init["clientInfo"]["name"], "OpenIDEA");
assert_eq!(init["capabilities"]["general"]["positionEncodings"], json!(["utf-16"]));
assert_eq!(init["capabilities"]["textDocument"]["completion"]["completionItem"]["snippetSupport"], true);
assert_eq!(init["initializationOptions"], json!({ "flag": true, "nested": { "deep": 1 } }));
assert_eq!(init["rootUri"], json!(uri_of(&h.root)));
eventually(|| s.notified("initialized").len() == 1).await;
assert_eq!(h.statuses("mock"), vec![ServerStatus::Starting, ServerStatus::Ready]);
let uri = uri_of(&path);
eventually(|| s.docs.lock().get(&uri).map(String::as_str) == Some("fn main() {\n}\n")).await;
let open = &s.notified("textDocument/didOpen")[0];
assert_eq!(open["textDocument"]["languageId"], "mock");
assert_eq!(open["textDocument"]["version"], 1);
let full = || -> String { panic!("full text must not be needed") };
h.mgr.did_change(doc, 2, &[change(rng(0, 11, 0, 11), "\n let é😀 = 1;")], &full);
h.mgr.did_change(doc, 3, &[change(rng(1, 10, 1, 10), "x"), change(rng(1, 4, 1, 7), "const")], &full);
let expected = "fn main() {\n const é😀x = 1;\n}\n";
eventually(|| s.docs.lock().get(&uri).map(String::as_str) == Some(expected)).await;
let changes = s.notified("textDocument/didChange");
assert_eq!(changes.last().unwrap()["textDocument"]["version"], 3);
assert_eq!(changes.last().unwrap()["contentChanges"].as_array().unwrap().len(), 2);
h.mgr.did_save(doc, expected);
eventually(|| s.notified("textDocument/didSave").len() == 1).await;
assert_eq!(s.notified("textDocument/didSave")[0]["text"], expected);
h.mgr.did_close(doc);
eventually(|| s.notified("textDocument/didClose").len() == 1).await;
assert!(s.docs.lock().is_empty());
let infos = h.mgr.servers();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].name, "mock");
assert_eq!(infos[0].root, h.root);
assert_eq!(infos[0].status, ServerStatus::Ready);
}
#[tokio::test(flavor = "multi_thread")]
async fn full_sync_and_desync_recovery() {
let h = harness(&[("full", json!({ "textDocumentSync": 1 })), ("inc", json!({ "textDocumentSync": 2 }))]);
let (doc, path) = h.open("a.mk", "abc");
h.wait_status("full", ServerStatus::Ready).await;
h.wait_status("inc", ServerStatus::Ready).await;
let (full, inc) = (h.launcher.state("full"), h.launcher.state("inc"));
let uri = uri_of(&path);
eventually(|| full.docs.lock().contains_key(&uri) && inc.docs.lock().contains_key(&uri)).await;
h.mgr.did_change(doc, 2, &[change(rng(0, 3, 0, 3), "d")], &|| unreachable!());
eventually(|| full.docs.lock()[&uri] == "abcd" && inc.docs.lock()[&uri] == "abcd").await;
assert!(full.notified("textDocument/didChange")[0]["contentChanges"][0].get("range").is_none());
assert!(inc.notified("textDocument/didChange")[0]["contentChanges"][0].get("range").is_some());
// A change that does not fit the tracked text forces a full resync from the source of truth.
h.mgr.did_change(doc, 3, &[change(rng(7, 0, 7, 0), "zz")], &|| "truth".to_owned());
eventually(|| full.docs.lock()[&uri] == "truth" && inc.docs.lock()[&uri] == "truth").await;
}
#[tokio::test(flavor = "multi_thread")]
async fn completion_mapping_merge_and_resolve() {
let h = harness(&[("a", incremental_caps()), ("b", json!({ "textDocumentSync": 2, "completionProvider": {} }))]);
let (doc, _) = h.open("a.mk", "let x = foo.ba");
h.wait_status("a", ServerStatus::Ready).await;
h.wait_status("b", ServerStatus::Ready).await;
let (a, b) = (h.launcher.state("a"), h.launcher.state("b"));
a.respond(
"textDocument/completion",
json!({
"isIncomplete": false,
"itemDefaults": { "data": { "from": "defaults" } },
"items": [{
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()" },
"textEdit": { "newText": "bar()$0", "insert": {"start":{"line":0,"character":12},"end":{"line":0,"character":14}},
"replace": {"start":{"line":0,"character":12},"end":{"line":0,"character":15}} },
"insertTextFormat": 2, "sortText": "0001"
}]
}),
);
b.respond("textDocument/completion", json!({ "isIncomplete": true, "items": [{ "label": "baz" }] }));
a.respond("completionItem/resolve", json!({
"label": "bar", "documentation": { "kind": "markdown", "value": "**docs**" },
"additionalTextEdits": [{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":0}}, "newText": "use foo;\n" }]
}));
let list = h.mgr.completion(doc, Pos::new(0, 14), Some(".".into()), Default::default()).await.unwrap();
assert!(list.is_incomplete);
assert_eq!(list.items.len(), 2);
let bar = list.items.iter().find(|i| i.label == "bar").unwrap().clone();
assert_eq!(bar.kind, Some(CompletionKind::Method));
assert_eq!(bar.insert_text, "bar()$0");
assert!(bar.is_snippet);
assert_eq!(bar.label_detail.as_deref(), Some("()"));
assert_eq!(bar.replace_range, Some(rng(0, 12, 0, 14)));
assert_eq!(bar.replace_range_tab, Some(rng(0, 12, 0, 15)));
assert_eq!(bar.data, Some(json!({ "from": "defaults" })));
let baz = list.items.iter().find(|i| i.label == "baz").unwrap();
assert_eq!(baz.insert_text, "baz");
assert_ne!(baz.server, bar.server);
let req = &a.requested("textDocument/completion")[0];
assert_eq!(req["context"], json!({ "triggerKind": 2, "triggerCharacter": "." }));
assert_eq!(b.requested("textDocument/completion")[0]["context"], json!({ "triggerKind": 1 }));
let resolved = h.mgr.resolve_completion(doc, bar.clone()).await.unwrap();
assert_eq!(resolved.documentation.as_ref().unwrap().value, "**docs**");
assert_eq!(resolved.additional_edits[0].new_text, "use foo;\n");
assert_eq!(resolved.insert_text, "bar()$0", "fields absent from the resolve reply are kept");
assert_eq!(resolved.replace_range_tab, Some(rng(0, 12, 0, 15)));
let sent = &a.requested("completionItem/resolve")[0];
assert_eq!(sent["data"], json!({ "from": "defaults" }));
assert_eq!(sent["textEdit"]["replace"]["end"]["character"], 15);
// Server b has no resolve support: the item comes back unchanged.
let baz2 = h.mgr.resolve_completion(doc, baz.clone()).await.unwrap();
assert_eq!(baz2.label, "baz");
assert!(b.requested("completionItem/resolve").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn inline_completion_only_from_servers_that_advertise_it() {
let h = harness(&[
("plain", incremental_caps()),
("ghost", json!({ "textDocumentSync": 2, "inlineCompletionProvider": {} })),
("late", json!({ "textDocumentSync": 2 })),
]);
let (doc, _) = h.open("a.mk", "let x = fo");
for name in ["plain", "ghost", "late"] {
h.wait_status(name, ServerStatus::Ready).await;
}
let (plain, ghost) = (h.launcher.state("plain"), h.launcher.state("ghost"));
let init = &plain.requested("initialize")[0];
assert_eq!(init["capabilities"]["textDocument"]["inlineCompletion"], json!({ "dynamicRegistration": true }));
ghost.respond(
"textDocument/inlineCompletion",
json!({ "items": [
{ "insertText": "foo(1)", "range": {"start":{"line":0,"character":8},"end":{"line":0,"character":10}},
"command": { "title": "accepted", "command": "ghost.accepted", "arguments": [7] } },
{ "insertText": { "kind": "snippet", "value": "foo(${1:a}, $2)$0" }, "filterText": "fo" },
{ "insertText": "" }
] }),
);
let items = h.mgr.inline_completion(doc, Pos::new(0, 10), true, Default::default()).await.unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].insert_text, "foo(1)");
assert_eq!(items[0].range, Some(rng(0, 8, 0, 10)));
assert_eq!(items[0].command.as_ref().unwrap().command, "ghost.accepted");
assert_eq!(items[1].insert_text, "foo(a, )");
assert_eq!(items[1].filter_text.as_deref(), Some("fo"));
let req = &ghost.requested("textDocument/inlineCompletion")[0];
assert_eq!(req["context"], json!({ "triggerKind": 2 }));
assert_eq!(req["position"], json!({ "line": 0, "character": 10 }));
assert!(plain.requested("textDocument/inlineCompletion").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn unsupported_feature_names_the_running_servers_that_lack_it() {
let h = harness(&[("plain", incremental_caps()), ("other", json!({ "textDocumentSync": 2 }))]);
let (doc, _) = h.open("a.mk", "let x = fo");
for name in ["plain", "other"] {
h.wait_status(name, ServerStatus::Ready).await;
}
let e = h.mgr.inline_completion(doc, Pos::new(0, 10), false, Default::default()).await.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(e.message, "plain, other doesn't support inline completion");
let e = h.mgr.type_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming).await.unwrap_err();
assert_eq!(e.message, "plain, other doesn't support type hierarchy");
}
#[tokio::test(flavor = "multi_thread")]
async fn diagnostics_become_events_and_feed_code_actions() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "let unused = 1;\nlet y = 2;\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let diag = |line: u32| {
json!({
"range": {"start":{"line":line,"character":4},"end":{"line":line,"character":10}},
"severity": 2, "code": "W1", "message": "unused variable", "tags": [1], "data": { "fix": line }
})
};
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": [diag(0), diag(1)] }))
.unwrap();
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { .. }))).await;
{
let events = h.events.lock();
let (p, source, items) = events
.iter()
.find_map(|e| match e {
IdeEvent::Diagnostics { path, source, items } => Some((path.clone(), source.clone(), items.clone())),
_ => None,
})
.unwrap();
assert_eq!(p, path);
assert_eq!(source, "mock");
assert_eq!(items.len(), 2);
assert_eq!(items[0].severity, Severity::Warning);
assert_eq!(items[0].code.as_deref(), Some("W1"));
assert!(items[0].unnecessary);
assert_eq!(items[0].range, rng(0, 4, 0, 10));
}
// A reloaded UI asks for them again.
h.events.lock().clear();
h.mgr.resend_diagnostics();
let again = h.events.lock().iter().find_map(|e| match e {
IdeEvent::Diagnostics { path, items, .. } => Some((path.clone(), items.len())),
_ => None,
});
assert_eq!(again, Some((path.clone().into(), 2)));
s.respond(
"textDocument/codeAction",
json!([
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 } },
{ "title": "Run", "command": "mock.run", "arguments": [1] }
]),
);
s.respond(
"codeAction/resolve",
json!({
"title": "Remove", "edit": { "changes": { uri_of(&path): [
{ "range": {"start":{"line":0,"character":0},"end":{"line":1,"character":0}}, "newText": "" } ] } }
}),
);
let actions = h.mgr.code_actions(doc, rng(0, 5, 0, 5), vec!["quickfix".into()]).await.unwrap();
assert_eq!(actions.len(), 2);
assert_eq!(actions[0].kind.as_deref(), Some("quickfix"));
assert!(actions[0].is_preferred);
assert_eq!(actions[1].command.as_ref().unwrap().command, "mock.run");
let req = &s.requested("textDocument/codeAction")[0];
assert_eq!(req["context"]["only"], json!(["quickfix"]));
let ctx = req["context"]["diagnostics"].as_array().unwrap();
assert_eq!(ctx.len(), 1, "only diagnostics overlapping the range");
assert_eq!(ctx[0]["data"], json!({ "fix": 0 }));
let resolved = h.mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
let edit = resolved.edit.unwrap();
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0))
);
h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap();
assert_eq!(s.requested("workspace/executeCommand")[0], json!({ "command": "mock.run", "arguments": [1] }));
let err = h
.mgr
.execute_command(doc, Command { title: "x".into(), command: "other".into(), arguments: vec![] })
.await
.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_edit_hook_and_server_requests() {
let h = harness(&[("mock", incremental_caps())]);
let (_doc, path) = h.open("a.mk", "abc\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let applied: Arc<Mutex<Vec<WorkspaceEdit>>> = Arc::default();
{
let applied = applied.clone();
h.mgr.set_apply_edit_hook(Box::new(move |e| {
applied.lock().push(e);
true
}));
}
let new_file = h.root.join("new.mk");
let reply: Value = s
.peer()
.request(
"workspace/applyEdit",
json!({ "edit": { "documentChanges": [
{ "kind": "create", "uri": uri_of(&new_file) },
{ "textDocument": { "uri": uri_of(&path), "version": 1 },
"edits": [{ "range": {"start":{"line":0,"character":1},"end":{"line":0,"character":2}}, "newText": "X" }] }
] } }),
None,
)
.await
.unwrap();
assert_eq!(reply, json!({ "applied": true }));
let edits = applied.lock().clone();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false });
assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X"));
let peer = s.peer();
let cfg: Value = peer
.request(
"workspace/configuration",
json!({ "items": [{ "section": "mock" }, { "section": "nested.deep" }, { "section": "nope" }] }),
None,
)
.await
.unwrap();
assert_eq!(cfg, json!([{ "flag": true, "nested": { "deep": 1 } }, 1, null]));
let folders: Value = peer.request("workspace/workspaceFolders", Value::Null, None).await.unwrap();
assert_eq!(folders[0]["uri"], json!(uri_of(&h.root)));
let v: Value = peer.request("window/workDoneProgress/create", json!({ "token": "t" }), None).await.unwrap();
assert_eq!(v, Value::Null);
let v: Value = peer
.request(
"window/showMessageRequest",
json!({ "type": 1, "message": "boom", "actions": [{ "title": "OK" }] }),
None,
)
.await
.unwrap();
assert_eq!(v, Value::Null);
let v: Value = peer
.request(
"client/registerCapability",
json!({ "registrations": [{ "id": "1", "method": "textDocument/documentHighlight" }] }),
None,
)
.await
.unwrap();
assert_eq!(v, Value::Null);
assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err());
peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap();
peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap();
peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { level: ide_core::event::NotifyLevel::Warning, body: Some(b), .. } if b == "careful"))
})
.await;
let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count();
assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced");
// Dynamically registered capability is now usable.
s.respond(
"textDocument/documentHighlight",
json!([{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}}, "kind": 3 }]),
);
let hl = h.mgr.document_highlights(_doc, Pos::new(0, 1)).await.unwrap();
assert_eq!(hl[0].kind, HighlightKind::Write);
}
#[tokio::test(flavor = "multi_thread")]
async fn progress_tasks_and_busy_status() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let peer = h.launcher.state("mock").peer();
peer.notify(
"$/progress",
json!({ "token": 1, "value": { "kind": "begin", "title": "Indexing", "percentage": 0 } }),
)
.unwrap();
peer.notify(
"$/progress",
json!({ "token": 1, "value": { "kind": "report", "message": "3/10", "percentage": 30 } }),
)
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
peer.notify("$/progress", json!({ "token": 1, "value": { "kind": "end" } })).unwrap();
h.wait_status("mock", ServerStatus::Ready).await;
let tasks: Vec<(String, Option<String>, Option<f32>, bool)> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, message, fraction, done, .. } => {
Some((title.clone(), message.clone(), *fraction, *done))
}
_ => None,
})
.collect();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0], ("Indexing".into(), None, Some(0.0), false));
assert_eq!(tasks[1], ("Indexing".into(), Some("3/10".into()), Some(0.3), false));
assert!(tasks[2].3);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_a_cancellable_progress_task_asks_the_server_to_stop() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let peer = s.peer();
let begin = |token: Value, cancellable: bool| json!({ "token": token, "value": { "kind": "begin", "title": "Building", "cancellable": cancellable } });
peer.notify("$/progress", begin(json!(7), true)).unwrap();
peer.notify("$/progress", begin(json!("plain"), false)).unwrap();
let task_of = |want: bool| {
h.events.lock().iter().find_map(|e| match e {
IdeEvent::Task { id, cancellable, done: false, .. } if *cancellable == want => Some(*id),
_ => None,
})
};
eventually(|| task_of(true).is_some() && task_of(false).is_some()).await;
let (cancellable, plain) = (task_of(true).unwrap(), task_of(false).unwrap());
assert!(!ide_core::task::cancel(plain), "the server did not offer to cancel it");
assert!(ide_core::task::cancel(cancellable));
eventually(|| !s.notified("window/workDoneProgress/cancel").is_empty()).await;
assert_eq!(s.notified("window/workDoneProgress/cancel"), [json!({ "token": 7 })]);
// Once it ended there is nothing left to cancel.
peer.notify("$/progress", json!({ "token": 7, "value": { "kind": "end" } })).unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { id, done: true, .. } if *id == cancellable))
})
.await;
assert!(!ide_core::task::cancel(cancellable));
}
#[tokio::test(flavor = "multi_thread")]
async fn angular_project_loading_shows_as_progress() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let peer = h.launcher.state("mock").peer();
peer.notify("angular/projectLoadingStart", Value::Null).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
peer.notify("angular/projectLoadingFinish", Value::Null).unwrap();
h.wait_status("mock", ServerStatus::Ready).await;
let tasks: Vec<(String, bool)> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
_ => None,
})
.collect();
assert_eq!(tasks, [("Loading Angular project".into(), false), ("Loading Angular project".into(), true)]);
}
#[tokio::test(flavor = "multi_thread")]
async fn crash_restarts_and_reopens_documents() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "one");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let uri = uri_of(&path);
eventually(|| s.docs.lock().contains_key(&uri)).await;
s.peer()
.notify(
"textDocument/publishDiagnostics",
json!({ "uri": uri, "diagnostics": [
{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message": "e" }] }),
)
.unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty()))
})
.await;
// Edit while running, then crash.
h.mgr.did_change(doc, 2, &[change(rng(0, 3, 0, 3), " two")], &|| unreachable!());
eventually(|| s.docs.lock().get(&uri).map(String::as_str) == Some("one two")).await;
s.peer().kill();
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
// Diagnostics of the dead instance are withdrawn.
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { items, .. } if items.is_empty())))
.await;
eventually(|| s.launches.load(Ordering::SeqCst) == 2).await;
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().get(&uri).map(String::as_str) == Some("one two")).await;
let opens = s.notified("textDocument/didOpen");
assert_eq!(opens.last().unwrap()["textDocument"]["version"], 2);
// Features work again on the new instance.
s.respond("textDocument/hover", json!({ "contents": { "kind": "markdown", "value": "hi" } }));
let hover = h.mgr.hover(doc, Pos::new(0, 1), Default::default()).await.unwrap().unwrap();
assert_eq!(hover.contents.value, "hi");
}
#[tokio::test(flavor = "multi_thread")]
async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
let s = h.launcher.state("mock");
let count = |status| h.statuses("mock").iter().filter(|s| **s == status).count();
for n in 1..=4 {
// Counted, not the last status: a restart may already have replaced it.
eventually(|| count(ServerStatus::Ready) == n).await;
assert_eq!(s.launches.load(Ordering::SeqCst), n);
s.peer().kill();
eventually(|| count(ServerStatus::Crashed) == n).await;
}
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 4, "initial start + 3 restarts");
let last = h.events.lock().iter().rev().find_map(|e| match e {
IdeEvent::LanguageServer { message, .. } => message.clone(),
_ => None,
});
assert!(last.unwrap().contains("restart it manually"));
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
assert_eq!(s.launches.load(Ordering::SeqCst), 5);
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_server_reports_once() {
let h = harness(&[("ghost", json!({}))]);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("ghost", ServerStatus::Missing).await;
h.open("b.mk", "y");
tokio::time::sleep(Duration::from_millis(100)).await;
let missing: Vec<Option<String>> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServer { status: ServerStatus::Missing, message, .. } => Some(message.clone()),
_ => None,
})
.collect();
assert_eq!(missing, vec![Some("install ghost".to_owned())]);
let err = h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(h.mgr.document_symbols(doc).await.unwrap(), None);
assert!(h.mgr.semantic_tokens(doc).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn launch_error_fails_without_starting_the_server() {
let h = harness(&[("mock", incremental_caps())]);
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
assert!(!h.mgr.serves_path(&h.root.join("a.mk")), "batch inspection skips it");
h.open("a.mk", "x");
// Not a crash: nothing ran, the configuration has to change.
h.wait_status("mock", ServerStatus::Misconfigured).await;
assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 0);
let reason = h.events.lock().iter().find_map(|e| match e {
IdeEvent::LanguageServer { status: ServerStatus::Misconfigured, message, .. } => message.clone(),
_ => None,
});
assert_eq!(reason.as_deref(), Some("needs JDK 21"));
assert!(!h.statuses("mock").contains(&ServerStatus::Crashed));
}
#[tokio::test(flavor = "multi_thread")]
async fn preparation_refreshes_config_and_survives_unchanged_settings() {
use std::sync::atomic::AtomicBool;
let h = harness(&[("mock", incremental_caps())]);
let prepared = Arc::new(AtomicBool::new(false));
let started = Arc::new(AtomicUsize::new(0));
let (release, wait) = std::sync::mpsc::channel();
let wait = Mutex::new(wait);
h.mgr.set_config_hook(Box::new({
let prepared = prepared.clone();
move |cfg, _| {
cfg.launch_error = (!prepared.load(Ordering::SeqCst)).then(|| "needs runtime".into());
}
}));
h.mgr.set_prepare_hook(Box::new({
let started = started.clone();
move |_, _, cancel| {
started.fetch_add(1, Ordering::SeqCst);
wait.lock().recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!cancel.is_cancelled(), "unchanged settings cancelled preparation");
prepared.store(true, Ordering::SeqCst);
}
}));
h.open("a.mk", "x");
eventually(|| started.load(Ordering::SeqCst) > 0).await;
h.mgr.reconfigure().await;
release.send(()).unwrap();
h.wait_status("mock", ServerStatus::Ready).await;
assert_eq!(started.load(Ordering::SeqCst), 1);
assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn settings_changes_reach_servers_without_restarts() {
let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]);
let settings: Arc<Mutex<Option<Value>>> = Default::default();
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
{
let settings = settings.clone();
h.mgr.set_config_hook(Box::new(move |cfg, _| cfg.settings = settings.lock().clone()));
}
{
let disabled = disabled.clone();
h.mgr.set_servers_hook(Box::new(move |_, defined| {
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
}));
}
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
h.wait_status("extra", ServerStatus::Ready).await;
// Only `settings` changed: pushed to the running server, which keeps running.
let s = h.launcher.state("mock");
*settings.lock() = Some(json!({ "nested": { "other": 2 } }));
h.mgr.reconfigure().await;
eventually(|| !s.notified("workspace/didChangeConfiguration").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeConfiguration")[0],
json!({ "settings": { "flag": true, "nested": { "deep": 1, "other": 2 } } })
);
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
let cfg: Value = s
.peer()
.request("workspace/configuration", json!({ "items": [{ "section": "nested.other" }] }), None)
.await
.unwrap();
assert_eq!(cfg, json!([2]));
// Disabled: stopped and forgotten; enabled again: attached to the open document.
disabled.lock().push("extra".into());
h.mgr.reconfigure().await;
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["mock"]);
assert_eq!(h.statuses("extra").last(), Some(&ServerStatus::Stopped));
assert!(h.removed("extra"));
assert!(!h.removed("mock"));
disabled.lock().clear();
h.mgr.reconfigure().await;
h.wait_status("extra", ServerStatus::Ready).await;
eventually(|| h.launcher.state("extra").notified("textDocument/didOpen").len() == 2).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"autobuild": {"enabled": false}}}
}))
.unwrap(),
);
});
let data = tempfile::tempdir().unwrap();
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
}));
h.mgr.set_config_hook(Box::new({
let style = style.clone();
let root = h.root.clone();
let data = data.path().to_path_buf();
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
}));
h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
"OpenIDEA IntelliJ IDEA"
);
for mode in ["custom", "default", "intellij", "default"] {
style.lock().formatter = Some(mode.into());
h.mgr.reconfigure().await;
let expected = match mode {
"custom" => uri_of(&h.root.join("custom.xml")),
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
_ => String::new(),
};
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
})
.await;
let result: Value = state
.peer()
.request(
"workspace/configuration",
json!({
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
}),
None,
)
.await
.unwrap();
assert_eq!(result[0]["url"], expected);
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
assert_eq!(result[1], false);
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"bundles": [], "settings": {"java": {"project": {"referencedLibraries": ["lib/**/*.jar"]}}}
}))
.unwrap(),
);
});
let paths = Arc::new(Mutex::new(Some(vec!["src/main/java".into()])));
h.mgr.set_config_hook(Box::new({
let paths = paths.clone();
move |cfg, _| ide_lsp::jdtls::configure_source_paths(cfg, paths.lock().clone())
}));
let (_, file) = h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(init["initializationOptions"]["settings"]["java"]["project"]["sourcePaths"], json!(["src/main/java"]));
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
*paths.lock() = Some(vec!["generated".into(), "tests".into()]);
h.mgr.reconfigure().await;
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["project"]["sourcePaths"] == json!(["generated", "tests"]))
})
.await;
let notification = state.notified("workspace/didChangeConfiguration").pop().unwrap();
assert_eq!(notification["settings"]["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"]));
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
state.respond("java/buildWorkspace", json!(1));
assert_eq!(
h.mgr.java_build(&h.root, &Default::default()).await.unwrap(),
ide_lsp::jdtls::JavaBuildStatus::Succeeded
);
assert_eq!(state.requested("java/buildWorkspace"), vec![json!(false)]);
// Removing the last hint restores importer inference. Omitting a setting
// in didChangeConfiguration would retain jdtls's previous preference.
*paths.lock() = None;
h.mgr.reconfigure().await;
eventually(|| state.launches.load(Ordering::SeqCst) == 2).await;
h.wait_status("jdtls", ServerStatus::Ready).await;
let init = state.requested("initialize").pop().unwrap();
assert!(init["initializationOptions"]["settings"]["java"]["project"].get("sourcePaths").is_none());
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
}
#[tokio::test(flavor = "multi_thread")]
async fn unmanaged_java_run_imports_sources_without_an_open_editor() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
std::fs::create_dir_all(h.root.join("src/demo")).unwrap();
let file = h.root.join("src/demo/Main.java");
std::fs::write(&file, "package demo; public class Main {}").unwrap();
h.mgr.set_config_hook(Box::new(|cfg, _| {
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
}));
let state = h.launcher.state("jdtls");
state.respond("java/buildWorkspace", json!(1));
assert_eq!(
h.mgr.java_build(&h.root, &Default::default()).await.unwrap(),
ide_lsp::jdtls::JavaBuildStatus::Succeeded
);
assert_eq!(state.requested("initialize")[0]["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
assert!(state.notified("textDocument/didOpen").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
// Preferred installed: the alternative is never started.
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("main", ServerStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(h.statuses("alt").is_empty());
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
// Preferred missing: open documents move to the alternative.
let h = harness_with_fallbacks(&[("ghost", json!({})), ("alt", incremental_caps())], &["alt"]);
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
let (doc, path) = h.open("a.mk", "x");
h.wait_status("ghost", ServerStatus::Missing).await;
h.wait_status("alt", ServerStatus::Ready).await;
let uri = uri_of(&path);
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
let (_, other) = h.open("b.mk", "y");
let other = uri_of(&other);
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&other)).await;
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_server_requiring_a_root_marker_serves_only_files_below_one() {
// `framework` next to `main` (one instance for the whole project), like the
// Angular server next to vtsls.
let servers = [("main", incremental_caps()), ("framework", incremental_caps())];
let h = harness_configured("mock", "mk", &servers, |cfg| match cfg.name.as_str() {
"framework" => cfg.require_root_marker = true,
_ => cfg.root_markers.clear(),
});
for dir in ["plain", "app"] {
std::fs::create_dir_all(h.root.join(dir)).unwrap();
}
std::fs::write(h.root.join("app/mock.toml"), "").unwrap();
let (_, plain) = h.open("plain/a.mk", "x");
h.wait_status("main", ServerStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(h.statuses("framework").is_empty());
let (_, app) = h.open("app/b.mk", "y");
h.wait_status("framework", ServerStatus::Ready).await;
let framework = h.launcher.state("framework");
eventually(|| framework.docs.lock().contains_key(&uri_of(&app))).await;
assert!(!framework.docs.lock().contains_key(&uri_of(&plain)));
eventually(|| h.launcher.state("main").docs.lock().len() == 2).await;
// It stands in for no other server: with `ghost` missing, its fallback serves too.
let servers = [("ghost", json!({})), ("framework", incremental_caps()), ("alt", incremental_caps())];
let h = harness_configured("mock", "mk", &servers, |cfg| match cfg.name.as_str() {
"framework" => cfg.require_root_marker = true,
"alt" => cfg.fallback = true,
_ => {}
});
h.launcher.state("ghost").missing.store(true, Ordering::SeqCst);
std::fs::write(h.root.join("mock.toml"), "").unwrap();
h.open("a.mk", "x");
h.wait_status("framework", ServerStatus::Ready).await;
h.wait_status("alt", ServerStatus::Ready).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() {
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
{
let disabled = disabled.clone();
h.mgr.set_servers_hook(Box::new(move |_, defined| {
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
}));
}
let (doc, path) = h.open("a.mk", "x");
let uri = uri_of(&path);
h.wait_status("main", ServerStatus::Ready).await;
assert!(h.statuses("alt").is_empty());
// Preferred disabled: the fallback takes over the open document.
disabled.lock().push("main".into());
h.mgr.reconfigure().await;
h.wait_status("alt", ServerStatus::Ready).await;
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["alt"]);
assert!(h.removed("main"));
// Enabled again: the fallback closes the document and stops.
disabled.lock().clear();
h.mgr.reconfigure().await;
h.wait_status("main", ServerStatus::Ready).await;
eventually(|| h.launcher.state("main").docs.lock().contains_key(&uri)).await;
assert!(!h.launcher.state("alt").notified("textDocument/didClose").is_empty());
assert_eq!(h.statuses("alt").last(), Some(&ServerStatus::Stopped));
assert!(h.removed("alt"));
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["main"]);
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
assert_eq!(h.launcher.state("alt").requested("textDocument/hover").len(), 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn implementations_per_position() {
let caps = json!({ "textDocumentSync": 1, "implementationProvider": true });
let h = harness(&[("mock", caps)]);
let (doc, path) = h.open("a.mk", "iface A {}\nimpl B {}\nimpl C {}\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let uri = uri_of(&path);
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
if method != "textDocument/implementation" {
return None;
}
// Line 0 is implemented on lines 1 and 2; nothing else is.
Some(match params["position"]["line"].as_u64() {
Some(0) => json!([
{ "uri": uri, "range": {"start":{"line":1,"character":5},"end":{"line":1,"character":6}} },
{ "uri": uri, "range": {"start":{"line":2,"character":5},"end":{"line":2,"character":6}} },
]),
_ => json!([]),
})
}));
let found = h.mgr.implementations(doc, &[Pos::new(0, 6), Pos::new(1, 5)], Default::default()).await.unwrap();
assert_eq!(found.len(), 2);
assert_eq!(found[0].iter().map(|l| l.range.start.line).collect::<Vec<_>>(), vec![1, 2]);
assert!(found[1].is_empty());
assert!(h.mgr.implementations(doc, &[], Default::default()).await.unwrap().is_empty());
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
let err = h.mgr.implementations(doc, &[Pos::new(0, 6)], cancel).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn navigation_rename_format_and_decorations() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "fn foo() {}\nfoo();\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let uri = uri_of(&path);
let r = |l0: u32, c0: u32, l1: u32, c1: u32| json!({"start":{"line":l0,"character":c0},"end":{"line":l1,"character":c1}});
s.respond(
"textDocument/definition",
json!([{ "targetUri": uri, "targetRange": r(0,0,0,11), "targetSelectionRange": r(0,3,0,6) }]),
);
let defs = h.mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await.unwrap();
assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))]);
let err = h.mgr.goto(doc, Pos::new(1, 1), GotoKind::TypeDefinition).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
s.respond("textDocument/references", json!([{ "uri": uri, "range": r(0,3,0,6) }, { "uri": uri, "range": r(1,0,1,3) }, { "uri": uri, "range": r(1,0,1,3) }]));
let refs = h.mgr.references(doc, Pos::new(1, 1), true, true, Default::default()).await.unwrap().locations;
assert_eq!(refs.len(), 2);
assert_eq!(s.requested("textDocument/references")[0]["context"]["includeDeclaration"], true);
s.respond("textDocument/prepareRename", json!({ "range": r(1,0,1,3), "placeholder": "foo" }));
assert_eq!(h.mgr.prepare_rename(doc, Pos::new(1, 1)).await.unwrap(), Some(rng(1, 0, 1, 3)));
s.respond("textDocument/rename", json!({ "changes": { uri.clone(): [{ "range": r(0,3,0,6), "newText": "bar" }, { "range": r(1,0,1,3), "newText": "bar" }] } }));
let edit = h.mgr.rename(doc, Pos::new(1, 1), "bar".into(), Default::default()).await.unwrap().edit;
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
assert!(h.mgr.can_format(doc).await.unwrap());
s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }]));
let edits =
h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap();
assert_eq!(edits[0].range, rng(0, 10, 0, 11));
assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true }));
assert!(
h.mgr
.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false })
.await
.is_err()
);
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
let tokens = h.mgr.semantic_tokens(doc).await.unwrap().unwrap();
assert_eq!(tokens.data, vec![0, 3, 3, 0, 1, 1, 0, 3, 0, 0]);
assert_eq!(tokens.token_types, vec!["function", "variable"]);
s.respond("textDocument/codeLens", json!([{ "range": r(0,0,0,2), "data": 1 }, { "range": r(1,0,1,2), "command": { "title": "Run", "command": "mock.run" } }]));
s.respond(
"codeLens/resolve",
json!({ "range": r(0,0,0,2), "command": { "title": "1 reference", "command": "mock.refs" } }),
);
let lenses = h.mgr.code_lens(doc).await.unwrap();
assert_eq!(lenses.len(), 2);
assert_eq!(lenses[0].command.as_ref().unwrap().title, "1 reference");
assert_eq!(s.requested("codeLens/resolve").len(), 1);
s.respond(
"textDocument/inlayHint",
json!([{ "position": {"line":1,"character":5}, "label": [{"value": "x:"}], "kind": 2, "paddingRight": true }]),
);
let hints = h.mgr.inlay_hints(doc, rng(0, 0, 2, 0)).await.unwrap();
assert_eq!(hints[0].label, "x:");
assert_eq!(hints[0].kind, InlayKind::Parameter);
// Nothing to prepare at the position: NotFound, so the UI can retry at the enclosing declaration.
s.respond("textDocument/prepareCallHierarchy", json!(null));
let err =
h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming, Default::default()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
let item = json!({ "name": "foo", "kind": 12, "uri": uri, "range": r(0,0,0,11), "selectionRange": r(0,3,0,6), "data": "id" });
s.respond("textDocument/prepareCallHierarchy", json!([item]));
s.respond("callHierarchy/incomingCalls", json!([{ "from": { "name": "main", "kind": 12, "uri": uri, "range": r(1,0,1,6), "selectionRange": r(1,0,1,3) }, "fromRanges": [r(1,0,1,3)] }]));
let calls = h
.mgr
.call_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming, Default::default())
.await
.unwrap()
.items;
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "main");
assert_eq!(calls[0].from_ranges, vec![rng(1, 0, 1, 3)]);
assert_eq!(s.requested("callHierarchy/incomingCalls")[0]["item"]["data"], "id");
// No documentSymbolProvider → None so callers fall back to tree-sitter.
assert_eq!(h.mgr.document_symbols(doc).await.unwrap(), None);
}
/// The edits of `edit` in `path`, by range.
fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> {
edit.files
.iter()
.filter_map(|f| match f {
FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
Some(edits.iter().map(|e| (e.range, e.new_text.clone())))
}
_ => None,
})
.flatten()
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_merges_the_servers_that_can_rename_there() {
// `ts` can't rename inside a template string (its `prepareRename` is null), `ng` can.
let h = harness(&[("ts", incremental_caps()), ("ng", incremental_caps())]);
let (doc, path) = h.open("a.mk", "template: `{{ title }}`\ntitle = 1\n");
h.wait_status("ts", ServerStatus::Ready).await;
h.wait_status("ng", ServerStatus::Ready).await;
let html = h.root.join("a.html");
let (uri, html_uri) = (uri_of(&path), uri_of(&html));
let r = |l: u32, c0: u32, c1: u32| json!({"start":{"line":l,"character":c0},"end":{"line":l,"character":c1}});
let ng = h.launcher.state("ng");
ng.respond("textDocument/prepareRename", json!({ "range": r(0, 14, 19), "placeholder": "title" }));
ng.respond(
"textDocument/rename",
json!({ "changes": {
uri.clone(): [{ "range": r(0, 14, 19), "newText": "heading" }, { "range": r(1, 0, 5), "newText": "heading" }],
html_uri: [{ "range": r(0, 3, 8), "newText": "heading" }],
} }),
);
assert_eq!(h.mgr.prepare_rename(doc, Pos::new(0, 15)).await.unwrap(), Some(rng(0, 14, 0, 19)));
let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into(), Default::default()).await.unwrap().edit;
assert_eq!(edits_in(&edit, &path).len(), 2);
assert!(h.launcher.state("ts").requested("textDocument/rename").is_empty());
// Both can rename the declaration: `ng` adds what `ts` doesn't see, and
// `ts`'s own edits win where both change the same text.
let ts = h.launcher.state("ts");
ts.respond("textDocument/prepareRename", json!({ "range": r(1, 0, 5), "placeholder": "title" }));
ts.respond(
"textDocument/rename",
json!({ "changes": { uri: [{ "range": r(1, 0, 5), "newText": "heading: x" }] } }),
);
let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into(), Default::default()).await.unwrap().edit;
assert_eq!(edits_in(&edit, &path), [(rng(0, 14, 0, 19), "heading".into()), (rng(1, 0, 1, 5), "heading: x".into())]);
assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]);
}
#[tokio::test(flavor = "multi_thread")]
async fn angular_server_is_asked_about_typescript_only_in_component_templates() {
let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())];
let h = harness_for("typescript", "ts", &servers, &[]);
let (doc, _) = h.open("a.ts", "@Component({ template: '<p>{{ title }}</p>' })\nclass A { title = 1; }\n");
h.wait_status("vtsls", ServerStatus::Ready).await;
h.wait_status("angular-language-server", ServerStatus::Ready).await;
let ng = h.launcher.state("angular-language-server");
let asked = |method: &str| ng.requested(method).len();
let mgr = &h.mgr;
let ask = |pos: Pos| async move {
mgr.completion(doc, pos, None, Default::default()).await.unwrap();
mgr.hover(doc, pos, Default::default()).await.unwrap();
mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
};
// In the class: vtsls alone, though it has no answer.
ask(Pos::new(1, 12)).await;
assert_eq!(
(asked("textDocument/completion"), asked("textDocument/hover"), asked("textDocument/definition")),
(0, 0, 0)
);
// In the template: the Angular server too.
ask(Pos::new(0, 31)).await;
assert_eq!(
(asked("textDocument/completion"), asked("textDocument/hover"), asked("textDocument/definition")),
(1, 1, 1)
);
// Inlay hints only where Angular is imported.
let lines = rng(0, 0, 2, 0);
h.mgr.inlay_hints(doc, lines).await.unwrap();
assert_eq!(asked("textDocument/inlayHint"), 0);
let (doc, _) = h.open("b.ts", "import { Component } from '@angular/core';\n");
h.mgr.inlay_hints(doc, lines).await.unwrap();
assert_eq!(asked("textDocument/inlayHint"), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn angular_references_leave_out_the_declaration_unless_asked() {
let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())];
let h = harness_for("typescript", "ts", &servers, &[]);
let (doc, path) = h.open("a.ts", "class A { title = 1; }\n");
h.wait_status("vtsls", ServerStatus::Ready).await;
h.wait_status("angular-language-server", ServerStatus::Ready).await;
let html = h.root.join("a.html");
let declaration =
json!({ "uri": uri_of(&path), "range": {"start":{"line":0,"character":10},"end":{"line":0,"character":15}} });
let usage =
json!({ "uri": uri_of(&html), "range": {"start":{"line":0,"character":3},"end":{"line":0,"character":8}} });
// Like ngserver, it lists the declaration whatever `includeDeclaration` says;
// vtsls knows where it is.
let ng = h.launcher.state("angular-language-server");
ng.respond("textDocument/references", json!([declaration, usage]));
let vtsls = h.launcher.state("vtsls");
vtsls.respond("textDocument/definition", json!([declaration]));
let mgr = &h.mgr;
let found = |include_declaration: bool| async move {
let refs = mgr.references(doc, Pos::new(0, 11), include_declaration, false, Default::default()).await;
let mut v: Vec<(PathBuf, Range)> =
refs.unwrap().locations.iter().map(|l| (l.path.to_path_buf(), l.range)).collect();
v.sort();
v
};
assert_eq!(found(false).await, [(html.clone(), rng(0, 3, 0, 8))]);
assert_eq!(found(true).await, [(html, rng(0, 3, 0, 8)), (path, rng(0, 10, 0, 15))]);
assert_eq!(vtsls.requested("textDocument/definition").len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn utf8_server_positions_are_converted() {
let h = harness(&[(
"u8",
json!({ "positionEncoding": "utf-8", "textDocumentSync": 2, "hoverProvider": true, "definitionProvider": true }),
)]);
let (doc, path) = h.open("a.mk", "é😀x = é😀x\n");
h.wait_status("u8", ServerStatus::Ready).await;
let s = h.launcher.state("u8");
let uri = uri_of(&path);
s.respond(
"textDocument/definition",
json!([{ "uri": uri, "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":7}} }]),
);
// UTF-16 col 3 ("x" after "é😀") is byte 6.
let defs = h.mgr.goto(doc, Pos::new(0, 3), GotoKind::Definition).await.unwrap();
assert_eq!(s.requested("textDocument/definition")[0]["position"], json!({ "line": 0, "character": 6 }));
assert_eq!(defs[0].range, rng(0, 0, 0, 4));
// didChange ranges are converted too.
h.mgr.did_change(doc, 2, &[change(rng(0, 4, 0, 4), "y")], &|| unreachable!());
eventually(|| !s.notified("textDocument/didChange").is_empty()).await;
assert_eq!(s.notified("textDocument/didChange")[0]["contentChanges"][0]["range"]["start"]["character"], 7);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancellation_is_propagated() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let token = ide_core::CancellationToken::new();
token.cancel();
let err = h.mgr.hover(doc, Pos::new(0, 0), token).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn code_action_query_reports_advertised_kinds_and_cancels() {
let mut caps = incremental_caps();
caps["codeActionProvider"] = json!({ "codeActionKinds": ["quickfix", "refactor.extract", "refactor.inline"] });
let h = harness(&[("mock", caps)]);
let (doc, _) = h.open("a.mk", "let x = 1;\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
s.respond(
"textDocument/codeAction",
json!([{ "title": "Extract into variable", "kind": "refactor.extract.variable" }]),
);
let q = h
.mgr
.code_action_query(doc, rng(0, 8, 0, 9), vec!["refactor".into()], ide_core::CancellationToken::new())
.await
.unwrap();
assert_eq!(q.actions.len(), 1);
assert_eq!(q.actions[0].kind.as_deref(), Some("refactor.extract.variable"));
assert_eq!(q.advertised_kinds, Some(vec!["quickfix".into(), "refactor.extract".into(), "refactor.inline".into()]));
assert_eq!(s.requested("textDocument/codeAction")[0]["context"]["only"], json!(["refactor"]));
let token = ide_core::CancellationToken::new();
token.cancel();
let err = h.mgr.code_action_query(doc, rng(0, 8, 0, 9), vec![], token).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn code_action_query_without_listed_kinds() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let q = h.mgr.code_action_query(doc, rng(0, 0, 0, 0), vec![], ide_core::CancellationToken::new()).await.unwrap();
assert!(q.actions.is_empty());
assert_eq!(q.advertised_kinds, None, "`codeActionProvider` without `codeActionKinds` may return any kind");
}
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_all_stops_gracefully() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
tokio::time::timeout(Duration::from_secs(5), h.mgr.shutdown_all()).await.unwrap();
assert_eq!(s.requested("shutdown").len(), 1);
assert_eq!(s.notified("exit").len(), 1);
assert_eq!(h.statuses("mock").last(), Some(&ServerStatus::Stopped));
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart after shutdown");
// Opening more documents after shutdown does not start servers.
h.open("b.mk", "y");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn noop_handler_is_usable_for_raw_peers() {
// Sanity check that the public jsonrpc API composes with the launcher seam.
let (a, b) = tokio::io::duplex(1024);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default())));
let client = Peer::connect(ar, aw, Arc::new(NoopHandler));
let v: Value = client.request("shutdown", (), None).await.unwrap();
assert_eq!(v, Value::Null);
}
// ------------------------------------------------------------------ virtual documents
#[tokio::test(flavor = "multi_thread")]
async fn virtual_locations_and_text_document_content() {
let mut caps = incremental_caps();
caps["typeHierarchyProvider"] = json!(true);
caps["workspace"] = json!({ "textDocumentContent": { "schemes": ["mocklib"] } });
let h = harness(&[("mock", caps)]);
let (doc, path) = h.open("a.mk", "use Thing;\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let r = |l0: u32, c0: u32, l1: u32, c1: u32| json!({"start":{"line":l0,"character":c0},"end":{"line":l1,"character":c1}});
let lib = "mocklib://deps/lib-1.0/Thing.mk";
// Non-file targets are kept (as virtual locations) instead of dropped.
s.respond("textDocument/definition", json!({ "uri": lib, "range": r(2, 4, 2, 9) }));
let defs = h.mgr.goto(doc, Pos::new(0, 5), GotoKind::Definition).await.unwrap();
assert_eq!(defs, vec![ide_core::Location { path: lib.into(), range: rng(2, 4, 2, 9), uri: Some(lib.to_owned()) }]);
s.respond(
"textDocument/references",
json!([{ "uri": uri_of(&path), "range": r(0, 4, 0, 9) }, { "uri": lib, "range": r(2, 4, 2, 9) }]),
);
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap().locations;
assert_eq!(refs.iter().map(|l| l.uri.is_some()).collect::<Vec<_>>(), vec![false, true]);
s.respond(
"textDocument/prepareTypeHierarchy",
json!([{ "name": "Thing", "kind": 5, "uri": uri_of(&path), "range": r(0,0,0,1), "selectionRange": r(0,0,0,1) }]),
);
s.respond(
"typeHierarchy/supertypes",
json!([{ "name": "Base", "kind": 5, "uri": lib, "range": r(1,0,3,1), "selectionRange": r(1,6,1,10) }]),
);
let supers = h.mgr.type_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming).await.unwrap();
assert_eq!(supers[0].uri.as_deref(), Some(lib));
assert_eq!(supers[0].selection_range, rng(1, 6, 1, 10));
// Contents come from `workspace/textDocumentContent` and are cached.
s.respond("workspace/textDocumentContent", json!({ "text": "// lib\npub struct Base;\npub struct Thing;\n" }));
let vd = h.mgr.virtual_document(lib).await.unwrap();
assert_eq!(vd.title, "Thing.mk");
assert_eq!(vd.language_id.as_str(), "mock");
assert!(!vd.decompiled);
assert_eq!(h.mgr.virtual_document(lib).await.unwrap(), vd);
let asked = s.requested("workspace/textDocumentContent");
assert_eq!(asked.len(), 1);
assert_eq!(asked[0]["uri"], lib);
// Opened read-only on the owning server: didOpen with the URI, requests
// inside it use it, closing sends didClose; never didChange.
let vdoc = DocId::next();
h.mgr.did_open_virtual(vdoc, lib, &vd.language_id, 0, &vd.text);
eventually(|| s.docs.lock().contains_key(lib)).await;
s.respond("textDocument/hover", json!({ "contents": { "kind": "markdown", "value": "struct Thing" } }));
let hover = h.mgr.hover(vdoc, Pos::new(2, 12), Default::default()).await.unwrap().unwrap();
assert_eq!(hover.contents.value, "struct Thing");
assert_eq!(s.requested("textDocument/hover").last().unwrap()["textDocument"]["uri"], lib);
h.mgr.did_close(vdoc);
eventually(|| !s.docs.lock().contains_key(lib)).await;
assert!(s.notified("textDocument/didChange").iter().all(|p| p["textDocument"]["uri"] != lib));
// Unknown schemes nobody serves fail cleanly.
assert!(h.mgr.virtual_document("other://x/y.mk").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_class_file_contents() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (doc, _) = h.open("App.java", "import org.example.Lib;\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib-1.0.jar/org.example/Lib.class?=demo/%5C/m2%5C/lib-1.0.jar%3Corg.example%28Lib.class";
s.respond(
"textDocument/definition",
json!([{ "uri": jdt, "range": { "start": { "line": 3, "character": 13 }, "end": { "line": 3, "character": 16 } } }]),
);
let defs = h.mgr.goto(doc, Pos::new(0, 20), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].uri.as_deref(), Some(jdt));
s.respond(
"java/classFileContents",
json!("// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {}\n"),
);
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert_eq!(s.requested("java/classFileContents")[0], json!({ "uri": jdt }));
assert!(vd.decompiled);
assert_eq!(vd.title, "Lib.class");
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar"));
let source_uri = jdt.replace("Lib.class", "WithSources.class");
s.respond(
"java/classFileContents",
json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"),
);
let source = h.mgr.virtual_document(&source_uri).await.unwrap();
assert!(!source.decompiled);
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
caps["workspaceSymbolProvider"] = json!(true);
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class";
s.respond("workspace/symbol", json!([
{"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}}
]));
let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into()));
assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt);
assert!(
h.mgr
.java_class_file(
Path::new("/other/lib.jar!/p/Outer.class"),
Some(("/other/lib.jar".into(), "p/Outer.class".into()))
)
.await
.is_err()
);
let sources = h.root.join("src/main/java");
std::fs::create_dir_all(sources.join("p")).unwrap();
let source = sources.join("p/Outer.java");
std::fs::write(&source, "package p; class Outer {} ").unwrap();
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
for class in ["Outer", "Outer$Nested", "Outer$1"] {
let path = h.root.join(format!("target/classes/p/{class}.class"));
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
// Compare as paths: the manager joins the relative source path with std::path, so
// separators can differ from the literal "p/Outer.java" used to build `source` on Windows.
assert_eq!(Path::new(&resolved), source.as_path());
}
let path = h.root.join("build/classes/java/main/p/Missing.class");
let uri = uri_of(&path);
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri);
let vd = h.mgr.virtual_document(&uri).await.unwrap();
assert!(vd.decompiled);
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.title, "Missing.class");
assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri));
}
#[tokio::test(flavor = "multi_thread")]
async fn will_and_did_rename_files_honour_filters() {
let mut caps = incremental_caps();
caps["workspace"] = json!({ "fileOperations": {
"willRename": { "filters": [
{ "scheme": "file", "pattern": { "glob": "**/*.mk", "matches": "file" } },
{ "scheme": "file", "pattern": { "glob": "**", "matches": "folder" } }
] },
"didRename": { "filters": [{ "pattern": { "glob": "**/*.mk" } }] }
}});
let h = harness(&[("mock", caps)]);
std::fs::create_dir_all(h.root.join("pkg")).unwrap();
let util = h.root.join("pkg/util.mk");
let main = h.root.join("main.mk");
std::fs::write(&util, "fn util() {}\n").unwrap();
std::fs::write(&main, "use pkg/util;\n").unwrap();
let s = h.launcher.state("mock");
let r = |l0: u32, c0: u32, l1: u32, c1: u32| json!({"start":{"line":l0,"character":c0},"end":{"line":l1,"character":c1}});
s.respond(
"workspace/willRenameFiles",
json!({ "changes": { uri_of(&main): [{ "range": r(0,4,0,7), "newText": "lib" }] } }),
);
// Nothing open yet: the server is started for the moved file's language,
// and the file is opened just for the request (tsserver needs a project).
let to = h.root.join("lib/util.mk");
let result =
h.mgr.will_rename_files(&[(util.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
assert_eq!(result.servers, vec!["mock".to_owned()]);
assert!(result.errors.is_empty());
match &result.edit.files[..] {
[FileEdit::Edit { path, edits }] => {
assert_eq!(path, &main);
assert_eq!(edits[0].range, rng(0, 4, 0, 7));
assert_eq!(edits[0].new_text, "lib");
}
other => panic!("unexpected edit {other:?}"),
}
let req = &s.requested("workspace/willRenameFiles")[0];
assert_eq!(req["files"], json!([{ "oldUri": uri_of(&util), "newUri": uri_of(&to) }]));
assert_eq!(s.notified("textDocument/didOpen")[0]["textDocument"]["uri"], uri_of(&util));
eventually(|| !s.notified("textDocument/didClose").is_empty()).await;
assert_eq!(s.notified("textDocument/didClose")[0]["textDocument"]["uri"], uri_of(&util));
// Folders match the folder filter; files of other types match nothing.
let dir_to = h.root.join("lib");
h.mgr.will_rename_files(&[(h.root.join("pkg"), dir_to.clone())], ide_core::CancellationToken::new()).await.unwrap();
assert_eq!(s.requested("workspace/willRenameFiles")[1]["files"][0]["newUri"], uri_of(&dir_to));
let txt = h.root.join("notes.txt");
std::fs::write(&txt, "x").unwrap();
let none = h
.mgr
.will_rename_files(&[(txt.clone(), h.root.join("n.txt"))], ide_core::CancellationToken::new())
.await
.unwrap();
assert!(none.servers.is_empty() && none.edit.files.is_empty());
assert_eq!(s.requested("workspace/willRenameFiles").len(), 2);
// didRenameFiles after the move, filtered the same way.
std::fs::create_dir_all(h.root.join("lib")).unwrap();
std::fs::rename(&util, &to).unwrap();
std::fs::rename(&txt, h.root.join("n.txt")).unwrap();
h.mgr.did_rename_files(&[(util.clone(), to.clone()), (txt, h.root.join("n.txt"))]);
eventually(|| !s.notified("workspace/didRenameFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didRenameFiles")[0]["files"],
json!([{ "oldUri": uri_of(&util), "newUri": uri_of(&to) }])
);
// Servers without fileOperations are never asked.
let plain = harness(&[("plain", incremental_caps())]);
let f = plain.root.join("a.mk");
std::fs::write(&f, "x").unwrap();
let r = plain
.mgr
.will_rename_files(&[(f.clone(), plain.root.join("b.mk"))], ide_core::CancellationToken::new())
.await
.unwrap();
assert!(r.servers.is_empty());
assert!(plain.launcher.state("plain").requested("workspace/willRenameFiles").is_empty());
assert_eq!(
s.requested("initialize")[0]["capabilities"]["workspace"]["fileOperations"],
json!({ "dynamicRegistration": true, "willRename": true, "didRename": true })
);
}
// ------------------------------------------------------------------ inspect code
struct InspectTestHost {
mgr: Arc<LspManager>,
lang: LanguageId,
}
impl ide_lsp::InspectHost for InspectTestHost {
fn open(&self, path: &Path) -> ide_core::IdeResult<(DocId, bool)> {
let text = std::fs::read_to_string(path).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
let doc = DocId::next();
self.mgr.did_open(doc, path, &self.lang, 1, &text);
Ok((doc, false))
}
fn close(&self, doc: DocId) {
self.mgr.did_close(doc);
}
}
fn inspect_opts() -> ide_lsp::InspectOptions {
ide_lsp::InspectOptions { concurrency: 2, timeout: Duration::from_secs(5), settle: Duration::from_millis(50) }
}
async fn inspect(h: &Harness, lang: &str, files: Vec<PathBuf>) -> Vec<(PathBuf, ide_lsp::FileInspection)> {
let host = Arc::new(InspectTestHost { mgr: h.mgr.clone(), lang: LanguageId::new(lang) });
let out = Mutex::new(Vec::new());
h.mgr
.inspect_files(host, files, inspect_opts(), ide_core::CancellationToken::new(), &|p, r| out.lock().push((p, r)))
.await;
let mut out = out.into_inner();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[tokio::test(flavor = "multi_thread")]
async fn inspect_waits_for_published_diagnostics_and_closes_files() {
let h = harness(&[("mock", incremental_caps())]);
let a = h.root.join("a.mk");
let b = h.root.join("b.mk");
std::fs::write(&a, "let unused = 1;\n").unwrap();
std::fs::write(&b, "ok\n").unwrap();
let s = h.launcher.state("mock");
// The "server" publishes for every file it gets opened: one problem in a.mk, none in b.mk.
let publisher = {
let s = s.clone();
let (a, b) = (a.clone(), b.clone());
tokio::spawn(async move {
let mut done = Vec::new();
for _ in 0..500 {
for open in s.notified("textDocument/didOpen") {
let uri = open["textDocument"]["uri"].as_str().unwrap().to_owned();
if done.contains(&uri) {
continue;
}
let diags = if uri == uri_of(&a) {
json!([{ "range": {"start":{"line":0,"character":4},"end":{"line":0,"character":10}},
"severity": 2, "message": "unused variable" }])
} else {
assert_eq!(uri, uri_of(&b));
json!([])
};
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": diags }))
.unwrap();
done.push(uri);
}
if done.len() == 2 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
};
let results = inspect(&h, "mock", vec![a.clone(), b.clone()]).await;
publisher.await.unwrap();
assert_eq!(results.len(), 2);
match &results[0].1 {
ide_lsp::FileInspection::Inspected(items) => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].message, "unused variable");
assert_eq!(items[0].severity, Severity::Warning);
assert_eq!(items[0].source.as_deref(), Some("mock"));
assert_eq!(items[0].range, rng(0, 4, 0, 10));
}
other => panic!("unexpected {other:?}"),
}
assert!(matches!(&results[1].1, ide_lsp::FileInspection::Inspected(items) if items.is_empty()));
// Transient documents are closed again.
eventually(|| s.notified("textDocument/didClose").len() == 2).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn inspect_pulls_diagnostics_when_the_server_supports_it() {
let mut caps = incremental_caps();
caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness(&[("mock", caps)]);
let a = h.root.join("a.mk");
std::fs::write(&a, "x\n").unwrap();
let s = h.launcher.state("mock");
s.respond(
"textDocument/diagnostic",
json!({ "kind": "full", "items": [{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}},
"severity": 1, "message": "bad x", "source": "mockc" }] }),
);
let results = inspect(&h, "mock", vec![a.clone()]).await;
match &results[0].1 {
ide_lsp::FileInspection::Inspected(items) => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].severity, Severity::Error);
assert_eq!(items[0].source.as_deref(), Some("mockc"));
}
other => panic!("unexpected {other:?}"),
}
assert_eq!(s.requested("textDocument/diagnostic")[0]["textDocument"]["uri"], json!(uri_of(&a)));
}
#[tokio::test(flavor = "multi_thread")]
async fn inspect_reports_files_without_a_server() {
let h = harness(&[("mock", incremental_caps())]);
let txt = h.root.join("notes.txt");
std::fs::write(&txt, "hello\n").unwrap();
assert!(!h.mgr.serves_path(&txt));
assert!(h.mgr.serves_path(&h.root.join("a.mk")));
let results = inspect(&h, "plaintext", vec![txt]).await;
assert!(matches!(results[0].1, ide_lsp::FileInspection::NoServer));
}
#[tokio::test(flavor = "multi_thread")]
async fn css_family_configuration_returns_default_objects() {
for language in ["css", "scss", "less"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let registry = Arc::new(LanguageRegistry::load(None).unwrap());
let launcher = Arc::new(MockLauncher::default());
let state = launcher.state("vscode-css-language-server");
*state.caps.lock() = incremental_caps();
let mgr = LspManager::with_launcher(root.clone(), registry, Arc::new(|_| {}), launcher);
let path = root.join(format!("a.{language}"));
let text = ".qa { colr: red; }";
std::fs::write(&path, text).unwrap();
mgr.did_open(DocId::next(), &path, &LanguageId::new(language), 1, text);
eventually(|| !state.notified("textDocument/didOpen").is_empty()).await;
let reply: Value = state
.peer()
.request(
"workspace/configuration",
json!({ "items": [
{ "section": "css", "scopeUri": uri_of(&path) },
{ "section": "scss", "scopeUri": uri_of(&path) },
{ "section": "less", "scopeUri": uri_of(&path) },
{ "section": "unknown" }
] }),
None,
)
.await
.unwrap();
assert_eq!(reply, json!([{}, {}, {}, null]), "{language}");
assert_eq!(state.requested("initialize")[0]["initializationOptions"]["provideFormatter"], true);
}
}
// ------------------------------------------------------------------ references beyond the analysis scope
const WORD: &str = "target";
/// Locations of [`WORD`] in `text` (ASCII, so UTF-16 columns are byte offsets).
fn occurrences(uri: &str, text: &str) -> Vec<Value> {
let mut out = Vec::new();
for (line, l) in text.lines().enumerate() {
for (col, _) in l.match_indices(WORD) {
let end = col + WORD.len();
out.push(json!({ "uri": uri, "range": {
"start": { "line": line, "character": col }, "end": { "line": line, "character": end } } }));
}
}
out
}
/// A server that only analyses its open documents (minus `blind` URIs), like
/// Pyright for files outside its `include`.
fn open_files_only(blind: Vec<String>) -> Responder {
Arc::new(move |s, method, params| {
let docs: Vec<(String, String)> =
s.docs.lock().iter().filter(|(u, _)| !blind.contains(u)).map(|(u, t)| (u.clone(), t.clone())).collect();
let locs = || docs.iter().flat_map(|(u, t)| occurrences(u, t));
match method {
"textDocument/references" => Some(Value::Array(locs().collect())),
"textDocument/rename" => {
let mut changes = serde_json::Map::new();
for loc in locs() {
let edits = changes.entry(loc["uri"].as_str().unwrap()).or_insert_with(|| json!([]));
edits.as_array_mut().unwrap().push(json!({ "range": loc["range"], "newText": "renamed" }));
}
Some(json!({ "changes": changes }))
}
"textDocument/prepareCallHierarchy" => {
let uri = &params["textDocument"]["uri"];
let at = json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } });
Some(json!([{ "name": WORD, "kind": 12, "uri": uri, "range": at, "selectionRange": at }]))
}
"callHierarchy/incomingCalls" => Some(Value::Array(
locs()
.map(|l| {
let from = json!({ "name": "caller", "kind": 12, "uri": l["uri"], "range": l["range"], "selectionRange": l["range"] });
json!({ "from": from, "fromRanges": [l["range"]] })
})
.collect(),
)),
_ => None,
}
})
}
/// Candidate files: those directly under the root that contain the word; records the queries.
fn grep_candidates(h: &Harness) -> Arc<Mutex<Vec<ide_lsp::CandidateQuery>>> {
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
h.mgr.set_candidates_hook(Box::new(move |q, _| {
log.lock().push(q.clone());
let mut files: Vec<PathBuf> = std::fs::read_dir(&q.root)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| std::fs::read_to_string(p).is_ok_and(|t| t.contains(&q.word)))
.collect();
files.sort();
ide_lsp::CandidateFiles { files, limit_hit: false }
}));
seen
}
fn uris(params: &[Value]) -> Vec<String> {
params.iter().map(|p| p["textDocument"]["uri"].as_str().unwrap().to_owned()).collect()
}
fn diagnostics_for(h: &Harness, path: &Path) -> usize {
h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Diagnostics { path: p, .. } if p == path)).count()
}
fn scope_harness() -> (Harness, Arc<MockState>) {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(open_files_only(vec![]));
(h, s)
}
#[tokio::test(flavor = "multi_thread")]
async fn references_open_unopened_files_that_mention_the_name() {
let (h, s) = scope_harness();
let seen = grep_candidates(&h);
let b = h.root.join("b.mk");
let c = h.root.join("c.mk");
std::fs::write(&b, "call target\n").unwrap();
std::fs::write(&c, "nothing here\n").unwrap();
let (doc, a) = h.open("a.mk", "def target\n");
let (_, d) = h.open("d.mk", "use target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 2).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
let mut paths: Vec<PathBuf> = refs.locations.iter().map(|l| l.path.to_path_buf()).collect();
paths.sort();
assert_eq!(paths, vec![a.clone(), b.clone(), d.clone()]);
assert_eq!(refs.incomplete, None);
let q = seen.lock()[0].clone();
assert_eq!((q.word.as_str(), q.root.as_path(), q.limit), (WORD, h.root.as_path(), ide_lsp::MAX_CANDIDATES));
assert_eq!(q.languages, vec![h.lang.clone()]);
// Only the unopened candidate was opened (from disk), then closed again.
let opens = s.notified("textDocument/didOpen");
let temp: Vec<&Value> = opens.iter().filter(|p| p["textDocument"]["uri"] == uri_of(&b)).collect();
assert_eq!(temp.len(), 1);
assert_eq!(temp[0]["textDocument"]["version"], 0);
assert_eq!(temp[0]["textDocument"]["text"], "call target\n");
assert!(!uris(&opens).contains(&uri_of(&c)));
assert_eq!(uris(&opens).iter().filter(|u| **u == uri_of(&d)).count(), 1);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
assert_eq!(s.requested("textDocument/references").len(), 2);
assert_eq!(s.docs.lock().len(), 2);
// When every candidate file is already in the answer, no second request is made.
std::fs::remove_file(&b).unwrap();
h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(s.requested("textDocument/references").len(), 3);
// Without project_wide only the server's scope counts: no project search.
std::fs::write(&b, "call target\n").unwrap();
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(seen.lock().len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn references_never_reopen_or_close_the_users_files() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
grep_candidates(&h);
let (doc, _) = h.open("a.mk", "def target\n");
// Open, mentions the name, but the server does not report it.
let (_, d) = h.open("d.mk", "use target\n");
*s.responder.lock() = Some(open_files_only(vec![uri_of(&d)]));
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 2).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert_eq!(uris(&s.notified("textDocument/didOpen")).iter().filter(|u| **u == uri_of(&d)).count(), 1);
assert!(s.notified("textDocument/didClose").is_empty());
assert_eq!(s.requested("textDocument/references").len(), 1);
assert!(s.docs.lock().contains_key(&uri_of(&d)));
}
#[tokio::test(flavor = "multi_thread")]
async fn references_report_a_capped_candidate_search() {
let (h, s) = scope_harness();
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
files: vec![q.root.join("b.mk")],
limit_hit: true,
}));
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
}
/// The Java command must participate in the same widening contract as standard LSP.
#[tokio::test(flavor = "multi_thread")]
async fn classified_java_references_widen_without_losing_metadata() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
*s.responder.lock() = Some(Arc::new(|s, method, _| {
(method == "workspace/executeCommand").then(|| {
let docs = s.docs.lock();
let access = if docs.len() == 1 { "read" } else { "readWrite" };
Value::Array(
docs.iter()
.flat_map(|(uri, text)| {
occurrences(uri, text)
.into_iter()
.map(|location| json!({"location": location, "kind": "field", "access": access}))
})
.collect(),
)
})
}));
let b = h.root.join("b.java");
std::fs::write(&b, "target++;").unwrap();
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let seen = grep_candidates(&h);
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(seen.lock().is_empty());
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(refs.classified.len(), 2);
assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite))));
assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field)));
assert_eq!(refs.incomplete, None);
assert_eq!(s.requested("workspace/executeCommand").len(), 3);
assert!(s.requested("textDocument/references").is_empty());
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
for limit_hit in [true, false] {
let skipped = h.root.join("large.java");
std::fs::write(&skipped, " ".repeat((1 << 20) + 1)).unwrap();
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![skipped.clone()],
limit_hit,
}));
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(
refs.incomplete,
Some(if limit_hit { ReferencesIncomplete::TooManyFiles } else { ReferencesIncomplete::FilesSkipped })
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_java_command_falls_back_to_unclassified_native_references() {
for project_wide in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "NoSuchMethodError".into()));
*s.responder.lock() = Some(open_files_only(Vec::new()));
let (doc, path) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, project_wide, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert_eq!(refs.locations[0].path, path);
assert!(refs.classified.is_empty());
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
assert_eq!(s.requested("textDocument/references").len(), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_widened_java_command_retries_with_native_references() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let native = open_files_only(Vec::new());
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" {
// The initial query succeeds, but the next extension query fails.
s.errors.lock().insert(method.into(), (-32603, "reflection failed".into()));
return Some(Value::Array(
s.docs
.lock()
.iter()
.flat_map(|(uri, text)| {
occurrences(uri, text)
.into_iter()
.map(|location| json!({"location": location, "kind": "field", "access": "read"}))
})
.collect(),
));
}
native(s, method, params)
}));
std::fs::write(h.root.join("b.java"), "target++;").unwrap();
grep_candidates(&h);
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert!(refs.classified.is_empty());
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
assert_eq!(s.requested("textDocument/references").len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn superseded_java_command_does_not_fall_back() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let mgr = Arc::downgrade(&h.mgr);
*s.responder.lock() = Some(Arc::new(move |_, method, _| {
if method != "workspace/executeCommand" {
return None;
}
mgr.upgrade().unwrap().did_change(doc, 2, &[change(rng(0, 0, 0, 0), " ")], &|| unreachable!());
// Invalid extension response after the source version changed.
Some(json!({"failed": true}))
}));
let err = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(s.requested("textDocument/references").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelled_java_command_does_not_fall_back() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32800, "cancelled".into()));
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let err = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(s.requested("textDocument/references").is_empty());
}
/// What each search widened with candidate files reports for the symbol at the start of `doc`.
async fn incomplete_reports(h: &Harness, doc: DocId) -> Vec<(&'static str, Option<ReferencesIncomplete>)> {
let at = Pos::new(0, 5);
let cancel = ide_core::CancellationToken::new;
vec![
("references", h.mgr.references(doc, at, true, true, cancel()).await.unwrap().incomplete),
("rename", h.mgr.rename(doc, at, "renamed".into(), cancel()).await.unwrap().incomplete),
("callers", h.mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, cancel()).await.unwrap().incomplete),
]
}
fn expect_all(reports: Vec<(&'static str, Option<ReferencesIncomplete>)>, want: Option<ReferencesIncomplete>) {
for (search, got) in reports {
assert_eq!(got, want, "{search}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn every_widened_search_reports_a_capped_candidate_search() {
let (h, s) = scope_harness();
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
files: vec![q.root.join("b.mk")],
limit_hit: true,
}));
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
}
#[tokio::test(flavor = "multi_thread")]
async fn every_widened_search_reports_candidates_it_could_not_open() {
let oversized = "target\n".repeat(200_000);
// Name, content (`None`: not on disk), whether the search misses it.
let cases: [(&str, Option<&[u8]>, bool); 4] = [
("large.mk", Some(oversized.as_bytes()), true),
("binary.mk", Some(b"target \xff\xfe\n"), true),
("deleted.mk", None, false),
("small.mk", Some(b"target\n"), false),
];
for (name, content, missed) in cases {
let (h, s) = scope_harness();
let file = h.root.join(name);
if let Some(content) = content {
std::fs::write(&file, content).unwrap();
}
let listed = file.clone();
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![listed.clone()],
limit_hit: false,
}));
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let want = missed.then_some(ReferencesIncomplete::FilesSkipped);
for (search, got) in incomplete_reports(&h, doc).await {
assert_eq!(got, want, "{name}: {search}");
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_large_file_the_user_has_open_is_not_reported_as_skipped() {
let (h, s) = scope_harness();
let large = "target\n".repeat(200_000);
let (_, path) = h.open("large.mk", &large);
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 2).await;
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![path.clone()],
limit_hit: false,
}));
// `covered` would hide the file: answer as if the server had not listed it.
*s.responder.lock() = Some(open_files_only(vec![uri_of(&h.root.join("large.mk"))]));
expect_all(incomplete_reports(&h, doc).await, None);
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_and_callers_can_be_cancelled() {
let (h, s) = scope_harness();
h.mgr.set_candidates_hook(Box::new(|_, cancel| {
while !cancel.is_cancelled() {
std::thread::sleep(Duration::from_millis(5));
}
ide_lsp::CandidateFiles::default()
}));
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let cancel_later = || {
let cancel = ide_core::CancellationToken::new();
let later = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
later.cancel();
});
cancel
};
let err = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into(), cancel_later()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
let err =
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, cancel_later()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_a_references_search_stops_the_candidate_search_and_closes_files() {
let (h, s) = scope_harness();
h.mgr.set_candidates_hook(Box::new(|_, cancel| {
while !cancel.is_cancelled() {
std::thread::sleep(Duration::from_millis(5));
}
ide_lsp::CandidateFiles::default()
}));
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
let cancel = ide_core::CancellationToken::new();
let later = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
later.cancel();
});
let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
// Cancelled while the server answers with a file opened for the search.
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
grep_candidates(&h);
let slow = uri_of(&b);
let inner = open_files_only(vec![]);
let gate = Arc::new(Gate::default());
let held = gate.clone();
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if s.docs.lock().contains_key(&slow) {
held.hold();
}
inner(s, method, params)
}));
let cancel = ide_core::CancellationToken::new();
let mgr = h.mgr.clone();
let search = tokio::spawn({
let cancel = cancel.clone();
async move { mgr.references(doc, Pos::new(0, 5), true, true, cancel).await }
});
eventually(|| gate.entered()).await;
cancel.cancel();
// Returns while the server still holds the answer.
let err = search.await.unwrap().unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
gate.open();
}
#[tokio::test(flavor = "multi_thread")]
async fn diagnostics_of_files_opened_for_a_search_stay_out_of_problems() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
grep_candidates(&h);
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
let (doc, a) = h.open("a.mk", "def target\n");
let diag = json!([{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message": "e" }]);
let inner = open_files_only(vec![]);
let (temp, user, d) = (uri_of(&b), uri_of(&a), diag.clone());
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if s.docs.lock().contains_key(&temp) {
for uri in [&temp, &user] {
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": d })).unwrap();
}
}
inner(s, method, params)
}));
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
// Published right after the didClose (analysis still running): dropped too.
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": diag })).unwrap();
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&a), "diagnostics": [] })).unwrap();
eventually(|| diagnostics_for(&h, &a) == 2).await;
assert_eq!(diagnostics_for(&h, &b), 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn opening_a_file_during_a_search_takes_it_over() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
grep_candidates(&h);
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
let slow = uri_of(&b);
let inner = open_files_only(vec![]);
let gate = Arc::new(Gate::default());
let held = gate.clone();
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if s.docs.lock().get(&slow).is_some_and(|t| t.starts_with("call")) {
held.hold();
}
inner(s, method, params)
}));
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let mgr = h.mgr.clone();
let search = tokio::spawn(async move { mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await });
// The server answers with the copy opened for the search.
eventually(|| gate.entered()).await;
let user_doc = DocId::next();
h.mgr.did_open(user_doc, &b, &h.lang, 7, "edited target\n");
eventually(|| s.docs.lock().get(&uri_of(&b)).map(String::as_str) == Some("edited target\n")).await;
gate.open();
search.await.unwrap().unwrap();
// The temporary copy was closed before the user's didOpen, and the user's stays open.
let events: Vec<(String, Value)> = s
.notifications
.lock()
.iter()
.filter(|(m, p)| m.starts_with("textDocument/did") && p["textDocument"]["uri"] == uri_of(&b))
.map(|(m, p)| (m.clone(), p["textDocument"]["version"].clone()))
.collect();
assert_eq!(
events,
vec![
("textDocument/didOpen".to_owned(), json!(0)),
("textDocument/didClose".to_owned(), Value::Null),
("textDocument/didOpen".to_owned(), json!(7)),
]
);
assert_eq!(s.docs.lock().get(&uri_of(&b)).map(String::as_str), Some("edited target\n"));
// Its diagnostics are the user's again.
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": [] })).unwrap();
eventually(|| diagnostics_for(&h, &b) == 1).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn an_empty_answer_while_indexing_is_retried_when_indexing_ends() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
let (doc, path) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
let peer = s.peer();
peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
// Nothing found while indexing; indexing ends once that answer is out.
s.respond("textDocument/references", Value::Array(occurrences(&uri_of(&path), "def target\n")));
let answered = Arc::new(std::sync::atomic::AtomicBool::new(false));
let first = answered.clone();
*s.responder.lock() = Some(Arc::new(move |_, method, _| {
(method == "textDocument/references" && !first.swap(true, Ordering::SeqCst)).then(|| json!([]))
}));
tokio::spawn(async move {
eventually(|| answered.load(Ordering::SeqCst)).await;
peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "end" } })).unwrap();
});
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert_eq!(refs.incomplete, None);
assert_eq!(s.requested("textDocument/references").len(), 2);
// While the server works, results are taken as they are; finding nothing is flagged.
s.peer()
.notify("$/progress", json!({ "token": "again", "value": { "kind": "begin", "title": "Indexing" } }))
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!((refs.locations.len(), refs.incomplete), (1, None));
s.respond("textDocument/references", json!([]));
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 0);
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_edits_files_outside_the_analysis_scope() {
let (h, s) = scope_harness();
grep_candidates(&h);
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
let (doc, a) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into(), Default::default()).await.unwrap().edit;
let mut paths: Vec<PathBuf> = edit
.files
.iter()
.map(|f| match f {
FileEdit::Edit { path, .. } => path.to_path_buf(),
_ => panic!("unexpected {f:?}"),
})
.collect();
paths.sort();
assert_eq!(paths, vec![a, b.clone()]);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
}
#[tokio::test]
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
for capable in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": if capable {
vec!["typescript.tsserverRequest"]
} else { vec!["_typescript.organizeImports"] } });
let h = harness_configured("typescript", "ts", &[("arbitrary-name", caps)], |cfg| {
cfg.tsserver_project = true;
});
let s = h.launcher.state("arbitrary-name");
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
std::fs::write(h.root.join("unopened.ts"), "export const helper = 1;").unwrap();
h.open("main.ts", "helper");
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
if !capable {
assert!(s.requested("workspace/executeCommand").is_empty());
h.mgr.shutdown_all().await;
assert!(s.requested("workspace/executeCommand").is_empty());
continue;
}
let requests = s.requested("workspace/executeCommand");
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
h.mgr.project_files_changed([&h.root.join("new.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
assert_eq!(
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
3
);
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
eventually(|| {
s.requested("workspace/executeCommand")
.iter()
.filter(|r| r["arguments"][0] == "openExternalProject")
.count()
>= 5
})
.await;
std::fs::write(h.root.join("tsconfig.json"), "{}").unwrap();
h.mgr.project_files_changed([&h.root.join("tsconfig.json")]);
eventually(|| {
s.requested("workspace/executeCommand")
.iter()
.filter(|r| r["arguments"][0] == "closeExternalProject")
.count()
>= 2
})
.await;
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn gopls_interactive_command_validates_before_execution_and_applies_edits() {
let mut caps = incremental_caps();
caps["experimental"] = json!({"interactiveResolveProvider": {"kinds": ["command"]}});
caps["executeCommandProvider"] = json!({"commands": ["gopls.implement_interface", "gopls.add_test"]});
let h = harness(&[("gopls", caps)]);
let (doc, path) = h.open("probe.mk", "type T struct{}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
let s = h.launcher.state("gopls");
let server = h.mgr.servers()[0].id;
assert_eq!(
s.requested("initialize")[0]["capabilities"]["experimental"]["interactiveResolve"],
json!({"inputTypes": ["string"]})
);
let new_file = h.root.join("probe_test.go");
let edit = json!({"documentChanges": [{"kind": "create", "uri": uri_of(&new_file)}, {
"textDocument": {"uri": uri_of(&path), "version": 1},
"edits": [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, "newText": "// generated\n"}]
}]});
*s.responder.lock() = Some(Arc::new(move |s, method, p| {
if method == "command/resolve" {
let valid = p["formAnswers"][0]["value"] == "pkg.I";
return Some(if valid {
json!({"command": p["command"], "arguments": p["arguments"], "formAnswers": p["formAnswers"]})
} else {
json!({"command": p["command"], "arguments": p["arguments"], "formFields": [{"id": "interface", "description": "Enter interface", "required": true, "type": {"kind": "string"}, "error": if p["formAnswers"].as_array().is_some_and(|a| !a.is_empty()) { "Unknown interface" } else { "" }}]})
});
}
if method == "workspace/executeCommand" {
if p["command"] != "gopls.add_test" {
assert_eq!(p["formAnswers"], json!([{"id": "interface", "value": "pkg.I"}]));
}
let reply: Value = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(s.peer().request(
"workspace/applyEdit",
json!({"edit": edit}),
None,
))
})
.unwrap();
assert_eq!(reply, json!({"applied": true}));
return Some(Value::Null);
}
None
}));
let applied = Arc::new(Mutex::new(Vec::new()));
let received = applied.clone();
h.mgr.set_apply_edit_hook(Box::new(move |edit| {
received.lock().push(edit);
true
}));
let command = Command {
title: "Implement".into(),
command: "gopls.implement_interface".into(),
arguments: vec![json!({"location": 1})],
};
let token = ide_core::CancellationToken::new;
let initial = h.mgr.resolve_command(doc, server, command.clone(), vec![], token()).await.unwrap().unwrap();
assert_eq!(initial.form_fields[0].description, "Enter interface");
let invalid = h
.mgr
.resolve_command(
doc,
server,
command.clone(),
vec![FormAnswer { id: "interface".into(), value: "bad".into() }],
token(),
)
.await
.unwrap()
.unwrap();
assert_eq!(invalid.form_fields[0].error.as_deref(), Some("Unknown interface"));
assert!(applied.lock().is_empty());
assert!(s.requested("workspace/executeCommand").is_empty());
let cancelled = token();
cancelled.cancel();
let err = h.mgr.resolve_command(doc, server, command.clone(), vec![], cancelled).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
let cancelled = token();
cancelled.cancel();
let err = h.mgr.execute_action_command(doc, server, command.clone(), vec![], cancelled).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(applied.lock().is_empty());
assert!(s.requested("workspace/executeCommand").is_empty());
let validated = h
.mgr
.resolve_command(
doc,
server,
command,
vec![FormAnswer { id: "interface".into(), value: "pkg.I".into() }],
token(),
)
.await
.unwrap()
.unwrap();
assert!(validated.form_fields.is_empty());
h.mgr.execute_action_command(doc, server, validated.command, validated.form_answers, token()).await.unwrap();
assert_eq!(applied.lock().len(), 1);
let created = h
.mgr
.execute_action_command(
doc,
server,
Command { title: "Test".into(), command: "gopls.add_test".into(), arguments: vec![] },
vec![],
token(),
)
.await
.unwrap();
assert_eq!(created, vec![new_file]);
}
#[tokio::test(flavor = "multi_thread")]
async fn interactive_capability_is_scoped_to_gopls() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("probe.mk", "x\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
let form = h
.mgr
.resolve_command(
doc,
h.mgr.servers()[0].id,
Command { title: "Run".into(), command: "mock.run".into(), arguments: vec![json!(1)] },
vec![],
ide_core::CancellationToken::new(),
)
.await
.unwrap();
assert!(form.is_none());
assert!(s.requested("command/resolve").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn formatting_provider_selection_tracks_dynamic_registration() {
let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]);
let (doc, _) = h.open("a.mk", "hello\n");
h.wait_status("mock", ServerStatus::Ready).await;
assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document");
let peer = h.launcher.state("mock").peer();
peer.request::<_, Value>(
"client/registerCapability",
json!({"registrations": [
{"id": "formatter", "method": "textDocument/formatting"}
]}),
None,
)
.await
.unwrap();
assert!(h.mgr.can_format(doc).await.unwrap());
peer.request::<_, Value>(
"client/unregisterCapability",
json!({"unregisterations": [
{"id": "formatter", "method": "textDocument/formatting"}
]}),
None,
)
.await
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}