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`/`args` 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///     args: vec![],
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    /// Arguments passed to the executable.
319    pub args: Vec<String>,
320}
321
322impl EntrySummary {
323    fn of(cfg: &LspServerConfig) -> Self {
324        Self {
325            language: cfg.language_id.clone(),
326            command: cfg.command.clone(),
327            args: cfg.args.clone(),
328        }
329    }
330}
331
332impl fmt::Display for EntrySummary {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        write!(
335            f,
336            "language '{}', command '{}'",
337            self.language, self.command
338        )?;
339        if !self.args.is_empty() {
340            write!(f, ", args {:?}", self.args)?;
341        }
342        Ok(())
343    }
344}
345
346/// Per-language routing table: which server handles which tool.
347#[derive(Debug, Default, Clone)]
348struct LanguageRoutes {
349    /// Tools explicitly claimed via a server's `handles` list.
350    explicit: HashMap<ToolKind, ServerId>,
351    /// The single server (if any) that omitted `handles` — serves every
352    /// tool not explicitly claimed by another server for this language.
353    default: Option<ServerId>,
354}
355
356/// Why [`ToolRouter::resolve_any`] could not find a server for a
357/// workspace-wide tool.
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359#[non_exhaustive]
360pub enum NoServerReason {
361    /// No server is registered in this workspace at all. Reflects what has
362    /// *registered* (i.e. finished spawning), not what is configured in
363    /// `mcpls.toml` — a server that is still initializing, or one that was
364    /// configured but failed to spawn, is indistinguishable from "nothing
365    /// configured" at this layer. Callers with access to the set of servers
366    /// still expected to register (e.g. an expected server slot) can
367    /// tell these apart.
368    NothingRegistered,
369    /// At least one server is registered, but none explicitly claims the
370    /// requested tool and none is a catch-all.
371    NoClaimant,
372}
373
374/// How far one configured server's startup has progressed, as seen by
375/// [`ToolRouter::rebind`].
376///
377/// # Examples
378///
379/// ```
380/// use mcpls_core::config::{LanguageId, ServerId, ServerSettlement, ToolKind, ToolRouter};
381///
382/// let mut router = ToolRouter::catch_all([(ServerId::from_static("pyright"), LanguageId::from_static("python"))]);
383/// router.rebind(|_| ServerSettlement::Failed);
384/// assert!(router.resolve("python", ToolKind::Hover).is_none());
385/// ```
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum ServerSettlement {
388    /// Still initializing: its routes stay as configured.
389    Pending,
390    /// Initialized and registered.
391    Registered,
392    /// Failed to start: routes naming it are redirected or dropped.
393    Failed,
394}
395
396/// Resolves `(language, tool)` to the [`ServerId`] that should handle it.
397///
398/// Built once at startup by [`Self::from_configs`] over the *applicable*
399/// (post-heuristics) server configs. The table in use is derived from that
400/// immutable original by [`Self::rebind`] each time a server settles, so no
401/// route ever points at a server known to have failed to spawn.
402#[derive(Debug, Default, Clone)]
403pub struct ToolRouter {
404    by_language: HashMap<LanguageId, LanguageRoutes>,
405    /// Config declaration order, used by `resolve_any` for a deterministic
406    /// choice among candidates. Pruned to non-failed servers by `rebind`.
407    order: Vec<ServerId>,
408}
409
410impl ToolRouter {
411    /// Build a router from the configs applicable in this workspace,
412    /// enforcing the workspace-scoped validation rules:
413    ///
414    /// 1. No two applicable servers (in any language) may share a
415    ///    [`ServerId`] — it is the key of every map keyed by server identity.
416    /// 2. No two applicable servers for one language may both omit `handles`
417    ///    (two catch-alls).
418    /// 3. No tool may be claimed via `handles` by two applicable servers of
419    ///    the same language.
420    ///
421    /// Also emits a `tracing::warn!` for any language whose union of
422    /// `handles` claims is partial and has no catch-all server, naming the
423    /// tools nobody will serve.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`crate::error::Error::Config`] naming the conflicting entries if any
428    /// of the three rules above is violated.
429    pub fn from_configs<'a, I>(cfgs: I) -> Result<Self>
430    where
431        I: IntoIterator<Item = &'a LspServerConfig>,
432    {
433        let mut by_language: HashMap<LanguageId, LanguageRoutes> = HashMap::new();
434        let mut order: Vec<ServerId> = Vec::new();
435        let mut seen_ids: HashMap<ServerId, &LspServerConfig> = HashMap::new();
436
437        for cfg in cfgs {
438            let id = cfg.id();
439
440            if let Some(previous) = seen_ids.get(&id) {
441                return Err(ConfigError::DuplicateServerId {
442                    id,
443                    first: Box::new(EntrySummary::of(previous)),
444                    second: Box::new(EntrySummary::of(cfg)),
445                }
446                .into());
447            }
448            seen_ids.insert(id.clone(), cfg);
449            order.push(id.clone());
450
451            let routes = by_language.entry(cfg.language_id.clone()).or_default();
452
453            match &cfg.handles {
454                None => {
455                    if let Some(existing) = &routes.default {
456                        return Err(ConfigError::TwoCatchAllServers {
457                            language: cfg.language_id.clone(),
458                            existing: existing.clone(),
459                            id,
460                        }
461                        .into());
462                    }
463                    routes.default = Some(id);
464                }
465                Some(tools) => {
466                    for tool in tools {
467                        if let Some(existing) = routes.explicit.get(tool) {
468                            return Err(ConfigError::ToolClaimedTwice {
469                                tool: *tool,
470                                language: cfg.language_id.clone(),
471                                existing: existing.clone(),
472                                id,
473                            }
474                            .into());
475                        }
476                        routes.explicit.insert(*tool, id.clone());
477                    }
478                }
479            }
480        }
481
482        // Deliberately untested (M4): asserting on `tracing` output would
483        // need a subscriber/capture dev-dependency this crate doesn't
484        // otherwise pull in. Verified by inspection instead; the `uncovered`
485        // computation itself is exercised indirectly by every `resolve`
486        // test above that checks an unclaimed tool returns `None`.
487        for (language, routes) in &by_language {
488            if routes.default.is_none() {
489                let uncovered: Vec<&str> = ToolKind::ALL
490                    .iter()
491                    .filter(|t| !routes.explicit.contains_key(t))
492                    .map(ToolKind::as_str)
493                    .collect();
494                if !uncovered.is_empty() {
495                    tracing::warn!(
496                        "language '{language}' has no catch-all server and does not claim: {}",
497                        uncovered.join(", ")
498                    );
499                }
500            }
501        }
502
503        Ok(Self { by_language, order })
504    }
505
506    /// Build a router where every entry is a catch-all for its language.
507    ///
508    /// Test helper: takes `(id, language)` pairs rather than a single entry
509    /// because some tests (e.g. the `typescript`/`typescriptreact` exact-match
510    /// preference) need two catch-alls registered at once.
511    #[must_use]
512    pub fn catch_all<I>(entries: I) -> Self
513    where
514        I: IntoIterator<Item = (ServerId, LanguageId)>,
515    {
516        let mut by_language: HashMap<LanguageId, LanguageRoutes> = HashMap::new();
517        let mut order = Vec::new();
518        for (id, language) in entries {
519            order.push(id.clone());
520            by_language.entry(language).or_default().default = Some(id);
521        }
522        Self { by_language, order }
523    }
524
525    /// Derives the active routes from the configured ones and the current
526    /// [`ServerSettlement`] of every server; a pure function of those, so the
527    /// result never depends on the order in which servers settled, and once
528    /// none is `Pending` it equals what a single batch rebind produces.
529    ///
530    /// Per language:
531    ///
532    /// - an explicit route to a `Registered` or `Pending` server is kept;
533    /// - an explicit route to a `Failed` server is rebound to the language's
534    ///   catch-all when that is `Registered`, kept pointing at the failed
535    ///   server while the catch-all is `Pending` (the route is never bound to
536    ///   a server that has not registered), and dropped when the catch-all is
537    ///   `Failed` or absent;
538    /// - a `Failed` catch-all is dropped;
539    /// - a dead route is never rebound to a *narrowly-scoped* live server: a
540    ///   server that declared `handles = [...]` has explicitly declined every
541    ///   other tool, and conscripting it would override that declaration (and,
542    ///   via the diagnostics cache filter, start caching diagnostics the user
543    ///   deliberately routed away).
544    ///
545    /// The server order used by [`Self::resolve_any`] keeps every server that
546    /// is not `Failed`, so a pending server's position is preserved.
547    ///
548    /// Silent: callers log the server that just settled, not every earlier
549    /// failure again.
550    pub fn rebind(&mut self, settlement: impl Fn(&ServerId) -> ServerSettlement) {
551        for routes in self.by_language.values_mut() {
552            let catch_all = routes.default.clone().map(|id| (settlement(&id), id));
553
554            let dead: Vec<ToolKind> = routes
555                .explicit
556                .iter()
557                .filter(|(_, id)| settlement(id) == ServerSettlement::Failed)
558                .map(|(tool, _)| *tool)
559                .collect();
560            for tool in dead {
561                match &catch_all {
562                    Some((ServerSettlement::Registered, id)) => {
563                        routes.explicit.insert(tool, id.clone());
564                    }
565                    Some((ServerSettlement::Pending, _)) => {}
566                    Some((ServerSettlement::Failed, _)) | None => {
567                        routes.explicit.remove(&tool);
568                    }
569                }
570            }
571
572            if matches!(catch_all, Some((ServerSettlement::Failed, _))) {
573                routes.default = None;
574            }
575        }
576
577        self.order
578            .retain(|id| settlement(id) != ServerSettlement::Failed);
579    }
580
581    /// [`Self::rebind`] for a finished startup: every server in `registered`
582    /// is `Registered`, every other one `Failed`.
583    pub fn rebind_to_registered(&mut self, registered: &HashSet<ServerId>) {
584        self.rebind(|id| {
585            if registered.contains(id) {
586                ServerSettlement::Registered
587            } else {
588                ServerSettlement::Failed
589            }
590        });
591    }
592
593    /// The configured languages with a route (explicit or catch-all) naming
594    /// `id`, sorted.
595    #[must_use]
596    pub fn languages_routed_to(&self, id: &ServerId) -> Vec<LanguageId> {
597        let mut languages: Vec<LanguageId> = self
598            .by_language
599            .iter()
600            .filter(|(_, routes)| {
601                routes.default.as_ref() == Some(id) || routes.explicit.values().any(|v| v == id)
602            })
603            .map(|(language, _)| language.clone())
604            .collect();
605        languages.sort_unstable();
606        languages
607    }
608
609    /// The catch-all server configured for `language_id`, if any.
610    #[must_use]
611    pub fn catch_all_for(&self, language_id: &str) -> Option<&ServerId> {
612        self.by_language.get(language_id)?.default.as_ref()
613    }
614
615    /// Resolve the server that should handle `tool` for `language_id`.
616    ///
617    /// Explicit claims win over the language's catch-all; if neither exists,
618    /// returns `None`.
619    #[must_use]
620    pub fn resolve(&self, language_id: &str, tool: ToolKind) -> Option<&ServerId> {
621        let routes = self.by_language.get(language_id)?;
622        routes.explicit.get(&tool).or(routes.default.as_ref())
623    }
624
625    /// Resolve a server for `tool` without a specific language — used for
626    /// workspace-wide tools like `workspace_symbol_search` that have no
627    /// document to detect a language from.
628    ///
629    /// Resolves in two tiers, in config declaration order:
630    /// 1. the first server that explicitly claims `tool`;
631    /// 2. else the first catch-all server.
632    ///
633    /// Deliberately does *not* fall back to "the first server at all" when
634    /// neither tier matches: a server with a `handles` list has explicitly
635    /// declined every tool not on it, so forwarding an unclaimed workspace-wide
636    /// tool to it anyway would silently violate that declaration. Callers get
637    /// [`NoServerReason`] instead, distinguishing "nothing configured" from
638    /// "something is configured but nothing claims this tool" so they can
639    /// report a precise error rather than defaulting to an arbitrary server.
640    ///
641    /// # Errors
642    ///
643    /// Returns [`NoServerReason::NothingRegistered`] if no server is
644    /// registered at all, or [`NoServerReason::NoClaimant`] if servers are
645    /// registered but none explicitly claims `tool` and none is a catch-all.
646    pub fn resolve_any(&self, tool: ToolKind) -> std::result::Result<&ServerId, NoServerReason> {
647        let claims_explicitly = |id: &ServerId| {
648            self.by_language
649                .values()
650                .any(|r| r.explicit.get(&tool) == Some(id))
651        };
652        let is_catch_all = |id: &ServerId| {
653            self.by_language
654                .values()
655                .any(|r| r.default.as_ref() == Some(id))
656        };
657
658        self.order
659            .iter()
660            .find(|id| claims_explicitly(id))
661            .or_else(|| self.order.iter().find(|id| is_catch_all(id)))
662            .ok_or(if self.order.is_empty() {
663                NoServerReason::NothingRegistered
664            } else {
665                NoServerReason::NoClaimant
666            })
667    }
668
669    /// Whether `language_id` currently has at least one live-or-configured
670    /// route (a catch-all or an explicit claim), used to distinguish
671    /// `NoServerForTool` (some server handles this language, just not this
672    /// tool) from `NoServerForLanguage` (nothing does).
673    ///
674    /// Deliberately checks route *contents*, not just map-key presence: after
675    /// `rebind_to_registered` drops every route for a language whose sole
676    /// server failed to spawn, this must go back to `false` so that language
677    /// reports `NoServerForLanguage` exactly as it did before per-tool
678    /// routing existed, not `NoServerForTool`.
679    #[must_use]
680    pub fn has_language(&self, language_id: &str) -> bool {
681        self.by_language
682            .get(language_id)
683            .is_some_and(|r| r.default.is_some() || !r.explicit.is_empty())
684    }
685
686    /// Every language this router was built with, sorted, including
687    /// languages whose routes [`Self::rebind_to_registered`] has since
688    /// emptied -- so a language whose servers all failed to spawn is still
689    /// listed (and can be reported as having no server) rather than vanishing.
690    ///
691    /// # Examples
692    ///
693    /// ```
694    /// use mcpls_core::config::{LanguageId, ServerId, ToolRouter};
695    ///
696    /// let router = ToolRouter::catch_all([
697    ///     (ServerId::from_static("pyright"), LanguageId::from_static("python")),
698    ///     (ServerId::from_static("rust-analyzer"), LanguageId::from_static("rust")),
699    /// ]);
700    /// assert_eq!(router.configured_languages(), ["python", "rust"]);
701    /// ```
702    #[must_use]
703    pub fn configured_languages(&self) -> Vec<LanguageId> {
704        let mut languages: Vec<LanguageId> = self.by_language.keys().cloned().collect();
705        languages.sort_unstable();
706        languages
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use std::assert_matches;
713
714    use super::*;
715    use crate::config::{ServerCommand, TimeoutSecs};
716    use crate::error::Error;
717
718    #[test]
719    fn test_server_id_new_rejects_blank_and_deserialization_agrees() {
720        assert_eq!(ServerId::new(""), Err(InvalidServerId));
721        assert_eq!(ServerId::new("  \t"), Err(InvalidServerId));
722        assert_eq!("pyright".parse::<ServerId>().unwrap().as_str(), "pyright");
723        assert!(serde_json::from_str::<ServerId>("\" \"").is_err());
724        assert_eq!(
725            serde_json::from_str::<ServerId>("\"pylsp\"").unwrap(),
726            ServerId::from_static("pylsp")
727        );
728    }
729
730    #[test]
731    #[should_panic(expected = "server id must be ASCII and not blank")]
732    fn test_server_id_from_static_panics_on_blank() {
733        let _ = ServerId::from_static(" ");
734    }
735
736    #[test]
737    fn test_server_id_static_and_owned_compare_and_serialize_alike() {
738        let owned = ServerId::new("rust".to_owned()).unwrap();
739        assert_eq!(owned, ServerId::from_static("rust"));
740        assert_eq!(owned, "rust");
741        assert_eq!(serde_json::to_string(&owned).unwrap(), "\"rust\"");
742        assert_eq!(String::from(owned), "rust");
743    }
744
745    #[test]
746    fn test_tool_set_rejects_empty_and_duplicates() {
747        assert_eq!(ToolSet::new(vec![]), Err(InvalidToolSet::Empty));
748        assert_eq!(
749            ToolSet::new(vec![ToolKind::Hover, ToolKind::Rename, ToolKind::Hover]),
750            Err(InvalidToolSet::Duplicate(ToolKind::Hover))
751        );
752    }
753
754    #[test]
755    fn test_tool_set_preserves_order_and_round_trips() {
756        let set = ToolSet::new(vec![ToolKind::Rename, ToolKind::Hover]).unwrap();
757        assert_eq!(
758            set.iter().copied().collect::<Vec<_>>(),
759            [ToolKind::Rename, ToolKind::Hover]
760        );
761        assert!(set.contains(ToolKind::Hover));
762        assert!(!set.contains(ToolKind::Definition));
763        let json = serde_json::to_string(&set).unwrap();
764        assert_eq!(json, r#"["rename","hover"]"#);
765        assert_eq!(serde_json::from_str::<ToolSet>(&json).unwrap(), set);
766        assert!(serde_json::from_str::<ToolSet>("[]").is_err());
767    }
768
769    fn cfg(
770        language_id: &str,
771        name: Option<&str>,
772        handles: Option<Vec<ToolKind>>,
773    ) -> LspServerConfig {
774        LspServerConfig {
775            language_id: LanguageId::new(language_id).unwrap(),
776            command: ServerCommand::from_static("cmd").into(),
777            args: vec![],
778            env: crate::config::ServerEnv::default(),
779            file_patterns: vec![],
780            initialization_options: None,
781            settings: None,
782            timeout_seconds: TimeoutSecs::new(30).unwrap(),
783            request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
784            heuristics: None,
785            name: name.map(|id| ServerId::new(id).unwrap()),
786            handles: handles.map(|tools| ToolSet::new(tools).unwrap()),
787            indexing: crate::bridge::IndexingPolicy::Auto,
788        }
789    }
790
791    #[test]
792    fn test_resolve_explicit_wins_over_catch_all() {
793        let configs = vec![
794            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
795            cfg("python", Some("pylsp"), None),
796        ];
797        let router = ToolRouter::from_configs(&configs).unwrap();
798        assert_eq!(
799            router.resolve("python", ToolKind::Hover),
800            Some(&ServerId::from_static("pyright"))
801        );
802        assert_eq!(
803            router.resolve("python", ToolKind::Diagnostics),
804            Some(&ServerId::from_static("pylsp"))
805        );
806    }
807
808    #[test]
809    fn test_resolve_no_catch_all_unclaimed_is_none() {
810        let configs = vec![cfg("python", Some("pyright"), Some(vec![ToolKind::Hover]))];
811        let router = ToolRouter::from_configs(&configs).unwrap();
812        assert_eq!(router.resolve("python", ToolKind::Diagnostics), None);
813    }
814
815    #[test]
816    fn test_resolve_any_explicit_claimer_beats_catch_all_declared_first() {
817        let configs = vec![
818            cfg("python", Some("python-narrow"), Some(vec![ToolKind::Hover])),
819            cfg("rust", Some("rust-catch-all"), None),
820        ];
821        let router = ToolRouter::from_configs(&configs).unwrap();
822        // Neither server explicitly claims WorkspaceSymbols, so the rust
823        // catch-all must win over the narrowly-scoped python server, even
824        // though python was declared first.
825        assert_eq!(
826            router.resolve_any(ToolKind::WorkspaceSymbols),
827            Ok(&ServerId::from_static("rust-catch-all"))
828        );
829    }
830
831    #[test]
832    fn test_resolve_any_prefers_explicit_claimer_over_catch_all() {
833        let configs = vec![
834            cfg("rust", Some("rust-catch-all"), None),
835            cfg(
836                "python",
837                Some("python-explicit"),
838                Some(vec![ToolKind::WorkspaceSymbols]),
839            ),
840        ];
841        let router = ToolRouter::from_configs(&configs).unwrap();
842        assert_eq!(
843            router.resolve_any(ToolKind::WorkspaceSymbols),
844            Ok(&ServerId::from_static("python-explicit"))
845        );
846    }
847
848    #[test]
849    fn test_from_configs_rejects_duplicate_server_id_across_languages() {
850        let configs = vec![
851            cfg("python", None, None),
852            cfg("typescript", Some("python"), None),
853        ];
854        let err = ToolRouter::from_configs(&configs).unwrap_err();
855        assert_matches!(err, Error::Config(ConfigError::DuplicateServerId { .. }));
856    }
857
858    #[test]
859    fn test_from_configs_duplicate_server_id_error_distinguishes_entries() {
860        // Two `[[lsp_servers]]` entries sharing `language_id = "rust"` with
861        // neither setting `name`: both resolve to the same ServerId, which
862        // used to make the error message name both conflicting halves
863        // identically ("used by both the 'rust' and 'rust' language
864        // entries"). The message must let a user tell the two entries apart.
865        let configs = vec![
866            LspServerConfig {
867                language_id: LanguageId::from_static("rust"),
868                command: ServerCommand::from_static("rust-analyzer").into(),
869                args: vec![],
870                env: crate::config::ServerEnv::default(),
871                file_patterns: vec![],
872                initialization_options: None,
873                settings: None,
874                timeout_seconds: TimeoutSecs::new(30).unwrap(),
875                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
876                heuristics: None,
877                name: None,
878                handles: None,
879                indexing: crate::bridge::IndexingPolicy::Auto,
880            },
881            LspServerConfig {
882                language_id: LanguageId::from_static("rust"),
883                command: ServerCommand::from_static("rust-analyzer").into(),
884                args: vec!["--dummy-second-instance".to_string()],
885                env: crate::config::ServerEnv::default(),
886                file_patterns: vec![],
887                initialization_options: None,
888                settings: None,
889                timeout_seconds: TimeoutSecs::new(30).unwrap(),
890                request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
891                heuristics: None,
892                name: None,
893                handles: None,
894                indexing: crate::bridge::IndexingPolicy::Auto,
895            },
896        ];
897        let err = ToolRouter::from_configs(&configs).unwrap_err();
898        let Error::Config(config_err) = err else {
899            panic!("expected Config, got {err:?}");
900        };
901        let msg = config_err.to_string();
902        // Must not print a positional index: `from_configs` only ever sees
903        // the post-heuristics applicable subset, so any "entry #N" would
904        // usually name the wrong `[[lsp_servers]]` array position.
905        assert!(!msg.contains("entry #"), "message was: {msg}");
906        assert!(msg.contains("rust-analyzer"), "message was: {msg}");
907        assert!(
908            msg.contains("--dummy-second-instance"),
909            "message was: {msg}"
910        );
911    }
912
913    #[test]
914    fn test_from_configs_duplicate_server_id_error_identical_entries_still_reports() {
915        // When two colliding entries are identical in every visible field,
916        // there's nothing left to distinguish them by; the message should
917        // still name the collision (both halves read the same) rather than
918        // fabricate a misleading index.
919        let configs = vec![cfg("rust", None, None), cfg("rust", None, None)];
920        let err = ToolRouter::from_configs(&configs).unwrap_err();
921        let Error::Config(config_err) = err else {
922            panic!("expected Config, got {err:?}");
923        };
924        let msg = config_err.to_string();
925        assert!(!msg.contains("entry #"), "message was: {msg}");
926        assert!(
927            msg.contains("duplicate server id 'rust'"),
928            "message was: {msg}"
929        );
930    }
931
932    #[test]
933    fn test_from_configs_rejects_two_catch_alls() {
934        let configs = vec![
935            cfg("python", Some("a"), None),
936            cfg("python", Some("b"), None),
937        ];
938        let err = ToolRouter::from_configs(&configs).unwrap_err();
939        assert_matches!(err, Error::Config(ConfigError::TwoCatchAllServers { .. }));
940    }
941
942    #[test]
943    fn test_from_configs_rejects_duplicate_tool_claim() {
944        let configs = vec![
945            cfg("python", Some("a"), Some(vec![ToolKind::Hover])),
946            cfg("python", Some("b"), Some(vec![ToolKind::Hover])),
947        ];
948        let err = ToolRouter::from_configs(&configs).unwrap_err();
949        assert_matches!(err, Error::Config(ConfigError::ToolClaimedTwice { .. }));
950    }
951
952    #[test]
953    fn test_rebind_to_registered_dead_server_with_live_catch_all() {
954        let configs = vec![
955            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
956            cfg("python", Some("pylsp"), None),
957        ];
958        let mut router = ToolRouter::from_configs(&configs).unwrap();
959        let registered: HashSet<ServerId> = HashSet::from([ServerId::from_static("pylsp")]);
960        router.rebind_to_registered(&registered);
961
962        assert_eq!(
963            router.resolve("python", ToolKind::Hover),
964            Some(&ServerId::from_static("pylsp"))
965        );
966    }
967
968    #[test]
969    fn test_rebind_to_registered_dead_server_no_catch_all_drops_route() {
970        let configs = vec![
971            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
972            cfg("python", Some("pylsp"), Some(vec![ToolKind::Diagnostics])),
973        ];
974        let mut router = ToolRouter::from_configs(&configs).unwrap();
975        let registered: HashSet<ServerId> = HashSet::from([ServerId::from_static("pylsp")]);
976        router.rebind_to_registered(&registered);
977
978        // pyright died, no catch-all exists, and pylsp never claimed Hover:
979        // the route must drop rather than conscript pylsp.
980        assert_eq!(router.resolve("python", ToolKind::Hover), None);
981        assert_eq!(
982            router.resolve("python", ToolKind::Diagnostics),
983            Some(&ServerId::from_static("pylsp"))
984        );
985    }
986
987    #[test]
988    fn test_rebind_to_registered_all_failed_drops_everything() {
989        let configs = vec![cfg("rust", None, None)];
990        let mut router = ToolRouter::from_configs(&configs).unwrap();
991        router.rebind_to_registered(&HashSet::new());
992        assert_eq!(router.resolve("rust", ToolKind::Hover), None);
993        assert_eq!(
994            router.resolve_any(ToolKind::Hover),
995            Err(NoServerReason::NothingRegistered)
996        );
997        // A single-server-per-language config whose server fails to spawn
998        // must report NoServerForLanguage upstream, not NoServerForTool --
999        // has_language must go back to false once every route is dropped.
1000        assert!(!router.has_language("rust"));
1001    }
1002
1003    fn explicit_and_catch_all_router() -> ToolRouter {
1004        ToolRouter::from_configs(&[
1005            cfg("python", Some("pyright"), Some(vec![ToolKind::Hover])),
1006            cfg("python", Some("pylsp"), None),
1007        ])
1008        .unwrap()
1009    }
1010
1011    #[test]
1012    fn rebind_keeps_pending_routes_and_order() {
1013        let mut router = ToolRouter::from_configs(&[
1014            cfg("rust", Some("slow"), None),
1015            cfg("python", Some("fast"), None),
1016        ])
1017        .unwrap();
1018
1019        router.rebind(|id| {
1020            if id.as_str() == "fast" {
1021                ServerSettlement::Registered
1022            } else {
1023                ServerSettlement::Pending
1024            }
1025        });
1026
1027        assert_eq!(
1028            router.resolve("rust", ToolKind::Hover),
1029            Some(&ServerId::from_static("slow"))
1030        );
1031        assert_eq!(
1032            router.resolve_any(ToolKind::Hover),
1033            Ok(&ServerId::from_static("slow"))
1034        );
1035    }
1036
1037    #[test]
1038    fn dead_explicit_route_waits_for_pending_catch_all_and_drops_when_it_fails() {
1039        let settle = |pyright, pylsp| {
1040            let mut router = explicit_and_catch_all_router();
1041            router.rebind(|id| {
1042                if id.as_str() == "pyright" {
1043                    pyright
1044                } else {
1045                    pylsp
1046                }
1047            });
1048            router.resolve("python", ToolKind::Hover).cloned()
1049        };
1050
1051        let pending_catch_all = settle(ServerSettlement::Failed, ServerSettlement::Pending);
1052        assert_eq!(pending_catch_all, Some(ServerId::from_static("pyright")));
1053        let registered_catch_all = settle(ServerSettlement::Failed, ServerSettlement::Registered);
1054        assert_eq!(registered_catch_all, Some(ServerId::from_static("pylsp")));
1055        let failed_catch_all = settle(ServerSettlement::Failed, ServerSettlement::Failed);
1056        assert_eq!(failed_catch_all, None);
1057    }
1058
1059    #[test]
1060    fn rebind_without_pending_servers_matches_the_batch_rule_for_every_outcome() {
1061        for pyright_up in [false, true] {
1062            for pylsp_up in [false, true] {
1063                let registered: HashSet<ServerId> = [("pyright", pyright_up), ("pylsp", pylsp_up)]
1064                    .into_iter()
1065                    .filter(|(_, up)| *up)
1066                    .map(|(id, _)| ServerId::new(id).unwrap())
1067                    .collect();
1068                let mut router = explicit_and_catch_all_router();
1069                router.rebind_to_registered(&registered);
1070
1071                let hover = if pyright_up {
1072                    Some("pyright")
1073                } else if pylsp_up {
1074                    Some("pylsp")
1075                } else {
1076                    None
1077                };
1078                assert_eq!(
1079                    router
1080                        .resolve("python", ToolKind::Hover)
1081                        .map(ServerId::as_str),
1082                    hover,
1083                    "pyright up: {pyright_up}, pylsp up: {pylsp_up}"
1084                );
1085                assert_eq!(
1086                    router
1087                        .resolve("python", ToolKind::Diagnostics)
1088                        .map(ServerId::as_str),
1089                    pylsp_up.then_some("pylsp")
1090                );
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn test_rebind_prunes_order_for_resolve_any() {
1097        let configs = vec![cfg("rust", Some("a"), None), cfg("python", Some("b"), None)];
1098        let mut router = ToolRouter::from_configs(&configs).unwrap();
1099        let registered: HashSet<ServerId> = HashSet::from([ServerId::from_static("b")]);
1100        router.rebind_to_registered(&registered);
1101        assert_eq!(
1102            router.resolve_any(ToolKind::Hover),
1103            Ok(&ServerId::from_static("b"))
1104        );
1105    }
1106
1107    #[test]
1108    fn test_resolve_any_no_claimant_does_not_fall_back_to_arbitrary_server() {
1109        // A single narrowly-scoped server that does not claim WorkspaceSymbols
1110        // and has no catch-all anywhere must not be silently conscripted for
1111        // it -- that would violate its explicit `handles` declaration.
1112        let configs = vec![cfg("python", Some("pyright"), Some(vec![ToolKind::Hover]))];
1113        let router = ToolRouter::from_configs(&configs).unwrap();
1114        assert_eq!(
1115            router.resolve_any(ToolKind::WorkspaceSymbols),
1116            Err(NoServerReason::NoClaimant)
1117        );
1118    }
1119
1120    #[test]
1121    fn test_has_language() {
1122        let configs = vec![cfg("rust", None, None)];
1123        let router = ToolRouter::from_configs(&configs).unwrap();
1124        assert!(router.has_language("rust"));
1125        assert!(!router.has_language("python"));
1126    }
1127
1128    #[test]
1129    fn test_catch_all_helper_registers_two_entries() {
1130        let router = ToolRouter::catch_all([
1131            (
1132                ServerId::from_static("ts"),
1133                LanguageId::from_static("typescript"),
1134            ),
1135            (
1136                ServerId::from_static("tsx"),
1137                LanguageId::from_static("typescriptreact"),
1138            ),
1139        ]);
1140        assert_eq!(
1141            router.resolve("typescript", ToolKind::Hover),
1142            Some(&ServerId::from_static("ts"))
1143        );
1144        assert_eq!(
1145            router.resolve("typescriptreact", ToolKind::Hover),
1146            Some(&ServerId::from_static("tsx"))
1147        );
1148    }
1149
1150    #[test]
1151    fn test_tool_kind_as_str_and_all_len() {
1152        assert_eq!(ToolKind::Hover.as_str(), "hover");
1153        assert_eq!(ToolKind::CallHierarchy.as_str(), "call_hierarchy");
1154        assert_eq!(ToolKind::ALL.len(), 21);
1155
1156        // `ALL` is a slice now, so nothing pins its element count at compile
1157        // time the way `[Self; 15]` used to -- guard against duplicate or
1158        // missing entries at runtime instead.
1159        let unique_names: std::collections::HashSet<&str> =
1160            ToolKind::ALL.iter().map(ToolKind::as_str).collect();
1161        assert_eq!(unique_names.len(), ToolKind::ALL.len());
1162    }
1163
1164    #[test]
1165    fn test_server_id_display_and_as_str() {
1166        let id = ServerId::from_static("pyright");
1167        assert_eq!(id.as_str(), "pyright");
1168        assert_eq!(id.to_string(), "pyright");
1169    }
1170}