codive_lsp/
server.rs

1//! LSP server definitions and spawning
2//!
3//! This module defines available language servers and how to spawn them.
4//! Each server has a unique ID, supported file extensions, root detection logic,
5//! and a spawn function.
6
7use anyhow::{Context, Result};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Stdio};
11use std::sync::Arc;
12use tracing::{debug, info, warn};
13
14/// Root detection function type
15pub type RootDetector = Arc<dyn Fn(&Path) -> Option<PathBuf> + Send + Sync>;
16
17/// Spawn function type
18pub type SpawnFn = Arc<dyn Fn(&Path) -> Result<LspServerHandle> + Send + Sync>;
19
20/// Handle to a spawned LSP server process
21pub struct LspServerHandle {
22    /// The child process
23    pub process: Child,
24    /// Custom initialization options to send to the server
25    pub initialization: Option<serde_json::Value>,
26}
27
28/// Information about an LSP server
29pub struct LspServerInfo {
30    /// Unique identifier (e.g., "rust-analyzer", "typescript")
31    pub id: String,
32    /// File extensions this server handles (with leading dot)
33    pub extensions: Vec<String>,
34    /// Whether this is a global server (not project-specific)
35    pub global: bool,
36    /// Function to detect project root from a file path
37    pub root_detector: RootDetector,
38    /// Function to spawn the server
39    pub spawn_fn: SpawnFn,
40}
41
42impl LspServerInfo {
43    /// Create a new LSP server info
44    pub fn new(
45        id: impl Into<String>,
46        extensions: Vec<&str>,
47        root_detector: RootDetector,
48        spawn_fn: SpawnFn,
49    ) -> Self {
50        Self {
51            id: id.into(),
52            extensions: extensions.into_iter().map(String::from).collect(),
53            global: false,
54            root_detector,
55            spawn_fn,
56        }
57    }
58
59    /// Check if this server handles a given file extension
60    pub fn handles_extension(&self, ext: &str) -> bool {
61        self.extensions.is_empty() || self.extensions.iter().any(|e| e == ext)
62    }
63
64    /// Detect the project root for a file
65    pub fn detect_root(&self, file: &Path) -> Option<PathBuf> {
66        (self.root_detector)(file)
67    }
68
69    /// Spawn the server for a project root
70    pub fn spawn(&self, root: &Path) -> Result<LspServerHandle> {
71        (self.spawn_fn)(root)
72    }
73}
74
75/// Registry of available LSP servers
76pub struct LspRegistry {
77    servers: HashMap<String, LspServerInfo>,
78}
79
80impl Default for LspRegistry {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl LspRegistry {
87    /// Create a new registry with default servers
88    pub fn new() -> Self {
89        let mut registry = Self {
90            servers: HashMap::new(),
91        };
92
93        // Register built-in servers
94        registry.register(Self::rust_analyzer());
95        registry.register(Self::typescript());
96        registry.register(Self::pyright());
97        registry.register(Self::gopls());
98        registry.register(Self::deno());
99        registry.register(Self::clangd());
100        registry.register(Self::lua_ls());
101        registry.register(Self::css());
102        registry.register(Self::html());
103        registry.register(Self::json());
104        registry.register(Self::yaml());
105        registry.register(Self::vue());
106        registry.register(Self::svelte());
107        registry.register(Self::tailwindcss());
108        registry.register(Self::eslint());
109        registry.register(Self::ruby());
110        registry.register(Self::php());
111        registry.register(Self::zig());
112
113        registry
114    }
115
116    /// Register a server
117    pub fn register(&mut self, server: LspServerInfo) {
118        info!(server_id = %server.id, "Registering LSP server");
119        self.servers.insert(server.id.clone(), server);
120    }
121
122    /// Get a server by ID
123    pub fn get(&self, id: &str) -> Option<&LspServerInfo> {
124        self.servers.get(id)
125    }
126
127    /// Get all servers that handle a given file extension
128    pub fn servers_for_extension(&self, ext: &str) -> Vec<&LspServerInfo> {
129        self.servers
130            .values()
131            .filter(|s| s.handles_extension(ext))
132            .collect()
133    }
134
135    /// Get all registered servers
136    pub fn all_servers(&self) -> impl Iterator<Item = &LspServerInfo> {
137        self.servers.values()
138    }
139
140    /// Get server IDs
141    pub fn server_ids(&self) -> Vec<&str> {
142        self.servers.keys().map(|s| s.as_str()).collect()
143    }
144
145    // ========================================================================
146    // Built-in Server Definitions
147    // ========================================================================
148
149    /// rust-analyzer for Rust
150    fn rust_analyzer() -> LspServerInfo {
151        LspServerInfo::new(
152            "rust-analyzer",
153            vec![".rs"],
154            Arc::new(|file| find_project_root(file, &["Cargo.toml"])),
155            Arc::new(|root| {
156                let binary = which::which("rust-analyzer")
157                    .context("rust-analyzer not found in PATH")?;
158
159                debug!(binary = ?binary, root = ?root, "Spawning rust-analyzer");
160
161                let process = Command::new(binary)
162                    .current_dir(root)
163                    .stdin(Stdio::piped())
164                    .stdout(Stdio::piped())
165                    .stderr(Stdio::null())
166                    .spawn()
167                    .context("Failed to spawn rust-analyzer")?;
168
169                Ok(LspServerHandle {
170                    process,
171                    initialization: Some(serde_json::json!({
172                        "checkOnSave": {
173                            "command": "clippy"
174                        }
175                    })),
176                })
177            }),
178        )
179    }
180
181    /// typescript-language-server for TypeScript/JavaScript
182    fn typescript() -> LspServerInfo {
183        LspServerInfo::new(
184            "typescript",
185            vec![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
186            Arc::new(|file| {
187                // Exclude if deno.json exists (use Deno LSP instead)
188                if find_project_root(file, &["deno.json", "deno.jsonc"]).is_some() {
189                    return None;
190                }
191                find_project_root(
192                    file,
193                    &[
194                        "package-lock.json",
195                        "bun.lockb",
196                        "bun.lock",
197                        "pnpm-lock.yaml",
198                        "yarn.lock",
199                        "package.json",
200                    ],
201                )
202            }),
203            Arc::new(|root| {
204                // Try to find typescript-language-server
205                let binary = which::which("typescript-language-server")
206                    .context("typescript-language-server not found in PATH")?;
207
208                debug!(binary = ?binary, root = ?root, "Spawning typescript-language-server");
209
210                let process = Command::new(binary)
211                    .arg("--stdio")
212                    .current_dir(root)
213                    .stdin(Stdio::piped())
214                    .stdout(Stdio::piped())
215                    .stderr(Stdio::null())
216                    .spawn()
217                    .context("Failed to spawn typescript-language-server")?;
218
219                Ok(LspServerHandle {
220                    process,
221                    initialization: None,
222                })
223            }),
224        )
225    }
226
227    /// Pyright for Python
228    fn pyright() -> LspServerInfo {
229        LspServerInfo::new(
230            "pyright",
231            vec![".py", ".pyi"],
232            Arc::new(|file| {
233                find_project_root(
234                    file,
235                    &[
236                        "pyproject.toml",
237                        "setup.py",
238                        "setup.cfg",
239                        "requirements.txt",
240                        "pyrightconfig.json",
241                    ],
242                )
243            }),
244            Arc::new(|root| {
245                // Try pyright-langserver first (npm package), then pyright
246                let binary = which::which("pyright-langserver")
247                    .or_else(|_| which::which("pyright"))
248                    .context("pyright not found in PATH")?;
249
250                debug!(binary = ?binary, root = ?root, "Spawning pyright");
251
252                let process = Command::new(binary)
253                    .arg("--stdio")
254                    .current_dir(root)
255                    .stdin(Stdio::piped())
256                    .stdout(Stdio::piped())
257                    .stderr(Stdio::null())
258                    .spawn()
259                    .context("Failed to spawn pyright")?;
260
261                Ok(LspServerHandle {
262                    process,
263                    initialization: None,
264                })
265            }),
266        )
267    }
268
269    /// gopls for Go
270    fn gopls() -> LspServerInfo {
271        LspServerInfo::new(
272            "gopls",
273            vec![".go"],
274            Arc::new(|file| find_project_root(file, &["go.mod", "go.work"])),
275            Arc::new(|root| {
276                let binary =
277                    which::which("gopls").context("gopls not found in PATH")?;
278
279                debug!(binary = ?binary, root = ?root, "Spawning gopls");
280
281                let process = Command::new(binary)
282                    .current_dir(root)
283                    .stdin(Stdio::piped())
284                    .stdout(Stdio::piped())
285                    .stderr(Stdio::null())
286                    .spawn()
287                    .context("Failed to spawn gopls")?;
288
289                Ok(LspServerHandle {
290                    process,
291                    initialization: None,
292                })
293            }),
294        )
295    }
296
297    /// Deno LSP for Deno projects
298    fn deno() -> LspServerInfo {
299        LspServerInfo::new(
300            "deno",
301            vec![".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"],
302            Arc::new(|file| {
303                // Only activate if deno.json exists
304                find_project_root(file, &["deno.json", "deno.jsonc"])
305            }),
306            Arc::new(|root| {
307                let binary = which::which("deno").context("deno not found in PATH")?;
308
309                debug!(binary = ?binary, root = ?root, "Spawning deno lsp");
310
311                let process = Command::new(binary)
312                    .arg("lsp")
313                    .current_dir(root)
314                    .stdin(Stdio::piped())
315                    .stdout(Stdio::piped())
316                    .stderr(Stdio::null())
317                    .spawn()
318                    .context("Failed to spawn deno lsp")?;
319
320                Ok(LspServerHandle {
321                    process,
322                    initialization: Some(serde_json::json!({
323                        "enable": true,
324                        "lint": true,
325                        "unstable": false
326                    })),
327                })
328            }),
329        )
330    }
331
332    /// clangd for C/C++
333    fn clangd() -> LspServerInfo {
334        LspServerInfo::new(
335            "clangd",
336            vec![".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".C", ".H"],
337            Arc::new(|file| {
338                find_project_root(
339                    file,
340                    &["compile_commands.json", "CMakeLists.txt", "Makefile", ".clangd"],
341                )
342            }),
343            Arc::new(|root| {
344                let binary = which::which("clangd").context("clangd not found in PATH")?;
345
346                debug!(binary = ?binary, root = ?root, "Spawning clangd");
347
348                let process = Command::new(binary)
349                    .arg("--background-index")
350                    .current_dir(root)
351                    .stdin(Stdio::piped())
352                    .stdout(Stdio::piped())
353                    .stderr(Stdio::null())
354                    .spawn()
355                    .context("Failed to spawn clangd")?;
356
357                Ok(LspServerHandle {
358                    process,
359                    initialization: None,
360                })
361            }),
362        )
363    }
364
365    /// lua-language-server for Lua
366    fn lua_ls() -> LspServerInfo {
367        LspServerInfo::new(
368            "lua-language-server",
369            vec![".lua"],
370            Arc::new(|file| {
371                find_project_root(file, &[".luarc.json", ".luarc.jsonc", ".luacheckrc"])
372                    .or_else(|| file.parent().map(|p| p.to_path_buf()))
373            }),
374            Arc::new(|root| {
375                let binary = which::which("lua-language-server")
376                    .context("lua-language-server not found in PATH")?;
377
378                debug!(binary = ?binary, root = ?root, "Spawning lua-language-server");
379
380                let process = Command::new(binary)
381                    .current_dir(root)
382                    .stdin(Stdio::piped())
383                    .stdout(Stdio::piped())
384                    .stderr(Stdio::null())
385                    .spawn()
386                    .context("Failed to spawn lua-language-server")?;
387
388                Ok(LspServerHandle {
389                    process,
390                    initialization: None,
391                })
392            }),
393        )
394    }
395
396    /// CSS Language Server
397    fn css() -> LspServerInfo {
398        LspServerInfo::new(
399            "css",
400            vec![".css", ".scss", ".sass", ".less"],
401            Arc::new(|file| {
402                find_project_root(file, &["package.json"]).or_else(|| file.parent().map(|p| p.to_path_buf()))
403            }),
404            Arc::new(|root| {
405                // Try vscode-css-language-server or css-languageserver
406                let binary = which::which("vscode-css-language-server")
407                    .or_else(|_| which::which("css-languageserver"))
408                    .context("css language server not found in PATH")?;
409
410                debug!(binary = ?binary, root = ?root, "Spawning CSS language server");
411
412                let process = Command::new(binary)
413                    .arg("--stdio")
414                    .current_dir(root)
415                    .stdin(Stdio::piped())
416                    .stdout(Stdio::piped())
417                    .stderr(Stdio::null())
418                    .spawn()
419                    .context("Failed to spawn CSS language server")?;
420
421                Ok(LspServerHandle {
422                    process,
423                    initialization: None,
424                })
425            }),
426        )
427    }
428
429    /// HTML Language Server
430    fn html() -> LspServerInfo {
431        LspServerInfo::new(
432            "html",
433            vec![".html", ".htm"],
434            Arc::new(|file| {
435                find_project_root(file, &["package.json"]).or_else(|| file.parent().map(|p| p.to_path_buf()))
436            }),
437            Arc::new(|root| {
438                let binary = which::which("vscode-html-language-server")
439                    .or_else(|_| which::which("html-languageserver"))
440                    .context("HTML language server not found in PATH")?;
441
442                debug!(binary = ?binary, root = ?root, "Spawning HTML language server");
443
444                let process = Command::new(binary)
445                    .arg("--stdio")
446                    .current_dir(root)
447                    .stdin(Stdio::piped())
448                    .stdout(Stdio::piped())
449                    .stderr(Stdio::null())
450                    .spawn()
451                    .context("Failed to spawn HTML language server")?;
452
453                Ok(LspServerHandle {
454                    process,
455                    initialization: None,
456                })
457            }),
458        )
459    }
460
461    /// JSON Language Server
462    fn json() -> LspServerInfo {
463        LspServerInfo::new(
464            "json",
465            vec![".json", ".jsonc"],
466            Arc::new(|file| file.parent().map(|p| p.to_path_buf())),
467            Arc::new(|root| {
468                let binary = which::which("vscode-json-language-server")
469                    .or_else(|_| which::which("json-languageserver"))
470                    .context("JSON language server not found in PATH")?;
471
472                debug!(binary = ?binary, root = ?root, "Spawning JSON language server");
473
474                let process = Command::new(binary)
475                    .arg("--stdio")
476                    .current_dir(root)
477                    .stdin(Stdio::piped())
478                    .stdout(Stdio::piped())
479                    .stderr(Stdio::null())
480                    .spawn()
481                    .context("Failed to spawn JSON language server")?;
482
483                Ok(LspServerHandle {
484                    process,
485                    initialization: None,
486                })
487            }),
488        )
489    }
490
491    /// YAML Language Server
492    fn yaml() -> LspServerInfo {
493        LspServerInfo::new(
494            "yaml",
495            vec![".yaml", ".yml"],
496            Arc::new(|file| file.parent().map(|p| p.to_path_buf())),
497            Arc::new(|root| {
498                let binary =
499                    which::which("yaml-language-server").context("yaml-language-server not found in PATH")?;
500
501                debug!(binary = ?binary, root = ?root, "Spawning YAML language server");
502
503                let process = Command::new(binary)
504                    .arg("--stdio")
505                    .current_dir(root)
506                    .stdin(Stdio::piped())
507                    .stdout(Stdio::piped())
508                    .stderr(Stdio::null())
509                    .spawn()
510                    .context("Failed to spawn yaml-language-server")?;
511
512                Ok(LspServerHandle {
513                    process,
514                    initialization: None,
515                })
516            }),
517        )
518    }
519
520    /// Vue Language Server (Volar)
521    fn vue() -> LspServerInfo {
522        LspServerInfo::new(
523            "vue",
524            vec![".vue"],
525            Arc::new(|file| find_project_root(file, &["package.json", "vue.config.js", "vite.config.js"])),
526            Arc::new(|root| {
527                // Try vue-language-server (Volar)
528                let binary = which::which("vue-language-server")
529                    .or_else(|_| which::which("volar-server"))
530                    .context("Vue language server not found in PATH")?;
531
532                debug!(binary = ?binary, root = ?root, "Spawning Vue language server");
533
534                let process = Command::new(binary)
535                    .arg("--stdio")
536                    .current_dir(root)
537                    .stdin(Stdio::piped())
538                    .stdout(Stdio::piped())
539                    .stderr(Stdio::null())
540                    .spawn()
541                    .context("Failed to spawn Vue language server")?;
542
543                Ok(LspServerHandle {
544                    process,
545                    initialization: None,
546                })
547            }),
548        )
549    }
550
551    /// Svelte Language Server
552    fn svelte() -> LspServerInfo {
553        LspServerInfo::new(
554            "svelte",
555            vec![".svelte"],
556            Arc::new(|file| find_project_root(file, &["package.json", "svelte.config.js"])),
557            Arc::new(|root| {
558                let binary = which::which("svelteserver")
559                    .or_else(|_| which::which("svelte-language-server"))
560                    .context("Svelte language server not found in PATH")?;
561
562                debug!(binary = ?binary, root = ?root, "Spawning Svelte language server");
563
564                let process = Command::new(binary)
565                    .arg("--stdio")
566                    .current_dir(root)
567                    .stdin(Stdio::piped())
568                    .stdout(Stdio::piped())
569                    .stderr(Stdio::null())
570                    .spawn()
571                    .context("Failed to spawn Svelte language server")?;
572
573                Ok(LspServerHandle {
574                    process,
575                    initialization: None,
576                })
577            }),
578        )
579    }
580
581    /// Tailwind CSS IntelliSense
582    fn tailwindcss() -> LspServerInfo {
583        LspServerInfo::new(
584            "tailwindcss",
585            vec![".html", ".jsx", ".tsx", ".vue", ".svelte"],
586            Arc::new(|file| {
587                // Only activate if tailwind config exists
588                find_project_root(file, &["tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs"])
589            }),
590            Arc::new(|root| {
591                let binary = which::which("tailwindcss-language-server")
592                    .context("tailwindcss-language-server not found in PATH")?;
593
594                debug!(binary = ?binary, root = ?root, "Spawning Tailwind CSS language server");
595
596                let process = Command::new(binary)
597                    .arg("--stdio")
598                    .current_dir(root)
599                    .stdin(Stdio::piped())
600                    .stdout(Stdio::piped())
601                    .stderr(Stdio::null())
602                    .spawn()
603                    .context("Failed to spawn tailwindcss-language-server")?;
604
605                Ok(LspServerHandle {
606                    process,
607                    initialization: None,
608                })
609            }),
610        )
611    }
612
613    /// ESLint Language Server
614    fn eslint() -> LspServerInfo {
615        LspServerInfo::new(
616            "eslint",
617            vec![".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"],
618            Arc::new(|file| {
619                // Only activate if ESLint config exists
620                find_project_root(
621                    file,
622                    &[
623                        ".eslintrc",
624                        ".eslintrc.js",
625                        ".eslintrc.json",
626                        ".eslintrc.yaml",
627                        ".eslintrc.yml",
628                        "eslint.config.js",
629                        "eslint.config.mjs",
630                    ],
631                )
632            }),
633            Arc::new(|root| {
634                let binary = which::which("vscode-eslint-language-server")
635                    .or_else(|_| which::which("eslint-language-server"))
636                    .context("ESLint language server not found in PATH")?;
637
638                debug!(binary = ?binary, root = ?root, "Spawning ESLint language server");
639
640                let process = Command::new(binary)
641                    .arg("--stdio")
642                    .current_dir(root)
643                    .stdin(Stdio::piped())
644                    .stdout(Stdio::piped())
645                    .stderr(Stdio::null())
646                    .spawn()
647                    .context("Failed to spawn ESLint language server")?;
648
649                Ok(LspServerHandle {
650                    process,
651                    initialization: Some(serde_json::json!({
652                        "validate": "on",
653                        "packageManager": "npm",
654                        "codeActionOnSave": {
655                            "enable": false
656                        }
657                    })),
658                })
659            }),
660        )
661    }
662
663    /// Solargraph for Ruby
664    fn ruby() -> LspServerInfo {
665        LspServerInfo::new(
666            "solargraph",
667            vec![".rb", ".rake", ".gemspec"],
668            Arc::new(|file| {
669                find_project_root(file, &["Gemfile", ".solargraph.yml", "Rakefile"])
670            }),
671            Arc::new(|root| {
672                let binary =
673                    which::which("solargraph").context("solargraph not found in PATH")?;
674
675                debug!(binary = ?binary, root = ?root, "Spawning Solargraph");
676
677                let process = Command::new(binary)
678                    .arg("stdio")
679                    .current_dir(root)
680                    .stdin(Stdio::piped())
681                    .stdout(Stdio::piped())
682                    .stderr(Stdio::null())
683                    .spawn()
684                    .context("Failed to spawn solargraph")?;
685
686                Ok(LspServerHandle {
687                    process,
688                    initialization: None,
689                })
690            }),
691        )
692    }
693
694    /// Intelephense for PHP
695    fn php() -> LspServerInfo {
696        LspServerInfo::new(
697            "intelephense",
698            vec![".php", ".phtml"],
699            Arc::new(|file| {
700                find_project_root(file, &["composer.json", "index.php"])
701            }),
702            Arc::new(|root| {
703                let binary =
704                    which::which("intelephense").context("intelephense not found in PATH")?;
705
706                debug!(binary = ?binary, root = ?root, "Spawning Intelephense");
707
708                let process = Command::new(binary)
709                    .arg("--stdio")
710                    .current_dir(root)
711                    .stdin(Stdio::piped())
712                    .stdout(Stdio::piped())
713                    .stderr(Stdio::null())
714                    .spawn()
715                    .context("Failed to spawn intelephense")?;
716
717                Ok(LspServerHandle {
718                    process,
719                    initialization: None,
720                })
721            }),
722        )
723    }
724
725    /// ZLS for Zig
726    fn zig() -> LspServerInfo {
727        LspServerInfo::new(
728            "zls",
729            vec![".zig"],
730            Arc::new(|file| {
731                find_project_root(file, &["build.zig", "zls.json"])
732            }),
733            Arc::new(|root| {
734                let binary = which::which("zls").context("zls not found in PATH")?;
735
736                debug!(binary = ?binary, root = ?root, "Spawning ZLS");
737
738                let process = Command::new(binary)
739                    .current_dir(root)
740                    .stdin(Stdio::piped())
741                    .stdout(Stdio::piped())
742                    .stderr(Stdio::null())
743                    .spawn()
744                    .context("Failed to spawn zls")?;
745
746                Ok(LspServerHandle {
747                    process,
748                    initialization: None,
749                })
750            }),
751        )
752    }
753}
754
755// ============================================================================
756// Helper Functions
757// ============================================================================
758
759/// Find a project root by searching upward for marker files
760pub fn find_project_root(file: &Path, markers: &[&str]) -> Option<PathBuf> {
761    let start_dir = if file.is_file() {
762        file.parent()?
763    } else {
764        file
765    };
766
767    let mut current = start_dir;
768    loop {
769        for marker in markers {
770            if current.join(marker).exists() {
771                return Some(current.to_path_buf());
772            }
773        }
774
775        match current.parent() {
776            Some(parent) => current = parent,
777            None => break,
778        }
779    }
780
781    None
782}
783
784/// Find a project root, stopping at a specific directory
785pub fn find_project_root_with_stop(
786    file: &Path,
787    markers: &[&str],
788    stop_at: &Path,
789) -> Option<PathBuf> {
790    let start_dir = if file.is_file() {
791        file.parent()?
792    } else {
793        file
794    };
795
796    let mut current = start_dir;
797    loop {
798        // Stop if we've reached the boundary
799        if current == stop_at {
800            break;
801        }
802
803        for marker in markers {
804            if current.join(marker).exists() {
805                return Some(current.to_path_buf());
806            }
807        }
808
809        match current.parent() {
810            Some(parent) => current = parent,
811            None => break,
812        }
813    }
814
815    None
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use std::fs;
822    use tempfile::tempdir;
823
824    #[test]
825    fn test_find_project_root() {
826        let temp = tempdir().unwrap();
827        let project_dir = temp.path().join("my-project");
828        let src_dir = project_dir.join("src");
829        let nested_dir = src_dir.join("nested");
830
831        fs::create_dir_all(&nested_dir).unwrap();
832        fs::write(project_dir.join("Cargo.toml"), "").unwrap();
833
834        let file = nested_dir.join("main.rs");
835        fs::write(&file, "").unwrap();
836
837        let root = find_project_root(&file, &["Cargo.toml"]);
838        assert_eq!(root, Some(project_dir));
839    }
840
841    #[test]
842    fn test_find_project_root_not_found() {
843        let temp = tempdir().unwrap();
844        let file = temp.path().join("orphan.rs");
845        fs::write(&file, "").unwrap();
846
847        let root = find_project_root(&file, &["Cargo.toml"]);
848        assert_eq!(root, None);
849    }
850
851    #[test]
852    fn test_registry_default_servers() {
853        let registry = LspRegistry::new();
854
855        assert!(registry.get("rust-analyzer").is_some());
856        assert!(registry.get("typescript").is_some());
857        assert!(registry.get("pyright").is_some());
858        assert!(registry.get("gopls").is_some());
859    }
860
861    #[test]
862    fn test_servers_for_extension() {
863        let registry = LspRegistry::new();
864
865        let rust_servers = registry.servers_for_extension(".rs");
866        assert_eq!(rust_servers.len(), 1);
867        assert_eq!(rust_servers[0].id, "rust-analyzer");
868
869        let ts_servers = registry.servers_for_extension(".ts");
870        assert_eq!(ts_servers.len(), 1);
871        assert_eq!(ts_servers[0].id, "typescript");
872    }
873}