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}