1#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LspCandidate {
25 pub binary: &'static str,
27 pub default_args: &'static [&'static str],
29}
30
31pub trait LanguagePlugin: Send + Sync {
36 fn language_id(&self) -> &'static str;
38
39 fn file_extensions(&self) -> &'static [&'static str];
44
45 fn marker_files(&self) -> &'static [&'static str];
50
51 fn marker_search_depth(&self) -> u32;
55
56 fn lsp_candidates(&self) -> &[LspCandidate];
62
63 fn install_hint(&self) -> &'static str;
65}
66
67pub 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
101pub 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
133pub 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
165pub struct PythonPlugin;
167
168pub 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 }
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#[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#[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#[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 #[test]
300 fn test_trait_is_object_safe() {
301 let _: Box<dyn LanguagePlugin> = Box::new(RustPlugin);
303 let _: &dyn LanguagePlugin = &GoPlugin;
304 }
305
306 #[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 #[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 #[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 #[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 #[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 #[test]
539 fn test_plugins_match_language_id_for_extension() {
540 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 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 #[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}