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