Skip to main content

mcpls_core/config/
routing.rs

1//! Explicit per-tool routing (#174).
2//!
3//! `language_id` alone is not a unique server identity: two servers can
4//! share one language (e.g. pyright and pylsp both for `python`), each
5//! handling a different subset of MCP tools. This module defines the typed
6//! vocabulary for that routing — [`ServerId`], [`ToolKind`] — and
7//! [`ToolRouter`], which resolves `(language, tool)` to the server that
8//! should handle it.
9//!
10//! `ToolKind` lives here, in `config`, rather than in `mcp` (which is where
11//! its variants are semantically drawn from) to keep `config` a leaf module:
12//! `mcp` and `bridge` both depend on `config`, so putting `ToolKind` in `mcp`
13//! would create a `config -> mcp -> bridge -> config` cycle. When a new
14//! routable MCP tool is added, extend [`ToolKind::ALL`] here.
15
16use std::borrow::Cow;
17use std::collections::{HashMap, HashSet};
18use std::fmt;
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23use super::language_id::LanguageId;
24use super::server::{LaunchCommand, LspServerConfig};
25use super::text_newtype::impl_text_newtype;
26use crate::error::{ConfigError, Result};
27
28/// A server id was empty or whitespace-only.
29#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
30#[error("server id cannot be blank (omit `name` to default to the language id)")]
31pub struct InvalidServerId;
32
33/// Unique identity of a configured LSP server within a workspace.
34///
35/// Derived from [`LspServerConfig::id`]: a server's explicit `name` if set,
36/// otherwise its `language_id`. This is the key used throughout the bridge
37/// layer (the translator's server slots, notification receivers)
38/// instead of a raw language string, so two servers sharing a language no
39/// longer silently overwrite each other.
40///
41/// Deserializes from a string and rejects a blank one, so the config file and
42/// [`Self::new`] apply the same rule.
43///
44/// # Examples
45///
46/// ```
47/// use mcpls_core::config::ServerId;
48///
49/// assert_eq!(ServerId::new("pyright").unwrap().as_str(), "pyright");
50/// assert!(ServerId::new("  ").is_err());
51/// ```
52#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
53#[serde(try_from = "String", into = "String")]
54#[schemars(with = "String")]
55pub struct ServerId(Cow<'static, str>);
56
57impl_text_newtype!(ServerId, InvalidServerId, non_blank, "server id");
58
59impl From<LanguageId> for ServerId {
60    fn from(id: LanguageId) -> Self {
61        Self(Cow::Owned(id.into()))
62    }
63}
64
65/// A routable MCP tool: every MCP tool that dispatches a request to a
66/// specific LSP server via [`ToolRouter`].
67///
68/// Cache-only tools (`get_cached_diagnostics`, `get_server_logs`,
69/// `get_server_messages`) are deliberately excluded — they never reach a
70/// client directly, so they have nothing to route.
71///
72/// `CallHierarchy` covers `prepare`, `incoming_calls`, and `outgoing_calls`
73/// as a single route: the opaque item returned by `prepare` is only
74/// meaningful to the server that produced it, and the incoming/outgoing
75/// handlers never call `ensure_open` themselves — they rely on `prepare`
76/// having already synced the document to the *same* server. `TypeHierarchy`
77/// covers `prepare_type_hierarchy`, `get_supertypes` and `get_subtypes` for
78/// the same reason. `Rename` also covers `prepare_rename`, so the verdict
79/// and the edit always come from the same server.
80///
81/// # Examples
82///
83/// ```
84/// use mcpls_core::config::ToolKind;
85///
86/// assert_eq!(ToolKind::Hover.as_str(), "hover");
87/// assert_eq!(ToolKind::ALL.len(), 21);
88/// ```
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91#[non_exhaustive]
92pub enum ToolKind {
93    /// `textDocument/hover`.
94    Hover,
95    /// `textDocument/definition`.
96    Definition,
97    /// `textDocument/typeDefinition`.
98    TypeDefinition,
99    /// `textDocument/implementation`.
100    Implementation,
101    /// `textDocument/references`.
102    References,
103    /// `textDocument/diagnostic` (pull) and the `publishDiagnostics` cache filter.
104    Diagnostics,
105    /// `textDocument/rename` and `textDocument/prepareRename`.
106    Rename,
107    /// `textDocument/completion`.
108    Completions,
109    /// `textDocument/signatureHelp`.
110    SignatureHelp,
111    /// `textDocument/documentSymbol`.
112    DocumentSymbols,
113    /// `workspace/symbol`.
114    WorkspaceSymbols,
115    /// `textDocument/formatting`.
116    FormatDocument,
117    /// `textDocument/codeAction`.
118    CodeActions,
119    /// `textDocument/prepareCallHierarchy`, `callHierarchy/incomingCalls`, `callHierarchy/outgoingCalls`.
120    CallHierarchy,
121    /// `textDocument/inlayHint`.
122    InlayHints,
123    /// `textDocument/declaration`.
124    Declaration,
125    /// `textDocument/prepareTypeHierarchy`, `typeHierarchy/supertypes`, `typeHierarchy/subtypes`.
126    TypeHierarchy,
127    /// `textDocument/documentHighlight`.
128    DocumentHighlights,
129    /// `textDocument/rangeFormatting`.
130    FormatRange,
131    /// `textDocument/selectionRange`.
132    SelectionRange,
133    /// `textDocument/foldingRange`.
134    FoldingRange,
135}
136
137impl ToolKind {
138    /// Every routable tool, in a fixed order. Used to compute the §5
139    /// coverage warning and to build error messages that enumerate tools.
140    pub const ALL: &[Self] = &[
141        Self::Hover,
142        Self::Definition,
143        Self::TypeDefinition,
144        Self::Implementation,
145        Self::References,
146        Self::Diagnostics,
147        Self::Rename,
148        Self::Completions,
149        Self::SignatureHelp,
150        Self::DocumentSymbols,
151        Self::WorkspaceSymbols,
152        Self::FormatDocument,
153        Self::CodeActions,
154        Self::CallHierarchy,
155        Self::InlayHints,
156        Self::Declaration,
157        Self::TypeHierarchy,
158        Self::DocumentHighlights,
159        Self::FormatRange,
160        Self::SelectionRange,
161        Self::FoldingRange,
162    ];
163
164    /// The `snake_case` name used in config `handles` lists and error messages.
165    #[must_use]
166    pub const fn as_str(&self) -> &'static str {
167        match self {
168            Self::Hover => "hover",
169            Self::Definition => "definition",
170            Self::TypeDefinition => "type_definition",
171            Self::Implementation => "implementation",
172            Self::References => "references",
173            Self::Diagnostics => "diagnostics",
174            Self::Rename => "rename",
175            Self::Completions => "completions",
176            Self::SignatureHelp => "signature_help",
177            Self::DocumentSymbols => "document_symbols",
178            Self::WorkspaceSymbols => "workspace_symbols",
179            Self::FormatDocument => "format_document",
180            Self::CodeActions => "code_actions",
181            Self::CallHierarchy => "call_hierarchy",
182            Self::InlayHints => "inlay_hints",
183            Self::Declaration => "declaration",
184            Self::TypeHierarchy => "type_hierarchy",
185            Self::DocumentHighlights => "document_highlights",
186            Self::FormatRange => "format_range",
187            Self::SelectionRange => "selection_range",
188            Self::FoldingRange => "folding_range",
189        }
190    }
191}
192
193impl std::fmt::Display for ToolKind {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.write_str(self.as_str())
196    }
197}
198
199/// Why a list of tools is not a valid [`ToolSet`].
200#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
201pub enum InvalidToolSet {
202    /// The list was empty.
203    #[error("handles cannot be empty (omit `handles` for a catch-all server)")]
204    Empty,
205    /// A tool appeared more than once.
206    #[error("duplicate tool '{0}' in `handles`")]
207    Duplicate(ToolKind),
208}
209
210/// The non-empty, duplicate-free tools one server is restricted to.
211///
212/// Deserializes from a list of tool names and rejects an empty list or a
213/// repeated tool at load time.
214///
215/// # Examples
216///
217/// ```
218/// use mcpls_core::config::{ToolKind, ToolSet};
219///
220/// let set = ToolSet::new(vec![ToolKind::Hover, ToolKind::Rename]).unwrap();
221/// assert!(set.contains(ToolKind::Hover));
222/// assert!(ToolSet::new(vec![]).is_err());
223/// assert!(ToolSet::new(vec![ToolKind::Hover, ToolKind::Hover]).is_err());
224/// ```
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(try_from = "Vec<ToolKind>", into = "Vec<ToolKind>")]
227pub struct ToolSet(Vec<ToolKind>);
228
229impl ToolSet {
230    /// Builds a set from `tools`, preserving their order.
231    ///
232    /// # Errors
233    ///
234    /// Returns [`InvalidToolSet::Empty`] for an empty list and
235    /// [`InvalidToolSet::Duplicate`] for the first repeated tool.
236    pub fn new(tools: Vec<ToolKind>) -> std::result::Result<Self, InvalidToolSet> {
237        if tools.is_empty() {
238            return Err(InvalidToolSet::Empty);
239        }
240        let mut seen = HashSet::new();
241        if let Some(tool) = tools.iter().find(|tool| !seen.insert(**tool)) {
242            return Err(InvalidToolSet::Duplicate(*tool));
243        }
244        Ok(Self(tools))
245    }
246
247    /// A set holding exactly `tool`.
248    #[must_use]
249    pub fn single(tool: ToolKind) -> Self {
250        Self(vec![tool])
251    }
252
253    /// Whether `tool` is in the set.
254    #[must_use]
255    pub fn contains(&self, tool: ToolKind) -> bool {
256        self.0.contains(&tool)
257    }
258
259    /// The tools in configured order; never empty.
260    pub fn iter(&self) -> std::slice::Iter<'_, ToolKind> {
261        self.0.iter()
262    }
263}
264
265impl<'a> IntoIterator for &'a ToolSet {
266    type Item = &'a ToolKind;
267    type IntoIter = std::slice::Iter<'a, ToolKind>;
268
269    fn into_iter(self) -> Self::IntoIter {
270        self.iter()
271    }
272}
273
274impl TryFrom<Vec<ToolKind>> for ToolSet {
275    type Error = InvalidToolSet;
276
277    fn try_from(tools: Vec<ToolKind>) -> std::result::Result<Self, Self::Error> {
278        Self::new(tools)
279    }
280}
281
282impl From<ToolSet> for Vec<ToolKind> {
283    fn from(set: ToolSet) -> Self {
284        set.0
285    }
286}
287
288/// Identifies a `[[lsp_servers]]` entry in error messages that must let a user
289/// tell apart two entries sharing the same [`ServerId`] — the id alone is
290/// useless there, since it's exactly what collided.
291///
292/// Deliberately does not include a positional index: [`ToolRouter::from_configs`]
293/// only ever sees the post-heuristics *applicable* subset for a given
294/// workspace, not the raw `[[lsp_servers]]` array, so a printed index would
295/// usually name the wrong TOML entry (misleading, worse than omitting it).
296/// `command` and the argument count distinguish the entries instead; when two entries are
297/// truly identical in every visible field, the description is the same for
298/// both halves, which is an honest reflection of the ambiguity.
299///
300/// # Examples
301///
302/// ```
303/// use mcpls_core::config::{EntrySummary, LanguageId, LaunchCommand, ServerCommand};
304///
305/// let summary = EntrySummary {
306///     language: LanguageId::from_static("python"),
307///     command: LaunchCommand::from(ServerCommand::from_static("pyright")),
308///     arg_count: 0,
309/// };
310/// assert_eq!(summary.to_string(), "language 'python', command 'pyright'");
311/// ```
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct EntrySummary {
314    /// Language the entry serves.
315    pub language: LanguageId,
316    /// Executable the entry launches.
317    pub command: LaunchCommand,
318    /// Number of arguments passed to the executable.
319    ///
320    /// Only the count is kept: arguments can carry secrets (`--api-key=...`)
321    /// that must not reach `Display` or the derived `Debug` of an error.
322    pub arg_count: usize,
323}
324
325impl EntrySummary {
326    fn of(cfg: &LspServerConfig) -> Self {
327        Self {
328            language: cfg.language_id.clone(),
329            command: cfg.command.clone(),
330            arg_count: cfg.args.len(),
331        }
332    }
333}
334
335impl fmt::Display for EntrySummary {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        write!(
338            f,
339            "language '{}', command '{}'",
340            self.language, self.command
341        )?;
342        if self.arg_count > 0 {
343            let noun = if self.arg_count == 1 { "arg" } else { "args" };
344            write!(f, ", {} {noun}", self.arg_count)?;
345        }
346        Ok(())
347    }
348}
349
350/// Per-language routing table: which server handles which tool.
351#[derive(Debug, Default, Clone)]
352struct LanguageRoutes {
353    /// Tools explicitly claimed via a server's `handles` list.
354    explicit: HashMap<ToolKind, ServerId>,
355    /// The single server (if any) that omitted `handles` — serves every
356    /// tool not explicitly claimed by another server for this language.
357    default: Option<ServerId>,
358}
359
360/// Why [`ToolRouter::resolve_any`] could not find a server for a
361/// workspace-wide tool.
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363#[non_exhaustive]
364pub enum NoServerReason {
365    /// No server is registered in this workspace at all. Reflects what has
366    /// *registered* (i.e. finished spawning), not what is configured in
367    /// `mcpls.toml` — a server that is still initializing, or one that was
368    /// configured but failed to spawn, is indistinguishable from "nothing
369    /// configured" at this layer. Callers with access to the set of servers
370    /// still expected to register (e.g. an expected server slot) can
371    /// tell these apart.
372    NothingRegistered,
373    /// At least one server is registered, but none explicitly claims the
374    /// requested tool and none is a catch-all.
375    NoClaimant,
376}
377
378/// How far one configured server's startup has progressed, as seen by
379/// [`ToolRouter::rebind`].
380///
381/// # Examples
382///
383/// ```
384/// use mcpls_core::config::{LanguageId, ServerId, ServerSettlement, ToolKind, ToolRouter};
385///
386/// let mut router = ToolRouter::catch_all([(ServerId::from_static("pyright"), LanguageId::from_static("python"))]);
387/// router.rebind(|_| ServerSettlement::Failed);
388/// assert!(router.resolve("python", ToolKind::Hover).is_none());
389/// ```
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum ServerSettlement {
392    /// Still initializing: its routes stay as configured.
393    Pending,
394    /// Initialized and registered.
395    Registered,
396    /// Failed to start: routes naming it are redirected or dropped.
397    Failed,
398}
399
400/// Resolves `(language, tool)` to the [`ServerId`] that should handle it.
401///
402/// Built once at startup by [`Self::from_configs`] over the *applicable*
403/// (post-heuristics) server configs. The table in use is derived from that
404/// immutable original by [`Self::rebind`] each time a server settles, so no
405/// route ever points at a server known to have failed to spawn.
406#[derive(Debug, Default, Clone)]
407pub struct ToolRouter {
408    by_language: HashMap<LanguageId, LanguageRoutes>,
409    /// Config declaration order, used by `resolve_any` for a deterministic
410    /// choice among candidates. Pruned to non-failed servers by `rebind`.
411    order: Vec<ServerId>,
412}
413
414impl ToolRouter {
415    /// Build a router from the configs applicable in this workspace,
416    /// enforcing the workspace-scoped validation rules:
417    ///
418    /// 1. No two applicable servers (in any language) may share a
419    ///    [`ServerId`] — it is the key of every map keyed by server identity.
420    /// 2. No two applicable servers for one language may both omit `handles`
421    ///    (two catch-alls).
422    /// 3. No tool may be claimed via `handles` by two applicable servers of
423    ///    the same language.
424    ///
425    /// Also emits a `tracing::warn!` for any language whose union of
426    /// `handles` claims is partial and has no catch-all server, naming the
427    /// tools nobody will serve.
428    ///
429    /// # Errors
430    ///
431    /// Returns [`crate::error::Error::Config`] naming the conflicting entries if any
432    /// of the three rules above is violated.
433    pub fn from_configs<'a, I>(cfgs: I) -> Result<Self>
434    where
435        I: IntoIterator<Item = &'a LspServerConfig>,
436    {
437        let mut by_language: HashMap<LanguageId, LanguageRoutes> = HashMap::new();
438        let mut order: Vec<ServerId> = Vec::new();
439        let mut seen_ids: HashMap<ServerId, &LspServerConfig> = HashMap::new();
440
441        for cfg in cfgs {
442            let id = cfg.id();
443
444            if let Some(previous) = seen_ids.get(&id) {
445                return Err(ConfigError::DuplicateServerId {
446                    id,
447                    first: Box::new(EntrySummary::of(previous)),
448                    second: Box::new(EntrySummary::of(cfg)),
449                }
450                .into());
451            }
452            seen_ids.insert(id.clone(), cfg);
453            order.push(id.clone());
454
455            let routes = by_language.entry(cfg.language_id.clone()).or_default();
456
457            match &cfg.handles {
458                None => {
459                    if let Some(existing) = &routes.default {
460                        return Err(ConfigError::TwoCatchAllServers {
461                            language: cfg.language_id.clone(),
462                            existing: existing.clone(),
463                            id,
464                        }
465                        .into());
466                    }
467                    routes.default = Some(id);
468                }
469                Some(tools) => {
470                    for tool in tools {
471                        if let Some(existing) = routes.explicit.get(tool) {
472                            return Err(ConfigError::ToolClaimedTwice {
473                                tool: *tool,
474                                language: cfg.language_id.clone(),
475                                existing: existing.clone(),
476                                id,
477                            }
478                            .into());
479                        }
480                        routes.explicit.insert(*tool, id.clone());
481                    }
482                }
483            }
484        }
485
486        // Deliberately untested (M4): asserting on `tracing` output would
487        // need a subscriber/capture dev-dependency this crate doesn't
488        // otherwise pull in. Verified by inspection instead; the `uncovered`
489        // computation itself is exercised indirectly by every `resolve`
490        // test above that checks an unclaimed tool returns `None`.
491        for (language, routes) in &by_language {
492            if routes.default.is_none() {
493                let uncovered: Vec<&str> = ToolKind::ALL
494                    .iter()
495                    .filter(|t| !routes.explicit.contains_key(t))
496                    .map(ToolKind::as_str)
497                    .collect();
498                if !uncovered.is_empty() {
499                    tracing::warn!(
500                        "language '{language}' has no catch-all server and does not claim: {}",
501                        uncovered.join(", ")
502                    );
503                }
504            }
505        }
506
507        Ok(Self { by_language, order })
508    }
509
510    /// Build a router where every entry is a catch-all for its language.
511    ///
512    /// Test helper: takes `(id, language)` pairs rather than a single entry
513    /// because some tests (e.g. the `typescript`/`typescriptreact` exact-match
514    /// preference) need two catch-alls registered at once.
515    #[must_use]
516    pub fn catch_all<I>(entries: I) -> Self
517    where
518        I: IntoIterator<Item = (ServerId, LanguageId)>,
519    {
520        let mut by_language: HashMap<LanguageId, LanguageRoutes> = HashMap::new();
521        let mut order = Vec::new();
522        for (id, language) in entries {
523            order.push(id.clone());
524            by_language.entry(language).or_default().default = Some(id);
525        }
526        Self { by_language, order }
527    }
528
529    /// Derives the active routes from the configured ones and the current
530    /// [`ServerSettlement`] of every server; a pure function of those, so the
531    /// result never depends on the order in which servers settled, and once
532    /// none is `Pending` it equals what a single batch rebind produces.
533    ///
534    /// Per language:
535    ///
536    /// - an explicit route to a `Registered` or `Pending` server is kept;
537    /// - an explicit route to a `Failed` server is rebound to the language's
538    ///   catch-all when that is `Registered`, kept pointing at the failed
539    ///   server while the catch-all is `Pending` (the route is never bound to
540    ///   a server that has not registered), and dropped when the catch-all is
541    ///   `Failed` or absent;
542    /// - a `Failed` catch-all is dropped;
543    /// - a dead route is never rebound to a *narrowly-scoped* live server: a
544    ///   server that declared `handles = [...]` has explicitly declined every
545    ///   other tool, and conscripting it would override that declaration (and,
546    ///   via the diagnostics cache filter, start caching diagnostics the user
547    ///   deliberately routed away).
548    ///
549    /// The server order used by [`Self::resolve_any`] keeps every server that
550    /// is not `Failed`, so a pending server's position is preserved.
551    ///
552    /// Silent: callers log the server that just settled, not every earlier
553    /// failure again.
554    pub fn rebind(&mut self, settlement: impl Fn(&ServerId) -> ServerSettlement) {
555        for routes in self.by_language.values_mut() {
556            let catch_all = routes.default.clone().map(|id| (settlement(&id), id));
557
558            let dead: Vec<ToolKind> = routes
559                .explicit
560                .iter()
561                .filter(|(_, id)| settlement(id) == ServerSettlement::Failed)
562                .map(|(tool, _)| *tool)
563                .collect();
564            for tool in dead {
565                match &catch_all {
566                    Some((ServerSettlement::Registered, id)) => {
567                        routes.explicit.insert(tool, id.clone());
568                    }
569                    Some((ServerSettlement::Pending, _)) => {}
570                    Some((ServerSettlement::Failed, _)) | None => {
571                        routes.explicit.remove(&tool);
572                    }
573                }
574            }
575
576            if matches!(catch_all, Some((ServerSettlement::Failed, _))) {
577                routes.default = None;
578            }
579        }
580
581        self.order
582            .retain(|id| settlement(id) != ServerSettlement::Failed);
583    }
584
585    /// [`Self::rebind`] for a finished startup: every server in `registered`
586    /// is `Registered`, every other one `Failed`.
587    pub fn rebind_to_registered(&mut self, registered: &HashSet<ServerId>) {
588        self.rebind(|id| {
589            if registered.contains(id) {
590                ServerSettlement::Registered
591            } else {
592                ServerSettlement::Failed
593            }
594        });
595    }
596
597    /// The configured languages with a route (explicit or catch-all) naming
598    /// `id`, sorted.
599    #[must_use]
600    pub fn languages_routed_to(&self, id: &ServerId) -> Vec<LanguageId> {
601        let mut languages: Vec<LanguageId> = self
602            .by_language
603            .iter()
604            .filter(|(_, routes)| {
605                routes.default.as_ref() == Some(id) || routes.explicit.values().any(|v| v == id)
606            })
607            .map(|(language, _)| language.clone())
608            .collect();
609        languages.sort_unstable();
610        languages
611    }
612
613    /// The catch-all server configured for `language_id`, if any.
614    #[must_use]
615    pub fn catch_all_for(&self, language_id: &str) -> Option<&ServerId> {
616        self.by_language.get(language_id)?.default.as_ref()
617    }
618
619    /// Resolve the server that should handle `tool` for `language_id`.
620    ///
621    /// Explicit claims win over the language's catch-all; if neither exists,
622    /// returns `None`.
623    #[must_use]
624    pub fn resolve(&self, language_id: &str, tool: ToolKind) -> Option<&ServerId> {
625        let routes = self.by_language.get(language_id)?;
626        routes.explicit.get(&tool).or(routes.default.as_ref())
627    }
628
629    /// Resolve a server for `tool` without a specific language — used for
630    /// workspace-wide tools like `workspace_symbol_search` that have no
631    /// document to detect a language from.
632    ///
633    /// Resolves in two tiers, in config declaration order:
634    /// 1. the first server that explicitly claims `tool`;
635    /// 2. else the first catch-all server.
636    ///
637    /// Deliberately does *not* fall back to "the first server at all" when
638    /// neither tier matches: a server with a `handles` list has explicitly
639    /// declined every tool not on it, so forwarding an unclaimed workspace-wide
640    /// tool to it anyway would silently violate that declaration. Callers get
641    /// [`NoServerReason`] instead, distinguishing "nothing configured" from
642    /// "something is configured but nothing claims this tool" so they can
643    /// report a precise error rather than defaulting to an arbitrary server.
644    ///
645    /// # Errors
646    ///
647    /// Returns [`NoServerReason::NothingRegistered`] if no server is
648    /// registered at all, or [`NoServerReason::NoClaimant`] if servers are
649    /// registered but none explicitly claims `tool` and none is a catch-all.
650    pub fn resolve_any(&self, tool: ToolKind) -> std::result::Result<&ServerId, NoServerReason> {
651        let claims_explicitly = |id: &ServerId| {
652            self.by_language
653                .values()
654                .any(|r| r.explicit.get(&tool) == Some(id))
655        };
656        let is_catch_all = |id: &ServerId| {
657            self.by_language
658                .values()
659                .any(|r| r.default.as_ref() == Some(id))
660        };
661
662        self.order
663            .iter()
664            .find(|id| claims_explicitly(id))
665            .or_else(|| self.order.iter().find(|id| is_catch_all(id)))
666            .ok_or(if self.order.is_empty() {
667                NoServerReason::NothingRegistered
668            } else {
669                NoServerReason::NoClaimant
670            })
671    }
672
673    /// Whether `language_id` currently has at least one live-or-configured
674    /// route (a catch-all or an explicit claim), used to distinguish
675    /// `NoServerForTool` (some server handles this language, just not this
676    /// tool) from `NoServerForLanguage` (nothing does).
677    ///
678    /// Deliberately checks route *contents*, not just map-key presence: after
679    /// `rebind_to_registered` drops every route for a language whose sole
680    /// server failed to spawn, this must go back to `false` so that language
681    /// reports `NoServerForLanguage` exactly as it did before per-tool
682    /// routing existed, not `NoServerForTool`.
683    #[must_use]
684    pub fn has_language(&self, language_id: &str) -> bool {
685        self.by_language
686            .get(language_id)
687            .is_some_and(|r| r.default.is_some() || !r.explicit.is_empty())
688    }
689
690    /// Every language this router was built with, sorted, including
691    /// languages whose routes [`Self::rebind_to_registered`] has since
692    /// emptied -- so a language whose servers all failed to spawn is still
693    /// listed (and can be reported as having no server) rather than vanishing.
694    ///
695    /// # Examples
696    ///
697    /// ```
698    /// use mcpls_core::config::{LanguageId, ServerId, ToolRouter};
699    ///
700    /// let router = ToolRouter::catch_all([
701    ///     (ServerId::from_static("pyright"), LanguageId::from_static("python")),
702    ///     (ServerId::from_static("rust-analyzer"), LanguageId::from_static("rust")),
703    /// ]);
704    /// assert_eq!(router.configured_languages(), ["python", "rust"]);
705    /// ```
706    #[must_use]
707    pub fn configured_languages(&self) -> Vec<LanguageId> {
708        let mut languages: Vec<LanguageId> = self.by_language.keys().cloned().collect();
709        languages.sort_unstable();
710        languages
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use std::assert_matches;
717
718    use super::*;
719    use crate::config::{ServerCommand, TimeoutSecs};
720    use crate::error::Error;
721
722    #[test]
723    fn test_server_id_new_rejects_blank_and_deserialization_agrees() {
724        assert_eq!(ServerId::new(""), Err(InvalidServerId));
725        assert_eq!(ServerId::new("  \t"), Err(InvalidServerId));
726        assert_eq!("pyright".parse::<ServerId>().unwrap().as_str(), "pyright");
727        assert!(serde_json::from_str::<ServerId>("\" \"").is_err());
728        assert_eq!(
729            serde_json::from_str::<ServerId>("\"pylsp\"").unwrap(),
730            ServerId::from_static("pylsp")
731        );
732    }
733
734    #[test]
735    #[should_panic(expected = "server id must be ASCII and not blank")]
736    fn test_server_id_from_static_panics_on_blank() {
737        let _ = ServerId::from_static(" ");
738    }
739
740    #[test]
741    fn test_server_id_static_and_owned_compare_and_serialize_alike() {
742        let owned = ServerId::new("rust".to_owned()).unwrap();
743        assert_eq!(owned, ServerId::from_static("rust"));
744        assert_eq!(owned, "rust");
745        assert_eq!(serde_json::to_string(&owned).unwrap(), "\"rust\"");
746        assert_eq!(String::from(owned), "rust");
747    }
748
749    #[test]
750    fn test_tool_set_rejects_empty_and_duplicates() {
751        assert_eq!(ToolSet::new(vec![]), Err(InvalidToolSet::Empty));
752        assert_eq!(
753            ToolSet::new(vec![ToolKind::Hover, ToolKind::Rename, ToolKind::Hover]),
754            Err(InvalidToolSet::Duplicate(ToolKind::Hover))
755        );
756    }
757
758    #[test]
759    fn test_tool_set_preserves_order_and_round_trips() {
760        let set = ToolSet::new(vec![ToolKind::Rename, ToolKind::Hover]).unwrap();
761        assert_eq!(
762            set.iter().copied().collect::<Vec<_>>(),
763            [ToolKind::Rename, ToolKind::Hover]
764        );
765        assert!(set.contains(ToolKind::Hover));
766        assert!(!set.contains(ToolKind::Definition));
767        let json = serde_json::to_string(&set).unwrap();
768        assert_eq!(json, r#"["rename","hover"]"#);
769        assert_eq!(serde_json::from_str::<ToolSet>(&json).unwrap(), set);
770        assert!(serde_json::from_str::<ToolSet>("[]").is_err());
771    }
772
773    fn cfg(
774        language_id: &str,
775        name: Option<&str>,
776        handles: Option<Vec<ToolKind>>,
777    ) -> LspServerConfig {
778        LspServerConfig {
779            language_id: LanguageId::new(language_id).unwrap(),
780            command: ServerCommand::from_static("cmd").into(),
781            args: crate::config::ServerArgs::default(),
782            env: crate::config::ServerEnv::default(),
783            file_patterns: vec![],
784            initialization_options: None,
785            settings: None,
786            timeout_seconds: TimeoutSecs::new(30).unwrap(),
787            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
788            heuristics: None,
789            name: name.map(|id| ServerId::new(id).unwrap()),
790            handles: handles.map(|tools| ToolSet::new(tools).unwrap()),
791            indexing: crate::bridge::IndexingPolicy::Auto,
792        }
793    }
794
795    #[test]
796    fn test_resolve_explicit_wins_over_catch_all() {
797        let configs = vec![
798            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
799            cfg("python", Some("pylsp"), None),
800        ];
801        let router = ToolRouter::from_configs(&configs).unwrap();
802        assert_eq!(
803            router.resolve("python", ToolKind::Hover),
804            Some(&ServerId::from_static("pyright"))
805        );
806        assert_eq!(
807            router.resolve("python", ToolKind::Diagnostics),
808            Some(&ServerId::from_static("pylsp"))
809        );
810    }
811
812    #[test]
813    fn test_resolve_no_catch_all_unclaimed_is_none() {
814        let configs = vec![cfg("python", Some("pyright"), Some(vec![ToolKind::Hover]))];
815        let router = ToolRouter::from_configs(&configs).unwrap();
816        assert_eq!(router.resolve("python", ToolKind::Diagnostics), None);
817    }
818
819    #[test]
820    fn test_resolve_any_explicit_claimer_beats_catch_all_declared_first() {
821        let configs = vec![
822            cfg("python", Some("python-narrow"), Some(vec![ToolKind::Hover])),
823            cfg("rust", Some("rust-catch-all"), None),
824        ];
825        let router = ToolRouter::from_configs(&configs).unwrap();
826        // Neither server explicitly claims WorkspaceSymbols, so the rust
827        // catch-all must win over the narrowly-scoped python server, even
828        // though python was declared first.
829        assert_eq!(
830            router.resolve_any(ToolKind::WorkspaceSymbols),
831            Ok(&ServerId::from_static("rust-catch-all"))
832        );
833    }
834
835    #[test]
836    fn test_resolve_any_prefers_explicit_claimer_over_catch_all() {
837        let configs = vec![
838            cfg("rust", Some("rust-catch-all"), None),
839            cfg(
840                "python",
841                Some("python-explicit"),
842                Some(vec![ToolKind::WorkspaceSymbols]),
843            ),
844        ];
845        let router = ToolRouter::from_configs(&configs).unwrap();
846        assert_eq!(
847            router.resolve_any(ToolKind::WorkspaceSymbols),
848            Ok(&ServerId::from_static("python-explicit"))
849        );
850    }
851
852    #[test]
853    fn test_from_configs_rejects_duplicate_server_id_across_languages() {
854        let configs = vec![
855            cfg("python", None, None),
856            cfg("typescript", Some("python"), None),
857        ];
858        let err = ToolRouter::from_configs(&configs).unwrap_err();
859        assert_matches!(err, Error::Config(ConfigError::DuplicateServerId { .. }));
860    }
861
862    #[test]
863    fn test_from_configs_duplicate_server_id_error_distinguishes_entries() {
864        // Two `[[lsp_servers]]` entries sharing `language_id = "rust"` with
865        // neither setting `name`: both resolve to the same ServerId, which
866        // used to make the error message name both conflicting halves
867        // identically ("used by both the 'rust' and 'rust' language
868        // entries"). The message must let a user tell the two entries apart.
869        let configs = vec![
870            LspServerConfig {
871                language_id: LanguageId::from_static("rust"),
872                command: ServerCommand::from_static("rust-analyzer").into(),
873                args: crate::config::ServerArgs::default(),
874                env: crate::config::ServerEnv::default(),
875                file_patterns: vec![],
876                initialization_options: None,
877                settings: None,
878                timeout_seconds: TimeoutSecs::new(30).unwrap(),
879                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
880                heuristics: None,
881                name: None,
882                handles: None,
883                indexing: crate::bridge::IndexingPolicy::Auto,
884            },
885            LspServerConfig {
886                language_id: LanguageId::from_static("rust"),
887                command: ServerCommand::from_static("rust-analyzer").into(),
888                args: crate::config::ServerArgs::try_from(vec![
889                    "--dummy-second-instance".to_string(),
890                ])
891                .unwrap(),
892                env: crate::config::ServerEnv::default(),
893                file_patterns: vec![],
894                initialization_options: None,
895                settings: None,
896                timeout_seconds: TimeoutSecs::new(30).unwrap(),
897                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
898                heuristics: None,
899                name: None,
900                handles: None,
901                indexing: crate::bridge::IndexingPolicy::Auto,
902            },
903        ];
904        let err = ToolRouter::from_configs(&configs).unwrap_err();
905        let Error::Config(config_err) = err else {
906            panic!("expected Config, got {err:?}");
907        };
908        let msg = config_err.to_string();
909        // Must not print a positional index: `from_configs` only ever sees
910        // the post-heuristics applicable subset, so any "entry #N" would
911        // usually name the wrong `[[lsp_servers]]` array position.
912        assert!(!msg.contains("entry #"), "message was: {msg}");
913        assert!(msg.contains("rust-analyzer"), "message was: {msg}");
914        assert!(msg.contains(", 1 arg"), "message was: {msg}");
915        assert!(!msg.contains("1 args"), "message was: {msg}");
916        assert!(
917            !msg.contains("--dummy-second-instance"),
918            "message was: {msg}"
919        );
920    }
921
922    #[test]
923    fn test_entry_summary_pluralises_the_argument_count() {
924        let summary = |arg_count| EntrySummary {
925            language: LanguageId::from_static("rust"),
926            command: ServerCommand::from_static("ra").into(),
927            arg_count,
928        };
929        assert_eq!(summary(0).to_string(), "language 'rust', command 'ra'");
930        assert_eq!(
931            summary(1).to_string(),
932            "language 'rust', command 'ra', 1 arg"
933        );
934        assert_eq!(
935            summary(2).to_string(),
936            "language 'rust', command 'ra', 2 args"
937        );
938    }
939
940    #[test]
941    fn test_from_configs_duplicate_server_id_error_never_prints_arguments() {
942        let secret_args = |args: &[&str]| {
943            crate::config::ServerArgs::try_from(
944                args.iter()
945                    .map(|arg| (*arg).to_string())
946                    .collect::<Vec<_>>(),
947            )
948            .unwrap()
949        };
950        let mut first = cfg("rust", None, None);
951        first.args = secret_args(&["--stdio"]);
952        let mut second = cfg("rust", None, None);
953        second.args = secret_args(&["--api-key=SECRET"]);
954        let err = ToolRouter::from_configs(&[first, second]).unwrap_err();
955        for rendered in [
956            format!("{err}"),
957            format!("{err:?}"),
958            crate::error::ErrorChain(&err).to_string(),
959        ] {
960            assert!(!rendered.contains("SECRET"), "leaked: {rendered}");
961        }
962    }
963
964    #[test]
965    fn test_from_configs_duplicate_server_id_error_identical_entries_still_reports() {
966        // When two colliding entries are identical in every visible field,
967        // there's nothing left to distinguish them by; the message should
968        // still name the collision (both halves read the same) rather than
969        // fabricate a misleading index.
970        let configs = vec![cfg("rust", None, None), cfg("rust", None, None)];
971        let err = ToolRouter::from_configs(&configs).unwrap_err();
972        let Error::Config(config_err) = err else {
973            panic!("expected Config, got {err:?}");
974        };
975        let msg = config_err.to_string();
976        assert!(!msg.contains("entry #"), "message was: {msg}");
977        assert!(
978            msg.contains("duplicate server id 'rust'"),
979            "message was: {msg}"
980        );
981    }
982
983    #[test]
984    fn test_from_configs_rejects_two_catch_alls() {
985        let configs = vec![
986            cfg("python", Some("a"), None),
987            cfg("python", Some("b"), None),
988        ];
989        let err = ToolRouter::from_configs(&configs).unwrap_err();
990        assert_matches!(err, Error::Config(ConfigError::TwoCatchAllServers { .. }));
991    }
992
993    #[test]
994    fn test_from_configs_rejects_duplicate_tool_claim() {
995        let configs = vec![
996            cfg("python", Some("a"), Some(vec![ToolKind::Hover])),
997            cfg("python", Some("b"), Some(vec![ToolKind::Hover])),
998        ];
999        let err = ToolRouter::from_configs(&configs).unwrap_err();
1000        assert_matches!(err, Error::Config(ConfigError::ToolClaimedTwice { .. }));
1001    }
1002
1003    #[test]
1004    fn test_rebind_to_registered_dead_server_with_live_catch_all() {
1005        let configs = vec![
1006            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
1007            cfg("python", Some("pylsp"), None),
1008        ];
1009        let mut router = ToolRouter::from_configs(&configs).unwrap();
1010        let registered: HashSet<ServerId> = HashSet::from([ServerId::from_static("pylsp")]);
1011        router.rebind_to_registered(&registered);
1012
1013        assert_eq!(
1014            router.resolve("python", ToolKind::Hover),
1015            Some(&ServerId::from_static("pylsp"))
1016        );
1017    }
1018
1019    #[test]
1020    fn test_rebind_to_registered_dead_server_no_catch_all_drops_route() {
1021        let configs = vec![
1022            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
1023            cfg("python", Some("pylsp"), Some(vec![ToolKind::Diagnostics])),
1024        ];
1025        let mut router = ToolRouter::from_configs(&configs).unwrap();
1026        let registered: HashSet<ServerId> = HashSet::from([ServerId::from_static("pylsp")]);
1027        router.rebind_to_registered(&registered);
1028
1029        // pyright died, no catch-all exists, and pylsp never claimed Hover:
1030        // the route must drop rather than conscript pylsp.
1031        assert_eq!(router.resolve("python", ToolKind::Hover), None);
1032        assert_eq!(
1033            router.resolve("python", ToolKind::Diagnostics),
1034            Some(&ServerId::from_static("pylsp"))
1035        );
1036    }
1037
1038    #[test]
1039    fn test_rebind_to_registered_all_failed_drops_everything() {
1040        let configs = vec![cfg("rust", None, None)];
1041        let mut router = ToolRouter::from_configs(&configs).unwrap();
1042        router.rebind_to_registered(&HashSet::new());
1043        assert_eq!(router.resolve("rust", ToolKind::Hover), None);
1044        assert_eq!(
1045            router.resolve_any(ToolKind::Hover),
1046            Err(NoServerReason::NothingRegistered)
1047        );
1048        // A single-server-per-language config whose server fails to spawn
1049        // must report NoServerForLanguage upstream, not NoServerForTool --
1050        // has_language must go back to false once every route is dropped.
1051        assert!(!router.has_language("rust"));
1052    }
1053
1054    fn explicit_and_catch_all_router() -> ToolRouter {
1055        ToolRouter::from_configs(&[
1056            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
1057            cfg("python", Some("pylsp"), None),
1058        ])
1059        .unwrap()
1060    }
1061
1062    #[test]
1063    fn rebind_keeps_pending_routes_and_order() {
1064        let mut router = ToolRouter::from_configs(&[
1065            cfg("rust", Some("slow"), None),
1066            cfg("python", Some("fast"), None),
1067        ])
1068        .unwrap();
1069
1070        router.rebind(|id| {
1071            if id.as_str() == "fast" {
1072                ServerSettlement::Registered
1073            } else {
1074                ServerSettlement::Pending
1075            }
1076        });
1077
1078        assert_eq!(
1079            router.resolve("rust", ToolKind::Hover),
1080            Some(&ServerId::from_static("slow"))
1081        );
1082        assert_eq!(
1083            router.resolve_any(ToolKind::Hover),
1084            Ok(&ServerId::from_static("slow"))
1085        );
1086    }
1087
1088    #[test]
1089    fn dead_explicit_route_waits_for_pending_catch_all_and_drops_when_it_fails() {
1090        let settle = |pyright, pylsp| {
1091            let mut router = explicit_and_catch_all_router();
1092            router.rebind(|id| {
1093                if id.as_str() == "pyright" {
1094                    pyright
1095                } else {
1096                    pylsp
1097                }
1098            });
1099            router.resolve("python", ToolKind::Hover).cloned()
1100        };
1101
1102        let pending_catch_all = settle(ServerSettlement::Failed, ServerSettlement::Pending);
1103        assert_eq!(pending_catch_all, Some(ServerId::from_static("pyright")));
1104        let registered_catch_all = settle(ServerSettlement::Failed, ServerSettlement::Registered);
1105        assert_eq!(registered_catch_all, Some(ServerId::from_static("pylsp")));
1106        let failed_catch_all = settle(ServerSettlement::Failed, ServerSettlement::Failed);
1107        assert_eq!(failed_catch_all, None);
1108    }
1109
1110    #[test]
1111    fn rebind_without_pending_servers_matches_the_batch_rule_for_every_outcome() {
1112        for pyright_up in [false, true] {
1113            for pylsp_up in [false, true] {
1114                let registered: HashSet<ServerId> = [("pyright", pyright_up), ("pylsp", pylsp_up)]
1115                    .into_iter()
1116                    .filter(|(_, up)| *up)
1117                    .map(|(id, _)| ServerId::new(id).unwrap())
1118                    .collect();
1119                let mut router = explicit_and_catch_all_router();
1120                router.rebind_to_registered(&registered);
1121
1122                let hover = if pyright_up {
1123                    Some("pyright")
1124                } else if pylsp_up {
1125                    Some("pylsp")
1126                } else {
1127                    None
1128                };
1129                assert_eq!(
1130                    router
1131                        .resolve("python", ToolKind::Hover)
1132                        .map(ServerId::as_str),
1133                    hover,
1134                    "pyright up: {pyright_up}, pylsp up: {pylsp_up}"
1135                );
1136                assert_eq!(
1137                    router
1138                        .resolve("python", ToolKind::Diagnostics)
1139                        .map(ServerId::as_str),
1140                    pylsp_up.then_some("pylsp")
1141                );
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn test_rebind_prunes_order_for_resolve_any() {
1148        let configs = vec![cfg("rust", Some("a"), None), cfg("python", Some("b"), None)];
1149        let mut router = ToolRouter::from_configs(&configs).unwrap();
1150        let registered: HashSet<ServerId> = HashSet::from([ServerId::from_static("b")]);
1151        router.rebind_to_registered(&registered);
1152        assert_eq!(
1153            router.resolve_any(ToolKind::Hover),
1154            Ok(&ServerId::from_static("b"))
1155        );
1156    }
1157
1158    #[test]
1159    fn test_resolve_any_no_claimant_does_not_fall_back_to_arbitrary_server() {
1160        // A single narrowly-scoped server that does not claim WorkspaceSymbols
1161        // and has no catch-all anywhere must not be silently conscripted for
1162        // it -- that would violate its explicit `handles` declaration.
1163        let configs = vec![cfg("python", Some("pyright"), Some(vec![ToolKind::Hover]))];
1164        let router = ToolRouter::from_configs(&configs).unwrap();
1165        assert_eq!(
1166            router.resolve_any(ToolKind::WorkspaceSymbols),
1167            Err(NoServerReason::NoClaimant)
1168        );
1169    }
1170
1171    #[test]
1172    fn test_has_language() {
1173        let configs = vec![cfg("rust", None, None)];
1174        let router = ToolRouter::from_configs(&configs).unwrap();
1175        assert!(router.has_language("rust"));
1176        assert!(!router.has_language("python"));
1177    }
1178
1179    #[test]
1180    fn test_catch_all_helper_registers_two_entries() {
1181        let router = ToolRouter::catch_all([
1182            (
1183                ServerId::from_static("ts"),
1184                LanguageId::from_static("typescript"),
1185            ),
1186            (
1187                ServerId::from_static("tsx"),
1188                LanguageId::from_static("typescriptreact"),
1189            ),
1190        ]);
1191        assert_eq!(
1192            router.resolve("typescript", ToolKind::Hover),
1193            Some(&ServerId::from_static("ts"))
1194        );
1195        assert_eq!(
1196            router.resolve("typescriptreact", ToolKind::Hover),
1197            Some(&ServerId::from_static("tsx"))
1198        );
1199    }
1200
1201    #[test]
1202    fn test_tool_kind_as_str_and_all_len() {
1203        assert_eq!(ToolKind::Hover.as_str(), "hover");
1204        assert_eq!(ToolKind::CallHierarchy.as_str(), "call_hierarchy");
1205        assert_eq!(ToolKind::ALL.len(), 21);
1206
1207        // `ALL` is a slice now, so nothing pins its element count at compile
1208        // time the way `[Self; 15]` used to -- guard against duplicate or
1209        // missing entries at runtime instead.
1210        let unique_names: std::collections::HashSet<&str> =
1211            ToolKind::ALL.iter().map(ToolKind::as_str).collect();
1212        assert_eq!(unique_names.len(), ToolKind::ALL.len());
1213    }
1214
1215    #[test]
1216    fn test_server_id_display_and_as_str() {
1217        let id = ServerId::from_static("pyright");
1218        assert_eq!(id.as_str(), "pyright");
1219        assert_eq!(id.to_string(), "pyright");
1220    }
1221}