From 379a52d6e7d9c3ecc5f9f30f00835be224ee7783 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:15:42 +0800 Subject: [PATCH] feat(debugger): Kotlin breakpoints through the Kotlin Debug Adapter (#434) Kotlin line breakpoints never verified through java-debug because jdtls cannot resolve classes for .kt files. Kotlin Application configurations and JDWP attach for modules with Kotlin sources now use the official Kotlin Debug Adapter (installable from the debugger prompt, running on the language-server JDK); pure Java keeps java-debug. The adapter resolves classes from source paths, so sources whose directories don't match their package are mirrored package-aligned outside the project (symlinks, copies where links fail) and paths are mapped back for breakpoints and stack frames; files that can't be mirrored are skipped rather than failing the session. Kotlin applications start with JDWP suspend=y and a session-owned JDWP relay holds the adapter's early resume until attach completes, so breakpoints are installed before the program runs. Limitations (docs/kotlin-debugging.md): @file:JvmName facades are not resolved; package/source-root changes need a new session. Not verified on a real Windows machine. Fixes #374 --- Cargo.lock | 1 + crates/ide-dap/Cargo.toml | 1 + crates/ide-dap/src/adapter.rs | 6 + crates/ide-dap/src/kotlin.rs | 218 ++++++++++++++++++ crates/ide-dap/src/kotlin_jdwp.rs | 166 +++++++++++++ crates/ide-dap/src/lib.rs | 78 ++++++- crates/ide-dap/src/session.rs | 87 ++++++- crates/ide-dap/src/tests.rs | 67 +++++- crates/ide-host/src/ops/debug.rs | 34 +++ crates/ide-host/src/ops/doc.rs | 6 +- crates/ide-host/src/ops/java.rs | 3 +- crates/ide-tools/src/tests.rs | 10 + docs/kotlin-debugging.md | 63 +++++ src/features/debugger/__tests__/java.test.ts | 38 +++ .../debugger/__tests__/missingAdapter.test.ts | 3 + src/features/debugger/java.ts | 57 ++++- src/features/debugger/missingAdapter.ts | 1 + src/features/run/ConfigForm.tsx | 1 + src/features/run/application.ts | 2 +- src/features/run/java.test.ts | 14 ++ src/features/run/javaDebug.ts | 6 +- src/platform/locales/text/zh.json | 1 + src/platform/locales/textCatalog.test.ts | 1 + 23 files changed, 850 insertions(+), 14 deletions(-) create mode 100644 crates/ide-dap/src/kotlin.rs create mode 100644 crates/ide-dap/src/kotlin_jdwp.rs create mode 100644 docs/kotlin-debugging.md diff --git a/Cargo.lock b/Cargo.lock index 7c05bd41..ab43cc1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4138,6 +4138,7 @@ dependencies = [ "serde", "serde_json", "specta", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/ide-dap/Cargo.toml b/crates/ide-dap/Cargo.toml index f402a1aa..a902900b 100644 --- a/crates/ide-dap/Cargo.toml +++ b/crates/ide-dap/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true [dependencies] +tempfile.workspace = true ide-core.workspace = true ide-language.workspace = true ide-jsonrpc.workspace = true diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 117e3c90..ade3d7c0 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -63,6 +63,12 @@ fn builtin(adapter: &str) -> Option { "tcp", "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH", ), + "kotlin-debug-adapter" => cfg( + "kotlin-debug-adapter", + &[], + "stdio", + "Install the Kotlin Debug Adapter from Settings > Language Servers", + ), "debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"), "js-debug" => cfg( "js-debug-adapter", diff --git a/crates/ide-dap/src/kotlin.rs b/crates/ide-dap/src/kotlin.rs new file mode 100644 index 00000000..fd3aae30 --- /dev/null +++ b/crates/ide-dap/src/kotlin.rs @@ -0,0 +1,218 @@ +//! Package-aligned sources for fwcd's path-based class resolver. Never write into a project. +use ide_core::{IdeError, IdeResult}; +use serde_json::Value; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +pub const ADAPTER: &str = "kotlin-debug-adapter"; + +pub struct SourceMirror { + dir: tempfile::TempDir, + paths: HashMap, + copies: Vec<(PathBuf, PathBuf)>, + source_dir: PathBuf, + roots: HashMap, +} + +impl SourceMirror { + pub fn create(project: &Path) -> IdeResult { + let base = fs::canonicalize(std::env::temp_dir())?; + // Check before creating anything, including when TMPDIR points into a project. + if base.starts_with(fs::canonicalize(project)?) { + return Err(IdeError::invalid("Kotlin source mirror must be outside the project")); + } + let dir = tempfile::Builder::new().prefix("openidea-kotlin-").tempdir_in(base)?; + let source_dir = if project.join("src").is_dir() { project.join("src") } else { project.to_owned() }; + let mut mirror = Self { + dir, + paths: HashMap::new(), + copies: Vec::new(), + source_dir: source_dir.clone(), + roots: HashMap::new(), + }; + mirror.scan(&source_dir)?; + Ok(mirror) + } + + pub fn root(&self) -> &Path { + self.dir.path() + } + + fn scan(&mut self, dir: &Path) -> IdeResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let kind = entry.file_type()?; + if kind.is_dir() { + if !matches!( + entry.file_name().to_str(), + Some(".git" | ".gradle" | "build" | "target" | "node_modules" | ".idea" | ".openidea") + ) { + self.scan(&path)?; + } + } else if matches!(path.extension().and_then(|s| s.to_str()), Some("kt" | "java")) { + self.add(&path)?; + } + } + Ok(()) + } + + fn add(&mut self, real: &Path) -> IdeResult<()> { + if self.paths.contains_key(real) { + return Ok(()); + } + let Ok(text) = fs::read_to_string(real) else { + tracing::debug!("Kotlin debug mirror: skipping unreadable {}", real.display()); + return Ok(()); + }; + let language = if real.extension().is_some_and(|e| e == "java") { "java" } else { "kotlin" }; + let relative = real.strip_prefix(&self.source_dir).unwrap_or(real); + let mut components = relative.components(); + let source_set = components.next().map(|c| PathBuf::from(c.as_os_str())).unwrap_or_default(); + let next_root = self.roots.len(); + let index = *self.roots.entry(source_set).or_insert(next_root); + let mut mirror = self.root().join(format!("root{index}")).join("src/main").join(language); + for part in package(&text).split('.').filter(|s| !s.is_empty()) { + if part == ".." || part.contains(['/', '\\']) { + tracing::debug!("Kotlin debug mirror: skipping {} (invalid package)", real.display()); + return Ok(()); + } + mirror.push(part); + } + mirror.push(real.file_name().ok_or_else(|| IdeError::invalid("Source has no filename"))?); + // Same package and file name in one source set (e.g. main and test of a module): + // the first wins; breakpoints in the other stay unverified instead of failing the session. + if self.paths.values().any(|p| p == &mirror) { + tracing::debug!("Kotlin debug mirror: skipping ambiguous {}", real.display()); + return Ok(()); + } + fs::create_dir_all(mirror.parent().unwrap())?; + if link(real, &mirror).is_err() { + fs::copy(real, &mirror)?; + self.copies.push((real.to_owned(), mirror.clone())); + } + self.paths.insert(real.to_owned(), mirror); + Ok(()) + } + + pub fn adapter_path(&self, real: &Path) -> Option { + self.paths.get(real).cloned() + } + pub fn client_path(&self, mirror: &Path) -> Option { + self.paths.iter().find_map(|(real, p)| (p == mirror).then(|| real.clone())) + } + pub fn refresh(&mut self) -> IdeResult<()> { + for (real, mirror) in &self.copies { + if real.is_file() { + fs::copy(real, mirror)?; + } + } + self.scan(&self.source_dir.clone()) + } + pub fn translate(&self, value: &mut Value) { + match value { + Value::Object(obj) => { + if let Some(path) = obj.get("path").and_then(Value::as_str).and_then(|p| self.client_path(Path::new(p))) + { + obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned())); + } + for child in obj.values_mut() { + self.translate(child); + } + } + Value::Array(items) => { + for child in items { + self.translate(child); + } + } + _ => {} + } + } +} + +#[cfg(unix)] +fn link(real: &Path, mirror: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(real, mirror) +} +#[cfg(windows)] +fn link(real: &Path, mirror: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_file(real, mirror) +} + +// Remove comments before reading the package directive (including nested Kotlin block comments). +fn package(text: &str) -> String { + let mut clean = String::new(); + let mut chars = text.chars().peekable(); + let mut depth = 0; + while let Some(c) = chars.next() { + if c == '/' && chars.peek() == Some(&'*') { + chars.next(); + depth += 1; + clean.push(' '); + } else if depth > 0 && c == '*' && chars.peek() == Some(&'/') { + chars.next(); + depth -= 1; + } else if depth == 0 && c == '/' && chars.peek() == Some(&'/') { + for c in chars.by_ref() { + if c == '\n' { + clean.push('\n'); + break; + } + } + } else if depth == 0 { + clean.push(c); + } + } + clean + .lines() + .find_map(|line| { + let rest = line.trim().strip_prefix("package")?; + if !rest.starts_with(char::is_whitespace) { + return None; + } + Some(rest.split(';').next().unwrap_or_default().trim().replace('`', "")) + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn external_mirror_round_trip_and_cleanup() { + let project = tempfile::tempdir().unwrap(); + let real = project.path().join("Main.kt"); + fs::write(&real, "/* package wrong */\npackage org.example\nfun main() {}\n").unwrap(); + let mirror = SourceMirror::create(project.path()).unwrap(); + let path = mirror.adapter_path(&real).unwrap(); + assert!(!path.starts_with(project.path())); + assert!(path.ends_with("src/main/kotlin/org/example/Main.kt")); + assert_eq!(fs::read_to_string(&path).unwrap(), fs::read_to_string(&real).unwrap()); + let mut payload = + serde_json::json!({"stackFrames": [{"source": {"path": path}, "line": 6}], "source": {"path": path}}); + mirror.translate(&mut payload); + assert_eq!(payload["stackFrames"][0]["source"]["path"], real.to_string_lossy().as_ref()); + assert_eq!(payload["source"]["path"], real.to_string_lossy().as_ref()); + let root = mirror.root().to_owned(); + drop(mirror); + assert!(!root.exists()); + assert!(real.exists()); + } + #[test] + fn copies_refresh_after_save() { + let project = tempfile::tempdir().unwrap(); + let real = project.path().join("Main.java"); + fs::write(&real, "package example;\nclass Main {}\n").unwrap(); + let mut mirror = SourceMirror::create(project.path()).unwrap(); + let path = mirror.adapter_path(&real).unwrap(); + fs::remove_file(&path).unwrap(); + fs::copy(&real, &path).unwrap(); + mirror.copies.push((real.clone(), path.clone())); + fs::write(&real, "package example;\nclass Main { int i; }\n").unwrap(); + mirror.refresh().unwrap(); + assert_eq!(fs::read(&path).unwrap(), fs::read(&real).unwrap()); + } +} diff --git a/crates/ide-dap/src/kotlin_jdwp.rs b/crates/ide-dap/src/kotlin_jdwp.rs new file mode 100644 index 00000000..f0c49c7f --- /dev/null +++ b/crates/ide-dap/src/kotlin_jdwp.rs @@ -0,0 +1,166 @@ +//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners. +//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before +//! installing breakpoint hooks. DAP attach completes only after those hooks exist. +use ide_core::IdeResult; +use std::{io, time::Duration}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::oneshot, + task::JoinHandle, +}; + +pub(crate) struct StartupRelay { + release: Option>, + task: JoinHandle<()>, +} + +impl StartupRelay { + pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local = listener.local_addr()?.port(); + let (release, ready) = oneshot::channel(); + let task = tokio::spawn(async move { + let connect = async { + let (adapter, _) = listener.accept().await?; + let jvm = TcpStream::connect((host.as_str(), port)).await?; + adapter.set_nodelay(true)?; + jvm.set_nodelay(true)?; + Ok::<_, io::Error>((adapter, jvm)) + }; + if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await { + let (mut ar, mut aw) = adapter.into_split(); + let (mut jr, mut jw) = jvm.into_split(); + let upstream = forward(&mut ar, &mut jw, ready); + let downstream = tokio::io::copy(&mut jr, &mut aw); + // Close both sockets as soon as either side disconnects. + tokio::select! { + r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); }, + _ = downstream => {}, + } + } + }); + Ok((Self { release: Some(release), task }, local)) + } + + pub fn release(&mut self) { + if let Some(ready) = self.release.take() { + let _ = ready.send(()); + } + } +} + +impl Drop for StartupRelay { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result> { + let mut header = [0; 11]; + reader.read_exact(&mut header).await?; + let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize; + if !(11..=16 * 1024 * 1024).contains(&len) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length")); + } + let mut packet = vec![0; len]; + packet[..11].copy_from_slice(&header); + reader.read_exact(&mut packet[11..]).await?; + Ok(packet) +} + +async fn forward( + reader: &mut (impl AsyncRead + Unpin), + writer: &mut (impl AsyncWrite + Unpin), + mut ready: oneshot::Receiver<()>, +) -> io::Result<()> { + let mut handshake = [0; 14]; + reader.read_exact(&mut handshake).await?; + if &handshake != b"JDWP-Handshake" { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake")); + } + writer.write_all(&handshake).await?; + let mut released = false; + let mut held: Vec> = Vec::new(); + loop { + // Retain the in-flight read when attach completes, including partial packets. + let read = packet(reader); + tokio::pin!(read); + let packet = tokio::select! { + result = &mut read => result?, + result = &mut ready, if !released => { + result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?; + released = true; + for packet in held.drain(..) { + writer.write_all(&packet).await?; + } + read.await? + } + }; + // JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3). + // Other commands must proceed: attach itself queries threads and installs requests. + if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) { + if held.len() >= 1024 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes")); + } + held.push(packet); + } else { + writer.write_all(&packet).await?; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn command(id: u8, set: u8, cmd: u8) -> Vec { + vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd] + } + + #[tokio::test] + async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() { + let (mut adapter, mut reader) = tokio::io::duplex(4096); + let (mut writer, mut jvm) = tokio::io::duplex(4096); + let (release, ready) = oneshot::channel(); + let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await }); + adapter.write_all(b"JDWP-Handshake").await.unwrap(); + let mut handshake = [0; 14]; + jvm.read_exact(&mut handshake).await.unwrap(); + assert_eq!(&handshake, b"JDWP-Handshake"); + let resume = command(1, 1, 9); + let thread_resume = command(2, 11, 3); + let setup = command(3, 1, 4); + adapter.write_all(&resume).await.unwrap(); + adapter.write_all(&thread_resume).await.unwrap(); + adapter.write_all(&setup).await.unwrap(); + assert_eq!(packet(&mut jvm).await.unwrap(), setup); + let next = command(4, 1, 9); + adapter.write_all(&next[..6]).await.unwrap(); + release.send(()).unwrap(); + assert_eq!(packet(&mut jvm).await.unwrap(), resume); + assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume); + adapter.write_all(&next[6..]).await.unwrap(); + assert_eq!(packet(&mut jvm).await.unwrap(), next); + drop(adapter); + assert!(task.await.unwrap().is_err()); + } + + #[tokio::test] + async fn dropping_session_closes_relay() { + let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap(); + let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + drop(relay); + let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn rejects_invalid_packet_lengths() { + for len in [0_u32, 10, 16 * 1024 * 1024 + 1] { + let mut bytes = command(1, 1, 9); + bytes[..4].copy_from_slice(&len.to_be_bytes()); + assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData); + } + } +} diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 5ab70125..550f85cf 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -13,6 +13,8 @@ pub mod attach; mod cargo; pub mod connection; pub mod jdwp; +pub mod kotlin; +mod kotlin_jdwp; mod session; use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; @@ -563,6 +565,14 @@ impl DebugManager { let dbg = adapter::find_config(&self.languages, &config.adapter) .ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?; let mut spec = adapter::resolve(&dbg, cwd.clone()); + if config.adapter == kotlin::ADAPTER + && let Some(home) = arguments + .as_object_mut() + .and_then(|o| o.remove("__adapterJavaHome")) + .and_then(|v| v.as_str().map(str::to_owned)) + { + spec.env.push(("JAVA_HOME".into(), home)); + } if let Some(command) = custom_command(&spec.command) { spec.command = command; } @@ -722,7 +732,19 @@ impl DebugManager { } } - async fn configure(&self, s: &Arc, arguments: Value) -> IdeResult<()> { + async fn configure(&self, s: &Arc, mut arguments: Value) -> IdeResult<()> { + if s.adapter == kotlin::ADAPTER { + let project = arguments + .get("projectRoot") + .and_then(Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| s.spec.cwd.clone()); + let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project)) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + arguments["projectRoot"] = json!(mirror.root()); + *s.kotlin_mirror.lock() = Some(mirror); + } let caps = s .conn .request_timeout( @@ -749,8 +771,32 @@ impl DebugManager { .await?; *s.caps.lock() = if caps.is_object() { caps } else { json!({}) }; + // The Kotlin adapter ignores the resolved classpath and project runtime on launch. + let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" { + session::start_kotlin_debuggee(s, &mut arguments).await?; + "attach" + } else { + &s.request + }; + if s.adapter == kotlin::ADAPTER { + let host = arguments + .get("hostName") + .and_then(Value::as_str) + .ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))? + .to_owned(); + let port = arguments + .get("port") + .and_then(Value::as_u64) + .and_then(|p| u16::try_from(p).ok()) + .filter(|p| *p != 0) + .ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?; + let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?; + *s.kotlin_relay.lock() = Some(relay); + arguments["hostName"] = json!("127.0.0.1"); + arguments["port"] = json!(port); + } // Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet. - let launch = s.conn.send_request(&s.request, arguments)?; + let launch = s.conn.send_request(request, arguments)?; let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) }); let mut initialized = s.initialized.subscribe(); let mut launch_result = None; @@ -780,6 +826,7 @@ impl DebugManager { s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() }); } } + Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()), Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()), } } @@ -802,7 +849,11 @@ impl DebugManager { let _ = self.send_exception_breakpoints(s).await; } if s.supports("supportsConfigurationDoneRequest") { - s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?; + if s.adapter == kotlin::ADAPTER { + drop(s.conn.send_request("configurationDone", json!({}))?); + } else { + s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?; + } } s.configured.store(true, Ordering::Release); if launch_result.is_none() { @@ -813,6 +864,9 @@ impl DebugManager { Err(_) => return Err(DapError::Timeout(s.request.clone()).into()), } } + if let Some(relay) = s.kotlin_relay.lock().as_mut() { + relay.release(); + } Ok(()) } @@ -869,6 +923,7 @@ impl DebugManager { Ok(states) => { result.get_or_insert(states); } + Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()), Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0), } } @@ -886,6 +941,23 @@ impl DebugManager { Ok(states) } + /// Refresh fallback copies after an editor save. + pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> { + let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect(); + tokio::task::spawn_blocking(move || { + for s in sessions { + if let Some(mirror) = s.kotlin_mirror.lock().as_mut() + && let Err(e) = mirror.refresh() + { + tracing::warn!("Could not refresh Kotlin debug sources: {e}"); + } + } + Ok(()) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + } + /// Last known verification states of a file's breakpoints (after `BreakpointsChanged`). pub fn breakpoint_states(&self, path: &Path) -> Vec { self.states.lock().get(path).cloned().unwrap_or_default() diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index 2ec511e8..38361fec 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -54,6 +54,8 @@ pub(crate) struct Session { /// Resolved path → the path as the IDE knows it, for adapters given resolved paths. resolved_paths: Mutex>, progress: Mutex>, + pub kotlin_relay: Mutex>, + pub kotlin_mirror: Mutex>, } /// Adapters that only match files by their resolved path: Godot maps a script to @@ -95,6 +97,8 @@ impl Session { breakpoint_ids: Mutex::new(HashMap::new()), resolved_paths: Mutex::new(HashMap::new()), progress: Mutex::new(HashMap::new()), + kotlin_relay: Mutex::new(None), + kotlin_mirror: Mutex::new(None), } } @@ -104,6 +108,9 @@ impl Session { /// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters). pub fn adapter_path(&self, path: &Path) -> PathBuf { + if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) { + return path; + } if self.adapter == "java-debug" { let native = java_source_path(&path.to_string_lossy()); self.resolved_paths.lock().insert(native.clone(), path.to_path_buf()); @@ -123,6 +130,9 @@ impl Session { /// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]). pub fn client_path(&self, path: &str) -> Option { + if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) { + return Some(real); + } let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) }; self.resolved_paths.lock().get(&key).cloned().or_else(|| { (self.adapter == "java-debug" && is_windows_source_path(path)) @@ -245,6 +255,7 @@ impl Session { /// Kills the adapter and every debuggee we started. pub fn kill_processes(&self) { + self.kotlin_relay.lock().take(); if let Some(mut c) = self.adapter_process.lock().take() { let _ = c.start_kill(); } @@ -319,7 +330,10 @@ pub(crate) async fn event_loop( } } -async fn on_event(mgr: &Arc, s: &Arc, event: &str, body: Value) { +async fn on_event(mgr: &Arc, s: &Arc, event: &str, mut body: Value) { + if let Some(mirror) = s.kotlin_mirror.lock().as_ref() { + mirror.translate(&mut body); + } let int = |k: &str| body.get(k).and_then(Value::as_i64); let str_ = |k: &str| body.get(k).and_then(Value::as_str).map(str::to_owned); match event { @@ -508,3 +522,74 @@ fn forward_output(s: &Session, mut stream } }); } + +/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches. +pub(crate) async fn start_kotlin_debuggee(s: &Arc, args: &mut Value) -> ide_core::IdeResult<()> { + use tokio::io::{AsyncBufReadExt, BufReader}; + let port = crate::adapter::free_port()?; + let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java"); + let strings = |key: &str| -> Vec { + args.get(key) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect()) + .unwrap_or_default() + }; + let classpath = + std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; + let mut cmd = tokio::process::Command::new(java); + ide_core::process::prepare(cmd.as_std_mut()); + cmd.args(strings("vmArguments")) + .arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}")) + .arg("-cp") + .arg(classpath); + let modules = strings("modulePaths"); + if !modules.is_empty() { + cmd.arg("--module-path") + .arg(std::env::join_paths(modules).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?); + } + cmd.arg( + args.get("mainClass") + .and_then(Value::as_str) + .ok_or_else(|| ide_core::IdeError::invalid("Missing Kotlin main class"))?, + ) + .args(strings("programArgs")) + .current_dir(&s.spec.cwd) + .envs(ide_core::process::shell_env()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(env) = args.get("env").and_then(Value::as_object) { + for (k, v) in env { + if let Some(v) = v.as_str() { + cmd.env(k, v); + } + } + } + let mut child = cmd.spawn()?; + if let Some(err) = child.stderr.take() { + forward_output(s, err, "stderr"); + } + let mut stdout = + BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?); + tokio::time::timeout(Duration::from_secs(15), async { + loop { + let mut line = String::new(); + if stdout.read_line(&mut line).await? == 0 { + return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener")); + } + if line.contains("Listening for transport dt_socket") { + return Ok(()); + } + s.emit(DebugEvent::Output { category: "stdout".into(), output: line }); + } + }) + .await + .map_err(|_| ide_core::IdeError::unavailable("JVM did not start its debug listener"))??; + forward_output(s, stdout, "stdout"); + s.debuggees.lock().push(child); + args["hostName"] = json!("127.0.0.1"); + args["port"] = json!(port); + args["timeout"] = json!(10000); + Ok(()) +} diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index e1247be7..16381f15 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -15,6 +15,7 @@ struct Script { run_in_terminal: Option>, /// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives. start_debugging: bool, + ignore_configuration_done: bool, source_path: Option, } @@ -131,7 +132,9 @@ async fn fake_adapter(stream ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"})) } "configurationDone" => { - ok!(msg, Value::Null); + if !script.ignore_configuration_done { + ok!(msg, Value::Null); + } if let Some(l) = pending_launch.take() { send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true})); } @@ -1018,6 +1021,68 @@ fn root_and_address_placeholders() { assert_eq!(adapter::parse_address("6006"), None); } +#[tokio::test] +async fn kotlin_configures_without_configuration_done_response() { + let h = harness(Script { ignore_configuration_done: true, ..Default::default() }); + let project = tempfile::tempdir().unwrap(); + let path = project.path().join("Main.kt"); + std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap(); + h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap(); + let mut config = config(); + config.adapter = kotlin::ADAPTER.into(); + config.request = "attach".into(); + config.cwd = Some(project.path().to_owned().into()); + config.extra_json = Some(json!({"projectRoot": project.path(), "hostName": "127.0.0.1", "port": 5005}).to_string()); + let id = tokio::time::timeout(Duration::from_secs(2), h.start(config)).await.unwrap().unwrap(); + let root = PathBuf::from(h.request("attach")["arguments"]["projectRoot"].as_str().unwrap()); + assert!(!root.starts_with(project.path())); + let request = h.request("setBreakpoints"); + assert_eq!(request["arguments"]["source"]["name"], "Main.kt"); + let mapped = PathBuf::from(request["arguments"]["source"]["path"].as_str().unwrap()); + assert!(mapped.starts_with(&root)); + assert!(mapped.is_file()); + assert_eq!(h.manager.session(id).unwrap().client_path(mapped.to_str().unwrap()), Some(path)); + h.manager.stop(id).await.unwrap(); +} + +/// Requires a compiled no-delay reporter fixture (Main.kt, package org.example, +/// loop at line 5, println(i) at line 6), and the unmodified official adapter. +/// KOTLIN_PROBE_CONFIG is a LaunchConfig JSON file; KOTLIN_PROBE_ADAPTER is its executable. +#[tokio::test] +#[ignore = "requires official Kotlin adapter and compiled reporter fixture"] +async fn kotlin_official_release_ten_no_delay_runs() { + let config: LaunchConfig = serde_json::from_slice( + &std::fs::read(std::env::var("KOTLIN_PROBE_CONFIG").expect("KOTLIN_PROBE_CONFIG")).unwrap(), + ) + .unwrap(); + let adapter = std::env::var("KOTLIN_PROBE_ADAPTER").expect("KOTLIN_PROBE_ADAPTER"); + let source = PathBuf::from(config.cwd.clone().unwrap()).join("src/main/kotlin/Main.kt"); + for run in 1..=10 { + let (owner, events) = recording_owner("kotlin-probe"); + let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new()))); + let h = Harness { manager, owner, events, log: Arc::default() }; + h.manager.set_breakpoints(source.clone(), vec![bp(5)]).await.unwrap(); + let id = h.manager.start_with(config.clone(), h.owner.clone(), |_| Some(adapter.clone())).await.unwrap(); + let stopped = h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "breakpoint")).await; + let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() }; + let frames = h.manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!(frames[0].line, 5); + assert_eq!(frames[0].path.as_deref(), Some(source.as_path())); + let scopes = h.manager.scopes(id, frames[0].id).await.unwrap(); + let mut variables = Vec::new(); + for scope in scopes { + variables.extend(h.manager.variables(id, scope.variables_reference).await.unwrap()); + } + assert!(variables.iter().any(|v| v.name == "i" && v.value == "0")); + h.manager.step(id, thread_id, StepKind::Over).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await; + let frames = h.manager.stack_trace(id, thread_id).await.unwrap(); + assert_eq!(frames[0].line, 4); + h.manager.stop(id).await.unwrap(); + println!("Run {run}/10: breakpoint Main.kt:6, i=0, step Main.kt:5; stopped"); + } +} + #[tokio::test] async fn java_windows_source_paths_for_launch_and_attach() { for request in ["launch", "attach"] { diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index a115a412..428d898c 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -19,6 +19,40 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide Ok(project) => project.settings.effective(), Err(_) => host.global_settings.effective(), }; + if config.adapter == ide_dap::kotlin::ADAPTER { + let data_dir = host.data_dir.clone(); + let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); + let runtime_settings = settings.clone(); + let runtime = host + .blocking(move || { + if let Some(jdk) = ide_sdk::apply::jdtls_jdk(&runtime_root, &runtime_settings) + .map_err(ide_core::IdeError::unavailable)? + { + if jdk.major >= 11 { + return Ok(jdk); + } + return Err(ide_core::IdeError::unavailable( + "Kotlin debugger needs JDK 11 or newer. Choose a newer language server JDK in Settings.", + )); + } + let jdks = ide_sdk::java::jdtls_jdks(11, &data_dir); + ide_sdk::java::jdtls_runtime(&jdks, 11).cloned().ok_or_else(|| { + ide_core::IdeError::unavailable( + "Kotlin debugger needs JDK 11 or newer. Download or choose a language server JDK in Settings.", + ) + }) + }) + .await?; + let mut extra: serde_json::Value = config + .extra_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| ide_core::IdeError::invalid(e.to_string()))? + .unwrap_or_else(|| serde_json::json!({})); + extra["__adapterJavaHome"] = serde_json::json!(runtime.home); + config.extra_json = Some(extra.to_string()); + } let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let custom = |command: &str| { let path = ide_sdk::apply::tool_command(&root, &settings, command)?; diff --git a/crates/ide-host/src/ops/doc.rs b/crates/ide-host/src/ops/doc.rs index c6b190dc..dd969387 100644 --- a/crates/ide-host/src/ops/doc.rs +++ b/crates/ide-host/src/ops/doc.rs @@ -28,12 +28,14 @@ pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: C pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> { let project = host.project(window)?; - host.blocking(move || project.documents.save(doc)).await + host.blocking(move || project.documents.save(doc)).await?; + host.debugger.refresh_source_mirrors(host.owner_window(window)).await } pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> { let project = host.project(window)?; - host.blocking(move || project.documents.save_all()).await + host.blocking(move || project.documents.save_all()).await?; + host.debugger.refresh_source_mirrors(host.owner_window(window)).await } pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult { diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 89a4e99c..a43eca54 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -21,6 +21,8 @@ pub async fn java_resolve_launch( let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); + // Let jdtls finish its build before the external compiler writes Kotlin/Scala classes. + let mut info = project.lsp.java_launch(&module, test, build).await?; let built = if build && compile::has_non_java_sources(&module) { let (root, cache, module, settings) = (project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone()); @@ -32,7 +34,6 @@ pub async fn java_resolve_launch( } else { Vec::new() }; - let mut info = project.lsp.java_launch(&module, test, build).await?; let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); for p in std::mem::take(&mut info.class_paths) { if !class_paths.contains(&p) { diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 5711664b..91c4912c 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -456,3 +456,13 @@ exit 0"#, let out = std::process::Command::new(&wrapper).output().unwrap(); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "/opt/dotnet"); } + +#[test] +fn kotlin_debugger_uses_existing_release_recipe_and_language_definition() { + let spec = registry::find("kotlin-debug-adapter").unwrap(); + assert!(matches!( + spec.method, + Method::Release { source: ReleaseSource::GitHub { repo: "fwcd/kotlin-debug-adapter" }, .. } + )); + assert!(langs().iter().any(|l| l.debugger.as_ref().is_some_and(|d| d.adapter == spec.name))); +} diff --git a/docs/kotlin-debugging.md b/docs/kotlin-debugging.md new file mode 100644 index 00000000..826fde36 --- /dev/null +++ b/docs/kotlin-debugging.md @@ -0,0 +1,63 @@ +# Kotlin debugging + +Kotlin Application configurations use the official `fwcd/kotlin-debug-adapter` +release, installed through Settings > Language Servers or the missing-debugger +prompt. A module containing `.kt` files under `src` uses this adapter for both +Application launch and Maven/Gradle JDWP attach, including mixed Java/Kotlin +modules. Pure Java modules keep `java-debug`. + +The adapter runs on the language-server JDK (11+). The application runs on its +resolved project JDK, with the classpath, VM arguments, program arguments and +environment from its run configuration. + +## Source paths + +The adapter infers JVM class names from source paths. OpenIDEA creates a temporary, +package-aligned source mirror outside the project and supplies it as the adapter's +`projectRoot`. For example, `src/main/kotlin/Main.kt` declaring `package org.example` +gets an external `src/main/kotlin/org/example/Main.kt` alias. Breakpoints go through +that alias; stack frames and event paths map back to the original editor file. +Source sets have separate mirror roots. Where file symlinks are unavailable, +OpenIDEA copies sources and refreshes the copies on editor saves. + +Restart debugging after changing a package or source root. The upstream adapter's +path-based resolver does not support renamed Kotlin facades (`@file:JvmName`), or +packages containing a literal `java` or `kotlin` component. Selecting the correct +main class does not remove those breakpoint limitations. + +The Kotlin Application launcher currently passes the classpath directly, without +Java's large-command-line shortening, and decodes application output as UTF-8. +Very large classpaths and non-UTF-8 application output remain limitations; Windows +runtime behavior has not been verified. + +## Startup ordering + +OpenIDEA starts Kotlin applications with JDWP `suspend=y` and attaches the adapter. +It waits for each `setBreakpoints` response before sending `configurationDone`. +Adapter 0.4.4 never completes the `configurationDone` response, so OpenIDEA sends +that request without awaiting its response. + +The official adapter can resume the VMStart event before installing its breakpoint +hooks and DAP listeners. A session-owned loopback JDWP relay holds VM and thread +resume commands until the successful DAP attach response, which follows listener +installation. Other JDWP requests and replies continue during setup. Attach failure +or session shutdown closes the relay and kills any JVM launched by OpenIDEA. No +adapter patch or timed startup sleep is used. An already-running external JVM must +be started with `suspend=y` to preserve its earliest breakpoints. + +## Verification + +`cargo test -p ide-dap --lib` covers relay ordering, fragmented packets, cleanup, +source mapping, and the adapter's missing configuration response. The ignored +`kotlin_official_release_ten_no_delay_runs` test exercises the real DAP client ten +times with an official adapter and a compiled reporter fixture. Set +`KOTLIN_PROBE_CONFIG` to a LaunchConfig JSON file and `KOTLIN_PROBE_ADAPTER` to the +release executable, then run: + +```sh +cargo test -p ide-dap kotlin_official_release_ten_no_delay_runs -- --ignored --nocapture +``` + +The fixture is `src/main/kotlin/Main.kt`, with `package org.example`, a `for (i in +0..3)` loop at line 5 and `println(i)` at line 6, without any startup delay. Each run +must hit line 6, expose `i = 0`, step to line 5, and stop its session. diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index 0dde9a59..323fa32c 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -3,6 +3,7 @@ import type { RunConfig } from "@/platform/ipc"; const invoked: Array<[string, unknown[]]> = []; let shortenCommandLine: string | null = null; +let kotlinFiles = false; vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); @@ -10,6 +11,12 @@ vi.mock("@/platform/ipc", async (orig) => { ...real, commands: { ...real.commands, + fsReadDir: (path: string) => + ok( + kotlinFiles && path.endsWith("/src") + ? [{ name: "Main.kt", path: `${path}/Main.kt`, kind: "file", excluded: false }] + : [], + ), javaResolveLaunch: (...a: unknown[]) => { invoked.push(["javaResolveLaunch", a]); return ok({ @@ -52,6 +59,7 @@ describe("Java debugging (java-debug through jdtls)", () => { beforeEach(() => { invoked.length = 0; shortenCommandLine = null; + kotlinFiles = false; }); it("launches Application configs with the resolved classpath on jdtls' debug server", async () => { @@ -100,6 +108,36 @@ describe("Java debugging (java-debug through jdtls)", () => { } }); + it("routes a Kotlin main to the Kotlin adapter and preserves JVM launch arguments", async () => { + const config = { ...app, debugAdapter: "kotlin-debug-adapter" }; + expect(isJavaDebuggable(config)).toBe(true); + const l = await javaLaunchConfig(config, "/p"); + expect(l).toMatchObject({ adapter: "kotlin-debug-adapter", request: "launch", cwd: "/p/api", env: { A: "1" } }); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + projectRoot: "/p/api", + mainClass: "a.Main", + classPaths: ["/p/target/classes", "/m2/a.jar"], + javaExec: "/jdk21/bin/java", + vmArguments: ["-Xmx1g"], + programArgs: ["--port", "8081 x"], + }); + expect(invoked.some(([command]) => command === "javaDebugServer")).toBe(false); + }); + + it("routes launch and JDWP attach in a mixed module to Kotlin without starting jdtls' adapter", async () => { + kotlinFiles = true; + expect((await javaLaunchConfig(app, "/p")).adapter).toBe("kotlin-debug-adapter"); + const l = await javaAttachConfig({ name: "mvn test", module: "api", hostName: "remote", port: 5005 }, "/p"); + expect(l.adapter).toBe("kotlin-debug-adapter"); + expect(JSON.parse(l.extraJson!)).toEqual({ + projectRoot: "/p/api", + hostName: "remote", + port: 5005, + timeout: 30_000, + }); + expect(invoked.some(([command]) => command === "javaDebugServer")).toBe(false); + }); + it("attaches to a JDWP agent", async () => { const l = await javaAttachConfig({ name: "mvn test", module: "", hostName: "127.0.0.1", port: 5005 }, "/p"); expect(l).toMatchObject({ request: "attach", cwd: "/p" }); diff --git a/src/features/debugger/__tests__/missingAdapter.test.ts b/src/features/debugger/__tests__/missingAdapter.test.ts index 4b3dd322..656ba956 100644 --- a/src/features/debugger/__tests__/missingAdapter.test.ts +++ b/src/features/debugger/__tests__/missingAdapter.test.ts @@ -18,6 +18,9 @@ describe("missing debug adapters", () => { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed", }); + expect( + missingAdapter("kotlin-debug-adapter", unavailable("debug adapter `kotlin-debug-adapter` not found (x)"))?.tool, + ).toBe("kotlin-debug-adapter"); expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve"); }); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index 5f5f93c4..2c73b066 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -16,9 +16,10 @@ import { import { notifications } from "@/workbench/contributions"; export const JAVA_ADAPTER = "java-debug"; +export const KOTLIN_ADAPTER = "kotlin-debug-adapter"; export function isJavaDebuggable(rc: RunConfig): boolean { - return isJvmApplication(rc) && (rc.debugAdapter ?? JAVA_ADAPTER) === JAVA_ADAPTER; + return isJvmApplication(rc) && [JAVA_ADAPTER, KOTLIN_ADAPTER].includes(rc.debugAdapter ?? JAVA_ADAPTER); } function join(base: string, p: string) { @@ -27,6 +28,24 @@ function join(base: string, p: string) { return rel === "." || rel === "" ? base : `${base.replace(/\/$/, "")}/${rel}`; } +/** Conventional JVM source sets, including Kotlin kept in a Java source directory. + * A mixed module uses Kotlin's adapter, which also accepts Java line breakpoints. + */ +export async function moduleHasKotlinSources(cwd: string): Promise { + const pending = [join(cwd, "src")]; + while (pending.length) { + const path = pending.pop()!; + const entries = await call(commands.fsReadDir(path)).catch(() => []); + for (const entry of entries) { + if (entry.excluded) continue; + if ((entry.kind === "file" || entry.targetKind === "file") && entry.name.endsWith(".kt")) return true; + // Do not follow directory symlinks: a source tree can link back to a parent. + if (entry.kind === "dir") pending.push(entry.path); + } + } + return false; +} + /** java-debug missing from jdtls: offer the one-click install (jdtls restarts with the bundle). */ function offerInstall(e: unknown): never { if (e instanceof IpcError && e.kind === "unavailable" && /java-debug|debugger/i.test(e.message)) { @@ -67,8 +86,29 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise { + const cwd = target.module ? join(root, target.module) : root; + if (await moduleHasKotlinSources(cwd)) { + return { + name: target.name, + adapter: KOTLIN_ADAPTER, + request: "attach", + program: null, + args: [], + cwd, + env: {}, + extraJson: JSON.stringify({ projectRoot: cwd, hostName: target.hostName, port: target.port, timeout: 30_000 }), + }; + } const port = await debugServer(target.module); return { name: target.name, diff --git a/src/features/debugger/missingAdapter.ts b/src/features/debugger/missingAdapter.ts index 7d13beec..8106ea51 100644 --- a/src/features/debugger/missingAdapter.ts +++ b/src/features/debugger/missingAdapter.ts @@ -6,6 +6,7 @@ import { IpcError } from "@/platform/ipc"; /** Adapters the tools installer can provide (adapter id → tool name). */ const INSTALLABLE: Record = { + "kotlin-debug-adapter": { tool: "kotlin-debug-adapter", title: "The Kotlin debugger is not installed" }, debugpy: { tool: "debugpy", title: "debugpy is not installed" }, "js-debug": { tool: "js-debug", title: "The JavaScript debugger (vscode-js-debug) is not installed" }, delve: { tool: "delve", title: "Delve is not installed" }, diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 6933e025..3f3259b8 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -36,6 +36,7 @@ import styles from "./Run.module.css"; */ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [ { id: "java-debug", label: "java-debug (Java)" }, + { id: "kotlin-debug-adapter", label: "kotlin-debug-adapter (Kotlin)" }, { id: "debugpy", label: "debugpy (Python)" }, { id: "js-debug", label: "js-debug (Node.js / TypeScript)" }, { id: "delve", label: "Delve (Go)" }, diff --git a/src/features/run/application.ts b/src/features/run/application.ts index 03453527..681ac8e5 100644 --- a/src/features/run/application.ts +++ b/src/features/run/application.ts @@ -45,7 +45,7 @@ export function applicationConfig( env: {}, detected: true, isTest: false, - debugAdapter: "java-debug", + debugAdapter: relPath.endsWith(".kt") ? "kotlin-debug-adapter" : "java-debug", }; } diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 079d8252..6750d3d3 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -140,6 +140,20 @@ describe("application configurations", () => { expect(jvmModuleDir("odd/App.java", [cfg({ id: "m", kind: "maven", cwd: "odd" })])).toBe("odd"); }); + it("adds JDWP to a script explicitly assigned the Kotlin debugger", () => { + const rc = cfg({ id: "kotlin-script", kind: "shell", command: "./run.sh", debugAdapter: "kotlin-debug-adapter" }); + expect(jdwpLaunch(rc)?.env.JAVA_TOOL_OPTIONS).toContain("-agentlib:jdwp="); + }); + + it("routes Kotlin facade and @file:JvmName Application configurations to Kotlin", () => { + for (const className of ["org.example.MainKt", "org.example.CustomFacade"]) { + expect(applicationConfig({ className }, "src/main/kotlin/Main.kt")).toMatchObject({ + debugAdapter: "kotlin-debug-adapter", + args: ["-cp", "${classpath}", className], + }); + } + }); + it("creates an Application / Spring Boot config named after the class", () => { const rel = "src/main/java/com/example/demo/SpringDemoApplication.java"; const src = diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index 187d0257..e62c82be 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -18,7 +18,7 @@ const isMavenTestGoal = (a: string) => const isGradleJvmTask = (a: string) => /(^|:)(test|bootRun|run|integrationTest|check)$/.test(a); /** A plugin goal that runs the application in Maven's own JVM (`tomcat7:run`, `jetty:run-war`, `exec:java`). */ const isMavenInProcessGoal = (a: string) => /^[\w.-]+(:[\w.-]+)*:(run|run-war|run-exploded|java)$/.test(a); -const JAVA_ADAPTER = "java-debug"; +const JVM_ADAPTERS = ["java-debug", "kotlin-debug-adapter"]; function withOption(env: Record, key: string, value: string): Record { const current = env[key]?.trim(); @@ -50,7 +50,7 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null { if (failsafe) out = withProperty(out, "maven.failsafe.debug", JDWP_AGENT); return { ...config, args: out }; } - if (config.debugAdapter === JAVA_ADAPTER || args.some(isMavenInProcessGoal)) + if (JVM_ADAPTERS.includes(config.debugAdapter ?? "") || args.some(isMavenInProcessGoal)) return { ...config, env: withOption(config.env, "MAVEN_OPTS", JDWP_AGENT) }; return null; } @@ -59,7 +59,7 @@ export function jdwpLaunch(config: RunConfig): RunConfig | null { return args.includes("--debug-jvm") ? config : { ...config, args: [...args, "--debug-jvm"] }; } // Application / Spring Boot configurations are launched by java-debug itself. - if (config.debugAdapter === JAVA_ADAPTER && !isJvmApplication(config)) + if (JVM_ADAPTERS.includes(config.debugAdapter ?? "") && !isJvmApplication(config)) return { ...config, env: withOption(config.env, "JAVA_TOOL_OPTIONS", JDWP_AGENT) }; return null; } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fc7bbc49..ac2c173a 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2133,6 +2133,7 @@ "Text or hash": "文本或哈希", "Text selected in the editor": "编辑器中选中的文本", "Text to find": "要查找的文本", + "The Kotlin debugger is not installed": "未安装 Kotlin 调试器", "The Java debugger is not installed": "未安装 Java 调试器", "The action already has this shortcut": "该操作已有此快捷键", "The active changelist cannot be deleted": "无法删除活动更改列表", diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 10c0ea50..82c2e251 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -114,6 +114,7 @@ const UNTRANSLATED = new Set([ "Greenplum", "Java", "java-debug (Java)", + "kotlin-debug-adapter (Kotlin)", "js-debug (Node.js / TypeScript)", "JSON", "lldb-dap (C, C++, Swift)",