From 3c783e8b5a5c7a2f8313950bc7aeb87f8a81a599 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:42:08 +0800 Subject: [PATCH] fix(lsp): Start jdtls on the JVM directly and report server output outside the protocol On Windows the downloaded jdtls was started through bin/jdtls.bat, which runs `python` and then `pause`. Without a working Python the batch file printed pause's prompt in the console code page on stdout; the client stopped reading at that "non-UTF-8 header", killed the process and showed only "exited with code 1". - jdtls: build the JVM command line of the release's launcher script (Equinox launcher jar, per-platform shared configuration, JDK 24+ XML limits, --jvm-arg options, -data) and start `java` from the selected JDK directly, on every platform. Own wrapper scripts and other layouts keep their command. - JSON-RPC transport: stdout lines that cannot start a message header are decoded, logged and handed to the stderr callback instead of ending the connection; stderr and these lines are decoded as UTF-8, else in the Windows OEM/ANSI code page, else lossily. - A process that exits on its own is reported closed only after its remaining output is read, and a failed initialize waits for the exit, so the crash message carries the real exit code and the last lines. - TCP-started language servers and DAP adapters read their output the same way (a non-UTF-8 line no longer stops the reader); stray adapter stdout goes into the adapter's error tail. Fixes #90 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- Cargo.lock | 1 + crates/ide-dap/src/adapter.rs | 26 +- crates/ide-dap/src/connection.rs | 23 +- crates/ide-dap/src/lib.rs | 5 +- crates/ide-jsonrpc/Cargo.toml | 1 + crates/ide-jsonrpc/src/lib.rs | 158 ++++++++---- crates/ide-jsonrpc/src/output.rs | 136 +++++++++++ crates/ide-jsonrpc/src/tests.rs | 67 ++++++ crates/ide-lsp/src/lib.rs | 4 + crates/ide-lsp/src/tcp.rs | 9 +- crates/ide-lsp/tests/stdio_crash.rs | 67 ++++++ crates/ide-sdk/src/jdtls.rs | 358 ++++++++++++++++++++++++++++ crates/ide-sdk/src/lib.rs | 1 + src-tauri/src/state.rs | 1 + 14 files changed, 789 insertions(+), 68 deletions(-) create mode 100644 crates/ide-jsonrpc/src/output.rs create mode 100644 crates/ide-lsp/tests/stdio_crash.rs create mode 100644 crates/ide-sdk/src/jdtls.rs diff --git a/Cargo.lock b/Cargo.lock index e9728f97..ab57338d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4146,6 +4146,7 @@ dependencies = [ name = "ide-jsonrpc" version = "0.1.17" dependencies = [ + "encoding_rs", "futures", "ide-core", "parking_lot", diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index e7277da7..c9537256 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -14,7 +14,7 @@ use std::pin::Pin; use std::process::Stdio; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio::process::{Child, Command}; pub const PORT_PLACEHOLDER: &str = "${port}"; @@ -457,17 +457,19 @@ fn pump_lines( name: String, sink: Arc>>, ) { - tokio::spawn(async move { - let mut lines = BufReader::new(stream).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}"); - let mut buf = sink.lock(); - if buf.len() >= 40 { - buf.remove(0); - } - buf.push(line); - } - }); + tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| { + tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}"); + push_line(&sink, line); + })); +} + +/// Appends to the recent-output buffer of an adapter (the last 40 lines). +pub(crate) fn push_line(sink: &parking_lot::Mutex>, line: String) { + let mut buf = sink.lock(); + if buf.len() >= 40 { + buf.remove(0); + } + buf.push(line); } const LOOPBACK: &str = "127.0.0.1"; diff --git a/crates/ide-dap/src/connection.rs b/crates/ide-dap/src/connection.rs index 7785fc4f..ed542305 100644 --- a/crates/ide-dap/src/connection.rs +++ b/crates/ide-dap/src/connection.rs @@ -95,7 +95,21 @@ pub struct Connection { impl Connection { /// Starts reader/writer tasks over `reader`/`writer`. Must be called inside a Tokio runtime. - pub fn start(reader: R, mut writer: W) -> (Self, mpsc::UnboundedReceiver) + pub fn start(reader: R, writer: W) -> (Self, mpsc::UnboundedReceiver) + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + Self::start_with_output(reader, writer, Box::new(|_| {})) + } + + /// [`start`](Self::start) that hands lines outside the protocol (an adapter's + /// own output on stdout, decoded) to `on_output`. + pub fn start_with_output( + reader: R, + mut writer: W, + on_output: Box, + ) -> (Self, mpsc::UnboundedReceiver) where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, @@ -133,9 +147,14 @@ impl Connection { let reader_inner = inner.clone(); tokio::spawn(async move { let mut reader = BufReader::new(reader); + let mut noise = |line: &[u8]| { + let text = ide_jsonrpc::output::decode(line); + tracing::warn!("dap: output outside the protocol: {text}"); + on_output(text); + }; loop { let msg = tokio::select! { - msg = framing::read_message(&mut reader) => msg, + msg = framing::read_message_or_noise(&mut reader, &mut noise) => msg, _ = reader_inner.cancel.cancelled() => break, }; match msg { diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 247049f2..5ab70125 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -681,7 +681,10 @@ impl DebugManager { } let io = self.launcher.launch(spec.clone(), reuse_port).await?; let stderr = io.stderr.clone(); - let (conn, incoming) = Connection::start(io.reader, io.writer); + let (conn, incoming) = { + let stderr = stderr.clone(); + Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line))) + }; let session = Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child)); self.sessions.lock().insert(id, session.clone()); diff --git a/crates/ide-jsonrpc/Cargo.toml b/crates/ide-jsonrpc/Cargo.toml index 7d124630..615d1f81 100644 --- a/crates/ide-jsonrpc/Cargo.toml +++ b/crates/ide-jsonrpc/Cargo.toml @@ -15,3 +15,4 @@ futures.workspace = true parking_lot.workspace = true thiserror.workspace = true tracing.workspace = true +encoding_rs.workspace = true diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 1086e892..638fd75f 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -6,7 +6,10 @@ //! * [`Peer`]: a JSON-RPC 2.0 endpoint with request/response correlation, //! cancellation (`$/cancelRequest`) and handlers for server→client traffic. +pub mod output; + pub mod framing { + use crate::output::trim_line_end as trim_line; use std::io::{Error, ErrorKind}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -21,6 +24,19 @@ pub mod framing { /// matched case-insensitively, unknown headers (e.g. `Content-Type`) are /// ignored, and both `\r\n` and bare `\n` line endings are accepted. pub async fn read_message(reader: &mut R) -> std::io::Result>> { + read_message_or_noise(reader, &mut |line| { + tracing::warn!("jsonrpc: ignoring output outside the protocol: {}", crate::output::decode(line)); + }) + .await + } + + /// [`read_message`] that hands lines which can't start a message header (a + /// launcher's or runtime's own output on stdout, in any encoding) to `on_noise` + /// and skips them, instead of failing on them. + pub async fn read_message_or_noise( + reader: &mut R, + on_noise: &mut (dyn FnMut(&[u8]) + Send), + ) -> std::io::Result>> { let mut content_length: Option = None; let mut saw_any = false; let mut line = Vec::with_capacity(64); @@ -37,6 +53,11 @@ pub mod framing { if n as u64 >= MAX_HEADER_LINE { return Err(Error::new(ErrorKind::InvalidData, "header line too long")); } + if !saw_any && header(&line).is_none() { + // Output without a final newline, then EOF (`pause` waiting for a key). + on_noise(trim_line(&line)); + return Ok(None); + } return Err(Error::new(ErrorKind::UnexpectedEof, "EOF inside message header")); } let text = trim_line(&line); @@ -50,12 +71,18 @@ pub mod framing { // Tolerate stray blank lines between messages. continue; } - saw_any = true; - let text = std::str::from_utf8(text).map_err(|_| Error::new(ErrorKind::InvalidData, "non-UTF-8 header"))?; - let Some((name, value)) = text.split_once(':') else { - return Err(Error::new(ErrorKind::InvalidData, format!("malformed header line: {text:?}"))); + let Some((name, value)) = header(text) else { + if !saw_any { + on_noise(text); + continue; + } + return Err(Error::new( + ErrorKind::InvalidData, + format!("malformed header line: {:?}", crate::output::decode(text)), + )); }; - if name.trim().eq_ignore_ascii_case("content-length") { + saw_any = true; + if name.eq_ignore_ascii_case("content-length") { let len: usize = value .trim() .parse() @@ -72,12 +99,12 @@ pub mod framing { Ok(Some(body)) } - fn trim_line(line: &[u8]) -> &[u8] { - let mut end = line.len(); - while end > 0 && matches!(line[end - 1], b'\n' | b'\r') { - end -= 1; - } - &line[..end] + /// `name: value` with a header-like name (a token, no spaces), else `None`. + fn header(line: &[u8]) -> Option<(&str, &str)> { + let text = std::str::from_utf8(trim_line(line)).ok()?; + let (name, value) = text.split_once(':')?; + let token = !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + token.then_some((name, value)) } /// Writes one framed message and flushes once. @@ -126,7 +153,7 @@ use serde_json::{Value, json}; use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}; +use tokio::io::{AsyncRead, AsyncWrite, BufReader}; use tokio::sync::{mpsc, oneshot, watch}; use tokio_util::sync::CancellationToken; @@ -205,6 +232,8 @@ struct Inner { /// Fired by [`Peer::kill`]; stops tasks and kills the child. kill: CancellationToken, pid: Option, + /// Fired when the reader task has stopped (EOF, error or kill). + reader_done: CancellationToken, } impl Inner { @@ -250,10 +279,20 @@ pub enum Framing { } impl Framing { - async fn read(self, reader: &mut R) -> std::io::Result>> { + async fn read( + self, + reader: &mut R, + on_noise: &mut (dyn FnMut(&[u8]) + Send), + ) -> std::io::Result>> { match self { - Framing::ContentLength => framing::read_message(reader).await, - Framing::Lines => framing::read_line_message(reader).await, + Framing::ContentLength => framing::read_message_or_noise(reader, on_noise).await, + Framing::Lines => loop { + let Some(line) = framing::read_line_message(reader).await? else { return Ok(None) }; + match line.iter().find(|b| !b.is_ascii_whitespace()) { + Some(b'{' | b'[') => return Ok(Some(line)), + _ => on_noise(&line), + } + }, } } @@ -265,6 +304,9 @@ impl Framing { } } +/// How long a process that exited is given for its last output to be read. +const OUTPUT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1); + /// A connected JSON-RPC peer. Cloning shares the connection. #[derive(Clone)] pub struct Peer { @@ -282,8 +324,9 @@ impl Drop for KillOnDrop { } impl Peer { - /// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines are - /// forwarded to `tracing` and to `on_stderr`. + /// Spawns `command` and speaks JSON-RPC over its stdio. Stderr lines, and + /// stdout lines that are not part of a message (a launcher's own output), are + /// decoded (see [`output::decode`]) and forwarded to `tracing` and to `on_stderr`. /// /// Must be called from within a Tokio runtime. pub fn spawn( @@ -324,42 +367,44 @@ impl Peer { let stderr = child.stderr.take(); let pid = child.id(); - let peer = Self::start(stdout, stdin, handler, false, pid, framing); + let on_output: Arc = Arc::from(on_stderr); + let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone())); - if let Some(stderr) = stderr { + let stderr_task = stderr.map(|stderr| { let name = command.to_owned(); - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut line = Vec::new(); - loop { - line.clear(); - match reader.read_until(b'\n', &mut line).await { - Ok(0) | Err(_) => break, - Ok(_) => { - let text = String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_owned(); - tracing::debug!(target: "ide_jsonrpc::stderr", "[{name}] {text}"); - on_stderr(text); - } - } - } - }); - } + tokio::spawn(output::read_lines(stderr, move |text| { + tracing::debug!(target: "ide_jsonrpc::stderr", "[{name}] {text}"); + on_output(text); + })) + }); - peer.supervise(child); + peer.supervise(child, stderr_task); Ok(peer) } /// Kills `child` when the connection is torn down; closes the connection when it exits. - fn supervise(&self, mut child: tokio::process::Child) { + /// After an exit of its own, the connection is reported closed only once what the + /// process wrote last (stderr, stray stdout) has been read, so a crash report has it. + fn supervise(&self, mut child: tokio::process::Child, stderr_task: Option>) { let inner = self.inner.clone(); tokio::spawn(async move { - let status = tokio::select! { - status = child.wait() => status.ok(), + let (status, killed) = tokio::select! { + status = child.wait() => (status.ok(), false), _ = inner.kill.cancelled() => { let _ = child.start_kill(); - child.wait().await.ok() + (child.wait().await.ok(), true) } }; + if !killed { + // Bounded: a grandchild may keep the pipes open. + let drained = async { + if let Some(task) = stderr_task { + let _ = task.await; + } + inner.reader_done.cancelled().await; + }; + let _ = tokio::time::timeout(OUTPUT_DRAIN, drained).await; + } inner.mark_closed(status.and_then(|s| s.code())); }); } @@ -373,7 +418,7 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - Self::start(reader, writer, handler, true, None, Framing::ContentLength) + Self::start(reader, writer, handler, true, None, Framing::ContentLength, None) } /// [`connect`](Self::connect) with the given wire framing. @@ -382,7 +427,7 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - Self::start(reader, writer, handler, true, None, framing) + Self::start(reader, writer, handler, true, None, framing, None) } /// [`Peer::connect`] over streams served by `child` (a server reached over a socket @@ -399,8 +444,8 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength); - peer.supervise(child); + let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None); + peer.supervise(child, None); peer } @@ -411,6 +456,7 @@ impl Peer { close_on_eof: bool, pid: Option, framing: Framing, + on_noise: Option>, ) -> Self where R: AsyncRead + Unpin + Send + 'static, @@ -426,6 +472,7 @@ impl Peer { closed, kill: CancellationToken::new(), pid, + reader_done: CancellationToken::new(), }); // Writer: a single task drains the queue so messages never interleave. @@ -476,20 +523,39 @@ impl Peer { let inner = inner.clone(); tokio::spawn(async move { let mut reader = BufReader::new(reader); + let seen_message = AtomicBool::new(false); + let mut noise = |line: &[u8]| { + let text = output::decode(line); + if seen_message.load(Ordering::Relaxed) { + tracing::debug!("jsonrpc: output outside the protocol: {text}"); + } else { + tracing::warn!("jsonrpc: output before the first message: {text}"); + } + if let Some(f) = &on_noise { + f(text); + } + }; loop { let msg = tokio::select! { - msg = framing.read(&mut reader) => msg, + msg = framing.read(&mut reader, &mut noise) => msg, _ = inner.kill.cancelled() => break, }; match msg { - Ok(Some(body)) => dispatch(&inner, &handler, ¬if_tx, &body), + Ok(Some(body)) => { + seen_message.store(true, Ordering::Relaxed); + dispatch(&inner, &handler, ¬if_tx, &body); + } Ok(None) => break, Err(e) => { tracing::warn!("jsonrpc read failed: {e}"); + if let Some(f) = &on_noise { + f(format!("invalid protocol output: {e}")); + } break; } } } + inner.reader_done.cancel(); inner.close_transport(); if close_on_eof || inner.kill.is_cancelled() { inner.mark_closed(None); diff --git a/crates/ide-jsonrpc/src/output.rs b/crates/ide-jsonrpc/src/output.rs new file mode 100644 index 00000000..92e45104 --- /dev/null +++ b/crates/ide-jsonrpc/src/output.rs @@ -0,0 +1,136 @@ +//! Text a server process writes outside the protocol: stderr, and stray stdout +//! lines (a launcher script's or the runtime's own messages) before a message. +//! +//! Such text is often not UTF-8: on Windows, `cmd.exe`, batch files and native +//! launchers write in the system code page (GBK on a Chinese system). + +use encoding_rs::Encoding; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; + +/// `bytes` as text: UTF-8 when valid, else (on Windows) the console / ANSI code +/// page, else UTF-8 with replacement characters. +pub fn decode(bytes: &[u8]) -> String { + decode_with(bytes, &native_code_pages()) +} + +/// [`decode`] trying the given Windows code pages, in order, after UTF-8. +pub fn decode_with(bytes: &[u8], code_pages: &[u32]) -> String { + if let Ok(s) = std::str::from_utf8(bytes) { + return s.to_owned(); + } + for enc in code_pages.iter().filter_map(|&cp| encoding_for_code_page(cp)) { + let (text, had_errors) = enc.decode_without_bom_handling(bytes); + if !had_errors { + return text.into_owned(); + } + } + String::from_utf8_lossy(bytes).into_owned() +} + +/// The `encoding_rs` encoding of a Windows code page, when it has one. +pub fn encoding_for_code_page(cp: u32) -> Option<&'static Encoding> { + Some(match cp { + 65001 => encoding_rs::UTF_8, + 936 => encoding_rs::GBK, + 54936 => encoding_rs::GB18030, + 950 => encoding_rs::BIG5, + 932 => encoding_rs::SHIFT_JIS, + 949 => encoding_rs::EUC_KR, + 866 => encoding_rs::IBM866, + 874 => encoding_rs::WINDOWS_874, + 1250 => encoding_rs::WINDOWS_1250, + 1251 => encoding_rs::WINDOWS_1251, + 1252 => encoding_rs::WINDOWS_1252, + 1253 => encoding_rs::WINDOWS_1253, + 1254 => encoding_rs::WINDOWS_1254, + 1255 => encoding_rs::WINDOWS_1255, + 1256 => encoding_rs::WINDOWS_1256, + 1257 => encoding_rs::WINDOWS_1257, + 1258 => encoding_rs::WINDOWS_1258, + 20866 => encoding_rs::KOI8_R, + 21866 => encoding_rs::KOI8_U, + _ => return None, + }) +} + +/// The OEM (console) and ANSI code pages: console programs and batch files write in +/// the former, GUI-subsystem and Java launchers in the latter. +#[cfg(windows)] +fn native_code_pages() -> [u32; 2] { + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetOEMCP() -> u32; + fn GetACP() -> u32; + } + // SAFETY: argument-less Win32 queries. + unsafe { [GetOEMCP(), GetACP()] } +} + +#[cfg(not(windows))] +fn native_code_pages() -> [u32; 0] { + [] +} + +/// Removes a trailing `\n` / `\r\n` (and stray `\r`s before it). +pub fn trim_line_end(line: &[u8]) -> &[u8] { + let mut end = line.len(); + while end > 0 && matches!(line[end - 1], b'\n' | b'\r') { + end -= 1; + } + &line[..end] +} + +/// Reads `stream` to its end, calling `on_line` with each line decoded by [`decode`]. +/// Unlike `AsyncBufReadExt::lines`, a line that is not UTF-8 does not end the stream. +pub async fn read_lines(stream: R, mut on_line: impl FnMut(String)) { + let mut reader = BufReader::new(stream); + let mut line = Vec::new(); + loop { + line.clear(); + match reader.read_until(b'\n', &mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => on_line(decode(trim_line_end(&line))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `请按任意键继续. . .`, what `pause` prints on a Chinese Windows (code page 936). + const PAUSE_GBK: &[u8] = b"\xc7\xeb\xb0\xb4\xc8\xce\xd2\xe2\xbc\xfc\xbc\xcc\xd0\xf8. . ."; + + #[test] + fn utf8_passes_through() { + assert_eq!(decode_with("héllo 中文".as_bytes(), &[936]), "héllo 中文"); + } + + #[test] + fn code_page_text_is_decoded() { + assert_eq!(decode_with(PAUSE_GBK, &[936, 936]), "请按任意键继续. . ."); + // The first code page that decodes cleanly wins. + assert_eq!(decode_with(PAUSE_GBK, &[437, 936]), "请按任意键继续. . ."); + assert_eq!(decode_with(b"caf\xe9", &[1252]), "café"); + } + + #[test] + fn undecodable_text_is_lossy_utf8() { + let out = decode_with(PAUSE_GBK, &[]); + assert!(out.contains('\u{FFFD}') && out.ends_with(". . ."), "{out}"); + assert_eq!(decode_with(b"a\xffb", &[65001]), "a\u{FFFD}b"); + } + + #[tokio::test] + async fn read_lines_survives_non_utf8_lines() { + let mut input = b"first\r\n".to_vec(); + input.extend_from_slice(b"\xff\xfe broken\n"); + input.extend_from_slice(b"last"); + let mut lines = Vec::new(); + read_lines(&input[..], |l| lines.push(l)).await; + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "first"); + assert!(lines[1].ends_with(" broken")); + assert_eq!(lines[2], "last"); + } +} diff --git a/crates/ide-jsonrpc/src/tests.rs b/crates/ide-jsonrpc/src/tests.rs index 11050997..58fc3f0d 100644 --- a/crates/ide-jsonrpc/src/tests.rs +++ b/crates/ide-jsonrpc/src/tests.rs @@ -44,6 +44,46 @@ mod framing_tests { assert!(read_all(long.as_bytes()).await.is_err(), "header too long"); } + async fn read_with_noise(input: &[u8]) -> (std::io::Result>>, Vec) { + let mut reader = BufReader::new(input); + let mut noise = Vec::new(); + let mut out = Vec::new(); + let result = loop { + match read_message_or_noise(&mut reader, &mut |l| noise.push(crate::output::decode(l))).await { + Ok(Some(msg)) => out.push(msg), + Ok(None) => break Ok(out), + Err(e) => break Err(e), + } + }; + (result, noise) + } + + #[tokio::test] + async fn output_between_messages_is_reported_and_skipped() { + let input = b"OpenJDK 64-Bit Server VM warning: Sharing is only supported\r\n\xc7\xeb\xb0\xb4 \xff\n\ + Content-Length: 2\r\n\r\n{}Picked up JAVA_TOOL_OPTIONS: -Dx\nContent-Length: 4\r\n\r\nnull"; + let (msgs, noise) = read_with_noise(input).await; + assert_eq!(msgs.unwrap(), vec![b"{}".to_vec(), b"null".to_vec()]); + assert_eq!(noise.len(), 3, "{noise:?}"); + assert_eq!(noise[0], "OpenJDK 64-Bit Server VM warning: Sharing is only supported"); + assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx"); + } + + #[tokio::test] + async fn unterminated_output_then_eof_is_reported() { + // `pause` prints its prompt without a newline and the process is gone. + let (msgs, noise) = read_with_noise(b"Press any key to continue . . . ").await; + assert!(msgs.unwrap().is_empty()); + assert_eq!(noise, ["Press any key to continue . . . "]); + } + + #[tokio::test] + async fn garbage_inside_a_header_is_still_an_error() { + let (msgs, _) = read_with_noise(b"Content-Length: 2\r\nnot a header\r\n\r\n{}").await; + let e = msgs.unwrap_err(); + assert!(e.to_string().contains("not a header"), "{e}"); + } + #[tokio::test] async fn handles_partial_reads() { let (mut client, server) = tokio::io::duplex(4); @@ -351,3 +391,30 @@ async fn kill_terminates_process() { peer.kill(); tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap(); } + +/// A launcher that prints in the system code page and exits before speaking the +/// protocol: `jdtls.bat` on a Chinese Windows when its Python step fails, then +/// `pause` prints `请按任意键继续. . .` in GBK on stdout. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn output_before_the_handshake_is_reported_not_dropped() { + let lines = Arc::new(Mutex::new(Vec::new())); + let sink = lines.clone(); + let script = r"printf 'python: not found\n' >&2; printf '\307\353\260\264\310\316\322\342\274\374\274\314\320\370. . .\r\n'; exit 1"; + let peer = Peer::spawn( + "sh", + &["-c".into(), script.into()], + &std::env::temp_dir(), + &[], + Arc::new(NoopHandler), + Box::new(move |l| sink.lock().push(l)), + ) + .unwrap(); + let init = peer.request::<_, Value>("initialize", json!({}), None).await; + assert!(matches!(init, Err(RpcError::Closed)), "{init:?}"); + assert_eq!(tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap(), Some(1)); + // Everything the process wrote is in by the time the close is reported. + let lines = lines.lock().clone(); + assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}"); + assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}"); +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 3ba57aa2..0765e0f6 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -406,6 +406,10 @@ impl LspManager { } Err(e) => { tracing::warn!("{}: initialize failed: {e}", server.name); + if matches!(e, RpcError::Closed) { + // 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(); // Treated like a crash so transient failures retry with backoff. self.spawn_supervisor(server, generation, peer); diff --git a/crates/ide-lsp/src/tcp.rs b/crates/ide-lsp/src/tcp.rs index c3992d21..9775570d 100644 --- a/crates/ide-lsp/src/tcp.rs +++ b/crates/ide-lsp/src/tcp.rs @@ -14,7 +14,7 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::io::AsyncRead; pub(crate) const PORT_PLACEHOLDER: &str = "${port}"; pub(crate) const ROOT_PLACEHOLDER: &str = "${root}"; @@ -157,12 +157,7 @@ pub(crate) fn connect_or_start( } fn pump_lines(stream: R, on_line: Arc) { - tokio::spawn(async move { - let mut lines = BufReader::new(stream).lines(); - while let Ok(Some(line)) = lines.next_line().await { - on_line(line); - } - }); + tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| on_line(line))); } #[cfg(test)] diff --git a/crates/ide-lsp/tests/stdio_crash.rs b/crates/ide-lsp/tests/stdio_crash.rs new file mode 100644 index 00000000..37583022 --- /dev/null +++ b/crates/ide-lsp/tests/stdio_crash.rs @@ -0,0 +1,67 @@ +//! A stdio server that dies before the handshake: what it printed (on stderr, and +//! on stdout outside the protocol, in any encoding) must reach the crash message. +#![cfg(unix)] + +use ide_core::event::ServerStatus; +use ide_core::{DocId, IdeEvent, LanguageId}; +use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig}; +use ide_lsp::{LspManager, ProcessLauncher}; +use parking_lot::Mutex; +use std::sync::Arc; +use std::time::Duration; + +/// Shaped like `jdtls.bat` on a Chinese Windows whose Python step fails: an error on +/// stderr, then `pause` prompting in GBK on stdout (`请按任意键继续. . .`), reading one +/// key from stdin, exiting with an error. +const LAUNCHER: &str = r#"#!/bin/sh +echo "'python' is not recognized as an internal or external command" >&2 +printf '\307\353\260\264\310\316\322\342\274\374\274\314\320\370. . . ' +head -c 1 >/dev/null +printf '\r\n' +exit 1 +"#; + +#[tokio::test(flavor = "multi_thread")] +async fn crash_before_the_handshake_reports_what_the_server_printed() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + let script = root.join("fake-jdtls"); + std::fs::write(&script, LAUNCHER).unwrap(); + std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + + let server: LanguageServerConfig = + toml::from_str(&format!("name = \"fake\"\ncommand = {:?}", script.to_string_lossy())).unwrap(); + let lang: LanguageConfig = toml::from_str("id = \"java\"\nname = \"Java\"\nextensions = [\"java\"]").unwrap(); + let lang = LanguageConfig { language_servers: vec![server], ..lang }; + let registry = Arc::new(LanguageRegistry::from_configs(vec![lang])); + 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, Arc::new(ProcessLauncher)); + let path = root.join("A.java"); + std::fs::write(&path, "class A {}\n").unwrap(); + mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class A {}\n"); + + let crashed = || { + events.lock().iter().find_map(|e| match e { + IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(), + _ => None, + }) + }; + let mut message = None; + for _ in 0..1000 { + message = crashed(); + if message.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let message = message.expect("the server is reported as crashed"); + assert!(message.starts_with("fake exited with code 1:\n"), "{message}"); + assert!(message.contains("'python' is not recognized"), "{message}"); + // The stdout prompt, decoded (code page 936 on Windows, lossily elsewhere). + assert!(message.contains(". . ."), "{message}"); + mgr.shutdown_all().await; +} diff --git a/crates/ide-sdk/src/jdtls.rs b/crates/ide-sdk/src/jdtls.rs new file mode 100644 index 00000000..f4f35724 --- /dev/null +++ b/crates/ide-sdk/src/jdtls.rs @@ -0,0 +1,358 @@ +//! Starting jdtls on the JVM directly instead of through its launcher scripts. +//! +//! The release ships `bin/jdtls` (Python), `bin/jdtls.py` and, for Windows, +//! `bin/jdtls.bat`, which runs `python` and then `pause`. Each needs a Python +//! interpreter; without one the batch file prints `pause`'s prompt in the console +//! code page and exits. The command line the script builds is reproduced here, so +//! only a JDK is needed. + +use crate::java::{Jdk, jdk_at}; +use ide_language::LanguageServerConfig; +use std::path::{Path, PathBuf}; + +/// The parts of a jdtls install the JVM is started with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JdtlsInstall { + /// `plugins/org.eclipse.equinox.launcher_.jar`. + pub launcher: PathBuf, + /// The shared, read-only OSGi configuration for this platform (`config_win`, `config_mac_arm`…). + pub config: PathBuf, +} + +/// Names of the release's launcher scripts; anything else is the user's own wrapper. +const SCRIPTS: &[&str] = &["jdtls", "jdtls.bat", "jdtls.cmd", "jdtls.py"]; + +/// The directory with `plugins/` of the jdtls `executable` belongs to (`/bin/jdtls`, +/// or Homebrew's `/bin/jdtls` wrapper with the install under `libexec`). +pub fn jdtls_home(executable: &Path) -> Option { + let exe = dunce::canonicalize(executable).ok()?; + let home = exe.parent()?.parent()?; + [home.to_path_buf(), home.join("libexec")].into_iter().find(|h| h.join("plugins").is_dir()) +} + +/// Shared configuration directories to try, best first, for `os` / `arch` +/// (`std::env::consts` values), as the release names them. +pub fn config_dir_names(os: &str, arch: &str) -> Vec { + let base = match os { + "windows" => "config_win", + "macos" => "config_mac", + _ => "config_linux", + }; + let mut names = Vec::new(); + if arch == "aarch64" { + names.push(format!("{base}_arm")); + } + names.push(base.to_owned()); + names +} + +/// The launcher jar and configuration of the install at `home`, when it has the +/// release layout. +pub fn find_install(home: &Path, os: &str, arch: &str) -> Option { + let plugins = home.join("plugins"); + let unversioned = plugins.join("org.eclipse.equinox.launcher.jar"); + let launcher = if unversioned.is_file() { + unversioned + } else { + let mut jars: Vec = std::fs::read_dir(&plugins) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name().and_then(|n| n.to_str()).is_some_and(|n| { + // Not the native fragments (`org.eclipse.equinox.launcher.win32.win32.x86_64_…`). + n.starts_with("org.eclipse.equinox.launcher_") && n.ends_with(".jar") + }) + }) + .collect(); + jars.sort(); + jars.pop()? + }; + let config = + config_dir_names(os, arch).into_iter().map(|n| home.join(n)).find(|d| d.join("config.ini").is_file())?; + Some(JdtlsInstall { launcher, config }) +} + +/// Arguments meant for the launcher script, sorted out. +#[derive(Debug, Default, PartialEq, Eq)] +struct ScriptArgs { + jvm: Vec, + java: Option, + data: Option, + rest: Vec, +} + +fn split_script_args(args: &[String]) -> ScriptArgs { + let mut out = ScriptArgs::default(); + let mut it = args.iter(); + while let Some(a) = it.next() { + if let Some(v) = a.strip_prefix("--jvm-arg=") { + out.jvm.push(v.to_owned()); + } else if a == "--jvm-arg" { + out.jvm.extend(it.next().cloned()); + } else if let Some(v) = a.strip_prefix("--java-executable=") { + out.java = Some(v.to_owned()); + } else if a == "--java-executable" { + out.java = it.next().cloned(); + } else if a == "-data" { + out.data = it.next().cloned(); + } else if let Some(v) = a.strip_prefix("-data=") { + out.data = Some(v.to_owned()); + } else if a != "--validate-java-version" && a != "--no-validate-java-version" { + out.rest.push(a.clone()); + } + } + out +} + +/// The JVM command line `bin/jdtls` builds: its system properties and module +/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`. +pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec { + let parsed = split_script_args(script_args); + let mut args: Vec = Vec::new(); + if java_major >= 24 { + args.extend(["-Djdk.xml.maxGeneralEntitySizeLimit=0".into(), "-Djdk.xml.totalEntitySizeLimit=0".into()]); + } + args.extend( + [ + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Dosgi.checkConfiguration=true", + ] + .map(String::from), + ); + args.push(format!("-Dosgi.sharedConfiguration.area={}", install.config.to_string_lossy())); + args.extend( + [ + "-Dosgi.sharedConfiguration.area.readOnly=true", + "-Dosgi.configuration.cascaded=true", + "-Xms1G", + "--add-modules=ALL-SYSTEM", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + ] + .map(String::from), + ); + args.extend(parsed.jvm); + args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]); + if let Some(data) = parsed.data { + args.extend(["-data".to_owned(), data]); + } + args.extend(parsed.rest); + args +} + +/// Rewrites a jdtls launch (`command` = the release's script, `JAVA_HOME` chosen by +/// [`crate::java::configure_jdtls`]) to start `java` directly. Left unchanged when the +/// command is someone's own wrapper, the install has another layout, or no JDK is set +/// (the launch then reports why). +pub fn launch_directly(cfg: &mut LanguageServerConfig) { + if cfg.name != "jdtls" || cfg.launch_error.is_some() { + return; + } + let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH")); + let Some(script) = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) else { return }; + let is_script = dunce::canonicalize(&script) + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase())) + .is_some_and(|n| SCRIPTS.contains(&n.as_str())); + if !is_script { + return; + } + let Some(install) = + jdtls_home(&script).and_then(|h| find_install(&h, std::env::consts::OS, std::env::consts::ARCH)) + else { + return; + }; + let own_java = split_script_args(&cfg.args).java.map(PathBuf::from); + let jdk: Option<(PathBuf, Jdk)> = match own_java { + Some(java) => java.parent().and_then(Path::parent).and_then(jdk_at).map(|jdk| (java, jdk)), + None => cfg.env.get("JAVA_HOME").and_then(|h| jdk_at(Path::new(h))).map(|jdk| { + let java = crate::java::java_executable(&jdk.home); + (java, jdk) + }), + }; + let Some((java, jdk)) = jdk.filter(|(java, _)| java.is_file()) else { return }; + cfg.args = java_args(&install, jdk.major, &cfg.args); + cfg.command = java.to_string_lossy().into_owned(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + /// What [`crate::java::configure_jdtls`] and the settings pass to the script. + fn script_args(data: &str, agent: &str) -> Vec { + strings(&[ + "--jvm-arg=-Xmx2G", + "-data", + data, + "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false", + &format!("--jvm-arg=-javaagent:{agent}"), + ]) + } + + #[test] + fn windows_command_line() { + // The reporter's layout: a managed install under %APPDATA%, JDK 25, a user name with a space and CJK. + let tools = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\jdtls"; + let install = JdtlsInstall { + launcher: PathBuf::from(format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar")), + config: PathBuf::from(format!(r"{tools}\config_win")), + }; + let data = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\lsp\jdtls\0123456789abcdef"; + let agent = r"C:\Users\张 三\AppData\Roaming\ai.l0ng.openidea\tools\lombok\bin\lombok.jar"; + let args = java_args(&install, 25, &script_args(data, agent)); + assert_eq!( + args, + strings(&[ + "-Djdk.xml.maxGeneralEntitySizeLimit=0", + "-Djdk.xml.totalEntitySizeLimit=0", + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Dosgi.checkConfiguration=true", + &format!(r"-Dosgi.sharedConfiguration.area={tools}\config_win"), + "-Dosgi.sharedConfiguration.area.readOnly=true", + "-Dosgi.configuration.cascaded=true", + "-Xms1G", + "--add-modules=ALL-SYSTEM", + "--add-opens", + "java.base/java.util=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "-Xmx2G", + "-Djava.import.generatesMetadataFilesAtProjectRoot=false", + &format!("-javaagent:{agent}"), + "-jar", + &format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"), + "-data", + data, + ]) + ); + } + + #[test] + fn unix_command_line() { + let install = JdtlsInstall { + launcher: PathBuf::from( + "/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", + ), + config: PathBuf::from("/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm"), + }; + let args = java_args( + &install, + 21, + &strings(&["--jvm-arg", "-Dlog.level=ALL", "--no-validate-java-version", "-data", "/d", "--extra"]), + ); + assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21"); + assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned())); + assert_eq!( + args[args.len() - 7..], + strings(&[ + "java.base/java.lang=ALL-UNNAMED", + "-Dlog.level=ALL", + "-jar", + "/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", + "-data", + "/d", + "--extra", + ]) + ); + assert!(!args.iter().any(|a| a.contains("validate-java-version"))); + } + + #[test] + fn config_dirs_per_platform() { + assert_eq!(config_dir_names("windows", "x86_64"), ["config_win"]); + assert_eq!(config_dir_names("windows", "aarch64"), ["config_win_arm", "config_win"]); + assert_eq!(config_dir_names("macos", "aarch64"), ["config_mac_arm", "config_mac"]); + assert_eq!(config_dir_names("linux", "x86_64"), ["config_linux"]); + } + + #[test] + fn finds_the_launcher_and_configuration_of_a_release_layout() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::create_dir_all(home.join("plugins")).unwrap(); + for jar in [ + "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.1300.v20250331-1702.jar", + "org.eclipse.jdt.ls.core_1.50.0.jar", + ] { + std::fs::write(home.join("plugins").join(jar), "").unwrap(); + } + assert_eq!(find_install(home, "windows", "x86_64"), None, "no configuration"); + for config in ["config_win", "config_mac", "config_mac_arm"] { + std::fs::create_dir_all(home.join(config)).unwrap(); + std::fs::write(home.join(config).join("config.ini"), "").unwrap(); + } + let win = find_install(home, "windows", "x86_64").unwrap(); + assert_eq!(win.launcher, home.join("plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar")); + assert_eq!(win.config, home.join("config_win")); + assert_eq!(find_install(home, "windows", "aarch64").unwrap().config, home.join("config_win")); + assert_eq!(find_install(home, "macos", "aarch64").unwrap().config, home.join("config_mac_arm")); + } + + #[cfg(unix)] + #[test] + fn rewrites_the_release_script_to_java() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("jdtls"); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::create_dir_all(home.join("plugins")).unwrap(); + std::fs::write(home.join("plugins/org.eclipse.equinox.launcher_1.7.0.jar"), "").unwrap(); + for config in config_dir_names(std::env::consts::OS, std::env::consts::ARCH) { + std::fs::create_dir_all(home.join(&config)).unwrap(); + std::fs::write(home.join(&config).join("config.ini"), "").unwrap(); + } + let script = home.join("bin/jdtls"); + std::fs::write(&script, "#!/usr/bin/env python3\n").unwrap(); + std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + let jdk = dir.path().join("jdk-25"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap(); + std::fs::write(jdk.join("bin/java"), "").unwrap(); + + let cfg = |command: &str| -> LanguageServerConfig { + let mut cfg: LanguageServerConfig = + toml::from_str(&format!("name = \"jdtls\"\ncommand = {command:?}")).unwrap(); + cfg.env.insert("PATH".into(), home.join("bin").to_string_lossy().into_owned()); + cfg.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned()); + cfg.args = strings(&["-data", "/ws", "--jvm-arg=-Xmx1G"]); + cfg + }; + let mut direct = cfg("jdtls"); + launch_directly(&mut direct); + let java = dunce::canonicalize(&jdk).unwrap().join("bin/java"); + assert_eq!(Path::new(&direct.command), java); + assert_eq!(direct.args[0], "-Djdk.xml.maxGeneralEntitySizeLimit=0"); + assert!(direct.args.iter().any(|a| a == "-Xmx1G")); + let jar = direct.args.iter().position(|a| a == "-jar").unwrap(); + assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar")); + assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"])); + + // Someone's own wrapper, or no usable JDK: the command is kept. + std::fs::write(home.join("bin/my-jdtls"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(home.join("bin/my-jdtls"), std::os::unix::fs::PermissionsExt::from_mode(0o755)) + .unwrap(); + let mut own = cfg("my-jdtls"); + launch_directly(&mut own); + assert_eq!(own.command, "my-jdtls"); + let mut no_jdk = cfg("jdtls"); + no_jdk.env.remove("JAVA_HOME"); + launch_directly(&mut no_jdk); + assert_eq!(no_jdk.command, "jdtls"); + let mut failing = cfg("jdtls"); + failing.launch_error = Some("needs JDK 25".into()); + launch_directly(&mut failing); + assert_eq!(failing.command, "jdtls"); + } +} diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index e0a9df62..6e766b8c 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -10,6 +10,7 @@ mod discover; mod exec; pub mod godot; pub mod java; +pub mod jdtls; pub mod libraries; pub mod locate; pub mod lombok; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 0f2073ae..6f564f5e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -293,6 +293,7 @@ impl AppState { &bundles, lombok.jar(), ); + ide_sdk::jdtls::launch_directly(cfg); } })); }