Skip to main content

lattice_lsp/
config.rs

1//! Per-language-server configuration.
2//!
3//! The editor maintains a registry of [`ServerConfig`] entries
4//! keyed by language identifier. When a buffer of that language
5//! is opened, the supervisor checks for an existing actor at the
6//! resolved workspace root; if absent, it constructs one from the
7//! config.
8//!
9//! v1 ships hardcoded defaults for rust-analyzer, pyright, gopls,
10//! tsserver, clangd, and lua-language-server; users override via
11//! a future `lsp.toml` (queued behind ยง5.12). For now,
12//! [`builtin_servers`] returns the curated list and the editor
13//! reads from it directly.
14
15use std::collections::HashMap;
16use std::ffi::OsString;
17use std::path::PathBuf;
18
19use serde_json::Value;
20
21/// Configuration for one language server. Captured at registry
22/// time; immutable after spawn.
23#[derive(Debug, Clone)]
24pub struct ServerConfig {
25    /// Stable identifier used in logs / telemetry / reference
26    /// counting. Convention: language id (e.g. `"rust"`,
27    /// `"python"`); a workspace can host multiple actors with
28    /// the same id if they target different roots.
29    pub id: String,
30    /// Path to the server binary. Relative paths resolve via
31    /// `PATH`. Tilde expansion and env-var substitution are NOT
32    /// done here -- the registry layer applies them when
33    /// loading from `lsp.toml` so the runtime sees a literal
34    /// path.
35    pub binary: PathBuf,
36    /// Arguments passed verbatim. rust-analyzer takes none;
37    /// pyright wants `--stdio`; gopls is configured via init
38    /// options.
39    pub args: Vec<OsString>,
40    /// Extra environment variables for the spawned process.
41    /// Useful for `RUST_LOG=info` or `PYTHONPATH=...`.
42    pub env: HashMap<String, String>,
43    /// Workspace-root markers. The supervisor walks up from the
44    /// opened buffer's path looking for any of these; the first
45    /// hit becomes the server's `rootUri`. Empty list falls back
46    /// to the buffer's directory.
47    pub root_markers: Vec<String>,
48    /// Server-specific initialization options. Sent verbatim as
49    /// `initialize.params.initializationOptions`. rust-analyzer
50    /// uses this to toggle `cargo check` on save, pyright for
51    /// venv discovery, etc.
52    pub initialization_options: Option<Value>,
53    /// File-pattern globs this server handles. The supervisor
54    /// matches a buffer's path against these to decide which
55    /// server (if any) to attach. Multiple patterns are OR'd.
56    /// Example: `["*.rs", "*.toml"]` for rust-analyzer to also
57    /// see Cargo manifests.
58    pub file_patterns: Vec<String>,
59    /// LSP language identifier sent in `didOpen.textDocument.languageId`.
60    /// Distinct from `id` because some servers (denols, vtsls)
61    /// handle multiple language ids; the registry can have one
62    /// `ServerConfig` per pattern with distinct `language_id`
63    /// values and the same `id` (so the actor is shared).
64    pub language_id: String,
65}
66
67impl ServerConfig {
68    /// Build a minimal config -- intended for tests and as a
69    /// starting point for the curated registry below.
70    pub fn new(
71        id: impl Into<String>,
72        binary: impl Into<PathBuf>,
73        language_id: impl Into<String>,
74    ) -> Self {
75        Self {
76            id: id.into(),
77            binary: binary.into(),
78            args: Vec::new(),
79            env: HashMap::new(),
80            root_markers: Vec::new(),
81            initialization_options: None,
82            file_patterns: Vec::new(),
83            language_id: language_id.into(),
84        }
85    }
86
87    /// Builder helper -- one-line `.with_args(["--stdio"])`.
88    pub fn with_args<I, S>(mut self, args: I) -> Self
89    where
90        I: IntoIterator<Item = S>,
91        S: Into<OsString>,
92    {
93        self.args = args.into_iter().map(Into::into).collect();
94        self
95    }
96
97    /// Builder helper for env vars.
98    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
99        self.env.insert(key.into(), value.into());
100        self
101    }
102
103    /// Builder helper for root markers (e.g. `["Cargo.toml", ".git"]`).
104    pub fn with_root_markers<I, S>(mut self, markers: I) -> Self
105    where
106        I: IntoIterator<Item = S>,
107        S: Into<String>,
108    {
109        self.root_markers = markers.into_iter().map(Into::into).collect();
110        self
111    }
112
113    /// Builder helper for file-pattern globs.
114    pub fn with_file_patterns<I, S>(mut self, patterns: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: Into<String>,
118    {
119        self.file_patterns = patterns.into_iter().map(Into::into).collect();
120        self
121    }
122
123    /// Builder helper for `initializationOptions`.
124    pub fn with_initialization_options(mut self, opts: Value) -> Self {
125        self.initialization_options = Some(opts);
126        self
127    }
128}
129
130/// Curated registry of well-known language servers. The editor
131/// loads these as defaults; user overrides (from `lsp.toml` in a
132/// future iteration) merge on top.
133///
134/// Each entry uses the canonical binary name -- on PATH for any
135/// developer who installed the server through their language's
136/// usual channel. Servers that need extra args (pyright wants
137/// `--stdio`) get them here.
138pub fn builtin_servers() -> Vec<ServerConfig> {
139    vec![
140        // rust-analyzer is the canonical Rust LSP. Speaks the
141        // full LSP 3.17 spec; honours initializationOptions for
142        // check-on-save toggles. Workspace root is the Cargo
143        // workspace top (Cargo.toml with [workspace] preferred,
144        // any Cargo.toml as fallback).
145        ServerConfig::new("rust", "rust-analyzer", "rust")
146            .with_root_markers(["Cargo.toml", "rust-project.json", ".git"])
147            .with_file_patterns(["*.rs"]),
148        // pyright: Microsoft's Python type checker; --stdio is
149        // mandatory for LSP mode (default is HTTP).
150        ServerConfig::new("python", "pyright-langserver", "python")
151            .with_args(["--stdio"])
152            .with_root_markers([
153                "pyproject.toml",
154                "setup.py",
155                "setup.cfg",
156                "requirements.txt",
157                ".git",
158            ])
159            .with_file_patterns(["*.py", "*.pyi"]),
160        // gopls: official Go server. No args; configured via
161        // initializationOptions (we leave it on defaults).
162        ServerConfig::new("go", "gopls", "go")
163            .with_root_markers(["go.mod", "go.work", ".git"])
164            .with_file_patterns(["*.go"]),
165        // typescript-language-server: the community npm-installed
166        // server. `--stdio` for the LSP-over-stdio mode.
167        ServerConfig::new("typescript", "typescript-language-server", "typescript")
168            .with_args(["--stdio"])
169            .with_root_markers(["tsconfig.json", "jsconfig.json", "package.json", ".git"])
170            .with_file_patterns(["*.ts", "*.tsx", "*.js", "*.jsx", "*.mts", "*.cts"]),
171        // clangd: the upstream LLVM C/C++ server. Default args
172        // are fine; serious users supply `compile_commands.json`
173        // at the root.
174        ServerConfig::new("c-cpp", "clangd", "cpp")
175            .with_root_markers([
176                "compile_commands.json",
177                "compile_flags.txt",
178                "CMakeLists.txt",
179                ".git",
180            ])
181            .with_file_patterns(["*.c", "*.h", "*.cc", "*.cpp", "*.cxx", "*.hpp", "*.hh"]),
182        // lua-language-server: sumneko's Lua server.
183        ServerConfig::new("lua", "lua-language-server", "lua")
184            .with_root_markers([".luarc.json", ".luarc.jsonc", "stylua.toml", ".git"])
185            .with_file_patterns(["*.lua"]),
186    ]
187}
188
189/// Walk up from `start_dir` looking for any of `markers`. Returns
190/// the resolved workspace root, applying language-specific
191/// "outermost wins" semantics where appropriate. Falls back to
192/// `start_dir` when no marker matches -- the LSP spec allows
193/// passing the buffer's directory as a degenerate workspace.
194///
195/// **Cargo-workspace awareness.** Rust's Cargo allows nested
196/// `Cargo.toml` files (member crate's `[package]`-only Cargo.toml
197/// inside the workspace's `[workspace]` Cargo.toml). rust-analyzer
198/// must be anchored at the *workspace* root for cross-crate
199/// goto-definition + external-dependency indexing to work. This
200/// resolver therefore walks the entire ancestor chain when
201/// `Cargo.toml` is among the markers and prefers the *outermost*
202/// `Cargo.toml` that declares `[workspace]`. For all other markers
203/// (and for standalone-crate Rust projects with no enclosing
204/// workspace) the *nearest* match still wins -- the historical
205/// behaviour and what every other language wants.
206pub fn resolve_workspace_root(start_dir: &std::path::Path, markers: &[String]) -> PathBuf {
207    let mut nearest_marker_dir: Option<PathBuf> = None;
208    let mut outermost_workspace_dir: Option<PathBuf> = None;
209    let cargo_marker_present = markers.iter().any(|m| m == "Cargo.toml");
210
211    // Absolutize `start_dir` before walking up. With a relative
212    // `start_dir` (e.g. `crates/foo/src/`) `Path::parent()` stops
213    // at an empty component long before reaching any workspace
214    // ancestor that lives above the cwd -- the resolver would
215    // then either fall back to the buffer's own directory or
216    // (worse, post-fix) return the nearest Cargo.toml without
217    // the outer `[workspace]` ever being seen. Canonicalize when
218    // the path exists; absolutize via `current_dir().join` when
219    // it doesn't (e.g. `:e new-file.txt` against an unsaved
220    // path).
221    let absolute_start: PathBuf = std::fs::canonicalize(start_dir)
222        .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(start_dir)))
223        .unwrap_or_else(|_| start_dir.to_path_buf());
224
225    let mut cursor = Some(absolute_start.as_path());
226    while let Some(dir) = cursor {
227        for marker in markers {
228            let marker_path = dir.join(marker);
229            if marker_path.exists() {
230                if nearest_marker_dir.is_none() {
231                    nearest_marker_dir = Some(dir.to_path_buf());
232                }
233                // Cargo workspace upgrade path: peek inside any
234                // Cargo.toml we encounter; the outermost one with
235                // `[workspace]` is the true root. Cheap line scan
236                // -- no TOML parser dep needed.
237                if cargo_marker_present
238                    && marker == "Cargo.toml"
239                    && cargo_toml_declares_workspace(&marker_path)
240                {
241                    outermost_workspace_dir = Some(dir.to_path_buf());
242                }
243            }
244        }
245        cursor = dir.parent();
246    }
247    outermost_workspace_dir
248        .or(nearest_marker_dir)
249        .unwrap_or(absolute_start)
250}
251
252/// Does the given `Cargo.toml` declare a `[workspace]` section?
253/// Returns false on any read / parse failure -- the caller treats
254/// the file as a non-workspace `Cargo.toml` and the resolver falls
255/// back to the nearest-marker path.
256///
257/// We avoid pulling in a TOML parser dep for this single check;
258/// a line-by-line scan for `[workspace]` (or `[workspace.something]`)
259/// is precise enough. False positives require a `[workspace]`
260/// substring at the start of a non-comment line that *also*
261/// satisfies TOML's section-header grammar -- vanishingly rare in
262/// real Cargo manifests.
263fn cargo_toml_declares_workspace(path: &std::path::Path) -> bool {
264    let Ok(content) = std::fs::read_to_string(path) else {
265        return false;
266    };
267    content.lines().any(|line| {
268        let trimmed = line.trim();
269        trimmed == "[workspace]" || trimmed.starts_with("[workspace.")
270    })
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::fs;
277    use tempfile_lite as tempdir; // avoid extra dep; see helper
278
279    /// Tiny in-test temp dir helper -- avoids pulling in `tempfile`.
280    /// Creates `target/lsp-tests/<name>` and returns the path; the
281    /// caller deletes it. We don't need atomicity here; tests are
282    /// serialised within a single Cargo invocation.
283    mod tempfile_lite {
284        use std::path::PathBuf;
285        pub fn new_dir(name: &str) -> PathBuf {
286            let mut p = std::env::temp_dir();
287            p.push(format!("lattice-lsp-test-{}-{}", name, std::process::id()));
288            std::fs::create_dir_all(&p).unwrap();
289            // Canonicalize the created dir so equality checks against the
290            // resolver's output are stable on hosts that put the temp dir
291            // behind a symlink (macOS: `/var` -> `/private/var`).
292            // `resolve_workspace_root` canonicalizes `start_dir`, so its
293            // result is already the canonical form; the expected value must
294            // match.
295            std::fs::canonicalize(&p).unwrap()
296        }
297    }
298
299    #[test]
300    fn builtin_servers_have_distinct_ids() {
301        let servers = builtin_servers();
302        let mut ids: Vec<&str> = servers.iter().map(|c| c.id.as_str()).collect();
303        ids.sort();
304        let n_before = ids.len();
305        ids.dedup();
306        assert_eq!(ids.len(), n_before, "duplicate server id in builtin set");
307    }
308
309    #[test]
310    fn builtin_servers_have_file_patterns() {
311        for cfg in builtin_servers() {
312            assert!(
313                !cfg.file_patterns.is_empty(),
314                "{} has no file patterns; supervisor cannot match it to a buffer",
315                cfg.id
316            );
317        }
318    }
319
320    #[test]
321    fn builtin_servers_have_root_markers() {
322        // Every server config must list at least one root marker
323        // beyond `.git`; otherwise we'd anchor every workspace at
324        // the user's home dir.
325        for cfg in builtin_servers() {
326            let non_git: Vec<&String> = cfg
327                .root_markers
328                .iter()
329                .filter(|m| m.as_str() != ".git")
330                .collect();
331            assert!(
332                !non_git.is_empty(),
333                "{} only has .git as a root marker -- needs a language-specific anchor",
334                cfg.id
335            );
336        }
337    }
338
339    #[test]
340    fn resolve_workspace_root_finds_marker_in_parent() {
341        let dir = tempdir::new_dir("ws-root-parent");
342        let inner = dir.join("a/b/c");
343        fs::create_dir_all(&inner).unwrap();
344        fs::write(dir.join("Cargo.toml"), "[package]\nname=\"x\"").unwrap();
345
346        let resolved = resolve_workspace_root(&inner, &["Cargo.toml".into()]);
347        assert_eq!(resolved, dir);
348
349        fs::remove_dir_all(&dir).ok();
350    }
351
352    #[test]
353    fn resolve_workspace_root_falls_back_to_start_dir() {
354        let dir = tempdir::new_dir("ws-root-no-marker");
355        fs::create_dir_all(&dir).unwrap();
356        let resolved = resolve_workspace_root(&dir, &["never-exists.toml".into()]);
357        assert_eq!(resolved, dir);
358        fs::remove_dir_all(&dir).ok();
359    }
360
361    #[test]
362    fn resolve_workspace_root_prefers_outermost_cargo_workspace() {
363        // Layout:
364        //   tmp/Cargo.toml         -- [workspace] members = [...]
365        //   tmp/crates/foo/Cargo.toml  -- [package]
366        //   tmp/crates/foo/src/lib.rs
367        // Walking up from `src/`, we should land on `tmp/`, not
368        // `tmp/crates/foo/`. This is the bug that broke cross-
369        // crate goto-definition + external-dep hover for nested
370        // member crates.
371        let dir = tempdir::new_dir("ws-root-cargo-outer");
372        let inner = dir.join("crates/foo/src");
373        fs::create_dir_all(&inner).unwrap();
374        fs::write(
375            dir.join("Cargo.toml"),
376            "[workspace]\nmembers = [\"crates/*\"]\n",
377        )
378        .unwrap();
379        fs::write(
380            dir.join("crates/foo/Cargo.toml"),
381            "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
382        )
383        .unwrap();
384
385        let resolved = resolve_workspace_root(&inner, &["Cargo.toml".into()]);
386        assert_eq!(
387            resolved, dir,
388            "outermost Cargo.toml with [workspace] should win"
389        );
390        fs::remove_dir_all(&dir).ok();
391    }
392
393    #[test]
394    fn resolve_workspace_root_standalone_crate_uses_nearest() {
395        // No enclosing workspace: nearest Cargo.toml wins (the
396        // crate's own).
397        let dir = tempdir::new_dir("ws-root-standalone");
398        let inner = dir.join("src");
399        fs::create_dir_all(&inner).unwrap();
400        fs::write(
401            dir.join("Cargo.toml"),
402            "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n",
403        )
404        .unwrap();
405        let resolved = resolve_workspace_root(&inner, &["Cargo.toml".into()]);
406        assert_eq!(resolved, dir);
407        fs::remove_dir_all(&dir).ok();
408    }
409
410    #[test]
411    fn resolve_workspace_root_two_unrelated_cargo_dirs_keeps_nearest() {
412        // Two nested Cargo.toml files, neither declares
413        // [workspace]. This is unusual (broken-by-cargo) but the
414        // fallback must still pick the nearest -- the inner crate
415        // is the right anchor for tooling.
416        let dir = tempdir::new_dir("ws-root-unrelated-nested");
417        let inner_crate = dir.join("inner");
418        let inner_src = inner_crate.join("src");
419        fs::create_dir_all(&inner_src).unwrap();
420        fs::write(
421            dir.join("Cargo.toml"),
422            "[package]\nname = \"outer\"\nversion = \"0.1.0\"\n",
423        )
424        .unwrap();
425        fs::write(
426            inner_crate.join("Cargo.toml"),
427            "[package]\nname = \"inner\"\nversion = \"0.1.0\"\n",
428        )
429        .unwrap();
430        let resolved = resolve_workspace_root(&inner_src, &["Cargo.toml".into()]);
431        assert_eq!(
432            resolved, inner_crate,
433            "nearest Cargo.toml should win when no workspace is declared"
434        );
435        fs::remove_dir_all(&dir).ok();
436    }
437
438    #[test]
439    fn resolve_workspace_root_workspace_table_with_subkey_detected() {
440        // `[workspace.dependencies]` is a sub-table; the parser
441        // should still recognise it as workspace-bearing.
442        let dir = tempdir::new_dir("ws-root-workspace-subkey");
443        let inner = dir.join("crates/foo/src");
444        fs::create_dir_all(&inner).unwrap();
445        fs::write(
446            dir.join("Cargo.toml"),
447            "[workspace.dependencies]\nserde = \"1\"\n",
448        )
449        .unwrap();
450        fs::write(
451            dir.join("crates/foo/Cargo.toml"),
452            "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
453        )
454        .unwrap();
455        let resolved = resolve_workspace_root(&inner, &["Cargo.toml".into()]);
456        assert_eq!(resolved, dir);
457        fs::remove_dir_all(&dir).ok();
458    }
459
460    #[test]
461    fn resolve_workspace_root_resolves_relative_start_dir() {
462        // Regression for the post-resolver-fix break: when
463        // `Document::open` keeps the path relative (e.g.
464        // `lattice src/lib.rs` from inside a member crate), the
465        // resolver's start_dir is relative. `Path::parent()`
466        // stops at an empty component long before the outer
467        // workspace's Cargo.toml is reached, so a member crate
468        // anchored at the inner `crates/foo/Cargo.toml` would
469        // (post-fix) have no Cargo.toml found anywhere, and
470        // `:lsp-status` reported "no servers attached". Fix:
471        // absolutize start_dir via canonicalize / cwd.join
472        // before walking.
473        let dir = tempdir::new_dir("ws-root-relative-start");
474        let inner = dir.join("crates/foo/src");
475        fs::create_dir_all(&inner).unwrap();
476        fs::write(
477            dir.join("Cargo.toml"),
478            "[workspace]\nmembers = [\"crates/*\"]\n",
479        )
480        .unwrap();
481        fs::write(
482            dir.join("crates/foo/Cargo.toml"),
483            "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
484        )
485        .unwrap();
486
487        // cd into the workspace root so a relative start_dir
488        // matches the path-shape the App passes through.
489        let saved_cwd = std::env::current_dir().unwrap();
490        std::env::set_current_dir(&dir).unwrap();
491        let relative = std::path::Path::new("crates/foo/src");
492        let resolved = resolve_workspace_root(relative, &["Cargo.toml".into()]);
493        std::env::set_current_dir(saved_cwd).unwrap();
494
495        // canonicalize() may resolve symlinks; compare against
496        // the canonical workspace dir for stability across hosts
497        // that put `/tmp` behind a symlink (macOS does).
498        let expected = std::fs::canonicalize(&dir).unwrap();
499        assert_eq!(
500            resolved, expected,
501            "relative start_dir must still find the outer workspace"
502        );
503
504        fs::remove_dir_all(&dir).ok();
505    }
506
507    #[test]
508    fn resolve_workspace_root_non_cargo_marker_keeps_nearest() {
509        // Markers other than Cargo.toml don't get the
510        // outermost-wins semantics -- a `package.json` deep in a
511        // monorepo should anchor at the deepest match (the
512        // sub-package), not at the monorepo root.
513        let dir = tempdir::new_dir("ws-root-non-cargo");
514        let inner = dir.join("apps/web/src");
515        fs::create_dir_all(&inner).unwrap();
516        fs::write(dir.join("package.json"), "{}").unwrap();
517        fs::write(dir.join("apps/web/package.json"), "{}").unwrap();
518        let resolved = resolve_workspace_root(&inner, &["package.json".into()]);
519        assert_eq!(
520            resolved,
521            dir.join("apps/web"),
522            "nearest non-Cargo marker wins"
523        );
524        fs::remove_dir_all(&dir).ok();
525    }
526
527    #[test]
528    fn builder_chains() {
529        let cfg = ServerConfig::new("rust", "rust-analyzer", "rust")
530            .with_args(["--no-cache"])
531            .with_env("RUST_LOG", "info")
532            .with_root_markers(["Cargo.toml"])
533            .with_file_patterns(["*.rs"]);
534        assert_eq!(cfg.id, "rust");
535        assert_eq!(cfg.args, vec![OsString::from("--no-cache")]);
536        assert_eq!(cfg.env.get("RUST_LOG").unwrap(), "info");
537    }
538}