use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConnectMode {
#[default]
Stdio,
Socket,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdapterConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
pub file_types: Vec<String>,
#[serde(default)]
pub root_markers: Vec<String>,
#[serde(default)]
pub connect_mode: ConnectMode,
#[serde(default)]
pub launch_defaults: serde_json::Value,
#[serde(default)]
pub attach_defaults: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct ResolvedAdapter {
pub name: String,
pub resolved_command: PathBuf,
pub args: Vec<String>,
pub file_types: Vec<String>,
pub languages: Vec<String>,
pub root_markers: Vec<String>,
pub launch_defaults: serde_json::Value,
pub attach_defaults: serde_json::Value,
pub connect_mode: ConnectMode,
}
const EXTENSIONLESS_DEBUGGER_ORDER: &[&str] = &["gdb", "lldb-dap"];
fn load_defaults() -> HashMap<String, AdapterConfig> {
let raw: HashMap<String, AdapterConfig> = serde_json::from_str(include_str!("defaults.json"))
.expect("defaults.json is valid at compile time");
raw
}
fn resolve_command(command: &str) -> Option<PathBuf> {
which::which(command).ok()
}
fn has_root_markers(cwd: &Path, markers: &[String]) -> bool {
for marker in markers {
let candidate = cwd.join(marker);
if candidate.exists() {
return true;
}
}
false
}
pub fn resolve_adapter(name: &str) -> Option<ResolvedAdapter> {
let defaults = load_defaults();
let config = defaults.get(name)?;
let resolved_command = resolve_command(&config.command)?;
Some(ResolvedAdapter {
name: name.to_string(),
resolved_command,
args: config.args.clone(),
file_types: config.file_types.clone(),
root_markers: config.root_markers.clone(),
launch_defaults: config.launch_defaults.clone(),
attach_defaults: config.attach_defaults.clone(),
connect_mode: config.connect_mode,
languages: config.languages.clone(),
})
}
pub fn get_available_adapters() -> Vec<ResolvedAdapter> {
let defaults = load_defaults();
defaults
.keys()
.filter_map(|name| resolve_adapter(name))
.collect()
}
fn get_matching_adapters(program: &Path, cwd: &Path) -> Vec<ResolvedAdapter> {
let available = get_available_adapters();
let ext = program
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}").to_lowercase());
match ext {
None => {
let native: std::collections::HashSet<&str> =
EXTENSIONLESS_DEBUGGER_ORDER.iter().copied().collect();
available
.into_iter()
.filter(|a| {
native.contains(a.name.as_str())
|| (!a.root_markers.is_empty() && has_root_markers(cwd, &a.root_markers))
})
.collect()
}
Some(ref ext) => {
let (exact, rest): (Vec<_>, Vec<_>) = available
.into_iter()
.partition(|a| a.file_types.iter().any(|ft| ft.eq_ignore_ascii_case(ext)));
if exact.is_empty() { rest } else { exact }
}
}
}
fn sort_adapters_for_launch(program: &Path, cwd: &Path, adapters: &mut [ResolvedAdapter]) {
let ext = program
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}").to_lowercase());
adapters.sort_by(|left, right| {
let left_ext = ext
.as_ref()
.is_some_and(|e| left.file_types.iter().any(|ft| ft.eq_ignore_ascii_case(e)));
let right_ext = ext
.as_ref()
.is_some_and(|e| right.file_types.iter().any(|ft| ft.eq_ignore_ascii_case(e)));
if left_ext != right_ext {
return if left_ext {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let left_root = has_root_markers(cwd, &left.root_markers);
let right_root = has_root_markers(cwd, &right.root_markers);
if left_root != right_root {
return if left_root {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let left_rank = EXTENSIONLESS_DEBUGGER_ORDER
.iter()
.position(|n| *n == left.name)
.map_or(usize::MAX, |i| i);
let right_rank = EXTENSIONLESS_DEBUGGER_ORDER
.iter()
.position(|n| *n == right.name)
.map_or(usize::MAX, |i| i);
if left_rank != right_rank {
return left_rank.cmp(&right_rank);
}
left.name.cmp(&right.name)
});
}
pub fn select_launch_adapter(
program: &Path,
cwd: &Path,
adapter_name: Option<&str>,
) -> Option<ResolvedAdapter> {
if let Some(name) = adapter_name {
return resolve_adapter(name);
}
let mut matches = get_matching_adapters(program, cwd);
sort_adapters_for_launch(program, cwd, &mut matches);
matches.into_iter().next()
}
pub fn select_attach_adapter(
adapter_name: Option<&str>,
port: Option<u16>,
) -> Option<ResolvedAdapter> {
if let Some(name) = adapter_name {
return resolve_adapter(name);
}
let available = get_available_adapters();
if port.is_some() {
if let Some(debugpy) = available.iter().find(|a| a.name == "debugpy") {
return Some(debugpy.clone());
}
}
for preferred in EXTENSIONLESS_DEBUGGER_ORDER {
if let Some(a) = available.iter().find(|a| a.name == *preferred) {
return Some(a.clone());
}
}
available.into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_json_parses() {
let defaults = load_defaults();
assert!(!defaults.is_empty(), "defaults.json should have entries");
for (name, cfg) in &defaults {
assert!(!cfg.command.is_empty(), "{name}: command must not be empty");
}
}
#[test]
fn cwd_relative_to_defaults() {
let defaults = load_defaults();
assert!(
defaults.contains_key("lldb-dap"),
"lldb-dap must be bundled"
);
assert!(defaults.contains_key("debugpy"), "debugpy must be bundled");
assert!(defaults.contains_key("dlv"), "dlv must be bundled");
assert!(defaults.contains_key("rdbg"), "rdbg must be bundled");
assert!(defaults.contains_key("gdb"), "gdb must be bundled");
}
fn make_temp_dir() -> TempDir {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("dirge-dap-config-test-{id}-{}", std::process::id()));
std::fs::create_dir_all(&path).unwrap();
TempDir { path }
}
struct TempDir {
path: std::path::PathBuf,
}
impl TempDir {
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn root_marker_found() {
let tmp = make_temp_dir();
let cwd = tmp.path();
std::fs::write(cwd.join("Cargo.toml"), "").unwrap();
let markers = vec!["Cargo.toml".to_string(), "CMakeLists.txt".to_string()];
assert!(has_root_markers(cwd, &markers));
}
#[test]
fn root_marker_not_found() {
let tmp = make_temp_dir();
let markers = vec!["Cargo.toml".to_string()];
assert!(!has_root_markers(tmp.path(), &markers));
}
#[test]
fn root_marker_empty_list() {
let tmp = make_temp_dir();
assert!(!has_root_markers(tmp.path(), &[]));
}
#[test]
fn explicit_adapter_name_bypasses_heuristics() {
let result =
select_launch_adapter(Path::new("/tmp/prog.py"), Path::new("/tmp"), Some("gdb"));
let _ = result;
}
#[test]
fn explicit_attach_adapter_name() {
let result = select_attach_adapter(Some("gdb"), None);
let _ = result;
}
#[test]
fn connect_mode_stdio_is_default() {
let json = serde_json::json!({
"command": "test-adapter",
"args": [],
"languages": [],
"file_types": [],
"root_markers": []
});
let cfg: AdapterConfig = serde_json::from_value(json).unwrap();
assert_eq!(cfg.connect_mode, ConnectMode::Stdio);
}
#[test]
fn connect_mode_socket() {
let json = serde_json::json!({
"command": "test-adapter",
"connect_mode": "socket"
});
let cfg: AdapterConfig = serde_json::from_value(json).unwrap();
assert_eq!(cfg.connect_mode, ConnectMode::Socket);
}
#[test]
fn connect_mode_explicit_stdio() {
let json = serde_json::json!({
"command": "test-adapter",
"connect_mode": "stdio"
});
let cfg: AdapterConfig = serde_json::from_value(json).unwrap();
assert_eq!(cfg.connect_mode, ConnectMode::Stdio);
}
fn make_adapter(name: &str, file_types: &[&str], root_markers: &[&str]) -> ResolvedAdapter {
ResolvedAdapter {
name: name.to_string(),
resolved_command: PathBuf::from(format!("/usr/bin/{name}")),
args: vec![],
file_types: file_types.iter().map(|s| s.to_string()).collect(),
root_markers: root_markers.iter().map(|s| s.to_string()).collect(),
launch_defaults: serde_json::Value::Object(Default::default()),
attach_defaults: serde_json::Value::Object(Default::default()),
connect_mode: ConnectMode::Stdio,
languages: vec![],
}
}
#[test]
fn sort_prioritizes_extension_match() {
let tmp = make_temp_dir();
let cwd = tmp.path();
let mut adapters = vec![
make_adapter("debugpy", &[".py"], &[]),
make_adapter("gdb", &[".c", ".rs"], &[]),
];
sort_adapters_for_launch(Path::new("/tmp/prog.py"), cwd, &mut adapters);
assert_eq!(adapters[0].name, "debugpy");
assert_eq!(adapters[1].name, "gdb");
}
#[test]
fn sort_prioritizes_root_marker() {
let tmp = make_temp_dir();
let cwd = tmp.path();
std::fs::write(cwd.join("Cargo.toml"), "").unwrap();
let mut adapters = vec![
make_adapter("lldb-dap", &[".rs"], &[]),
make_adapter("gdb", &[".rs"], &["Cargo.toml"]),
];
sort_adapters_for_launch(Path::new("/tmp/prog.rs"), cwd, &mut adapters);
assert_eq!(adapters[0].name, "gdb");
assert_eq!(adapters[1].name, "lldb-dap");
}
#[test]
fn sort_native_debugger_rank_tiebreaker() {
let tmp = make_temp_dir();
let cwd = tmp.path();
let mut adapters = vec![
make_adapter("debugpy", &[".c"], &[]),
make_adapter("lldb-dap", &[".c"], &[]),
make_adapter("gdb", &[".c"], &[]),
];
sort_adapters_for_launch(Path::new("/tmp/prog.c"), cwd, &mut adapters);
assert_eq!(adapters[0].name, "gdb");
assert_eq!(adapters[1].name, "lldb-dap");
assert_eq!(adapters[2].name, "debugpy");
}
#[test]
fn sort_alphabetical_tiebreaker() {
let tmp = make_temp_dir();
let cwd = tmp.path();
let mut adapters = vec![
make_adapter("bbb", &[".rs"], &[]),
make_adapter("aaa", &[".rs"], &[]),
];
sort_adapters_for_launch(Path::new("/tmp/prog.rs"), cwd, &mut adapters);
assert_eq!(adapters[0].name, "aaa");
assert_eq!(adapters[1].name, "bbb");
}
#[test]
fn all_file_types_have_an_adapter() {
let defaults = load_defaults();
let mut seen = std::collections::HashSet::new();
let mut covered = std::collections::HashSet::new();
for (name, cfg) in &defaults {
for ft in &cfg.file_types {
seen.insert(ft.clone());
covered.insert((ft.clone(), name.clone()));
}
}
for ext in &seen {
let adapters: Vec<_> = defaults
.iter()
.filter(|(_, cfg)| cfg.file_types.contains(ext))
.map(|(n, _)| n.as_str())
.collect();
assert!(
!adapters.is_empty(),
"extension {ext} has no adapter (should be unreachable)"
);
}
assert!(seen.contains(".py"), ".py must be covered");
assert!(seen.contains(".rs"), ".rs must be covered");
assert!(seen.contains(".go"), ".go must be covered");
assert!(seen.contains(".c"), ".c must be covered");
assert!(seen.contains(".js"), ".js must be covered");
assert!(seen.contains(".rb"), ".rb must be covered");
}
#[test]
fn every_adapter_has_file_types() {
let defaults = load_defaults();
for (name, cfg) in &defaults {
assert!(
!cfg.file_types.is_empty(),
"adapter {name} must declare at least one file_type"
);
}
}
#[test]
fn known_language_to_adapter_mappings() {
let defaults = load_defaults();
let debugpy = &defaults["debugpy"];
assert!(debugpy.file_types.contains(&".py".to_string()));
assert!(debugpy.languages.contains(&"python".to_string()));
let dlv = &defaults["dlv"];
assert!(dlv.file_types.contains(&".go".to_string()));
assert!(dlv.languages.contains(&"go".to_string()));
let rdbg = &defaults["rdbg"];
assert!(rdbg.file_types.contains(&".rb".to_string()));
assert!(rdbg.languages.contains(&"ruby".to_string()));
let js_dap = &defaults["node-dap"];
assert!(js_dap.file_types.contains(&".js".to_string()));
assert!(js_dap.file_types.contains(&".ts".to_string()));
assert!(js_dap.languages.contains(&"javascript".to_string()));
assert!(js_dap.languages.contains(&"typescript".to_string()));
let jdtls = &defaults["jdtls-debug"];
assert!(jdtls.file_types.contains(&".java".to_string()));
assert!(jdtls.languages.contains(&"java".to_string()));
let elixir = &defaults["elixir-ls-debugger"];
assert!(elixir.file_types.contains(&".ex".to_string()));
assert!(elixir.file_types.contains(&".exs".to_string()));
assert!(elixir.languages.contains(&"elixir".to_string()));
let clj = &defaults["clojure-lsp-debug"];
assert!(clj.file_types.contains(&".clj".to_string()));
assert!(clj.languages.contains(&"clojure".to_string()));
}
#[test]
fn c_family_covered_by_native_debuggers() {
let defaults = load_defaults();
let c_exts = [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"];
for ext in c_exts {
let covered_by_gdb = defaults["gdb"].file_types.contains(&ext.to_string());
let covered_by_lldb = defaults["lldb-dap"].file_types.contains(&ext.to_string());
assert!(
covered_by_gdb || covered_by_lldb,
"C-family extension {ext} must be covered by gdb or lldb-dap"
);
}
}
#[test]
fn every_adapter_has_command() {
let defaults = load_defaults();
for (name, cfg) in &defaults {
assert!(
!cfg.command.is_empty(),
"adapter {name}: command must not be empty"
);
assert!(
!cfg.command.trim().is_empty(),
"adapter {name}: command must not be whitespace"
);
}
}
#[test]
fn root_markers_are_non_empty_strings() {
let defaults = load_defaults();
for (name, cfg) in &defaults {
for marker in &cfg.root_markers {
assert!(
!marker.trim().is_empty(),
"adapter {name}: root marker must not be whitespace"
);
}
}
}
#[test]
fn extensionless_prefers_native() {
let tmp = make_temp_dir();
let cwd = tmp.path();
let mut all = vec![
make_adapter("gdb", &[".c"], &[]),
make_adapter("lldb-dap", &[".c", ".rs"], &[]),
make_adapter("debugpy", &[".py"], &[]),
];
sort_adapters_for_launch(Path::new("/tmp/a.out"), cwd, &mut all);
let names: Vec<&str> = all.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["gdb", "lldb-dap", "debugpy"]);
}
#[test]
fn extensionless_root_marker_overrides_native_rank() {
let tmp = make_temp_dir();
let cwd = tmp.path();
std::fs::write(cwd.join("Cargo.toml"), "").unwrap();
let mut all = vec![
make_adapter("gdb", &[".c"], &[]),
make_adapter("debugpy", &[".py"], &["Cargo.toml"]),
];
sort_adapters_for_launch(Path::new("/tmp/a.out"), cwd, &mut all);
assert_eq!(all[0].name, "debugpy");
assert_eq!(all[1].name, "gdb");
}
#[test]
fn unknown_extension_returns_all() {
let tmp = make_temp_dir();
let cwd = tmp.path();
let mut adapters = vec![
make_adapter("gdb", &[".c"], &[]),
make_adapter("debugpy", &[".py"], &[]),
];
sort_adapters_for_launch(Path::new("/tmp/prog.xyz"), cwd, &mut adapters);
assert_eq!(adapters.len(), 2);
}
#[test]
fn attach_with_port_prefers_debugpy() {
let result = select_attach_adapter(Some("debugpy"), Some(5678));
let _ = result;
}
#[test]
fn attach_without_port_prefers_native() {
let result = select_attach_adapter(None, None);
let _ = result;
}
#[test]
fn every_bundled_adapter_connect_mode_is_valid() {
let defaults = load_defaults();
for (_name, cfg) in &defaults {
match cfg.connect_mode {
ConnectMode::Stdio | ConnectMode::Socket => {}
}
}
}
#[test]
fn socket_mode_adapters() {
let defaults = load_defaults();
assert_eq!(defaults["dlv"].connect_mode, ConnectMode::Socket);
assert_eq!(defaults["codelldb"].connect_mode, ConnectMode::Socket);
}
#[test]
fn stdio_mode_adapters() {
let defaults = load_defaults();
assert_eq!(defaults["debugpy"].connect_mode, ConnectMode::Stdio);
assert_eq!(defaults["gdb"].connect_mode, ConnectMode::Stdio);
assert_eq!(defaults["lldb-dap"].connect_mode, ConnectMode::Stdio);
assert_eq!(defaults["rdbg"].connect_mode, ConnectMode::Stdio);
assert_eq!(defaults["node-dap"].connect_mode, ConnectMode::Stdio);
}
}