From 92581855172a7ff7da338d9a34014ba3a7d6f663 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:43:51 +0800 Subject: [PATCH 1/6] fix(ui): Keep focus rings off text fields regardless of role Search Everywhere's query input carries role="combobox", so the global focus-ring rule drew an outline inside its frame. Text inputs are now excluded from that rule, and the selector list uses :where() so its specificity matches a bare :focus-visible and component focus styles always take precedence. --- src/styles/global.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index ba8dff292..f359bf836 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -96,11 +96,13 @@ a:hover { /* Focus rings mark controls only, as in IDEA: lists, trees, tables, menus, popups and other focusable containers show focus through their selected row instead of a frame. Text fields - aren't listed: their frame shows focus, and an outline here draws a second box inside it. */ + never get one, whatever their role (a search box is a combobox): their frame shows focus, and + an outline here draws a second box inside it. `:where` keeps this at the specificity of a bare + `:focus-visible`, so a component's own focus style always wins. */ :focus-visible { outline: none; } -:is( +:where( button, a[href], input:is([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="button"], [type="submit"], [type="reset"]), @@ -115,6 +117,13 @@ a:hover { [role="combobox"], [role="slider"], [role="spinbutton"] +):not( + :where( + input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="button"], [type="submit"], [type="reset"]), + textarea, + [contenteditable="true"], + [contenteditable=""] + ) ):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; From a06518951685d54df59fe8403bc437c52110af78 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:44:09 +0800 Subject: [PATCH 2/6] fix(lsp): Launch jdtls with java and surface output outside the protocol (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the auto-downloaded jdtls ran through `bin\jdtls.bat`, which starts the Python wrapper and ends with `pause`. When Python failed, `pause` wrote its prompt to stdout in the console code page; the transport treated that as a broken LSP header ("non-UTF-8 header"), dropped it and the server was killed, so the status only said "exited with code 1". - jdtls is now launched with `java` directly on every platform (Equinox launcher, platform config dir, JDK 24+ flags, forwarded JVM args, `-data`), so Python is no longer needed; user wrappers and unusual layouts keep their command. - Output outside the protocol on stdout (and non-UTF-8 stderr) is decoded (UTF-8, then the Windows OEM/ANSI code page, then lossy), logged and included in the crash message, for stdio and TCP language servers, DAP adapters and ACP agents. - Crash messages carry the real exit code and the last lines of output. 未在 Windows 实机验证 (Windows paths covered by unit tests and a Windows-target clippy run). 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 e9728f97c..ab57338d5 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 e7277da7d..c9537256a 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 7785fc4fb..ed5423051 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 247049f25..5ab70125b 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 7d1246300..615d1f815 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 1086e8920..638fd75f6 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 000000000..92e451049 --- /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 110509972..58fc3f0de 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 3ba57aa2b..0765e0f69 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 c3992d215..9775570d0 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 000000000..375830224 --- /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 000000000..f4f357244 --- /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 e0a9df622..6e766b8c5 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 0f2073ae0..6f564f5eb 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); } })); } From 14f72fc3075cddba47741465ccd6a019bf905ff4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:54:48 +0800 Subject: [PATCH 3/6] feat(http-client): Generate requests from Spring Web mappings (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring MVC/WebFlux mapping methods (Java and Kotlin) in @RestController / @Controller classes get a gutter icon with "Generate Request in HTTP Client", and the class line gets "Generate Requests for Controller"; both are also actions (`http.generateSpringRequest`, `http.generateSpringControllerRequests`) in Find Action, the editor menu and ⌥↵. Requests are appended to the `generated-requests.http` scratch and opened at the new request. The URL combines class and method paths ({id} and {id:regex} → {{id}}), with host, port and context path from application.properties / .yml (default http://localhost:8080). @RequestParam, @RequestHeader, params/headers, consumes/produces and @RequestBody (JSON skeleton from the body type, nested one level) are mapped; same-file constants are resolved. Adds an `intentionProviders` editor facet so features can contribute ⌥↵ items. Fixes #100 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/api.ts | 22 + src/editor/lsp/intentions.ts | 13 +- src/features/http-client/index.ts | 32 +- src/features/http-client/spring/editor.ts | 346 +++++++++++ src/features/http-client/spring/endpoints.ts | 297 ++++++++++ src/features/http-client/spring/request.ts | 254 ++++++++ src/features/http-client/spring/source.ts | 560 ++++++++++++++++++ .../http-client/spring/spring.test.ts | 302 ++++++++++ src/platform/locales/text/zh.json | 5 + 9 files changed, 1824 insertions(+), 7 deletions(-) create mode 100644 src/features/http-client/spring/editor.ts create mode 100644 src/features/http-client/spring/endpoints.ts create mode 100644 src/features/http-client/spring/request.ts create mode 100644 src/features/http-client/spring/source.ts create mode 100644 src/features/http-client/spring/spring.test.ts diff --git a/src/editor/api.ts b/src/editor/api.ts index 060ec4acf..78892027d 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -283,6 +283,28 @@ export function contributedAt(state: EditorState, key: string, pos: number): return state.languageDataAt(key, pos)[0]; } +/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */ +export interface ContributedIntention { + id: string; + label: string; + icon?: string; + run: () => void | Promise; +} + +/** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */ +export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>(); + +export function contributedIntentions(view: EditorView): ContributedIntention[] { + return view.state.facet(intentionProviders).flatMap((p) => { + try { + return p(view); + } catch (e) { + console.error("intention provider failed", e); + return []; + } + }); +} + // --- navigation history ------------------------------------------------------ /** One entry of the Back/Forward (⌘[ / ⌘]) history. 0-based line, UTF-16 col. */ diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index f459e77ce..7894f3c40 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -7,7 +7,7 @@ import { StateEffect, StateField, type Extension } from "@codemirror/state"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { forEachDiagnostic } from "@codemirror/lint"; import { commands, isCancelled, type CodeAction } from "@/platform/ipc"; -import { whenSynced } from "@/editor/api"; +import { contributedIntentions, whenSynced } from "@/editor/api"; import { shortcutLabel } from "@/platform/keymap"; import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp"; import { offsetsToRange } from "./positions"; @@ -207,8 +207,8 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[]) /** ⌥↵ */ export async function showIntentions(view: EditorView) { try { - // Typo fixes come first and need no language server. - const local = spellingIntentions(view); + // Typo fixes and feature-contributed actions come first and need no language server. + const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)]; let actions: CodeAction[] = []; let failure: { error: unknown } | null = null; try { @@ -338,20 +338,21 @@ const bulbPlugin = ViewPlugin.fromClass( if (!doc || doc.readOnly || !this.view.dom.isConnected) return; const state = this.view.state; const typoFix = spellingIntentions(this.view).length > 0; + const local = typoFix || contributedIntentions(this.view).length > 0; const backoff = serverBackoff.active(doc.doc); - if (backoff && !typoFix) return; + if (backoff && !local) return; let actions: CodeAction[] = []; if (!backoff) { try { actions = await fetchIntentions(this.view); } catch (e) { if (isUnavailable(e)) serverBackoff.mark(doc.doc); - if (!typoFix) return; + if (!local) return; } } if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return; const line = state.doc.lineAt(state.selection.main.head).number; - const any = actions.length > 0 || typoFix; + const any = actions.length > 0 || local; this.view.dispatch({ effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null), }); diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index 4910ce323..578ab673d 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -1,7 +1,8 @@ /** * HTTP Client (IDEA's `.http` / `.rest` files): highlighting, ▶ gutter per * request, environments, Run All, and a tool window with the responses and - * their history. Parsing, variable resolution and sending happen in Rust + * their history, and requests generated from Spring Web mappings in Java / + * Kotlin sources (`spring/`). Parsing, variable resolution and sending happen in Rust * (`ide-http`). */ import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api"; @@ -19,6 +20,15 @@ import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } f import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView"; import { httpLanguageDescription } from "./language"; import { SERVICE_VIEW, resetHttpClient, runAll, runRequest } from "./store"; +import { + GENERATE_CONTROLLER_REQUESTS, + GENERATE_REQUEST, + generateAtCaret, + menuGroupItems, + springEditorExtension, +} from "./spring/editor"; + +const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin"; export const NEW_REQUEST_TEMPLATE = `### Example request # Variables come from http-client.env.json / http-client.private.env.json @@ -105,6 +115,8 @@ export function activate(): () => void { }), registerLanguageMode(httpLanguageDescription), registerEditorContribution({ id: "http.requests", order: 56, extensions: httpEditorExtension }), + registerEditorContribution({ id: "http.spring", order: 57, extensions: springEditorExtension }), + registerMenuGroup({ id: "http.spring", menus: ["editor"], order: 40, items: (_menu, ctx) => menuGroupItems(ctx) }), registerServiceView({ id: SERVICE_VIEW, title: "HTTP Client", @@ -156,6 +168,24 @@ export function activate(): () => void { else if (isTauri()) await newRequest(directory ?? undefined); }, }, + { + id: GENERATE_REQUEST, + title: "Generate Request in HTTP Client", + category: "Tools", + icon: "file-http", + keywords: ["spring", "request mapping", "rest", "endpoint", "http client"], + when: SPRING_WHEN, + run: () => generateAtCaret("endpoint"), + }, + { + id: GENERATE_CONTROLLER_REQUESTS, + title: "Generate Requests for Controller", + category: "Tools", + icon: "file-http", + keywords: ["spring", "request mapping", "rest", "endpoints", "http client"], + when: SPRING_WHEN, + run: () => generateAtCaret("controller"), + }, { id: "http.showToolWindow", title: "HTTP Client", diff --git a/src/features/http-client/spring/editor.ts b/src/features/http-client/spring/editor.ts new file mode 100644 index 000000000..4d31d13f1 --- /dev/null +++ b/src/features/http-client/spring/editor.ts @@ -0,0 +1,346 @@ +/** + * Spring Web in Java / Kotlin editors: a gutter icon on every request mapping + * and controller, context actions and editor menu items that generate the + * requests into the `generated-requests.http` scratch and open it. + */ +import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import { + activeEditor, + editorsFor, + intentionProviders, + useEditorRegistry, + type ContributedIntention, + type EditorDocContext, + type TextEditorHandle, +} from "@/editor/api"; +import { tx } from "@/platform/i18n"; +import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { isVirtualUri } from "@/platform/virtualDocs"; +import { iconMarkup, showContextMenu, type MenuItem } from "@/ui"; +import { editorService, notifications, type MenuGroupContext } from "@/workbench/contributions"; +import { springAt, springControllers, type Controller, type Endpoint } from "./endpoints"; +import { appendRequests, bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request"; +import { parseSource } from "./source"; +import styles from "../HttpClient.module.css"; + +export const SPRING_LANGUAGES = new Set(["java", "kotlin"]); +export const GENERATED_REQUESTS = "generated-requests.http"; +export const GENERATE_REQUEST = "http.generateSpringRequest"; +export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests"; + +const reader: SourceReader = { + read: async (path) => { + try { + return await call(commands.fsReadText(path)); + } catch { + return null; + } + }, +}; + +/** One request per endpoint (`paths[i]` picks another path of the mapping). */ +export interface Target { + endpoint: Endpoint; + path?: string; +} + +async function scratchFile(): Promise<{ path: string; created: boolean }> { + const { appDataDir } = await import("@tauri-apps/api/path"); + const dir = `${(await appDataDir()).replace(/[\\/]+$/, "")}/scratches`; + const ignoreExisting = (e: unknown) => { + if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e; + }; + await call(commands.fsCreateDir(dir)).catch(ignoreExisting); + const path = `${dir}/${GENERATED_REQUESTS}`; + let created = true; + await call(commands.fsCreateFile(path)).catch((e) => { + ignoreExisting(e); + created = false; + }); + return { path, created }; +} + +/** The text editor of `path`, once the just opened tab has created it. */ +function editorFor(path: string, timeoutMs = 5000): Promise { + const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null; + const now = find(); + if (now) return Promise.resolve(now); + return new Promise((resolve) => { + const done = (e: TextEditorHandle | null) => { + clearTimeout(timer); + unsubscribe(); + resolve(e); + }; + const timer = setTimeout(() => done(null), timeoutMs); + const unsubscribe = useEditorRegistry.subscribe(() => { + const e = find(); + if (e) done(e); + }); + }); +} + +/** Renders `targets` of the source at `sourcePath`, appends them to the scratch and opens it at the first one. */ +export async function generateRequests(sourcePath: string, text: string, targets: Target[]): Promise { + if (!targets.length) return; + try { + const root = useProject.getState().project?.root ?? null; + const [host, lookup] = await Promise.all([ + findHost(sourcePath, root, reader), + bodyLookup( + sourcePath, + parseSource(text), + targets.map((t) => t.endpoint), + reader, + ), + ]); + const blocks = targets.map(({ endpoint, path }) => + renderRequest(endpoint, { + host, + path, + body: endpoint.bodyType ? skeleton(endpoint.bodyType, lookup) : undefined, + }), + ); + const file = await scratchFile(); + if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] }); + await editorService().openFile(file.path, { focus: true }); + const e = await editorFor(file.path); + if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`); + const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks); + e.view.dispatch({ + changes: { from, insert }, + selection: { anchor: caret }, + effects: EditorView.scrollIntoView(caret, { y: "center" }), + userEvent: "input", + }); + } catch (err) { + notifications().notify({ + level: "error", + title: "Cannot generate the HTTP request", + body: err instanceof Error ? err.message : String(err), + key: "http-generate", + }); + } +} + +function endpointItems(sourcePath: string, text: string, e: Endpoint): MenuItem[] { + const generate = (path?: string) => void generateRequests(sourcePath, text, [{ endpoint: e, path }]); + return [ + e.paths.length > 1 + ? { + label: "Generate Request in HTTP Client", + icon: "file-http", + submenu: e.paths.map((p) => ({ label: `${e.httpMethod} ${p}`, verbatim: true, run: () => generate(p) })), + } + : { label: "Generate Request in HTTP Client", icon: "file-http", run: () => generate() }, + ]; +} + +function controllerItems(sourcePath: string, text: string, c: Controller): MenuItem[] { + return c.endpoints.length + ? [ + { + label: "Generate Requests for Controller", + icon: "file-http", + run: () => + void generateRequests( + sourcePath, + text, + c.endpoints.map((endpoint) => ({ endpoint })), + ), + }, + ] + : []; +} + +/** Editor context menu items for the caret at `offset`: the generate actions (a path choice for several paths). */ +export function springMenuItems(sourcePath: string, text: string, offset: number): MenuItem[] { + const { controller, endpoint } = springAt(springControllers(text), offset); + const items: MenuItem[] = []; + if (endpoint) + items.push( + endpoint.paths.length > 1 + ? endpointItems(sourcePath, text, endpoint)[0]! + : { kind: "action", action: GENERATE_REQUEST, icon: "file-http" }, + ); + if (controller?.endpoints.length) + items.push({ kind: "action", action: GENERATE_CONTROLLER_REQUESTS, icon: "file-http" }); + return items; +} + +export function menuGroupItems(ctx: MenuGroupContext): MenuItem[] { + const path = ctx.paths[0]; + if (!path || ctx.text === undefined || ctx.line === undefined || !/\.(java|kt)$/.test(path)) return []; + // The actions work at the caret: only the active editor's menu offers them. + const e = activeEditor(); + if (!e || e.ctx.path !== path || !SPRING_LANGUAGES.has(e.ctx.language)) return []; + return springMenuItems(path, ctx.text, e.view.state.selection.main.head); +} + +/** The generate actions at the caret of the active editor. */ +export async function generateAtCaret(scope: "endpoint" | "controller"): Promise { + const e = activeEditor(); + if (!e || !SPRING_LANGUAGES.has(e.ctx.language)) return; + const text = e.view.state.doc.toString(); + const { controller, endpoint } = springAt(springControllers(text), e.view.state.selection.main.head); + const endpoints = scope === "endpoint" ? (endpoint ? [endpoint] : []) : (controller?.endpoints ?? []); + if (!endpoints.length) { + notifications().notify({ + level: "info", + title: scope === "endpoint" ? "No Spring request mapping at the caret" : "No Spring controller at the caret", + key: "http-generate", + }); + return; + } + await generateRequests( + e.ctx.path, + text, + endpoints.map((x) => ({ endpoint: x })), + ); +} + +// --- gutter ---------------------------------------------------------------------- + +interface Mark { + pos: number; + /** Endpoint key or the controller's name. */ + key: string; + title: string; + items: (path: string, text: string) => MenuItem[]; +} + +const setMarks = StateEffect.define(); + +const marksField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setMarks)) return e.value; + if (!tr.docChanged || !value.length) return value; + return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from })); + }, +}); + +function computeMarks(state: EditorState): Mark[] { + const doc = state.doc; + const marks: Mark[] = []; + const lineStart = (offset: number) => doc.lineAt(Math.min(offset, doc.length)).from; + for (const c of springControllers(doc.toString())) { + if (c.endpoints.length) + marks.push({ + pos: lineStart(c.offset), + key: `class:${c.name}`, + title: "Generate Requests for Controller", + items: (path, text) => controllerItems(path, text, c), + }); + for (const e of c.endpoints) + marks.push({ + pos: lineStart(e.offset), + key: `${e.httpMethod} ${e.paths.join(",")}`, + title: `${e.httpMethod} ${e.paths[0] ?? "/"}`, + items: (path, text) => endpointItems(path, text, e), + }); + } + // One icon per line (the first wins). + return marks.filter((m, i) => marks.findIndex((x) => x.pos === m.pos) === i); +} + +class SpringMarker extends GutterMarker { + constructor(readonly mark: Mark) { + super(); + } + override eq(other: GutterMarker): boolean { + return other instanceof SpringMarker && other.mark.key === this.mark.key && other.mark.title === this.mark.title; + } + override toDOM(): Node { + const el = document.createElement("div"); + el.className = styles.runMarker ?? ""; + el.title = this.mark.title; + el.setAttribute("role", "button"); + el.setAttribute("aria-label", this.mark.title); + el.innerHTML = iconMarkup("file-http", 14) ?? ""; + return el; + } +} + +const DEBOUNCE = 400; + +export function springEditorExtension(ctx: EditorDocContext): Extension { + if (!SPRING_LANGUAGES.has(ctx.language) || ctx.virtual || isVirtualUri(ctx.path)) return []; + const path = ctx.path; + const detector = ViewPlugin.fromClass( + class { + timer: ReturnType | undefined; + constructor(readonly view: EditorView) { + this.schedule(0); + } + update(u: ViewUpdate) { + if (u.docChanged) this.schedule(DEBOUNCE); + } + schedule(delay: number) { + clearTimeout(this.timer); + this.timer = setTimeout(() => { + const found = computeMarks(this.view.state); + const cur = this.view.state.field(marksField); + const same = + cur.length === found.length && cur.every((m, i) => m.pos === found[i]!.pos && m.key === found[i]!.key); + if (!same) this.view.dispatch({ effects: setMarks.of(found) }); + }, delay); + } + destroy() { + clearTimeout(this.timer); + } + }, + ); + const intentions = (view: EditorView): ContributedIntention[] => { + // Only files that have mappings (the gutter found some) are parsed again here. + if (!view.state.field(marksField, false)?.length) return []; + const text = view.state.doc.toString(); + const { controller, endpoint } = springAt(springControllers(text), view.state.selection.main.head); + const out: ContributedIntention[] = []; + if (endpoint) + out.push({ + id: GENERATE_REQUEST, + label: "Generate Request in HTTP Client", + icon: "file-http", + run: () => generateRequests(path, text, [{ endpoint }]), + }); + if (controller?.endpoints.length) + out.push({ + id: GENERATE_CONTROLLER_REQUESTS, + label: "Generate Requests for Controller", + icon: "file-http", + run: () => + generateRequests( + path, + text, + controller.endpoints.map((e) => ({ endpoint: e })), + ), + }); + return out; + }; + return [ + marksField, + detector, + intentionProviders.of(intentions), + gutter({ + class: `${styles.runGutter} cm-openidea-gutter-spring`, + markers: (view) => + RangeSet.of( + view.state.field(marksField).map((m) => new SpringMarker(m).range(m.pos)), + true, + ), + domEventHandlers: { + mousedown: (view, block, event) => { + const mark = view.state.field(marksField).find((m) => m.pos === block.from); + if (!mark || (event as MouseEvent).button !== 0) return false; + const el = (event.target as HTMLElement).closest?.(`.${styles.runMarker}`) as HTMLElement | null; + const rect = (el ?? (event.target as HTMLElement)).getBoundingClientRect(); + event.preventDefault(); + showContextMenu({ x: rect.left, y: rect.bottom + 2 }, mark.items(path, view.state.doc.toString())); + return true; + }, + }, + }), + ]; +} diff --git a/src/features/http-client/spring/endpoints.ts b/src/features/http-client/spring/endpoints.ts new file mode 100644 index 000000000..7a9ef1253 --- /dev/null +++ b/src/features/http-client/spring/endpoints.ts @@ -0,0 +1,297 @@ +/** + * Spring Web request mappings of a Java / Kotlin source: `@RequestMapping` / + * `@GetMapping`… methods of `@RestController` / `@Controller` classes, with + * the class-level mapping applied. Constants are resolved within the file. + */ +import { + allClasses, + parseSource, + type Annotation, + type ClassDecl, + type Expr, + type Param, + type SourceFile, +} from "./source"; + +export interface QueryParam { + name: string; + /** `defaultValue`, if any. */ + value: string | null; +} + +export interface Endpoint { + className: string; + methodName: string; + /** Offset of the method name (gutter line). */ + offset: number; + /** The method declaration, annotations included. */ + range: { from: number; to: number }; + /** Offset of the controller class name. */ + classOffset: number; + httpMethod: string; + /** Class × method paths (`/` joined, `{var}` kept), first one first. */ + paths: string[]; + query: QueryParam[]; + headers: { name: string; value: string }[]; + /** `consumes` (first). */ + consumes: string | null; + /** `produces` (first). */ + produces: string | null; + /** `@RequestBody` parameter type as written, if any. */ + bodyType: string | null; +} + +export interface Controller { + name: string; + offset: number; + /** The class declaration, annotations included. */ + range: { from: number; to: number }; + endpoints: Endpoint[]; +} + +const SHORTCUTS: Record = { + GetMapping: "GET", + PostMapping: "POST", + PutMapping: "PUT", + DeleteMapping: "DELETE", + PatchMapping: "PATCH", +}; + +const CONTROLLER = new Set(["RestController", "Controller"]); + +const MEDIA_TYPES: Record = { + ALL: "*/*", + APPLICATION_JSON: "application/json", + APPLICATION_JSON_UTF8: "application/json;charset=UTF-8", + APPLICATION_XML: "application/xml", + APPLICATION_ATOM_XML: "application/atom+xml", + APPLICATION_FORM_URLENCODED: "application/x-www-form-urlencoded", + APPLICATION_OCTET_STREAM: "application/octet-stream", + APPLICATION_PDF: "application/pdf", + APPLICATION_PROBLEM_JSON: "application/problem+json", + APPLICATION_PROBLEM_XML: "application/problem+xml", + APPLICATION_NDJSON: "application/x-ndjson", + APPLICATION_STREAM_JSON: "application/stream+json", + APPLICATION_CBOR: "application/cbor", + APPLICATION_GRAPHQL_RESPONSE: "application/graphql-response+json", + IMAGE_GIF: "image/gif", + IMAGE_JPEG: "image/jpeg", + IMAGE_PNG: "image/png", + MULTIPART_FORM_DATA: "multipart/form-data", + MULTIPART_MIXED: "multipart/mixed", + TEXT_EVENT_STREAM: "text/event-stream", + TEXT_HTML: "text/html", + TEXT_MARKDOWN: "text/markdown", + TEXT_PLAIN: "text/plain", + TEXT_XML: "text/xml", +}; + +/** Evaluates string-valued annotation arguments within one file. */ +export class Constants { + private readonly fields = new Map(); + constructor(file: SourceFile) { + for (const f of [...file.fields, ...allClasses(file).flatMap((c) => c.fields)]) + if (f.init && !this.fields.has(f.name)) this.fields.set(f.name, f.init); + } + + /** Every element of an array (`{a, b}`, `[a, b]`, `arrayOf(a, b)`) or the single value; unresolvable ones dropped. */ + strings(expr: Expr | undefined, enums = false): string[] { + if (!expr?.length) return []; + const first = expr[0]!; + let inner: Expr | null = null; + if (first.kind === "p" && (first.text === "{" || first.text === "[")) inner = expr.slice(1, -1); + else if (first.kind === "id" && /^(arrayOf|listOf|setOf)$/.test(first.text) && expr[1]?.text === "(") + inner = expr.slice(2, -1); + if (!inner) { + const v = this.value(expr, enums, new Set()); + return v === null ? [] : [v]; + } + return splitTop(inner) + .map((e) => this.value(e, enums, new Set())) + .filter((v): v is string => v !== null); + } + + string(expr: Expr | undefined, enums = false): string | null { + return this.strings(expr, enums)[0] ?? null; + } + + private value(expr: Expr, enums: boolean, seen: Set): string | null { + let out = ""; + for (const part of splitPlus(expr)) { + const v = this.part(part, enums, seen); + if (v === null) return null; + out += v; + } + return out; + } + + private part(part: Expr, enums: boolean, seen: Set): string | null { + // Kotlin templates of constants; other `${…}` are property placeholders, kept. + if (part.length === 1 && part[0]!.kind === "str") + return (part[0]!.value ?? "").replace( + /\$\{(\w+)\}|\$([A-Za-z_]\w*)/g, + (m, a: string | undefined, b: string | undefined) => { + const name = (a ?? b)!; + const init = seen.has(name) ? undefined : this.fields.get(name); + return init ? (this.value(init, false, new Set([...seen, name])) ?? m) : m; + }, + ); + if (part.length === 1 && part[0]!.kind === "num") return part[0]!.text; + if (part[0]?.text === "(" && part[part.length - 1]?.text === ")") return this.value(part.slice(1, -1), enums, seen); + // `NAME`, `Paths.NAME`, `MediaType.APPLICATION_JSON_VALUE`, `RequestMethod.GET`. + if (!part.every((t, i) => (i % 2 ? t.kind === "p" && t.text === "." : t.kind === "id"))) return null; + const names = part.filter((t) => t.kind === "id").map((t) => t.text); + const last = names[names.length - 1]!; + const qualifier = names[names.length - 2]; + if (qualifier === "MediaType") return MEDIA_TYPES[last.replace(/_VALUE$/, "")] ?? null; + const init = this.fields.get(last); + if (init && !seen.has(last)) { + seen.add(last); + return this.value(init, enums, seen); + } + return enums ? last : null; + } +} + +function splitTop(expr: Expr): Expr[] { + const parts: Expr[] = []; + let cur: Expr = []; + let depth = 0; + for (const t of expr) { + if (t.kind === "p" && "([{".includes(t.text)) depth++; + if (t.kind === "p" && ")]}".includes(t.text)) depth--; + if (depth === 0 && t.kind === "p" && t.text === ",") { + parts.push(cur); + cur = []; + } else cur.push(t); + } + if (cur.length) parts.push(cur); + return parts; +} + +function splitPlus(expr: Expr): Expr[] { + const parts: Expr[] = []; + let cur: Expr = []; + let depth = 0; + for (const t of expr) { + if (t.kind === "p" && "([{".includes(t.text)) depth++; + if (t.kind === "p" && ")]}".includes(t.text)) depth--; + if (depth === 0 && t.kind === "p" && t.text === "+") { + parts.push(cur); + cur = []; + } else cur.push(t); + } + parts.push(cur); + return parts; +} + +function mapping(annotations: Annotation[]): { ann: Annotation; method: string | null } | null { + for (const ann of annotations) { + if (SHORTCUTS[ann.name]) return { ann, method: SHORTCUTS[ann.name]! }; + if (ann.name === "RequestMapping") return { ann, method: null }; + } + return null; +} + +function mappingPaths(ann: Annotation | undefined, c: Constants): string[] { + if (!ann) return []; + return c.strings(ann.args.get("path") ?? ann.args.get("value")); +} + +export function joinPaths(base: string, path: string): string { + const joined = `/${base}/${path}`.replace(/\/{2,}/g, "/"); + return joined.length > 1 ? joined.replace(/\/$/, "") : "/"; +} + +function annotation(p: Param, name: string): Annotation | undefined { + return p.annotations.find((a) => a.name === name); +} + +/** Map-typed `@RequestParam` / `@RequestHeader` collect all of them: nothing to generate. */ +const isMap = (type: string) => + /^(java\.util\.)?(Map|MultiValueMap|HttpHeaders|LinkedMultiValueMap|HashMap)\b/.test(type); + +function endpoint(cls: ClassDecl, method: ClassDecl["methods"][number], c: Constants): Endpoint | null { + const m = mapping(method.annotations); + if (!m) return null; + const classMapping = cls.annotations.find((a) => a.name === "RequestMapping"); + const httpMethod = + m.method ?? + c.string(m.ann.args.get("method"), true) ?? + (classMapping && c.string(classMapping.args.get("method"), true)) ?? + "GET"; + const bases = mappingPaths(classMapping, c); + const own = mappingPaths(m.ann, c); + const paths: string[] = []; + for (const b of bases.length ? bases : [""]) for (const p of own.length ? own : [""]) paths.push(joinPaths(b, p)); + + const query: QueryParam[] = []; + const headers: { name: string; value: string }[] = []; + let bodyType: string | null = null; + for (const p of method.params) { + const rp = annotation(p, "RequestParam"); + const rh = annotation(p, "RequestHeader"); + if (rp && !isMap(p.type)) { + const name = c.string(rp.args.get("value")) || c.string(rp.args.get("name")) || p.name; + query.push({ name, value: c.string(rp.args.get("defaultValue")) }); + } else if (rh && !isMap(p.type)) { + const name = c.string(rh.args.get("value")) || c.string(rh.args.get("name")) || p.name; + headers.push({ name, value: c.string(rh.args.get("defaultValue")) ?? `{{${name}}}` }); + } else if (annotation(p, "RequestBody")) bodyType = p.type || "Object"; + } + // `params = "a=1"` / `headers = "X-Api=1"` narrow the mapping: the request must carry them. + for (const cond of [...c.strings(classMapping?.args.get("params")), ...c.strings(m.ann.args.get("params"))]) { + const [name, value] = cond.split("=", 2); + if (name && !cond.includes("!") && !query.some((q) => q.name === name.trim())) + query.push({ name: name.trim(), value: value?.trim() ?? null }); + } + for (const cond of [...c.strings(classMapping?.args.get("headers")), ...c.strings(m.ann.args.get("headers"))]) { + const [name, value] = cond.split("=", 2); + if (name && !cond.includes("!") && value !== undefined) headers.push({ name: name.trim(), value: value.trim() }); + } + const first = (key: string) => + c.string(m.ann.args.get(key)) ?? (classMapping && c.string(classMapping.args.get(key))) ?? null; + return { + className: cls.name, + methodName: method.name, + offset: method.nameStart, + range: { from: method.start, to: method.end }, + classOffset: cls.nameStart, + httpMethod: httpMethod.toUpperCase(), + paths, + query, + headers, + consumes: first("consumes"), + produces: first("produces"), + bodyType, + }; +} + +export function controllersOf(file: SourceFile): Controller[] { + const constants = new Constants(file); + const out: Controller[] = []; + for (const cls of allClasses(file)) { + if (!cls.annotations.some((a) => CONTROLLER.has(a.name))) continue; + const endpoints = cls.methods.map((m) => endpoint(cls, m, constants)).filter((e): e is Endpoint => !!e); + out.push({ name: cls.name, offset: cls.nameStart, range: { from: cls.start, to: cls.end }, endpoints }); + } + return out; +} + +/** Controllers of a Java / Kotlin source, with their endpoints. */ +export function springControllers(text: string): Controller[] { + // Cheap guard: most files are not controllers. + if (!/@(?:[\w.]+\.)?(?:Rest)?Controller\b/.test(text)) return []; + return controllersOf(parseSource(text)); +} + +/** The controller and endpoint whose declaration holds `offset`. */ +export function springAt( + controllers: Controller[], + offset: number, +): { controller: Controller | null; endpoint: Endpoint | null } { + // Innermost controller (nested classes come after their outer one). + const controller = controllers.filter((c) => c.range.from <= offset && offset <= c.range.to).pop() ?? null; + const endpoint = controller?.endpoints.find((e) => e.range.from <= offset && offset <= e.range.to) ?? null; + return { controller, endpoint }; +} diff --git a/src/features/http-client/spring/request.ts b/src/features/http-client/spring/request.ts new file mode 100644 index 000000000..6678a6b66 --- /dev/null +++ b/src/features/http-client/spring/request.ts @@ -0,0 +1,254 @@ +/** + * Spring endpoints → `.http` requests: the server address from + * `application.properties` / `application.yml`, `{var}` → `{{var}}`, query + * parameters, headers and a JSON skeleton of the `@RequestBody` type. + */ +import type { Endpoint } from "./endpoints"; +import { allClasses, parseSource, type ClassDecl, type SourceFile } from "./source"; + +export const DEFAULT_HOST = "http://localhost:8080"; + +/** Flat `key → value` of a `.properties` file (`=`, `:` or space separated; `#` / `!` comments). */ +export function parseProperties(text: string): Map { + const out = new Map(); + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + let line = lines[i]!.trim(); + if (!line || line.startsWith("#") || line.startsWith("!")) continue; + while (line.endsWith("\\") && i + 1 < lines.length) line = line.slice(0, -1) + lines[++i]!.trim(); + const m = /^((?:\\.|[^=:\s])+)\s*[=:\s]\s*(.*)$/.exec(line); + if (m && !out.has(m[1]!)) out.set(m[1]!.replace(/\\(.)/g, "$1"), m[2]!.trim()); + } + return out; +} + +/** Flat dotted keys of a YAML file's first document (block mappings and scalars only; enough for `server.*`). */ +export function parseYaml(text: string): Map { + const out = new Map(); + const stack: { indent: number; key: string }[] = []; + let started = false; + for (const raw of text.split(/\r?\n/)) { + if (/^---/.test(raw)) { + if (started) break; + continue; + } + const line = raw.replace(/\s+#.*$/, ""); + if (!line.trim() || line.trim().startsWith("#") || line.trim().startsWith("- ")) continue; + const m = /^(\s*)("[^"]*"|'[^']*'|[^:]+?)\s*:(?:\s+(.*))?$/.exec(line); + if (!m) continue; + started = true; + const indent = m[1]!.length; + while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop(); + const key = m[2]!.replace(/^["']|["']$/g, ""); + const path = [...stack.map((s) => s.key), key].join("."); + const value = m[3]?.trim(); + if (value && value !== "|" && value !== ">") { + if (!out.has(path)) out.set(path, value.replace(/^"(.*)"$|^'(.*)'$/, "$1$2")); + } else stack.push({ indent, key }); + } + return out; +} + +/** `${PORT:8081}` → `8081`; a placeholder without a default is unknown. */ +function resolved(value: string | undefined): string | null { + if (value === undefined) return null; + let unknown = false; + const out = value.replace(/\$\{[^}:]*(?::([^}]*))?\}/g, (_m, def: string | undefined) => { + if (def === undefined) unknown = true; + return def ?? ""; + }); + return unknown ? null : out; +} + +/** `http://localhost:` from Spring Boot configuration (first file with a value wins). */ +export function hostFromConfig(configs: Map[]): string { + const get = (...keys: string[]) => { + for (const c of configs) + for (const k of keys) { + const v = resolved(c.get(k)); + if (v) return v; + } + return null; + }; + const port = get("server.port"); + const ssl = /^true$/i.test(get("server.ssl.enabled") ?? "") || !!get("server.ssl.key-store"); + const scheme = ssl ? "https" : "http"; + const portNum = port && /^\d+$/.test(port) && port !== "0" ? Number(port) : 8080; + const context = [ + get("server.servlet.context-path", "server.context-path", "spring.webflux.base-path") ?? "", + get("spring.mvc.servlet.path") ?? "", + ] + .join("/") + .replace(/\/{2,}/g, "/") + .replace(/\/$/, ""); + const ctx = context && !context.startsWith("/") ? `/${context}` : context; + return `${scheme}://localhost:${portNum}${ctx}`; +} + +const NUMBER = + /^(byte|short|int|long|float|double|Byte|Short|Integer|Long|Float|Double|Int|BigDecimal|BigInteger|Number|UByte|UShort|UInt|ULong)\??$/; +const BOOLEAN = /^(boolean|Boolean)\??$/; +const STRING = + /^(String|CharSequence|char|Character|Char|UUID|LocalDate|LocalDateTime|LocalTime|Instant|OffsetDateTime|ZonedDateTime|Date|Duration|URI|URL)\??$/; +const COLLECTION = + /^(List|ArrayList|LinkedList|Set|HashSet|TreeSet|LinkedHashSet|Collection|Iterable|MutableList|MutableSet|Flux|Array|.*Array)(<.*>)?\??$|\[\]$|\.\.\.$/; +const MAP = /^(Map|HashMap|LinkedHashMap|TreeMap|MutableMap|MultiValueMap|JsonNode|ObjectNode)(<.*>)?\??$/; + +/** Unwraps `Mono`, `Optional`, `HttpEntity`, `@Valid`-less qualified names and nullability. */ +export function bareType(type: string): string { + let t = type.trim().replace(/\?$/, ""); + for (;;) { + const m = /^(?:[\w.]*\.)?(Mono|Optional|HttpEntity|RequestEntity|Deferred|CompletableFuture)<(.*)>$/.exec(t); + if (!m) break; + t = m[2]!.trim(); + } + return t.replace(/^(?:[a-z_$][\w$]*\.)+/, ""); +} + +export type Json = null | boolean | number | string | Json[] | { [k: string]: Json }; + +/** Placeholder value for a field / body of `type`; classes found by `lookup` get their properties (one level deep for nested ones). */ +export function skeleton(type: string, lookup: (name: string) => ClassDecl | null, depth = 0): Json { + const t = bareType(type); + if (NUMBER.test(t)) return 0; + if (BOOLEAN.test(t)) return false; + if (STRING.test(t)) return ""; + if (COLLECTION.test(t)) return []; + if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {}; + const cls = lookup(t.replace(/<.*$/, "")); + if (!cls || depth > 1) return {}; + const obj: { [k: string]: Json } = {}; + for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1); + for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1); + return obj; +} + +const pathVariables = (path: string) => path.replace(/\{\*?([\w.-]+)(?::[^{}]*(?:\{[^{}]*\}[^{}]*)*)?\}/g, "{{$1}}"); + +export interface RenderOptions { + host: string; + /** Path to use (one of `endpoint.paths`); default the first. */ + path?: string; + /** JSON body of the `@RequestBody` type. */ + body?: Json; +} + +/** One `### Name` request block, ending with a line break. */ +export function renderRequest(e: Endpoint, opts: RenderOptions): string { + const path = pathVariables(opts.path ?? e.paths[0] ?? "/"); + const query = e.query.map((q) => `${q.name}=${q.value ?? `{{${q.name}}}`}`).join("&"); + const lines = [ + `### ${e.className}.${e.methodName}`, + `${e.httpMethod} ${opts.host}${path}${query ? `?${query}` : ""}`, + ]; + const headers = [...e.headers]; + const contentType = e.consumes ?? (e.bodyType ? "application/json" : null); + if (e.produces) headers.unshift({ name: "Accept", value: e.produces }); + if (contentType && (e.bodyType || e.consumes)) headers.push({ name: "Content-Type", value: contentType }); + for (const h of headers) lines.push(`${h.name}: ${h.value}`); + if (e.bodyType) { + lines.push(""); + const body = opts.body ?? {}; + const form = + contentType === "application/x-www-form-urlencoded" && body && typeof body === "object" && !Array.isArray(body); + lines.push( + form + ? Object.keys(body) + .map((k) => `${k}=`) + .join("&") + : JSON.stringify(body, null, 2), + ); + } + return `${lines.join("\n")}\n`; +} + +/** Text to append to a request file holding `existing`, and the offset (in the new text) of the first request line. */ +export function appendRequests(existing: string, blocks: string[]): { insert: string; from: number; caret: number } { + const from = existing.length; + let sep = ""; + if (existing.trim()) sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n"; + const insert = sep + blocks.join("\n"); + const firstLine = blocks[0]?.indexOf("\n") ?? -1; + return { insert, from, caret: from + sep.length + (firstLine >= 0 ? firstLine + 1 : 0) }; +} + +// --- project lookups ------------------------------------------------------------- + +export interface SourceReader { + /** File text, null when missing. */ + read(path: string): Promise; +} + +const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, ""); + +/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */ +export function configCandidates(sourcePath: string, projectRoot: string | null): string[] { + const norm = sourcePath.replace(/\\/g, "/"); + const at = norm.lastIndexOf("/src/main/"); + const roots = at >= 0 ? [norm.slice(0, at)] : []; + if (projectRoot && !roots.includes(projectRoot)) roots.push(projectRoot.replace(/\\/g, "/").replace(/\/$/, "")); + return roots.flatMap((r) => + ["application.properties", "application.yml", "application.yaml"].map((f) => `${r}/src/main/resources/${f}`), + ); +} + +export async function findHost(sourcePath: string, projectRoot: string | null, fs: SourceReader): Promise { + const configs: Map[] = []; + for (const path of configCandidates(sourcePath, projectRoot)) { + const text = await fs.read(path).catch(() => null); + if (text === null) continue; + configs.push(path.endsWith(".properties") ? parseProperties(text) : parseYaml(text)); + } + return hostFromConfig(configs); +} + +/** + * Body types of `endpoints`: classes of the controller's file, then + * `.java|.kt` beside it or where its import points (same source root). + */ +export async function bodyLookup( + sourcePath: string, + file: SourceFile, + endpoints: Endpoint[], + fs: SourceReader, +): Promise<(name: string) => ClassDecl | null> { + const known = new Map(); + const add = (f: SourceFile) => { + for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c); + }; + add(file); + const sourceRoot = (path: string, f: SourceFile) => { + let root = dirname(path); + for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root); + return root; + }; + const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"]; + /** Loads `name` as seen from `from` (its imports, then its directory). */ + const load = async (name: string, from: { path: string; file: SourceFile }) => { + if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null; + const imported = from.file.imports.find((i) => i.endsWith(`.${name}`)); + const dir = dirname(from.path); + const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`; + for (const d of pkgDir ? [pkgDir, dir] : [dir]) + for (const x of ext) { + const path = `${d}/${name}${x}`; + const text = await fs.read(path).catch(() => null); + if (text === null) continue; + const loaded = parseSource(text); + add(loaded); + return { path, file: loaded }; + } + return null; + }; + const typeName = (t: string) => bareType(t).replace(/<.*$/, ""); + const origin = { path: sourcePath.replace(/\\/g, "/"), file }; + const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : []))); + for (const n of names) { + const from = (await load(n, origin)) ?? origin; + // Property types one level down, as seen from the body type's file. + const c = known.get(n); + for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : []) + await load(typeName(p.type), from); + } + return (name) => known.get(name) ?? null; +} diff --git a/src/features/http-client/spring/source.ts b/src/features/http-client/spring/source.ts new file mode 100644 index 000000000..068fc23d7 --- /dev/null +++ b/src/features/http-client/spring/source.ts @@ -0,0 +1,560 @@ +/** + * A declaration-level reader for Java and Kotlin sources: classes, methods, + * fields and their annotations with unevaluated argument expressions. Enough + * to read Spring Web mappings (no types, no statements); comments, strings and + * bracket nesting are handled exactly, the rest is skipped. + */ + +export interface Tok { + kind: "id" | "str" | "num" | "p"; + text: string; + /** String literals: the contents (escapes decoded). */ + value?: string; + start: number; + end: number; + /** A line break precedes the token. */ + nl: boolean; +} + +/** Unevaluated expression: its tokens. */ +export type Expr = Tok[]; + +export interface Annotation { + /** Simple name (`GetMapping` of `@org.springframework…GetMapping`). */ + name: string; + /** Arguments by name; a positional argument is `value`. */ + args: Map; +} + +export interface Param { + name: string; + /** Type as written, without spaces (`List`); "" when unknown. */ + type: string; + annotations: Annotation[]; +} + +export interface Field { + name: string; + type: string; + isStatic: boolean; + init: Expr | null; +} + +export interface Method { + name: string; + annotations: Annotation[]; + params: Param[]; + /** Offset of the name. */ + nameStart: number; + /** Whole declaration, annotations included. */ + start: number; + end: number; +} + +export interface ClassDecl { + name: string; + annotations: Annotation[]; + nameStart: number; + /** Whole declaration, annotations included. */ + start: number; + end: number; + /** Kotlin `object` / companion: its properties are static. */ + isObject: boolean; + methods: Method[]; + fields: Field[]; + /** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */ + ctorParams: Param[]; + classes: ClassDecl[]; +} + +export interface SourceFile { + packageName: string; + imports: string[]; + classes: ClassDecl[]; + /** Top-level properties (Kotlin). */ + fields: Field[]; +} + +const ID_START = /[A-Za-z_$]/; +const ID_PART = /[\w$]/; + +export function tokenize(text: string): Tok[] { + const out: Tok[] = []; + let i = 0; + let nl = false; + const n = text.length; + while (i < n) { + const c = text[i]!; + if (c === "\n") { + nl = true; + i++; + continue; + } + if (c === " " || c === "\t" || c === "\r" || c === "\f") { + i++; + continue; + } + if (c === "/" && text[i + 1] === "/") { + while (i < n && text[i] !== "\n") i++; + continue; + } + if (c === "/" && text[i + 1] === "*") { + const close = text.indexOf("*/", i + 2); + const end = close < 0 ? n : close + 2; + if (text.slice(i, end).includes("\n")) nl = true; + i = end; + continue; + } + const start = i; + if (c === '"' && text.startsWith('"""', i)) { + const close = text.indexOf('"""', i + 3); + const end = close < 0 ? n : close + 3; + const body = text.slice(i + 3, close < 0 ? n : close); + // Java text blocks start after a line break; Kotlin raw strings keep it. + out.push({ kind: "str", text: text.slice(i, end), value: body.replace(/^[ \t]*\r?\n/, ""), start, end, nl }); + i = end; + } else if (c === '"' || c === "'") { + let j = i + 1; + let value = ""; + while (j < n && text[j] !== c && text[j] !== "\n") { + if (text[j] === "\\" && j + 1 < n) { + const e = text[j + 1]!; + value += e === "n" ? "\n" : e === "t" ? "\t" : e; + j += 2; + } else value += text[j++]; + } + const end = Math.min(n, j + 1); + out.push({ kind: "str", text: text.slice(i, end), value, start, end, nl }); + i = end; + } else if (c === "`") { + const close = text.indexOf("`", i + 1); + const end = close < 0 || text.slice(i, close).includes("\n") ? i + 1 : close + 1; + out.push({ kind: "id", text: end > i + 1 ? text.slice(i + 1, end - 1) : "`", start, end, nl }); + i = end; + } else if (ID_START.test(c)) { + let j = i + 1; + while (j < n && ID_PART.test(text[j]!)) j++; + out.push({ kind: "id", text: text.slice(i, j), start, end: j, nl }); + i = j; + } else if (/\d/.test(c)) { + let j = i + 1; + while (j < n && /[\w.]/.test(text[j]!)) j++; + out.push({ kind: "num", text: text.slice(i, j), start, end: j, nl }); + i = j; + } else { + out.push({ kind: "p", text: c, start, end: i + 1, nl }); + i++; + } + nl = false; + } + return out; +} + +const OPEN: Record = { "(": ")", "[": "]", "{": "}" }; + +/** Index of the matching closing bracket for every opening one (unbalanced: the end). */ +function matchBrackets(toks: Tok[]): Int32Array { + const match = new Int32Array(toks.length).fill(-1); + const stack: number[] = []; + toks.forEach((t, i) => { + if (t.kind !== "p") return; + if (OPEN[t.text]) stack.push(i); + else if (t.text === ")" || t.text === "]" || t.text === "}") { + // Pop to the matching opener (unclosed ones inside end here too); stray closers are ignored. + for (let k = stack.length - 1; k >= 0; k--) { + if (OPEN[toks[stack[k]!]!.text] === t.text) { + for (const o of stack.splice(k)) match[o] = i; + break; + } + } + } + }); + for (const o of stack) match[o] = toks.length; + return match; +} + +const CLASS_KW = new Set(["class", "interface", "enum", "record", "object"]); +const MODIFIERS = new Set([ + "public", + "private", + "protected", + "internal", + "static", + "final", + "abstract", + "open", + "override", + "const", + "data", + "sealed", + "inline", + "suspend", + "lateinit", + "default", + "synchronized", + "native", + "transient", + "volatile", + "strictfp", + "operator", + "infix", + "external", + "annotation", + "inner", + "value", + "companion", + "fun", + "val", + "var", +]); +const NOT_METHOD = new Set(["if", "for", "while", "switch", "catch", "synchronized", "return", "new", "throw", "when"]); + +class Reader { + readonly match: Int32Array; + constructor(readonly toks: Tok[]) { + this.match = matchBrackets(toks); + } + + is(i: number, text: string): boolean { + const t = this.toks[i]; + return !!t && t.text === text && (t.kind === "p" || t.kind === "id"); + } + + /** Kotlin: a token on a new line that starts a member ends the previous one. */ + startsMember(i: number): boolean { + const t = this.toks[i]; + if (!t || !t.nl) return false; + return t.text === "@" || t.text === "}" || (t.kind === "id" && (MODIFIERS.has(t.text) || CLASS_KW.has(t.text))); + } + + /** `@Name(args)` at `i` (the `@`); returns the annotation and the next index. */ + annotation(i: number): [Annotation, number] | null { + if (!this.is(i, "@") || this.toks[i + 1]?.kind !== "id" || this.is(i + 1, "interface")) return null; + let j = i + 1; + let name = this.toks[j]!.text; + // Kotlin use-site targets: `@field:JsonProperty`. + if (this.is(j + 1, ":") && this.toks[j + 2]?.kind === "id") { + j += 2; + name = this.toks[j]!.text; + } + while (this.is(j + 1, ".") && this.toks[j + 2]?.kind === "id") { + j += 2; + name = this.toks[j]!.text; + } + j++; + const args = new Map(); + if (this.is(j, "(") && !this.toks[j]!.nl) { + const close = this.match[j]!; + const positional: Tok[][] = []; + for (const part of this.split(j + 1, close)) { + if (!part.length) continue; + if (part[0]!.kind === "id" && part[1]?.text === "=" && part[1].kind === "p" && part[2]?.text !== "=") + args.set(part[0]!.text, part.slice(2)); + else positional.push(part); + } + // Kotlin `vararg value`: `@GetMapping("/a", "/b")` is an array. + if (positional.length === 1) args.set("value", positional[0]!); + else if (positional.length > 1) { + const p = (text: string, at: Tok): Tok => ({ kind: "p", text, start: at.start, end: at.start, nl: false }); + const first = positional[0]![0]!; + args.set("value", [ + p("[", first), + ...positional.flatMap((x, k) => (k ? [p(",", x[0]!), ...x] : x)), + p("]", first), + ]); + } + j = close + 1; + } + return [{ name, args }, j]; + } + + /** Splits `[from, to)` at top-level commas (brackets and generics nest). */ + split(from: number, to: number): Tok[][] { + const parts: Tok[][] = []; + let cur: Tok[] = []; + let angle = 0; + for (let i = from; i < to; i++) { + const t = this.toks[i]!; + if (t.kind === "p" && OPEN[t.text]) { + const close = Math.min(this.match[i]!, to - 1); + for (let k = i; k <= close; k++) cur.push(this.toks[k]!); + i = close; + continue; + } + if (t.kind === "p" && t.text === "<") angle++; + if (t.kind === "p" && t.text === ">" && angle > 0) angle--; + if (t.kind === "p" && t.text === "," && angle === 0) { + parts.push(cur); + cur = []; + continue; + } + cur.push(t); + } + parts.push(cur); + return parts.filter((p, idx) => p.length || idx < parts.length - 1); + } + + params(open: number): Param[] { + const close = this.match[open]!; + const out: Param[] = []; + for (const part of this.split(open + 1, close)) { + const annotations: Annotation[] = []; + const rest: Tok[] = []; + const sub = new Reader(part); + for (let i = 0; i < part.length;) { + const a = sub.annotation(i); + if (a) { + annotations.push(a[0]); + i = a[1]; + } else rest.push(part[i++]!); + } + const colon = rest.findIndex((t) => t.kind === "p" && t.text === ":"); + if (colon >= 0) { + // Kotlin `name: Type = default`. + const head = rest + .slice(0, colon) + .filter((t) => t.kind === "id" && !MODIFIERS.has(t.text) && t.text !== "vararg"); + const eq = rest.findIndex((t, k) => k > colon && t.kind === "p" && t.text === "="); + const name = head[head.length - 1]?.text; + if (name) out.push({ name, type: joined(rest.slice(colon + 1, eq < 0 ? undefined : eq)), annotations }); + } else { + const toks = rest.filter((t) => !(t.kind === "id" && t.text === "final")); + const last = toks[toks.length - 1]; + if (last?.kind === "id") out.push({ name: last.text, type: joined(toks.slice(0, -1)), annotations }); + } + } + return out; + } + + /** Members of a body `[from, to)`; nested declarations recurse. */ + body(from: number, to: number, into: ClassDecl): void { + let annotations: Annotation[] = []; + let header: Tok[] = []; + let declStart = -1; + const reset = () => { + annotations = []; + header = []; + declStart = -1; + }; + const flushField = (init: Expr | null) => { + const ids = header.filter((t) => t.kind === "id"); + const kw = header.findIndex((t) => t.kind === "id" && (t.text === "val" || t.text === "var")); + const isStatic = into.isObject || ids.some((t) => t.text === "static" || t.text === "const"); + if (kw >= 0) { + const name = header[kw + 1]; + const colon = header.findIndex((t, k) => k > kw && t.text === ":" && t.kind === "p"); + if (name?.kind === "id") + into.fields.push({ + name: name.text, + type: colon >= 0 ? joined(header.slice(colon + 1)) : "", + isStatic, + init, + }); + } else { + const typeToks = header.filter((t) => !(t.kind === "id" && MODIFIERS.has(t.text))); + const name = typeToks[typeToks.length - 1]; + if (name?.kind === "id" && typeToks.length >= 2) + into.fields.push({ name: name.text, type: joined(typeToks.slice(0, -1)), isStatic, init }); + } + }; + let i = from; + while (i < to) { + const t = this.toks[i]!; + if (header.length && this.startsMember(i) && !this.is(i, "}")) { + flushField(null); + reset(); + } + const a = this.annotation(i); + if (a) { + if (declStart < 0) declStart = t.start; + annotations.push(a[0]); + i = a[1]; + continue; + } + if (t.kind === "p" && t.text === ";") { + flushField(null); + reset(); + i++; + continue; + } + if (declStart < 0) declStart = t.start; + const companion = t.kind === "id" && t.text === "object" && this.is(i + 1, "{"); + if (t.kind === "id" && CLASS_KW.has(t.text) && (this.toks[i + 1]?.kind === "id" || companion)) { + const nameTok = companion ? t : this.toks[i + 1]!; + const cls: ClassDecl = { + name: companion ? "Companion" : nameTok.text, + annotations, + nameStart: nameTok.start, + start: declStart, + end: nameTok.end, + isObject: t.text === "object", + methods: [], + fields: [], + ctorParams: [], + classes: [], + }; + let j = companion ? i + 1 : i + 2; + if (this.is(j, "<")) { + let depth = 0; + for (; j < to; j++) { + if (this.is(j, "<")) depth++; + else if (this.is(j, ">") && --depth === 0) break; + } + j++; + } + // Kotlin `class A @Inject constructor(…)`. + while (j < to && (this.is(j, "@") || this.toks[j]?.kind === "id") && !this.toks[j]!.nl) { + const ann = this.annotation(j); + if (ann) j = ann[1]; + else if (this.toks[j]!.kind === "id" && (MODIFIERS.has(this.toks[j]!.text) || this.is(j, "constructor"))) j++; + else break; + } + if (this.is(j, "(")) { + cls.ctorParams = this.params(j); + j = this.match[j]! + 1; + } + for (; j < to; j++) { + if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break; + if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!; + } + if (this.is(j, "{")) { + const close = Math.min(this.match[j]!, to); + this.body(j + 1, close, cls); + cls.end = this.toks[close]?.end ?? this.toks[to - 1]!.end; + j = close + 1; + } else cls.end = this.toks[j - 1]!.end; + into.classes.push(cls); + reset(); + i = j; + continue; + } + if ( + t.kind === "id" && + this.is(i + 1, "(") && + !NOT_METHOD.has(t.text) && + !header.some((h) => h.kind === "p" && (h.text === "=" || h.text === ".")) + ) { + const params = this.params(i + 1); + let j = this.match[i + 1]! + 1; + for (; j < to; j++) { + if (this.is(j, "{")) { + j = this.match[j]! + 1; + break; + } + if (this.is(j, ";")) { + j++; + break; + } + if (this.startsMember(j)) break; + if (this.toks[j]!.kind === "p" && OPEN[this.toks[j]!.text]) j = this.match[j]!; + } + const end = this.toks[Math.min(j, this.toks.length) - 1]?.end ?? t.end; + into.methods.push({ name: t.text, annotations, params, nameStart: t.start, start: declStart, end }); + reset(); + i = j; + continue; + } + if (t.kind === "p" && t.text === "=") { + const init: Tok[] = []; + let j = i + 1; + for (; j < to; j++) { + if (this.is(j, ";") || this.is(j, "}")) break; + const prev = this.toks[j - 1]!; + const operand = prev.kind === "p" && /[+.(,=]/.test(prev.text); + if (j > i + 1 && this.startsMember(j) && !operand) break; + if (j > i + 1 && this.toks[j]!.nl && !operand && !/^[+.]$/.test(this.toks[j]!.text)) break; + const tok = this.toks[j]!; + if (tok.kind === "p" && OPEN[tok.text]) { + const close = Math.min(this.match[j]!, to - 1); + for (let k = j; k <= close; k++) init.push(this.toks[k]!); + j = close; + } else init.push(tok); + } + flushField(init); + reset(); + i = this.is(j, ";") ? j + 1 : j; + continue; + } + if (t.kind === "p" && OPEN[t.text]) { + // Initializer blocks, enum constant arguments… + i = this.match[i]! + 1; + if (t.text === "{") reset(); + continue; + } + if (t.kind === "p" && t.text === ",") { + // Enum constants / `int a, b;`. + reset(); + i++; + continue; + } + header.push(t); + i++; + } + if (header.length) flushField(null); + } +} + +function joined(toks: Tok[]): string { + return toks.map((t) => t.text).join(""); +} + +export function parseSource(text: string): SourceFile { + const toks = tokenize(text); + const r = new Reader(toks); + let packageName = ""; + const imports: string[] = []; + let i = 0; + // `package a.b;` / `import a.b.C;` (Kotlin: no semicolons; `import a.b.C as D`). + for (;;) { + const t = toks[i]; + if (!t || t.kind !== "id" || (t.text !== "package" && t.text !== "import")) { + if (t && r.is(i, "@") && toks[i + 1]?.text === "file") { + const a = r.annotation(i); + if (a) { + i = a[1]; + continue; + } + } + break; + } + let j = i + 1; + let name = ""; + while (j < toks.length && !toks[j]!.nl && !r.is(j, ";")) { + if (toks[j]!.kind === "id" && toks[j]!.text === "as") break; + name += toks[j]!.text; + j++; + } + while (j < toks.length && !toks[j]!.nl && !r.is(j, ";")) j++; + if (r.is(j, ";")) j++; + if (t.text === "package") packageName = name; + else imports.push(name.replace(/^static/, "")); + i = j; + } + const root: ClassDecl = { + name: "", + annotations: [], + nameStart: 0, + start: 0, + end: text.length, + isObject: true, + methods: [], + fields: [], + ctorParams: [], + classes: [], + }; + r.body(i, toks.length, root); + return { packageName, imports, classes: root.classes, fields: root.fields }; +} + +/** Every class of the file, nested ones included. */ +export function allClasses(file: SourceFile): ClassDecl[] { + const out: ClassDecl[] = []; + const walk = (cs: ClassDecl[]) => { + for (const c of cs) { + out.push(c); + walk(c.classes); + } + }; + walk(file.classes); + return out; +} diff --git a/src/features/http-client/spring/spring.test.ts b/src/features/http-client/spring/spring.test.ts new file mode 100644 index 000000000..9f7bca8f7 --- /dev/null +++ b/src/features/http-client/spring/spring.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "vitest"; +import { springAt, springControllers, type Endpoint } from "./endpoints"; +import { + appendRequests, + bodyLookup, + findHost, + hostFromConfig, + parseProperties, + parseYaml, + renderRequest, + skeleton, + type SourceReader, +} from "./request"; +import { parseSource } from "./source"; + +const JAVA = `package com.example.demo.web; + +import com.example.demo.dto.CreateUser; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +/** Users { not a brace } */ +@RestController +@RequestMapping(UserController.BASE + "/users") +public class UserController { + static final String BASE = "/api"; + private static final String BY_ID = "/{id}"; + private final UserService service; + + public UserController(UserService service) { this.service = service; } + + @GetMapping + public List list(@RequestParam(defaultValue = "0") int page, @RequestParam("q") String query, + @RequestParam Map all) { + return service.list(); // "}" in a comment + } + + @GetMapping(BY_ID) + public User get(@PathVariable Long id, @RequestHeader("X-Trace") String trace) { + if (id == null) { throw new IllegalArgumentException("{"); } + return service.get(id); + } + + @PostMapping(value = {"", "/new"}, consumes = MediaType.APPLICATION_JSON_VALUE, produces = "application/json") + public User create(@Valid @RequestBody CreateUser body) { return service.create(body); } + + @RequestMapping(path = "/{id:\\\\d+}/avatar", method = RequestMethod.PUT) + public void avatar(@PathVariable("id") long id, @RequestBody byte[] data) {} + + @DeleteMapping(path = BY_ID, params = "force=true", headers = "X-Api=1") + public void delete(@PathVariable Long id) {} + + @org.springframework.web.bind.annotation.PatchMapping("/{id}/name") + public void rename(@PathVariable Long id, @RequestBody Rename rename) {} + + public void helper() {} + + record Rename(String name, boolean notify) {} +} +`; + +const KOTLIN = `package com.example.demo.web + +import org.springframework.web.bind.annotation.* + +const val API = "/api/v2" + +@RestController +@RequestMapping(API) +class OrderController(private val service: OrderService) { + companion object { + const val ORDERS = "/orders" + } + + @GetMapping("\${ORDERS}/{id}", ORDERS + "/by-id/{id}") + fun get(@PathVariable id: Long): Order = service.get(id) + + @PostMapping(ORDERS) + suspend fun create(@RequestBody order: NewOrder, @RequestParam(required = false) dryRun: Boolean?): Order { + return service.create(order) + } + + @RequestMapping(value = [ORDERS + "/search"], method = [RequestMethod.POST, RequestMethod.GET]) + fun search(@RequestParam("q") query: String) = service.search(query) +} + +data class NewOrder(val item: String, val quantity: Int, val tags: List = emptyList()) +`; + +function byMethod(endpoints: Endpoint[], name: string): Endpoint { + const e = endpoints.find((x) => x.methodName === name); + if (!e) throw new Error(`no endpoint ${name}`); + return e; +} + +describe("Spring mappings (Java)", () => { + const [ctrl] = springControllers(JAVA); + const eps = ctrl!.endpoints; + + it("finds the controller and its mapped methods only", () => { + expect(ctrl!.name).toBe("UserController"); + expect(eps.map((e) => e.methodName)).toEqual(["list", "get", "create", "avatar", "delete", "rename"]); + expect(JAVA.slice(byMethod(eps, "get").offset).startsWith("get(")).toBe(true); + expect(JAVA.slice(ctrl!.offset).startsWith("UserController {")).toBe(true); + }); + + it("combines class and method paths, resolving constants of the file", () => { + expect(byMethod(eps, "list").paths).toEqual(["/api/users"]); + expect(byMethod(eps, "get").paths).toEqual(["/api/users/{id}"]); + expect(byMethod(eps, "create").paths).toEqual(["/api/users", "/api/users/new"]); + expect(byMethod(eps, "rename").paths).toEqual(["/api/users/{id}/name"]); + }); + + it("reads HTTP methods from shortcuts and RequestMapping#method", () => { + expect(eps.map((e) => e.httpMethod)).toEqual(["GET", "GET", "POST", "PUT", "DELETE", "PATCH"]); + }); + + it("reads request parameters, headers, bodies, consumes and produces", () => { + expect(byMethod(eps, "list").query).toEqual([ + { name: "page", value: "0" }, + { name: "q", value: null }, + ]); + expect(byMethod(eps, "get").headers).toEqual([{ name: "X-Trace", value: "{{X-Trace}}" }]); + const create = byMethod(eps, "create"); + expect(create).toMatchObject({ + bodyType: "CreateUser", + consumes: "application/json", + produces: "application/json", + }); + expect(byMethod(eps, "delete")).toMatchObject({ + query: [{ name: "force", value: "true" }], + headers: [{ name: "X-Api", value: "1" }], + }); + }); + + it("renders requests with path variables, query and JSON body", () => { + const host = "http://localhost:8080"; + expect(renderRequest(byMethod(eps, "list"), { host })).toBe( + "### UserController.list\nGET http://localhost:8080/api/users?page=0&q={{q}}\n", + ); + expect(renderRequest(byMethod(eps, "get"), { host })).toBe( + "### UserController.get\nGET http://localhost:8080/api/users/{{id}}\nX-Trace: {{X-Trace}}\n", + ); + expect(renderRequest(byMethod(eps, "avatar"), { host })).toBe( + "### UserController.avatar\nPUT http://localhost:8080/api/users/{{id}}/avatar\nContent-Type: application/json\n\n{}\n", + ); + const file = parseSource(JAVA); + const lookup = (n: string) => file.classes.flatMap((c) => c.classes).find((c) => c.name === n) ?? null; + const rename = byMethod(eps, "rename"); + expect(renderRequest(rename, { host, body: skeleton(rename.bodyType!, lookup) })).toBe( + [ + "### UserController.rename", + "PATCH http://localhost:8080/api/users/{{id}}/name", + "Content-Type: application/json", + "", + "{", + ' "name": "",', + ' "notify": false', + "}", + "", + ].join("\n"), + ); + expect(renderRequest(byMethod(eps, "create"), { host, path: "/api/users/new" })).toBe( + [ + "### UserController.create", + "POST http://localhost:8080/api/users/new", + "Accept: application/json", + "Content-Type: application/json", + "", + "{}", + "", + ].join("\n"), + ); + }); +}); + +describe("Spring mappings (Kotlin)", () => { + const [ctrl] = springControllers(KOTLIN); + const eps = ctrl!.endpoints; + + it("finds mappings with companion and top-level constants, arrays and suspend functions", () => { + expect(ctrl!.name).toBe("OrderController"); + expect(eps.map((e) => [e.methodName, e.httpMethod, e.paths])).toEqual([ + ["get", "GET", ["/api/v2/orders/{id}", "/api/v2/orders/by-id/{id}"]], + ["create", "POST", ["/api/v2/orders"]], + ["search", "POST", ["/api/v2/orders/search"]], + ]); + expect(byMethod(eps, "create")).toMatchObject({ bodyType: "NewOrder", query: [{ name: "dryRun", value: null }] }); + expect(byMethod(eps, "search").query).toEqual([{ name: "q", value: null }]); + }); + + it("builds the body from a data class of the file", async () => { + const file = parseSource(KOTLIN); + const lookup = await bodyLookup("/p/src/main/kotlin/com/example/demo/web/OrderController.kt", file, eps, { + read: async () => null, + }); + expect(skeleton("NewOrder", lookup)).toEqual({ item: "", quantity: 0, tags: [] }); + }); +}); + +describe("non-controllers", () => { + it("ignores classes that are not controllers and files without any", () => { + expect(springControllers('@Service class A { @GetMapping("/x") void a() {} }')).toEqual([]); + expect(springControllers("class A {}")).toEqual([]); + expect( + springControllers('@FeignClient interface C { @GetMapping("/x") String a(); }\n@Controller class B {}'), + ).toEqual([{ name: "B", offset: expect.any(Number), range: expect.any(Object), endpoints: [] }]); + }); + + it("uses GET for a RequestMapping without method and the class-level method otherwise", () => { + const [c] = springControllers( + '@Controller @RequestMapping(value="/a", method=RequestMethod.POST) class A { @RequestMapping("/b") String b() { return ""; } }', + ); + expect(c!.endpoints[0]).toMatchObject({ httpMethod: "POST", paths: ["/a/b"] }); + const [d] = springControllers('@RestController class D { @RequestMapping("/b") String b() { return ""; } }'); + expect(d!.endpoints[0]).toMatchObject({ httpMethod: "GET", paths: ["/b"] }); + }); +}); + +describe("server address", () => { + it("reads server.port and the context path from properties", () => { + const props = parseProperties( + "# comment\nserver.port=9090\nserver.servlet.context-path = /shop/\nspring.application.name: demo\n", + ); + expect(hostFromConfig([props])).toBe("http://localhost:9090/shop"); + }); + + it("reads nested and flattened keys from YAML's first document", () => { + const yml = parseYaml( + 'spring:\n application:\n name: demo # x\nserver:\n port: ${PORT:8181}\n servlet:\n context-path: "/api"\n---\nserver:\n port: 1\n', + ); + expect(hostFromConfig([yml])).toBe("http://localhost:8181/api"); + expect(hostFromConfig([parseYaml("server.port: 7070\n")])).toBe("http://localhost:7070"); + }); + + it("defaults to localhost:8080 and keeps unknown placeholders out", () => { + expect(hostFromConfig([])).toBe("http://localhost:8080"); + expect(hostFromConfig([parseProperties("server.port=${PORT}\n")])).toBe("http://localhost:8080"); + expect(hostFromConfig([parseProperties("server.port=0\n")])).toBe("http://localhost:8080"); + expect(hostFromConfig([parseProperties("server.port=8443\nserver.ssl.enabled=true\n")])).toBe( + "https://localhost:8443", + ); + expect(hostFromConfig([parseYaml("spring:\n webflux:\n base-path: /rx\n")])).toBe("http://localhost:8080/rx"); + }); + + it("looks in the module's src/main/resources", async () => { + const files: Record = { + "/ws/api/src/main/resources/application.yml": "server:\n port: 8001\n", + "/ws/src/main/resources/application.properties": "server.port=9000\n", + }; + const fs: SourceReader = { read: async (p) => files[p] ?? null }; + expect(await findHost("/ws/api/src/main/java/a/A.java", "/ws", fs)).toBe("http://localhost:8001"); + expect(await findHost("/ws/other/A.java", "/ws", fs)).toBe("http://localhost:9000"); + }); +}); + +describe("body types from other files", () => { + it("follows the import to the same source root, then the controller's directory", async () => { + const files: Record = { + "/p/src/main/java/com/example/demo/dto/CreateUser.java": + "package com.example.demo.dto;\npublic class CreateUser {\n public static final int MAX = 3;\n private String name;\n private int age;\n private Address address;\n private List roles;\n}\n", + "/p/src/main/java/com/example/demo/dto/Address.java": + "package com.example.demo.dto;\npublic record Address(String city, String zip) {}\n", + }; + const fs: SourceReader = { read: async (p) => files[p] ?? null }; + const file = parseSource(JAVA); + const eps = springControllers(JAVA)[0]!.endpoints; + const lookup = await bodyLookup("/p/src/main/java/com/example/demo/web/UserController.java", file, eps, fs); + expect(skeleton("CreateUser", lookup)).toEqual({ name: "", age: 0, address: { city: "", zip: "" }, roles: [] }); + expect(skeleton("Mono", lookup)).toMatchObject({ name: "" }); + expect(skeleton("List", lookup)).toEqual([]); + expect(skeleton("Unknown", lookup)).toEqual({}); + }); +}); + +describe("appending to the request file", () => { + const block = "### A.a\nGET http://localhost:8080/a\n"; + it("starts an empty file with the request", () => { + expect(appendRequests("", [block])).toEqual({ insert: block, from: 0, caret: 8 }); + }); + + it("separates from existing requests with a blank line before ###", () => { + const existing = "### X\nGET http://x\n"; + const r = appendRequests(existing, [block, block]); + expect(existing + r.insert).toBe(`${existing}\n${block}\n${block}`); + expect((existing + r.insert).slice(r.caret).startsWith("GET http://localhost:8080/a")).toBe(true); + expect(appendRequests("GET http://x", [block]).insert.startsWith("\n\n###")).toBe(true); + expect(appendRequests("GET http://x\n\n", [block]).insert.startsWith("###")).toBe(true); + }); +}); + +describe("caret lookup", () => { + it("finds the endpoint whose declaration (annotations included) holds the caret, and its controller", () => { + const controllers = springControllers(JAVA); + const at = (needle: string) => springAt(controllers, JAVA.indexOf(needle)); + expect(at("@GetMapping(BY_ID)").endpoint?.methodName).toBe("get"); + expect(at("service.get(id)").endpoint?.methodName).toBe("get"); + expect(at("public void helper").endpoint).toBeNull(); + expect(at("public void helper").controller?.name).toBe("UserController"); + expect(springAt(controllers, 0)).toEqual({ controller: null, endpoint: null }); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cb52c8407..d2c6a1b27 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -194,6 +194,7 @@ "Cannot convert encoding": "无法转换编码", "Cannot create scratch file": "无法创建临时文件", "Cannot create the HTTP request file": "无法创建 HTTP 请求文件", + "Cannot generate the HTTP request": "无法生成 HTTP 请求", "Cannot duplicate the data source": "无法复制数据源", "Cannot execute": "无法执行", "Cannot execute on {target}": "无法在 {target} 上执行", @@ -790,6 +791,8 @@ "Generate": "生成", "Generate DDL to Clipboard": "生成 DDL 到剪贴板", "Generate DDL to Query Console": "生成 DDL 到查询控制台", + "Generate Request in HTTP Client": "在 HTTP 客户端中生成请求", + "Generate Requests for Controller": "为控制器生成请求", "Generate…": "生成…", "Get from Version Control": "从版本控制获取", "Get from Version Control…": "从版本控制获取…", @@ -1167,6 +1170,8 @@ "No GitHub repository": "没有 GitHub 仓库", "No Gradle projects": "没有 Gradle 项目", "No HTTP request at the caret": "光标处没有 HTTP 请求", + "No Spring controller at the caret": "光标处没有 Spring 控制器", + "No Spring request mapping at the caret": "光标处没有 Spring 请求映射", "No Maven projects": "没有 Maven 项目", "No profile for '{name}'": "没有 '{name}' 的分析数据", "No Python found": "未找到 Python", From c3b64304ac408162d31ee7e957ac23b612e9d65e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:01:11 +0800 Subject: [PATCH 4/6] fix(agents): Offer project skills and commands in the composer (#105) The new-task composer only offered the slash commands the agent sent in its last session, stored once globally, so a project that hadn't run a session (or whose last session ran elsewhere) showed none of its skills; matching was substring-only, so `/trig` didn't find `/triage` either. Offered commands are now remembered per project root. For Claude Code, skills (`.claude/skills/*/SKILL.md`) and commands (`.claude/commands/**/*.md`) are also discovered on disk, from the working directory up to home plus `~/.claude`, and merged with the agent's list (agent entries win). New `agent_commands` IPC serves the home composer (project root) and session composer (the task's cwd). Matching ranks prefix (also after `plugin:`), then substring, then subsequence. Picking a completion inserts `/name ` and the prompt is sent unchanged. Not covered: a new worktree of a project that gitignores `.claude/` doesn't contain the project's skills. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-agent/src/lib.rs | 79 +++-- crates/ide-agent/src/skills.rs | 281 ++++++++++++++++++ crates/ide-agent/src/tests.rs | 50 +++- src-tauri/src/commands/agent.rs | 16 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/state.rs | 1 + src/bindings.ts | 5 + src/features/agents/HomeEditor.tsx | 5 +- src/features/agents/SessionEditor.tsx | 4 +- src/features/agents/agents.test.ts | 15 + src/features/agents/composerCommands.test.tsx | 88 ++++++ src/features/agents/composerModel.ts | 24 +- src/features/agents/store.ts | 39 ++- 13 files changed, 576 insertions(+), 32 deletions(-) create mode 100644 crates/ide-agent/src/skills.rs create mode 100644 src/features/agents/composerCommands.test.tsx diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index c08548da4..c62073bf3 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -10,6 +10,7 @@ mod config; mod model; mod provider; +mod skills; mod timeline; mod worktree; @@ -53,10 +54,12 @@ pub struct AgentPaths { pub store_dir: PathBuf, /// Parent of the tasks' worktrees. pub worktrees_dir: PathBuf, - /// Settings and commands each agent offered last time (shared by all projects). + /// Settings each agent offered last time (shared by all projects), and its commands per project. pub models_file: PathBuf, /// User-defined agents, one `.toml` each (`name`, `command`, `args`, `env`). pub custom_agents_dir: Option, + /// The user's home: its `.claude` holds their own skills and commands. + pub home_dir: Option, } /// Starts the ACP server of a provider in a directory, speaking to `client`. @@ -80,8 +83,9 @@ struct Shared { struct Known { #[serde(default)] config: Vec, - #[serde(default)] - commands: Vec, + /// By project root: skills and custom commands differ per project. + #[serde(default, rename = "projectCommands")] + commands: HashMap>, } impl Shared { @@ -96,8 +100,18 @@ impl Shared { } } - fn known(&self, provider: &str) -> Known { - self.known.lock().get(provider).cloned().unwrap_or_default() + fn known_config(&self, provider: &str) -> Vec { + self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default() + } + + /// The commands the agent offered last time in this project. + fn known_commands(&self, provider: &str) -> Vec { + let cache = self.known.lock(); + cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default() + } + + fn root_key(&self) -> String { + self.paths.project_root.to_string_lossy().into_owned() } fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) { @@ -109,8 +123,11 @@ impl Shared { known.config = c.to_vec(); changed = true; } - if let Some(c) = commands.filter(|c| !c.is_empty() && known.commands != *c) { - known.commands = c.to_vec(); + let root = self.root_key(); + if let Some(c) = + commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c)) + { + known.commands.insert(root, c.to_vec()); changed = true; } if !changed { @@ -238,25 +255,42 @@ impl AgentManager { let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); let list = specs .iter() - .map(|p| { - let known = self.shared.known(&p.id); - AgentProvider { - id: p.id.clone(), - name: p.name.clone(), - command: std::iter::once(p.command.as_str()) - .chain(p.args.iter().map(String::as_str)) - .collect::>() - .join(" "), - installed: provider::installed(&p.command), - config: known.config, - commands: known.commands, - } + .map(|p| AgentProvider { + id: p.id.clone(), + name: p.name.clone(), + command: std::iter::once(p.command.as_str()) + .chain(p.args.iter().map(String::as_str)) + .collect::>() + .join(" "), + installed: provider::installed(&p.command), + config: self.shared.known_config(&p.id), + commands: self.shared.known_commands(&p.id), }) .collect(); *self.shared.providers.write() = specs; list } + /// The slash commands to complete for `provider`, in `task`'s directory or (without one) + /// in the project: what the agent offered last time there, plus, for Claude Code, the + /// skills and custom commands on disk it has not listed. + pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult> { + let (provider, offered, cwd) = match task { + Some(id) => { + let task = self.task(id)?; + let s = task.state.lock(); + (s.task.provider.clone(), s.task.commands.clone(), s.task.cwd.0.clone()) + } + None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()), + }; + if provider != "claude" { + return Ok(offered); + } + let home = self.shared.paths.home_dir.clone(); + let found = blocking(move || Ok(skills::discover(&cwd, home.as_deref()))).await?; + Ok(skills::merge(offered, found)) + } + fn task(&self, id: &str) -> IdeResult> { self.tasks.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no agent task {id}"))) } @@ -289,8 +323,7 @@ impl AgentManager { } }; // Until the session reports its own, show what the agent offered last time with the choices applied. - let known = self.shared.known(&spec.id); - let mut config = known.config; + let mut config = self.shared.known_config(&spec.id); for choice in &req.config { if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) { o.current = Some(choice.value.clone()); @@ -312,7 +345,7 @@ impl AgentManager { created_ms: now, updated_ms: now, config, - commands: known.commands, + commands: self.shared.known_commands(&spec.id), usage: None, queue: Vec::new(), }; diff --git a/crates/ide-agent/src/skills.rs b/crates/ide-agent/src/skills.rs new file mode 100644 index 000000000..b426fc06b --- /dev/null +++ b/crates/ide-agent/src/skills.rs @@ -0,0 +1,281 @@ +//! Claude Code's slash commands on disk, for completion before (or without) a session: +//! skills (`.claude/skills//SKILL.md`) and custom commands (`.claude/commands/**/*.md`) +//! of the working directory and every directory above it, then the user's `~/.claude`. + +use crate::AgentCommand; +use std::collections::HashSet; +use std::path::Path; + +const MAX_DESCRIPTION: usize = 200; +/// Command directories nest this deep at most (`a/b/c.md` → `a:b:c`). +const MAX_DEPTH: usize = 4; + +/// The commands Claude Code finds from `cwd`: nearest directory first, the user's last; +/// the first of a name wins. +pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut add = |found: Vec| { + for c in found { + if seen.insert(c.name.clone()) { + out.push(c); + } + } + }; + for dir in cwd.ancestors() { + if home.is_some_and(|h| h == dir) { + break; + } + add(in_dir(&dir.join(".claude"))); + } + if let Some(home) = home { + add(in_dir(&home.join(".claude"))); + } + out +} + +/// `first` (the agent's own list) plus the commands of `more` it lacks. +pub(crate) fn merge(first: Vec, more: Vec) -> Vec { + let names: HashSet = first.iter().map(|c| c.name.clone()).collect(); + let mut out = first; + out.extend(more.into_iter().filter(|c| !names.contains(&c.name))); + out +} + +fn in_dir(claude: &Path) -> Vec { + if !claude.is_dir() { + return Vec::new(); + } + let mut out = skills(&claude.join("skills")); + commands(&claude.join("commands"), "", 0, &mut out); + out +} + +fn skills(dir: &Path) -> Vec { + let mut out: Vec = sorted(dir) + .into_iter() + .filter_map(|path| { + let text = std::fs::read_to_string(path.join("SKILL.md")).ok()?; + let meta = Frontmatter::parse(&text); + if meta.get("user-invocable").is_some_and(|v| v.eq_ignore_ascii_case("false")) { + return None; + } + let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?; + Some(meta.command(name, &text)) + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH { + return; + } + for path in sorted(dir) { + let Some(name) = file_name(&path) else { continue }; + if path.is_dir() { + commands(&path, &format!("{prefix}{name}:"), depth + 1, out); + } else if let Some(stem) = name.strip_suffix(".md") + && let Ok(text) = std::fs::read_to_string(&path) + { + out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text)); + } + } +} + +fn sorted(dir: &Path) -> Vec { + let mut paths: Vec<_> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| file_name(p).is_some_and(|n| !n.starts_with('.'))) + .collect(); + paths.sort(); + paths +} + +fn file_name(path: &Path) -> Option { + path.file_name()?.to_str().map(str::to_owned) +} + +/// The `key: value` lines between the leading `---` fences (folded `>` / `|` values joined). +struct Frontmatter { + fields: Vec<(String, String)>, + /// Where the body starts. + body: usize, +} + +impl Frontmatter { + fn parse(text: &str) -> Self { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let mut lines = text.split_inclusive('\n'); + let mut offset = 0; + let mut fields: Vec<(String, String)> = Vec::new(); + match lines.next() { + Some(first) if first.trim_end() == "---" => offset += first.len(), + _ => return Self { fields, body: 0 }, + } + for line in lines { + offset += line.len(); + let line = line.trim_end(); + if line == "---" { + return Self { fields, body: offset }; + } + if line.starts_with([' ', '\t']) { + if let Some((_, value)) = fields.last_mut() { + if !value.is_empty() { + value.push(' '); + } + value.push_str(line.trim()); + } + continue; + } + if let Some((key, value)) = line.split_once(':') { + let value = value.trim(); + let value = if matches!(value, ">" | "|" | ">-" | "|-") { "" } else { unquote(value) }; + fields.push((key.trim().to_owned(), value.to_owned())); + } + } + // No closing fence: not frontmatter. + Self { fields: Vec::new(), body: 0 } + } + + fn get(&self, key: &str) -> Option<&str> { + self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + + /// `description`, else the body's first line. + fn command(&self, name: String, text: &str) -> AgentCommand { + let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| { + let body = text.get(self.body..).unwrap_or_default(); + body.lines() + .map(|l| l.trim().trim_start_matches('#').trim()) + .find(|l| !l.is_empty()) + .unwrap_or_default() + .to_owned() + }); + AgentCommand { + name, + description: truncate(&description), + hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned), + } + } +} + +fn unquote(value: &str) -> &str { + for q in ['"', '\''] { + if let Some(inner) = value.strip_prefix(q).and_then(|v| v.strip_suffix(q)) { + return inner; + } + } + value +} + +fn truncate(s: &str) -> String { + match s.char_indices().nth(MAX_DESCRIPTION) { + Some((i, _)) => format!("{}…", s[..i].trim_end()), + None => s.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn names(cmds: &[AgentCommand]) -> Vec<&str> { + cmds.iter().map(|c| c.name.as_str()).collect() + } + + fn cmd(name: &str, description: &str) -> AgentCommand { + AgentCommand { name: name.into(), description: description.into(), hint: None } + } + + #[test] + fn project_and_user_skills_and_commands() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let repo = tmp.path().join("work").join("idea"); + let skills = repo.join(".claude/skills"); + write( + &skills.join("triage/SKILL.md"), + "---\nname: triage\ndescription: One pass over open issues and PRs\n---\n# Triage\n", + ); + write(&skills.join("issue-fix/SKILL.md"), "---\nname: issue-fix\ndescription: \"Fix an issue\"\n---\n"); + // No frontmatter name: the directory's. + write(&skills.join("verify/SKILL.md"), "---\ndescription: >\n Build and\n drive the app\n---\n"); + write(&skills.join("hidden/SKILL.md"), "---\nname: hidden\nuser-invocable: false\n---\n"); + write(&skills.join("no-skill-file/README.md"), "nothing"); + write(&repo.join(".claude/commands/review.md"), "---\nargument-hint: \n---\nReview a pull request\n"); + write(&repo.join(".claude/commands/git/commit.md"), "# Commit the staged changes\n"); + // A parent directory's commands apply too. + write(&tmp.path().join("work/.claude/commands/parent.md"), "---\ndescription: from above\n---\n"); + // The user's: shadowed by the project's skill of the same name. + write(&home.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: user triage\n---\n"); + write(&home.join(".claude/skills/lint/SKILL.md"), "---\nname: lint\ndescription: user lint\n---\n"); + write(&home.join(".claude/commands/brooks-review.md"), "---\ndescription: Review\n---\n"); + + let found = discover(&repo, Some(&home)); + assert_eq!( + names(&found), + ["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"] + ); + let by = |n: &str| found.iter().find(|c| c.name == n).unwrap(); + assert_eq!(by("triage").description, "One pass over open issues and PRs"); + assert_eq!(by("issue-fix").description, "Fix an issue"); + assert_eq!(by("verify").description, "Build and drive the app"); + assert_eq!(by("git:commit").description, "Commit the staged changes"); + assert_eq!( + (by("review").description.as_str(), by("review").hint.as_deref()), + ("Review a pull request", Some("")) + ); + } + + #[test] + fn nothing_without_claude_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let repo = home.join("plain"); + std::fs::create_dir_all(&repo).unwrap(); + assert!(discover(&repo, Some(&home)).is_empty()); + } + + #[test] + fn walking_up_stops_at_home() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + // Above home: not the user's, and not this project's. + write(&tmp.path().join(".claude/commands/outside.md"), "x\n"); + write(&home.join(".claude/commands/mine.md"), "x\n"); + let repo = home.join("src/app"); + std::fs::create_dir_all(&repo).unwrap(); + assert_eq!(names(&discover(&repo, Some(&home))), ["mine"]); + } + + #[test] + fn the_agents_entries_win() { + let merged = merge( + vec![cmd("triage", "from the agent"), cmd("compact", "Compact")], + vec![cmd("triage", "from disk"), cmd("verify", "Verify")], + ); + assert_eq!(names(&merged), ["triage", "compact", "verify"]); + assert_eq!(merged[0].description, "from the agent"); + } + + #[test] + fn frontmatter_edge_cases() { + let f = Frontmatter::parse("no fence\n"); + assert!(f.fields.is_empty()); + let f = Frontmatter::parse("---\nname: x\nunterminated\n"); + assert!(f.get("name").is_none()); + let f = Frontmatter::parse("\u{feff}---\r\nname: 'quoted'\r\n---\r\nbody\r\n"); + assert_eq!(f.get("name"), Some("quoted")); + assert_eq!(truncate(&"é".repeat(MAX_DESCRIPTION + 5)).chars().count(), MAX_DESCRIPTION + 1); + } +} diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index a41b17fcb..b98428bca 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -93,6 +93,10 @@ impl Handler for FakeAgent { } fn manager(dir: &Path, agent: Arc) -> AgentManager { + manager_at(dir, &dir.join("models.json"), agent) +} + +fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentManager { let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| { let (a, b) = duplex(1 << 20); let (ar, aw) = tokio::io::split(a); @@ -107,14 +111,54 @@ fn manager(dir: &Path, agent: Arc) -> AgentManager { project_root: dir.to_path_buf(), store_dir: dir.join("store"), worktrees_dir: dir.join("worktrees"), - models_file: dir.join("models.json"), + models_file: models_file.to_path_buf(), custom_agents_dir: None, + home_dir: None, }, tokio::runtime::Handle::current(), connector, ) } +#[tokio::test(flavor = "multi_thread")] +async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { + let tmp = tempfile::tempdir().unwrap(); + let (a, b, models) = (tmp.path().join("a"), tmp.path().join("b"), tmp.path().join("models.json")); + std::fs::create_dir_all(a.join(".claude/skills/triage")).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + std::fs::write(a.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n").unwrap(); + let names = |c: Vec| c.into_iter().map(|c| c.name).collect::>(); + + let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default())); + // Before any session: the skills on disk. + assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]); + let task = mgr_a + .create(NewAgentTask { + prompt: text("Fix the lexer"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr_a, &task.id, AgentStatus::NeedsInput).await; + assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["review", "triage"]); + assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]); + // Other agents get only what they offered. + assert!(mgr_a.commands("codex", None).await.unwrap().is_empty()); + mgr_a.shutdown(); + + // Another project shares the settings but not the commands. + let mgr_b = manager_at(&b, &models, Arc::new(FakeAgent::default())); + let claude = mgr_b.providers().into_iter().find(|p| p.id == "claude").unwrap(); + assert_eq!(claude.config.len(), 2); + assert!(claude.commands.is_empty()); + assert!(mgr_b.commands("claude", None).await.unwrap().is_empty()); + let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default())); + assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]); +} + async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { for _ in 0..500 { if mgr.task(id).unwrap().state.lock().task.status == status { @@ -299,6 +343,9 @@ fn prompts_become_acp_content_blocks() { blocks(&p, &[Some("fn a() {}".into())])[1], json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}}) ); + // A slash command goes through as typed: the agent runs it. + let skill = clean(text(" /triage dry-run\n")).unwrap(); + assert_eq!(blocks(&skill, &[]), [json!({"type": "text", "text": "/triage dry-run"})]); let old = upgrade( json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}), ); @@ -375,6 +422,7 @@ async fn real_claude_task_in_a_worktree() { worktrees_dir: tmp.path().join("wt"), models_file: tmp.path().join("models.json"), custom_agents_dir: None, + home_dir: None, }, tokio::runtime::Handle::current(), ); diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index ea34a02ed..f04372c5e 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,6 +1,6 @@ use crate::state::AppState; use ide_agent::{ - AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, + AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, }; use ide_core::{IdeResult, IpcPath}; use std::sync::Arc; @@ -27,6 +27,20 @@ pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult< Ok(state.project(window.label())?.agents.providers()) } +/// Slash commands to complete for `provider` in `task`'s directory, or in the project +/// before a task exists (skills and custom commands on disk included). +#[tauri::command] +#[specta::specta] +pub async fn agent_commands( + window: Window, + state: State<'_, AppState>, + provider: String, + task: Option, +) -> IdeResult> { + let project = state.project(window.label())?; + project.agents.commands(&provider, task.as_deref()).await +} + #[tauri::command] #[specta::specta] pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e55f4e988..09cec38cc 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -62,6 +62,7 @@ pub fn builder() -> tauri_specta::Builder { app::log_frontend, agent::agent_subscribe, agent::agent_providers, + agent::agent_commands, agent::agent_entries, agent::agent_create, agent::agent_prompt, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 6f564f5eb..4707ddc3d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -418,6 +418,7 @@ impl AppState { .join(&project_key), models_file: self.data_dir.join("agents").join("models.json"), custom_agents_dir: Some(self.config_dir.join("agents")), + home_dir: dirs::home_dir(), }, tauri::async_runtime::handle().inner().clone(), )); diff --git a/src/bindings.ts b/src/bindings.ts index 75150d95c..36fee4c4b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -56,6 +56,11 @@ export const commands = { /** Streams the project's agent events to this window; returns every task. */ agentSubscribe: (onEvent: Channel) => typedError(__TAURI_INVOKE("agent_subscribe", { onEvent })), agentProviders: () => typedError(__TAURI_INVOKE("agent_providers")), + /** + * Slash commands to complete for `provider` in `task`'s directory, or in the project + * before a task exists (skills and custom commands on disk included). + */ + agentCommands: (provider: string, task: string | null) => typedError(__TAURI_INVOKE("agent_commands", { provider, task })), agentEntries: (task: string) => typedError(__TAURI_INVOKE("agent_entries", { task })), agentCreate: (request: NewAgentTask) => typedError(__TAURI_INVOKE("agent_create", { request })), /** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */ diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index cf559e8b9..598e42109 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -9,7 +9,7 @@ import { Composer } from "./Composer"; import { canSend, emptyDraft } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { providerIcon } from "./model"; -import { HOME, startTask, updateDraft, useAgents } from "./store"; +import { HOME, NO_COMMANDS, startTask, updateDraft, useAgentCommands, useAgents } from "./store"; import styles from "./Home.module.css"; export function HomeEditor() { @@ -36,6 +36,7 @@ export function HomeEditor() { }, []); const provider = providers.find((p) => p.id === draft.provider) ?? providers[0]; + const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); // Installed agents first; the rest under a "Not installed" title, with the command they need. const installed = providers.filter((p) => p.installed); const missing = providers.filter((p) => !p.installed); @@ -70,7 +71,7 @@ export function HomeEditor() { placeholder="Describe a task for the agent. @ references a file, / runs a command" root={root} projectRoot={root} - commands={provider?.commands ?? []} + commands={agentCommands} history={history} config={provider?.config ?? []} onConfig={setOption} diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 4c7b78b5e..588484148 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -25,6 +25,7 @@ import { sendMessage, sendQueuedNow, setTaskConfig, + useAgentCommands, useAgents, } from "./store"; import styles from "./Session.module.css"; @@ -279,6 +280,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt const project = useProject((s) => s.project); const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft(); const [sending, setSending] = useState(false); + const agentCommands = useAgentCommands(task.provider, task.id, task.commands); const busy = isBusy(task.status); // ↑ recalls this conversation's messages, newest first. const history = useMemo( @@ -304,7 +306,7 @@ function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEnt placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"} root={task.cwd} projectRoot={project?.root ?? task.cwd} - commands={task.commands} + commands={agentCommands} history={history} config={task.config} onConfig={setOption} diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index 5c7d11608..0718f5e86 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -66,6 +66,21 @@ describe("agent composer", () => { expect(matchCommands(cmds, "rev").map((c) => c.name)).toEqual(["review", "pr-review"]); }); + it("matches commands by prefix, plugin name, substring, then letters in order", () => { + const cmds = ["triage", "verify", "issue-fix", "brooks-lint:brooks-review", "pr-review", "init"].map((name) => ({ + name, + description: "", + hint: null, + })); + const names = (q: string) => matchCommands(cmds, q).map((c) => c.name); + expect(names("trig")).toEqual(["triage"]); + expect(names("brooks-r")).toEqual(["brooks-lint:brooks-review"]); + expect(names("rev")).toEqual(["brooks-lint:brooks-review", "pr-review"]); + expect(names("if")).toEqual(["verify", "issue-fix"]); + expect(names("xyz")).toEqual([]); + expect(names("")).toHaveLength(cmds.length); + }); + it("sends only the files still mentioned", () => { const prompt = toPrompt({ text: " look at @src/a.rs:3-5 and @b.rs ", diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx new file mode 100644 index 000000000..2eda9eb06 --- /dev/null +++ b/src/features/agents/composerCommands.test.tsx @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { AgentCommand, AgentProvider } from "@/bindings"; + +const agentCommands = vi.fn(); +const agentCreate = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + agentCommands: (provider: string, task: string | null) => agentCommands(provider, task), + agentCreate: (request: unknown) => agentCreate(request), + vcsBranches: () => Promise.resolve({ status: "ok", data: [] }), + }, + }; +}); +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }), +})); + +import { HomeEditor } from "./HomeEditor"; +import { HOME, updateComposer, useAgents } from "./store"; +import { emptyDraft } from "./composerModel"; + +const command = (name: string, description: string): AgentCommand => ({ name, description, hint: null }); +const claude: AgentProvider = { + id: "claude", + name: "Claude Code", + command: "npx", + installed: true, + config: [], + // Offered by the agent's last session in this project. + commands: [command("compact", "Free up context")], +}; + +beforeEach(() => { + agentCommands.mockReset(); + agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } }); + useAgents.setState({ + providers: [claude], + draft: { provider: "claude", config: {}, isolation: "local", base: null }, + }); + updateComposer(HOME, emptyDraft()); +}); + +describe("New Task composer commands", () => { + it("completes the project's skills merged with the agent's commands", async () => { + agentCommands.mockResolvedValue({ + status: "ok", + data: [ + command("compact", "Free up context"), + command("triage", "One pass over open issues and PRs"), + command("issue-fix", "Fix an issue"), + ], + }); + render(); + await waitFor(() => expect(agentCommands).toHaveBeenCalledWith("claude", null)); + const box = screen.getByRole("textbox"); + fireEvent.change(box, { target: { value: "/trig" } }); + const list = await screen.findByRole("listbox", { name: "Commands" }); + const options = within(list).getAllByRole("option"); + expect(options.map((o) => o.textContent)).toEqual(["/triageOne pass over open issues and PRs"]); + + fireEvent.keyDown(box, { key: "Enter" }); + expect(useAgents.getState().composers.get(HOME)?.text).toBe("/triage "); + + // Sent as typed, so the agent runs the skill. + fireEvent.change(box, { target: { value: "/triage dry-run" } }); + fireEvent.keyDown(box, { key: "Enter" }); + await waitFor(() => expect(agentCreate).toHaveBeenCalled()); + expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" }); + }); + + it("offers nothing the project does not have", async () => { + agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] }); + render(); + await waitFor(() => expect(agentCommands).toHaveBeenCalled()); + const box = screen.getByRole("textbox"); + fireEvent.change(box, { target: { value: "/trig" } }); + expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull(); + fireEvent.change(box, { target: { value: "/com" } }); + expect(await screen.findByRole("option")).toHaveTextContent("/compact"); + }); +}); diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index ca21d89cd..0e02294a4 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -40,11 +40,29 @@ export function applyCompletion(text: string, c: Completion, insert: string): { return { text: next, caret: c.from + insert.length + 1 }; } -/** Commands matching `query`, prefix matches first. */ +/** + * Commands matching `query`: prefix matches first (a plugin's "plugin:name" also by its + * name), then substrings, then names holding its letters in order ("trig" → "triage"). + */ export function matchCommands(commands: readonly AgentCommand[], query: string): AgentCommand[] { const q = query.toLowerCase(); - const hits = commands.filter((c) => c.name.toLowerCase().includes(q)); - return hits.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q))); + const rank = (name: string) => { + const n = name.toLowerCase(); + if (n.startsWith(q) || n.slice(n.lastIndexOf(":") + 1).startsWith(q)) return 0; + if (n.includes(q)) return 1; + return inOrder(n, q) ? 2 : -1; + }; + return commands + .map((command) => ({ command, rank: rank(command.name) })) + .filter((m) => m.rank >= 0) + .sort((a, b) => a.rank - b.rank) + .map((m) => m.command); +} + +function inOrder(text: string, query: string): boolean { + let i = 0; + for (const ch of text) if (ch === query[i]) i++; + return i >= query.length; } /** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */ diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index b064a7713..9606211ba 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -1,7 +1,16 @@ /** Agent tasks of the open project, mirrored from `ide-agent` through `agentSubscribe`. */ import { Channel } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; import { create } from "zustand"; -import type { AgentChange, AgentEntry, AgentEvent, AgentProvider, AgentTask, Isolation } from "@/bindings"; +import type { + AgentChange, + AgentCommand, + AgentEntry, + AgentEvent, + AgentProvider, + AgentTask, + Isolation, +} from "@/bindings"; import { call, commands, isTauri } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel"; @@ -212,6 +221,34 @@ async function refreshProviders() { if (providers) useAgents.setState({ providers }); } +export const NO_COMMANDS: readonly AgentCommand[] = []; + +/** + * Slash commands to complete: what the agent offered (`offered`; fetched again when it + * changes) plus, for Claude Code, the skills and custom commands on disk. Without `task`, + * those of the project. + */ +export function useAgentCommands( + provider: string | undefined, + task: string | null, + offered: readonly AgentCommand[], +): readonly AgentCommand[] { + const [found, setFound] = useState<{ for: unknown[]; list: readonly AgentCommand[] } | null>(null); + useEffect(() => { + if (!provider || !isTauri()) return; + let alive = true; + call(commands.agentCommands(provider, task)) + .then((list) => alive && setFound({ for: [provider, task, offered], list })) + .catch(() => {}); + return () => { + alive = false; + }; + }, [provider, task, offered]); + // Until the list for these arguments arrives, what the agent offered. + const current = found && found.for[0] === provider && found.for[1] === task && found.for[2] === offered; + return current ? found.list : offered; +} + export function openTask(task: string) { // The title rides along so a restored tab has it before the tasks load. const title = useAgents.getState().tasks.get(task)?.title; From 6de6b33449c7f4d5ddf669ffb4c6d0d25e2607cd Mon Sep 17 00:00:00 2001 From: itning Date: Tue, 27 Oct 2026 21:02:10 +0800 Subject: [PATCH 5/6] feat(vcs): Double click switches the log and Cmd/Ctrl+C copies branches in Git Log (#101) In Git Log's Branches pane a single click filtered the log and a double click checked the branch out. Now a click only selects; double click or Enter shows the log of that ref (also the current branch and HEAD), and Enter on a multi-selection filters by all selected branches in tree order. Checkout stays in the context menu. The pane keeps its own selection and follows filter changes made elsewhere. Cmd/Ctrl+C copies the selected branches as full refs, one per line (HEAD as `HEAD`), leaving copy in the search field and elsewhere untouched. Co-authored-by: itning <19759891+itning@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/features/vcs/GitLog.tsx | 59 ++++++-- .../vcs/__tests__/logBranches.test.tsx | 141 ++++++++++++++++++ 2 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 src/features/vcs/__tests__/logBranches.test.tsx diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index a5472dfff..87a485c77 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -30,7 +30,9 @@ import { showContextMenu, type MenuItem, T, + copyText, } from "@/ui"; +import { hasPrimaryModifier } from "@/platform/keymap"; import { commitFileDiffData, openDiff, revisionChangeDiffData, withChain } from "./diffInput"; import { collapseLinear, computeGraph, hideLongEdges, laneColor, reachableFrom, type GraphRow } from "./graph"; import { @@ -54,7 +56,7 @@ import { refLabels, useLogOptions, type LogOptions } from "./logOptions"; import { KIND_COLOR, baseName, formatTime, relTo, type EffectiveKind } from "./model"; import { clearConsole, useVcsConsole } from "./ops"; import { currentRepo, loadBranches, useVcs } from "./store"; -import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps"; +import { loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps"; import { branchMenu } from "./branchMenu"; import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches"; import { chooseDateRange, chooseMany, promptText } from "./dialogs"; @@ -167,7 +169,7 @@ function branchDetail(n: BranchTreeNode): string | undefined { return [b.current ? "current" : "", sync].filter(Boolean).join(" ") || undefined; } -function BranchesPanel() { +export function BranchesPanel() { const branches = useVcs((s) => s.branches); const root = useVcs((s) => s.root); const branchesLoaded = useVcs((s) => s.branchesLoaded); @@ -180,12 +182,43 @@ function BranchesPanel() { const roots = useMemo(() => filterBranchTree(allRoots, query), [allRoots, query]); // While searching every match is shown expanded. const searchExpanded = useMemo(() => (query.trim() ? new Set(branchGroupKeys(roots)) : undefined), [roots, query]); - const selected = useMemo( - () => new Set(filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`))), - [filterBranches], - ); + // Highlighted rows: a click only selects (IDEA), and only a double click or + // Enter switches the log, so the selection is the pane's own state. It still + // follows the filter when something else changes it (Show Log, the filter bar). + const filterKeys = useMemo(() => filterBranches.map((b) => (b === "HEAD" ? "head" : `b:${b}`)), [filterBranches]); + const filterKey = filterKeys.join("\n"); + const [shownKey, setShownKey] = useState(filterKey); + const [selected, setSelected] = useState>(() => new Set(filterKeys)); + if (shownKey !== filterKey) { + // The filter changed elsewhere (Show Log, the filter bar): highlight it. + setShownKey(filterKey); + setSelected(new Set(filterKeys)); + } + // Branches in tree order, for Copy: full refs, so the folder path comes along + // (`feat/x`, `origin/main`). + const refs = useMemo(() => { + const out: { key: string; ref: string }[] = []; + const walk = (nodes: readonly BranchTreeNode[]) => { + for (const n of nodes) { + if (n.ref) out.push({ key: n.key, ref: n.ref }); + walk(n.children ?? []); + } + }; + walk(allRoots); + return out; + }, [allRoots]); + const onKeyDownCapture = (e: KeyboardEvent) => { + if ((e.key !== "c" && e.key !== "C") || !hasPrimaryModifier(e) || e.altKey || e.shiftKey) return; + // The branch search keeps the browser's own copy. + if ((e.target as HTMLElement).closest("input, textarea")) return; + const names = refs.filter((r) => selected.has(r.key)).map((r) => r.ref); + if (!names.length) return; + e.preventDefault(); + e.stopPropagation(); + void copyText(names.join("\n")); + }; return ( -
+
{ - // Several selected branches filter the log by all of them. - const refs = [...sel].flatMap((k) => (k === "head" ? ["HEAD"] : k.startsWith("b:") ? [k.slice(2)] : [])); - if (refs.length) setLogFilters({ branches: refs }); + onSelectionChange={(sel) => setSelected(sel)} + onActivate={(n) => { + // IDEA: double click (or Enter) switches the log to that ref, or to every + // selected one when it is part of a multi-selection; checking out stays in the context menu. + if (!n.ref) return; + const sel = selected.has(n.key) ? refs.filter((r) => selected.has(r.key)).map((r) => r.ref) : [n.ref]; + setLogFilters({ branches: sel }); }} - onActivate={(n) => n.branch && !n.branch.current && void checkoutBranch(n.branch.name)} onContextMenu={(n, _s, e) => { const items = n?.branch ? branchMenu(n.branch, current) : []; if (!items.length) return; diff --git a/src/features/vcs/__tests__/logBranches.test.tsx b/src/features/vcs/__tests__/logBranches.test.tsx new file mode 100644 index 000000000..676190c4a --- /dev/null +++ b/src/features/vcs/__tests__/logBranches.test.tsx @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { Branch, BranchInfo } from "@/bindings"; +import { isMacHost } from "@/platform/keymap"; + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commands: { + ...real.commands, + vcsLog: () => Promise.resolve({ status: "ok", data: [] }), + vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }), + }, + }; +}); + +import { BranchesPanel } from "../GitLog"; +import { emptyFilters, useGitLog } from "../logStore"; +import { useVcs } from "../store"; + +const b = (name: string, opts: Partial = {}): Branch => ({ + name, + remote: false, + current: false, + upstream: null, + ahead: 0, + behind: 0, + lastCommitMs: 0, + commit: "", + ...opts, +}); + +const branches = [b("main", { current: true, commit: "1" }), b("feature", { commit: "2" })]; + +const head: BranchInfo = { + head: "main", + headCommit: "1", + upstream: null, + ahead: 0, + behind: 0, + operation: null, +}; + +const writeText = vi.fn().mockResolvedValue(undefined); +/** The copy modifier on this host: ⌘ on macOS, Ctrl elsewhere. */ +const cmd = () => (isMacHost() ? { metaKey: true } : { ctrlKey: true }); + +beforeEach(() => { + writeText.mockClear(); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + useVcs.setState({ root: "/p", repo: "/p", branches, branchesLoaded: true, branch: head }); + useGitLog.setState({ filters: emptyFilters() }); +}); + +describe("Git Log branches pane", () => { + it("one click only selects the branch", () => { + render(); + fireEvent.mouseDown(screen.getByText("feature")); + expect(useGitLog.getState().filters.branches).toEqual([]); + expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true"); + }); + + it("double click switches the log to that branch", () => { + render(); + fireEvent.doubleClick(screen.getByText("feature")); + expect(useGitLog.getState().filters.branches).toEqual(["feature"]); + }); + + it("double click on the current branch switches to its log too", () => { + render(); + fireEvent.doubleClick(screen.getByText("main")); + expect(useGitLog.getState().filters.branches).toEqual(["main"]); + }); + + it("double click on HEAD shows HEAD's log", () => { + render(); + fireEvent.doubleClick(screen.getByText("HEAD (Current Branch)")); + expect(useGitLog.getState().filters.branches).toEqual(["HEAD"]); + }); + + it("Enter switches the log to every selected branch, in tree order", () => { + useVcs.setState({ branches: [b("main", { current: true }), b("beta"), b("alpha")] }); + render(); + fireEvent.mouseDown(screen.getByText("beta")); + fireEvent.mouseDown(screen.getByText("alpha"), cmd()); + expect(useGitLog.getState().filters.branches).toEqual([]); + fireEvent.keyDown(screen.getByRole("tree", { name: "Branches" }), { key: "Enter" }); + expect(useGitLog.getState().filters.branches).toEqual(["alpha", "beta"]); + }); + + it("follows the log filter when it changes elsewhere", () => { + render(); + fireEvent.mouseDown(screen.getByText("main")); + act(() => useGitLog.setState({ filters: { ...emptyFilters(), branches: ["feature"] } })); + expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("main").closest("[data-row]")).toHaveAttribute("aria-selected", "false"); + }); + + it("copies the selected branch name", async () => { + render(); + fireEvent.mouseDown(screen.getByText("feature")); + fireEvent.keyDown(screen.getByText("feature"), { key: "c", ...cmd() }); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("feature")); + }); + + it("copies HEAD as HEAD", async () => { + render(); + fireEvent.mouseDown(screen.getByText("HEAD (Current Branch)")); + fireEvent.keyDown(screen.getByText("HEAD (Current Branch)"), { key: "c", ...cmd() }); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("HEAD")); + }); + + it("copies the folder path along with the branch name", async () => { + useVcs.setState({ + branches: [b("feat/idea-parity", { current: true, commit: "1" })], + branch: { ...head, head: "feat/idea-parity" }, + }); + render(); + fireEvent.mouseDown(screen.getByText("idea-parity")); + fireEvent.keyDown(screen.getByText("idea-parity"), { key: "c", ...cmd() }); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("feat/idea-parity")); + }); + + it("copies every selected branch, one per line, in tree order", async () => { + useVcs.setState({ branches: [b("main", { current: true }), b("beta"), b("alpha")] }); + render(); + fireEvent.mouseDown(screen.getByText("beta"), cmd()); + fireEvent.mouseDown(screen.getByText("alpha"), cmd()); + fireEvent.keyDown(screen.getByText("alpha"), { key: "c", ...cmd() }); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("alpha\nbeta")); + }); + + it("leaves Cmd/Ctrl+C in the branch search field alone", async () => { + render(); + fireEvent.mouseDown(screen.getByText("feature")); + fireEvent.keyDown(screen.getByLabelText("Filter branches"), { key: "c", ...cmd() }); + await Promise.resolve(); + expect(writeText).not.toHaveBeenCalled(); + }); +}); From 2fe6b7b87bc936c96b22e45507cbfc3606fe970b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:02:54 +0800 Subject: [PATCH 6/6] feat(editor): Implemented / overridden gutter markers (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gutter markers for type hierarchies: ↓ "Is implemented / Has subclasses / Is overridden" on types and methods with implementations, ↑ "Implements / Overrides method in X" on methods that implement or override a supertype method. One target navigates, several open the Choose Implementation popup. Settings › Editor › General › Gutter Icons gets two toggles. Markers come from the language server: a new batched `lsp_implementations` command (up to 4 concurrent, cancellable, sharing the goto query path) for ↓, and the Super Method lookup for ↑, which now prefers the overload with matching parameters (also fixes ⌘U on overloads). Only declarations near the viewport are queried, debounced, cached per document version and cancelled when superseded. Works for any server with implementation / type hierarchy support; servers without type hierarchy show ↓ only. Fixes #99 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-lsp/src/lib.rs | 66 ++- crates/ide-lsp/tests/mock_server.rs | 33 ++ src-tauri/src/commands/lsp.rs | 19 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 6 + src/editor/core/cm/prefs.ts | 2 + src/editor/core/cm/theme.ts | 3 + src/editor/lsp/index.ts | 4 +- src/editor/lsp/navigation.ts | 2 +- src/editor/navigation/LineMarkers.module.css | 19 + src/editor/navigation/implementationGutter.ts | 392 ++++++++++++++++++ src/editor/navigation/index.ts | 2 + src/editor/navigation/lineMarkers.test.ts | 353 ++++++++++++++++ src/editor/navigation/lineMarkers.ts | 222 ++++++++++ src/editor/navigation/structureNav.test.ts | 21 + src/editor/navigation/structureNav.ts | 43 +- .../settings/pages/editor/general.tsx | 12 + src/platform/locales/text/zh.json | 14 + src/ui/expui/gutter/implementedMethod.svg | 7 + .../expui/gutter/implementedMethod_dark.svg | 7 + src/ui/expui/gutter/implementingMethod.svg | 7 + .../expui/gutter/implementingMethod_dark.svg | 7 + src/ui/expui/gutter/overridenMethod.svg | 7 + src/ui/expui/gutter/overridenMethod_dark.svg | 7 + src/ui/expui/gutter/overridingMethod.svg | 7 + src/ui/expui/gutter/overridingMethod_dark.svg | 7 + src/ui/icons.tsx | 4 + 27 files changed, 1252 insertions(+), 22 deletions(-) create mode 100644 src/editor/navigation/LineMarkers.module.css create mode 100644 src/editor/navigation/implementationGutter.ts create mode 100644 src/editor/navigation/lineMarkers.test.ts create mode 100644 src/editor/navigation/lineMarkers.ts create mode 100644 src/ui/expui/gutter/implementedMethod.svg create mode 100644 src/ui/expui/gutter/implementedMethod_dark.svg create mode 100644 src/ui/expui/gutter/implementingMethod.svg create mode 100644 src/ui/expui/gutter/implementingMethod_dark.svg create mode 100644 src/ui/expui/gutter/overridenMethod.svg create mode 100644 src/ui/expui/gutter/overridenMethod_dark.svg create mode 100644 src/ui/expui/gutter/overridingMethod.svg create mode 100644 src/ui/expui/gutter/overridingMethod_dark.svg diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 0765e0f69..9a0489acf 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -29,6 +29,7 @@ pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path}; use caps::{ServerCaps, SyncKind}; use convert::{Conv, lsp_range}; +use futures::StreamExt; use futures::future::join_all; use ide_core::error::ErrorKind; use ide_core::{ @@ -228,6 +229,18 @@ impl Outcome { } } +/// Implementation requests of one line-marker pass in flight at once. +const IMPLEMENTATION_CONCURRENCY: usize = 4; + +fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) { + match kind { + GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), + GotoKind::Declaration => ("textDocument/declaration", "declarationProvider", "go to declaration"), + GotoKind::TypeDefinition => ("textDocument/typeDefinition", "typeDefinitionProvider", "go to type definition"), + GotoKind::Implementation => ("textDocument/implementation", "implementationProvider", "go to implementation"), + } +} + impl LspManager { pub fn new(project_root: PathBuf, languages: Arc, sink: SharedSink) -> Arc { Self::with_launcher(project_root, languages, sink, Arc::new(ProcessLauncher)) @@ -925,25 +938,54 @@ impl LspManager { } pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { - let (method, cap, feature) = match kind { - GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), - GotoKind::Declaration => ("textDocument/declaration", "declarationProvider", "go to declaration"), - GotoKind::TypeDefinition => { - ("textDocument/typeDefinition", "typeDefinitionProvider", "go to type definition") - } - GotoKind::Implementation => { - ("textDocument/implementation", "implementationProvider", "go to implementation") - } - }; + let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; + self.goto_in(&snap, &targets, method, pos, None).await + } + + /// Implementations of the symbols at each of `positions` (gutter line + /// markers), one list per position in order. A position whose request + /// fails gets an empty list; when every one fails, the first error is + /// returned (an unavailable server is not "no implementations"). + pub async fn implementations( + &self, + doc: DocId, + positions: &[Pos], + cancel: CancellationToken, + ) -> IdeResult>> { + let (method, cap, feature) = goto_method(GotoKind::Implementation); + let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; + let requests = positions.iter().copied().map(|pos| { + let (snap, targets, cancel) = (&snap, &targets, &cancel); + async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await } + }); + let results: Vec>> = + futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if !results.is_empty() && results.iter().all(Result::is_err) { + return Err(results.into_iter().find_map(Result::err).expect("all failed")); + } + Ok(results.into_iter().map(Result::unwrap_or_default).collect()) + } + + async fn goto_in( + &self, + snap: &DocSnap, + targets: &[Target], + method: &str, + pos: Pos, + cancel: Option<&CancellationToken>, + ) -> IdeResult> { let mut out = Outcome::new(); - for t in &targets { + for t in targets { match self .call::<_, Option>( t, method, snap.pos_params(t, pos), - None, + cancel, DEFAULT_TIMEOUT, ) .await diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 0b26e0840..3d414f773 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -792,6 +792,39 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok()); } +#[tokio::test(flavor = "multi_thread")] +async fn implementations_per_position() { + let caps = json!({ "textDocumentSync": 1, "implementationProvider": true }); + let h = harness(&[("mock", caps)]); + let (doc, path) = h.open("a.mk", "iface A {}\nimpl B {}\nimpl C {}\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + let uri = uri_of(&path); + *s.responder.lock() = Some(Arc::new(move |_, method, params| { + if method != "textDocument/implementation" { + return None; + } + // Line 0 is implemented on lines 1 and 2; nothing else is. + Some(match params["position"]["line"].as_u64() { + Some(0) => json!([ + { "uri": uri, "range": {"start":{"line":1,"character":5},"end":{"line":1,"character":6}} }, + { "uri": uri, "range": {"start":{"line":2,"character":5},"end":{"line":2,"character":6}} }, + ]), + _ => json!([]), + }) + })); + let found = h.mgr.implementations(doc, &[Pos::new(0, 6), Pos::new(1, 5)], Default::default()).await.unwrap(); + assert_eq!(found.len(), 2); + assert_eq!(found[0].iter().map(|l| l.range.start.line).collect::>(), vec![1, 2]); + assert!(found[1].is_empty()); + assert!(h.mgr.implementations(doc, &[], Default::default()).await.unwrap().is_empty()); + + let cancel = ide_core::CancellationToken::new(); + cancel.cancel(); + let err = h.mgr.implementations(doc, &[Pos::new(0, 6)], cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); +} + #[tokio::test(flavor = "multi_thread")] async fn navigation_rename_format_and_decorations() { let h = harness(&[("mock", incremental_caps())]); diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 352e4c903..a4448ccee 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -94,6 +94,25 @@ pub async fn lsp_goto( project!(window, state).lsp.goto(doc, pos, kind).await } +/// Implementations of the symbols at `positions`, one list per position (the +/// editor's "is implemented / overridden" gutter markers). Cancel with +/// `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn lsp_implementations( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + positions: Vec, +) -> IdeResult>> { + let lsp = project!(window, state).lsp.clone(); + let cancel = state.begin_request(request); + let result = lsp.implementations(doc, &positions, cancel).await; + state.end_request(request); + result +} + /// Contents of a virtual document (library class, decompiled code) that a /// language server returned a non-`file:` URI for. Cached per project. #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 09cec38cc..f99d3e3d3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -144,6 +144,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_hover, lsp::lsp_signature_help, lsp::lsp_goto, + lsp::lsp_implementations, lsp::lsp_virtual_document, lsp::lsp_references, lsp::lsp_document_highlights, diff --git a/src/bindings.ts b/src/bindings.ts index 36fee4c4b..7ce47b757 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -287,6 +287,12 @@ export const commands = { activeParameter: number, } | null, IdeError>(__TAURI_INVOKE("lsp_signature_help", { doc, pos })), lspGoto: (doc: DocId, pos: Pos, kind: GotoKind) => typedError(__TAURI_INVOKE("lsp_goto", { doc, pos, kind })), + /** + * Implementations of the symbols at `positions`, one list per position (the + * editor's "is implemented / overridden" gutter markers). Cancel with + * `cancel_request(request)`. + */ + lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })), /** * Contents of a virtual document (library class, decompiled code) that a * language server returned a non-`file:` URI for. Cached per project. diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 95c5cf61d..0e794b371 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -402,6 +402,8 @@ const noSurround = EditorView.inputHandler.of((view, from, to, text) => { export const GUTTER_ICON_GROUPS = [ { id: "run", gutterClass: "cm-openidea-gutter-run" }, { id: "bookmarks", gutterClass: "cm-openidea-bookmarks" }, + { id: "inheritors", gutterClass: "cm-openidea-gutter-lineMarkers" }, + { id: "supers", gutterClass: "cm-openidea-gutter-lineMarkers" }, ] as const; export interface PrefsOptions { diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index f053ee393..ff746dda0 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -57,6 +57,9 @@ export const openideaTheme = EditorView.theme({ boxShadow: "inset 0 1px 0 var(--border-method-separator, var(--border))", }, "&.cm-openidea-hideGutter-run .cm-openidea-gutter-run, &.cm-openidea-hideGutter-bookmarks .cm-openidea-bookmarks": { display: "none" }, + // Inheritance markers share one gutter: each group hides its own icons. + "&.cm-openidea-hideGutter-inheritors :is(.cm-openidea-lineMarker-implemented, .cm-openidea-lineMarker-overridden), &.cm-openidea-hideGutter-supers :is(.cm-openidea-lineMarker-implementing, .cm-openidea-lineMarker-overriding)": + { display: "none" }, // Distraction Free Mode: no gutter; the text column (n chars + line padding) is centred. "&.cm-openidea-distractionFree": { "--openidea-df-width": "calc(var(--openidea-df-columns, 80) * 1ch + 22px)" }, "&.cm-openidea-distractionFree .cm-gutters": { display: "none" }, diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 7ab85eda2..34d5f1e7c 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -31,7 +31,7 @@ import { spellingExtension } from "./spelling"; import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups"; import { DOCUMENTATION_WINDOW } from "./docs"; import { prefetchMarkdown } from "./markdown"; -import { registerNavigationActions } from "@/editor/navigation"; +import { implementationGutter, registerNavigationActions } from "@/editor/navigation"; export function lspExtensions(ctx: EditorDocContext): Extension[] { return [ @@ -61,6 +61,8 @@ export function activate(): () => void { installDiagnosticsEvents(), installAvailabilityEvents(), registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }), + // After the run icons (order 55), as their own gutter column. + registerEditorContribution({ id: "lsp.lineMarkers", order: 56, extensions: implementationGutter }), // After the templates (order 90): live template / postfix expansion keeps ⇥ first. registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }), registerOverlay({ id: "editor-lsp.popups", component: LspPopups }), diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 973893e1e..2136f9631 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -121,7 +121,7 @@ export function chooserTitle(kind: GotoKind, name: string | null, count: number, } /** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */ -function pinToFind(symbol: string, locations: readonly Location[]) { +export function pinToFind(symbol: string, locations: readonly Location[]) { return { label: "Open in Find Tool Window", run: () => void executeAction("find.showUsageResults", { symbol, locations }), diff --git a/src/editor/navigation/LineMarkers.module.css b/src/editor/navigation/LineMarkers.module.css new file mode 100644 index 000000000..0890c9e41 --- /dev/null +++ b/src/editor/navigation/LineMarkers.module.css @@ -0,0 +1,19 @@ +/* Editor gutter inheritance icons (implemented / overridden / implementing / overriding). */ +.gutter :global(.cm-gutterElement) { + display: flex; + align-items: center; + justify-content: center; + gap: 1px; +} +.marker { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-small); + cursor: pointer; +} +.marker:hover { + background: var(--bg-hover); +} diff --git a/src/editor/navigation/implementationGutter.ts b/src/editor/navigation/implementationGutter.ts new file mode 100644 index 000000000..b21bec478 --- /dev/null +++ b/src/editor/navigation/implementationGutter.ts @@ -0,0 +1,392 @@ +/** + * Gutter icons for inheritance: "is implemented / overridden" (↓) on types and + * methods with implementations or overriders, "implements / overrides" (↑) on + * methods with a super method. Clicking one navigates to its single target or + * opens the targets chooser. + * + * Generic over language servers: ↓ comes from implementation requests (one + * cancellable batch per pass), ↑ from the type hierarchy and the supertypes' + * document symbols (as Super Method, ⌘U). Only declarations in and near the + * viewport are asked, in one debounced pass after edits / scrolling; results + * are cached per document text, and markers follow their lines in between. + */ +import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp"; +import { serverBackoff } from "@/editor/lsp/availability"; +import { readManyFileLines, withDocument } from "@/editor/lsp/files"; +import { locationItems, navigateTo, pinToFind } from "@/editor/lsp/navigation"; +import { openPopup } from "@/editor/lsp/popupStore"; +import { + commands, + isCancelled, + nextRequestId, + onIdeEvent, + type HierarchyItem, + type Location, + type OutlineItem, + type Pos, + type RequestId, +} from "@/platform/ipc"; +import { editorPrefs, type EditorExtras } from "@/platform/editorSettings"; +import { useProject } from "@/platform/project"; +import { iconMarkup } from "@/ui"; +import { + MARKER_ICONS, + candidateKey, + inheritorsMarker, + markerCandidates, + supersMarker, + withoutSelf, + type LineMarker, + type MarkerCandidate, + type MarkerGroup, +} from "./lineMarkers"; +import { superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav"; +import styles from "./LineMarkers.module.css"; + +/** Lines above / below the viewport that are covered too, so scrolling feels instant. */ +const MARGIN_LINES = 60; +const MAX_PER_PASS = 60; +const DEBOUNCE_MS = 500; + +/** Settings › Editor › General › Gutter Icons groups that are on. */ +export function enabledGroups(prefs: Pick): Set { + if (!prefs.showGutterIcons) return new Set(); + return new Set((["inheritors", "supers"] as const).filter((g) => !prefs.gutterIconsDisabled.includes(g))); +} + +export interface MarkerDeps { + /** Implementations of the symbols at `positions`, one list each (rejects when the server cannot answer). */ + implementations: (positions: Pos[]) => Promise; + supers: SuperDeps; + readFiles: (paths: readonly string[]) => Promise>; +} + +interface CacheEntry { + inheritors?: LineMarker | null; + supers?: LineMarker | null; +} + +/** Results for one document text; `unsupported` remembers a server that cannot answer a group. */ +export interface MarkerCache { + entries: Map; + unsupported: Set; +} + +export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() }); + +/** + * Asks the server about the `candidates` not in `cache` yet (only the enabled + * `groups`) and stores the answers. Cancellation propagates; an unavailable + * server marks its group unsupported for this cache. + */ +export async function fillMarkers( + path: string, + candidates: readonly MarkerCandidate[], + groups: ReadonlySet, + deps: MarkerDeps, + cache: MarkerCache, +): Promise { + const entry = (c: MarkerCandidate) => { + const key = candidateKey(c); + let e = cache.entries.get(key); + if (!e) cache.entries.set(key, (e = {})); + return e; + }; + const down = + groups.has("inheritors") && !cache.unsupported.has("inheritors") + ? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined) + : []; + const up = + groups.has("supers") && !cache.unsupported.has("supers") + ? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined) + : []; + + let implementations: Location[][] = []; + if (down.length) { + try { + implementations = (await deps.implementations(down.map((c) => ({ line: c.line, col: c.col })))).map((locs, i) => + withoutSelf(path, down[i]!, locs), + ); + } catch (e) { + if (isCancelled(e)) throw e; + if (isUnavailable(e)) cache.unsupported.add("inheritors"); + down.length = 0; + } + } + const supers: SuperMethod[][] = []; + for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers)); + + const files = await deps.readFiles([ + ...implementations.flat().map((l) => l.path), + ...supers.flat().map((s) => s.location.path), + ]); + down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files))); + up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files))); +} + +/** Markers known for `candidates`, in document order (↓ before ↑ on a line). */ +export function cachedMarkers( + candidates: readonly MarkerCandidate[], + groups: ReadonlySet, + cache: MarkerCache, +): LineMarker[] { + const out: LineMarker[] = []; + for (const c of candidates) { + const e = cache.entries.get(candidateKey(c)); + if (!e) continue; + if (groups.has("inheritors") && e.inheritors) out.push(e.inheritors); + if (groups.has("supers") && e.supers) out.push(e.supers); + } + return out; +} + +/** Opens the single target, or the chooser over several. */ +export async function openMarkerTargets(marker: LineMarker, anchor: { x: number; y: number }) { + if (marker.targets.length === 1) { + await navigateTo(marker.targets[0]!); + return; + } + openPopup({ + kind: "list", + anchor, + title: marker.chooserTitle, + items: await locationItems(marker.targets), + pin: pinToFind(marker.name, marker.targets), + }); +} + +// --------------------------------------------------------------------------- +// CodeMirror + +interface PlacedMarker { + marker: LineMarker; + /** Line start offset in the current state. */ + pos: number; +} + +const setMarkers = StateEffect.define(); + +const markersField = StateField.define({ + create: () => [], + update(value, tr) { + for (const e of tr.effects) if (e.is(setMarkers)) return e.value; + if (!tr.docChanged || !value.length) return value; + return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from })); + }, +}); + +function place(doc: Text, markers: readonly LineMarker[]): PlacedMarker[] { + return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from })); +} + +const sameMarkers = (a: readonly PlacedMarker[], b: readonly PlacedMarker[]) => + a.length === b.length && + a.every( + (m, i) => m.pos === b[i]!.pos && m.marker.kind === b[i]!.marker.kind && m.marker.tooltip === b[i]!.marker.tooltip, + ); + +class InheritanceMarker extends GutterMarker { + constructor(readonly marker: LineMarker) { + super(); + } + override eq(other: GutterMarker): boolean { + return ( + other instanceof InheritanceMarker && + other.marker.kind === this.marker.kind && + other.marker.tooltip === this.marker.tooltip + ); + } + override toDOM(): Node { + const el = document.createElement("div"); + el.className = `${styles.marker} cm-openidea-lineMarker-${this.marker.kind}`; + el.dataset.kind = this.marker.kind; + el.title = this.marker.tooltip; + el.setAttribute("role", "button"); + el.setAttribute("aria-label", this.marker.tooltip); + el.innerHTML = iconMarkup(MARKER_ICONS[this.marker.kind], 14) ?? ""; + return el; + } +} + +const gutterMarkers = new WeakMap>(); + +function markerSet(placed: readonly PlacedMarker[]): RangeSet { + let set = gutterMarkers.get(placed); + if (!set) { + set = RangeSet.of( + placed.map((m) => new InheritanceMarker(m.marker).range(m.pos)), + true, + ); + gutterMarkers.set(placed, set); + } + return set; +} + +class MarkersPlugin { + private refresh = debounce(() => void this.run(), DEBOUNCE_MS); + private cache = emptyCache(); + private cacheDoc: Text | null = null; + private outline: { doc: Text; items: OutlineItem[]; candidates: MarkerCandidate[] } | null = null; + private generation = 0; + private request: RequestId | null = null; + private offSettings: () => void; + private offServers: () => void; + + constructor(private view: EditorView) { + this.refresh(); + this.offSettings = useProject.subscribe((s, prev) => { + if (s.settings !== prev.settings) this.refresh(); + }); + // Answers given while a server was starting or indexing may be incomplete. + this.offServers = onIdeEvent("languageServer", (e) => { + if (e.status !== "ready") return; + this.invalidate(); + this.refresh(); + }); + } + + update(u: ViewUpdate) { + if (u.docChanged || u.viewportChanged) this.refresh(); + // Other files may have changed meanwhile (a new implementation). + else if (u.focusChanged && u.view.hasFocus) { + this.invalidate(); + this.refresh(); + } + } + + private invalidate() { + this.cacheDoc = null; + this.outline = null; + } + + private cancelRequest() { + if (this.request === null) return; + void commands.cancelRequest(this.request).catch(() => {}); + this.request = null; + } + + private show(placed: PlacedMarker[]) { + if (!sameMarkers(this.view.state.field(markersField), placed)) + this.view.dispatch({ effects: setMarkers.of(placed) }); + } + + private async run() { + const view = this.view; + if (!view.dom.isConnected) return; + const ctx = docOf(view); + if (!ctx) return; + const gen = ++this.generation; + this.cancelRequest(); + const groups = enabledGroups(editorPrefs()); + if (!groups.size || serverBackoff.active(ctx.doc)) { + if (!groups.size) this.show([]); + return; + } + const text = view.state.doc; + const stale = () => gen !== this.generation || view.state.doc !== text; + if (this.cacheDoc !== text) { + this.cache = emptyCache(); + this.cacheDoc = text; + } + try { + if (this.outline?.doc !== text) { + const items = await lspRequest(() => commands.docOutline(ctx.doc)); + if (stale()) return; + this.outline = { + doc: text, + items, + candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")), + }; + } + const { candidates, items } = this.outline; + const { from, to } = view.viewport; + const first = text.lineAt(from).number - 1 - MARGIN_LINES; + const last = text.lineAt(to).number - 1 + MARGIN_LINES; + const visible = candidates.filter((c) => c.line >= first && c.line <= last).slice(0, MAX_PER_PASS); + const request = (this.request = nextRequestId()); + await fillMarkers(ctx.path, visible, groups, markerDeps(ctx, items, request, this.cache), this.cache); + if (this.request === request) this.request = null; + if (stale()) return; + this.show(place(text, cachedMarkers(candidates, groups, this.cache))); + } catch (e) { + if (isUnavailable(e)) serverBackoff.mark(ctx.doc); + else if (!isCancelled(e)) console.debug("[lineMarkers]", e); + } + } + + destroy() { + this.refresh.cancel(); + this.cancelRequest(); + this.offSettings(); + this.offServers(); + this.generation++; + } +} + +/** LSP access for one pass: this document's outline as known, others' opened on demand, each asked once. */ +function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: RequestId, cache: MarkerCache): MarkerDeps { + const supertypes = new Map>(); + const outlines = new Map>([[ctx.path, Promise.resolve(items)]]); + const hierarchy = (doc: number, pos: Pos) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming")); + return { + implementations: (positions) => lspRequest(() => commands.lspImplementations(request, ctx.doc, positions)), + supers: { + supertypes: (path, pos) => { + const key = `${path}:${pos.line}:${pos.col}`; + let p = supertypes.get(key); + if (!p) { + p = + path === ctx.path + ? hierarchy(ctx.doc, pos).catch((e) => { + // No type hierarchy for this file: no ↑ markers until the server changes. + if (isUnavailable(e)) cache.unsupported.add("supers"); + throw e; + }) + : withDocument(path, (doc) => hierarchy(doc, pos)); + supertypes.set(key, p); + } + return p; + }, + outline: (path) => { + let p = outlines.get(path); + if (!p) outlines.set(path, (p = withDocument(path, (doc) => lspRequest(() => commands.docOutline(doc))))); + return p; + }, + }, + readFiles: readManyFileLines, + }; +} + +function markerAt(view: EditorView, lineFrom: number, el: Element | null): LineMarker | null { + const onLine = view.state.field(markersField).filter((m) => m.pos === lineFrom); + const kind = (el?.closest(`.${styles.marker}`) as HTMLElement | null)?.dataset.kind; + return (onLine.find((m) => m.marker.kind === kind) ?? (onLine.length === 1 ? onLine[0] : null))?.marker ?? null; +} + +export function implementationGutter(ctx: EditorDocContext): Extension { + if (ctx.doc < 0) return []; + return [ + markersField, + ViewPlugin.fromClass(MarkersPlugin), + gutter({ + class: `${styles.gutter} cm-openidea-gutter-lineMarkers`, + markers: (view) => markerSet(view.state.field(markersField)), + domEventHandlers: { + mousedown: (view, block, event) => { + const e = event as MouseEvent; + if (e.button !== 0) return false; + const target = e.target instanceof Element ? e.target : null; + const marker = markerAt(view, block.from, target); + if (!marker) return false; + e.preventDefault(); + const rect = (target?.closest(`.${styles.marker}`) ?? target)?.getBoundingClientRect(); + void openMarkerTargets(marker, rect ? { x: rect.left, y: rect.bottom + 2 } : { x: e.clientX, y: e.clientY }); + return true; + }, + }, + }), + ]; +} diff --git a/src/editor/navigation/index.ts b/src/editor/navigation/index.ts index 8dccb8f16..832b6fe10 100644 --- a/src/editor/navigation/index.ts +++ b/src/editor/navigation/index.ts @@ -173,3 +173,5 @@ export function registerNavigationActions(): () => void { editorAction("navigate.previousMethod", "Previous Method", (v) => gotoMethod(v, -1)), ]); } + +export { implementationGutter } from "./implementationGutter"; diff --git a/src/editor/navigation/lineMarkers.test.ts b/src/editor/navigation/lineMarkers.test.ts new file mode 100644 index 000000000..11f3ecc09 --- /dev/null +++ b/src/editor/navigation/lineMarkers.test.ts @@ -0,0 +1,353 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + IpcError, + type HierarchyItem, + type Location, + type OutlineItem, + type Pos, + type SymbolKind, +} from "@/platform/ipc"; + +const nav = vi.hoisted(() => ({ navigateTo: vi.fn(async () => {}), openPopup: vi.fn() })); +vi.mock("@/editor/lsp/navigation", async (orig) => ({ + ...(await orig()), + navigateTo: nav.navigateTo, + locationItems: async (locs: Location[]) => locs.map((l, i) => ({ id: `${i}`, label: l.path, run: () => {} })), +})); +vi.mock("@/editor/lsp/popupStore", async (orig) => ({ + ...(await orig()), + openPopup: nav.openPopup, +})); + +import { candidateKey, markerCandidates, type MarkerCandidate } from "./lineMarkers"; +import { + cachedMarkers, + emptyCache, + enabledGroups, + fillMarkers, + openMarkerTargets, + type MarkerDeps, +} from "./implementationGutter"; + +const ALL = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] }); + +function item( + name: string, + kind: SymbolKind, + line: number, + end: number, + children: OutlineItem[] = [], + col = 4, +): OutlineItem { + return { + name, + kind, + detail: null, + range: { start: { line, col: 0 }, end: { line: end, col: 1 } }, + selection_range: { start: { line, col }, end: { line, col: col + name.length } }, + children, + }; +} + +const loc = (path: string, line: number, col = 4): Location => ({ + path, + range: { start: { line, col }, end: { line, col: col + 3 } }, +}); + +const hier = (name: string, kind: SymbolKind, path: string, line = 0): HierarchyItem => ({ + name, + kind, + detail: null, + path, + range: { start: { line, col: 0 }, end: { line: line + 20, col: 1 } }, + selectionRange: { start: { line, col: 4 }, end: { line, col: 4 + name.length } }, + fromRanges: [], +}); + +// Shape.java, as jdtls outlines it. +const SHAPE = [ + "public interface Shape {", + " double area();", + ' default String label() { return ""; }', + " static Shape unit() { return null; }", + "}", +]; +const SHAPE_OUTLINE = [ + item( + "Shape", + "interface", + 0, + 4, + [ + item("area()", "method", 1, 1, [], 11), + item("label()", "method", 2, 2, [], 19), + item("unit()", "method", 3, 3, [], 17), + ], + 17, + ), +]; +const SHAPE_PATH = "/p/Shape.java"; + +// Circle.java: implements Shape, overrides Base.label(). +const CIRCLE = [ + "public class Circle extends Base implements Shape {", + " @Override", + " public double area() { return 1; }", + " private void helper() {}", + ' public final String label() { return "c"; }', + "}", +]; +const CIRCLE_OUTLINE = [ + item( + "Circle", + "class", + 0, + 5, + [item("area()", "method", 1, 2), item("helper()", "method", 3, 3, [], 17), item("label()", "method", 4, 4, [], 24)], + 13, + ), +]; +// Make `area()`'s identifier sit on line 2 (its range starts at the annotation). +CIRCLE_OUTLINE[0]!.children[0]!.selection_range = { start: { line: 2, col: 18 }, end: { line: 2, col: 22 } }; + +const BASE = [ + "public abstract class Base {", + " public abstract void run();", + ' public String label() { return ""; }', + "}", +]; +const BASE_OUTLINE = [ + item("Base", "class", 0, 3, [item("run()", "method", 1, 1, [], 25), item("label()", "method", 2, 2, [], 18)], 22), +]; + +const FILES: Record = { + [SHAPE_PATH]: SHAPE, + "/p/Circle.java": CIRCLE, + "/p/Square.java": ["public class Square implements Shape {", " public double area() { return 2; }", "}"], + "/p/Base.java": BASE, +}; + +const lines = (text: string[]) => (l: number) => text[l] ?? ""; + +describe("marker candidates", () => { + it("covers inheritable types and their methods, without private / static / final ones", () => { + const shape = markerCandidates(SHAPE_OUTLINE, lines(SHAPE)); + expect(shape.map((c) => [c.name, c.abstract, c.inheritable, c.overriding])).toEqual([ + ["Shape", true, true, false], + ["area", true, true, true], + // A default method has a body: overriding it is not implementing it. + ["label", false, true, true], + ["unit", true, false, false], + ]); + const circle = markerCandidates(CIRCLE_OUTLINE, lines(CIRCLE)); + expect(circle.map((c) => [c.name, c.line, c.inheritable, c.overriding])).toEqual([ + ["Circle", 0, true, false], + ["area", 2, true, true], + ["helper", 3, false, false], + ["label", 4, false, true], + ]); + expect(markerCandidates(BASE_OUTLINE, lines(BASE)).map((c) => [c.name, c.abstract])).toEqual([ + ["Base", true], + ["run", true], + ["label", false], + ]); + }); + + it("skips structs, enums and free functions for implementation requests", () => { + const outline = [item("Point", "struct", 0, 3, [item("len", "method", 1, 1)]), item("main", "function", 5, 6)]; + const found = markerCandidates(outline, () => ""); + expect(found.map((c) => [c.name, c.inheritable, c.overriding])).toEqual([["len", false, true]]); + }); +}); + +/** A server answering implementation and type hierarchy requests over the files above. */ +function server(impls: Record, supers: Record) { + const calls = { implementations: [] as Pos[][], supertypes: 0 }; + const outlines: Record = { + [SHAPE_PATH]: SHAPE_OUTLINE, + "/p/Circle.java": CIRCLE_OUTLINE, + "/p/Base.java": BASE_OUTLINE, + }; + const deps = (path: string): MarkerDeps => ({ + implementations: async (positions) => { + calls.implementations.push(positions); + return positions.map((p) => impls[`${path}:${p.line}`] ?? []); + }, + supers: { + supertypes: async (p) => { + calls.supertypes++; + return supers[p] ?? []; + }, + outline: async (p) => outlines[p] ?? [], + }, + readFiles: async (paths) => new Map(paths.map((p) => [p, FILES[p] ?? null])), + }); + return { calls, deps }; +} + +describe("markers from server answers", () => { + const shapeImpls = { + // The server lists the declaration itself too. + [`${SHAPE_PATH}:0`]: [loc(SHAPE_PATH, 0), loc("/p/Circle.java", 0, 13), loc("/p/Square.java", 0, 13)], + [`${SHAPE_PATH}:1`]: [loc("/p/Circle.java", 2, 18), loc("/p/Square.java", 1, 18)], + }; + + it("marks implemented interfaces and methods with every implementation as targets", async () => { + const { deps, calls } = server(shapeImpls, {}); + const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE)); + const cache = emptyCache(); + await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache); + // One batch for every inheritable declaration. + expect(calls.implementations).toEqual([ + [ + { line: 0, col: 17 }, + { line: 1, col: 11 }, + { line: 2, col: 19 }, + ], + ]); + const markers = cachedMarkers(candidates, ALL, cache); + expect(markers.map((m) => [m.line, m.kind, m.targets.length, m.tooltip])).toEqual([ + [0, "implemented", 2, "Is implemented by Circle, Square"], + [1, "implemented", 2, "Is implemented in Circle, Square"], + ]); + expect(markers[0]!.chooserTitle).toBe("Choose Implementation of Shape (2 found)"); + }); + + it("marks subclassed classes and overridden methods", async () => { + const { deps } = server( + { + "/p/Base.java:0": [loc("/p/Circle.java", 0, 13)], + "/p/Base.java:2": [loc("/p/Circle.java", 4, 25)], + }, + {}, + ); + const candidates = markerCandidates(BASE_OUTLINE, lines(BASE)); + const cache = emptyCache(); + await fillMarkers("/p/Base.java", candidates, ALL, deps("/p/Base.java"), cache); + const markers = cachedMarkers(candidates, ALL, cache); + // `abstract class Base` is implemented, `label()` overridden. + expect(markers.map((m) => [m.line, m.kind, m.tooltip])).toEqual([ + [0, "implemented", "Is implemented by Circle"], + [2, "overridden", "Is overridden in Circle"], + ]); + }); + + it("marks implementing and overriding methods from the type hierarchy", async () => { + const supers = { + "/p/Circle.java": [hier("Base", "class", "/p/Base.java"), hier("Shape", "interface", SHAPE_PATH)], + }; + const { deps, calls } = server({}, supers); + const candidates = markerCandidates(CIRCLE_OUTLINE, lines(CIRCLE)); + const cache = emptyCache(); + await fillMarkers("/p/Circle.java", candidates, ALL, deps("/p/Circle.java"), cache); + const markers = cachedMarkers(candidates, ALL, cache); + expect( + markers.map((m) => [m.line, m.kind, m.tooltip, m.targets.map((t) => `${t.path}:${t.range.start.line}`)]), + ).toEqual([ + [2, "implementing", "Implements method in Shape", [`${SHAPE_PATH}:1`]], + // Base.label() and the default Shape.label() have bodies. + [4, "overriding", "Overrides method in Base, Shape", ["/p/Base.java:2", `${SHAPE_PATH}:2`]], + ]); + expect(markers[1]!.chooserTitle).toBe("Choose Super Method of label (2 found)"); + expect(calls.supertypes).toBeGreaterThan(0); + }); + + it("asks once per document version and only for enabled groups", async () => { + const { deps, calls } = server(shapeImpls, {}); + const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE)); + const cache = emptyCache(); + await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache); + await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), cache); + expect(calls.implementations).toHaveLength(1); + // A new document version starts from an empty cache. + await fillMarkers(SHAPE_PATH, candidates, ALL, deps(SHAPE_PATH), emptyCache()); + expect(calls.implementations).toHaveLength(2); + + const supersOnly = enabledGroups({ showGutterIcons: true, gutterIconsDisabled: ["inheritors"] }); + const fresh = emptyCache(); + await fillMarkers(SHAPE_PATH, candidates, supersOnly, deps(SHAPE_PATH), fresh); + expect(calls.implementations).toHaveLength(2); + expect(cachedMarkers(candidates, ALL, cache)).toHaveLength(2); + expect(cachedMarkers(candidates, supersOnly, cache)).toHaveLength(0); + }); + + it("stops asking a server that cannot answer, and caches nothing when cancelled", async () => { + const candidates = markerCandidates(SHAPE_OUTLINE, lines(SHAPE)); + const failing = (kind: "unavailable" | "cancelled") => { + const calls = { n: 0 }; + const deps: MarkerDeps = { + implementations: async () => { + calls.n++; + throw new IpcError({ kind, message: kind }); + }, + supers: { supertypes: async () => [], outline: async () => [] }, + readFiles: async () => new Map(), + }; + return { calls, deps }; + }; + const off = failing("unavailable"); + const cache = emptyCache(); + await fillMarkers(SHAPE_PATH, candidates, ALL, off.deps, cache); + await fillMarkers(SHAPE_PATH, candidates, ALL, off.deps, cache); + expect(off.calls.n).toBe(1); + expect(cache.unsupported.has("inheritors")).toBe(true); + + const cancelled = failing("cancelled"); + const kept = emptyCache(); + await expect(fillMarkers(SHAPE_PATH, candidates, ALL, cancelled.deps, kept)).rejects.toThrow(); + expect(kept.entries.size).toBe(0); + }); +}); + +describe("settings", () => { + it("follows Settings › Gutter Icons", () => { + expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] })]).toEqual(["inheritors", "supers"]); + expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: ["supers", "run"] })]).toEqual([ + "inheritors", + ]); + expect(enabledGroups({ showGutterIcons: false, gutterIconsDisabled: [] }).size).toBe(0); + }); +}); + +describe("clicking a marker", () => { + beforeEach(() => { + nav.navigateTo.mockClear(); + nav.openPopup.mockClear(); + }); + const candidate = (): MarkerCandidate => markerCandidates(SHAPE_OUTLINE, lines(SHAPE))[0]!; + + it("navigates to a single target", async () => { + const target = loc("/p/Circle.java", 0, 13); + await openMarkerTargets( + { kind: "implemented", line: 0, name: "Shape", targets: [target], tooltip: "", chooserTitle: "" }, + { x: 1, y: 2 }, + ); + expect(nav.navigateTo).toHaveBeenCalledWith(target); + expect(nav.openPopup).not.toHaveBeenCalled(); + }); + + it("opens the chooser over several targets", async () => { + const targets = [loc("/p/Circle.java", 0, 13), loc("/p/Square.java", 0, 13)]; + expect(candidateKey(candidate())).toBe("0:17:Shape"); + await openMarkerTargets( + { + kind: "implemented", + line: 0, + name: "Shape", + targets, + tooltip: "", + chooserTitle: "Choose Implementation of Shape (2 found)", + }, + { x: 1, y: 2 }, + ); + expect(nav.navigateTo).not.toHaveBeenCalled(); + expect(nav.openPopup).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "list", + anchor: { x: 1, y: 2 }, + title: "Choose Implementation of Shape (2 found)", + }), + ); + expect(nav.openPopup.mock.calls[0]![0].items).toHaveLength(2); + }); +}); diff --git a/src/editor/navigation/lineMarkers.ts b/src/editor/navigation/lineMarkers.ts new file mode 100644 index 000000000..5ab67daeb --- /dev/null +++ b/src/editor/navigation/lineMarkers.ts @@ -0,0 +1,222 @@ +/** + * Inheritance line markers: which declarations of a document get an + * "is implemented / overridden" (↓) or "implements / overrides" (↑) gutter + * icon, from the language server's answers (implementation requests and the + * type hierarchy). Pure; the gutter itself is `implementationGutter`. + */ +import type { Location, OutlineItem, SymbolKind } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { identifierAt } from "@/editor/lsp/files"; +import { baseName, type SuperMethod } from "./structureNav"; + +export type MarkerKind = "implemented" | "overridden" | "implementing" | "overriding"; + +/** Settings › Gutter Icons groups: ↓ markers and ↑ markers. */ +export type MarkerGroup = "inheritors" | "supers"; + +export const markerGroup = (kind: MarkerKind): MarkerGroup => + kind === "implemented" || kind === "overridden" ? "inheritors" : "supers"; + +export const MARKER_ICONS: Record = { + implemented: "gutter-implemented", + overridden: "gutter-overridden", + implementing: "gutter-implementing", + overriding: "gutter-overriding", +}; + +export interface MarkerCandidate { + /** Outline name (`eat(String)` with jdtls), matched against supertypes' members. */ + symbol: string; + /** Bare name for titles. */ + name: string; + /** 0-based line / UTF-16 col of the identifier. */ + line: number; + col: number; + method: boolean; + /** Declared abstract: an interface / trait, a member of one, or marked `abstract`. */ + abstract: boolean; + /** Can have implementations / overriders (asked with an implementation request). */ + inheritable: boolean; + /** Can implement / override a supertype's method (looked up in the type hierarchy). */ + overriding: boolean; + /** Identifier position of the enclosing type (methods). */ + owner: { line: number; col: number } | null; +} + +export interface LineMarker { + kind: MarkerKind; + line: number; + /** Name of the declaration the marker is on. */ + name: string; + targets: Location[]; + tooltip: string; + /** Title of the targets chooser. */ + chooserTitle: string; +} + +/** Types whose subtypes / implementations are worth a marker (not structs, enums: they cannot be extended). */ +const INHERITABLE_TYPES = new Set(["class", "interface", "trait"]); +const ABSTRACT_TYPES = new Set(["interface", "trait"]); +/** Types whose methods may implement or override a supertype's. */ +const MEMBER_OWNERS = new Set(["class", "interface", "trait", "enum", "struct"]); + +/** Modifiers that rule out implementations / overriders. */ +const NOT_INHERITABLE = /\b(private|static|final)\b/; +const NOT_OVERRIDING = /\b(private|static)\b/; +const ABSTRACT = /\babstract\b/; +/** An interface method with a body (Java `default`). */ +const DEFAULT = /\bdefault\b/; + +/** The declaration's text before its identifier (annotations and modifiers included). */ +function declarationPrefix(item: OutlineItem, lineText: (line: number) => string): string { + const { start } = item.range; + const at = item.selection_range.start; + if (start.line > at.line) return lineText(at.line).slice(0, at.col); + const lines: string[] = []; + for (let l = start.line; l <= at.line && lines.length < 12; l++) { + const text = lineText(l); + lines.push( + l === at.line + ? text.slice(l === start.line ? start.col : 0, at.col) + : l === start.line + ? text.slice(start.col) + : text, + ); + } + // Annotation arguments (`@Foo(final = …)`) are not modifiers. + return lines.join(" ").replace(/@[\w.]+(\([^)]*\))?/g, " "); +} + +/** Declarations that may get a marker, in document order. */ +export function markerCandidates( + outline: readonly OutlineItem[], + lineText: (line: number) => string, +): MarkerCandidate[] { + const out: MarkerCandidate[] = []; + const walk = (items: readonly OutlineItem[], parent: OutlineItem | null) => { + for (const it of items) { + const prefix = () => declarationPrefix(it, lineText); + const at = it.selection_range.start; + if (INHERITABLE_TYPES.has(it.kind)) { + const p = prefix(); + out.push({ + symbol: it.name, + name: baseName(it.name), + line: at.line, + col: at.col, + method: false, + abstract: ABSTRACT_TYPES.has(it.kind) || ABSTRACT.test(p), + inheritable: !NOT_INHERITABLE.test(p), + overriding: false, + owner: null, + }); + } else if (it.kind === "method" && parent && MEMBER_OWNERS.has(parent.kind)) { + const p = prefix(); + const inParent = ABSTRACT_TYPES.has(parent.kind); + out.push({ + symbol: it.name, + name: baseName(it.name), + line: at.line, + col: at.col, + method: true, + abstract: (inParent && !DEFAULT.test(p)) || ABSTRACT.test(p), + inheritable: INHERITABLE_TYPES.has(parent.kind) && !NOT_INHERITABLE.test(p), + overriding: !NOT_OVERRIDING.test(p), + owner: parent.selection_range.start, + }); + } + if (it.children.length) walk(it.children, it); + } + }; + walk(outline, null); + return out.sort((a, b) => a.line - b.line || a.col - b.col); +} + +export const candidateKey = (c: MarkerCandidate) => `${c.line}:${c.col}:${c.symbol}`; + +/** Servers answer an implementation request on a declaration with the declaration itself too. */ +export function withoutSelf(path: string, c: MarkerCandidate, targets: readonly Location[]): Location[] { + return targets.filter((t) => !(t.path === path && t.range.start.line === c.line)); +} + +const TYPE_DECLARATION = /\b(?:class|interface|trait|object|struct|enum|record|impl)\s+([\p{L}_$][\p{L}\p{N}_$]*)/u; + +/** Name of the type a target sits in: the nearest type declaration above it, else the file name. */ +export function enclosingTypeName(lines: readonly string[] | null | undefined, target: Location): string { + for (let l = target.range.start.line - 1; lines && l >= 0 && l >= target.range.start.line - 2000; l--) { + const m = TYPE_DECLARATION.exec(lines[l] ?? ""); + if (m && !/^\s*(\/\/|\*|\/\*)/.test(lines[l] ?? "")) return m[1]!; + } + const file = target.path.split(/[\\/]/).pop() ?? target.path; + return file.replace(/\.[^.]+$/, ""); +} + +/** Display name of a target: the type itself for type markers, the type around it for methods. */ +export function targetName(lines: readonly string[] | null | undefined, target: Location, method: boolean): string { + if (!method) { + const name = identifierAt(lines?.[target.range.start.line] ?? "", target.range.start.col); + if (name) return name; + } + return enclosingTypeName(lines, target); +} + +const MAX_NAMES = 8; + +function nameList(names: readonly string[]): string { + const unique = [...new Set(names)].sort((a, b) => a.localeCompare(b)); + const shown = unique.slice(0, MAX_NAMES).join(", "); + return unique.length > MAX_NAMES ? tx`${shown} and ${unique.length - MAX_NAMES} more` : shown; +} + +/** ↓ marker of `c` from its implementations (self excluded already); null without any. */ +export function inheritorsMarker( + c: MarkerCandidate, + targets: readonly Location[], + files: ReadonlyMap, +): LineMarker | null { + if (!targets.length) return null; + const kind: MarkerKind = c.abstract ? "implemented" : "overridden"; + const names = nameList(targets.map((t) => targetName(files.get(t.path), t, c.method))); + const n = targets.length; + let tooltip: string; + let chooserTitle: string; + if (!c.method) { + tooltip = c.abstract ? tx`Is implemented by ${names}` : tx`Is subclassed by ${names}`; + chooserTitle = c.abstract + ? tx`Choose Implementation of ${c.name} (${n} found)` + : tx`Choose Subclass of ${c.name} (${n} found)`; + } else { + tooltip = c.abstract ? tx`Is implemented in ${names}` : tx`Is overridden in ${names}`; + chooserTitle = c.abstract + ? tx`Choose Implementation of ${c.name} (${n} found)` + : tx`Choose Overriding Method of ${c.name} (${n} found)`; + } + return { kind, line: c.line, name: c.name, targets: [...targets], tooltip, chooserTitle }; +} + +/** Whether a super method is abstract: declared in an interface / trait, or marked `abstract`. */ +export function isAbstractSuper(s: SuperMethod, files: ReadonlyMap): boolean { + const lines = files.get(s.location.path); + const prefix = lines ? declarationPrefix(s.method, (l) => lines[l] ?? "") : ""; + return (ABSTRACT_TYPES.has(s.owner.kind) && !DEFAULT.test(prefix)) || ABSTRACT.test(prefix); +} + +/** ↑ marker of method `c` from its super methods; null without any. */ +export function supersMarker( + c: MarkerCandidate, + supers: readonly SuperMethod[], + files: ReadonlyMap, +): LineMarker | null { + if (!supers.length) return null; + // Implementing wins when one of several super methods is abstract, as an interface method would. + const implementing = supers.some((s) => isAbstractSuper(s, files)); + const owners = nameList(supers.map((s) => baseName(s.owner.name))); + return { + kind: implementing ? "implementing" : "overriding", + line: c.line, + name: c.name, + targets: supers.map((s) => s.location), + tooltip: implementing ? tx`Implements method in ${owners}` : tx`Overrides method in ${owners}`, + chooserTitle: tx`Choose Super Method of ${c.name} (${supers.length} found)`, + }; +} diff --git a/src/editor/navigation/structureNav.test.ts b/src/editor/navigation/structureNav.test.ts index 9d18e8bca..d0781c0c5 100644 --- a/src/editor/navigation/structureNav.test.ts +++ b/src/editor/navigation/structureNav.test.ts @@ -63,4 +63,25 @@ describe("Super Method", () => { // jdtls names methods with their parameters. expect(await superMethods("/Foo.java", { line: 0, col: 4 }, "a() : void", deps)).toHaveLength(1); }); + + it("prefers the overload with the same signature", async () => { + const base: HierarchyItem = { + name: "Base", + kind: "class", + detail: null, + path: "/Base.java", + range: r(0, 10), + selectionRange: { start: { line: 0, col: 4 }, end: { line: 0, col: 8 } }, + fromRanges: [], + }; + const deps = { + supertypes: async (path: string) => (path === "/Foo.java" ? [base] : []), + outline: async () => [item("Base", "class", 0, 10, [item("eat(String)", "method", 2, 2), item("eat(int)", "method", 4, 4)])], + }; + const lines = async (name: string) => (await superMethods("/Foo.java", { line: 0, col: 4 }, name, deps)).map((l) => l.range.start.line); + expect(await lines("eat(int)")).toEqual([4]); + // Without a matching signature (or without signatures at all), every overload. + expect(await lines("eat(T)")).toEqual([2, 4]); + expect(await lines("eat")).toEqual([2, 4]); + }); }); diff --git a/src/editor/navigation/structureNav.ts b/src/editor/navigation/structureNav.ts index f25bb1f0e..ce18f0923 100644 --- a/src/editor/navigation/structureNav.ts +++ b/src/editor/navigation/structureNav.ts @@ -60,8 +60,11 @@ export function memberAt(outline: readonly OutlineItem[], caret: Pos): CaretMemb /** `beta(int)` (jdtls) and `beta` name the same method. */ export const baseName = (name: string) => name.replace(/\s*[(<].*$/, ""); -/** Methods named `name` directly inside the type declared at `item` (its outline node found by position). */ -export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] { +/** `beta(int) : void` (jdtls) is the signature `beta(int)`; plain names stay as they are. */ +const signature = (name: string) => name.replace(/\s*:.*$/, ""); + +/** The outline node of the type declared at `item` (found by position). */ +export function typeOf(outline: readonly OutlineItem[], item: HierarchyItem): OutlineItem | null { const at = item.selectionRange.start; const find = (items: readonly OutlineItem[]): OutlineItem | null => { for (const i of items) { @@ -71,9 +74,20 @@ export function methodsInType(outline: readonly OutlineItem[], item: HierarchyIt } return null; }; - const type = find(outline); + return find(outline); +} + +/** + * Methods named `name` directly inside the type declared at `item`: the + * overload with the same signature when the server names them with one + * (`eat(String)`), else every method of that name. + */ +export function methodsInType(outline: readonly OutlineItem[], item: HierarchyItem, name: string): OutlineItem[] { + const type = typeOf(outline, item); const bare = baseName(name); - return type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : []; + const named = type ? type.children.filter((c) => METHODS.has(c.kind) && baseName(c.name) === bare) : []; + const exact = named.filter((c) => signature(c.name) === signature(name)); + return exact.length ? exact : named; } export interface SuperDeps { @@ -81,13 +95,20 @@ export interface SuperDeps { outline: (path: string) => Promise; } -/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */ -export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise { +export interface SuperMethod { + location: Location; + /** The supertype declaring it. */ + owner: HierarchyItem; + method: OutlineItem; +} + +/** Super methods of `method` (a member of the type at `typePos`), with their declaring types, nearest level first. */ +export async function superMethodTargets(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise { let level: Array<{ path: string; pos: Pos }> = [{ path, pos: typePos }]; const seen = new Set(); for (let d = 0; d < depth && level.length; d++) { const next: Array<{ path: string; pos: Pos }> = []; - const found: Location[] = []; + const found: SuperMethod[] = []; for (const t of level) { const supers = await deps.supertypes(t.path, t.pos).catch(() => [] as HierarchyItem[]); for (const s of supers) { @@ -95,7 +116,8 @@ export async function superMethods(path: string, typePos: Pos, method: string, d if (seen.has(key)) continue; seen.add(key); const methods = methodsInType(await deps.outline(s.path).catch(() => [] as OutlineItem[]), s, method); - if (methods.length) for (const m of methods) found.push({ path: s.path, range: m.selection_range }); + if (methods.length) + for (const m of methods) found.push({ location: { path: s.path, range: m.selection_range }, owner: s, method: m }); else next.push({ path: s.path, pos: s.selectionRange.start }); } } @@ -104,3 +126,8 @@ export async function superMethods(path: string, typePos: Pos, method: string, d } return []; } + +/** Super methods of `method` (a member of the type at `typePos`), searching up to `depth` levels of supertypes. */ +export async function superMethods(path: string, typePos: Pos, method: string, deps: SuperDeps, depth = 3): Promise { + return (await superMethodTargets(path, typePos, method, deps, depth)).map((s) => s.location); +} diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index eb1cf7f80..0e512f186 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -536,6 +536,18 @@ const GUTTER = editorPage({ component: () => , }, { t: "custom", label: "Bookmarks", component: () => }, + { + t: "custom", + label: "Implemented and overridden", + keywords: ["implementations", "subclasses", "inheritors"], + component: () => , + }, + { + t: "custom", + label: "Implementing and overriding", + keywords: ["super method", "overrides", "implements"], + component: () => , + }, ], }, ], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d2c6a1b27..57dcb07b3 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3708,6 +3708,20 @@ "Can't rename '{name}' without the language server: only local variables can be renamed based on syntax": "没有语言服务器时无法重命名 '{name}':基于语法只能重命名局部变量", "Choose Super Class or Interface ({count} found)": "选择超类或接口(找到 {count} 个)", "Choose Super Method of {name} ({count} found)": "选择 {name} 的超方法(找到 {count} 个)", + "Choose Implementation of {name} ({count} found)": "选择 {name} 的实现(找到 {count} 个)", + "Choose Subclass of {name} ({count} found)": "选择 {name} 的子类(找到 {count} 个)", + "Choose Overriding Method of {name} ({count} found)": "选择 {name} 的重写方法(找到 {count} 个)", + "Is implemented by {names}": "由 {names} 实现", + "Is subclassed by {names}": "子类:{names}", + "Is implemented in {names}": "在 {names} 中实现", + "Is overridden in {names}": "在 {names} 中重写", + "Implements method in {names}": "实现 {names} 中的方法", + "Overrides method in {names}": "重写 {names} 中的方法", + "{names} and {count} more": "{names} 等另外 {count} 个", + "Implemented and overridden": "已实现和已重写", + "Implementing and overriding": "实现和重写", + "Implemented and overridden declarations": "已被实现和重写的声明", + "Implementing and overriding methods": "实现和重写超类型方法的方法", "Replace all {count} occurrences": "替换全部 {count} 处", "↵ Apply · Esc Keep '{name}'": "↵ 应用 · Esc 保留 '{name}'", " (in {files} files)": "(位于 {files} 个文件中)", diff --git a/src/ui/expui/gutter/implementedMethod.svg b/src/ui/expui/gutter/implementedMethod.svg new file mode 100644 index 000000000..62a791392 --- /dev/null +++ b/src/ui/expui/gutter/implementedMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/implementedMethod_dark.svg b/src/ui/expui/gutter/implementedMethod_dark.svg new file mode 100644 index 000000000..e6397c7e8 --- /dev/null +++ b/src/ui/expui/gutter/implementedMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/implementingMethod.svg b/src/ui/expui/gutter/implementingMethod.svg new file mode 100644 index 000000000..1a761caef --- /dev/null +++ b/src/ui/expui/gutter/implementingMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/implementingMethod_dark.svg b/src/ui/expui/gutter/implementingMethod_dark.svg new file mode 100644 index 000000000..c9a9f345a --- /dev/null +++ b/src/ui/expui/gutter/implementingMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridenMethod.svg b/src/ui/expui/gutter/overridenMethod.svg new file mode 100644 index 000000000..8ed3f0146 --- /dev/null +++ b/src/ui/expui/gutter/overridenMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridenMethod_dark.svg b/src/ui/expui/gutter/overridenMethod_dark.svg new file mode 100644 index 000000000..40c4e894b --- /dev/null +++ b/src/ui/expui/gutter/overridenMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridingMethod.svg b/src/ui/expui/gutter/overridingMethod.svg new file mode 100644 index 000000000..75e1d3877 --- /dev/null +++ b/src/ui/expui/gutter/overridingMethod.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/gutter/overridingMethod_dark.svg b/src/ui/expui/gutter/overridingMethod_dark.svg new file mode 100644 index 000000000..bb314139c --- /dev/null +++ b/src/ui/expui/gutter/overridingMethod_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 71277ef1e..0f43b3cc8 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -322,6 +322,10 @@ const ICONS = { "gutter-run-all": "actions/runAll", "gutter-run-passed": "gutter/runSuccess", "gutter-run-failed": "gutter/runError", + "gutter-implemented": "gutter/implementedMethod", + "gutter-implementing": "gutter/implementingMethod", + "gutter-overridden": "gutter/overridenMethod", + "gutter-overriding": "gutter/overridingMethod", stop: "run/stop", rerun: "run/rerun", "rerun-filled": "run/rerun_stroke",