Files
openidea/crates/ide-lsp/tests/mock_server.rs
T

3984 lines
182 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,
binary: Mutex<Option<PathBuf>>,
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>>,
/// Holds the handling of notifications of this method until the gate opens.
held: Mutex<Option<(String, Arc<Gate>)>>,
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;
let held = s.held.lock().clone();
if let Some((m, gate)) = held
&& m == method
{
gate.hold();
}
// 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_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &ide_core::CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel)
.map(|peer| (peer, self.state(&config.name).binary.lock().clone()))
}
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,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
resync_on_configuration: false,
pinned: None,
}
}
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,
scratch_template: 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 files_changed_on_disk_reach_the_servers_of_their_language() {
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 (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
// Other languages and files outside the server's root are left out.
let other = h.root.join("notes.txt");
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
h.mgr.watched_files_changed(
&[created.clone(), other],
&[modified.clone(), outside],
std::slice::from_ref(&removed),
&[h.root.join(".venv/lib/site-packages")],
);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([
{ "uri": uri_of(&created), "type": 1 },
{ "uri": uri_of(&modified), "type": 2 },
{ "uri": uri_of(&removed), "type": 3 },
])
);
// Nothing of its languages: no notification.
h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[], &[]);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), 1);
h.mgr.shutdown_all().await;
}
/// A server that registers file watchers hears of what they match, inside excluded folders
/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project
/// that relative patterns name are reported for watching; unregistered, it falls back to
/// its languages' files.
#[tokio::test(flavor = "multi_thread")]
async fn registered_file_watchers_choose_the_changes_a_server_hears_of() {
let h = harness(&[("mock", incremental_caps())]);
let roots = Arc::new(Mutex::new(Vec::<Vec<PathBuf>>::new()));
{
let roots = roots.clone();
h.mgr.set_file_watch_roots_hook(Box::new(move |r| roots.lock().push(r)));
}
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let outside = tempfile::tempdir().unwrap();
let outside = dunce::canonicalize(outside.path()).unwrap();
let external = outside.join("venv/lib/site-packages");
s.peer()
.request::<_, Value>(
"client/registerCapability",
json!({ "registrations": [{ "id": "w", "method": "workspace/didChangeWatchedFiles", "registerOptions": { "watchers": [
{ "globPattern": "**/*.{mk,mod}" },
{ "globPattern": "**/pyrightconfig.json", "kind": 2 },
{ "globPattern": "**/site-packages/**" },
{ "globPattern": { "baseUri": uri_of(&external), "pattern": "**" } },
{ "globPattern": { "baseUri": uri_of(&h.root.join("sub")), "pattern": "**" } },
] } }] }),
None,
)
.await
.unwrap();
assert_eq!(roots.lock().last(), Some(&vec![external.clone()]), "only folders outside the project");
let sp = h.root.join(".venv/lib/python3.14/site-packages");
let notified = || s.notified("workspace/didChangeWatchedFiles");
h.mgr.watched_files_changed(
&[h.root.join("go.mod"), h.root.join("notes.txt"), h.root.join("pyrightconfig.json")],
&[h.root.join("pyrightconfig.json"), external.join("idna/core.py")],
&[h.root.join("old.mk")],
&[sp.join("idna"), h.root.join("node_modules/x")],
);
eventually(|| !notified().is_empty()).await;
assert_eq!(
notified()[0]["changes"],
json!([
{ "uri": uri_of(&h.root.join("go.mod")), "type": 1 },
{ "uri": uri_of(&h.root.join("pyrightconfig.json")), "type": 2 },
{ "uri": uri_of(&external.join("idna/core.py")), "type": 2 },
{ "uri": uri_of(&h.root.join("old.mk")), "type": 3 },
{ "uri": uri_of(&sp.join("idna")), "type": 1 },
])
);
s.peer()
.request::<_, Value>(
"client/unregisterCapability",
json!({ "unregisterations": [{ "id": "w", "method": "workspace/didChangeWatchedFiles" }] }),
None,
)
.await
.unwrap();
assert_eq!(roots.lock().last(), Some(&vec![]));
h.mgr.watched_files_changed(&[h.root.join("go.mod"), h.root.join("b.mk")], &[], &[], &[sp.join("idna")]);
eventually(|| notified().len() == 2).await;
assert_eq!(notified()[1]["changes"], json!([{ "uri": uri_of(&h.root.join("b.mk")), "type": 1 }]));
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);
}
/// The diagnostics shown last for `path` by `source`, as their messages.
fn shown(h: &Harness, path: &Path, source: &str) -> Option<Vec<String>> {
h.events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path: p, source: s, items } if **p == *path && s == source => {
Some(items.iter().map(|d| d.message.clone()).collect())
}
_ => None,
})
}
/// Reports a "bad" diagnostic per line containing `bad`, as `textDocument/diagnostic`; answers
/// `unchanged` when the text is what `previousResultId` was computed from.
fn pull_responder() -> Responder {
Arc::new(|s: &MockState, method: &str, params: &Value| {
if method != "textDocument/diagnostic" {
return None;
}
let text = s.docs.lock().get(params["textDocument"]["uri"].as_str()?).cloned()?;
let id = format!("{}", text.len() * 31 + text.matches("bad").count());
if params["previousResultId"].as_str() == Some(id.as_str()) {
return Some(json!({ "kind": "unchanged", "resultId": id }));
}
let items: Vec<Value> = text
.lines()
.enumerate()
.filter(|(_, l)| l.contains("bad"))
.map(|(i, _)| {
json!({ "range": {"start":{"line":i,"character":0},"end":{"line":i,"character":3}},
"severity": 1, "message": "bad" })
})
.collect();
Some(json!({ "kind": "full", "resultId": id, "items": items }))
})
}
#[tokio::test(flavor = "multi_thread")]
async fn pulled_diagnostics_follow_edits_and_join_pushed_ones() {
let mut pull_caps = incremental_caps();
pull_caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness(&[("mock", pull_caps), ("push", incremental_caps())]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(pull_responder());
let (doc, path) = h.open("a.mk", "ok\nbad\n");
h.wait_status("mock", ServerStatus::Ready).await;
h.wait_status("push", ServerStatus::Ready).await;
assert!(s.requested("initialize")[0]["capabilities"]["textDocument"]["diagnostic"].is_object());
eventually(|| shown(&h, &path, "mock") == Some(vec!["bad".into()])).await;
assert!(h.launcher.state("push").requested("textDocument/diagnostic").is_empty(), "it pulls from providers only");
// What the server still pushes (rust-analyzer: `cargo check`) shows next to what was pulled.
let pushed = json!({ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":2}},
"severity": 2, "message": "pushed" });
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": [pushed] }))
.unwrap();
eventually(|| shown(&h, &path, "mock") == Some(vec!["pushed".into(), "bad".into()])).await;
// Edits are pulled again once typing pauses.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 0), "bad\n")], &|| unreachable!());
eventually(|| shown(&h, &path, "mock") == Some(vec!["pushed".into(), "bad".into(), "bad".into()])).await;
// Pulled diagnostics are code actions' context like pushed ones.
let _ = h.mgr.code_actions(doc, rng(2, 1, 2, 1), vec![]).await;
let ctx = &s.requested("textDocument/codeAction")[0]["context"]["diagnostics"];
assert_eq!(ctx.as_array().unwrap().iter().map(|d| d["message"].as_str().unwrap()).collect::<Vec<_>>(), ["bad"]);
// Saving unchanged text asks with the last result id; `unchanged` keeps what is shown.
let pulls = s.requested("textDocument/diagnostic").len();
h.mgr.did_save(doc, "bad\nok\nbad\n");
eventually(|| s.requested("textDocument/diagnostic").len() > pulls).await;
let last = s.requested("textDocument/diagnostic").last().cloned().unwrap();
assert!(last["previousResultId"].is_string());
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(shown(&h, &path, "mock"), Some(vec!["pushed".into(), "bad".into(), "bad".into()]));
// Closing drops what was pulled; the pushed diagnostics stay the server's to clear.
h.mgr.did_close(doc);
eventually(|| shown(&h, &path, "mock") == Some(vec!["pushed".into()])).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_cancelled_pull_is_retried_and_a_refresh_pulls_again() {
let mut caps = incremental_caps();
caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(pull_responder());
// Busy indexing: the server cancels and asks to be asked again.
s.errors.lock().insert("textDocument/diagnostic".into(), (-32802, "indexing".into()));
let (_doc, path) = h.open("a.mk", "bad\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.requested("textDocument/diagnostic").len() >= 2).await;
assert_eq!(shown(&h, &path, "mock"), None);
s.errors.lock().clear();
eventually(|| shown(&h, &path, "mock") == Some(vec!["bad".into()])).await;
// `workspace/diagnostic/refresh` (e.g. after a project reload) pulls open files again.
*s.responder.lock() = Some(Arc::new(|_: &MockState, method: &str, _: &Value| {
(method == "textDocument/diagnostic").then(|| {
json!({ "kind": "full", "items": [
{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":3}}, "message": "reloaded" } ] })
})
}));
let refreshed: Value = s.peer().request("workspace/diagnostic/refresh", Value::Null, None).await.unwrap();
assert_eq!(refreshed, Value::Null);
eventually(|| shown(&h, &path, "mock") == Some(vec!["reloaded".into()])).await;
}
#[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": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }),
)
.unwrap();
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 and rust-analyzer's developer notices are 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);
}
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
#[tokio::test(flavor = "multi_thread")]
async fn navigation_waits_for_a_server_that_is_still_loading() {
#[derive(Clone, Copy, Debug)]
enum Answer {
Empty,
ContentModified,
}
for answer in [Answer::Empty, Answer::ContentModified] {
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 peer = s.peer();
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
if let Answer::ContentModified = answer {
s.errors.lock().insert("textDocument/definition".into(), (-32801, "content modified".into()));
}
let mgr = h.mgr.clone();
let goto = tokio::spawn(async move { mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await });
eventually(|| !s.requested("textDocument/definition").is_empty()).await;
assert!(!goto.is_finished(), "{answer:?}: an answer while loading is not final");
s.errors.lock().clear();
let r = json!({"start":{"line":0,"character":3},"end":{"line":0,"character":6}});
s.respond("textDocument/definition", json!({ "uri": uri_of(&path), "range": r }));
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
let defs = tokio::time::timeout(Duration::from_secs(10), goto).await.unwrap().unwrap().unwrap();
assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))], "{answer:?}");
h.wait_status("mock", ServerStatus::Ready).await;
}
// Hover documentation the same way.
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let mgr = h.mgr.clone();
let hover = tokio::spawn(async move { mgr.hover(doc, Pos::new(0, 0), Default::default()).await });
eventually(|| !s.requested("textDocument/hover").is_empty()).await;
s.respond("textDocument/hover", json!({ "contents": "docs" }));
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
let hover = tokio::time::timeout(Duration::from_secs(10), hover).await.unwrap().unwrap().unwrap();
assert!(hover.is_some());
// "Nothing here" is the answer at once: rust-analyzer loaded with `cargo check`
// running, and a server without `serverStatus` indexing in the background.
for quiescent in [Some(true), None] {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let peer = s.peer();
if let Some(q) = quiescent {
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": q })).unwrap();
}
peer.notify("$/progress", json!({ "token": "bg", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let defs = tokio::time::timeout(Duration::from_secs(2), h.mgr.goto(doc, Pos::new(0, 0), GotoKind::Definition))
.await
.unwrap_or_else(|_| panic!("{quiescent:?}: no wait for background work"))
.unwrap();
assert!(defs.is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 1, "{quiescent:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn progress_titled_with_an_executable_path_shows_the_program_name() {
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();
let title = "/Users/thomas/Library/Application Support/mbx/bin/cargo check";
peer.notify("$/progress", json!({ "token": "fc", "value": { "kind": "begin", "title": title } })).unwrap();
peer.notify("$/progress", json!({ "token": "fc", "value": { "kind": "report", "message": "tauri_plugin (lib)" } }))
.unwrap();
peer.notify("$/progress", json!({ "token": "fc", "value": { "kind": "end" } })).unwrap();
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. }))).await;
let titles: Vec<String> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { title, .. } => Some(title.clone()),
_ => None,
})
.collect();
assert_eq!(titles, ["cargo check"; 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);
}
/// Intelephense applies the PHP version only to documents it parses after reading its
/// configuration: once that is answered, servers that opt in get their open documents again.
#[tokio::test(flavor = "multi_thread")]
async fn configuration_answer_resyncs_open_documents_of_opted_in_servers() {
let h = harness_configured("mock", "mk", &[("mock", incremental_caps()), ("extra", incremental_caps())], |cfg| {
cfg.resync_on_configuration = cfg.name == "mock"
});
let (doc, path) = h.open("a.mk", "enum A {}");
let uri = uri_of(&path);
let (s, extra) = (h.launcher.state("mock"), h.launcher.state("extra"));
h.wait_status("mock", ServerStatus::Ready).await;
h.wait_status("extra", ServerStatus::Ready).await;
eventually(|| s.docs.lock().contains_key(&uri) && extra.docs.lock().contains_key(&uri)).await;
h.mgr.did_change(doc, 2, &[change(rng(0, 9, 0, 9), "\n")], &|| unreachable!());
eventually(|| s.notified("textDocument/didChange").len() == 1).await;
let items = |section: &str| json!({ "items": [{ "section": section }] });
let options = json!([{ "flag": true, "nested": { "deep": 1 } }]);
let answer: Value = extra.peer().request("workspace/configuration", items("extra"), None).await.unwrap();
assert_eq!(answer, options);
// The mock handles what arrives in order, answers included: a re-sync sent before the
// answer would hold it back here.
let gate = Arc::new(Gate::default());
*s.held.lock() = Some(("textDocument/didChange".into(), gate.clone()));
let peer = s.peer();
let request = peer.request::<_, Value>("workspace/configuration", items("mock"), None);
let answer = tokio::time::timeout(Duration::from_secs(10), request).await.expect("re-sync sent before the answer");
assert_eq!(answer.unwrap(), options);
eventually(|| gate.entered()).await;
assert_eq!(s.notified("textDocument/didChange").len(), 1);
gate.open();
eventually(|| s.notified("textDocument/didChange").len() == 2).await;
assert_eq!(
s.notified("textDocument/didChange")[1],
json!({ "textDocument": { "uri": uri, "version": 2 }, "contentChanges": [{ "text": "enum A {}\n" }] })
);
assert_eq!(s.docs.lock()[&uri], "enum A {}\n");
// A notification sent after both answers reaches the servers after anything they triggered.
h.mgr.did_save(doc, "enum A {}\n");
eventually(|| extra.notified("textDocument/didSave").len() == 1 && s.notified("textDocument/didSave").len() == 1)
.await;
assert_eq!(s.notified("textDocument/didChange").len(), 2, "one re-sync per answer");
let methods: Vec<String> = extra.notifications.lock().iter().map(|(m, _)| m.clone()).collect();
assert_eq!(methods, ["initialized", "textDocument/didOpen", "textDocument/didChange", "textDocument/didSave"]);
assert_eq!(s.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 java_launch_serializes_the_classpath_with_the_build_lock() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (_, _file) = h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
state.respond("java/buildWorkspace", json!(1));
// A launch with a build re-takes the lock after java_build has released it.
let info = tokio::time::timeout(
Duration::from_secs(30),
h.mgr.java_launch(&h.root, false, true, false, &Default::default()),
)
.await
.expect("java_launch deadlocked on the build lock")
.unwrap();
assert!(info.class_paths.is_empty());
// The delegated external build holds the lock while java_launch resolves the
// classpath; java_launch must not re-take it, or the launch would deadlock.
let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap();
tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default()))
.await
.expect("java_launch deadlocked on the delegated external build's lock")
.unwrap();
drop(guard);
let commands: Vec<_> = state
.requested("workspace/executeCommand")
.iter()
.filter_map(|p| p["command"].as_str().map(str::to_owned))
.collect();
assert!(commands.contains(&"java.project.getClasspaths".to_owned()));
}
#[tokio::test(flavor = "multi_thread")]
async fn open_java_files_are_checked_again_after_a_project_update() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (_, a) = h.open("A.java", "class A {}");
let (_, b) = h.open("B.java", "class B {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let refreshed = || {
let mut uris: Vec<Value> = state
.requested("workspace/executeCommand")
.iter()
.filter(|p| p["command"] == "java.project.refreshDiagnostics")
.map(|p| {
assert_eq!(p["arguments"][1], "thisFile");
p["arguments"][0].clone()
})
.collect();
uris.sort_by_key(|u| u.to_string());
uris
};
// Other status messages leave the documents alone.
state
.peer()
.notify("language/status", json!({ "type": "Message", "message": "Updating project configurations..." }))
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(refreshed().is_empty());
state.peer().notify("language/status", json!({ "type": "ProjectStatus", "message": "OK" })).unwrap();
let mut expected = vec![json!(uri_of(&a)), json!(uri_of(&b))];
expected.sort_by_key(|u| u.to_string());
eventually(|| refreshed() == expected).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
use std::io::Write;
let jar = |path: &Path, entries: &[(&str, &str)]| {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, text) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
};
let repo = tempfile::tempdir().unwrap();
let dir = repo.path().join("org/springframework/boot/spring-boot/3.5.16");
let source = "package org.springframework.boot\n\ninline fun <reified T : Any> runApplication(vararg args: String) =\n SpringApplication.run(T::class.java, *args)\n";
jar(
&dir.join("spring-boot-3.5.16.jar"),
&[("org/springframework/boot/SpringApplicationExtensionsKt.class", "\u{0}")],
);
jar(
&dir.join("spring-boot-3.5.16-sources.jar"),
&[("org/springframework/boot/SpringApplicationExtensions.kt", source)],
);
let other = repo.path().join("acme/lib/1.0/lib-1.0.jar");
jar(&other, &[("acme/Thing.class", "\u{0}")]);
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
let r = |l: u32, c0: u32, c1: u32| json!({"start":{"line":l,"character":c0},"end":{"line":l,"character":c1}});
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": ["decompile"] });
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", caps)], &[]);
let (doc, _) = h.open("Main.kt", "fun main(args: Array<String>) {\n runApplication<App>(*args)\n}\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let s = h.launcher.state("kotlin-lsp");
// A class with a cached sources jar: navigation lands on the declaration in its source.
let class =
jar_uri(&dir.join("spring-boot-3.5.16.jar"), "org/springframework/boot/SpringApplicationExtensionsKt.class");
s.respond("textDocument/definition", json!({ "uri": class, "range": r(7, 43, 57) }));
let defs = h.mgr.goto(doc, Pos::new(1, 8), GotoKind::Definition).await.unwrap();
let sources =
jar_uri(&dir.join("spring-boot-3.5.16-sources.jar"), "org/springframework/boot/SpringApplicationExtensions.kt");
assert_eq!(
defs,
vec![ide_core::Location { path: sources.clone().into(), range: rng(2, 29, 2, 43), uri: Some(sources.clone()) }]
);
let vd = h.mgr.virtual_document(&sources).await.unwrap();
assert_eq!((vd.text.as_str(), vd.title.as_str(), vd.decompiled), (source, "SpringApplicationExtensions.kt", false));
assert_eq!(vd.language_id.as_str(), "kotlin");
// The class URI itself (from usages, say) shows the same source.
assert_eq!(h.mgr.virtual_document(&class).await.unwrap().text, source);
assert!(s.requested("workspace/executeCommand").is_empty(), "no server round trip for cached sources");
// No sources jar: the server's `decompile` command.
let thing = jar_uri(&other, "acme/Thing.class");
s.respond("textDocument/definition", json!({ "uri": thing, "range": r(0, 6, 11) }));
let defs = h.mgr.goto(doc, Pos::new(1, 8), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].uri.as_deref(), Some(thing.as_str()), "kept as the server returned it");
s.respond("workspace/executeCommand", json!({ "code": "package acme\n\nclass Thing", "language": "kotlin" }));
let vd = h.mgr.virtual_document(&thing).await.unwrap();
assert_eq!(vd.text, "package acme\n\nclass Thing");
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
}
#[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 java_implementations_come_from_sources_first() {
let caps = json!({
"textDocumentSync": 1,
"implementationProvider": true,
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let uri = uri_of(&path);
let at = move |lines: &[u32]| {
lines
.iter()
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
_ => None,
}));
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![1]);
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
assert_eq!(lines(&markers[0]), vec![1]);
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0]["command"], "openidea.java.implementations");
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
// A dependency's type: those in the sources first, then the dependencies' too.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert_eq!(*partial.lock(), vec![vec![1]]);
assert_eq!(lines(&found), vec![1, 3]);
assert_eq!(s.requested("textDocument/implementation").len(), 1);
// Without a receiver for the first ones (gutter markers), just all of them.
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert!(partial.lock().is_empty());
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 3);
}
#[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, tool: None })
.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, tool: None }
)
.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 features_limit_what_a_server_is_used_for() {
// A refactoring-only server listed first, announcing everything the main one does.
let h = harness_configured("mock", "mk", &[("rope", incremental_caps()), ("main", incremental_caps())], |cfg| {
if cfg.name == "rope" {
cfg.features = vec!["code-action".into()];
cfg.code_action_kinds = vec!["refactor".into()];
}
});
let (doc, path) = h.open("a.mk", "let x = 1;\n");
for name in ["rope", "main"] {
h.wait_status(name, ServerStatus::Ready).await;
}
let (rope, main) = (h.launcher.state("rope"), h.launcher.state("main"));
for s in [&rope, &main] {
s.respond("textDocument/hover", json!({ "contents": "x" }));
s.respond("textDocument/formatting", json!([]));
}
h.mgr.hover(doc, Pos::new(0, 4), Default::default()).await.unwrap();
assert_eq!(main.requested("textDocument/hover").len(), 1);
h.mgr
.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None })
.await
.unwrap();
assert_eq!(main.requested("textDocument/formatting").len(), 1);
assert!(rope.requested("textDocument/hover").is_empty() && rope.requested("textDocument/formatting").is_empty());
rope.respond(
"textDocument/codeAction",
json!([
{ "title": "Organize imports", "kind": "source.organizeImports" },
{ "title": "Extract variable", "kind": "refactor.extract.variable" },
{ "title": "Run", "command": "mock.run" }
]),
);
main.respond("textDocument/codeAction", json!([{ "title": "Fix", "kind": "quickfix" }]));
let q = h.mgr.code_action_query(doc, rng(0, 8, 0, 9), vec![], ide_core::CancellationToken::new()).await.unwrap();
let mut titles: Vec<&str> = q.actions.iter().map(|a| a.title.as_str()).collect();
titles.sort();
assert_eq!(titles, ["Extract variable", "Fix"], "rope's other kinds and bare commands are dropped");
let diag =
json!([{ "range": {"start":{"line":0,"character":4},"end":{"line":0,"character":5}}, "message": "unused" }]);
rope.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag }))
.unwrap();
main.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag }))
.unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "main"))
})
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "rope")));
}
#[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 })
);
}
#[tokio::test(flavor = "multi_thread")]
async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() {
// vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command.
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true);
let money = h.root.join("money.ts");
let index = h.root.join("index.ts");
std::fs::write(&money, "export const one = 1;\n").unwrap();
std::fs::write(&index, "import { one } from \"./money\";\n").unwrap();
let s = h.launcher.state("vtsls");
*s.responder.lock() = Some(Arc::new(move |_, method, p| {
(method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| {
json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"),
"textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] })
})
}));
let to = h.root.join("lib/money.ts");
let result =
h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
assert_eq!(result.servers, vec!["vtsls".to_owned()]);
assert!(result.errors.is_empty(), "{:?}", result.errors);
match &result.edit.files[..] {
[FileEdit::Edit { path, edits, version: None }] => {
assert_eq!(path, &index);
assert_eq!(edits[0].range, rng(0, 21, 0, 28));
assert_eq!(edits[0].new_text, "./lib/money");
}
other => panic!("unexpected edit {other:?}"),
}
let asked = s.requested("workspace/executeCommand");
let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap();
assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to }));
}
// ------------------------------------------------------------------ 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);
// Scoped; then the sources, widened; then the dependencies too.
assert_eq!(s.requested("workspace/executeCommand").len(), 4);
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 })
);
}
}
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
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 library = json!({"uri": "jdt://contents/lib.jar/lib/Lib.class", "range": {
"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 10}}});
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
(method == "workspace/executeCommand").then(|| {
let sources = params["arguments"].get(4) == Some(&json!("sources"));
let mut rows: Vec<Value> = s
.docs
.lock()
.iter()
.flat_map(|(uri, text)| occurrences(uri, text))
.map(|location| json!({"location": location, "kind": "unknown"}))
.chain((!sources).then(|| json!({"location": library, "kind": "unknown", "text": " target();"})))
.collect();
rows.reverse();
Value::Array(rows)
})
}));
let b = h.root.join("b.java");
std::fs::write(&b, "target();").unwrap();
grep_candidates(&h);
let (doc, a) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Mutex::new(Vec::new());
let refs = h
.mgr
.references_streaming(
doc,
Pos::new(0, 5),
true,
true,
true,
Default::default(),
Some(&|r| partials.lock().push(r)),
)
.await
.unwrap();
let partials = partials.into_inner();
assert_eq!(partials.len(), 1);
let paths =
|r: &References| r.locations.iter().map(|l| (l.path.to_path_buf(), l.uri.is_some())).collect::<Vec<_>>();
let mut shown = paths(&partials[0]);
shown.sort();
assert_eq!(shown, [(a.clone(), false), (b.clone(), false)]);
assert_eq!(partials[0].classified.len(), 2);
// All of them, those in the sources first.
assert_eq!(refs.locations.len(), 3);
assert!(refs.locations[..2].iter().all(|l| l.uri.is_none()));
assert!(refs.locations[2].uri.is_some());
// The server gives a library class's line along.
let line = |r: &References| r.classified.iter().find(|c| c.location.uri.is_some()).and_then(|c| c.text.clone());
assert_eq!(line(&refs).as_deref(), Some(" target();"));
assert!(!refs.libraries_skipped);
let scopes: Vec<_> =
s.requested("workspace/executeCommand").iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources")), None]);
// Without the libraries: the sources alone, widened, and no partial answer.
let partials = Mutex::new(Vec::new());
let project = h
.mgr
.references_streaming(
doc,
Pos::new(0, 5),
true,
true,
false,
Default::default(),
Some(&|r| partials.lock().push(r)),
)
.await
.unwrap();
assert!(partials.into_inner().is_empty());
assert!(project.libraries_skipped);
assert_eq!(project.locations.len(), 2);
assert!(project.locations.iter().all(|l| l.uri.is_none()));
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
// Scoped probes (code lenses) search everything at once.
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 2);
}
#[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");
let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"];
assert!(experimental.get("interactiveResolve").is_none(), "{experimental}");
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());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() {
for language in ["c", "cpp", "objective-c", "objective-cpp"] {
let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("main.src", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("clangd", ServerStatus::Ready).await;
h.open("other.src", "int *p = 0;");
h.mgr.shutdown_all().await;
let events = h.events.lock();
let warnings: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)),
_ => None,
})
.collect();
assert_eq!(warnings.len(), 1, "{language}");
assert_eq!(warnings[0].0, "clangd");
assert!(warnings[0].1.contains(".clang-tidy"));
assert_eq!(warnings[0].2, "clangd");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_warning_requires_apple_binary_and_a_config() {
for (binary, config) in [
("/usr/bin/clangd", false),
("/opt/homebrew/opt/llvm/bin/clangd", true),
("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true),
] {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary));
if config {
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
}
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
h.mgr.shutdown_all().await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })));
std::fs::create_dir(h.root.join("nested")).unwrap();
std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("nested/other.cpp", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_definition_uses_java_without_yaml_definition_capability() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Settings.java"));
s.respond("workspace/executeCommand", json!({
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
"keys": [{"name": "app.timeOut", "relaxed": true}], "roots": []
}));
let path = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n");
let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].range.start, Pos::new(2, 4));
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out");
assert!(s.requested("textDocument/definition").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let (doc, path) = h.open("Settings.java", "int timeOut;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let root = uri_of(&h.root);
s.respond(
"workspace/executeCommand",
json!({
"locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root]
}),
);
s.respond(
"textDocument/references",
json!([{"uri": uri_of(&path), "range": {
"start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7}
}}]),
);
std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap();
std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap();
let overlay = h.root.join("application-dev.yml");
std::fs::write(&overlay, "app:\n old: 3\n").unwrap();
h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n");
let sibling = h.root.join("other");
std::fs::create_dir(&sibling).unwrap();
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap();
h.mgr.did_open(
DocId::next(),
&sibling.join("application.yml"),
&LanguageId::new("yaml"),
1,
"app:\n time-out: still-wrong\n",
);
let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(s.requested("workspace/executeCommand").is_empty());
let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 4, "{refs:?}");
assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling)));
assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay));
assert_eq!(refs.incomplete, None);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_extension_failure_preserves_native_java_navigation() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;");
h.wait_status("jdtls", ServerStatus::Ready).await;
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
s.respond(
"textDocument/definition",
json!({"uri": uri_of(&path), "range": {
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
}}),
);
let result = h.mgr.goto(doc, Pos::new(0, 12), GotoKind::Definition).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
h.mgr.prewarm_java_configuration();
h.mgr.prewarm_java_configuration();
h.wait_status("jdtls", ServerStatus::Ready).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
assert!(s.requested("workspace/executeCommand").is_empty());
let config = h.root.join("application.yml");
h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n");
s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap();
eventually(|| {
s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup")
})
.await;
let requests = s.requested("workspace/executeCommand");
let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap();
assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]]));
assert!(s.notified("textDocument/didOpen").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() {
let mut fixtures = Vec::new();
for mode in ["untrusted", "closed", "non-java"] {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
if mode != "non-java" {
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
}
if mode == "untrusted" {
h.mgr.set_execution_allowed(false);
}
h.mgr.prewarm_java_configuration();
if mode == "closed" {
h.mgr.shutdown_all().await;
}
fixtures.push(h);
}
tokio::time::sleep(Duration::from_millis(2200)).await;
for h in fixtures {
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0);
}
}
/// The configuration command indexes the whole project, so Go to Definition (and
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
/// symbols in a Spring file go straight to JDT.
#[tokio::test(flavor = "multi_thread")]
async fn java_definition_consults_configuration_only_inside_key_arguments() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let text = "class A {\n @Value(\"${app.port}\") int port;\n String a() { return env.getProperty(Keys.HOST) + x.getValue(); }\n}\n";
let (doc, path) = h.open("A.java", text);
h.wait_status("jdtls", ServerStatus::Ready).await;
s.respond("workspace/executeCommand", json!({"locations": [], "keys": [], "roots": []}));
s.respond(
"textDocument/definition",
json!({"uri": uri_of(&path), "range": {
"start": {"line": 0, "character": 6}, "end": {"line": 0, "character": 7}
}}),
);
// Field type, field name, method name, the lookup's receiver and name, another call.
for pos in [Pos::new(1, 24), Pos::new(1, 28), Pos::new(2, 9), Pos::new(2, 22), Pos::new(2, 28), Pos::new(2, 53)] {
assert_eq!(h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap().len(), 1, "{pos:?}");
}
assert!(s.requested("workspace/executeCommand").is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 6);
// Inside a placeholder and a constant lookup key: configuration first, JDT as fallback.
for pos in [Pos::new(1, 13), Pos::new(2, 41)] {
h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
}
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
}
/// JDT LS aborts its Maven import on Windows when the project holds a file with a
/// reserved device name (`nul` left by `2>nul`); the error names those files there
/// and stays silent elsewhere, where such names are ordinary files.
#[tokio::test(flavor = "multi_thread")]
async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let s = h.launcher.state("jdtls");
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
// A file named `nul` cannot be created through the Win32 namespace; on Windows use the verbatim path.
let nul = if cfg!(windows) && !h.root.to_string_lossy().starts_with(r"\\?\") {
PathBuf::from(format!(r"\\?\{}", h.root.join("nul").display()))
} else {
h.root.join("nul")
};
std::fs::write(&nul, "54 bytes").unwrap();
h.open("A.java", "class A {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let failure = "Initialization failed | java.lang.IllegalArgumentException: Path must include project and resource name: /demo | at UnifiedTree.createChildNodeFromFileSystem(UnifiedTree.java:320)";
for _ in 0..2 {
s.peer().notify("window/logMessage", json!({ "type": 1, "message": failure })).unwrap();
}
s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap();
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after")))
.await;
// The scan runs off the RPC thread; give it time to report.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let failures: Vec<String> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { title, body: Some(b), .. } if title == "Java project import failed" => Some(b.clone()),
_ => None,
})
.collect();
if cfg!(windows) {
assert_eq!(failures.len(), 1, "reported once: {failures:?}");
assert!(failures[0].contains("nul"), "{failures:?}");
} else {
assert!(failures.is_empty(), "{failures:?}");
}
let _ = std::fs::remove_file(&nul);
}
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
#[tokio::test(flavor = "multi_thread")]
async fn java_hierarchy_refactorings_use_the_extension_commands() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] =
json!({"commands": ["openidea.java.hierarchy.members", "openidea.java.hierarchy.refactor"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
*s.responder.lock() = Some(Arc::new(|_, method, params| {
if method != "workspace/executeCommand" {
return None;
}
let args = params["arguments"].as_array().unwrap();
let uri = args[0].clone();
Some(match (params["command"].as_str().unwrap(), args[1].as_u64().unwrap()) {
(_, 9) => json!({"refused": "Type has no super types."}),
("openidea.java.hierarchy.members", _) => json!({
"className": "Dog",
"targets": [{"id": "=p/src<{Animal.java[Animal", "name": "Animal", "qualifiedName": "Animal", "interface": false}],
"members": [{"id": "=p/src<{Dog.java[Dog~bark", "label": "bark() : String", "kind": "method", "selected": true, "abstractable": true}],
}),
_ => json!({
"problems": [{"severity": "warning", "message": "The visibility of field 'tail' will be changed to package."}],
"edit": {"changes": {uri.as_str().unwrap(): [
{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}}, "newText": "class"}
]}},
}),
})
}));
let text =
format!("class Dog extends Animal {{ String bark() {{ return \"\"; }} }}{}class Lonely {{}}", "\n".repeat(9));
let (doc, path) = h.open("Dog.java", &text);
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let info =
h.mgr.hierarchy_members(doc, Pos::new(0, 35), HierarchyRefactoring::PullUp, Default::default()).await.unwrap();
assert_eq!(info.class_name, "Dog");
assert_eq!(info.targets[0].name, "Animal");
assert!(info.members[0].selected && info.members[0].abstractable);
assert_eq!(info.members[0].kind, HierarchyMemberKind::Method);
let sent = s.requested("workspace/executeCommand");
assert_eq!(sent[0]["arguments"], json!([uri_of(&path), 0, 35, "pullUp"]));
let choices = vec![HierarchyChoice { id: info.members[0].id.clone(), make_abstract: true }];
let done = h
.mgr
.hierarchy_refactor(
doc,
Pos::new(0, 35),
HierarchyRefactoring::PushDown,
Some(info.targets[0].id.clone()),
choices,
Default::default(),
)
.await
.unwrap();
assert_eq!(done.problems[0].severity, ProblemSeverity::Warning);
let edit = done.edit.unwrap();
assert!(matches!(&edit.files[..], [FileEdit::Edit { path: p, edits, .. }] if p == &path && edits.len() == 1));
let sent = s.requested("workspace/executeCommand");
assert_eq!(
sent[1]["arguments"],
json!([uri_of(&path), 0, 35, "pushDown", "=p/src<{Animal.java[Animal",
[{"id": "=p/src<{Dog.java[Dog~bark", "abstract": true}]])
);
let refused = h.mgr.hierarchy_members(doc, Pos::new(9, 0), HierarchyRefactoring::PullUp, Default::default()).await;
let e = refused.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
assert_eq!(e.message, "Type has no super types.");
}
#[tokio::test(flavor = "multi_thread")]
async fn hierarchy_refactorings_name_the_servers_without_them() {
let h = harness(&[("plain", incremental_caps())]);
let (doc, _) = h.open("a.mk", "class A {}");
h.wait_status("plain", ServerStatus::Ready).await;
let e = h
.mgr
.hierarchy_members(doc, Pos::new(0, 6), HierarchyRefactoring::PullUp, Default::default())
.await
.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(e.message, "plain doesn't support Pull Members Up");
}