Skip to main content

synwire_dap/
registry.rs

1//! Debug adapter registry with built-in entries for common languages.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8/// A registered debug adapter entry.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DebugAdapterEntry {
11    /// Human-readable adapter name.
12    pub name: String,
13    /// Language identifiers this adapter supports (e.g. `"rust"`, `"go"`).
14    pub language_ids: Vec<String>,
15    /// Binary command to launch the adapter.
16    pub command: String,
17    /// Default arguments for the adapter command.
18    pub args: Vec<String>,
19    /// Per-platform install instructions (keys: `"macos"`, `"linux"`, `"windows"`).
20    pub install_instructions: HashMap<String, String>,
21    /// Project homepage URL.
22    pub homepage: String,
23}
24
25/// Registry of known debug adapters.
26///
27/// Pre-populated with entries for common adapters (`CodeLLDB`, Delve,
28/// debugpy, js-debug, java-debug). Additional entries can be registered
29/// at runtime.
30pub struct DebugAdapterRegistry {
31    entries: Vec<DebugAdapterEntry>,
32}
33
34impl DebugAdapterRegistry {
35    /// Create a registry pre-populated with built-in adapter entries.
36    #[must_use]
37    pub fn with_builtins() -> Self {
38        Self {
39            entries: builtin_entries(),
40        }
41    }
42
43    /// Create an empty registry.
44    #[must_use]
45    pub const fn empty() -> Self {
46        Self {
47            entries: Vec::new(),
48        }
49    }
50
51    /// Register a custom adapter entry.
52    pub fn register(&mut self, entry: DebugAdapterEntry) {
53        self.entries.push(entry);
54    }
55
56    /// Look up adapters by language identifier.
57    #[must_use]
58    pub fn find_by_language(&self, language_id: &str) -> Vec<&DebugAdapterEntry> {
59        self.entries
60            .iter()
61            .filter(|e| e.language_ids.iter().any(|l| l == language_id))
62            .collect()
63    }
64
65    /// Look up an adapter by name.
66    #[must_use]
67    pub fn find_by_name(&self, name: &str) -> Option<&DebugAdapterEntry> {
68        self.entries.iter().find(|e| e.name == name)
69    }
70
71    /// Return all registered entries.
72    #[must_use]
73    pub fn all(&self) -> &[DebugAdapterEntry] {
74        &self.entries
75    }
76
77    /// Check whether a specific adapter binary is available on `PATH`.
78    #[must_use]
79    pub fn is_available(&self, name: &str) -> bool {
80        self.find_by_name(name)
81            .is_some_and(|entry| which::which(&entry.command).is_ok())
82    }
83
84    /// Detect which debug adapters are available for a project directory.
85    ///
86    /// Scans the project root up to 2 levels deep for file extensions, maps
87    /// them to language identifiers, then checks the registry for matching
88    /// adapters whose binary is on `PATH`.
89    ///
90    /// Supports polyglot repos -- returns multiple adapters if multiple
91    /// languages are detected.
92    #[must_use]
93    pub fn detect_for_project(&self, project_root: &Path) -> Vec<&DebugAdapterEntry> {
94        let extensions = collect_extensions(project_root, 2);
95        let mut language_ids = HashSet::new();
96        for ext in &extensions {
97            if let Some(lang) = extension_to_language_id(ext) {
98                let _inserted = language_ids.insert(lang);
99            }
100        }
101
102        let mut seen = HashSet::new();
103        let mut result = Vec::new();
104        for lang_id in &language_ids {
105            for entry in self.find_by_language(lang_id) {
106                if seen.insert(&entry.name) && which::which(&entry.command).is_ok() {
107                    result.push(entry);
108                }
109            }
110        }
111        result
112    }
113}
114
115impl Default for DebugAdapterRegistry {
116    fn default() -> Self {
117        Self::with_builtins()
118    }
119}
120
121/// Map a file extension (without leading dot) to a language identifier.
122///
123/// Returns `None` for unrecognised extensions.
124fn extension_to_language_id(ext: &str) -> Option<&'static str> {
125    match ext {
126        "rs" => Some("rust"),
127        "go" => Some("go"),
128        "py" | "pyi" => Some("python"),
129        "js" | "mjs" | "cjs" => Some("javascript"),
130        "ts" | "mts" | "cts" | "tsx" => Some("typescript"),
131        "java" => Some("java"),
132        "c" | "h" => Some("c"),
133        "cpp" | "cxx" | "cc" | "hpp" | "hxx" => Some("cpp"),
134        _ => None,
135    }
136}
137
138/// Directories to skip when scanning for file extensions.
139const SKIP_DIRS: &[&str] = &[
140    "node_modules",
141    "target",
142    ".git",
143    ".hg",
144    ".svn",
145    "__pycache__",
146    ".mypy_cache",
147    ".pytest_cache",
148    ".tox",
149    ".venv",
150    "venv",
151    ".env",
152    "vendor",
153    "dist",
154    "build",
155    "out",
156    ".next",
157    ".nuxt",
158    "coverage",
159    ".cargo",
160    ".rustup",
161];
162
163/// Collect unique file extensions from a directory tree up to `max_depth` levels.
164///
165/// Skips hidden directories and common non-source directories listed in
166/// [`SKIP_DIRS`]. Returns extensions without the leading dot.
167fn collect_extensions(root: &Path, max_depth: usize) -> Vec<String> {
168    let mut extensions = HashSet::new();
169    collect_extensions_recursive(root, max_depth, 0, &mut extensions);
170    extensions.into_iter().collect()
171}
172
173/// Recursive helper for [`collect_extensions`].
174fn collect_extensions_recursive(
175    dir: &Path,
176    max_depth: usize,
177    current_depth: usize,
178    extensions: &mut HashSet<String>,
179) {
180    let Ok(entries) = std::fs::read_dir(dir) else {
181        return;
182    };
183
184    for entry in entries {
185        let Ok(entry) = entry else { continue };
186
187        let Ok(file_type) = entry.file_type() else {
188            continue;
189        };
190
191        let name = entry.file_name();
192        let name_str = name.to_string_lossy();
193
194        if file_type.is_dir() {
195            if name_str.starts_with('.') || SKIP_DIRS.contains(&name_str.as_ref()) {
196                continue;
197            }
198            if current_depth < max_depth {
199                collect_extensions_recursive(
200                    &entry.path(),
201                    max_depth,
202                    current_depth + 1,
203                    extensions,
204                );
205            }
206        } else if file_type.is_file()
207            && let Some(ext) = entry.path().extension().and_then(|e| e.to_str())
208        {
209            let _inserted = extensions.insert(ext.to_owned());
210        }
211    }
212}
213
214/// Built-in adapter entries for the five supported adapters.
215#[allow(clippy::too_many_lines)] // Data definition -- splitting would reduce clarity.
216fn builtin_entries() -> Vec<DebugAdapterEntry> {
217    vec![
218        DebugAdapterEntry {
219            name: "codelldb".into(),
220            language_ids: vec!["rust".into(), "c".into(), "cpp".into()],
221            command: "codelldb".into(),
222            args: vec!["--port".into(), "0".into()],
223            install_instructions: {
224                let mut m = HashMap::new();
225                let _ = m.insert(
226                    "linux".into(),
227                    "Download from https://github.com/vadimcn/codelldb/releases".into(),
228                );
229                let _ = m.insert(
230                    "macos".into(),
231                    "Download from https://github.com/vadimcn/codelldb/releases".into(),
232                );
233                let _ = m.insert(
234                    "windows".into(),
235                    "Download from https://github.com/vadimcn/codelldb/releases".into(),
236                );
237                m
238            },
239            homepage: "https://github.com/vadimcn/codelldb".into(),
240        },
241        DebugAdapterEntry {
242            name: "dlv-dap".into(),
243            language_ids: vec!["go".into()],
244            command: "dlv".into(),
245            args: vec!["dap".into()],
246            install_instructions: {
247                let mut m = HashMap::new();
248                let _ = m.insert(
249                    "linux".into(),
250                    "go install github.com/go-delve/delve/cmd/dlv@latest".into(),
251                );
252                let _ = m.insert(
253                    "macos".into(),
254                    "go install github.com/go-delve/delve/cmd/dlv@latest".into(),
255                );
256                let _ = m.insert(
257                    "windows".into(),
258                    "go install github.com/go-delve/delve/cmd/dlv@latest".into(),
259                );
260                m
261            },
262            homepage: "https://github.com/go-delve/delve".into(),
263        },
264        DebugAdapterEntry {
265            name: "debugpy".into(),
266            language_ids: vec!["python".into()],
267            command: "python".into(),
268            args: vec!["-m".into(), "debugpy.adapter".into()],
269            install_instructions: {
270                let mut m = HashMap::new();
271                let _ = m.insert("linux".into(), "pip install debugpy".into());
272                let _ = m.insert("macos".into(), "pip install debugpy".into());
273                let _ = m.insert("windows".into(), "pip install debugpy".into());
274                m
275            },
276            homepage: "https://github.com/microsoft/debugpy".into(),
277        },
278        DebugAdapterEntry {
279            name: "js-debug".into(),
280            language_ids: vec!["javascript".into(), "typescript".into(), "node".into()],
281            command: "js-debug-adapter".into(),
282            args: Vec::new(),
283            install_instructions: {
284                let mut m = HashMap::new();
285                let _ = m.insert("linux".into(), "npm install -g @vscode/js-debug".into());
286                let _ = m.insert("macos".into(), "npm install -g @vscode/js-debug".into());
287                let _ = m.insert("windows".into(), "npm install -g @vscode/js-debug".into());
288                m
289            },
290            homepage: "https://github.com/microsoft/vscode-js-debug".into(),
291        },
292        DebugAdapterEntry {
293            name: "java-debug".into(),
294            language_ids: vec!["java".into()],
295            command: "java".into(),
296            args: vec!["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n".into()],
297            install_instructions: {
298                let mut m = HashMap::new();
299                let _ = m.insert(
300                    "linux".into(),
301                    "Install via JDT.LS: https://github.com/microsoft/java-debug".into(),
302                );
303                let _ = m.insert(
304                    "macos".into(),
305                    "Install via JDT.LS: https://github.com/microsoft/java-debug".into(),
306                );
307                let _ = m.insert(
308                    "windows".into(),
309                    "Install via JDT.LS: https://github.com/microsoft/java-debug".into(),
310                );
311                m
312            },
313            homepage: "https://github.com/microsoft/java-debug".into(),
314        },
315    ]
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn builtin_registry_has_five_entries() {
325        let registry = DebugAdapterRegistry::with_builtins();
326        assert_eq!(registry.all().len(), 5);
327    }
328
329    #[test]
330    fn find_by_language_rust() {
331        let registry = DebugAdapterRegistry::with_builtins();
332        let adapters = registry.find_by_language("rust");
333        assert_eq!(adapters.len(), 1);
334        assert_eq!(adapters[0].name, "codelldb");
335    }
336
337    #[test]
338    fn find_by_language_go() {
339        let registry = DebugAdapterRegistry::with_builtins();
340        let adapters = registry.find_by_language("go");
341        assert_eq!(adapters.len(), 1);
342        assert_eq!(adapters[0].name, "dlv-dap");
343    }
344
345    #[test]
346    fn find_by_name() {
347        let registry = DebugAdapterRegistry::with_builtins();
348        assert!(registry.find_by_name("debugpy").is_some());
349        assert!(registry.find_by_name("nonexistent").is_none());
350    }
351
352    #[test]
353    fn extension_to_language_id_known() {
354        assert_eq!(super::extension_to_language_id("rs"), Some("rust"));
355        assert_eq!(super::extension_to_language_id("go"), Some("go"));
356        assert_eq!(super::extension_to_language_id("py"), Some("python"));
357        assert_eq!(super::extension_to_language_id("ts"), Some("typescript"));
358        assert_eq!(super::extension_to_language_id("cpp"), Some("cpp"));
359    }
360
361    #[test]
362    fn extension_to_language_id_unknown() {
363        assert_eq!(super::extension_to_language_id("xyz"), None);
364        assert_eq!(super::extension_to_language_id(""), None);
365    }
366
367    #[test]
368    fn detect_for_project_returns_empty_for_nonexistent_dir() {
369        let registry = DebugAdapterRegistry::with_builtins();
370        let result = registry.detect_for_project(std::path::Path::new("/nonexistent/path/12345"));
371        assert!(result.is_empty());
372    }
373
374    #[test]
375    fn collect_extensions_from_temp_dir() {
376        let dir = std::env::temp_dir().join("synwire_dap_test_collect_ext");
377        let _ = std::fs::remove_dir_all(&dir);
378        std::fs::create_dir_all(dir.join("src")).unwrap();
379        std::fs::write(dir.join("main.go"), "package main").unwrap();
380        std::fs::write(dir.join("src/helper.py"), "").unwrap();
381
382        let exts = super::collect_extensions(&dir, 2);
383        assert!(exts.contains(&"go".to_owned()));
384        assert!(exts.contains(&"py".to_owned()));
385
386        let _ = std::fs::remove_dir_all(&dir);
387    }
388
389    #[test]
390    fn register_custom_entry() {
391        let mut registry = DebugAdapterRegistry::empty();
392        assert!(registry.all().is_empty());
393
394        registry.register(DebugAdapterEntry {
395            name: "my-adapter".into(),
396            language_ids: vec!["lua".into()],
397            command: "lua-debug".into(),
398            args: Vec::new(),
399            install_instructions: HashMap::new(),
400            homepage: "https://example.com".into(),
401        });
402
403        assert_eq!(registry.all().len(), 1);
404        let found = registry.find_by_language("lua");
405        assert_eq!(found.len(), 1);
406    }
407}