Skip to main content

mcpls_core/bridge/translator/
mod.rs

1//! MCP to LSP translation layer.
2//!
3//! `Translator` owns the LSP client/server registries and dispatches MCP
4//! tool calls to per-domain handler modules. This module defines the
5//! `Translator` struct itself plus setup/lifecycle methods (construction,
6//! registration, shutdown); actual tool-call handling lives in the sibling
7//! modules below, grouped by domain.
8
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex as StdMutex, OnceLock};
12
13use tokio::sync::Mutex;
14
15use self::clock::{Clock, SystemClock};
16use self::encoding_ctx::EncodingCtx;
17use self::servers::{Backend, Phase, Servers};
18use crate::bridge::encoding::PositionEncoding;
19use crate::bridge::indexing::IndexingReset;
20use crate::bridge::state::ResourceLimits;
21use crate::bridge::{DocumentTracker, NotificationCache, WorkspaceRoots};
22use crate::config::{
23    FilePattern, IndexingReadyTimeoutSecs, LanguageId, LanguageMap, ServerId, ServerSettlement,
24    ToolKind, ToolRouter,
25};
26use crate::error::{ServerSpawnFailure, StartupFailure};
27use crate::lsp::{LspServer, ServerInitConfig};
28use crate::redaction::Redactions;
29use crate::util::lock_std;
30
31mod addressing;
32mod assist;
33mod availability;
34mod call_hierarchy;
35#[cfg(test)]
36mod characterization;
37mod clock;
38mod diagnostics;
39mod dto;
40mod edits;
41mod enclosing;
42#[cfg(test)]
43mod enclosing_tests;
44mod encoding_ctx;
45mod folding_range;
46mod hierarchy;
47mod highlights;
48mod kind_filter;
49mod navigation;
50mod positioned;
51#[cfg(test)]
52mod prepare_range_tests;
53mod pull_support;
54mod respawn;
55mod restart;
56mod routing;
57mod selection_range;
58mod servers;
59mod support;
60mod symbols;
61#[cfg(test)]
62mod testing;
63mod type_hierarchy;
64
65pub use addressing::{
66    AddressableTool, Addressed, MAX_SYMBOL_NAME_BYTES, PositionSource, ResolvedSymbol,
67    ResolvedTarget, SymbolName, SymbolNameError, SymbolQuery, SymbolTarget,
68};
69pub use availability::{DiagnosticsAnswer, DiagnosticsAvailability, DiagnosticsOrigin};
70pub use dto::*;
71pub use enclosing::{
72    Contextual, ContextualDiagnostic, ContextualLocation, EnclosingSymbol, EnclosingSymbolOutcome,
73    EnrichmentSummary, NotComputedReason, ResultContext, SymbolFidelity, UnavailableReason,
74};
75pub use kind_filter::{
76    CodeActionKindFilter, KindFilter, KindFilterField, KindFilterInput, RejectedKindFilter,
77    Rejection, SymbolKindFilter,
78};
79pub use restart::{
80    DiagnosticsRole, MAX_RESTART_SERVER_IDS, MAX_SERVER_ID_BYTES, NotificationReceivers,
81    NotificationWiring, RestartFailure, RestartOutcome, RestartServerResult, RestartTarget,
82    ServerIds, ServerIdsError, ServerRestartEntry,
83};
84pub use routing::Capability;
85pub use support::{RouteSupport, ToolSupportSnapshot};
86
87/// Translator handles MCP tool calls by converting them to LSP requests.
88///
89/// All fields use interior mutability so `Translator` can be shared via a
90/// plain `Arc<Translator>` with no outer lock: every LSP tool call would
91/// otherwise serialize behind a single mutex for its entire round trip
92/// (including the LSP request timeout), which is the root cause fixed here.
93/// Each field is locked independently and only for the short, synchronous
94/// section that touches it. In particular, the actual LSP request/response
95/// round trip (`client.request(...)`) always runs with no lock held.
96///
97/// `document_tracker` is no exception: `DocumentTracker` locks its own state
98/// per-path internally (see its docs), so `prepare_document`'s call into
99/// `ensure_open` never holds a lock shared across unrelated paths or
100/// languages while it does that document's disk I/O and
101/// `textDocument/didOpen`/`didChange` notify.
102#[derive(Debug)]
103pub struct Translator {
104    /// Per-server state, one slot per routing identity. Locked only for a
105    /// short synchronous section, never across an LSP request, and never
106    /// together with `router`.
107    servers: StdMutex<Servers>,
108    /// The translator-wide lifecycle.
109    phase: StdMutex<Phase>,
110    /// Secrets of every configured server, known from startup, so error
111    /// text is redacted even while no server is live.
112    startup_redactions: Arc<Redactions>,
113    /// Union of the startup set and the live clients' sets, rebuilt on change.
114    merged_redactions: StdMutex<Option<MergedRedactions>>,
115    /// Document state tracker. Locks its own state internally, per path.
116    document_tracker: Arc<DocumentTracker>,
117    /// Resource limits `document_tracker` was last built with. Kept
118    /// alongside `document_tracker` so [`Self::with_extensions`] and
119    /// [`Self::with_resource_limits`] can each rebuild the tracker from
120    /// whichever of (limits, language map) the other has already set,
121    /// regardless of call order -- see [`Self::with_resource_limits`].
122    resource_limits: ResourceLimits,
123    /// Allowed workspace roots for path validation. Read-only after `serve()`
124    /// setup, so no lock is needed.
125    workspace_roots: WorkspaceRoots,
126    /// File extension and name to language ID mappings. Read-only after
127    /// `serve()` setup, so no lock is needed.
128    language_map: Arc<LanguageMap>,
129    /// The `file_patterns` configured across all servers, reported when a
130    /// file's language has no server. Read-only after `serve()` setup.
131    file_patterns: Arc<[FilePattern]>,
132    /// Per-tool routing table: resolves `(language, tool)` to a `ServerId`.
133    /// Locked independently so a rebind (called from the background init task
134    /// as each server settles) never contends with an in-flight LSP round
135    /// trip.
136    router: Arc<StdMutex<Arc<ToolRouter>>>,
137    /// The routing table as installed by [`Self::with_router`], before any
138    /// route to a server that failed to start is dropped. Read-only
139    /// afterwards: the active `router` is derived from it on every
140    /// settlement, and it lets a failed lookup be traced back to the server
141    /// that would have served it, to report that server's [`StartupFailure`].
142    configured_router: Arc<ToolRouter>,
143    /// Diagnostics cache, shared with `serve_with`'s notification pump.
144    ///
145    /// `None` for a `Translator` built without [`Self::with_notification_cache`]
146    /// (e.g. most unit tests). When present, [`Self::respawn_if_dead`] uses
147    /// it to invalidate a respawned server's stale cached diagnostics --
148    /// see that method's docs for why that matters.
149    notification_cache: Option<Arc<Mutex<NotificationCache>>>,
150    /// How to re-start a diagnostics pump for a restarted server. Installed
151    /// once initialization has registered the initial pumps; until then a
152    /// manual restart reports the server as still initializing.
153    wiring: OnceLock<Arc<dyn NotificationWiring>>,
154    /// Time source for respawn-backoff bookkeeping ([`respawn`](self::respawn)).
155    /// Always [`SystemClock`] in production; overridden via
156    /// [`Self::with_clock`] in tests so backoff-window tests can advance
157    /// time deterministically instead of sleeping in real time.
158    clock: Arc<dyn Clock>,
159    /// Bound `Self::wait_for_indexing_ready` waits for a routed server to
160    /// report indexing readiness. Defaults to `navigation::INDEXING_READY_TIMEOUT`;
161    /// overridable via [`Self::with_indexing_ready_timeout`], wired from
162    /// `workspace.indexing_ready_timeout_seconds` in `mcpls.toml` (#424).
163    indexing_ready_timeout: std::time::Duration,
164}
165
166impl Translator {
167    /// Create a new translator.
168    ///
169    /// Starts with an empty router: nothing is routable until [`Self::with_router`]
170    /// installs one, which matches having no servers registered.
171    ///
172    /// Also starts with no workspace roots, which makes every path-taking
173    /// operation fail closed with `Error::NoWorkspaceRoots` -- embedders
174    /// MUST call [`Self::set_workspace_roots`] before serving any
175    /// path-taking request.
176    #[must_use]
177    pub fn new() -> Self {
178        Self {
179            servers: StdMutex::new(Servers::default()),
180            phase: StdMutex::new(Phase::default()),
181            startup_redactions: Arc::default(),
182            merged_redactions: StdMutex::new(None),
183            document_tracker: Arc::new(DocumentTracker::new(
184                ResourceLimits::default(),
185                HashMap::new(),
186            )),
187            resource_limits: ResourceLimits::default(),
188            workspace_roots: WorkspaceRoots::default(),
189            language_map: Arc::default(),
190            file_patterns: Arc::default(),
191            router: Arc::new(StdMutex::new(Arc::new(ToolRouter::default()))),
192            configured_router: Arc::new(ToolRouter::default()),
193            notification_cache: None,
194            wiring: OnceLock::new(),
195            clock: Arc::new(SystemClock),
196            indexing_ready_timeout: navigation::INDEXING_READY_TIMEOUT,
197        }
198    }
199
200    /// Override the time source used by respawn-backoff bookkeeping.
201    ///
202    /// Test-only: production always uses [`SystemClock`]. Lets
203    /// backoff-window tests advance a `FakeClock` deterministically instead
204    /// of sleeping in real time.
205    #[cfg(test)]
206    #[must_use]
207    fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
208        self.clock = clock;
209        self
210    }
211
212    /// Set the workspace roots for path validation.
213    ///
214    /// Only called during single-owner setup, before the translator is
215    /// shared, so this replaces the roots wholesale rather than locking.
216    ///
217    /// Mandatory for any embedder that will serve path-taking requests:
218    /// leaving `roots` empty (or never calling this) makes every such
219    /// operation reject with `Error::NoWorkspaceRoots` instead of allowing
220    /// unrestricted access.
221    pub fn set_workspace_roots(&mut self, roots: WorkspaceRoots) {
222        self.workspace_roots = roots;
223    }
224
225    /// Give the translator a handle to the shared diagnostics cache, so the
226    /// respawn path can invalidate a respawned server's stale entries.
227    ///
228    /// Only called during single-owner setup (mirrors [`Self::with_router`]),
229    /// before the translator is shared -- `serve_with` passes the same
230    /// `Arc<Mutex<NotificationCache>>` used by the notification pump tasks.
231    #[must_use]
232    pub fn with_notification_cache(mut self, cache: Arc<Mutex<NotificationCache>>) -> Self {
233        self.notification_cache = Some(cache);
234        self
235    }
236
237    /// Give the translator the secrets of every configured server.
238    ///
239    /// Only called during single-owner setup, before the translator is shared.
240    #[must_use]
241    pub(crate) fn with_startup_redactions(mut self, redactions: Arc<Redactions>) -> Self {
242        self.startup_redactions = redactions;
243        self
244    }
245
246    /// Override the bound `Self::wait_for_indexing_ready` waits for a routed
247    /// server to report indexing readiness, in place of the built-in
248    /// `navigation::INDEXING_READY_TIMEOUT` default.
249    ///
250    /// Only called during single-owner setup (mirrors [`Self::with_notification_cache`]),
251    /// before the translator is shared. `serve()` wires this from
252    /// `workspace.indexing_ready_timeout_seconds`, whose type already bounds it.
253    #[must_use]
254    pub const fn with_indexing_ready_timeout(mut self, timeout: IndexingReadyTimeoutSecs) -> Self {
255        self.indexing_ready_timeout = timeout.as_duration();
256        self
257    }
258
259    /// Mark the set of servers that are expected (configured + applicable)
260    /// but may still be initializing in the background.
261    #[allow(
262        clippy::needless_pass_by_value,
263        reason = "public API: callers hand over the set they built"
264    )]
265    pub fn set_expected_servers(&self, servers: HashSet<ServerId>) {
266        lock_std(&self.servers).set_expected(&servers);
267    }
268
269    /// Clear the expected-servers set (e.g. after background init failed).
270    pub fn clear_expected_servers(&self) {
271        lock_std(&self.servers).clear_expected();
272    }
273
274    /// Install the per-tool routing table built from the applicable configs.
275    ///
276    /// Only called during single-owner setup, before the translator is
277    /// shared, so this replaces the `Arc`-wrapped router wholesale.
278    #[must_use]
279    pub fn with_router(mut self, router: ToolRouter) -> Self {
280        let router = Arc::new(router);
281        self.configured_router = Arc::clone(&router);
282        self.router = Arc::new(StdMutex::new(router));
283        self
284    }
285
286    /// Remember why each of `failures` never registered, so later tool calls
287    /// can report it instead of a generic "no server configured".
288    ///
289    /// A server that is already running keeps running: its failure is stale.
290    #[cfg(test)]
291    pub(crate) fn record_startup_failures(&self, failures: &[ServerSpawnFailure]) {
292        let mut servers = lock_std(&self.servers);
293        for failure in failures {
294            servers.record_failure(failure);
295        }
296    }
297
298    /// The recorded startup failure of the server `id`, if it failed to start.
299    pub(crate) fn startup_failure(&self, id: &ServerId) -> Option<ServerSpawnFailure> {
300        lock_std(&self.servers).failure(id).cloned()
301    }
302
303    /// Every recorded startup failure, ordered by routing identity.
304    pub(crate) fn startup_failures(&self) -> Vec<ServerSpawnFailure> {
305        lock_std(&self.servers).failures()
306    }
307
308    /// Records the servers untrusted-workspace mode refused to start and
309    /// rebinds the router away from them.
310    ///
311    /// Does nothing for an empty list, so a trusted workspace is untouched.
312    /// Not [`Self::settle_failed`]: a refused server was never expected, so
313    /// its "settled twice or was never expected" error would be wrong.
314    pub(crate) fn record_refusals(&self, refused: &[ServerSpawnFailure]) {
315        if refused.is_empty() {
316            return;
317        }
318        {
319            let mut servers = lock_std(&self.servers);
320            for failure in refused {
321                servers.record_failure(failure);
322            }
323        }
324        self.rebind_router_to_settled();
325        for failure in refused {
326            self.warn_failed_routes(&failure.server_id, &RouteLoss::of(failure));
327        }
328    }
329
330    /// Settle the translator after the background init task panicked.
331    ///
332    /// Every config that never registered is recorded as
333    /// [`StartupFailure::InitTaskPanicked`] (unless it already has a recorded
334    /// failure), the router is rebound to what did register, and the
335    /// expected-server set is cleared, so tools return a terminal error
336    /// instead of `ServerInitializing` forever. The notification receivers and
337    /// pumps of registered servers died with the task, so those servers are
338    /// marked push-degraded and their indexing state is reset.
339    pub(crate) async fn settle_after_init_panic(&self, configs: &[ServerInitConfig]) {
340        lock_std(&self.phase).init_panicked();
341        let registered: HashSet<ServerId> = {
342            let mut servers = lock_std(&self.servers);
343            servers.fail_unsettled(configs.iter().map(|config| {
344                let server_config = &config.server_config();
345                ServerSpawnFailure {
346                    server_id: server_config.id(),
347                    language_id: server_config.language_id.clone(),
348                    command: server_config.command.server_command().clone(),
349                    reason: StartupFailure::InitTaskPanicked,
350                }
351            }));
352            servers
353                .ids()
354                .filter(|id| servers.client(id).is_some())
355                .cloned()
356                .collect()
357        };
358        self.rebind_router_to_settled();
359        self.clear_expected_servers();
360
361        if let Some(cache) = &self.notification_cache {
362            let mut cache = cache.lock().await;
363            for id in &registered {
364                cache.mark_push_degraded(id);
365                cache.reset_indexing_state(id, IndexingReset::Forget);
366            }
367        }
368    }
369
370    /// Rebind the routing table to the set of servers that actually
371    /// registered, dropping or redirecting routes to every other server as if
372    /// it had failed to spawn. See [`ToolRouter::rebind`] for the semantics.
373    pub fn rebind_router(&self, registered: &HashSet<ServerId>) {
374        self.install_router(|id| {
375            if registered.contains(id) {
376                ServerSettlement::Registered
377            } else {
378                ServerSettlement::Failed
379            }
380        });
381    }
382
383    /// Re-derive the routing table from the configured one and what has
384    /// settled so far: servers with a registered client are `Registered`,
385    /// servers with a recorded startup failure are `Failed`, all others are
386    /// still `Pending` and keep their routes. A pure function of that state,
387    /// so it does not matter in which order servers settled.
388    pub(crate) fn rebind_router_to_settled(&self) {
389        let settlements = lock_std(&self.servers).settlements();
390        self.install_router(|id| {
391            settlements
392                .get(id)
393                .copied()
394                .unwrap_or(ServerSettlement::Pending)
395        });
396    }
397
398    fn install_router(&self, settlement: impl Fn(&ServerId) -> ServerSettlement) {
399        let mut router = (*self.configured_router).clone();
400        router.rebind(settlement);
401        *lock_std(&self.router) = Arc::new(router);
402    }
403
404    /// The routing table as of now. The `router` lock is released before it
405    /// returns, so callers can then lock `servers` without holding both.
406    pub(super) fn router_snapshot(&self) -> Arc<ToolRouter> {
407        Arc::clone(&lock_std(&self.router))
408    }
409
410    /// Registers the server in one step, then re-derives routes.
411    pub(crate) fn settle_started(&self, server: LspServer) -> (ServerId, LanguageId) {
412        let id = server.init_config().server_config().id();
413        let language = server.client().language_id().clone();
414        let registered = lock_std(&self.servers).register(id.clone(), Backend::Process(server));
415        drop(registered.displaced);
416        if !registered.was_expected {
417            tracing::error!("LSP server '{id}' settled twice or was never expected");
418        }
419        self.rebind_router_to_settled();
420        (id, language)
421    }
422
423    /// As [`Self::settle_started`], with the failure recorded.
424    pub(crate) fn settle_failed(&self, failure: &ServerSpawnFailure) {
425        let id = failure.server_id.clone();
426        // The router moves off the failed server before it stops being
427        // expected, so no lookup reports a stale `ServerFailedToStart`.
428        lock_std(&self.servers).announce_failure(failure);
429        self.rebind_router_to_settled();
430        let was_expected = lock_std(&self.servers).record_failure(failure);
431        if !was_expected {
432            tracing::error!("LSP server '{id}' settled twice or was never expected");
433        }
434        self.warn_failed_routes(&id, &RouteLoss::FailedToStart);
435    }
436
437    /// Logs which languages lost a route to the failed server `id` and what
438    /// the state of each language's catch-all is.
439    fn warn_failed_routes(&self, id: &ServerId, loss: &RouteLoss<'_>) {
440        let languages = self.configured_router.languages_routed_to(id);
441        let catch_all_states: Vec<String> = languages
442            .iter()
443            .map(|language| {
444                let state = match self.configured_router.catch_all_for(language.as_str()) {
445                    None => "no catch-all".to_string(),
446                    Some(catch_all) if catch_all == id => "it was the catch-all".to_string(),
447                    Some(catch_all) if lock_std(&self.servers).client(catch_all).is_some() => {
448                        format!("catch-all '{catch_all}' registered")
449                    }
450                    Some(catch_all) if self.startup_failure(catch_all).is_some() => {
451                        format!("catch-all '{catch_all}' failed")
452                    }
453                    Some(catch_all) => format!("catch-all '{catch_all}' pending"),
454                };
455                format!("{language}: {state}")
456            })
457            .collect();
458        tracing::warn!(
459            "server '{id}' {loss}; routes of [{}] are rebound or dropped",
460            catch_all_states.join(", ")
461        );
462    }
463
464    /// Whether `id` is the server the router currently resolves
465    /// `ToolKind::Diagnostics` to for `language_id`.
466    ///
467    /// Purpose-built for the startup settlement, which needs this to compute
468    /// each pump's diagnostics role and the diagnostics-route count, without
469    /// exposing the router's lock guard outside this module.
470    #[must_use]
471    pub fn is_diagnostics_route(&self, language_id: &LanguageId, id: &ServerId) -> bool {
472        lock_std(&self.router).resolve(language_id.as_str(), ToolKind::Diagnostics) == Some(id)
473    }
474
475    /// Negotiated [`PositionEncoding`] of the registered server `id`, or the
476    /// LSP spec's own default (UTF-16) if `id` is not currently registered.
477    ///
478    /// Note this falls back to UTF-16, not [`PositionEncoding::default`]
479    /// (UTF-8): UTF-16 is what an absent/unrecognized negotiation means per
480    /// the LSP spec and what [`crate::lsp::LspServer::spawn`] itself falls
481    /// back to, so this must match rather than use the bridge type's own
482    /// default, which exists only for `PositionEncoding`'s own internal use.
483    #[must_use]
484    pub(crate) fn position_encoding_for(&self, server_id: &ServerId) -> PositionEncoding {
485        lock_std(&self.servers)
486            .server(server_id)
487            .and_then(|server| PositionEncoding::from_lsp(server.position_encoding().as_str()))
488            .unwrap_or(PositionEncoding::Utf16)
489    }
490
491    /// Build the [`EncodingCtx`] for converting positions/ranges in
492    /// responses from the registered server `id`.
493    fn encoding_ctx(&self, server_id: &ServerId) -> EncodingCtx {
494        EncodingCtx::new(
495            self.position_encoding_for(server_id),
496            self.document_tracker.clone(),
497            self.workspace_roots.clone(),
498        )
499    }
500
501    /// Rebuilds `document_tracker` from `self.resource_limits` and
502    /// `self.language_map`, whatever the two are currently set to.
503    ///
504    /// Called by every builder that touches either input ([`Self::with_extensions`],
505    /// [`Self::with_resource_limits`]), so each one only needs to set its own
506    /// field and call this -- it always reads *both* current values, so the
507    /// builders remain order-independent (see [`Self::with_resource_limits`])
508    /// without each one needing to know the other's field. A future builder
509    /// that adds a third tracker input should follow the same pattern:
510    /// update its own field, then call this.
511    fn rebuild_document_tracker(&mut self) {
512        self.document_tracker = Arc::new(DocumentTracker::new(
513            self.resource_limits,
514            (*self.language_map).clone(),
515        ));
516    }
517
518    /// Configure the `file_patterns` reported by no-server-for-language errors.
519    ///
520    /// Only called during single-owner setup, before the translator is shared.
521    #[must_use]
522    pub fn with_file_patterns(mut self, patterns: impl IntoIterator<Item = FilePattern>) -> Self {
523        self.file_patterns = patterns.into_iter().collect();
524        self
525    }
526
527    /// Configure the file extension and name mappings.
528    ///
529    /// This method sets the language map and updates the document tracker
530    /// to use the same mappings for language detection. A plain
531    /// extension-to-language `HashMap` is accepted too.
532    ///
533    /// Only called during single-owner setup, before the translator is
534    /// shared, so this replaces the `Arc`-wrapped fields wholesale.
535    #[must_use]
536    pub fn with_extensions(mut self, language_map: impl Into<LanguageMap>) -> Self {
537        self.language_map = Arc::new(language_map.into());
538        self.rebuild_document_tracker();
539        self
540    }
541
542    /// Configure resource limits (max open documents, max file size) for the
543    /// document tracker.
544    ///
545    /// Only called during single-owner setup, before the translator is
546    /// shared. This builder and [`Self::with_extensions`] may be called in
547    /// either order -- each rebuilds `document_tracker` from *both* of
548    /// `self.resource_limits`/`self.language_map`'s current values,
549    /// instead of one of them starting fresh from
550    /// `ResourceLimits::default()`/an empty language map, which previously
551    /// meant whichever builder ran last silently discarded the other's
552    /// effect.
553    #[must_use]
554    pub fn with_resource_limits(mut self, limits: ResourceLimits) -> Self {
555        self.resource_limits = limits;
556        self.rebuild_document_tracker();
557        self
558    }
559
560    /// Test-only: register a bare LSP client under its routing identity, next
561    /// to the server already registered for it, if any.
562    #[cfg(test)]
563    pub(crate) fn register_client(&self, id: ServerId, client: crate::lsp::LspClient) {
564        lock_std(&self.servers).register_test_client(id, client);
565    }
566
567    /// The secrets of every live server's client, for hiding them in tool
568    /// results. The merged set is rebuilt only when the set of client
569    /// redaction sets changes (registration, restart, respawn), outside the
570    /// `servers` lock.
571    pub(crate) fn server_text_redactions(&self) -> Arc<Redactions> {
572        let sources: Vec<Arc<Redactions>> = std::iter::once(Arc::clone(&self.startup_redactions))
573            .chain(
574                lock_std(&self.servers)
575                    .clients()
576                    .map(|client| Arc::clone(client.redactions())),
577            )
578            .filter(|set| !set.is_empty())
579            .collect();
580        let mut cache = lock_std(&self.merged_redactions);
581        if let Some(cached) = cache
582            .as_ref()
583            .filter(|cached| cached.is_built_from(&sources))
584        {
585            return Arc::clone(&cached.merged);
586        }
587        let merged = Arc::new(Redactions::union(sources.iter().map(Arc::as_ref)));
588        *cache = Some(MergedRedactions {
589            sources,
590            merged: Arc::clone(&merged),
591        });
592        merged
593    }
594
595    /// Test-only: register a bare LSP server under its routing identity, next
596    /// to the client already registered for it, if any.
597    #[cfg(test)]
598    pub(crate) fn register_server(&self, id: ServerId, server: LspServer) {
599        lock_std(&self.servers).register_test_server(id, server);
600    }
601
602    /// Register a spawned server under its routing identity, in one step.
603    ///
604    /// The routing identity and client are both derived from `server`
605    /// itself, so they cannot be registered out of sync. The server also owns
606    /// the config a respawn would use.
607    ///
608    /// # Examples
609    ///
610    /// ```no_run
611    /// use mcpls_core::bridge::Translator;
612    /// use mcpls_core::lsp::LspServer;
613    ///
614    /// fn register(translator: &Translator, server: LspServer) {
615    ///     translator.register_server_complete(server);
616    /// }
617    /// ```
618    pub fn register_server_complete(&self, server: LspServer) {
619        let id = server.init_config().server_config().id();
620        let registered = lock_std(&self.servers).register(id, Backend::Process(server));
621        drop(registered.displaced);
622    }
623
624    /// Number of currently registered LSP servers.
625    ///
626    /// Test-only: the server slots are private, so this is the one way a test
627    /// outside this module (e.g. `crate::tests`, exercising
628    /// [`Translator::shutdown_servers`] indirectly through `serve_with`'s
629    /// shutdown sequence) can observe that a registered server was actually
630    /// drained.
631    #[cfg(test)]
632    pub(crate) fn registered_server_count(&self) -> usize {
633        lock_std(&self.servers).running_servers().count()
634    }
635
636    /// Snapshot of currently open document paths, used for MCP resource listing.
637    #[must_use]
638    pub fn open_document_paths(&self) -> Vec<PathBuf> {
639        self.document_tracker.open_paths()
640    }
641
642    /// Whether a document is currently tracked as open.
643    #[must_use]
644    pub fn is_document_open(&self, path: &Path) -> bool {
645        self.document_tracker.is_open(path)
646    }
647
648    /// The document tracker, shared with [`EncodingCtx`] so a cache-only
649    /// caller (e.g. `get_cached_diagnostics`) can still prefer tracked
650    /// in-memory content over a disk read when converting positions.
651    #[must_use]
652    pub(crate) const fn document_tracker(&self) -> &Arc<DocumentTracker> {
653        &self.document_tracker
654    }
655
656    /// Gracefully shut down every registered LSP server.
657    ///
658    /// Drains the registered LSP servers and, for each one concurrently,
659    /// sends the LSP `shutdown` request and `exit` notification via
660    /// [`LspServer::shutdown`], whose handshake is bounded by
661    /// [`crate::lsp::SHUTDOWN_TIMEOUT`] and which then sweeps the server's
662    /// whole process tree, for up to [`crate::lsp::LIFELINE_SWEEP_BUDGET`]
663    /// more. A server that errors or fails to respond in time has its tree
664    /// killed instead. No outer timeout is applied, so when this returns every
665    /// tree is gone. Call this once, from the
666    /// top-level shutdown path, after the MCP transport has stopped
667    /// accepting new requests.
668    ///
669    /// # Limitations
670    ///
671    /// This only runs on the normal shutdown path (stdio EOF, `SIGTERM`/
672    /// `SIGINT`, or the HTTP transport's own graceful shutdown). A panic is
673    /// covered separately: release builds unwind, and the binary's `main`
674    /// shuts the runtime down so every task, and with it every LSP child
675    /// (`kill_on_drop`), is dropped. When no Rust code runs (`SIGKILL`, the
676    /// OOM killer, the forced `exit(1)` on a second signal during shutdown,
677    /// or a panic inside a `Drop` during unwinding) the children are still
678    /// killed by the lifeline watchdog (Unix) or Job Object (Windows), see
679    /// `specs/lsp/007-lsp-child-process-lifetime`, which also sweeps
680    /// descendants that left the process group (`setsid`/`setpgid`).
681    ///
682    /// `pub(crate)` rather than `pub`: this is meant for exactly one call
683    /// site (`serve_with`'s post-transport shutdown sequence), after the MCP
684    /// transport is already down. An external caller invoking it mid-session
685    /// would stop the servers while their slots (routing table) still
686    /// points at the now-shut-down servers, so in-flight tool calls would
687    /// resolve to a client whose server is gone.
688    pub(crate) async fn shutdown_servers(&self) {
689        self.begin_shutdown();
690        let servers: Vec<(ServerId, LspServer)> = lock_std(&self.servers).drain_servers();
691        if servers.is_empty() {
692            return;
693        }
694
695        let mut tasks = tokio::task::JoinSet::new();
696        let mut ids = HashMap::new();
697        for (id, server) in servers {
698            let task_id = id.clone();
699            let handle = tasks.spawn(async move {
700                match server.shutdown().await {
701                    Ok(()) => tracing::debug!(%id, "LSP server shut down gracefully"),
702                    Err(e) => tracing::warn!(
703                        %id, error = %e,
704                        "LSP server shutdown failed, killing process instead"
705                    ),
706                }
707            });
708            ids.insert(handle.id(), task_id);
709        }
710        join_shutdown_tasks(tasks, &ids).await;
711    }
712}
713
714/// Awaits every shutdown task, logging a panicked or cancelled one with its
715/// server id instead of re-raising it into the caller.
716async fn join_shutdown_tasks(
717    mut tasks: tokio::task::JoinSet<()>,
718    ids: &HashMap<tokio::task::Id, ServerId>,
719) {
720    while let Some(joined) = tasks.join_next_with_id().await {
721        if let Err(e) = joined {
722            let id = ids.get(&e.id()).map_or("unknown", ServerId::as_str);
723            tracing::error!(%id, error = %e, "LSP server shutdown task failed");
724        }
725    }
726}
727
728impl Default for Translator {
729    /// Same as [`Translator::new`]: no workspace roots configured, so every
730    /// path-taking operation fails closed with `Error::NoWorkspaceRoots`
731    /// until [`Translator::set_workspace_roots`] is called.
732    fn default() -> Self {
733        Self::new()
734    }
735}
736
737/// A merged redaction set and the per-client sets it was built from.
738#[derive(Debug)]
739struct MergedRedactions {
740    sources: Vec<Arc<Redactions>>,
741    merged: Arc<Redactions>,
742}
743
744impl MergedRedactions {
745    /// Whether `sources` are the same sets (by identity, in any order).
746    fn is_built_from(&self, sources: &[Arc<Redactions>]) -> bool {
747        self.sources.len() == sources.len()
748            && sources
749                .iter()
750                .all(|set| self.sources.iter().any(|known| Arc::ptr_eq(known, set)))
751    }
752}
753
754/// Why a server's routes were lost, as the startup log line words it.
755enum RouteLoss<'a> {
756    FailedToStart,
757    Refused(&'a crate::error::UntrustedRefusal),
758}
759
760impl<'a> RouteLoss<'a> {
761    const fn of(failure: &'a ServerSpawnFailure) -> Self {
762        match &failure.reason {
763            StartupFailure::RefusedUntrustedWorkspace(refusal) => Self::Refused(refusal),
764            StartupFailure::Spawn(_) | StartupFailure::InitTaskPanicked => Self::FailedToStart,
765        }
766    }
767}
768
769impl std::fmt::Display for RouteLoss<'_> {
770    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771        match self {
772            Self::FailedToStart => f.write_str("failed to start"),
773            Self::Refused(refusal) => write!(f, "refused (untrusted workspace: {refusal})"),
774        }
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use std::assert_matches;
781    use std::collections::{HashMap, HashSet};
782    use std::path::PathBuf;
783
784    use tempfile::TempDir;
785    use tokio::time::Duration;
786
787    use super::*;
788    use crate::config::{
789        DocumentLimit, FileExtension, ServerCommand, ServerId, SizeLimit, ToolKind, ToolRouter,
790        ToolSet,
791    };
792    use crate::error::Error;
793    use crate::test_lsp::fake_lsp_client;
794
795    #[tokio::test]
796    async fn test_server_text_redactions_follow_live_clients_across_respawn() {
797        let translator = Translator::new();
798        assert!(translator.server_text_redactions().is_empty());
799
800        let (a, _fake_a, _lanes_a) = crate::test_lsp::fake_lsp_client_with_redactions(
801            Redactions::new([("A_TOKEN".to_owned(), "alpha-secret-111".to_owned())]),
802        );
803        let (b, _fake_b, _lanes_b) = crate::test_lsp::fake_lsp_client_with_redactions(
804            Redactions::new([("B_TOKEN".to_owned(), "bravo-secret-222".to_owned())]),
805        );
806        translator.register_client(ServerId::from_static("a"), a);
807        translator.register_client(ServerId::from_static("b"), b);
808        let both = translator.server_text_redactions();
809        assert_eq!(both.apply("alpha-secret-111"), "[redacted:A_TOKEN]");
810        assert_eq!(both.apply("bravo-secret-222"), "[redacted:B_TOKEN]");
811
812        let (respawned, _fake_c, _lanes_c) = crate::test_lsp::fake_lsp_client_with_redactions(
813            Redactions::new([("C_TOKEN".to_owned(), "charlie-secret-333".to_owned())]),
814        );
815        translator.register_client(ServerId::from_static("a"), respawned);
816        let after = translator.server_text_redactions();
817        assert_eq!(after.apply("alpha-secret-111"), "alpha-secret-111");
818        assert_eq!(after.apply("charlie-secret-333"), "[redacted:C_TOKEN]");
819        assert_eq!(after.apply("bravo-secret-222"), "[redacted:B_TOKEN]");
820    }
821
822    #[tokio::test]
823    async fn test_server_text_redactions_are_cached_and_do_not_relog() {
824        use tracing_subscriber::prelude::*;
825
826        let translator = Translator::new();
827        let (a, _fake_a, _lanes_a) = crate::test_lsp::fake_lsp_client_with_redactions(
828            Redactions::new([("A_TOKEN".to_owned(), "alpha-secret-111".to_owned())]),
829        );
830        let (b, _fake_b, _lanes_b) = crate::test_lsp::fake_lsp_client_with_redactions(
831            Redactions::new([("B_TOKEN".to_owned(), "A_TOKEN]".to_owned())]),
832        );
833        translator.register_client(ServerId::from_static("a"), a);
834        translator.register_client(ServerId::from_static("b"), b);
835
836        let logs = crate::test_lsp::CapturedLogs::default();
837        let subscriber = tracing_subscriber::registry()
838            .with(tracing_subscriber::filter::LevelFilter::WARN)
839            .with(logs.clone());
840        let first = tracing::subscriber::with_default(subscriber, || {
841            let first = translator.server_text_redactions();
842            let second = translator.server_text_redactions();
843            let third = translator.server_text_redactions();
844            assert!(Arc::ptr_eq(&first, &second) && Arc::ptr_eq(&second, &third));
845            first
846        });
847
848        assert_eq!(logs.messages().len(), 1, "{:?}", logs.messages());
849        assert_eq!(first.apply("alpha-secret-111"), "[redacted:A_TOKEN]");
850    }
851
852    #[tokio::test]
853    async fn test_register_server_complete_fills_all_maps_under_init_config_id() {
854        let translator = Translator::new();
855        let config = crate::config::LspServerConfig::rust_analyzer();
856        let id = config.id();
857        translator.register_server_complete(crate::lsp::fake_lsp_server_with_config(config));
858
859        let servers = lock_std(&translator.servers);
860        assert!(servers.client(&id).is_some());
861        assert!(servers.server(&id).is_some());
862        drop(servers);
863    }
864
865    fn named_config(
866        name: &str,
867        language: &str,
868        handles: Option<Vec<ToolKind>>,
869    ) -> crate::config::LspServerConfig {
870        let mut config = crate::config::LspServerConfig::rust_analyzer();
871        config.name = Some(ServerId::new(name).unwrap());
872        config.language_id = LanguageId::new(language).unwrap();
873        config.handles = handles.map(|tools| ToolSet::new(tools).unwrap());
874        config
875    }
876
877    fn spawn_failure(config: &crate::config::LspServerConfig) -> ServerSpawnFailure {
878        ServerSpawnFailure {
879            server_id: config.id(),
880            language_id: config.language_id.clone(),
881            command: config.command.server_command().clone(),
882            reason: StartupFailure::InitTaskPanicked,
883        }
884    }
885
886    fn settle_all(
887        translator: &Translator,
888        configs: &[crate::config::LspServerConfig],
889        up: &[bool],
890    ) {
891        for (config, up) in configs.iter().zip(up) {
892            if *up {
893                translator.settle_started(crate::lsp::fake_lsp_server_with_config(config.clone()));
894            } else {
895                translator.settle_failed(&spawn_failure(config));
896            }
897        }
898    }
899
900    /// FR-006 / NFR-005: whatever order servers settle in, the final routes,
901    /// failure set and expected set equal the batch result.
902    #[tokio::test]
903    async fn settle_in_any_completion_order_yields_the_batch_router_and_failures() {
904        let configs = [
905            named_config("x", "rust", Some(vec![ToolKind::Hover])),
906            named_config("c", "rust", None),
907            named_config("y", "python", None),
908        ];
909        let orders = [
910            [0, 1, 2],
911            [0, 2, 1],
912            [1, 0, 2],
913            [1, 2, 0],
914            [2, 0, 1],
915            [2, 1, 0],
916        ];
917        for outcome in 0..8u8 {
918            let up: Vec<bool> = (0..3).map(|bit| outcome & (1 << bit) != 0).collect();
919            let registered: HashSet<ServerId> = configs
920                .iter()
921                .zip(&up)
922                .filter(|(_, up)| **up)
923                .map(|(config, _)| config.id())
924                .collect();
925            let mut batch = ToolRouter::from_configs(&configs).unwrap();
926            batch.rebind_to_registered(&registered);
927
928            for order in orders {
929                let translator =
930                    Translator::new().with_router(ToolRouter::from_configs(&configs).unwrap());
931                translator.set_expected_servers(
932                    configs
933                        .iter()
934                        .map(crate::config::LspServerConfig::id)
935                        .collect(),
936                );
937                for index in order {
938                    settle_all(
939                        &translator,
940                        std::slice::from_ref(&configs[index]),
941                        &[up[index]],
942                    );
943                }
944
945                let router = lock_std(&translator.router).clone();
946                for language in ["rust", "python"] {
947                    for tool in ToolKind::ALL.iter().copied() {
948                        assert_eq!(
949                            router.resolve(language, tool),
950                            batch.resolve(language, tool),
951                            "{language}/{tool:?}, up {up:?}, order {order:?}"
952                        );
953                    }
954                }
955                for tool in ToolKind::ALL.iter().copied() {
956                    assert_eq!(router.resolve_any(tool), batch.resolve_any(tool));
957                }
958                let failed: HashSet<ServerId> = translator
959                    .startup_failures()
960                    .into_iter()
961                    .map(|failure| failure.server_id)
962                    .collect();
963                let expected_failed: HashSet<ServerId> = configs
964                    .iter()
965                    .map(crate::config::LspServerConfig::id)
966                    .filter(|id| !registered.contains(id))
967                    .collect();
968                assert_eq!(failed, expected_failed);
969                assert!(!lock_std(&translator.servers).any_expected());
970            }
971        }
972    }
973
974    /// FR-007 window: the explicit server failed, its catch-all is still
975    /// initializing. Nothing is bound to the catch-all, but the lookup reports
976    /// the retryable `ServerInitializing` naming it (not a terminal failure
977    /// and not a dangling-route error); once the catch-all registers the route
978    /// is served by it.
979    #[tokio::test]
980    async fn dead_explicit_route_is_retryable_while_the_catch_all_initializes() {
981        use tracing_subscriber::prelude::*;
982
983        use crate::test_lsp::CapturedLogs;
984
985        let x = named_config(
986            "x",
987            "rust",
988            Some(vec![ToolKind::Hover, ToolKind::Diagnostics]),
989        );
990        let c = named_config("c", "rust", None);
991        let translator = Translator::new()
992            .with_extensions(crate::test_lsp::test_extensions())
993            .with_router(ToolRouter::from_configs([&x, &c]).unwrap());
994        translator.set_expected_servers([x.id(), c.id()].into_iter().collect());
995        let path = PathBuf::from("/ws/main.rs");
996        let logs = CapturedLogs::default();
997        let subscriber = tracing_subscriber::registry().with(logs.clone());
998
999        tracing::subscriber::with_default(subscriber, || {
1000            translator.settle_failed(&spawn_failure(&x));
1001
1002            let hover = translator
1003                .client_for_file(&path, ToolKind::Hover)
1004                .unwrap_err();
1005            std::assert_matches!(
1006                hover,
1007                Error::ServerInitializing { server_id } if server_id == c.id()
1008            );
1009            std::assert_matches!(
1010                translator.diagnostics_route_for_path(&path),
1011                routing::DiagnosticsRoute::Initializing(id) if id == c.id()
1012            );
1013            assert_eq!(
1014                translator
1015                    .tool_support_snapshot()
1016                    .document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
1017                RouteSupport::Initializing
1018            );
1019            assert!(
1020                logs.entries()
1021                    .iter()
1022                    .all(|(level, _)| *level != tracing::Level::ERROR),
1023                "{:?}",
1024                logs.messages()
1025            );
1026        });
1027
1028        translator.settle_started(crate::lsp::fake_lsp_server_with_config(c.clone()));
1029        let (served_by, _) = translator.client_for_file(&path, ToolKind::Hover).unwrap();
1030        assert_eq!(served_by, c.id());
1031    }
1032
1033    /// The window inside `settle_failed`: once the failure is noted but the
1034    /// router has not moved yet, a lookup still reads the server as
1035    /// initializing (retryable), never as a failed start, although a
1036    /// catch-all is registered and serves the call right after the rebind.
1037    #[tokio::test]
1038    async fn test_settle_failed_window_reads_initializing_not_failed() {
1039        let configs = [
1040            named_config("a", "rust", Some(vec![ToolKind::Hover])),
1041            named_config("b", "rust", None),
1042        ];
1043        let translator = Translator::new()
1044            .with_extensions(crate::test_lsp::test_extensions())
1045            .with_router(ToolRouter::from_configs(&configs).unwrap());
1046        translator.set_expected_servers(
1047            configs
1048                .iter()
1049                .map(crate::config::LspServerConfig::id)
1050                .collect(),
1051        );
1052        translator.settle_started(crate::lsp::fake_lsp_server_with_config(configs[1].clone()));
1053        let path = PathBuf::from("/ws/main.rs");
1054
1055        lock_std(&translator.servers).announce_failure(&spawn_failure(&configs[0]));
1056
1057        let err = translator
1058            .client_for_file(&path, ToolKind::Hover)
1059            .unwrap_err();
1060        std::assert_matches!(&err, Error::ServerInitializing { server_id } if *server_id == configs[0].id());
1061        assert_eq!(
1062            translator
1063                .tool_support_snapshot()
1064                .document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
1065            RouteSupport::Initializing
1066        );
1067
1068        translator.settle_failed(&spawn_failure(&configs[0]));
1069        let (served_by, _) = translator.client_for_file(&path, ToolKind::Hover).unwrap();
1070        assert_eq!(served_by, configs[1].id());
1071    }
1072
1073    /// The registry is read before the router, so a router already moved off
1074    /// a failing server never pairs with the registry from before the move.
1075    #[tokio::test]
1076    async fn test_snapshot_between_rebind_and_failure_record_still_routes_to_the_catch_all() {
1077        let configs = [
1078            named_config("a", "rust", Some(vec![ToolKind::Hover])),
1079            named_config("b", "rust", None),
1080        ];
1081        let translator = Translator::new()
1082            .with_extensions(crate::test_lsp::test_extensions())
1083            .with_router(ToolRouter::from_configs(&configs).unwrap());
1084        translator.set_expected_servers(
1085            configs
1086                .iter()
1087                .map(crate::config::LspServerConfig::id)
1088                .collect(),
1089        );
1090        translator.settle_started(crate::lsp::fake_lsp_server_with_config(configs[1].clone()));
1091
1092        lock_std(&translator.servers).announce_failure(&spawn_failure(&configs[0]));
1093        translator.rebind_router_to_settled();
1094
1095        assert_matches!(
1096            translator
1097                .tool_support_snapshot()
1098                .document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
1099            RouteSupport::Supported { server } | RouteSupport::CapabilityNotAdvertised { server, .. }
1100                if server == configs[1].id()
1101        );
1102    }
1103
1104    /// An init-task panic while a server is mid-restart leaves its slot alone:
1105    /// the restart's backend is restored to a running server, and the slot's
1106    /// bookkeeping survives.
1107    #[tokio::test]
1108    async fn test_init_panic_during_a_restart_does_not_fail_or_drop_the_restarting_server() {
1109        let config = crate::config::LspServerConfig::rust_analyzer();
1110        let id = config.id();
1111        let translator = Translator::new();
1112        translator
1113            .register_server_complete(crate::lsp::fake_lsp_server_with_config(config.clone()));
1114        let lock_before = translator.respawn_lock(&id);
1115        let held = lock_std(&translator.servers).take_for_restart(&id).unwrap();
1116
1117        translator
1118            .settle_after_init_panic(&[crate::test_lsp::init_config_for(config)])
1119            .await;
1120
1121        assert!(translator.startup_failure(&id).is_none());
1122        assert!(Arc::ptr_eq(&lock_before, &translator.respawn_lock(&id)));
1123        let refused = lock_std(&translator.servers).restore(&id, held);
1124        assert!(refused.is_none());
1125        assert_eq!(translator.registered_server_count(), 1);
1126    }
1127
1128    /// A settlement that finds an earlier failure keeps it and records only
1129    /// the servers that never settled.
1130    #[tokio::test]
1131    async fn settle_after_init_panic_after_partial_settlement_records_only_unsettled() {
1132        let configs = [
1133            named_config("a", "rust", Some(vec![ToolKind::Hover])),
1134            named_config("b", "python", None),
1135            named_config("c", "typescript", None),
1136        ];
1137        let translator = Translator::new().with_router(ToolRouter::from_configs(&configs).unwrap());
1138        translator.set_expected_servers(
1139            configs
1140                .iter()
1141                .map(crate::config::LspServerConfig::id)
1142                .collect(),
1143        );
1144        translator.settle_started(crate::lsp::fake_lsp_server_with_config(configs[0].clone()));
1145        translator.settle_failed(&ServerSpawnFailure {
1146            reason: StartupFailure::Spawn(std::sync::Arc::new(Error::ServerNotFound {
1147                command: crate::config::ServerCommand::from_static("b"),
1148                source: std::io::Error::from(std::io::ErrorKind::NotFound),
1149            })),
1150            ..spawn_failure(&configs[1])
1151        });
1152        let init_configs: Vec<_> = configs
1153            .iter()
1154            .cloned()
1155            .map(crate::test_lsp::init_config_for)
1156            .collect();
1157
1158        translator.settle_after_init_panic(&init_configs).await;
1159
1160        assert!(translator.startup_failure(&configs[0].id()).is_none());
1161        std::assert_matches!(
1162            translator.startup_failure(&configs[1].id()).unwrap().reason,
1163            StartupFailure::Spawn(_)
1164        );
1165        std::assert_matches!(
1166            translator.startup_failure(&configs[2].id()).unwrap().reason,
1167            StartupFailure::InitTaskPanicked
1168        );
1169    }
1170
1171    /// #528: after an init-task panic, a configured server that never
1172    /// registered gets a terminal `ServerFailedToStart` instead of
1173    /// `ServerInitializing` forever.
1174    #[tokio::test]
1175    async fn test_settle_after_init_panic_turns_initializing_into_startup_failure() {
1176        let config = crate::config::LspServerConfig::rust_analyzer();
1177        let id = config.id();
1178        let translator = Translator::new()
1179            .with_extensions(crate::test_lsp::test_extensions())
1180            .with_router(ToolRouter::catch_all([(
1181                id.clone(),
1182                LanguageId::from_static("rust"),
1183            )]));
1184        translator.set_expected_servers(HashSet::from([id.clone()]));
1185        let path = PathBuf::from("/ws/main.rs");
1186        let before = translator
1187            .client_for_file(&path, ToolKind::Hover)
1188            .unwrap_err();
1189        assert_matches!(before, Error::ServerInitializing { .. }, "got {before:?}");
1190
1191        translator
1192            .settle_after_init_panic(&[crate::test_lsp::init_config_for(config)])
1193            .await;
1194
1195        let after = translator
1196            .client_for_file(&path, ToolKind::Hover)
1197            .unwrap_err();
1198        assert_matches!(
1199                &after,
1200                Error::ServerFailedToStart(f)
1201                    if f.server_id == id && matches!(f.reason, StartupFailure::InitTaskPanicked),
1202            "got {after:?}"
1203        );
1204    }
1205
1206    /// A server that registered before the panic keeps serving.
1207    #[tokio::test]
1208    async fn test_settle_after_init_panic_keeps_registered_server_routable() {
1209        let config = crate::config::LspServerConfig::rust_analyzer();
1210        let id = config.id();
1211        let translator = Translator::new()
1212            .with_extensions(crate::test_lsp::test_extensions())
1213            .with_router(ToolRouter::catch_all([(
1214                id.clone(),
1215                LanguageId::from_static("rust"),
1216            )]));
1217        translator.set_expected_servers(HashSet::from([id.clone()]));
1218        translator
1219            .register_server_complete(crate::lsp::fake_lsp_server_with_config(config.clone()));
1220
1221        translator
1222            .settle_after_init_panic(&[crate::test_lsp::init_config_for(config)])
1223            .await;
1224
1225        let (routed, _client) = translator
1226            .client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
1227            .unwrap();
1228        assert_eq!(routed, id);
1229        assert!(translator.startup_failure(&id).is_none());
1230    }
1231
1232    #[test]
1233    fn test_record_startup_failures_orders_listing_by_server_id() {
1234        let translator = Translator::new();
1235        let failure = |id: &str| ServerSpawnFailure {
1236            server_id: ServerId::new(id).unwrap(),
1237            language_id: LanguageId::new(id).unwrap(),
1238            command: ServerCommand::new(id).unwrap(),
1239            reason: StartupFailure::InitTaskPanicked,
1240        };
1241        translator.record_startup_failures(&[failure("zls"), failure("clangd"), failure("gopls")]);
1242
1243        let ids: Vec<_> = translator
1244            .startup_failures()
1245            .into_iter()
1246            .map(|f| f.server_id.as_str().to_string())
1247            .collect();
1248        assert_eq!(ids, ["clangd", "gopls", "zls"]);
1249    }
1250
1251    /// A panicking shutdown task must be logged, not re-raised into the caller.
1252    #[tokio::test]
1253    async fn test_join_shutdown_tasks_survives_panicking_task() {
1254        let mut tasks = tokio::task::JoinSet::new();
1255        let handle = tasks.spawn(async { panic!("shutdown task boom") });
1256        let mut ids = HashMap::new();
1257        ids.insert(handle.id(), ServerId::from_static("rust"));
1258        tasks.spawn(async {});
1259
1260        join_shutdown_tasks(tasks, &ids).await;
1261    }
1262
1263    #[test]
1264    fn test_translator_new() {
1265        let translator = Translator::new();
1266        assert!(translator.workspace_roots.is_empty());
1267        assert_eq!(translator.registered_server_count(), 0);
1268    }
1269
1270    #[test]
1271    fn test_set_workspace_roots() {
1272        let mut translator = Translator::new();
1273        let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
1274        translator.set_workspace_roots(WorkspaceRoots::for_test(roots.clone(), Vec::new()));
1275        assert_eq!(translator.workspace_roots.canonical(), roots);
1276    }
1277
1278    #[test]
1279    fn test_register_server() {
1280        let translator = Translator::new();
1281
1282        // Initial state: no servers registered
1283        assert_eq!(translator.registered_server_count(), 0);
1284
1285        // The register_server method exists and is callable
1286        // Full integration testing with real LspServer is done in integration tests
1287        // This unit test verifies the method signature and basic functionality
1288
1289        // Note: We can't easily construct an LspServer in a unit test without async
1290        // and a real LSP server process. The actual registration functionality is
1291        // tested in integration tests (see rust_analyzer_tests.rs).
1292        // This test verifies the data structure is properly initialized.
1293    }
1294
1295    /// #241: `shutdown_servers` on an empty registry must return immediately
1296    /// rather than blocking (e.g. on a `JoinSet` that's never populated).
1297    #[tokio::test]
1298    async fn test_shutdown_servers_empty_registry_returns_promptly() {
1299        let translator = Translator::new();
1300
1301        let result =
1302            tokio::time::timeout(Duration::from_secs(1), translator.shutdown_servers()).await;
1303
1304        assert!(
1305            result.is_ok(),
1306            "shutdown_servers must return promptly when no servers are registered"
1307        );
1308    }
1309
1310    /// #241: `shutdown_servers` must drain every registered `LspServer` —
1311    /// this is the core behavior the issue is about (orphaned LSP children
1312    /// on shutdown). Uses `fake_lsp_server()` (an inert in-memory
1313    /// transport plus a real `LspServer`, see `lsp::lifecycle`), which
1314    /// won't answer the LSP `shutdown` handshake — proving the drain
1315    /// completes, via the error fallback path, without hanging on
1316    /// non-responsive servers.
1317    #[tokio::test]
1318    async fn test_shutdown_servers_drains_registered_servers() {
1319        let translator = Translator::new();
1320        translator.register_server(
1321            ServerId::from_static("server-a"),
1322            crate::lsp::fake_lsp_server(),
1323        );
1324        translator.register_server(
1325            ServerId::from_static("server-b"),
1326            crate::lsp::fake_lsp_server(),
1327        );
1328        assert_eq!(translator.registered_server_count(), 2);
1329
1330        // Bounded well above `lsp::SHUTDOWN_TIMEOUT` (10s) so a genuine
1331        // regression (a hang) still fails the test instead of the harness
1332        // itself timing out ambiguously.
1333        let result =
1334            tokio::time::timeout(Duration::from_secs(20), translator.shutdown_servers()).await;
1335
1336        assert!(
1337            result.is_ok(),
1338            "shutdown_servers must not hang against non-responsive mock servers"
1339        );
1340        assert_eq!(
1341            translator.registered_server_count(),
1342            0,
1343            "all registered servers must be drained"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() {
1349        // Mirrors the settled state of the real `serve_with` flow: the router
1350        // has dropped routes to servers that never registered and the
1351        // expected set is empty. Subsequent lookups must fall back to
1352        // NoServerForLanguage rather than keep implying the server is still
1353        // on its way.
1354        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
1355        let lang = LanguageMap::default().detect(&path);
1356        let id = ServerId::from(lang.clone());
1357
1358        let translator = Translator::new().with_router(ToolRouter::catch_all([(
1359            id.clone(),
1360            LanguageId::new(lang).unwrap(),
1361        )]));
1362        let mut expected = HashSet::new();
1363        expected.insert(id);
1364        translator.set_expected_servers(expected);
1365
1366        translator.rebind_router(&HashSet::new());
1367        translator.clear_expected_servers();
1368
1369        let err = translator
1370            .client_for_file(&path, ToolKind::Hover)
1371            .unwrap_err();
1372        assert_matches!(err, Error::NoServerForLanguage { .. });
1373    }
1374
1375    #[test]
1376    fn test_translator_with_custom_extensions() {
1377        let mut extension_map = HashMap::new();
1378        extension_map.insert(
1379            FileExtension::from_static("nu"),
1380            LanguageId::from_static("nushell"),
1381        );
1382        extension_map.insert(
1383            FileExtension::from_static("customext"),
1384            LanguageId::from_static("customlang"),
1385        );
1386
1387        let translator = Translator::new().with_extensions(extension_map);
1388
1389        assert_eq!(translator.language_map.detect(Path::new("a.nu")), "nushell");
1390        assert_eq!(
1391            translator.language_map.detect(Path::new("a.customext")),
1392            "customlang"
1393        );
1394        assert_eq!(translator.language_map.languages().count(), 2);
1395    }
1396
1397    /// `with_resource_limits` called before `with_extensions` (the order
1398    /// `serve()` uses) must reach `document_tracker`. With `max_documents:
1399    /// 1` and neither document locked, the second `ensure_open` evicts the
1400    /// first (#495) rather than failing -- `document_tracker.len()` staying
1401    /// at 1 is what proves the limit actually reached the tracker. Goes
1402    /// through `ensure_open` (not the raw `open`) so the first document is
1403    /// disk-verified and therefore actually evictable (#495 S4).
1404    #[tokio::test]
1405    async fn test_with_resource_limits_applies_before_with_extensions() {
1406        let limits = ResourceLimits {
1407            max_documents: DocumentLimit::new(1),
1408            max_file_size: SizeLimit::UNLIMITED,
1409        };
1410        let translator = Translator::new()
1411            .with_resource_limits(limits)
1412            .with_extensions(LanguageMap::default());
1413
1414        let dir = TempDir::new().unwrap();
1415        let path_a = dir.path().join("a.rs");
1416        std::fs::write(&path_a, "a").unwrap();
1417        let path_b = dir.path().join("b.rs");
1418        std::fs::write(&path_b, "b").unwrap();
1419
1420        let (client, _server) = fake_lsp_client();
1421        let server_id = ServerId::from_static("rust");
1422
1423        translator
1424            .document_tracker
1425            .ensure_open(&path_a, &server_id, &client)
1426            .await
1427            .unwrap();
1428        translator
1429            .document_tracker
1430            .ensure_open(&path_b, &server_id, &client)
1431            .await
1432            .unwrap();
1433        assert_eq!(translator.document_tracker.len(), 1);
1434        assert!(!translator.document_tracker.is_open(&path_a));
1435        assert!(translator.document_tracker.is_open(&path_b));
1436    }
1437
1438    /// `with_resource_limits` called *after* `with_extensions` (the reverse
1439    /// of `serve()`'s order) must still reach `document_tracker` -- the two
1440    /// builders must not clobber each other regardless of call order. See
1441    /// `Translator::with_resource_limits`'s docs.
1442    ///
1443    /// Uses a non-empty extension map (unlike the "before" test above) and
1444    /// asserts it survived `with_resource_limits`'s rebuild by checking the
1445    /// tracked document's resolved `language_id` -- a bug that dropped the
1446    /// extension map (e.g. rebuilding from `HashMap::new()` instead of
1447    /// `self.extension_map`) would leave `max_documents` correct but the
1448    /// extension map silently empty, which the "before" test alone cannot
1449    /// detect.
1450    #[tokio::test]
1451    async fn test_with_resource_limits_applies_after_with_extensions() {
1452        let limits = ResourceLimits {
1453            max_documents: DocumentLimit::new(1),
1454            max_file_size: SizeLimit::UNLIMITED,
1455        };
1456        let translator = Translator::new()
1457            .with_extensions(HashMap::from([(
1458                FileExtension::from_static("rs"),
1459                LanguageId::from_static("rust"),
1460            )]))
1461            .with_resource_limits(limits);
1462
1463        let dir = TempDir::new().unwrap();
1464        let path_a = dir.path().join("a.rs");
1465        std::fs::write(&path_a, "a").unwrap();
1466        let path_b = dir.path().join("b.rs");
1467        std::fs::write(&path_b, "b").unwrap();
1468
1469        let (client, _server) = fake_lsp_client();
1470        let server_id = ServerId::from_static("rust");
1471
1472        translator
1473            .document_tracker
1474            .ensure_open(&path_a, &server_id, &client)
1475            .await
1476            .unwrap();
1477        translator
1478            .document_tracker
1479            .ensure_open(&path_b, &server_id, &client)
1480            .await
1481            .unwrap();
1482        assert_eq!(translator.document_tracker.len(), 1);
1483        assert!(!translator.document_tracker.is_open(&path_a));
1484
1485        let state = translator.document_tracker.close(&path_b).unwrap();
1486        assert_eq!(
1487            state.language_id(),
1488            "rust",
1489            "extension map must have survived with_resource_limits's rebuild"
1490        );
1491    }
1492
1493    fn refusal_of(config: &crate::config::LspServerConfig) -> ServerSpawnFailure {
1494        ServerSpawnFailure {
1495            reason: StartupFailure::RefusedUntrustedWorkspace(
1496                crate::error::UntrustedRefusal::NotAllowed {
1497                    builtin: Some(crate::config::BuiltinServer::RustAnalyzer),
1498                },
1499            ),
1500            ..spawn_failure(config)
1501        }
1502    }
1503
1504    #[test]
1505    fn test_record_refusals_without_refusals_leaves_translator_untouched() {
1506        let config = crate::config::LspServerConfig::rust_analyzer();
1507        let translator =
1508            Translator::new().with_router(ToolRouter::from_configs([&config]).unwrap());
1509        let before = translator.router_snapshot();
1510
1511        translator.record_refusals(&[]);
1512
1513        assert!(translator.startup_failures().is_empty());
1514        assert!(Arc::ptr_eq(&before, &translator.router_snapshot()));
1515    }
1516
1517    #[test]
1518    fn test_refused_server_without_catch_all_reports_the_refusal() {
1519        let config = crate::config::LspServerConfig::rust_analyzer();
1520        let translator = Translator::new()
1521            .with_extensions(crate::test_lsp::test_extensions())
1522            .with_router(ToolRouter::from_configs([&config]).unwrap());
1523
1524        translator.record_refusals(&[refusal_of(&config)]);
1525
1526        let err = translator
1527            .client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
1528            .unwrap_err();
1529        assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == config.id());
1530        let text = err.to_string();
1531        assert!(text.contains("'rust'"), "{text}");
1532        assert!(text.contains("--allow-server rust"), "{text}");
1533        assert!(!text.contains("not retried"), "{text}");
1534    }
1535
1536    #[tokio::test]
1537    async fn test_refused_explicit_server_leaves_allowed_catch_all_serving() {
1538        let explicit = named_config("explicit", "rust", Some(vec![ToolKind::Hover]));
1539        let catch_all = named_config("allowed", "rust", None);
1540        let translator = Translator::new()
1541            .with_extensions(crate::test_lsp::test_extensions())
1542            .with_router(ToolRouter::from_configs([&explicit, &catch_all]).unwrap());
1543        translator.set_expected_servers(HashSet::from([catch_all.id()]));
1544
1545        translator.record_refusals(&[refusal_of(&explicit)]);
1546        translator.settle_started(crate::lsp::fake_lsp_server_with_config(catch_all.clone()));
1547
1548        let (routed, _client) = translator
1549            .client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
1550            .unwrap();
1551        assert_eq!(routed, catch_all.id());
1552    }
1553}