Skip to main content

mcpls_core/config/
server.rs

1//! LSP server configuration types.
2
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::ffi::OsStr;
6use std::ops::ControlFlow;
7use std::path::{Path, PathBuf};
8
9use ignore::WalkBuilder;
10use serde::{Deserialize, Serialize};
11
12use super::bounded_secs::TimeoutSecs;
13use super::command_stem::CommandStem;
14use super::host_os::HostOs;
15use super::language_id::LanguageId;
16#[cfg(test)]
17use super::limits::DEFAULT_HEURISTICS_MAX_DEPTH;
18use super::limits::SearchDepth;
19use super::patterns::{FilePattern, ProjectMarker};
20use super::routing::{ServerId, ToolSet};
21use super::server_env::{EnvKey, ServerEnv};
22use super::settings::LspSettings;
23use super::text_newtype::impl_text_newtype;
24use crate::bridge::IndexingPolicy;
25use crate::error::ConfigError;
26
27/// Directories excluded from recursive marker search.
28/// These are well-known directories that should never contain project markers.
29const EXCLUDED_DIRECTORIES: &[&str] = &[
30    "node_modules",
31    "target",
32    ".git",
33    "__pycache__",
34    ".venv",
35    "venv",
36    ".tox",
37    ".mypy_cache",
38    ".pytest_cache",
39    "build",
40    "dist",
41    ".cargo",
42    ".rustup",
43    "vendor",
44    "coverage",
45    ".next",
46    ".nuxt",
47];
48
49/// Heuristics for determining if an LSP server should be spawned.
50///
51/// Used to prevent spawning servers in projects where they are not applicable
52/// (e.g., rust-analyzer in a Python-only project).
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(deny_unknown_fields)]
55pub struct ServerHeuristics {
56    /// Files or directories that indicate this server is applicable.
57    ///
58    /// The server will spawn if ANY of these markers exist anywhere in the workspace tree
59    /// (searched recursively up to `heuristics_max_depth`). Well-known directories like
60    /// `node_modules`, `target`, `.git` are excluded from the search.
61    ///
62    /// If empty, the server will always attempt to spawn.
63    #[serde(default)]
64    pub project_markers: Vec<ProjectMarker>,
65}
66
67impl ServerHeuristics {
68    /// Create heuristics with the given project markers.
69    #[must_use]
70    pub fn with_markers(markers: impl IntoIterator<Item = ProjectMarker>) -> Self {
71        Self {
72            project_markers: markers.into_iter().collect(),
73        }
74    }
75}
76
77/// Calls `visit` with the name of every entry under `workspace_root`, within
78/// `max_depth`, skipping well-known generated directories and gitignored ones,
79/// until it breaks.
80fn walk_names(
81    workspace_root: &Path,
82    max_depth: SearchDepth,
83    mut visit: impl FnMut(&str) -> ControlFlow<()>,
84) -> ControlFlow<()> {
85    let mut builder = WalkBuilder::new(workspace_root);
86    // `standard_filters(false)` (bulk setter, last-write-wins) must run first or it
87    // undoes the overrides below; `.git_ignore(true)` itself is then a no-op outside
88    // an actual git repo (`require_git` defaults true).
89    builder
90        .standard_filters(false)
91        .max_depth(Some(max_depth.get()))
92        .hidden(false)
93        .git_ignore(true)
94        .git_global(false)
95        .git_exclude(false)
96        .follow_links(false)
97        .filter_entry(|entry| {
98            // Skip excluded directories entirely (prevents descending into them)
99            if entry.file_type().is_some_and(|ft| ft.is_dir())
100                && let Some(name) = entry.file_name().to_str()
101                && EXCLUDED_DIRECTORIES.contains(&name)
102            {
103                return false;
104            }
105            true
106        });
107
108    for entry in builder.build().flatten() {
109        if let Some(name) = entry.path().file_name().and_then(OsStr::to_str) {
110            visit(name)?;
111        }
112    }
113    ControlFlow::Continue(())
114}
115
116/// The project markers found under the workspace roots, collected with one
117/// tree walk per root however many servers are configured.
118///
119/// A server whose markers exist nowhere would otherwise cost a full walk of
120/// its own. Blocking: run it off the async workers.
121#[derive(Debug, Clone, Default)]
122pub struct MarkerScan {
123    found: Vec<ProjectMarker>,
124}
125
126impl MarkerScan {
127    /// Looks for every marker any of `servers` asks for under `roots`: the
128    /// roots themselves first, then one walk per root while a marker is still
129    /// missing, stopping as soon as all are found.
130    #[must_use]
131    pub fn collect<'a>(
132        roots: &[PathBuf],
133        servers: impl IntoIterator<Item = &'a LspServerConfig>,
134        max_depth: SearchDepth,
135    ) -> Self {
136        let mut missing: Vec<&ProjectMarker> = Vec::new();
137        for marker in servers
138            .into_iter()
139            .filter_map(|server| server.heuristics.as_ref())
140            .flat_map(|heuristics| &heuristics.project_markers)
141        {
142            if !missing.contains(&marker) {
143                missing.push(marker);
144            }
145        }
146        let mut found = Vec::new();
147        for root in roots {
148            if missing.is_empty() {
149                break;
150            }
151            missing.retain(|marker| {
152                let present = root.join(marker.as_str()).exists();
153                if present {
154                    found.push((*marker).clone());
155                }
156                !present
157            });
158            if missing.is_empty() {
159                break;
160            }
161            let all_found = walk_names(root, max_depth, |name| {
162                if let Some(index) = missing.iter().position(|marker| *marker == name) {
163                    found.push(missing.swap_remove(index).clone());
164                }
165                if missing.is_empty() {
166                    ControlFlow::Break(())
167                } else {
168                    ControlFlow::Continue(())
169                }
170            })
171            .is_break();
172            if all_found {
173                break;
174            }
175        }
176        Self { found }
177    }
178
179    /// Whether `server` applies to the scanned workspace: it has no markers,
180    /// or at least one was found.
181    #[must_use]
182    pub fn applies_to(&self, server: &LspServerConfig) -> bool {
183        server.heuristics.as_ref().is_none_or(|heuristics| {
184            heuristics.project_markers.is_empty()
185                || heuristics
186                    .project_markers
187                    .iter()
188                    .any(|marker| self.found.contains(marker))
189        })
190    }
191}
192
193/// Whether mcpls may replace a server entry's `command` and `args` at startup.
194///
195/// Records provenance and consent, not the chosen server: only the generated
196/// default TypeScript entry carries [`Self::Auto`], so a hand-written entry
197/// is never rewritten.
198///
199/// # Examples
200///
201/// ```
202/// use mcpls_core::config::ServerSelection;
203///
204/// assert_eq!(ServerSelection::default(), ServerSelection::Explicit);
205/// assert_eq!(serde_json::to_string(&ServerSelection::Auto).unwrap(), "\"auto\"");
206/// ```
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ServerSelection {
210    /// Start exactly the configured `command` and `args`.
211    #[default]
212    Explicit,
213    /// Start the native TypeScript server (`tsc --lsp --stdio`) instead of
214    /// the configured `typescript-language-server` when a TypeScript 7
215    /// install outside every workspace root is found. Valid only on
216    /// `typescript-language-server` entries.
217    Auto,
218}
219
220impl ServerSelection {
221    /// Whether this is the default [`Self::Explicit`] selection, so the
222    /// serializer omits the key from entries that carry it.
223    #[expect(
224        clippy::trivially_copy_pass_by_ref,
225        reason = "serde skip_serializing_if passes a reference"
226    )]
227    #[must_use]
228    pub(crate) const fn is_explicit(&self) -> bool {
229        matches!(self, Self::Explicit)
230    }
231}
232
233/// A server command was empty or whitespace-only.
234#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
235#[error("command cannot be blank")]
236pub struct InvalidServerCommand;
237
238/// The non-blank executable name or path of an LSP server.
239///
240/// Deserializes from a TOML string and rejects a blank one at load time.
241///
242/// # Examples
243///
244/// ```
245/// use mcpls_core::config::ServerCommand;
246///
247/// let command = ServerCommand::new("rust-analyzer").unwrap();
248/// assert_eq!(command, "rust-analyzer");
249/// assert!(ServerCommand::new("  ").is_err());
250/// ```
251#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
252#[serde(try_from = "String", into = "String")]
253pub struct ServerCommand(Cow<'static, str>);
254
255impl_text_newtype!(ServerCommand, InvalidServerCommand, non_blank, "command");
256
257impl AsRef<OsStr> for ServerCommand {
258    fn as_ref(&self) -> &OsStr {
259        OsStr::new(self.as_str())
260    }
261}
262
263impl AsRef<Path> for ServerCommand {
264    fn as_ref(&self) -> &Path {
265        Path::new(self.as_str())
266    }
267}
268
269/// `selection = "auto"` on a command that is not typescript-language-server.
270#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
271#[error("selection = \"auto\" is only valid for typescript-language-server entries")]
272pub struct InvalidAutoSelection;
273
274/// The command of a server entry together with the consent to replace it.
275///
276/// Binds [`ServerCommand`] to [`ServerSelection`] so that [`ServerSelection::Auto`]
277/// can only accompany a typescript-language-server command; the combination
278/// `rust-analyzer` plus `auto` cannot be built. The fields are private, so a
279/// command cannot be swapped without re-checking the selection: use
280/// [`Self::retarget`].
281///
282/// Reads like the [`ServerCommand`] it wraps (`as_str`, `Display`, `AsRef`,
283/// comparison with `&str`).
284///
285/// # Examples
286///
287/// ```
288/// use mcpls_core::config::{LaunchCommand, ServerCommand, ServerSelection};
289///
290/// let tsls = ServerCommand::new("typescript-language-server").unwrap();
291/// let auto = LaunchCommand::auto(tsls).unwrap();
292/// assert_eq!(auto.selection(), ServerSelection::Auto);
293///
294/// let rust = ServerCommand::new("rust-analyzer").unwrap();
295/// assert!(LaunchCommand::auto(rust.clone()).is_err());
296/// assert!(LaunchCommand::explicit(rust).is_explicit());
297/// ```
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct LaunchCommand {
300    command: ServerCommand,
301    selection: ServerSelection,
302}
303
304impl LaunchCommand {
305    /// `command` started exactly as written.
306    #[must_use]
307    pub const fn explicit(command: ServerCommand) -> Self {
308        Self {
309            command,
310            selection: ServerSelection::Explicit,
311        }
312    }
313
314    /// `command` that mcpls may replace with the native TypeScript server.
315    ///
316    /// # Errors
317    ///
318    /// [`InvalidAutoSelection`] unless `command` is typescript-language-server.
319    pub fn auto(command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
320        Self::new(command, ServerSelection::Auto)
321    }
322
323    /// `command` with `selection`.
324    ///
325    /// # Errors
326    ///
327    /// [`InvalidAutoSelection`] for [`ServerSelection::Auto`] on a command that
328    /// is not typescript-language-server.
329    pub fn new(
330        command: ServerCommand,
331        selection: ServerSelection,
332    ) -> Result<Self, InvalidAutoSelection> {
333        if selection == ServerSelection::Auto
334            && !BuiltinServer::TypescriptLanguageServer.matches_command(command.as_str())
335        {
336            return Err(InvalidAutoSelection);
337        }
338        Ok(Self { command, selection })
339    }
340
341    /// The default entry's command: typescript-language-server, auto-selected.
342    const fn typescript() -> Self {
343        Self {
344            command: ServerCommand::from_static(BuiltinServer::TypescriptLanguageServer.command()),
345            selection: ServerSelection::Auto,
346        }
347    }
348
349    /// This launch with `command` in place of the current one, keeping the
350    /// selection.
351    ///
352    /// # Errors
353    ///
354    /// [`InvalidAutoSelection`] when the selection is auto and `command` is not
355    /// typescript-language-server.
356    pub fn retarget(self, command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
357        Self::new(command, self.selection)
358    }
359
360    /// The command as configured.
361    #[must_use]
362    pub const fn server_command(&self) -> &ServerCommand {
363        &self.command
364    }
365
366    /// Whether mcpls may replace the command.
367    #[must_use]
368    pub const fn selection(&self) -> ServerSelection {
369        self.selection
370    }
371
372    /// Whether the command is started exactly as written.
373    #[must_use]
374    pub const fn is_explicit(&self) -> bool {
375        self.selection.is_explicit()
376    }
377
378    /// The command text.
379    #[must_use]
380    pub fn as_str(&self) -> &str {
381        self.command.as_str()
382    }
383
384    fn into_parts(self) -> (ServerCommand, ServerSelection) {
385        (self.command, self.selection)
386    }
387}
388
389impl From<ServerCommand> for LaunchCommand {
390    fn from(command: ServerCommand) -> Self {
391        Self::explicit(command)
392    }
393}
394
395impl std::fmt::Display for LaunchCommand {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        std::fmt::Display::fmt(&self.command, f)
398    }
399}
400
401impl AsRef<OsStr> for LaunchCommand {
402    fn as_ref(&self) -> &OsStr {
403        self.command.as_ref()
404    }
405}
406
407impl AsRef<Path> for LaunchCommand {
408    fn as_ref(&self) -> &Path {
409        self.command.as_ref()
410    }
411}
412
413impl PartialEq<str> for LaunchCommand {
414    fn eq(&self, other: &str) -> bool {
415        self.command == *other
416    }
417}
418
419impl PartialEq<&str> for LaunchCommand {
420    fn eq(&self, other: &&str) -> bool {
421        self.command == *other
422    }
423}
424
425/// Configuration for a single LSP server.
426///
427/// Serialization goes through a raw mirror of this struct, which reports an
428/// invalid `file_patterns` entry with the id of the server that holds it and
429/// builds the [`LaunchCommand`] from the `command` and `selection` keys.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(try_from = "RawLspServerConfig", into = "RawLspServerConfig")]
432pub struct LspServerConfig {
433    /// Language identifier (e.g., "rust", "python", "typescript").
434    pub language_id: LanguageId,
435
436    /// Command to start the LSP server, with whether mcpls may replace it.
437    pub command: LaunchCommand,
438
439    /// Arguments to pass to the LSP server command.
440    pub args: Vec<String>,
441
442    /// Environment variables for the LSP server process.
443    pub env: ServerEnv,
444
445    /// File patterns this server handles (glob patterns).
446    pub file_patterns: Vec<FilePattern>,
447
448    /// LSP initialization options (server-specific).
449    ///
450    /// For typescript-language-server, `None` lets mcpls pin the tsserver
451    /// bundled next to the server (`tsserver.path`) so a workspace-supplied
452    /// tsserver is not run. Setting `tsserver.path` here overrides the pin,
453    /// for example to opt back in to the workspace's TypeScript. Setting any
454    /// other options disables the pin: mcpls does not merge into user options.
455    /// TypeScript 7 and later ship no tsserver, so there is nothing to pin: with
456    /// only that installed the server fails to initialize, and the error says
457    /// so. A native `tsc --lsp --stdio` command gets no pin and no generated
458    /// options. See `SECURITY.md` for the trust model.
459    pub initialization_options: Option<serde_json::Value>,
460
461    /// Per-server settings pushed after `initialized` via
462    /// `workspace/didChangeConfiguration` and served on
463    /// `workspace/configuration`. Top-level dotted keys are expanded into
464    /// nested objects; keys inside values are left untouched.
465    pub settings: Option<LspSettings>,
466
467    /// Handshake timeout in seconds: bounds the `initialize` request during
468    /// server startup. Does not affect individual tool-call requests sent
469    /// after initialization; see [`Self::request_timeout_seconds`] for that.
470    /// The LSP server's `shutdown` request during teardown uses a separate,
471    /// fixed 5-second timeout that is not configurable by this field.
472    pub timeout_seconds: TimeoutSecs,
473
474    /// Per-request timeout in seconds, applied to each LSP request issued
475    /// while translating an MCP tool call (hover, definition, references, etc.).
476    ///
477    /// This bounds a single request attempt, not a whole tool call: on a
478    /// `-32801` (`ContentModified`) or `-32802` (`ServerCancelled`) response,
479    /// [`crate::lsp::LspClient::request`] retries up to 4 attempts with
480    /// backoff (one shared budget across both codes), so the worst-case
481    /// latency for one tool call is `4 * request_timeout_seconds + 3.5`
482    /// seconds. Completion requests are further capped at 10 seconds
483    /// regardless of this value; see
484    /// [`crate::lsp::LspClient::completion_timeout`].
485    pub request_timeout_seconds: TimeoutSecs,
486
487    /// Heuristics for determining if this server should be spawned.
488    /// If not specified, the server will always attempt to spawn.
489    pub heuristics: Option<ServerHeuristics>,
490
491    /// Human-readable server identity used as the routing key.
492    ///
493    /// Defaults to `language_id` when omitted (see [`Self::id`]). Must be
494    /// unique across all applicable servers in a workspace, regardless of
495    /// language: this is what lets two servers share one `language_id`
496    /// (e.g. pyright and pylsp both for `python`) without one silently
497    /// overwriting the other in the maps keyed by [`ServerId`].
498    pub name: Option<ServerId>,
499
500    /// Tools this server handles.
501    ///
502    /// `None` means this server is a catch-all: it serves every tool not
503    /// explicitly claimed by another server for the same language.
504    /// `Some(list)` restricts the server to exactly those tools.
505    pub handles: Option<ToolSet>,
506
507    /// Workspace-indexing readiness policy for this server (P4 escape
508    /// hatch). Defaults to [`IndexingPolicy::Auto`]: readiness is tracked
509    /// normally from whatever signals the server sends (rust-analyzer's
510    /// `experimental/serverStatus`, or a generic `$/progress` sequence).
511    /// Set to `"disabled"` for a server whose signal shape doesn't fit this
512    /// tracker's assumptions -- see [`IndexingPolicy::Disabled`].
513    pub indexing: IndexingPolicy,
514}
515
516/// The deserialized form of [`LspServerConfig`], before the entry-level checks.
517///
518/// Mirrors the public struct field for field; the `TryFrom` impl below
519/// destructures and builds both exhaustively, so a field added to one side
520/// only fails to compile.
521#[derive(Serialize, Deserialize)]
522#[serde(deny_unknown_fields)]
523struct RawLspServerConfig {
524    language_id: LanguageId,
525    command: ServerCommand,
526    #[serde(default)]
527    args: Vec<String>,
528    #[serde(default)]
529    env: BTreeMap<EnvKey, String>,
530    #[serde(default)]
531    file_patterns: Vec<String>,
532    #[serde(default)]
533    initialization_options: Option<serde_json::Value>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    settings: Option<LspSettings>,
536    #[serde(default)]
537    timeout_seconds: TimeoutSecs,
538    #[serde(default)]
539    request_timeout_seconds: TimeoutSecs,
540    #[serde(default)]
541    heuristics: Option<ServerHeuristics>,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    name: Option<ServerId>,
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    handles: Option<ToolSet>,
546    #[serde(default, skip_serializing_if = "IndexingPolicy::is_auto")]
547    indexing: IndexingPolicy,
548    #[serde(default, skip_serializing_if = "ServerSelection::is_explicit")]
549    selection: ServerSelection,
550}
551
552impl From<LspServerConfig> for RawLspServerConfig {
553    fn from(config: LspServerConfig) -> Self {
554        let LspServerConfig {
555            language_id,
556            command,
557            args,
558            env,
559            file_patterns,
560            initialization_options,
561            settings,
562            timeout_seconds,
563            request_timeout_seconds,
564            heuristics,
565            name,
566            handles,
567            indexing,
568        } = config;
569        let (command, selection) = command.into_parts();
570        Self {
571            language_id,
572            command,
573            args,
574            env: env.into(),
575            file_patterns: file_patterns.into_iter().map(String::from).collect(),
576            initialization_options,
577            settings,
578            timeout_seconds,
579            request_timeout_seconds,
580            heuristics,
581            name,
582            handles,
583            indexing,
584            selection,
585        }
586    }
587}
588
589impl TryFrom<RawLspServerConfig> for LspServerConfig {
590    type Error = ConfigError;
591
592    fn try_from(raw: RawLspServerConfig) -> Result<Self, Self::Error> {
593        let RawLspServerConfig {
594            language_id,
595            command,
596            args,
597            env,
598            file_patterns,
599            initialization_options,
600            settings,
601            timeout_seconds,
602            request_timeout_seconds,
603            heuristics,
604            name,
605            handles,
606            indexing,
607            selection,
608        } = raw;
609        let command = LaunchCommand::new(command, selection).map_err(|InvalidAutoSelection| {
610            ConfigError::SelectionAutoOnNonTypescript {
611                language: language_id.clone(),
612            }
613        })?;
614        let env = ServerEnv::from_entries(env, HostOs::CURRENT).map_err(|error| {
615            ConfigError::DuplicateEnvKey {
616                server: entry_id(name.as_ref(), &language_id),
617                error,
618            }
619        })?;
620        let file_patterns = file_patterns
621            .iter()
622            .map(|pattern| {
623                FilePattern::parse(pattern).map_err(|pattern| ConfigError::UnsupportedFilePattern {
624                    server: entry_id(name.as_ref(), &language_id),
625                    pattern,
626                })
627            })
628            .collect::<Result<_, _>>()?;
629        Ok(Self {
630            language_id,
631            command,
632            args,
633            env,
634            file_patterns,
635            initialization_options,
636            settings,
637            timeout_seconds,
638            request_timeout_seconds,
639            heuristics,
640            name,
641            handles,
642            indexing,
643        })
644    }
645}
646
647/// Language servers mcpls ships a default configuration for.
648///
649/// Single source of truth for each builtin's executable name and its install
650/// hint, so spawn-failure guidance cannot drift from the defaults.
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub enum BuiltinServer {
653    /// rust-analyzer for Rust.
654    RustAnalyzer,
655    /// pyright for Python.
656    Pyright,
657    /// typescript-language-server for TypeScript.
658    TypescriptLanguageServer,
659    /// gopls for Go.
660    Gopls,
661    /// clangd for C and C++.
662    Clangd,
663    /// zls for Zig.
664    Zls,
665}
666
667impl BuiltinServer {
668    /// Every builtin server.
669    pub const ALL: [Self; 6] = [
670        Self::RustAnalyzer,
671        Self::Pyright,
672        Self::TypescriptLanguageServer,
673        Self::Gopls,
674        Self::Clangd,
675        Self::Zls,
676    ];
677
678    /// Executable name looked up on `PATH`.
679    #[must_use]
680    pub const fn command(self) -> &'static str {
681        match self {
682            Self::RustAnalyzer => "rust-analyzer",
683            Self::Pyright => "pyright-langserver",
684            Self::TypescriptLanguageServer => "typescript-language-server",
685            Self::Gopls => "gopls",
686            Self::Clangd => "clangd",
687            Self::Zls => "zls",
688        }
689    }
690
691    /// The workspace-supplied code this server can run, for the message that
692    /// explains why untrusted mode refused it.
693    #[must_use]
694    pub const fn workspace_code(self) -> &'static str {
695        match self {
696            Self::RustAnalyzer => "Cargo build scripts and procedural macros",
697            Self::Pyright => {
698                "the project's Python environment, plugins and configured interpreters"
699            }
700            Self::TypescriptLanguageServer => {
701                "the workspace's tsserver and tsconfig plugins, unless pinned"
702            }
703            Self::Gopls => "the Go toolchain, including toolchain downloads requested by go.mod",
704            Self::Clangd => "commands from compile_commands.json and .clangd configuration",
705            Self::Zls => "build.zig through its build runner",
706        }
707    }
708
709    /// One-line instruction for installing this server.
710    #[must_use]
711    pub const fn install_hint(self) -> &'static str {
712        match self {
713            Self::RustAnalyzer => "rustup component add rust-analyzer",
714            Self::Pyright => "npm install -g pyright",
715            Self::TypescriptLanguageServer => {
716                "npm install -g typescript-language-server typescript@6"
717            }
718            Self::Gopls => "go install golang.org/x/tools/gopls@latest",
719            Self::Clangd => {
720                "install clangd via your package manager (e.g. apt install clangd, brew install llvm) or see https://clangd.llvm.org/installation"
721            }
722            Self::Zls => {
723                "install a zls matching your Zig version: https://zigtools.org/zls/install/"
724            }
725        }
726    }
727
728    /// Likely cause of this server exiting before completing the `initialize`
729    /// handshake, if one is known.
730    ///
731    /// Only rust-analyzer has one: `rustup` installs a `rust-analyzer` proxy
732    /// even when the component is not installed, and the proxy exits at once.
733    #[must_use]
734    pub const fn early_exit_hint(self) -> Option<&'static str> {
735        match self {
736            Self::RustAnalyzer => Some(
737                "the `rust-analyzer` found on PATH may be a rustup proxy without the component \
738                 installed -- run `rustup component add rust-analyzer`",
739            ),
740            Self::Pyright
741            | Self::TypescriptLanguageServer
742            | Self::Gopls
743            | Self::Clangd
744            | Self::Zls => None,
745        }
746    }
747
748    /// Whether this server is distributed through npm, and so is installed
749    /// as a `.cmd` shim on Windows.
750    #[must_use]
751    pub const fn is_npm_package(self) -> bool {
752        matches!(self, Self::Pyright | Self::TypescriptLanguageServer)
753    }
754
755    /// Whether `command` launches this builtin: its file stem is this
756    /// server's executable name, so an absolute path or a Windows `.cmd` shim
757    /// matches. Case-insensitive on Windows.
758    ///
759    /// # Examples
760    ///
761    /// ```
762    /// use mcpls_core::config::BuiltinServer;
763    ///
764    /// let tsls = BuiltinServer::TypescriptLanguageServer;
765    /// assert!(tsls.matches_command("typescript-language-server"));
766    /// assert!(tsls.matches_command("/usr/local/bin/typescript-language-server"));
767    /// assert!(tsls.matches_command("typescript-language-server.cmd"));
768    /// assert!(!tsls.matches_command("tsc"));
769    /// ```
770    #[must_use]
771    pub fn matches_command(self, command: &str) -> bool {
772        CommandStem::of(command).is(self.command())
773    }
774
775    /// Look up a builtin by its exact executable name.
776    ///
777    /// # Examples
778    ///
779    /// ```
780    /// use mcpls_core::config::BuiltinServer;
781    ///
782    /// assert_eq!(BuiltinServer::from_command("gopls"), Some(BuiltinServer::Gopls));
783    /// assert_eq!(BuiltinServer::from_command("/usr/bin/gopls"), None);
784    /// ```
785    #[must_use]
786    pub fn from_command(command: &str) -> Option<Self> {
787        Self::ALL.into_iter().find(|b| b.command() == command)
788    }
789}
790
791impl LspServerConfig {
792    /// The routing identity of this server: `name` if set, otherwise `language_id`.
793    ///
794    /// This is the key used across `Translator`'s client/server maps, so two
795    /// servers for the same language must set distinct `name`s or they
796    /// collide (see `ToolRouter::from_configs` for the enforcement).
797    #[must_use]
798    pub fn id(&self) -> ServerId {
799        entry_id(self.name.as_ref(), &self.language_id)
800    }
801
802    /// Build a built-in server config, filling in every field not passed as
803    /// a parameter.
804    fn builtin(
805        language_id: LanguageId,
806        server: BuiltinServer,
807        args: &[&str],
808        file_patterns: &[&'static str],
809        markers: impl IntoIterator<Item = &'static str>,
810    ) -> Self {
811        Self {
812            language_id,
813            command: ServerCommand::from_static(server.command()).into(),
814            args: args.iter().map(ToString::to_string).collect(),
815            env: crate::config::ServerEnv::default(),
816            file_patterns: file_patterns
817                .iter()
818                .map(|pattern| FilePattern::from_static(pattern))
819                .collect(),
820            initialization_options: None,
821            settings: None,
822            timeout_seconds: TimeoutSecs::DEFAULT,
823            request_timeout_seconds: TimeoutSecs::DEFAULT,
824            heuristics: Some(ServerHeuristics::with_markers(
825                markers.into_iter().map(ProjectMarker::from_static),
826            )),
827            name: None,
828            handles: None,
829            indexing: IndexingPolicy::Auto,
830        }
831    }
832
833    /// Create a default configuration for rust-analyzer.
834    #[must_use]
835    pub fn rust_analyzer() -> Self {
836        Self::builtin(
837            const { LanguageId::from_static("rust") },
838            BuiltinServer::RustAnalyzer,
839            &[],
840            &["**/*.rs"],
841            ["Cargo.toml", "rust-toolchain.toml"],
842        )
843    }
844
845    /// Create a default configuration for pyright.
846    #[must_use]
847    pub fn pyright() -> Self {
848        Self::builtin(
849            const { LanguageId::from_static("python") },
850            BuiltinServer::Pyright,
851            &["--stdio"],
852            &["**/*.py"],
853            [
854                "pyproject.toml",
855                "setup.py",
856                "requirements.txt",
857                "pyrightconfig.json",
858            ],
859        )
860    }
861
862    /// Create a default configuration for TypeScript language server.
863    ///
864    /// `initialization_options` stays `None` here; at startup mcpls fills in
865    /// the tsserver pin when it can resolve the server's bundled tsserver (see
866    /// [`Self::initialization_options`]). The entry is [`ServerSelection::Auto`],
867    /// so mcpls may start the native TypeScript server instead when TypeScript 7
868    /// is installed outside the workspace.
869    #[must_use]
870    pub fn typescript() -> Self {
871        Self {
872            command: LaunchCommand::typescript(),
873            ..Self::builtin(
874                const { LanguageId::from_static("typescript") },
875                BuiltinServer::TypescriptLanguageServer,
876                &["--stdio"],
877                &["**/*.ts", "**/*.tsx"],
878                ["package.json", "tsconfig.json", "jsconfig.json"],
879            )
880        }
881    }
882
883    /// Create a default configuration for gopls.
884    #[must_use]
885    pub fn gopls() -> Self {
886        Self::builtin(
887            const { LanguageId::from_static("go") },
888            BuiltinServer::Gopls,
889            &["serve"],
890            &["**/*.go"],
891            ["go.mod", "go.sum"],
892        )
893    }
894
895    /// Create a default configuration for clangd.
896    #[must_use]
897    pub fn clangd() -> Self {
898        Self::builtin(
899            const { LanguageId::from_static("cpp") },
900            BuiltinServer::Clangd,
901            &[],
902            &["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
903            [
904                "CMakeLists.txt",
905                "compile_commands.json",
906                "Makefile",
907                ".clangd",
908            ],
909        )
910    }
911
912    /// Create a default configuration for zls.
913    #[must_use]
914    pub fn zls() -> Self {
915        Self::builtin(
916            const { LanguageId::from_static("zig") },
917            BuiltinServer::Zls,
918            &[],
919            &["**/*.zig"],
920            ["build.zig", "build.zig.zon"],
921        )
922    }
923}
924
925/// The id a server entry is known by: its `name`, else its language.
926fn entry_id(name: Option<&ServerId>, language: &LanguageId) -> ServerId {
927    name.cloned()
928        .unwrap_or_else(|| ServerId::from(language.clone()))
929}
930
931#[cfg(test)]
932mod tests {
933    use tempfile::TempDir;
934
935    use super::*;
936    use crate::config::ToolRouter;
937
938    fn heuristics_with<const N: usize>(markers: [&'static str; N]) -> ServerHeuristics {
939        ServerHeuristics::with_markers(markers.map(ProjectMarker::from_static))
940    }
941
942    #[test]
943    fn test_server_command_rejects_blank_and_round_trips() {
944        assert_eq!(ServerCommand::new(""), Err(InvalidServerCommand));
945        assert_eq!(ServerCommand::new(" \t"), Err(InvalidServerCommand));
946        let command: ServerCommand = serde_json::from_str("\"gopls\"").unwrap();
947        assert_eq!(command, "gopls");
948        assert_eq!(serde_json::to_string(&command).unwrap(), "\"gopls\"");
949        assert!(serde_json::from_str::<ServerCommand>("\"  \"").is_err());
950    }
951
952    #[test]
953    #[should_panic(expected = "command must be ASCII and not blank")]
954    fn test_server_command_from_static_panics_on_blank() {
955        let _ = ServerCommand::from_static(" ");
956    }
957
958    #[test]
959    #[should_panic(expected = "command must be ASCII and not blank")]
960    fn test_server_command_from_static_panics_on_non_ascii_blank() {
961        let _ = ServerCommand::from_static("\u{a0}");
962    }
963
964    #[test]
965    fn test_matches_command_accepts_stem_and_rejects_other_servers() {
966        let tsls = BuiltinServer::TypescriptLanguageServer;
967        for command in [
968            "typescript-language-server",
969            "/usr/local/bin/typescript-language-server",
970            "typescript-language-server.cmd",
971        ] {
972            assert!(tsls.matches_command(command), "{command}");
973        }
974        for command in ["tsc", "", "rust-analyzer", "typescript-language-server-x"] {
975            assert!(!tsls.matches_command(command), "{command}");
976        }
977    }
978
979    #[test]
980    fn test_selection_serde_and_defaults() {
981        assert!(ServerSelection::default().is_explicit());
982        assert_eq!(
983            serde_json::from_str::<ServerSelection>("\"auto\"").unwrap(),
984            ServerSelection::Auto
985        );
986        assert!(serde_json::from_str::<ServerSelection>("\"native\"").is_err());
987        assert_eq!(
988            LspServerConfig::typescript().command.selection(),
989            ServerSelection::Auto
990        );
991        assert_eq!(
992            LspServerConfig::rust_analyzer().command.selection(),
993            ServerSelection::Explicit
994        );
995    }
996
997    #[test]
998    fn test_default_typescript_command_is_a_valid_auto_selection() {
999        let command = LspServerConfig::typescript().command;
1000        assert_eq!(
1001            LaunchCommand::auto(command.server_command().clone()),
1002            Ok(command)
1003        );
1004    }
1005
1006    #[test]
1007    fn test_retarget_keeps_the_selection_and_rechecks_auto() {
1008        let tsls = |text: &str| ServerCommand::new(text).unwrap();
1009        let auto = LaunchCommand::auto(tsls("typescript-language-server")).unwrap();
1010
1011        let moved = auto
1012            .clone()
1013            .retarget(tsls("/opt/bin/typescript-language-server"))
1014            .unwrap();
1015        assert_eq!(moved.selection(), ServerSelection::Auto);
1016        assert_eq!(moved, "/opt/bin/typescript-language-server");
1017        assert_eq!(
1018            auto.retarget(tsls("rust-analyzer")),
1019            Err(InvalidAutoSelection)
1020        );
1021
1022        let explicit = LaunchCommand::explicit(tsls("rust-analyzer"));
1023        assert!(explicit.retarget(tsls("anything")).is_ok());
1024    }
1025
1026    #[test]
1027    fn test_selection_key_is_written_only_for_auto() {
1028        let typescript = toml::to_string(&LspServerConfig::typescript()).unwrap();
1029        assert!(typescript.contains("selection = \"auto\""), "{typescript}");
1030        let rust = toml::to_string(&LspServerConfig::rust_analyzer()).unwrap();
1031        assert!(!rust.contains("selection"), "{rust}");
1032    }
1033
1034    #[test]
1035    fn test_early_exit_hint_only_for_rust_analyzer() {
1036        for builtin in BuiltinServer::ALL {
1037            assert_eq!(
1038                builtin.early_exit_hint().is_some(),
1039                builtin == BuiltinServer::RustAnalyzer,
1040                "{builtin:?}"
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn test_rust_analyzer_defaults() {
1047        let config = LspServerConfig::rust_analyzer();
1048
1049        assert_eq!(config.language_id, "rust");
1050        assert_eq!(config.command, "rust-analyzer");
1051        assert_eq!(config.args.len(), 0);
1052        assert!(config.env.is_empty());
1053        assert_eq!(config.file_patterns, ["**/*.rs"]);
1054        assert!(config.initialization_options.is_none());
1055        assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1056    }
1057
1058    #[test]
1059    fn test_pyright_defaults() {
1060        let config = LspServerConfig::pyright();
1061
1062        assert_eq!(config.language_id, "python");
1063        assert_eq!(config.command, "pyright-langserver");
1064        assert_eq!(config.args, vec!["--stdio"]);
1065        assert!(config.env.is_empty());
1066        assert_eq!(config.file_patterns, ["**/*.py"]);
1067        assert!(config.initialization_options.is_none());
1068        assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1069    }
1070
1071    #[test]
1072    fn test_typescript_defaults() {
1073        let config = LspServerConfig::typescript();
1074
1075        assert_eq!(config.language_id, "typescript");
1076        assert_eq!(config.command, "typescript-language-server");
1077        assert_eq!(config.args, vec!["--stdio"]);
1078        assert!(config.env.is_empty());
1079        assert_eq!(config.file_patterns, ["**/*.ts", "**/*.tsx"]);
1080        assert!(config.initialization_options.is_none());
1081        assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
1082    }
1083
1084    #[test]
1085    fn test_custom_config() {
1086        let mut env = ServerEnv::default();
1087        env.insert(EnvKey::from_static("RUST_LOG"), "debug".to_string());
1088
1089        let config = LspServerConfig {
1090            language_id: LanguageId::from_static("custom"),
1091            command: ServerCommand::from_static("custom-lsp").into(),
1092            args: vec!["--flag".to_string()],
1093            env: env.clone(),
1094            file_patterns: vec![FilePattern::from_static("**/*.custom")],
1095            initialization_options: Some(serde_json::json!({"key": "value"})),
1096            settings: None,
1097            timeout_seconds: TimeoutSecs::new(60).unwrap(),
1098            request_timeout_seconds: TimeoutSecs::new(45).unwrap(),
1099            heuristics: None,
1100            name: None,
1101            handles: None,
1102            indexing: crate::bridge::IndexingPolicy::Auto,
1103        };
1104
1105        assert_eq!(config.language_id, "custom");
1106        assert_eq!(config.command, "custom-lsp");
1107        assert_eq!(config.args, vec!["--flag"]);
1108        assert_eq!(config.env.get("RUST_LOG"), Some("debug"));
1109        assert_eq!(config.file_patterns, ["**/*.custom"]);
1110        assert!(config.initialization_options.is_some());
1111        assert_eq!(config.timeout_seconds.get(), 60);
1112    }
1113
1114    #[test]
1115    fn test_serde_roundtrip() {
1116        let original = LspServerConfig::rust_analyzer();
1117
1118        let serialized = serde_json::to_string(&original).unwrap();
1119        let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
1120
1121        assert_eq!(deserialized.language_id, original.language_id);
1122        assert_eq!(deserialized.command, original.command);
1123        assert_eq!(deserialized.args, original.args);
1124        assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
1125        assert_eq!(
1126            deserialized.request_timeout_seconds,
1127            original.request_timeout_seconds
1128        );
1129    }
1130
1131    #[test]
1132    fn test_indexing_defaults_to_auto_when_omitted() {
1133        assert_eq!(
1134            LspServerConfig::rust_analyzer().indexing,
1135            crate::bridge::IndexingPolicy::Auto
1136        );
1137    }
1138
1139    /// P4: `[[lsp_servers]] indexing = "disabled"` must round-trip through
1140    /// TOML, the format `[[lsp_servers]]` entries are actually configured in.
1141    #[test]
1142    fn test_indexing_disabled_round_trips_through_toml() {
1143        let toml_str = r#"
1144            language_id = "rust"
1145            command = "rust-analyzer"
1146            indexing = "disabled"
1147        "#;
1148        let config: LspServerConfig = toml::from_str(toml_str).unwrap();
1149        assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Disabled);
1150
1151        let serialized = toml::to_string(&config).unwrap();
1152        assert!(serialized.contains(r#"indexing = "disabled""#));
1153        let round_tripped: LspServerConfig = toml::from_str(&serialized).unwrap();
1154        assert_eq!(
1155            round_tripped.indexing,
1156            crate::bridge::IndexingPolicy::Disabled
1157        );
1158    }
1159
1160    /// A `[[lsp_servers]]` entry that omits `indexing` entirely must default
1161    /// to `Auto`, not fail `deny_unknown_fields`/require the field.
1162    #[test]
1163    fn test_indexing_omitted_from_toml_defaults_to_auto() {
1164        let toml_str = r#"
1165            language_id = "rust"
1166            command = "rust-analyzer"
1167        "#;
1168        let config: LspServerConfig = toml::from_str(toml_str).unwrap();
1169        assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
1170    }
1171
1172    /// M4: a config at the default `IndexingPolicy::Auto` must not write
1173    /// `indexing = "auto"` into the serialized TOML -- every other optional
1174    /// field on this struct is skipped at its default, and generating
1175    /// ~30 builtin server entries each carrying a redundant `indexing =
1176    /// "auto"` line would be a visible regression to `mcpls.toml`'s
1177    /// generated default.
1178    #[test]
1179    fn test_indexing_auto_is_omitted_from_serialized_toml() {
1180        let config = LspServerConfig::rust_analyzer();
1181        assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
1182
1183        let serialized = toml::to_string(&config).unwrap();
1184        assert!(
1185            !serialized.contains("indexing"),
1186            "the default IndexingPolicy::Auto must be omitted, not serialized as \
1187             indexing = \"auto\""
1188        );
1189    }
1190
1191    #[test]
1192    fn test_clone() {
1193        let config = LspServerConfig::rust_analyzer();
1194        let cloned = config.clone();
1195
1196        assert_eq!(cloned.language_id, config.language_id);
1197        assert_eq!(cloned.command, config.command);
1198        assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
1199    }
1200
1201    #[test]
1202    fn test_empty_env() {
1203        let config = LspServerConfig::rust_analyzer();
1204        assert!(config.env.is_empty());
1205    }
1206
1207    #[test]
1208    fn test_multiple_file_patterns() {
1209        let config = LspServerConfig::typescript();
1210        assert_eq!(config.file_patterns.len(), 2);
1211        assert!(
1212            config
1213                .file_patterns
1214                .contains(&FilePattern::from_static("**/*.ts"))
1215        );
1216        assert!(
1217            config
1218                .file_patterns
1219                .contains(&FilePattern::from_static("**/*.tsx"))
1220        );
1221    }
1222
1223    #[test]
1224    fn test_initialization_options_none_by_default() {
1225        let configs = vec![
1226            LspServerConfig::rust_analyzer(),
1227            LspServerConfig::pyright(),
1228            LspServerConfig::typescript(),
1229        ];
1230
1231        for config in configs {
1232            assert!(config.initialization_options.is_none());
1233        }
1234    }
1235
1236    // Heuristics tests
1237    #[test]
1238    fn test_heuristics_empty_always_applicable() {
1239        let heuristics = ServerHeuristics::default();
1240        let tmp = TempDir::new().unwrap();
1241        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1242    }
1243
1244    #[test]
1245    fn test_heuristics_marker_present() {
1246        let tmp = TempDir::new().unwrap();
1247        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1248
1249        let heuristics = heuristics_with(["Cargo.toml"]);
1250        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1251    }
1252
1253    #[test]
1254    fn test_heuristics_marker_absent() {
1255        let tmp = TempDir::new().unwrap();
1256        let heuristics = heuristics_with(["Cargo.toml"]);
1257        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1258    }
1259
1260    #[test]
1261    fn test_heuristics_any_marker_matches() {
1262        let tmp = TempDir::new().unwrap();
1263        std::fs::write(tmp.path().join("setup.py"), "").unwrap();
1264
1265        let heuristics = heuristics_with(["pyproject.toml", "setup.py", "requirements.txt"]);
1266        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1267    }
1268
1269    #[test]
1270    fn test_should_spawn_without_heuristics() {
1271        let config = LspServerConfig {
1272            language_id: LanguageId::from_static("test"),
1273            command: ServerCommand::from_static("test-lsp").into(),
1274            args: vec![],
1275            env: crate::config::ServerEnv::default(),
1276            file_patterns: vec![],
1277            initialization_options: None,
1278            settings: None,
1279            timeout_seconds: TimeoutSecs::new(30).unwrap(),
1280            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
1281            heuristics: None,
1282            name: None,
1283            handles: None,
1284            indexing: crate::bridge::IndexingPolicy::Auto,
1285        };
1286
1287        let tmp = TempDir::new().unwrap();
1288        assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1289    }
1290
1291    #[test]
1292    fn test_should_spawn_with_heuristics() {
1293        let tmp = TempDir::new().unwrap();
1294        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1295
1296        let config = LspServerConfig::rust_analyzer();
1297        assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1298    }
1299
1300    #[test]
1301    fn test_should_not_spawn_without_markers() {
1302        let tmp = TempDir::new().unwrap();
1303        let config = LspServerConfig::rust_analyzer();
1304        assert!(!spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1305    }
1306
1307    #[test]
1308    fn test_heuristics_serde_roundtrip() {
1309        let heuristics = heuristics_with(["Cargo.toml", "rust-toolchain.toml"]);
1310        let json = serde_json::to_string(&heuristics).unwrap();
1311        let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
1312        assert_eq!(deserialized.project_markers, heuristics.project_markers);
1313    }
1314
1315    #[test]
1316    fn test_default_rust_analyzer_heuristics() {
1317        let config = LspServerConfig::rust_analyzer();
1318        assert!(config.heuristics.is_some());
1319        let markers = &config.heuristics.unwrap().project_markers;
1320        assert!(markers.contains(&ProjectMarker::from_static("Cargo.toml")));
1321    }
1322
1323    #[test]
1324    fn test_gopls_defaults() {
1325        let config = LspServerConfig::gopls();
1326
1327        assert_eq!(config.language_id, "go");
1328        assert_eq!(config.command, "gopls");
1329        assert_eq!(config.args, vec!["serve"]);
1330        assert!(config.heuristics.is_some());
1331        let markers = &config.heuristics.unwrap().project_markers;
1332        assert!(markers.contains(&ProjectMarker::from_static("go.mod")));
1333        assert!(markers.contains(&ProjectMarker::from_static("go.sum")));
1334    }
1335
1336    #[test]
1337    fn test_clangd_defaults() {
1338        let config = LspServerConfig::clangd();
1339
1340        assert_eq!(config.language_id, "cpp");
1341        assert_eq!(config.command, "clangd");
1342        assert_eq!(config.args.len(), 0);
1343        assert!(config.heuristics.is_some());
1344        let markers = &config.heuristics.unwrap().project_markers;
1345        assert!(markers.contains(&ProjectMarker::from_static("CMakeLists.txt")));
1346        assert!(markers.contains(&ProjectMarker::from_static("compile_commands.json")));
1347    }
1348
1349    #[test]
1350    fn test_zls_defaults() {
1351        let config = LspServerConfig::zls();
1352
1353        assert_eq!(config.language_id, "zig");
1354        assert_eq!(config.command, "zls");
1355        assert_eq!(config.args.len(), 0);
1356        assert!(config.heuristics.is_some());
1357        let markers = &config.heuristics.unwrap().project_markers;
1358        assert!(markers.contains(&ProjectMarker::from_static("build.zig")));
1359        assert!(markers.contains(&ProjectMarker::from_static("build.zig.zon")));
1360    }
1361
1362    // Recursive scanning tests
1363    #[test]
1364    fn test_recursive_empty_markers_always_applicable() {
1365        let heuristics = ServerHeuristics::default();
1366        let tmp = TempDir::new().unwrap();
1367        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1368    }
1369
1370    #[test]
1371    fn test_recursive_marker_at_root() {
1372        let tmp = TempDir::new().unwrap();
1373        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1374
1375        let heuristics = heuristics_with(["Cargo.toml"]);
1376        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1377    }
1378
1379    #[test]
1380    fn test_recursive_nested_python_project() {
1381        let tmp = TempDir::new().unwrap();
1382        // Create Rust project at root
1383        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1384        // Create nested Python project
1385        let python_dir = tmp.path().join("python");
1386        std::fs::create_dir(&python_dir).unwrap();
1387        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
1388
1389        let heuristics = heuristics_with(["pyproject.toml", "setup.py"]);
1390        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1391    }
1392
1393    #[test]
1394    fn test_recursive_deeply_nested_marker() {
1395        let tmp = TempDir::new().unwrap();
1396        // Create a deeply nested structure
1397        let deep_path = tmp.path().join("level1").join("level2").join("level3");
1398        std::fs::create_dir_all(&deep_path).unwrap();
1399        std::fs::write(deep_path.join("go.mod"), "").unwrap();
1400
1401        let heuristics = heuristics_with(["go.mod"]);
1402        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1403    }
1404
1405    #[test]
1406    fn test_recursive_no_marker_found() {
1407        let tmp = TempDir::new().unwrap();
1408        std::fs::create_dir(tmp.path().join("src")).unwrap();
1409        std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
1410
1411        let heuristics = heuristics_with(["Cargo.toml"]);
1412        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1413    }
1414
1415    #[test]
1416    fn test_recursive_max_depth_respected() {
1417        let tmp = TempDir::new().unwrap();
1418        // Create marker at depth 5
1419        let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
1420        std::fs::create_dir_all(&deep_path).unwrap();
1421        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
1422
1423        let heuristics = heuristics_with(["Cargo.toml"]);
1424        // With max_depth=3, should not find marker at depth 5
1425        assert!(!applies(
1426            &heuristics,
1427            tmp.path(),
1428            SearchDepth::new(3).unwrap()
1429        ));
1430        // With max_depth=10 (default), should find it
1431        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1432    }
1433
1434    #[test]
1435    fn test_recursive_excludes_node_modules() {
1436        let tmp = TempDir::new().unwrap();
1437        // Create package.json inside node_modules (should be ignored)
1438        let node_modules = tmp.path().join("node_modules").join("some-package");
1439        std::fs::create_dir_all(&node_modules).unwrap();
1440        std::fs::write(node_modules.join("package.json"), "").unwrap();
1441
1442        let heuristics = heuristics_with(["package.json"]);
1443        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1444    }
1445
1446    #[test]
1447    fn test_recursive_excludes_target_directory() {
1448        let tmp = TempDir::new().unwrap();
1449        // Create Cargo.toml inside target (should be ignored)
1450        let target = tmp.path().join("target").join("debug");
1451        std::fs::create_dir_all(&target).unwrap();
1452        std::fs::write(target.join("Cargo.toml"), "").unwrap();
1453
1454        let heuristics = heuristics_with(["Cargo.toml"]);
1455        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1456    }
1457
1458    #[test]
1459    fn test_recursive_excludes_git_directory() {
1460        let tmp = TempDir::new().unwrap();
1461        let git_dir = tmp.path().join(".git").join("hooks");
1462        std::fs::create_dir_all(&git_dir).unwrap();
1463        std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
1464
1465        let heuristics = heuristics_with(["Cargo.toml"]);
1466        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1467    }
1468
1469    #[test]
1470    fn test_recursive_excludes_pycache() {
1471        let tmp = TempDir::new().unwrap();
1472        let pycache = tmp.path().join("__pycache__");
1473        std::fs::create_dir_all(&pycache).unwrap();
1474        std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
1475
1476        let heuristics = heuristics_with(["pyproject.toml"]);
1477        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1478    }
1479
1480    #[test]
1481    fn test_recursive_excludes_venv() {
1482        let tmp = TempDir::new().unwrap();
1483        let venv = tmp.path().join(".venv").join("lib");
1484        std::fs::create_dir_all(&venv).unwrap();
1485        std::fs::write(venv.join("setup.py"), "").unwrap();
1486
1487        let heuristics = heuristics_with(["setup.py"]);
1488        assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1489    }
1490
1491    /// #476: `standard_filters(false)` is a bulk setter that resets
1492    /// `.git_ignore` (among others); it must run before the individual
1493    /// overrides, or it silently negates the `.git_ignore(true)` set
1494    /// afterward and the walk descends into gitignored directories.
1495    #[test]
1496    fn test_recursive_respects_gitignore_in_marker_search() {
1497        let tmp = TempDir::new().unwrap();
1498        // A bare `.git` directory is enough for the `ignore` crate to treat
1499        // this fixture as a git repository and start honoring `.gitignore`.
1500        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
1501        std::fs::write(tmp.path().join(".gitignore"), "ignored/\n").unwrap();
1502
1503        let ignored_dir = tmp.path().join("ignored");
1504        std::fs::create_dir_all(&ignored_dir).unwrap();
1505        std::fs::write(ignored_dir.join("Cargo.toml"), "").unwrap();
1506
1507        let heuristics = heuristics_with(["Cargo.toml"]);
1508        assert!(
1509            !applies(&heuristics, tmp.path(), SearchDepth::DEFAULT),
1510            "marker inside a gitignored directory must not make the server applicable"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_recursive_finds_marker_outside_excluded() {
1516        let tmp = TempDir::new().unwrap();
1517        // Create excluded dir with marker
1518        let node_modules = tmp.path().join("node_modules");
1519        std::fs::create_dir_all(&node_modules).unwrap();
1520        std::fs::write(node_modules.join("package.json"), "").unwrap();
1521        // Create valid marker in src
1522        let src = tmp.path().join("src");
1523        std::fs::create_dir_all(&src).unwrap();
1524        std::fs::write(src.join("package.json"), "").unwrap();
1525
1526        let heuristics = heuristics_with(["package.json"]);
1527        assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
1528    }
1529
1530    #[test]
1531    fn test_recursive_monorepo_structure() {
1532        let tmp = TempDir::new().unwrap();
1533        // Create monorepo with multiple language projects
1534        let rust_pkg = tmp.path().join("packages").join("rust-lib");
1535        let python_pkg = tmp.path().join("packages").join("python-bindings");
1536        let ts_pkg = tmp.path().join("packages").join("typescript-client");
1537
1538        std::fs::create_dir_all(&rust_pkg).unwrap();
1539        std::fs::create_dir_all(&python_pkg).unwrap();
1540        std::fs::create_dir_all(&ts_pkg).unwrap();
1541
1542        std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
1543        std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
1544        std::fs::write(ts_pkg.join("package.json"), "").unwrap();
1545
1546        // All should be detected
1547        let rust_heuristics = heuristics_with(["Cargo.toml"]);
1548        let python_heuristics = heuristics_with(["pyproject.toml"]);
1549        let ts_heuristics = heuristics_with(["package.json"]);
1550
1551        assert!(applies(&rust_heuristics, tmp.path(), SearchDepth::DEFAULT));
1552        assert!(applies(
1553            &python_heuristics,
1554            tmp.path(),
1555            SearchDepth::DEFAULT
1556        ));
1557        assert!(applies(&ts_heuristics, tmp.path(), SearchDepth::DEFAULT));
1558    }
1559
1560    #[test]
1561    fn test_should_spawn_recursive() {
1562        let tmp = TempDir::new().unwrap();
1563        // Create nested Python project in Rust workspace
1564        let python_dir = tmp.path().join("bindings").join("python");
1565        std::fs::create_dir_all(&python_dir).unwrap();
1566        std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
1567
1568        let config = LspServerConfig::pyright();
1569        assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1570    }
1571
1572    #[test]
1573    fn test_should_spawn_with_custom_max_depth() {
1574        let tmp = TempDir::new().unwrap();
1575        let deep_path = tmp.path().join("a").join("b").join("c").join("d");
1576        std::fs::create_dir_all(&deep_path).unwrap();
1577        std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
1578
1579        let config = LspServerConfig::rust_analyzer();
1580        // Shallow depth should not find it
1581        assert!(!spawns(&config, tmp.path(), SearchDepth::new(2).unwrap()));
1582        // Default depth should find it
1583        assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
1584    }
1585
1586    fn applies(heuristics: &ServerHeuristics, root: &Path, depth: SearchDepth) -> bool {
1587        let server = LspServerConfig {
1588            heuristics: Some(heuristics.clone()),
1589            ..LspServerConfig::rust_analyzer()
1590        };
1591        spawns(&server, root, depth)
1592    }
1593
1594    fn spawns(server: &LspServerConfig, root: &Path, depth: SearchDepth) -> bool {
1595        scan(&[root], std::slice::from_ref(server), depth).applies_to(server)
1596    }
1597
1598    fn scan(roots: &[&Path], servers: &[LspServerConfig], depth: SearchDepth) -> MarkerScan {
1599        let roots: Vec<PathBuf> = roots.iter().map(|root| root.to_path_buf()).collect();
1600        MarkerScan::collect(&roots, servers, depth)
1601    }
1602
1603    fn all_builtins() -> Vec<LspServerConfig> {
1604        vec![
1605            LspServerConfig::rust_analyzer(),
1606            LspServerConfig::pyright(),
1607            LspServerConfig::typescript(),
1608            LspServerConfig::gopls(),
1609            LspServerConfig::clangd(),
1610            LspServerConfig::zls(),
1611        ]
1612    }
1613
1614    #[test]
1615    fn test_marker_scan_finds_the_markers_of_every_server_in_one_pass() {
1616        let tmp = TempDir::new().unwrap();
1617        std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
1618        let python = tmp.path().join("bindings").join("python");
1619        std::fs::create_dir_all(&python).unwrap();
1620        std::fs::write(python.join("pyproject.toml"), "").unwrap();
1621        let servers = all_builtins();
1622
1623        let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1624
1625        let applying: Vec<_> = servers
1626            .iter()
1627            .filter(|server| found.applies_to(server))
1628            .map(|server| server.language_id.as_str())
1629            .collect();
1630        assert_eq!(applying, ["rust", "python"]);
1631    }
1632
1633    #[test]
1634    fn test_marker_scan_skips_excluded_directories_and_markers_below_the_depth() {
1635        let tmp = TempDir::new().unwrap();
1636        for (dir, file) in [
1637            ("a/b", "go.mod"),
1638            ("c", "package.json"),
1639            ("node_modules/x", "Cargo.toml"),
1640            ("d/e/f/g/h/i/j/k/l/m/n", "build.zig"),
1641        ] {
1642            std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
1643            std::fs::write(tmp.path().join(dir).join(file), "").unwrap();
1644        }
1645        let servers = all_builtins();
1646        let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1647
1648        let applying: Vec<_> = servers
1649            .iter()
1650            .filter(|server| found.applies_to(server))
1651            .map(|server| server.language_id.as_str())
1652            .collect();
1653        assert_eq!(applying, ["typescript", "go"]);
1654    }
1655
1656    #[test]
1657    fn test_marker_scan_unions_the_roots() {
1658        let (first, second) = (TempDir::new().unwrap(), TempDir::new().unwrap());
1659        std::fs::write(first.path().join("Cargo.toml"), "").unwrap();
1660        let nested = second.path().join("svc");
1661        std::fs::create_dir_all(&nested).unwrap();
1662        std::fs::write(nested.join("go.mod"), "").unwrap();
1663        let servers = all_builtins();
1664
1665        let found = scan(
1666            &[first.path(), second.path()],
1667            &servers,
1668            SearchDepth::DEFAULT,
1669        );
1670
1671        assert!(found.applies_to(&LspServerConfig::rust_analyzer()));
1672        assert!(found.applies_to(&LspServerConfig::gopls()));
1673        assert!(!found.applies_to(&LspServerConfig::zls()));
1674    }
1675
1676    #[test]
1677    fn test_marker_scan_honours_the_depth_limit() {
1678        let tmp = TempDir::new().unwrap();
1679        let deep = tmp.path().join("a").join("b").join("c").join("d");
1680        std::fs::create_dir_all(&deep).unwrap();
1681        std::fs::write(deep.join("Cargo.toml"), "").unwrap();
1682        let servers = [LspServerConfig::rust_analyzer()];
1683
1684        let shallow = scan(&[tmp.path()], &servers, SearchDepth::new(2).unwrap());
1685        let default = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
1686
1687        assert!(!shallow.applies_to(&servers[0]));
1688        assert!(default.applies_to(&servers[0]));
1689    }
1690
1691    #[test]
1692    fn test_marker_scan_applies_a_server_without_markers_everywhere() {
1693        let tmp = TempDir::new().unwrap();
1694        let mut no_markers = LspServerConfig::rust_analyzer();
1695        no_markers.heuristics = None;
1696        let mut empty_markers = LspServerConfig::rust_analyzer();
1697        empty_markers.heuristics = Some(ServerHeuristics::default());
1698
1699        let found = scan(&[tmp.path()], &[], SearchDepth::DEFAULT);
1700
1701        assert!(found.applies_to(&no_markers));
1702        assert!(found.applies_to(&empty_markers));
1703        assert!(!found.applies_to(&LspServerConfig::rust_analyzer()));
1704    }
1705
1706    #[test]
1707    fn test_default_heuristics_max_depth() {
1708        assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
1709    }
1710
1711    #[test]
1712    fn test_excluded_directories_constant() {
1713        assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
1714        assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
1715        assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
1716        assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
1717        assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
1718    }
1719
1720    const NATIVE_ENTRY: &str = r#"[[lsp_servers]]
1721language_id = "typescript"
1722command = "/home/me/ts7/node_modules/.bin/tsc"
1723args = ["--lsp", "--stdio"]
1724file_patterns = ["**/*.ts", "**/*.tsx"]"#;
1725
1726    fn repo_file(relative: &str) -> String {
1727        let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else(
1728            || std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
1729            Into::into,
1730        );
1731        std::fs::read_to_string(manifest.join("../..").join(relative))
1732            .unwrap()
1733            .replace("\r\n", "\n")
1734    }
1735
1736    fn native_entries(extra: &str) -> Vec<LspServerConfig> {
1737        let text = format!("{NATIVE_ENTRY}\n{extra}");
1738        toml::from_str::<crate::config::ServerConfig>(&text)
1739            .unwrap()
1740            .lsp_servers
1741    }
1742
1743    #[test]
1744    fn test_docs_agree_with_typescript_install_hint() {
1745        let hint = BuiltinServer::TypescriptLanguageServer.install_hint();
1746        let pin = hint.split_whitespace().last().unwrap();
1747        assert!(pin.starts_with("typescript@"), "{hint}");
1748
1749        let mut checked = 0;
1750        for file in [
1751            "README.md",
1752            "book/src/getting-started/installation.md",
1753            "book/src/guide/language-servers.md",
1754            "book/src/advanced/typescript.md",
1755        ] {
1756            for line in repo_file(file).lines() {
1757                if !(line.contains("npm install -g") && line.contains("typescript")) {
1758                    continue;
1759                }
1760                checked += 1;
1761                assert!(
1762                    line.split(|c: char| !(c.is_alphanumeric() || matches!(c, '@' | '.' | '-')))
1763                        .any(|word| word == pin),
1764                    "{file}: {line}"
1765                );
1766                if line.contains("typescript-language-server") {
1767                    assert!(line.contains(hint), "{file}: {line}");
1768                }
1769            }
1770        }
1771        assert!(checked >= 4, "only {checked} install lines found");
1772    }
1773
1774    #[test]
1775    fn test_documented_native_entry_is_in_the_book() {
1776        assert!(repo_file("book/src/advanced/typescript.md").contains(NATIVE_ENTRY));
1777    }
1778
1779    #[test]
1780    fn test_native_entry_replacing_default_typescript_entry_routes() {
1781        let router = ToolRouter::from_configs(&native_entries("")).unwrap();
1782        assert!(router.has_language("typescript"));
1783    }
1784
1785    #[test]
1786    fn test_native_entry_appended_to_default_is_rejected() {
1787        let mut configs = vec![LspServerConfig::typescript()];
1788        configs.extend(native_entries(""));
1789        let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
1790        assert!(err.contains("duplicate server id"), "{err}");
1791    }
1792
1793    #[test]
1794    fn test_named_native_entry_appended_to_default_is_rejected() {
1795        let mut named = native_entries("");
1796        named[0].name = Some(ServerId::from_static("native-ts"));
1797        let mut configs = vec![LspServerConfig::typescript()];
1798        configs.extend(named);
1799        let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
1800        assert!(err.contains("two catch-all servers"), "{err}");
1801    }
1802}