use std::sync::OnceLock;
use regex::Regex;
use serde_json::{Map, Value};
use super::canonical::{BreakLoc, CanonicalOps, HitEvent};
use super::{Backend, CleanResult, Dependency, DependencyCheck, SpawnConfig};
pub struct PhpdbgBackend;
impl Backend for PhpdbgBackend {
fn name(&self) -> &'static str {
"phpdbg"
}
fn description(&self) -> &'static str {
"PHP debugger"
}
fn types(&self) -> &'static [&'static str] {
&["php"]
}
fn spawn_config(&self, target: &str, args: &[String]) -> anyhow::Result<SpawnConfig> {
let mut spawn_args = vec!["-q".into(), "-e".into(), target.into()];
spawn_args.extend(args.iter().cloned());
Ok(SpawnConfig {
bin: "phpdbg".into(),
args: spawn_args,
env: vec![],
init_commands: vec![],
})
}
fn prompt_pattern(&self) -> &str {
r"prompt>"
}
fn dependencies(&self) -> Vec<Dependency> {
vec![Dependency {
name: "phpdbg",
check: DependencyCheck::Binary {
name: "phpdbg",
alternatives: &["phpdbg"],
version_cmd: None,
},
install: "sudo apt install php # phpdbg is bundled with PHP since 5.6",
}]
}
fn format_breakpoint(&self, spec: &str) -> String {
format!("break {spec}")
}
fn run_command(&self) -> &'static str {
"run"
}
fn quit_command(&self) -> &'static str {
"quit"
}
fn parse_help(&self, raw: &str) -> String {
let mut cmds: Vec<String> = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("phpdbg") || line.starts_with("To") {
continue;
}
if let Some(first) = line.split_whitespace().next() {
if first.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& first.len() < 20
&& first.len() > 1
{
cmds.push(first.to_string());
}
}
}
cmds.sort();
cmds.dedup();
format!("phpdbg: {}", cmds.join(", "))
}
fn adapters(&self) -> Vec<(&'static str, &'static str)> {
vec![("php.md", include_str!("../../skills/adapters/php.md"))]
}
fn clean(&self, cmd: &str, output: &str) -> CleanResult {
let trimmed = cmd.trim();
let mut events = Vec::new();
let mut lines = Vec::new();
for line in output.lines() {
let l = line.trim();
if l.starts_with("[Welcome to phpdbg") || l.starts_with("[Successful compilation") {
events.push(l.to_string());
continue;
}
if l.starts_with("[Breakpoint") || l.starts_with("[Break") {
events.push(l.to_string());
}
if (trimmed == "back" || trimmed == "t") && l.contains("phpdbg_exec") {
continue;
}
lines.push(line);
}
CleanResult {
output: lines.join("\n"),
events,
}
}
fn canonical_ops(&self) -> Option<&dyn CanonicalOps> { Some(self) }
}
impl CanonicalOps for PhpdbgBackend {
fn tool_name(&self) -> &'static str { "phpdbg" }
fn auto_capture_locals(&self) -> bool { false }
fn op_break(&self, loc: &BreakLoc) -> anyhow::Result<String> {
Ok(match loc {
BreakLoc::FileLine { file, line } => format!("break {file}:{line}"),
BreakLoc::Fqn(name) => format!("break {name}"),
BreakLoc::ModuleMethod { module, method } => format!("break {module}::{method}"),
})
}
fn op_run(&self, _args: &[String]) -> anyhow::Result<String> { Ok("run".into()) }
fn op_continue(&self) -> anyhow::Result<String> { Ok("continue".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("finish".into()) }
fn op_stack(&self, _n: Option<u32>) -> anyhow::Result<String> { Ok("back".into()) }
fn op_frame(&self, n: u32) -> anyhow::Result<String> { Ok(format!("frame {n}")) }
fn op_locals(&self) -> anyhow::Result<String> {
Ok("ev var_dump(get_defined_vars())".into())
}
fn op_print(&self, expr: &str) -> anyhow::Result<String> {
let trimmed = expr.trim();
let is_bare_ident = !trimmed.is_empty()
&& !trimmed.starts_with('$')
&& trimmed
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_');
if is_bare_ident {
Ok(format!("ev ${trimmed}"))
} else {
Ok(format!("ev {expr}"))
}
}
fn op_breaks(&self) -> anyhow::Result<String> {
Ok("info break".into())
}
fn op_list(&self, _loc: Option<&str>) -> anyhow::Result<String> { Ok("list".into()) }
fn parse_hit(&self, output: &str) -> Option<HitEvent> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"\[Break(?:point #\d+)? at (\S+):(\d+)").unwrap()
});
for line in output.lines() {
if let Some(c) = re.captures(line) {
let file = c[1].to_string();
let line_no: u32 = c[2].parse().ok()?;
return Some(HitEvent {
location_key: format!("{file}:{line_no}"),
thread: None,
frame_symbol: None,
file: Some(file),
line: Some(line_no),
});
}
}
None
}
fn parse_locals(&self, output: &str) -> Option<Value> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r#"\["(\w+)"\]\s*=>\s*(.+)"#).unwrap()
});
let mut obj = Map::new();
for line in output.lines() {
if let Some(c) = re.captures(line) {
let name = c[1].to_string();
let val = c[2].trim().to_string();
let clean_val = if let Some(inner) = val.strip_suffix(')') {
inner.rsplit_once('(').map(|(_, v)| v).unwrap_or(inner)
} else {
&val
};
let mut entry = Map::new();
entry.insert("value".into(), Value::String(clean_val.to_string()));
obj.insert(name, Value::Object(entry));
}
}
if obj.is_empty() { None } else { Some(Value::Object(obj)) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_breakpoint() {
assert_eq!(PhpdbgBackend.format_breakpoint("test.php:10"), "break test.php:10");
assert_eq!(PhpdbgBackend.format_breakpoint("my_function"), "break my_function");
}
#[test]
fn clean_extracts_breakpoint_events() {
let input = "[Breakpoint #0 at test.php:10]\nsome output\nmore output";
let r = PhpdbgBackend.clean("run", input);
assert!(r.events.iter().any(|e| e.contains("Breakpoint #0")));
assert!(r.output.contains("some output"));
}
#[test]
fn clean_filters_phpdbg_exec_from_backtrace() {
let input = "frame #0: test.php:10\nframe #1: phpdbg_exec stuff\nframe #2: test.php:5";
let r = PhpdbgBackend.clean("back", input);
assert!(!r.output.contains("phpdbg_exec"));
assert!(r.output.contains("frame #0"));
assert!(r.output.contains("frame #2"));
}
#[test]
fn clean_welcome_as_event() {
let input = "[Welcome to phpdbg, the interactive PHP debugger]\nready";
let r = PhpdbgBackend.clean("", input);
assert!(r.events.iter().any(|e| e.contains("Welcome")));
assert!(r.output.contains("ready"));
}
#[test]
fn clean_passthrough_normal_commands() {
let input = "$x = 42";
let r = PhpdbgBackend.clean("ev $x", input);
assert_eq!(r.output, "$x = 42");
assert!(r.events.is_empty());
}
#[test]
fn spawn_config_includes_target_and_args() {
let cfg = PhpdbgBackend
.spawn_config("test.php", &["--verbose".into()])
.unwrap();
assert_eq!(cfg.bin, "phpdbg");
assert!(cfg.args.contains(&"-q".to_string()));
assert!(cfg.args.contains(&"-e".to_string()));
assert!(cfg.args.contains(&"test.php".to_string()));
assert!(cfg.args.contains(&"--verbose".to_string()));
}
#[test]
fn parse_help_extracts_commands() {
let raw = "phpdbg help\nTo get help...\n exec e set execution context\n run r attempt execution\n step s step through\n break b set breakpoint";
let result = PhpdbgBackend.parse_help(raw);
assert!(result.contains("exec"));
assert!(result.contains("run"));
assert!(result.contains("step"));
assert!(result.contains("break"));
}
}