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