1use std::collections::HashMap;
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ServerState {
6 Undetected,
7 Missing,
8 Available,
9 Installing,
10 Starting,
11 Running,
12 Stopped,
13 Failed,
14}
15
16impl ServerState {
17 pub fn display(&self) -> &'static str {
18 match self {
19 Self::Undetected => "LSP: checking...",
20 Self::Missing => "LSP: not installed",
21 Self::Available => "LSP: available",
22 Self::Installing => "LSP: installing...",
23 Self::Starting => "LSP: starting...",
24 Self::Running => "LSP: ready",
25 Self::Stopped => "LSP: stopped",
26 Self::Failed => "LSP: failed",
27 }
28 }
29
30 pub fn is_available(&self) -> bool {
31 matches!(self, Self::Running)
32 }
33
34 pub fn is_pending(&self) -> bool {
35 matches!(
36 self,
37 Self::Undetected | Self::Installing | Self::Starting | Self::Available
38 )
39 }
40
41 pub fn is_terminal(&self) -> bool {
42 matches!(self, Self::Running | Self::Failed | Self::Stopped)
43 }
44
45 pub fn can_transition_to(self, new: Self) -> bool {
48 use ServerState::*;
49 if self == new {
50 return false;
51 }
52 matches!(
53 (self, new),
54 (
55 Undetected,
56 Missing | Available | Installing | Failed | Stopped
57 ) | (Missing, Installing | Available | Failed | Stopped)
58 | (Installing, Available | Failed | Stopped)
59 | (Available, Starting | Stopped | Failed)
60 | (Starting, Running | Failed | Stopped)
61 | (Running, Stopped | Failed)
62 | (Failed, Available | Installing | Starting | Stopped)
63 | (Stopped, Available | Starting)
64 )
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Language {
70 Rust,
71 Go,
72 TypeScript,
73 Python,
74 Markdown,
75 Json,
76 Yaml,
77 Toml,
78 Dockerfile,
79 Xml,
80}
81
82pub struct LanguageCapabilities {
83 pub has_tree_sitter: bool,
84 pub has_lsp: bool,
85}
86
87impl Language {
88 pub fn capabilities(self) -> LanguageCapabilities {
89 match self {
90 Language::Rust => LanguageCapabilities {
91 has_tree_sitter: true,
92 has_lsp: true,
93 },
94 Language::Go => LanguageCapabilities {
95 has_tree_sitter: true,
96 has_lsp: true,
97 },
98 Language::TypeScript => LanguageCapabilities {
99 has_tree_sitter: true,
100 has_lsp: true,
101 },
102 Language::Python => LanguageCapabilities {
103 has_tree_sitter: true,
104 has_lsp: true,
105 },
106 Language::Markdown => LanguageCapabilities {
107 has_tree_sitter: true,
108 has_lsp: false,
109 },
110 Language::Json => LanguageCapabilities {
111 has_tree_sitter: true,
112 has_lsp: false,
113 },
114 Language::Yaml => LanguageCapabilities {
115 has_tree_sitter: true,
116 has_lsp: false,
117 },
118 Language::Toml => LanguageCapabilities {
119 has_tree_sitter: true,
120 has_lsp: false,
121 },
122 Language::Dockerfile => LanguageCapabilities {
123 has_tree_sitter: true,
124 has_lsp: false,
125 },
126 Language::Xml => LanguageCapabilities {
127 has_tree_sitter: true,
128 has_lsp: false,
129 },
130 }
131 }
132
133 pub fn from_extension(ext: &str) -> Option<Self> {
134 match ext {
135 "rs" => Some(Self::Rust),
136 "go" => Some(Self::Go),
137 "ts" | "tsx" | "js" | "jsx" => Some(Self::TypeScript),
138 "py" => Some(Self::Python),
139 "md" | "markdown" => Some(Self::Markdown),
140 "json" | "jsonc" => Some(Self::Json),
141 "yaml" | "yml" => Some(Self::Yaml),
142 "toml" => Some(Self::Toml),
143 "dockerfile" => Some(Self::Dockerfile),
144 "xml" | "xsd" | "xsl" | "xslt" | "svg" | "rss" => Some(Self::Xml),
145 _ => None,
146 }
147 }
148
149 pub fn from_path(path: &std::path::Path) -> Option<Self> {
150 if let Some(ext) = path.extension().and_then(|e| e.to_str())
151 && let Some(lang) = Self::from_extension(ext)
152 {
153 return Some(lang);
154 }
155 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
156 let lower = file_name.to_ascii_lowercase();
157 if lower == "docker-compose.yml" || lower == "docker-compose.yaml" {
158 return Some(Self::Yaml);
159 }
160 if lower.starts_with("dockerfile") {
163 return Some(Self::Dockerfile);
164 }
165 None
166 }
167
168 pub fn language_id_for_path(path: &std::path::Path) -> Option<&'static str> {
169 path.extension()
170 .and_then(|e| e.to_str())
171 .and_then(|ext| match ext {
172 "rs" => Some("rust"),
173 "go" => Some("go"),
174 "ts" => Some("typescript"),
175 "tsx" => Some("typescriptreact"),
176 "js" => Some("javascript"),
177 "jsx" => Some("javascriptreact"),
178 "py" => Some("python"),
179 _ => None,
180 })
181 }
182
183 pub fn name(&self) -> &'static str {
184 match self {
185 Self::Rust => "rust",
186 Self::Go => "go",
187 Self::TypeScript => "typescript",
188 Self::Python => "python",
189 Self::Markdown => "markdown",
190 Self::Json => "json",
191 Self::Yaml => "yaml",
192 Self::Toml => "toml",
193 Self::Dockerfile => "dockerfile",
194 Self::Xml => "xml",
195 }
196 }
197
198 pub fn short_name(&self) -> &'static str {
199 match self {
200 Self::Rust => "rs",
201 Self::Go => "go",
202 Self::TypeScript => "ts",
203 Self::Python => "py",
204 Self::Markdown => "md",
205 Self::Json => "json",
206 Self::Yaml => "yaml",
207 Self::Toml => "toml",
208 Self::Dockerfile => "docker",
209 Self::Xml => "xml",
210 }
211 }
212}
213
214#[derive(Debug, Clone)]
215pub struct DocumentSymbol {
216 pub name: String,
217 pub kind: SymbolKind,
218 pub range: SymbolRange,
219 pub selection_range: Option<SymbolRange>,
220 pub children: Vec<DocumentSymbol>,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct SymbolRange {
225 pub start_line: usize,
226 pub start_col: usize,
227 pub end_line: usize,
228 pub end_col: usize,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum SymbolKind {
233 Function,
234 Variable,
235 Struct,
236 Enum,
237 Impl,
238 Const,
239 Field,
240 Method,
241 Module,
242 Other(String),
243}
244
245#[derive(Debug, Clone)]
246pub struct CompletionItem {
247 pub label: String,
248 pub detail: Option<String>,
249 pub kind: Option<String>,
250}
251
252#[derive(Debug, Clone)]
253pub struct HoverInfo {
254 pub contents: String,
255}
256
257#[derive(Debug, Clone)]
258pub struct Location {
259 pub file_path: PathBuf,
260 pub range: SymbolRange,
261}
262
263#[derive(Debug, Clone)]
264pub enum LspEvent {
265 StateChanged {
266 language: Language,
267 old: ServerState,
268 new: ServerState,
269 },
270 DiagnosticsReceived {
271 file_path: PathBuf,
272 diagnostics: Vec<Diagnostic>,
273 },
274 ServerMessage {
275 language: Language,
276 message: String,
277 },
278 Error {
279 language: Language,
280 error: String,
281 },
282}
283
284#[derive(Debug, Clone)]
285pub struct Diagnostic {
286 pub range: SymbolRange,
287 pub severity: DiagnosticSeverity,
288 pub message: String,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum DiagnosticSeverity {
293 Error,
294 Warning,
295 Information,
296 Hint,
297}
298
299#[derive(Debug, Clone)]
300pub struct SemanticToken {
301 pub line: usize,
302 pub start_col: usize,
303 pub length: usize,
304 pub token_type: String,
305}
306
307#[derive(Debug, Clone)]
308pub struct SelectionRange {
309 pub range: SymbolRange,
310 pub parent: Option<Box<SelectionRange>>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum InstallLine {
315 Stdout(String),
316 Stderr(String),
317 Failed(String),
318}
319
320#[derive(Debug, Clone, Default)]
321pub struct LspSharedState {
322 pub status: HashMap<Language, ServerState>,
323 pub install_line: Option<InstallLine>,
324}
325
326pub struct LspServerInfo {
327 pub language: Language,
328 pub server_name: &'static str,
329 pub binary_name: &'static str,
330 pub install_command: &'static str,
331 pub check_command: &'static str,
332 pub default_args: &'static [&'static str],
333 pub init_options_json: &'static str,
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn language_capabilities_all_variants() {
342 let rust = Language::Rust.capabilities();
343 assert!(rust.has_tree_sitter);
344 assert!(rust.has_lsp);
345
346 let go = Language::Go.capabilities();
347 assert!(go.has_tree_sitter);
348 assert!(go.has_lsp);
349
350 let ts = Language::TypeScript.capabilities();
351 assert!(ts.has_tree_sitter);
352 assert!(ts.has_lsp);
353
354 let py = Language::Python.capabilities();
355 assert!(py.has_tree_sitter);
356 assert!(py.has_lsp);
357
358 let md = Language::Markdown.capabilities();
359 assert!(md.has_tree_sitter);
360 assert!(!md.has_lsp, "Markdown has no LSP server");
361 }
362
363 #[test]
364 fn language_from_extension_work_item_cases() {
365 assert_eq!(Language::from_extension("md"), Some(Language::Markdown));
366 assert_eq!(
367 Language::from_extension("markdown"),
368 Some(Language::Markdown)
369 );
370 assert_eq!(Language::from_extension("tsx"), Some(Language::TypeScript));
371 assert_eq!(Language::from_extension("py"), Some(Language::Python));
372 assert_eq!(Language::from_extension("go"), Some(Language::Go));
373 assert_eq!(Language::from_extension("rs"), Some(Language::Rust));
374 }
375
376 #[test]
377 fn language_id_for_path_all_cases() {
378 use std::path::Path;
379 assert_eq!(
380 Language::language_id_for_path(Path::new("foo.tsx")),
381 Some("typescriptreact")
382 );
383 assert_eq!(
384 Language::language_id_for_path(Path::new("foo.jsx")),
385 Some("javascriptreact")
386 );
387 assert_eq!(
388 Language::language_id_for_path(Path::new("foo.ts")),
389 Some("typescript")
390 );
391 assert_eq!(
392 Language::language_id_for_path(Path::new("foo.js")),
393 Some("javascript")
394 );
395 assert_eq!(
396 Language::language_id_for_path(Path::new("foo.rs")),
397 Some("rust")
398 );
399 assert_eq!(
400 Language::language_id_for_path(Path::new("foo.go")),
401 Some("go")
402 );
403 assert_eq!(
404 Language::language_id_for_path(Path::new("foo.py")),
405 Some("python")
406 );
407 assert_eq!(Language::language_id_for_path(Path::new("foo.md")), None);
409 }
410
411 #[test]
412 fn language_detection() {
413 assert_eq!(Language::from_extension("rs"), Some(Language::Rust));
414 assert_eq!(Language::from_extension("go"), Some(Language::Go));
415 assert_eq!(Language::from_extension("ts"), Some(Language::TypeScript));
416 assert_eq!(Language::from_extension("tsx"), Some(Language::TypeScript));
417 assert_eq!(Language::from_extension("js"), Some(Language::TypeScript));
418 assert_eq!(Language::from_extension("jsx"), Some(Language::TypeScript));
419 assert_eq!(Language::from_extension("py"), Some(Language::Python));
420 assert_eq!(Language::from_extension("md"), Some(Language::Markdown));
421 assert_eq!(
422 Language::from_extension("markdown"),
423 Some(Language::Markdown)
424 );
425 assert_eq!(Language::from_extension(""), None);
426 assert_eq!(Language::from_extension("txt"), None);
427 }
428
429 #[test]
430 fn language_name() {
431 assert_eq!(Language::Rust.name(), "rust");
432 assert_eq!(Language::Go.name(), "go");
433 assert_eq!(Language::TypeScript.name(), "typescript");
434 assert_eq!(Language::Python.name(), "python");
435 assert_eq!(Language::Markdown.name(), "markdown");
436 }
437
438 #[test]
439 fn is_available_only_for_running() {
440 assert!(ServerState::Running.is_available());
441 assert!(!ServerState::Undetected.is_available());
442 assert!(!ServerState::Missing.is_available());
443 assert!(!ServerState::Available.is_available());
444 assert!(!ServerState::Installing.is_available());
445 assert!(!ServerState::Starting.is_available());
446 assert!(!ServerState::Stopped.is_available());
447 assert!(!ServerState::Failed.is_available());
448 }
449
450 #[test]
451 fn is_pending_covers_transient_states() {
452 assert!(ServerState::Undetected.is_pending());
453 assert!(ServerState::Installing.is_pending());
454 assert!(ServerState::Starting.is_pending());
455 assert!(ServerState::Available.is_pending());
456 assert!(!ServerState::Running.is_pending());
457 assert!(!ServerState::Missing.is_pending());
458 assert!(!ServerState::Stopped.is_pending());
459 assert!(!ServerState::Failed.is_pending());
460 }
461
462 #[test]
463 fn is_terminal_covers_final_states() {
464 assert!(ServerState::Running.is_terminal());
465 assert!(ServerState::Failed.is_terminal());
466 assert!(ServerState::Stopped.is_terminal());
467 assert!(!ServerState::Undetected.is_terminal());
468 assert!(!ServerState::Missing.is_terminal());
469 assert!(!ServerState::Available.is_terminal());
470 assert!(!ServerState::Installing.is_terminal());
471 assert!(!ServerState::Starting.is_terminal());
472 }
473
474 #[test]
475 fn terminal_states_are_not_pending() {
476 for state in [
477 ServerState::Running,
478 ServerState::Failed,
479 ServerState::Stopped,
480 ] {
481 assert!(!state.is_pending(), "{:?} should not be pending", state);
482 }
483 }
484
485 #[test]
486 fn pending_states_are_not_terminal() {
487 for state in [
488 ServerState::Undetected,
489 ServerState::Installing,
490 ServerState::Starting,
491 ServerState::Available,
492 ] {
493 assert!(!state.is_terminal(), "{:?} should not be terminal", state);
494 }
495 }
496
497 #[test]
498 fn all_states_have_display() {
499 assert_eq!(ServerState::Undetected.display(), "LSP: checking...");
500 assert_eq!(ServerState::Missing.display(), "LSP: not installed");
501 assert_eq!(ServerState::Available.display(), "LSP: available");
502 assert_eq!(ServerState::Installing.display(), "LSP: installing...");
503 assert_eq!(ServerState::Starting.display(), "LSP: starting...");
504 assert_eq!(ServerState::Running.display(), "LSP: ready");
505 assert_eq!(ServerState::Stopped.display(), "LSP: stopped");
506 assert_eq!(ServerState::Failed.display(), "LSP: failed");
507 }
508
509 #[test]
510 fn state_equality() {
511 assert_eq!(ServerState::Running, ServerState::Running);
512 assert_ne!(ServerState::Running, ServerState::Failed);
513 assert_ne!(ServerState::Stopped, ServerState::Failed);
514 }
515
516 #[test]
517 fn symbol_kind_other_preserves_content() {
518 let kind = SymbolKind::Other("custom_42".to_string());
519 assert_eq!(kind, SymbolKind::Other("custom_42".to_string()));
520 assert_ne!(kind, SymbolKind::Other("custom_99".to_string()));
521 assert_ne!(kind, SymbolKind::Function);
522 }
523
524 #[test]
525 fn symbol_range_equality() {
526 let r1 = SymbolRange {
527 start_line: 0,
528 start_col: 0,
529 end_line: 5,
530 end_col: 10,
531 };
532 let r2 = SymbolRange {
533 start_line: 0,
534 start_col: 0,
535 end_line: 5,
536 end_col: 10,
537 };
538 assert_eq!(r1, r2);
539 }
540
541 #[test]
542 fn can_transition_to_allows_normal_startup_path() {
543 assert!(ServerState::Undetected.can_transition_to(ServerState::Available));
544 assert!(ServerState::Available.can_transition_to(ServerState::Starting));
545 assert!(ServerState::Starting.can_transition_to(ServerState::Running));
546 assert!(ServerState::Running.can_transition_to(ServerState::Stopped));
547 }
548
549 #[test]
550 fn can_transition_to_allows_install_path() {
551 assert!(ServerState::Undetected.can_transition_to(ServerState::Missing));
552 assert!(ServerState::Missing.can_transition_to(ServerState::Installing));
553 assert!(ServerState::Installing.can_transition_to(ServerState::Available));
554 assert!(ServerState::Installing.can_transition_to(ServerState::Failed));
555 }
556
557 #[test]
558 fn can_transition_to_allows_failure_paths() {
559 assert!(ServerState::Starting.can_transition_to(ServerState::Failed));
560 assert!(ServerState::Running.can_transition_to(ServerState::Failed));
561 assert!(ServerState::Available.can_transition_to(ServerState::Failed));
562 }
563
564 #[test]
565 fn can_transition_to_allows_retry_from_failed() {
566 assert!(ServerState::Failed.can_transition_to(ServerState::Available));
567 assert!(ServerState::Failed.can_transition_to(ServerState::Installing));
568 assert!(ServerState::Failed.can_transition_to(ServerState::Starting));
569 }
570
571 #[test]
572 fn can_transition_to_rejects_invalid_jumps() {
573 assert!(!ServerState::Undetected.can_transition_to(ServerState::Running));
574 assert!(!ServerState::Undetected.can_transition_to(ServerState::Starting));
575 assert!(!ServerState::Missing.can_transition_to(ServerState::Running));
576 assert!(!ServerState::Available.can_transition_to(ServerState::Running));
577 assert!(!ServerState::Installing.can_transition_to(ServerState::Running));
578 assert!(!ServerState::Installing.can_transition_to(ServerState::Starting));
579 assert!(!ServerState::Running.can_transition_to(ServerState::Undetected));
580 assert!(!ServerState::Running.can_transition_to(ServerState::Available));
581 assert!(!ServerState::Running.can_transition_to(ServerState::Starting));
582 assert!(!ServerState::Stopped.can_transition_to(ServerState::Running));
583 assert!(!ServerState::Stopped.can_transition_to(ServerState::Failed));
584 }
585
586 #[test]
587 fn can_transition_to_rejects_self_transitions() {
588 for state in [
589 ServerState::Undetected,
590 ServerState::Missing,
591 ServerState::Available,
592 ServerState::Installing,
593 ServerState::Starting,
594 ServerState::Running,
595 ServerState::Stopped,
596 ServerState::Failed,
597 ] {
598 assert!(
599 !state.can_transition_to(state),
600 "{:?} → {:?} should not be allowed",
601 state,
602 state
603 );
604 }
605 }
606
607 #[test]
608 fn lsp_event_state_changed_fields() {
609 let event = LspEvent::StateChanged {
610 language: Language::Rust,
611 old: ServerState::Starting,
612 new: ServerState::Running,
613 };
614 match event {
615 LspEvent::StateChanged { language, old, new } => {
616 assert_eq!(language, Language::Rust);
617 assert_eq!(old, ServerState::Starting);
618 assert_eq!(new, ServerState::Running);
619 }
620 _ => panic!("wrong variant"),
621 }
622 }
623
624 #[test]
625 fn from_path_extension_detection_0009() {
626 use std::path::Path;
627 assert_eq!(
628 Language::from_path(Path::new("config.json")),
629 Some(Language::Json)
630 );
631 assert_eq!(
632 Language::from_path(Path::new("data.yaml")),
633 Some(Language::Yaml)
634 );
635 assert_eq!(
636 Language::from_path(Path::new("data.yml")),
637 Some(Language::Yaml)
638 );
639 assert_eq!(
640 Language::from_path(Path::new("config.toml")),
641 Some(Language::Toml)
642 );
643 assert_eq!(
644 Language::from_path(Path::new("schema.xml")),
645 Some(Language::Xml)
646 );
647 assert_eq!(
648 Language::from_path(Path::new("image.svg")),
649 Some(Language::Xml)
650 );
651 assert_eq!(
652 Language::from_path(Path::new("schema.xsd")),
653 Some(Language::Xml)
654 );
655 assert_eq!(Language::from_path(Path::new("unknown.foo")), None);
656 }
657
658 #[test]
659 fn from_path_dockerfile_stem_detection() {
660 use std::path::Path;
661 assert_eq!(
662 Language::from_path(Path::new("/project/Dockerfile")),
663 Some(Language::Dockerfile)
664 );
665 assert_eq!(
666 Language::from_path(Path::new("/project/dockerfile")),
667 Some(Language::Dockerfile)
668 );
669 assert_eq!(
670 Language::from_path(Path::new("/project/app.dockerfile")),
671 Some(Language::Dockerfile)
672 );
673 assert_eq!(
674 Language::from_path(Path::new("/project/main.go")),
675 Some(Language::Go)
676 );
677 }
678
679 #[test]
680 fn from_path_dockerfile_prefix_variants() {
681 use std::path::Path;
682 for name in [
684 "Dockerfile.dev",
685 "Dockerfile.prod",
686 "Dockerfile-test",
687 "Dockerfile_ci",
688 "DOCKERFILE",
689 "dockerfile-utils",
690 ] {
691 assert_eq!(
692 Language::from_path(Path::new(name)),
693 Some(Language::Dockerfile),
694 "{name} should resolve to Dockerfile"
695 );
696 }
697 assert_eq!(
699 Language::from_path(Path::new("docker-compose.yml")),
700 Some(Language::Yaml)
701 );
702 assert_eq!(Language::from_path(Path::new("README")), None);
704 }
705
706 #[test]
707 fn capabilities_new_variants_0009() {
708 for lang in [
709 Language::Json,
710 Language::Yaml,
711 Language::Toml,
712 Language::Dockerfile,
713 Language::Xml,
714 ] {
715 let caps = lang.capabilities();
716 assert!(
717 caps.has_tree_sitter,
718 "{} should have has_tree_sitter: true",
719 lang.name()
720 );
721 assert!(!caps.has_lsp, "{} should have has_lsp: false", lang.name());
722 }
723 }
724}