Skip to main content

pathfinder_lsp/
plugin.rs

1//! LT-2: Language Plugin Trait — per-language behavior abstraction.
2//!
3//! Each supported language implements [`LanguagePlugin`], encapsulating:
4//! - Language identification (ID, file extensions)
5//! - LSP binary discovery (binary candidates, default args)
6//! - Workspace detection (marker files, search depth)
7//! - Manifest validation rules
8//! - Install guidance for missing binaries
9//! - LSP initialization options
10//! - Install guidance for missing binaries
11//!
12//! # Design Rationale
13//!
14//! Before LT-2, per-language logic was scattered across `detect.rs`,
15//! `capabilities.rs`, and `process.rs` as match arms on string language IDs.
16//! This trait centralises that knowledge, making it straightforward to add
17//! new languages and test each language's configuration in isolation.
18//!
19//! The trait is **object-safe** so implementations can be used as
20//! `Box<dyn LanguagePlugin>` or `&dyn LanguagePlugin`.
21
22/// Describes a candidate LSP binary with its default arguments.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LspCandidate {
25    /// Binary name to resolve via `which` (e.g., `"rust-analyzer"`, `"gopls"`).
26    pub binary: &'static str,
27    /// Default CLI arguments (e.g., `["--stdio"]`).
28    pub default_args: &'static [&'static str],
29}
30
31/// Per-language LSP behaviour abstraction.
32///
33/// Implementations are pure data providers — no I/O, no async.
34/// This makes them trivially testable and composable.
35pub trait LanguagePlugin: Send + Sync {
36    /// Short identifier used as a map key (e.g., `"rust"`, `"go"`, `"typescript"`, `"python"`).
37    fn language_id(&self) -> &'static str;
38
39    /// File extensions that this language handles.
40    ///
41    /// Used by [`language_id_for_extension`] and [`touch_language`] in LT-4.
42    /// Example: `&["rs"]` for Rust, `&["ts", "tsx", "js", "jsx", "mjs", "cjs", "vue"]` for TypeScript.
43    fn file_extensions(&self) -> &'static [&'static str];
44
45    /// Marker files that indicate this language is used in the workspace.
46    ///
47    /// Returned in priority order — detection stops at the first match.
48    /// Example: `&["Cargo.toml"]` for Rust, `&["tsconfig.json", "package.json"]` for TypeScript.
49    fn marker_files(&self) -> &'static [&'static str];
50
51    /// Maximum directory depth to search for marker files.
52    ///
53    /// `0` = root only, `2` = root + up to 2 levels deep (for monorepos).
54    fn marker_search_depth(&self) -> u32;
55
56    /// LSP binary candidates in preference order.
57    ///
58    /// Detection tries each candidate via `which` and uses the first found.
59    /// Example: Rust has one (`rust-analyzer`), Python has four
60    /// (`pyright-langserver`, `pylsp`, `ruff-lsp`, `jedi-language-server`).
61    fn lsp_candidates(&self) -> &[LspCandidate];
62
63    /// Human-readable install guidance when no LSP binary is found.
64    fn install_hint(&self) -> &'static str;
65}
66
67// ── Concrete Implementations ──────────────────────────────────────────────────
68
69/// Rust language plugin — `rust-analyzer` + `Cargo.toml`.
70pub struct RustPlugin;
71
72impl LanguagePlugin for RustPlugin {
73    fn language_id(&self) -> &'static str {
74        "rust"
75    }
76
77    fn file_extensions(&self) -> &'static [&'static str] {
78        &["rs"]
79    }
80
81    fn marker_files(&self) -> &'static [&'static str] {
82        &["Cargo.toml"]
83    }
84
85    fn marker_search_depth(&self) -> u32 {
86        0
87    }
88
89    fn lsp_candidates(&self) -> &[LspCandidate] {
90        &[LspCandidate {
91            binary: "rust-analyzer",
92            default_args: &[],
93        }]
94    }
95
96    fn install_hint(&self) -> &'static str {
97        "Install rust-analyzer: https://rust-analyzer.github.io/"
98    }
99}
100
101/// Go language plugin — `gopls` + `go.mod`.
102pub struct GoPlugin;
103
104impl LanguagePlugin for GoPlugin {
105    fn language_id(&self) -> &'static str {
106        "go"
107    }
108
109    fn file_extensions(&self) -> &'static [&'static str] {
110        &["go"]
111    }
112
113    fn marker_files(&self) -> &'static [&'static str] {
114        &["go.mod"]
115    }
116
117    fn marker_search_depth(&self) -> u32 {
118        2
119    }
120
121    fn lsp_candidates(&self) -> &[LspCandidate] {
122        &[LspCandidate {
123            binary: "gopls",
124            default_args: &[],
125        }]
126    }
127
128    fn install_hint(&self) -> &'static str {
129        "Install gopls: go install golang.org/x/tools/gopls@latest"
130    }
131}
132
133/// TypeScript / JavaScript language plugin — `typescript-language-server` + `tsconfig.json` / `package.json`.
134pub struct TypeScriptPlugin;
135
136impl LanguagePlugin for TypeScriptPlugin {
137    fn language_id(&self) -> &'static str {
138        "typescript"
139    }
140
141    fn file_extensions(&self) -> &'static [&'static str] {
142        &["ts", "tsx", "js", "jsx", "mjs", "cjs", "vue"]
143    }
144
145    fn marker_files(&self) -> &'static [&'static str] {
146        &["tsconfig.json", "package.json"]
147    }
148
149    fn marker_search_depth(&self) -> u32 {
150        2
151    }
152
153    fn lsp_candidates(&self) -> &[LspCandidate] {
154        &[LspCandidate {
155            binary: "typescript-language-server",
156            default_args: &["--stdio"],
157        }]
158    }
159
160    fn install_hint(&self) -> &'static str {
161        "Install typescript-language-server: npm install -g typescript-language-server typescript"
162    }
163}
164
165/// Python language plugin — `pyright-langserver` / `pylsp` / `ruff-lsp` / `jedi-language-server`.
166pub struct PythonPlugin;
167
168/// Java language plugin — jdtls (Eclipse JDT Language Server).
169///
170/// Requires JDK 21+ to run jdtls. Supports Java 8–25 project analysis.
171/// Marker files searched up to depth 2 to support monorepo layouts
172/// (e.g. `services/backend/pom.xml`).
173pub struct JavaPlugin;
174
175impl LanguagePlugin for PythonPlugin {
176    fn language_id(&self) -> &'static str {
177        "python"
178    }
179
180    fn file_extensions(&self) -> &'static [&'static str] {
181        &["py", "pyi"]
182    }
183
184    fn marker_files(&self) -> &'static [&'static str] {
185        &["pyproject.toml", "setup.py", "requirements.txt"]
186    }
187
188    fn marker_search_depth(&self) -> u32 {
189        2
190    }
191
192    fn lsp_candidates(&self) -> &[LspCandidate] {
193        &[
194            LspCandidate {
195                binary: "pyright-langserver",
196                default_args: &["--stdio"],
197            },
198            LspCandidate {
199                binary: "pylsp",
200                default_args: &[],
201            },
202            LspCandidate {
203                binary: "ruff-lsp",
204                default_args: &[],
205            },
206            LspCandidate {
207                binary: "jedi-language-server",
208                default_args: &[],
209            },
210        ]
211    }
212
213    fn install_hint(&self) -> &'static str {
214        "Install pyright: npm install -g pyright\nOr install pylsp: pip install python-lsp-server"
215    }
216}
217
218impl LanguagePlugin for JavaPlugin {
219    fn language_id(&self) -> &'static str {
220        "java"
221    }
222
223    fn file_extensions(&self) -> &'static [&'static str] {
224        &["java"]
225    }
226
227    fn marker_files(&self) -> &'static [&'static str] {
228        &[
229            "pom.xml",
230            "build.gradle",
231            "build.gradle.kts",
232            "settings.gradle",
233            "settings.gradle.kts",
234        ]
235    }
236
237    fn marker_search_depth(&self) -> u32 {
238        2 // Monorepo support
239    }
240
241    fn lsp_candidates(&self) -> &[LspCandidate] {
242        &[LspCandidate {
243            binary: "jdtls",
244            default_args: &[],
245        }]
246    }
247
248    fn install_hint(&self) -> &'static str {
249        "Install jdtls: https://github.com/eclipse-jdtls/eclipse.jdt.ls#installation\n\
250         Requires JDK 21+ to run. Use sdkman: sdk install java 21-tem && sdk install jdtls"
251    }
252}
253
254// ── Registry ──────────────────────────────────────────────────────────────────
255
256/// All built-in language plugins.
257///
258/// Returns a static slice of all supported language plugins.
259/// Used by `detect_languages` to iterate over known languages and by
260/// `language_id_for_extension` to look up language IDs from file extensions.
261#[must_use]
262pub fn all_plugins() -> &'static [&'static dyn LanguagePlugin] {
263    &[
264        &RustPlugin,
265        &GoPlugin,
266        &TypeScriptPlugin,
267        &PythonPlugin,
268        &JavaPlugin,
269    ]
270}
271
272/// Look up a plugin by its language ID.
273#[must_use]
274pub fn plugin_for_language(language_id: &str) -> Option<&'static dyn LanguagePlugin> {
275    all_plugins()
276        .iter()
277        .find(|p| p.language_id() == language_id)
278        .copied()
279}
280
281/// Look up a plugin by file extension.
282///
283/// Returns the first plugin whose `file_extensions()` contains the given extension.
284#[must_use]
285pub fn plugin_for_extension(ext: &str) -> Option<&'static dyn LanguagePlugin> {
286    all_plugins()
287        .iter()
288        .find(|p| p.file_extensions().contains(&ext))
289        .copied()
290}
291
292#[cfg(test)]
293#[allow(clippy::unwrap_used)]
294mod tests {
295    use super::*;
296
297    // ── Trait object safety ─────────────────────────────────────────────
298
299    #[test]
300    fn test_trait_is_object_safe() {
301        // If this compiles, the trait is object-safe.
302        let _: Box<dyn LanguagePlugin> = Box::new(RustPlugin);
303        let _: &dyn LanguagePlugin = &GoPlugin;
304    }
305
306    // ── RustPlugin ──────────────────────────────────────────────────────
307
308    #[test]
309    fn test_rust_plugin_language_id() {
310        assert_eq!(RustPlugin.language_id(), "rust");
311    }
312
313    #[test]
314    fn test_rust_plugin_file_extensions() {
315        assert_eq!(RustPlugin.file_extensions(), &["rs"]);
316    }
317
318    #[test]
319    fn test_rust_plugin_marker_files() {
320        assert_eq!(RustPlugin.marker_files(), &["Cargo.toml"]);
321    }
322
323    #[test]
324    fn test_rust_plugin_marker_search_depth() {
325        assert_eq!(RustPlugin.marker_search_depth(), 0);
326    }
327
328    #[test]
329    fn test_rust_plugin_lsp_candidates() {
330        let candidates = RustPlugin.lsp_candidates();
331        assert_eq!(candidates.len(), 1);
332        assert_eq!(candidates[0].binary, "rust-analyzer");
333        assert!(candidates[0].default_args.is_empty());
334    }
335
336    #[test]
337    fn test_rust_plugin_install_hint() {
338        let hint = RustPlugin.install_hint();
339        assert!(hint.contains("rust-analyzer"));
340    }
341
342    // ── GoPlugin ────────────────────────────────────────────────────────
343
344    #[test]
345    fn test_go_plugin_language_id() {
346        assert_eq!(GoPlugin.language_id(), "go");
347    }
348
349    #[test]
350    fn test_go_plugin_file_extensions() {
351        assert_eq!(GoPlugin.file_extensions(), &["go"]);
352    }
353
354    #[test]
355    fn test_go_plugin_marker_files() {
356        assert_eq!(GoPlugin.marker_files(), &["go.mod"]);
357    }
358
359    #[test]
360    fn test_go_plugin_marker_search_depth() {
361        assert_eq!(GoPlugin.marker_search_depth(), 2);
362    }
363
364    #[test]
365    fn test_go_plugin_lsp_candidates() {
366        let candidates = GoPlugin.lsp_candidates();
367        assert_eq!(candidates.len(), 1);
368        assert_eq!(candidates[0].binary, "gopls");
369    }
370
371    #[test]
372    fn test_go_plugin_install_hint() {
373        let hint = GoPlugin.install_hint();
374        assert!(hint.contains("gopls"));
375    }
376
377    // ── TypeScriptPlugin ────────────────────────────────────────────────
378
379    #[test]
380    fn test_typescript_plugin_language_id() {
381        assert_eq!(TypeScriptPlugin.language_id(), "typescript");
382    }
383
384    #[test]
385    fn test_typescript_plugin_file_extensions() {
386        let exts = TypeScriptPlugin.file_extensions();
387        assert!(exts.contains(&"ts"));
388        assert!(exts.contains(&"tsx"));
389        assert!(exts.contains(&"js"));
390        assert!(exts.contains(&"jsx"));
391        assert!(exts.contains(&"mjs"));
392        assert!(exts.contains(&"cjs"));
393        assert!(exts.contains(&"vue"));
394    }
395
396    #[test]
397    fn test_typescript_plugin_marker_files() {
398        let markers = TypeScriptPlugin.marker_files();
399        assert_eq!(markers, &["tsconfig.json", "package.json"]);
400    }
401
402    #[test]
403    fn test_typescript_plugin_marker_search_depth() {
404        assert_eq!(TypeScriptPlugin.marker_search_depth(), 2);
405    }
406
407    #[test]
408    fn test_typescript_plugin_lsp_candidates() {
409        let candidates = TypeScriptPlugin.lsp_candidates();
410        assert_eq!(candidates.len(), 1);
411        assert_eq!(candidates[0].binary, "typescript-language-server");
412        assert_eq!(candidates[0].default_args, &["--stdio"]);
413    }
414
415    #[test]
416    fn test_typescript_plugin_install_hint() {
417        let hint = TypeScriptPlugin.install_hint();
418        assert!(hint.contains("typescript-language-server"));
419    }
420
421    // ── PythonPlugin ────────────────────────────────────────────────────
422
423    #[test]
424    fn test_python_plugin_language_id() {
425        assert_eq!(PythonPlugin.language_id(), "python");
426    }
427
428    #[test]
429    fn test_python_plugin_file_extensions() {
430        let exts = PythonPlugin.file_extensions();
431        assert!(exts.contains(&"py"));
432        assert!(exts.contains(&"pyi"));
433    }
434
435    #[test]
436    fn test_python_plugin_marker_files() {
437        let markers = PythonPlugin.marker_files();
438        assert_eq!(markers, &["pyproject.toml", "setup.py", "requirements.txt"]);
439    }
440
441    #[test]
442    fn test_python_plugin_marker_search_depth() {
443        assert_eq!(PythonPlugin.marker_search_depth(), 2);
444    }
445
446    #[test]
447    fn test_python_plugin_lsp_candidates() {
448        let candidates = PythonPlugin.lsp_candidates();
449        assert_eq!(candidates.len(), 4);
450        assert_eq!(candidates[0].binary, "pyright-langserver");
451        assert_eq!(candidates[0].default_args, &["--stdio"]);
452        assert_eq!(candidates[1].binary, "pylsp");
453        assert_eq!(candidates[2].binary, "ruff-lsp");
454        assert_eq!(candidates[3].binary, "jedi-language-server");
455    }
456
457    #[test]
458    fn test_python_plugin_install_hint() {
459        let hint = PythonPlugin.install_hint();
460        assert!(hint.contains("pyright"));
461        assert!(hint.contains("pylsp"));
462    }
463
464    // ── Registry ────────────────────────────────────────────────────────
465
466    #[test]
467    fn test_all_plugins_contains_all_five_languages() {
468        let plugins = all_plugins();
469        assert_eq!(plugins.len(), 5);
470        let ids: Vec<&str> = plugins.iter().map(|p| p.language_id()).collect();
471        assert!(ids.contains(&"rust"));
472        assert!(ids.contains(&"go"));
473        assert!(ids.contains(&"typescript"));
474        assert!(ids.contains(&"python"));
475        assert!(ids.contains(&"java"));
476    }
477
478    #[test]
479    fn test_plugin_for_language_found() {
480        let plugin = plugin_for_language("rust").unwrap();
481        assert_eq!(plugin.language_id(), "rust");
482    }
483
484    #[test]
485    fn test_plugin_for_language_found_java() {
486        let plugin = plugin_for_language("java").unwrap();
487        assert_eq!(plugin.language_id(), "java");
488    }
489
490    #[test]
491    fn test_plugin_for_language_not_found() {
492        assert!(plugin_for_language("kotlin").is_none());
493    }
494
495    #[test]
496    fn test_plugin_for_extension_rs() {
497        let plugin = plugin_for_extension("rs").unwrap();
498        assert_eq!(plugin.language_id(), "rust");
499    }
500
501    #[test]
502    fn test_plugin_for_extension_go() {
503        let plugin = plugin_for_extension("go").unwrap();
504        assert_eq!(plugin.language_id(), "go");
505    }
506
507    #[test]
508    fn test_plugin_for_extension_ts() {
509        let plugin = plugin_for_extension("ts").unwrap();
510        assert_eq!(plugin.language_id(), "typescript");
511    }
512
513    #[test]
514    fn test_plugin_for_extension_vue() {
515        let plugin = plugin_for_extension("vue").unwrap();
516        assert_eq!(plugin.language_id(), "typescript");
517    }
518
519    #[test]
520    fn test_plugin_for_extension_py() {
521        let plugin = plugin_for_extension("py").unwrap();
522        assert_eq!(plugin.language_id(), "python");
523    }
524
525    #[test]
526    fn test_plugin_for_extension_java() {
527        let plugin = plugin_for_extension("java").unwrap();
528        assert_eq!(plugin.language_id(), "java");
529    }
530
531    #[test]
532    fn test_plugin_for_extension_unknown() {
533        assert!(plugin_for_extension("kt").is_none());
534    }
535
536    // ── Cross-validation with existing code ─────────────────────────────
537
538    #[test]
539    fn test_plugins_match_language_id_for_extension() {
540        // Verify that the plugin registry returns the same language_id
541        // as the existing language_id_for_extension function for all known extensions.
542        use crate::client::language_id_for_extension;
543
544        for ext in &[
545            "rs", "go", "ts", "tsx", "js", "jsx", "mjs", "cjs", "vue", "py", "pyi", "java",
546        ] {
547            let from_fn = language_id_for_extension(ext);
548            let from_plugin = plugin_for_extension(ext).map(LanguagePlugin::language_id);
549            assert_eq!(
550                from_fn, from_plugin,
551                "Mismatch for extension .{ext}: fn={from_fn:?}, plugin={from_plugin:?}"
552            );
553        }
554    }
555
556    #[test]
557    fn test_all_plugins_have_unique_language_ids() {
558        let plugins = all_plugins();
559        let ids: Vec<&str> = plugins.iter().map(|p| p.language_id()).collect();
560        let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
561        assert_eq!(
562            ids.len(),
563            unique.len(),
564            "Duplicate language IDs found: {ids:?}"
565        );
566    }
567
568    #[test]
569    fn test_no_extension_overlap_between_plugins() {
570        // Each extension should map to exactly one plugin.
571        let plugins = all_plugins();
572        let mut seen = std::collections::HashMap::new();
573        for plugin in plugins {
574            for ext in plugin.file_extensions() {
575                if let Some(existing) = seen.insert(*ext, plugin.language_id()) {
576                    panic!(
577                        "Extension .{ext} claimed by both '{existing}' and '{}'",
578                        plugin.language_id()
579                    );
580                }
581            }
582        }
583    }
584
585    // ── JavaPlugin ──────────────────────────────────────────────────────
586
587    #[test]
588    fn test_java_plugin_language_id() {
589        assert_eq!(JavaPlugin.language_id(), "java");
590    }
591
592    #[test]
593    fn test_java_plugin_file_extensions() {
594        let exts = JavaPlugin.file_extensions();
595        assert_eq!(exts, &["java"]);
596    }
597
598    #[test]
599    fn test_java_plugin_marker_files() {
600        let markers = JavaPlugin.marker_files();
601        assert!(markers.contains(&"pom.xml"));
602        assert!(markers.contains(&"build.gradle"));
603        assert!(markers.contains(&"build.gradle.kts"));
604        assert!(markers.contains(&"settings.gradle"));
605        assert!(markers.contains(&"settings.gradle.kts"));
606    }
607
608    #[test]
609    fn test_java_plugin_marker_search_depth() {
610        assert_eq!(JavaPlugin.marker_search_depth(), 2);
611    }
612
613    #[test]
614    fn test_java_plugin_lsp_candidates() {
615        let candidates = JavaPlugin.lsp_candidates();
616        assert_eq!(candidates.len(), 1);
617        assert_eq!(candidates[0].binary, "jdtls");
618        assert!(candidates[0].default_args.is_empty());
619    }
620
621    #[test]
622    fn test_java_plugin_install_hint() {
623        let hint = JavaPlugin.install_hint();
624        assert!(hint.contains("jdtls"));
625        assert!(hint.contains("JDK 21"));
626    }
627}