use std::sync::OnceLock;
use regex::Regex;
use serde_json::{Map, Value};
use super::canonical::{BreakLoc, CanonicalOps, HitEvent, unsupported};
use super::{Backend, Dependency, DependencyCheck, SpawnConfig};
pub struct JdbBackend;
impl Backend for JdbBackend {
fn name(&self) -> &'static str {
"jdb"
}
fn description(&self) -> &'static str {
"Java/Kotlin debugger"
}
fn types(&self) -> &'static [&'static str] {
&["java", "kotlin"]
}
fn spawn_config(&self, target: &str, args: &[String]) -> anyhow::Result<SpawnConfig> {
let mut spawn_args = vec![target.into()];
spawn_args.extend(args.iter().cloned());
Ok(SpawnConfig {
bin: "jdb".into(),
args: spawn_args,
env: vec![],
init_commands: vec![],
})
}
fn prompt_pattern(&self) -> &str {
r"(\n> \z|\n\w+\[\d+\] \z)"
}
fn dependencies(&self) -> Vec<Dependency> {
vec![Dependency {
name: "jdb",
check: DependencyCheck::Binary {
name: "jdb",
alternatives: &["jdb"],
version_cmd: None,
},
install: "sudo apt install default-jdk # or: brew install openjdk",
}]
}
fn format_breakpoint(&self, spec: &str) -> String {
format!("stop at {spec}")
}
fn run_command(&self) -> &'static str {
"run"
}
fn parse_help(&self, raw: &str) -> String {
super::parse_help_first_token(raw, "jdb", false, |tok| {
tok.len() > 1
&& tok.len() < 20
&& tok.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
})
}
fn clean(&self, _cmd: &str, output: &str) -> String {
let mut lines = Vec::new();
let mut saw_deferred_bp = false;
let mut saw_bp_hit = false;
let mut saw_exit = false;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Set breakpoint") || trimmed.starts_with("Deferring breakpoint")
{
saw_deferred_bp = true;
continue;
}
if trimmed.starts_with("Breakpoint hit") {
saw_bp_hit = true;
}
if trimmed.starts_with("The application exited")
|| trimmed.starts_with("The application has been disconnected")
{
saw_exit = true;
}
if trimmed.contains("thread")
&& (trimmed.contains("started") || trimmed.contains("died"))
{
continue;
}
lines.push(line);
}
let mut out = lines.join("\n");
if saw_deferred_bp && saw_exit && !saw_bp_hit {
if !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out.push_str(
"[hint] breakpoint did not fire before the program exited. \
Verify the class:line is reachable, or — if the class was \
compiled without debug info — recompile with `javac -g` \
so jdb can resolve line numbers.",
);
}
out
}
fn adapters(&self) -> Vec<(&'static str, &'static str)> {
vec![(
"java.md",
include_str!("../../skills/references/adapters/java.md"),
)]
}
fn canonical_ops(&self) -> Option<&dyn CanonicalOps> {
Some(self)
}
}
impl CanonicalOps for JdbBackend {
fn tool_name(&self) -> &'static str {
"jdb"
}
fn auto_capture_locals(&self) -> bool {
false
}
fn op_break(&self, loc: &BreakLoc) -> anyhow::Result<String> {
Ok(match loc {
BreakLoc::FileLine { file, line } => {
let class = jdb_location_class(file, *line);
format!("stop at {class}:{line}")
}
BreakLoc::Fqn(name) => format!("stop at {name}"),
BreakLoc::ModuleMethod { module, method } => format!("stop at {module}.{method}"),
})
}
fn op_run(&self, _args: &[String]) -> anyhow::Result<String> {
Ok("run".into())
}
fn op_continue(&self) -> anyhow::Result<String> {
Ok("cont".into())
}
fn op_step(&self) -> anyhow::Result<String> {
Ok("step".into())
}
fn op_next(&self) -> anyhow::Result<String> {
Ok("next".into())
}
fn op_finish(&self) -> anyhow::Result<String> {
Ok("step up".into())
}
fn op_stack(&self, _n: Option<u32>) -> anyhow::Result<String> {
Ok("where".into())
}
fn op_frame(&self, n: u32) -> anyhow::Result<String> {
let _ = n;
Err(unsupported("jdb", "absolute frame selection"))
}
fn op_locals(&self) -> anyhow::Result<String> {
Ok("locals".into())
}
fn op_print(&self, expr: &str) -> anyhow::Result<String> {
Ok(format!("print {expr}"))
}
fn op_threads(&self) -> anyhow::Result<String> {
Ok("threads".into())
}
fn op_thread(&self, n: u32) -> anyhow::Result<String> {
Ok(format!("thread {n}"))
}
fn op_list(&self, _loc: Option<&str>) -> anyhow::Result<String> {
Ok("list".into())
}
fn op_breaks(&self) -> anyhow::Result<String> {
Ok("clear".into())
}
fn parse_hit(&self, output: &str) -> Option<HitEvent> {
static BP_RE: OnceLock<Regex> = OnceLock::new();
let bp_re = BP_RE.get_or_init(|| {
Regex::new(
r#"Breakpoint hit:.*?"thread=([^"]+)".*?([A-Za-z_$][\w$.]*\.[A-Za-z_$][\w$]*)\([^)]*\)[^,]*,\s*line=(\d+)"#,
)
.unwrap()
});
static STEP_RE: OnceLock<Regex> = OnceLock::new();
let step_re = STEP_RE.get_or_init(|| {
Regex::new(
r#"Step completed:.*?"thread=([^"]+)".*?([A-Za-z_$][\w$.]*\.[A-Za-z_$][\w$]*)\([^)]*\)[^,]*,\s*line=(\d+)"#,
)
.unwrap()
});
let parse_with = |re: &Regex| -> Option<HitEvent> {
for line in output.lines() {
if let Some(c) = re.captures(line) {
let thread = c[1].to_string();
let symbol = c[2].to_string();
let line_no: u32 = c[3].parse().ok()?;
let class_part = symbol.rsplit_once('.').map(|x| x.0).unwrap_or(&symbol);
let outer_class = class_part
.rsplit_once('$')
.map(|x| x.0)
.unwrap_or(class_part);
return Some(HitEvent {
location_key: format!("{outer_class}:{line_no}"),
thread: Some(thread),
frame_symbol: Some(symbol),
file: None,
line: Some(line_no),
});
}
}
None
};
parse_with(bp_re).or_else(|| parse_with(step_re))
}
fn parse_locals(&self, output: &str) -> Option<Value> {
let mut obj = Map::new();
for line in output.lines() {
let line = line.trim();
if let Some((name, val)) = line.split_once(" = ") {
let name = name.trim().to_string();
if name.is_empty() || name.contains(' ') {
continue;
}
let mut entry = Map::new();
entry.insert("value".into(), Value::String(val.trim().to_string()));
obj.insert(name, Value::Object(entry));
}
}
if obj.is_empty() {
None
} else {
Some(Value::Object(obj))
}
}
}
fn jdb_class_name(file: &str) -> String {
let stem = file
.strip_suffix(".java")
.or_else(|| file.strip_suffix(".kt"))
.unwrap_or(file)
.replace('\\', "/");
let relative = [
"src/main/java/",
"src/test/java/",
"src/",
"java/",
"kotlin/",
]
.iter()
.find_map(|root| stem.find(root).map(|i| &stem[i + root.len()..]));
let class = relative.unwrap_or_else(|| {
if stem.starts_with('/') {
stem.rsplit('/').next().unwrap_or(&stem)
} else {
&stem
}
});
class.replace('/', ".")
}
fn jdb_location_class(file: &str, line: u32) -> String {
let outer = jdb_class_name(file);
let path = std::path::Path::new(file);
let source_path = if path.is_file() {
path.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(path)
} else {
return outer;
};
let Ok(source) = std::fs::read_to_string(source_path) else {
return outer;
};
let source = sanitize_java_source(&source);
static CLASS_RE: OnceLock<Regex> = OnceLock::new();
let class_re = CLASS_RE.get_or_init(|| {
Regex::new(r"\b(?:class|interface|enum|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)").unwrap()
});
let mut depth = 0usize;
let mut classes: Vec<(String, usize)> = Vec::new();
let mut pending = Vec::new();
for (index, text) in source.lines().enumerate() {
let source_line = index as u32 + 1;
let declarations: Vec<(usize, String)> = class_re
.captures_iter(text)
.filter_map(|capture| Some((capture.get(0)?.start(), capture[1].to_string())))
.collect();
let mut declaration_index = 0;
if source_line == line {
let nested: Vec<&str> = classes
.iter()
.skip(1)
.map(|(name, _)| name.as_str())
.collect();
if !nested.is_empty() {
return format!("{}${}", outer, nested.join("$"));
}
}
for (position, character) in text.char_indices() {
match character {
'{' => {
if declaration_index < declarations.len()
&& declarations[declaration_index].0 < position
{
pending.push(declarations[declaration_index].1.clone());
declaration_index += 1;
}
if let Some(name) = pending.pop() {
classes.push((name, depth));
}
depth += 1;
}
'}' => {
depth = depth.saturating_sub(1);
while classes
.last()
.is_some_and(|(_, open_depth)| *open_depth >= depth)
{
classes.pop();
}
}
_ => {}
}
}
pending.extend(
declarations
.into_iter()
.skip(declaration_index)
.map(|(_, name)| name),
);
}
outer
}
fn sanitize_java_source(source: &str) -> String {
let mut clean = String::with_capacity(source.len());
let bytes = source.as_bytes();
let mut index = 0;
let mut block_comment = false;
let mut quote = None;
let mut text_block = false;
while index < bytes.len() {
if block_comment {
if bytes[index..].starts_with(b"*/") {
clean.push(' ');
clean.push(' ');
index += 2;
block_comment = false;
} else {
clean.push(if bytes[index] == b'\n' { '\n' } else { ' ' });
index += 1;
}
continue;
}
if text_block {
if bytes[index..].starts_with(b"\"\"\"") {
clean.push_str(" ");
index += 3;
text_block = false;
} else {
clean.push(if bytes[index] == b'\n' { '\n' } else { ' ' });
index += 1;
}
continue;
}
if let Some(end_quote) = quote {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
clean.push(' ');
clean.push(if bytes[index + 1] == b'\n' { '\n' } else { ' ' });
index += 2;
} else if bytes[index] as char == end_quote {
clean.push(' ');
index += 1;
quote = None;
} else {
clean.push(if bytes[index] == b'\n' { '\n' } else { ' ' });
index += 1;
}
continue;
}
if bytes[index..].starts_with(b"//") {
clean.push_str(" ");
index += 2;
while index < bytes.len() && bytes[index] != b'\n' {
clean.push(' ');
index += 1;
}
continue;
}
if bytes[index..].starts_with(b"/*") {
clean.push_str(" ");
index += 2;
block_comment = true;
continue;
}
if bytes[index..].starts_with(b"\"\"\"") {
clean.push_str(" ");
index += 3;
text_block = true;
continue;
}
if bytes[index] == b'"' || bytes[index] == b'\'' {
clean.push(' ');
quote = Some(bytes[index] as char);
index += 1;
continue;
}
clean.push(bytes[index] as char);
index += 1;
}
clean
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_breakpoint() {
assert_eq!(JdbBackend.format_breakpoint("Main:10"), "stop at Main:10");
}
#[test]
fn canonical_frame_does_not_translate_absolute_index_to_relative_up() {
let error = JdbBackend.op_frame(2).unwrap_err();
assert!(error.to_string().contains("not supported"));
}
#[test]
fn clean_extracts_breakpoint_events() {
let input = "Set breakpoint at Main:10\nnormal output\nDeferring breakpoint Main:20";
let r = JdbBackend.clean("stop at Main:10", input);
assert_eq!(r, "normal output");
}
#[test]
fn clean_hints_at_missing_g_when_program_exits_without_hit() {
let input = "\
Deferring breakpoint Broken:40\n\
It will be set after the class is loaded.\n\
...\n\
The application exited";
let r = JdbBackend.clean("run", input);
assert!(
r.to_lowercase().contains("javac -g") || r.to_lowercase().contains("debug info"),
"expected -g hint when bp didn't fire before exit, got: {}",
r
);
}
#[test]
fn clean_no_hint_when_breakpoint_fired() {
let input = "\
Deferring breakpoint Broken:40\n\
It will be set after the class is loaded.\n\
Breakpoint hit: \"thread=main\", Broken.main(), line=40 bci=0\n\
The application exited";
let r = JdbBackend.clean("run", input);
assert!(
!r.to_lowercase().contains("javac -g"),
"hint must not fire when a breakpoint was hit: {}",
r
);
}
#[test]
fn clean_extracts_thread_events() {
let input = "thread \"main\" started\noutput\nthread \"worker\" died";
let r = JdbBackend.clean("run", input);
assert_eq!(r, "output");
}
#[test]
fn parse_hit_breakpoint_banner() {
let raw = "> \nBreakpoint hit: \"thread=main\", Algos.fibonacci(), line=17 bci=13\n17 long next = a + b;\n\nmain[1] ";
let hit = JdbBackend.parse_hit(raw);
assert!(
hit.is_some(),
"parse_hit should match jdb breakpoint banner"
);
let hit = hit.unwrap();
assert_eq!(hit.thread.as_deref(), Some("main"));
assert_eq!(hit.frame_symbol.as_deref(), Some("Algos.fibonacci"));
assert_eq!(hit.line, Some(17));
}
#[test]
fn parse_hit_line_breakpoint_with_args() {
let raw =
"Breakpoint hit: \"thread=main\", Broken.merge(int[], int, int), line=35 bci=12\n";
let hit = JdbBackend
.parse_hit(raw)
.expect("should match parameterized method");
assert_eq!(hit.line, Some(35));
assert_eq!(hit.location_key, "Broken:35");
assert_eq!(hit.frame_symbol.as_deref(), Some("Broken.merge"));
}
#[test]
fn parse_hit_nested_class() {
let raw = "Breakpoint hit: \"thread=main\", com.x.Outer$Inner.run(), line=42 bci=0";
let hit = JdbBackend.parse_hit(raw).expect("nested class");
assert_eq!(hit.line, Some(42));
assert_eq!(hit.location_key, "com.x.Outer:42");
}
#[test]
fn file_breakpoint_keeps_java_package_qualifier() {
assert_eq!(
JdbBackend
.op_break(&BreakLoc::FileLine {
file: "src/com/example/Main.java".into(),
line: 12,
})
.unwrap(),
"stop at com.example.Main:12"
);
}
#[test]
fn file_breakpoint_targets_nested_class_at_source_line() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("Broken.java");
std::fs::write(
&source,
"public class Broken {\n static class Sku {\n boolean same() {\n return true;\n }\n }\n}\n",
)
.unwrap();
assert_eq!(
JdbBackend
.op_break(&BreakLoc::FileLine {
file: source.to_string_lossy().into_owned(),
line: 4,
})
.unwrap(),
"stop at Broken$Sku:4"
);
}
#[test]
fn file_breakpoint_ignores_braces_in_comments_and_strings() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("Broken.java");
std::fs::write(
&source,
"class Broken {\n static class Sku {\n String text = \"}\"; // }\n void run() {\n return;\n }\n }\n}\n",
)
.unwrap();
assert_eq!(
JdbBackend
.op_break(&BreakLoc::FileLine {
file: source.to_string_lossy().into_owned(),
line: 5,
})
.unwrap(),
"stop at Broken$Sku:5"
);
}
#[test]
fn file_breakpoint_accepts_class_brace_on_next_line() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("Broken.java");
std::fs::write(
&source,
"class Broken\n{\n static class Sku\n {\n void run() {}\n }\n}\n",
)
.unwrap();
assert_eq!(
JdbBackend
.op_break(&BreakLoc::FileLine {
file: source.to_string_lossy().into_owned(),
line: 5,
})
.unwrap(),
"stop at Broken$Sku:5"
);
}
#[test]
fn parse_locals_simple() {
let output = "a = 0\nb = 1\nnext = 1\ni = 0";
let v = JdbBackend.parse_locals(output).expect("should parse");
assert_eq!(
v.as_object()
.unwrap()
.get("a")
.unwrap()
.get("value")
.unwrap()
.as_str()
.unwrap(),
"0"
);
}
#[test]
fn parse_help_allows_hyphens() {
let raw = "stop-in Set breakpoint\ncont Continue execution\nx single-char excluded";
let result = JdbBackend.parse_help(raw);
assert!(result.contains("stop-in"));
assert!(result.contains("cont"));
assert!(!result.contains(", x,"));
}
}