diff --git a/Cargo.lock b/Cargo.lock index 7c05bd413..ab43cc1d3 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 f402a1aa2..a902900b7 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 117e3c90b..ade3d7c00 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 000000000..fd3aae30a --- /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 000000000..f0c49c7f0 --- /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 5ab70125b..550f85cfa 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 2ec511e83..38361fec3 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 e1247be71..16381f157 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 a115a4128..428d898c8 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 c6b190dc4..dd9693879 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 89a4e99c4..a43eca543 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 5711664b0..91c4912cb 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 000000000..826fde36f --- /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 0dde9a59f..323fa32cd 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 4b3dd322b..656ba9563 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 5f5f93c4f..2c73b0662 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 7d13beec7..8106ea519 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 6933e0253..3f3259b89 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 03453527b..681ac8e53 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 079d82529..6750d3d31 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 187d02574..e62c82be4 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 fc7bbc493..ac2c173a6 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 10c0ea509..82c2e2512 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)",