дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(java): share JDT LS across builds in a project window (#2272)
* feat(java): share JDT LS across builds in a project window * fix(java): wait for JDT restart before launch requests * fix(host): give relay final replies ten seconds of EOF grace relay::tests::final_responses_keep_flowing_after_input_eof flakes on Windows CI: the target writes one byte per second for seven seconds after input EOF, but the relay only waits five idle seconds, so a slow first byte makes the relay return with nothing received. Raise EOF_GRACE to ten seconds and update the companion test's server wait and elapsed bound to match. Refs #2272 (Windows CI failure) --------- Co-authored-by: arookieofc <[email protected]>
Цей коміт міститься в:
1 джерело
5d3432a000
коміт
a46d873f6c
16 files changed
+1034
-79
No files matched your search
@@ -896,6 +896,8 @@ pub struct JavaSettings {
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
|
||||
pub jdtls_shared: bool,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
@@ -1477,6 +1479,26 @@ mod maven_linkage_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_jdtls_defaults_off_and_round_trips() {
|
||||
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
|
||||
let java: JavaSettings = serde_json::from_str(old).unwrap();
|
||||
assert!(!java.jdtls_shared);
|
||||
}
|
||||
for shared in [false, true] {
|
||||
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
|
||||
let json = serde_json::to_value(&java).unwrap();
|
||||
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
|
||||
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
|
||||
}
|
||||
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -83,7 +83,7 @@ impl Host {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
@@ -137,6 +137,16 @@ impl Host {
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
// Workspace members have independent settings; target servers have their own path mappings.
|
||||
lsp.set_jdtls_shared_hook(Box::new(move || {
|
||||
let effective = settings.effective();
|
||||
!is_workspace
|
||||
&& effective.languages.java.jdtls_shared
|
||||
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
|
||||
}));
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(5);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `<host> <port>` from the arguments after `--connect`.
|
||||
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
@@ -25,7 +25,7 @@ pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
|
||||
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
|
||||
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
|
||||
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
|
||||
/// when the connection can't be opened. An input EOF allows ten idle seconds for a final reply.
|
||||
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
|
||||
let mut from_target = connect(host, port)?;
|
||||
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
@@ -140,11 +140,11 @@ mod tests {
|
||||
let mut request = Vec::new();
|
||||
socket.read_to_end(&mut request).unwrap();
|
||||
// Remain open after seeing EOF, like an idle keep-alive server.
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
wait.recv_timeout(Duration::from_secs(15)).unwrap();
|
||||
});
|
||||
let start = Instant::now();
|
||||
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_secs(8));
|
||||
assert!(start.elapsed() < Duration::from_secs(13));
|
||||
done.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
@@ -627,7 +627,11 @@ impl LspManager {
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.filter(|d| {
|
||||
d.language.as_str() == "java"
|
||||
&& d.path.is_absolute()
|
||||
&& d.servers.iter().any(|a| a.server.id == server.id)
|
||||
})
|
||||
.map(|d| crate::uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if uris.is_empty() {
|
||||
@@ -652,26 +656,50 @@ impl LspManager {
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
let java = LanguageId::new("java");
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
|
||||
Some(run) => Ok((server, run)),
|
||||
None => {
|
||||
let status = server.status();
|
||||
Err(IdeError::unavailable(match status {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
}))
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if let Some(run) = cancellable_wait(cancel, server.wait_ready(remaining)).await? {
|
||||
return Ok((server, run));
|
||||
}
|
||||
let status = server.status();
|
||||
if !matches!(
|
||||
status,
|
||||
ide_core::event::ServerStatus::Missing
|
||||
| ide_core::event::ServerStatus::Misconfigured
|
||||
| ide_core::event::ServerStatus::Crashed
|
||||
) && tokio::time::Instant::now() < deadline
|
||||
&& let Ok(_restart) =
|
||||
cancellable_wait(cancel, tokio::time::timeout_at(deadline, self.reconfigure_lock.lock())).await?
|
||||
{
|
||||
// A restart publishes Down while reaping its old JVM. Its successor may
|
||||
// also have a new identity after changing the shared workspace.
|
||||
if let Some(current) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER)
|
||||
&& current.root.is_dir()
|
||||
&& (current.id != server.id || current.is_starting() || current.running().is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let status = server.status();
|
||||
return Err(IdeError::unavailable(match status {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,20 @@ impl DocSnap {
|
||||
/// Receives the folders outside the project that servers want watched.
|
||||
pub type WatchRootsFn = dyn Fn(Vec<PathBuf>) + Send + Sync;
|
||||
|
||||
pub type JdtlsSharedFn = dyn Fn() -> bool + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JdtlsWorkspace {
|
||||
initialized: bool,
|
||||
shared: bool,
|
||||
roots: Vec<PathBuf>,
|
||||
transitioning: bool,
|
||||
}
|
||||
|
||||
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
|
||||
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
|
||||
}
|
||||
|
||||
pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
@@ -195,6 +209,9 @@ pub struct LspManager {
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
|
||||
watch_roots_hook: RwLock<Option<Arc<WatchRootsFn>>>,
|
||||
jdtls_shared_hook: RwLock<Option<Arc<JdtlsSharedFn>>>,
|
||||
jdtls_workspace: Mutex<JdtlsWorkspace>,
|
||||
reconfigure_lock: tokio::sync::Mutex<()>,
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
java_usages_warned: AtomicBool,
|
||||
@@ -402,6 +419,9 @@ impl LspManager {
|
||||
candidates_hook: RwLock::new(None),
|
||||
excludes_hook: RwLock::new(None),
|
||||
watch_roots_hook: RwLock::new(None),
|
||||
jdtls_shared_hook: RwLock::new(None),
|
||||
jdtls_workspace: Mutex::new(Default::default()),
|
||||
reconfigure_lock: tokio::sync::Mutex::new(()),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
java_usages_warned: AtomicBool::new(false),
|
||||
@@ -416,7 +436,56 @@ impl LspManager {
|
||||
// ---- server lookup & lifecycle ----
|
||||
|
||||
pub fn set_content_roots(&self, roots: Vec<PathBuf>) {
|
||||
*self.content_roots.write() = roots;
|
||||
let changed = {
|
||||
let mut current = self.content_roots.write();
|
||||
let changed = *current != roots;
|
||||
*current = roots;
|
||||
changed
|
||||
};
|
||||
if changed
|
||||
&& self.desired_jdtls_workspace().0
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
|
||||
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
|
||||
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
fn desired_jdtls_workspace(&self) -> (bool, Vec<PathBuf>) {
|
||||
let shared = self.jdtls_shared_hook.read().as_ref().is_some_and(|hook| hook());
|
||||
let mut roots = Vec::new();
|
||||
if shared {
|
||||
roots.push(self.project_root.clone());
|
||||
for root in self.content_roots.read().iter() {
|
||||
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
|
||||
roots.push(root.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(shared, roots)
|
||||
}
|
||||
|
||||
fn jdtls_workspace_state(&self) -> parking_lot::MutexGuard<'_, JdtlsWorkspace> {
|
||||
let mut state = self.jdtls_workspace.lock();
|
||||
if !state.initialized {
|
||||
(state.shared, state.roots) = self.desired_jdtls_workspace();
|
||||
state.initialized = true;
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn server_workspace_roots(&self, server: &Server) -> Vec<PathBuf> {
|
||||
if is_local_jdtls(&server.config) {
|
||||
let state = self.jdtls_workspace_state();
|
||||
if state.shared {
|
||||
return state.roots.clone();
|
||||
}
|
||||
}
|
||||
vec![server.root.clone()]
|
||||
}
|
||||
|
||||
fn project_root_for(&self, path: &Path) -> PathBuf {
|
||||
@@ -431,16 +500,21 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
|
||||
let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state());
|
||||
let mut servers = self.servers.lock();
|
||||
let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
});
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) {
|
||||
self.project_root.clone()
|
||||
} else {
|
||||
launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
})
|
||||
};
|
||||
if let Some(s) =
|
||||
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
|
||||
{
|
||||
@@ -490,6 +564,7 @@ impl LspManager {
|
||||
/// to the open documents (and start them). Also attaches servers newly
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> = self
|
||||
.docs
|
||||
.lock()
|
||||
@@ -538,6 +613,27 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
|
||||
if let Some(state) = &workspace {
|
||||
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
if state.shared != shared || (shared && state.roots != roots) {
|
||||
if let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let startup = if is_local_jdtls(&server.config) {
|
||||
match server.startup_lock.clone().try_lock_owned() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(_) => return,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `begin_start` only claims Idle servers, so a claimed server is never a
|
||||
// live one. Its root is the process's working directory: starting with
|
||||
// one deleted from disk fails (`os error 267` on Windows). Such a
|
||||
@@ -553,7 +649,10 @@ impl LspManager {
|
||||
return;
|
||||
}
|
||||
let server = server.clone();
|
||||
self.runtime.spawn(async move { me.run_instance(server, generation).await });
|
||||
self.runtime.spawn(async move {
|
||||
let _startup = startup;
|
||||
me.run_instance(server, generation).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
@@ -561,6 +660,7 @@ impl LspManager {
|
||||
let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation));
|
||||
let me = self.clone();
|
||||
let tail = server.clone();
|
||||
let launch_cancel = cancel.clone();
|
||||
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
|
||||
// servers may take a while to start listening.
|
||||
let launched = tokio::task::spawn_blocking(move || {
|
||||
@@ -568,17 +668,17 @@ impl LspManager {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
let mut cfg = me.configured(&tail);
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if cfg.target.is_none()
|
||||
&& !cancel.is_cancelled()
|
||||
&& !launch_cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
prepare(&cfg, &tail.root, &cancel);
|
||||
prepare(&cfg, &tail.root, &launch_cancel);
|
||||
cfg = me.configured(&tail);
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
@@ -588,7 +688,7 @@ impl LspManager {
|
||||
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
me.launcher.launch_resolved(
|
||||
@@ -596,7 +696,7 @@ impl LspManager {
|
||||
&root,
|
||||
handler,
|
||||
Box::new(move |line| tail.push_stderr(generation, line)),
|
||||
&cancel,
|
||||
&launch_cancel,
|
||||
)
|
||||
})
|
||||
.await
|
||||
@@ -605,7 +705,7 @@ impl LspManager {
|
||||
let peer = match launched {
|
||||
Ok((peer, binary)) => {
|
||||
if !server.set_resolved_binary(generation, binary) {
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
peer
|
||||
@@ -662,9 +762,14 @@ impl LspManager {
|
||||
init["initializationOptions"]["triggerFiles"] = json!([uri::path_to_uri_string(&path)]);
|
||||
}
|
||||
}
|
||||
let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None))
|
||||
.await
|
||||
.unwrap_or(Err(RpcError::Timeout));
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => {
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, Some(cancel.clone()))) => result.unwrap_or(Err(RpcError::Timeout)),
|
||||
};
|
||||
let caps = match result {
|
||||
Ok(result) => {
|
||||
if let Some(info) = result.get("serverInfo") {
|
||||
@@ -681,7 +786,7 @@ impl LspManager {
|
||||
// The server is exiting: let it, so the crash report has its exit code and last output.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
// Treated like a crash so transient failures retry with backoff.
|
||||
self.spawn_supervisor(server, generation, peer);
|
||||
return;
|
||||
@@ -690,7 +795,7 @@ impl LspManager {
|
||||
let run =
|
||||
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
|
||||
if !server.set_initializing(run.clone()) {
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = peer.notify("initialized", json!({})) {
|
||||
@@ -709,24 +814,30 @@ impl LspManager {
|
||||
|
||||
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
|
||||
let mut reopened = Vec::new();
|
||||
{
|
||||
let ready = {
|
||||
let mut docs = self.docs.lock();
|
||||
if !server.mark_ready(generation, run.clone()) {
|
||||
drop(docs);
|
||||
peer.kill();
|
||||
return;
|
||||
}
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
let params = d.did_open_params();
|
||||
for a in d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) {
|
||||
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: didOpen: {e}", server.name);
|
||||
false
|
||||
} else {
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
let params = d.did_open_params();
|
||||
for a in
|
||||
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
|
||||
{
|
||||
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: didOpen: {e}", server.name);
|
||||
}
|
||||
a.opened_gen = Some(generation);
|
||||
reopened.push(*id);
|
||||
}
|
||||
a.opened_gen = Some(generation);
|
||||
reopened.push(*id);
|
||||
}
|
||||
true
|
||||
}
|
||||
};
|
||||
if !ready {
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
for doc in reopened {
|
||||
self.schedule_pull(doc, Some(&server), Duration::ZERO);
|
||||
@@ -767,7 +878,6 @@ impl LspManager {
|
||||
|
||||
fn initialize_params(&self, server: &Server) -> Value {
|
||||
let root_uri = uri::path_to_uri(&server.root);
|
||||
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
let cfg = server.effective_config();
|
||||
let mut init_options =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
|
||||
@@ -776,11 +886,24 @@ impl LspManager {
|
||||
init_options = json!({});
|
||||
}
|
||||
init_options["settings"] = handler::all_settings(&cfg);
|
||||
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
|
||||
if shared {
|
||||
init_options["workspaceFolders"] = json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| uri::path_to_uri_string(root))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
let triggers: Vec<_> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.filter(|d| {
|
||||
d.language.as_str() == "java"
|
||||
&& d.path.is_absolute()
|
||||
&& d.servers.iter().any(|a| a.server.id == server.id)
|
||||
})
|
||||
.map(|d| uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if !triggers.is_empty() && init_options.get("triggerFiles").is_none() {
|
||||
@@ -794,7 +917,7 @@ impl LspManager {
|
||||
"locale": "en",
|
||||
"rootPath": server.root.to_string_lossy(),
|
||||
"rootUri": root_uri,
|
||||
"workspaceFolders": [{ "uri": root_uri, "name": name }],
|
||||
"workspaceFolders": self.workspace_folders(server),
|
||||
"capabilities": caps::client_capabilities(),
|
||||
"trace": "off",
|
||||
});
|
||||
@@ -811,8 +934,16 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_folders(&self, server: &Server) -> Value {
|
||||
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
json!([{ "uri": uri::path_to_uri(&server.root), "name": name }])
|
||||
json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| {
|
||||
let name =
|
||||
root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
json!({"uri": uri::path_to_uri(root), "name": name})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn sink(&self) -> &SharedSink {
|
||||
@@ -862,6 +993,7 @@ impl LspManager {
|
||||
/// Opens a read-only virtual document (`jdt://…`) on the server that
|
||||
/// returned its URI (`didOpen` only: it is never changed or saved).
|
||||
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
@@ -2450,15 +2582,18 @@ impl LspManager {
|
||||
.into_iter()
|
||||
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
|
||||
let watchers = run.watchers.read();
|
||||
let roots = self.server_workspace_roots(&s);
|
||||
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
|
||||
let languages = s.languages();
|
||||
all.filter(|(p, change)| {
|
||||
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
|
||||
roots.iter().any(|root| p.starts_with(root))
|
||||
&& languages.contains(&self.languages.detect(p, None))
|
||||
&& send(p, *change)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
|
||||
.filter(|(p, change)| roots.iter().any(|root| watchers.wants(root, p, *change)) && send(p, *change))
|
||||
.collect()
|
||||
};
|
||||
drop(watchers);
|
||||
@@ -3036,7 +3171,8 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
if allowed && !previous {
|
||||
for server in self.servers.lock().iter() {
|
||||
let servers = self.servers.lock().clone();
|
||||
for server in &servers {
|
||||
server.reset_for_restart();
|
||||
self.ensure_started(server);
|
||||
}
|
||||
@@ -3053,11 +3189,13 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn reconfigure(&self) {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
let servers = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
return;
|
||||
}
|
||||
self.reconfigure_jdtls_workspace().await;
|
||||
self.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
@@ -3120,6 +3258,39 @@ impl LspManager {
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
async fn reconfigure_jdtls_workspace(&self) {
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
let retired = {
|
||||
// Opens attach atomically with root selection; no document can acquire an old
|
||||
// instance between removing the registry entries and detaching its documents.
|
||||
let _gate = self.injections.gate.lock();
|
||||
let mut state = self.jdtls_workspace_state();
|
||||
if state.shared == shared && (!shared || state.roots == roots) {
|
||||
return;
|
||||
}
|
||||
state.transitioning = true;
|
||||
state.shared = shared;
|
||||
state.roots = roots;
|
||||
let mut servers = self.servers.lock();
|
||||
let retired: Vec<_> = servers.iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
|
||||
servers.retain(|s| !is_local_jdtls(&s.config));
|
||||
for doc in self.docs.lock().values_mut() {
|
||||
doc.servers.retain(|a| !is_local_jdtls(&a.server.config));
|
||||
}
|
||||
retired
|
||||
};
|
||||
for server in &retired {
|
||||
self.stop_instance(server, true).await;
|
||||
}
|
||||
self.forget(&retired);
|
||||
self.jdtls_workspace.lock().transitioning = false;
|
||||
self.attach_fallbacks();
|
||||
let pending: Vec<_> = self.servers.lock().iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
|
||||
for server in pending {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches fallbacks shadowed by enabled servers and instances whose target
|
||||
/// no longer matches the document's language. Stop them once unused.
|
||||
async fn detach_shadowed_servers(&self) {
|
||||
@@ -3255,6 +3426,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
@@ -3282,7 +3454,12 @@ impl LspManager {
|
||||
|
||||
/// Gracefully stops the current instance (`shutdown` + `exit`, then kill).
|
||||
async fn stop_instance(&self, server: &Arc<Server>, final_stop: bool) {
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else { return };
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else {
|
||||
if is_local_jdtls(&server.config) {
|
||||
let _startup = server.startup_lock.lock().await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let peer = run.peer.clone();
|
||||
// A server we connected to without starting it (an open Godot editor) is not
|
||||
// ours to shut down: only the connection closes.
|
||||
@@ -3302,13 +3479,24 @@ impl LspManager {
|
||||
peer.kill();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
Self::kill_peer(server, &peer).await;
|
||||
if is_local_jdtls(&server.config) {
|
||||
let _startup = server.startup_lock.lock().await;
|
||||
}
|
||||
server.end_stop(run.generation, &self.sink);
|
||||
self.file_watch_roots_changed();
|
||||
}
|
||||
|
||||
async fn kill_peer(server: &Server, peer: &ide_jsonrpc::Peer) {
|
||||
peer.kill();
|
||||
if is_local_jdtls(&server.config) && peer.pid().is_some() {
|
||||
peer.exited().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&self) {
|
||||
self.shutting_down.store(true, Ordering::Release);
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,8 @@ pub struct Server {
|
||||
phase_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
|
||||
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
@@ -158,6 +160,7 @@ impl Server {
|
||||
phase_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
startup_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -269,6 +272,10 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_starting(&self) -> bool {
|
||||
matches!(self.state.lock().conn, Conn::Starting)
|
||||
}
|
||||
|
||||
/// The instance told `initialized`, ready or not.
|
||||
pub fn instance(&self) -> Option<Running> {
|
||||
self.running().or_else(|| self.initializing.lock().clone())
|
||||
|
||||
@@ -35,6 +35,8 @@ struct MockState {
|
||||
responder: Mutex<Option<Responder>>,
|
||||
/// Holds the handling of notifications of this method until the gate opens.
|
||||
held: Mutex<Option<(String, Arc<Gate>)>>,
|
||||
initialize_gate: Mutex<Option<Arc<Gate>>>,
|
||||
launch_gate: Mutex<Option<Arc<Gate>>>,
|
||||
peer: Mutex<Option<Peer>>,
|
||||
}
|
||||
|
||||
@@ -73,7 +75,9 @@ fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize {
|
||||
text.len()
|
||||
}
|
||||
|
||||
struct MockHandler(Arc<MockState>);
|
||||
type MockConnection = Mutex<Option<Peer>>;
|
||||
|
||||
struct MockHandler(Arc<MockState>, std::sync::Weak<MockConnection>);
|
||||
|
||||
impl Handler for MockHandler {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
@@ -118,8 +122,14 @@ impl Handler for MockHandler {
|
||||
s.docs.lock().remove(&uri);
|
||||
}
|
||||
"exit" => {
|
||||
if let Some(p) = s.peer.lock().take() {
|
||||
p.kill();
|
||||
if let Some(connection) = self.1.upgrade()
|
||||
&& let Some(peer) = connection.lock().take()
|
||||
{
|
||||
peer.kill();
|
||||
}
|
||||
let mut latest = s.peer.lock();
|
||||
if latest.as_ref().is_some_and(Peer::is_closed) {
|
||||
*latest = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -131,7 +141,13 @@ impl Handler for MockHandler {
|
||||
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" } })),
|
||||
"initialize" => {
|
||||
let gate = s.initialize_gate.lock().take();
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
|
||||
}
|
||||
"shutdown" => Ok(Value::Null),
|
||||
_ => {
|
||||
if let Some(error) = s.errors.lock().get(method).cloned() {
|
||||
@@ -150,6 +166,7 @@ impl Handler for MockHandler {
|
||||
#[derive(Default)]
|
||||
struct MockLauncher {
|
||||
servers: Mutex<HashMap<String, Arc<MockState>>>,
|
||||
connections: Mutex<Vec<Arc<MockConnection>>>,
|
||||
}
|
||||
|
||||
impl MockLauncher {
|
||||
@@ -186,10 +203,18 @@ impl Launcher for MockLauncher {
|
||||
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())));
|
||||
let connection = Arc::new(Mutex::new(None));
|
||||
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone(), Arc::downgrade(&connection))));
|
||||
*connection.lock() = Some(server.clone());
|
||||
self.connections.lock().push(connection);
|
||||
*state.peer.lock() = Some(server);
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
Ok(Peer::connect(cr, cw, handler))
|
||||
let client = Peer::connect(cr, cw, handler);
|
||||
let gate = state.launch_gate.lock().take();
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +445,451 @@ async fn linked_projects_initialize_separate_language_server_roots() {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
fn java_siblings(h: &Harness) -> Vec<PathBuf> {
|
||||
["first", "second"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let root = h.root.join(name);
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("mock.toml"), "").unwrap();
|
||||
let path = root.join("Main.java");
|
||||
std::fs::write(&path, "class Main {}").unwrap();
|
||||
path
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_is_opt_in_and_keeps_module_requests_on_one_instance() {
|
||||
for shared in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
|
||||
let paths = java_siblings(&h);
|
||||
std::thread::scope(|scope| {
|
||||
for path in &paths {
|
||||
scope.spawn(|| h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}"));
|
||||
}
|
||||
});
|
||||
let state = h.launcher.state("jdtls");
|
||||
let count = if shared { 1 } else { 2 };
|
||||
eventually(|| state.requested("initialize").len() == count).await;
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), count);
|
||||
assert_eq!(h.mgr.servers().len(), count);
|
||||
for path in &paths {
|
||||
h.mgr.java_classpath(path.parent().unwrap(), false, &Default::default()).await.unwrap();
|
||||
}
|
||||
let requests = state.requested("workspace/executeCommand");
|
||||
let classpaths: Vec<_> = requests.iter().filter(|r| r["command"] == "java.project.getClasspaths").collect();
|
||||
assert_eq!(classpaths.len(), 2);
|
||||
for path in &paths {
|
||||
assert!(classpaths.iter().any(|r| r["arguments"][0] == uri_of(path.parent().unwrap())));
|
||||
}
|
||||
if shared {
|
||||
let init = &state.requested("initialize")[0];
|
||||
assert_eq!(init["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_toggle_retires_old_instances_and_reopens_every_document() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let paths = java_siblings(&h);
|
||||
for path in &paths {
|
||||
h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
eventually(|| {
|
||||
paths
|
||||
.iter()
|
||||
.all(|path| state.notified("textDocument/didOpen").iter().any(|v| v["textDocument"]["uri"] == uri_of(path)))
|
||||
})
|
||||
.await;
|
||||
let old: Vec<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 3 && state.docs.lock().len() == 2).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
for id in old {
|
||||
assert!(
|
||||
h.events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|event| matches!(event, IdeEvent::LanguageServerRemoved {server} if *server == id))
|
||||
);
|
||||
}
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 5).await;
|
||||
assert_eq!(h.mgr.servers().len(), 2);
|
||||
eventually(|| {
|
||||
paths.iter().all(|path| {
|
||||
state.notified("textDocument/didOpen").iter().filter(|v| v["textDocument"]["uri"] == uri_of(path)).count()
|
||||
== 3
|
||||
})
|
||||
})
|
||||
.await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_cancels_a_retiring_initialize_before_starting_its_replacement() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.initialize_gate.lock() = Some(gate.clone());
|
||||
let path = java_siblings(&h).remove(0);
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = state.peer();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
let switched = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
|
||||
let closed_before_response = old.is_closed();
|
||||
gate.open();
|
||||
switched.expect("initialize cancellation must not wait for its response");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
assert!(closed_before_response, "the old transport must close before its initialize response is released");
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
assert_eq!(h.mgr.servers()[0].root.as_ref(), h.root.as_path());
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_waits_for_an_abandoned_launcher_before_replacing_the_workspace() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
h.open("Main.java", "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = state.peer();
|
||||
let id = h.mgr.servers()[0].id;
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
h.mgr.set_content_roots(vec![dunce::canonicalize(external.path()).unwrap()]);
|
||||
eventually(|| h.mgr.servers().iter().all(|s| s.id != id)).await;
|
||||
h.open("Another.java", "class Another {}");
|
||||
let mut transition = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
let pending = tokio::time::timeout(Duration::from_millis(100), &mut transition).await.is_err();
|
||||
let launches = state.launches.load(Ordering::SeqCst);
|
||||
let old_alive = !old.is_closed();
|
||||
gate.open();
|
||||
assert!(pending, "retirement must wait for the launcher to return");
|
||||
assert_eq!(launches, 1, "the retiring launcher still owns its peer");
|
||||
assert!(old_alive);
|
||||
transition.await.unwrap();
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 2).await;
|
||||
assert!(old.is_closed());
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() {
|
||||
for (shared, failed) in [(false, false), (true, false), (false, true)] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move || shared && changed.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move |cfg, _| {
|
||||
let changed = changed.load(Ordering::SeqCst);
|
||||
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.to_string());
|
||||
if failed && changed {
|
||||
cfg.launch_error = Some("restart configuration error".into());
|
||||
}
|
||||
}
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
let path = java_siblings(&h).remove(0);
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = h.mgr.servers()[0].id;
|
||||
changed.store(true, Ordering::SeqCst);
|
||||
let restarting = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
h.wait_status("jdtls", ServerStatus::Stopped).await;
|
||||
let mut query = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.java_classpath(path.parent().unwrap(), false, &Default::default()).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
|
||||
gate.open();
|
||||
restarting.await.unwrap();
|
||||
let result = match early {
|
||||
Ok(result) => result.unwrap(),
|
||||
Err(_) => query.await.unwrap(),
|
||||
};
|
||||
if failed {
|
||||
assert!(result.unwrap_err().message.contains("restart configuration error"));
|
||||
} else {
|
||||
assert!(result.is_ok(), "a configuration restart is transient: {result:?}");
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(h.mgr.servers()[0].id != old, shared);
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_restart_barrier_wait_remains_cancellable() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move |cfg, _| {
|
||||
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.load(Ordering::SeqCst).to_string());
|
||||
}
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
h.open("Main.java", "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
changed.store(true, Ordering::SeqCst);
|
||||
let restarting = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
h.wait_status("jdtls", ServerStatus::Stopped).await;
|
||||
let cancel = CancellationToken::new();
|
||||
let mut query = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
let root = h.root.clone();
|
||||
let cancel = cancel.clone();
|
||||
async move { manager.java_classpath(&root, false, &cancel).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
|
||||
let waiting = early.is_err();
|
||||
cancel.cancel();
|
||||
let result = match early {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_) => tokio::time::timeout(Duration::from_secs(1), query).await,
|
||||
};
|
||||
gate.open();
|
||||
restarting.await.unwrap();
|
||||
assert!(waiting, "the query must wait behind the restart barrier");
|
||||
assert_eq!(result.unwrap().unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
|
||||
for missing in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
if missing {
|
||||
h.launcher.state("jdtls").missing.store(true, Ordering::SeqCst);
|
||||
} else {
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| {
|
||||
cfg.launch_error = Some("permanent mock configuration error".into())
|
||||
}));
|
||||
}
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(1), h.mgr.java_classpath(&h.root, false, &Default::default()))
|
||||
.await
|
||||
.expect("permanent failures must not incur the Java readiness timeout");
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.message.contains(if missing { "not installed" } else { "permanent mock configuration error" }));
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
let external = dunce::canonicalize(external.path()).unwrap();
|
||||
h.mgr.set_content_roots(vec![external.clone(), external.clone(), h.root.clone()]);
|
||||
let file = external.join("External.java");
|
||||
std::fs::write(&file, "class External {}").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "class External {}");
|
||||
let state = h.launcher.state("jdtls");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let init = &state.requested("initialize")[0];
|
||||
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
|
||||
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
let folders: Value = state.peer().request("workspace/workspaceFolders", Value::Null, None).await.unwrap();
|
||||
assert_eq!(folders, init["workspaceFolders"]);
|
||||
state.peer().request::<_, Value>("client/registerCapability", json!({"registrations":[{
|
||||
"id":"external-java", "method":"workspace/didChangeWatchedFiles", "registerOptions":{"watchers":[{"globPattern":"**/*.java"}]}
|
||||
}]}), None).await.unwrap();
|
||||
let other = external.join("Other.java");
|
||||
h.mgr.watched_files_changed(std::slice::from_ref(&other), &[], &[], &[]);
|
||||
eventually(|| {
|
||||
state.notified("workspace/didChangeWatchedFiles").iter().any(|v| v["changes"][0]["uri"] == uri_of(&other))
|
||||
})
|
||||
.await;
|
||||
h.mgr.set_content_roots(vec![]);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
assert_eq!(state.requested("initialize")[1]["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn trusting_an_existing_java_instance_restarts_it_without_locking_the_registry() {
|
||||
for shared in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
|
||||
h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
h.mgr.set_execution_allowed(false);
|
||||
h.mgr.reconfigure().await;
|
||||
h.mgr.set_execution_allowed(true);
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 1).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
|
||||
for fallback in [false, true] {
|
||||
let h = harness_for(
|
||||
"java",
|
||||
"java",
|
||||
&[("jdtls", incremental_caps()), ("other", incremental_caps())],
|
||||
if fallback { &["other"] } else { &[] },
|
||||
);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
h.launcher.state("jdtls").missing.store(fallback, Ordering::SeqCst);
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
eventually(|| h.launcher.state("other").requested("initialize").len() == 2).await;
|
||||
let servers = h.mgr.servers();
|
||||
assert_eq!(servers.iter().filter(|s| s.name == "jdtls").count(), 1);
|
||||
assert_eq!(servers.iter().filter(|s| s.name == "other").count(), 2);
|
||||
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), usize::from(!fallback));
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| {
|
||||
let mut target = configs[0].clone();
|
||||
target.target_key = Some("container".into());
|
||||
target
|
||||
.initialization_options
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("selectedTarget".into(), toml::Value::Boolean(true));
|
||||
vec![configs[0].clone(), target]
|
||||
}));
|
||||
h.open("Main.java", "class Main {}");
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
|
||||
let initial = state.requested("initialize");
|
||||
assert_eq!(initial.len(), 4);
|
||||
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
|
||||
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
|
||||
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
|
||||
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
|
||||
assert_eq!(local["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
|
||||
assert_eq!(
|
||||
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
|
||||
3,
|
||||
"local sharing does not retire targets"
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let (doc, _) = h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let uris: Vec<_> = (0..16).map(|n| format!("jdt://contents/project/Virtual{n}.class")).collect();
|
||||
state.respond(
|
||||
"textDocument/definition",
|
||||
json!(
|
||||
uris.iter()
|
||||
.map(|uri| json!({"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}))
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
h.mgr.goto(doc, Pos::default(), GotoKind::Definition).await.unwrap();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
let toggle = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
let mut opened = Vec::new();
|
||||
for uri in uris {
|
||||
let manager = h.mgr.clone();
|
||||
let language = h.lang.clone();
|
||||
let doc = DocId::next();
|
||||
opened.push((
|
||||
doc,
|
||||
std::thread::spawn(move || manager.did_open_virtual(doc, &uri, &language, 0, "class Virtual {}")),
|
||||
));
|
||||
}
|
||||
let docs: Vec<_> = opened
|
||||
.into_iter()
|
||||
.map(|(doc, thread)| {
|
||||
thread.join().unwrap();
|
||||
doc
|
||||
})
|
||||
.collect();
|
||||
toggle.await.unwrap();
|
||||
eventually(|| state.docs.lock().len() == docs.len() + 1).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
for doc in docs {
|
||||
h.mgr.hover(doc, Pos::default(), Default::default()).await.unwrap();
|
||||
}
|
||||
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())]);
|
||||
@@ -2730,7 +3200,7 @@ async fn noop_handler_is_usable_for_raw_peers() {
|
||||
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 _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default(), std::sync::Weak::new())));
|
||||
let client = Peer::connect(ar, aw, Arc::new(NoopHandler));
|
||||
let v: Value = client.request("shutdown", (), None).await.unwrap();
|
||||
assert_eq!(v, Value::Null);
|
||||
|
||||
@@ -63,6 +63,104 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires OPENIDEA_TEST_JDTLS and OPENIDEA_TEST_GRADLE_HOME, with JDK 21+"]
|
||||
async fn shared_jdtls_imports_independent_gradle_builds_and_external_root() {
|
||||
use serde_json::json;
|
||||
|
||||
let jdtls = std::env::var("OPENIDEA_TEST_JDTLS").expect("set OPENIDEA_TEST_JDTLS");
|
||||
let gradle_home = std::env::var("OPENIDEA_TEST_GRADLE_HOME").expect("set OPENIDEA_TEST_GRADLE_HOME");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let temp_root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let root = temp_root.join("workspace");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let external = temp_root.join("external");
|
||||
let mut files = Vec::new();
|
||||
for (name, build) in [("alpha", root.join("alpha")), ("beta", root.join("beta")), ("external", external.clone())] {
|
||||
let source_dir = build.join("app/src/main/java/demo");
|
||||
std::fs::create_dir_all(&source_dir).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), format!("rootProject.name = '{name}'\ninclude 'app'\n")).unwrap();
|
||||
std::fs::write(build.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "plugins { id 'java' }\n").unwrap();
|
||||
let source = format!(
|
||||
"package demo; public class Main {{ public static void main(String[] args) {{ {name}Marker(); }} static void {name}Marker() {{}} }}\n"
|
||||
);
|
||||
let path = source_dir.join("Main.java");
|
||||
std::fs::write(&path, &source).unwrap();
|
||||
files.push((name, path, source));
|
||||
}
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "jdtls");
|
||||
let server = &mut java.language_servers[0];
|
||||
server.command = jdtls;
|
||||
server.env.insert("GRADLE_USER_HOME".into(), dir.path().join("gradle-user-home").to_string_lossy().into_owned());
|
||||
server.args.extend(["-data".into(), dir.path().join("jdtls-data").to_string_lossy().into_owned()]);
|
||||
let options = server.initialization_options.as_mut().unwrap().as_table_mut().unwrap();
|
||||
let settings = options.get_mut("settings").unwrap().as_table_mut().unwrap();
|
||||
let java_settings = settings.get_mut("java").unwrap().as_table_mut().unwrap();
|
||||
let imports = java_settings.get_mut("import").unwrap().as_table_mut().unwrap();
|
||||
let gradle = imports.get_mut("gradle").unwrap().as_table_mut().unwrap();
|
||||
gradle.insert("home".into(), toml::Value::String(gradle_home));
|
||||
gradle.insert("wrapper".into(), toml::Value::try_from(json!({"enabled": false})).unwrap());
|
||||
if let Ok(home) = std::env::var("OPENIDEA_TEST_GRADLE_JAVA_HOME") {
|
||||
gradle.insert("java".into(), toml::Value::try_from(json!({"home": home})).unwrap());
|
||||
}
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
mgr.set_content_roots(vec![external]);
|
||||
for (_, path, source) in &files {
|
||||
mgr.did_open(DocId::next(), path, &LanguageId::new("java"), 1, source);
|
||||
}
|
||||
retry("shared jdtls ready", Duration::from_secs(60), || {
|
||||
let status = last_status(&events, "jdtls");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, message)) = &status {
|
||||
panic!("shared jdtls failed: {message:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
for (name, path, _) in &files {
|
||||
let cp = retry(&format!("{name} Gradle classpath"), Duration::from_secs(180), || async {
|
||||
let cp =
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.java_classpath(path, false, &Default::default()))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
cp.class_paths.iter().any(|p| p.contains(&format!("/{name}/app/"))).then_some(cp)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
cp.class_paths
|
||||
.iter()
|
||||
.all(|p| !files.iter().any(|(other, _, _)| other != name && p.contains(&format!("/{other}/app/")))),
|
||||
"classpath leaked between builds: {cp:?}"
|
||||
);
|
||||
let launch = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
mgr.java_launch(path, false, false, false, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("launch resolution timed out")
|
||||
.expect("launch resolution failed");
|
||||
assert_eq!(launch.class_paths, cp.class_paths, "launch resolved a different build for {name}");
|
||||
}
|
||||
let servers = mgr.servers().into_iter().filter(|s| s.name == "jdtls").collect::<Vec<_>>();
|
||||
assert_eq!(servers.len(), 1, "expected one JDT LS: {servers:?}");
|
||||
assert!(servers[0].pid.is_some(), "JDT LS process is not running");
|
||||
for (_, path, _) in [&files[0], &files[2]] {
|
||||
let bad = path.parent().unwrap().join("Broken.java");
|
||||
let bad_source = "package demo; class Broken { MissingType x; }";
|
||||
std::fs::write(&bad, bad_source).unwrap();
|
||||
mgr.did_open(DocId::next(), &bad, &LanguageId::new("java"), 1, bad_source);
|
||||
retry("diagnostics from shared JDT LS", Duration::from_secs(120), || {
|
||||
let found = events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == bad && items.iter().any(|d| d.message.contains("MissingType"))));
|
||||
async move { found.then_some(()) }
|
||||
}).await;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"]
|
||||
async fn kmp_lsp_java_end_to_end() {
|
||||
|
||||
@@ -5271,6 +5271,8 @@ export type JavaSettings = {
|
||||
* (VS Code's `java.jdt.ls.vmargs`).
|
||||
*/
|
||||
jdtlsVmArgs?: string,
|
||||
/** Share one JDT LS across builds in an ordinary project window; other windows remain separate. */
|
||||
jdtlsShared?: boolean,
|
||||
/** Build Tools › Maven. */
|
||||
maven?: MavenSettings,
|
||||
/** Build Tools › Gradle. */
|
||||
|
||||
@@ -514,6 +514,94 @@ describe("Settings dialog", () => {
|
||||
expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsVmArgs", '"-Xmx2g"']]);
|
||||
});
|
||||
|
||||
it("stages shared JDT LS in the project and writes it on Apply", async () => {
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(settingsSet).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
|
||||
expect(settingsSet.mock.calls).toEqual([["project", "languages.java.jdtlsShared", "true"]]);
|
||||
});
|
||||
|
||||
it("keeps the global shared JDT LS preference separate from the project's override", async () => {
|
||||
global = setAt(global, "languages.java.jdtlsShared", true);
|
||||
current = setAt(global, "languages.java.jdtlsShared", false);
|
||||
useProject.setState({ settings: current });
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
expect(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })).not.toBeChecked();
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "Global" }));
|
||||
const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" });
|
||||
expect(checkbox).toBeChecked();
|
||||
fireEvent.click(checkbox);
|
||||
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
|
||||
expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsShared", "false"]]);
|
||||
});
|
||||
|
||||
it("discards a shared JDT LS toggle on Cancel", async () => {
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
fireEvent.click(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(settingsSet).not.toHaveBeenCalled();
|
||||
expect(current.languages?.java?.jdtlsShared).toBe(false);
|
||||
});
|
||||
|
||||
it("disables shared JDT LS only in a native Workspace's project scope", async () => {
|
||||
useProject.setState((s) => ({
|
||||
project: { ...s.project!, workspace: { version: 1, name: "Workspace", projects: ["/p/member"] } },
|
||||
}));
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
expect(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })).toBeDisabled();
|
||||
expect(within(dialog).getByText(/Open a workspace member as a project/)).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "Global" }));
|
||||
const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" });
|
||||
expect(checkbox).toBeEnabled();
|
||||
fireEvent.click(checkbox);
|
||||
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
|
||||
expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsShared", "true"]]);
|
||||
});
|
||||
|
||||
it("disables sharing for a Java language server target only in project scope", async () => {
|
||||
current = setAt(current, "languageServerTargets", { java: { target: "docker:java", server: "jdtls" } });
|
||||
useProject.setState({ settings: current });
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" });
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(within(dialog).getByText(/Select Local machine for the Java language server/)).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "Global" }));
|
||||
expect(checkbox).toBeEnabled();
|
||||
fireEvent.click(checkbox);
|
||||
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
|
||||
expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsShared", "true"]]);
|
||||
});
|
||||
|
||||
it.each([{ java: { target: "", server: "jdtls" } }, { python: { target: "docker:python" } }])(
|
||||
"keeps sharing available when Java runs locally: %j",
|
||||
async (targets) => {
|
||||
current = setAt(current, "languageServerTargets", targets);
|
||||
useProject.setState({ settings: current });
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
expect(within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" })).toBeEnabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses a staged project Java language server target before Apply", async () => {
|
||||
const dialog = await openDialog("languages.java");
|
||||
fireEvent.click(within(dialog).getByRole("radio", { name: "This project" }));
|
||||
const checkbox = within(dialog).getByRole("checkbox", { name: "Share JDT LS across builds" });
|
||||
expect(checkbox).toBeEnabled();
|
||||
act(() => stage("languages.servers", "project", "languageServerTargets.java.target", "docker:java"));
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(settingsSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Settings for New Projects edits the global layer on every page", async () => {
|
||||
render(<SettingsDialog />);
|
||||
await act(async () => {
|
||||
|
||||
@@ -192,6 +192,15 @@ describe("languages settings", () => {
|
||||
expect(folderRole(ps, "src")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults shared JDT LS to off and accepts only boolean values", () => {
|
||||
expect(DEFAULT_LANGUAGES.java.jdtlsShared).toBe(false);
|
||||
expect(validateLanguages({}).java.jdtlsShared).toBe(false);
|
||||
expect(validateSettings({ languages: { java: { jdtlsShared: true } } }).languages.java.jdtlsShared).toBe(true);
|
||||
expect(validateLanguages({ java: { jdtlsShared: false } }).java.jdtlsShared).toBe(false);
|
||||
for (const invalid of ["true", 1, null, []])
|
||||
expect(validateLanguages({ java: { jdtlsShared: invalid } }).java.jdtlsShared).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults Java language level to SDK default and keeps only supported levels", () => {
|
||||
expect(validateLanguages({}).java.languageLevel).toBe("");
|
||||
expect(validateLanguages({ java: { languageLevel: "8" } }).java.languageLevel).toBe("8");
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
* use (applied by `ide-sdk` when language servers start and when run configurations load).
|
||||
*/
|
||||
import { LanguageServerTargetRows, LanguageServerTargetsPage } from "./LanguageServerTarget";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRunTargets } from "@/platform/pluginRunTargets";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { call, commands, type Sdk } from "@/platform/ipc";
|
||||
import { call, commands, type LanguageServerTarget, type Sdk } from "@/platform/ipc";
|
||||
import { IconButton, Input, type SelectOption, T } from "@/ui";
|
||||
import { JAVA_LANGUAGE_LEVELS, PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
|
||||
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
|
||||
import { choosePath } from "./api";
|
||||
import { useSetting } from "../components/context";
|
||||
import { usePageContext, useSetting } from "../components/context";
|
||||
import { SdkField, tildify, useDetected } from "./components";
|
||||
import styles from "./Sdk.module.css";
|
||||
|
||||
@@ -387,6 +388,11 @@ export const JAVA_LEVEL_OPTIONS: SelectOption[] = JAVA_LANGUAGE_LEVELS.map((leve
|
||||
);
|
||||
|
||||
export function JavaPage() {
|
||||
const [shared, setShared] = useSetting<boolean>("languages.java.jdtlsShared");
|
||||
const [serverTargets] = useSetting<Record<string, LanguageServerTarget>>("languageServerTargets", "project");
|
||||
const { scope } = usePageContext();
|
||||
const workspace = useProject((s) => !!s.project?.workspace);
|
||||
const sharedDisabled = scope === "project" && (workspace || !!serverTargets?.java?.target);
|
||||
const [vmArgs, setVmArgs] = useSetting<string>("languages.java.jdtlsVmArgs");
|
||||
const [languageLevel, setLanguageLevel] = useSetting<string>("languages.java.languageLevel");
|
||||
const id = useId();
|
||||
@@ -417,6 +423,19 @@ export function JavaPage() {
|
||||
</Row>
|
||||
</Section>
|
||||
<Section title="Language Server">
|
||||
<Toggle
|
||||
label="Share JDT LS across builds"
|
||||
checked={shared}
|
||||
onChange={setShared}
|
||||
disabled={sharedDisabled}
|
||||
hint={
|
||||
sharedDisabled
|
||||
? workspace
|
||||
? "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it."
|
||||
: "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it."
|
||||
: "Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes."
|
||||
}
|
||||
/>
|
||||
<SdkRow
|
||||
k="languages.java.jdtlsJdk"
|
||||
label="JDK"
|
||||
|
||||
@@ -132,6 +132,9 @@ export const LANGUAGE_PAGES: PageSpec[] = [
|
||||
"lombok",
|
||||
"javaagent",
|
||||
"eclipse jdt",
|
||||
"shared jdtls",
|
||||
"linked builds",
|
||||
"server process",
|
||||
],
|
||||
component: JavaPage,
|
||||
},
|
||||
|
||||
@@ -164,6 +164,7 @@ export interface LanguagesValid {
|
||||
languageLevel: (typeof JAVA_LANGUAGE_LEVELS)[number];
|
||||
jdtlsJdk: string;
|
||||
jdtlsVmArgs: string;
|
||||
jdtlsShared: boolean;
|
||||
gradle: {
|
||||
linkedProjects: string[] | null;
|
||||
jvm: string;
|
||||
@@ -243,6 +244,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
|
||||
languageLevel: "",
|
||||
jdtlsJdk: "",
|
||||
jdtlsVmArgs: "",
|
||||
jdtlsShared: false,
|
||||
gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
|
||||
maven: {
|
||||
runnerJre: "",
|
||||
@@ -401,6 +403,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
|
||||
languageLevel: oneOf(java.languageLevel, JAVA_LANGUAGE_LEVELS, d.java.languageLevel),
|
||||
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
|
||||
jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs),
|
||||
jdtlsShared: bool(java.jdtlsShared, d.java.jdtlsShared),
|
||||
gradle: {
|
||||
linkedProjects: Array.isArray(gradle.linkedProjects)
|
||||
? gradle.linkedProjects.filter((p): p is string => typeof p === "string")
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.",
|
||||
"Share JDT LS across builds": "Share JDT LS across builds",
|
||||
"Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.": "Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.",
|
||||
"Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes.": "Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes.",
|
||||
"Finder": "Finder",
|
||||
"Explorer": "Explorer",
|
||||
"Files": "Files",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"Shared JDT LS is available only in local ordinary project windows. Select Local machine for the Java language server to use it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将 Java 语言服务器的运行位置设为本机后再使用。",
|
||||
"Share JDT LS across builds": "在构建间共享 JDT LS",
|
||||
"Shared JDT LS is available only in local ordinary project windows. Open a workspace member as a project to change this setting for it.": "共享 JDT LS 仅适用于本机的普通项目窗口。请将工作区成员作为项目打开,再修改其此项设置。",
|
||||
"Uses one JDT LS process for linked builds in each local ordinary project window. Other project windows use separate processes.": "在每个本机普通项目窗口中,已关联的构建共用一个 JDT LS 进程。其他项目窗口使用各自独立的进程。",
|
||||
"Finder": "访达",
|
||||
"Explorer": "资源管理器",
|
||||
"Reveal in Finder": "在访达中显示",
|
||||
|
||||
Reference in new issue
Заблокувати користувача