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};
12
13use tokio::sync::Mutex;
14
15use self::clock::{Clock, SystemClock};
16use self::encoding_ctx::EncodingCtx;
17use self::respawn::RespawnBackoff;
18use crate::bridge::encoding::PositionEncoding;
19use crate::bridge::state::ResourceLimits;
20use crate::bridge::{DocumentTracker, NotificationCache, lock_std};
21use crate::config::{ServerId, ToolKind, ToolRouter};
22use crate::lsp::{LspClient, LspServer, ServerInitConfig};
23
24mod assist;
25mod call_hierarchy;
26mod clock;
27mod diagnostics;
28mod dto;
29mod edits;
30mod encoding_ctx;
31mod navigation;
32mod respawn;
33mod routing;
34mod symbols;
35#[cfg(test)]
36#[allow(clippy::unwrap_used, clippy::expect_used)]
37mod testing;
38
39pub use dto::*;
40pub use routing::validate_path_against_roots;
41
42/// Translator handles MCP tool calls by converting them to LSP requests.
43///
44/// All fields use interior mutability so `Translator` can be shared via a
45/// plain `Arc<Translator>` with no outer lock: every LSP tool call would
46/// otherwise serialize behind a single mutex for its entire round trip
47/// (including the LSP request timeout), which is the root cause fixed here.
48/// Each field is locked independently and only for the short, synchronous
49/// section that touches it. In particular, the actual LSP request/response
50/// round trip (`client.request(...)`) always runs with no lock held.
51///
52/// `document_tracker` is no exception: `DocumentTracker` locks its own state
53/// per-path internally (see its docs), so `prepare_document`'s call into
54/// `ensure_open` never holds a lock shared across unrelated paths or
55/// languages while it does that document's disk I/O and
56/// `textDocument/didOpen`/`didChange` notify.
57#[derive(Debug)]
58pub struct Translator {
59    /// LSP clients indexed by routing identity. Locked only for the map
60    /// lookup/insert itself, never across an LSP request.
61    lsp_clients: Arc<StdMutex<HashMap<ServerId, LspClient>>>,
62    /// LSP servers indexed by routing identity (held for lifetime management).
63    lsp_servers: Arc<StdMutex<HashMap<ServerId, LspServer>>>,
64    /// Document state tracker. Locks its own state internally, per path.
65    document_tracker: Arc<DocumentTracker>,
66    /// Resource limits `document_tracker` was last built with. Kept
67    /// alongside `document_tracker` so [`Self::with_extensions`] and
68    /// [`Self::with_resource_limits`] can each rebuild the tracker from
69    /// whichever of (limits, extension map) the other has already set,
70    /// regardless of call order -- see [`Self::with_resource_limits`].
71    resource_limits: ResourceLimits,
72    /// Allowed workspace roots for path validation. Read-only after `serve()`
73    /// setup, so no lock is needed.
74    workspace_roots: Arc<Vec<PathBuf>>,
75    /// Custom file extension to language ID mappings. Read-only after
76    /// `serve()` setup, so no lock is needed.
77    extension_map: Arc<HashMap<String, String>>,
78    /// Servers that are configured + applicable but may not have finished
79    /// initializing yet (background init). Used to return a clear "still
80    /// initializing" error instead of "no server configured".
81    expected_servers: Arc<StdMutex<HashSet<ServerId>>>,
82    /// Per-tool routing table: resolves `(language, tool)` to a `ServerId`.
83    /// Locked independently so `rebind_router` (called from a background
84    /// task once registration completes) never contends with an in-flight
85    /// LSP round trip.
86    router: Arc<StdMutex<ToolRouter>>,
87    /// Configs needed to respawn a server if its process dies later, keyed
88    /// by routing identity. Populated once per server right after a
89    /// successful spawn (see [`Self::register_server_config`]); the respawn
90    /// path ([`Self::respawn_if_dead`]) is the only reader.
91    server_configs: Arc<StdMutex<HashMap<ServerId, ServerInitConfig>>>,
92    /// Per-server single-flight lock so concurrent callers that both observe
93    /// a dead process don't race to respawn it independently -- the loser
94    /// waits for the winner's attempt to finish (success or failure) and
95    /// then re-reads whatever ended up registered. See
96    /// [`Self::respawn_if_dead`].
97    respawn_locks: Arc<StdMutex<HashMap<ServerId, Arc<Mutex<()>>>>>,
98    /// Consecutive respawn failures and last-attempt time per server, so a
99    /// crash-looping server backs off instead of eating a fresh
100    /// `timeout_seconds` on every tool call that arrives while it is down.
101    /// See [`Self::respawn_if_dead`].
102    respawn_backoffs: Arc<StdMutex<HashMap<ServerId, RespawnBackoff>>>,
103    /// Diagnostics cache, shared with `serve_with`'s notification pump.
104    ///
105    /// `None` for a `Translator` built without [`Self::with_notification_cache`]
106    /// (e.g. most unit tests). When present, [`Self::respawn_if_dead`] uses
107    /// it to invalidate a respawned server's stale cached diagnostics --
108    /// see that method's docs for why that matters.
109    notification_cache: Option<Arc<Mutex<NotificationCache>>>,
110    /// Time source for respawn-backoff bookkeeping ([`respawn`](self::respawn)).
111    /// Always [`SystemClock`] in production; overridden via
112    /// [`Self::with_clock`] in tests so backoff-window tests can advance
113    /// time deterministically instead of sleeping in real time.
114    clock: Arc<dyn Clock>,
115}
116
117/// Upper bound on how long [`Translator::shutdown_servers`] waits for a
118/// single LSP server's graceful `shutdown`/`exit` handshake before giving up
119/// and letting `kill_on_drop` terminate it instead.
120const SERVER_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
121
122impl Translator {
123    /// Create a new translator.
124    ///
125    /// Starts with an empty router: nothing is routable until [`Self::with_router`]
126    /// installs one, which matches having no servers registered.
127    #[must_use]
128    pub fn new() -> Self {
129        Self {
130            lsp_clients: Arc::new(StdMutex::new(HashMap::new())),
131            lsp_servers: Arc::new(StdMutex::new(HashMap::new())),
132            document_tracker: Arc::new(DocumentTracker::new(
133                ResourceLimits::default(),
134                HashMap::new(),
135            )),
136            resource_limits: ResourceLimits::default(),
137            workspace_roots: Arc::new(Vec::new()),
138            extension_map: Arc::new(HashMap::new()),
139            expected_servers: Arc::new(StdMutex::new(HashSet::new())),
140            router: Arc::new(StdMutex::new(ToolRouter::default())),
141            server_configs: Arc::new(StdMutex::new(HashMap::new())),
142            respawn_locks: Arc::new(StdMutex::new(HashMap::new())),
143            respawn_backoffs: Arc::new(StdMutex::new(HashMap::new())),
144            notification_cache: None,
145            clock: Arc::new(SystemClock),
146        }
147    }
148
149    /// Override the time source used by respawn-backoff bookkeeping.
150    ///
151    /// Test-only: production always uses [`SystemClock`]. Lets
152    /// backoff-window tests advance a `FakeClock` deterministically instead
153    /// of sleeping in real time.
154    #[cfg(test)]
155    #[must_use]
156    fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
157        self.clock = clock;
158        self
159    }
160
161    /// Set the workspace roots for path validation.
162    ///
163    /// Only called during single-owner setup, before the translator is
164    /// shared, so this replaces the `Arc` wholesale rather than locking.
165    pub fn set_workspace_roots(&mut self, roots: Vec<PathBuf>) {
166        self.workspace_roots = Arc::new(roots);
167    }
168
169    /// Give the translator a handle to the shared diagnostics cache, so the
170    /// respawn path can invalidate a respawned server's stale entries.
171    ///
172    /// Only called during single-owner setup (mirrors [`Self::with_router`]),
173    /// before the translator is shared -- `serve_with` passes the same
174    /// `Arc<Mutex<NotificationCache>>` used by the notification pump tasks.
175    #[must_use]
176    pub fn with_notification_cache(mut self, cache: Arc<Mutex<NotificationCache>>) -> Self {
177        self.notification_cache = Some(cache);
178        self
179    }
180
181    /// Mark the set of servers that are expected (configured + applicable)
182    /// but may still be initializing in the background.
183    pub fn set_expected_servers(&self, servers: HashSet<ServerId>) {
184        *lock_std(&self.expected_servers) = servers;
185    }
186
187    /// Clear the expected-servers set (e.g. after background init failed).
188    pub fn clear_expected_servers(&self) {
189        lock_std(&self.expected_servers).clear();
190    }
191
192    /// Install the per-tool routing table built from the applicable configs.
193    ///
194    /// Only called during single-owner setup, before the translator is
195    /// shared, so this replaces the `Arc`-wrapped router wholesale.
196    #[must_use]
197    pub fn with_router(mut self, router: ToolRouter) -> Self {
198        self.router = Arc::new(StdMutex::new(router));
199        self
200    }
201
202    /// Rebind the routing table to the set of servers that actually
203    /// registered, dropping or redirecting routes to servers that failed to
204    /// spawn. See `ToolRouter::rebind_to_registered` for the full semantics.
205    pub fn rebind_router(&self, registered: &HashSet<ServerId>) {
206        lock_std(&self.router).rebind_to_registered(registered);
207    }
208
209    /// Whether `id` is the server the router currently resolves
210    /// `ToolKind::Diagnostics` to for `language_id`.
211    ///
212    /// Purpose-built for `register_servers`, which needs this to compute the
213    /// diagnostics-cache filter passed into each pump task, without exposing
214    /// the router's lock guard outside this module.
215    #[must_use]
216    pub fn is_diagnostics_route(&self, language_id: &str, id: &ServerId) -> bool {
217        lock_std(&self.router).resolve(language_id, ToolKind::Diagnostics) == Some(id)
218    }
219
220    /// Negotiated [`PositionEncoding`] of the registered server `id`, or the
221    /// LSP spec's own default (UTF-16) if `id` is not currently registered.
222    ///
223    /// Note this falls back to UTF-16, not [`PositionEncoding::default`]
224    /// (UTF-8): UTF-16 is what an absent/unrecognized negotiation means per
225    /// the LSP spec and what [`crate::lsp::LspServer::spawn`] itself falls
226    /// back to, so this must match rather than use the bridge type's own
227    /// default, which exists only for `PositionEncoding`'s own internal use.
228    #[must_use]
229    pub(crate) fn position_encoding_for(&self, server_id: &ServerId) -> PositionEncoding {
230        lock_std(&self.lsp_servers)
231            .get(server_id)
232            .and_then(|server| PositionEncoding::from_lsp(server.position_encoding().as_str()))
233            .unwrap_or(PositionEncoding::Utf16)
234    }
235
236    /// Build the [`EncodingCtx`] for converting positions/ranges in
237    /// responses from the registered server `id`.
238    fn encoding_ctx(&self, server_id: &ServerId) -> EncodingCtx {
239        EncodingCtx {
240            encoding: self.position_encoding_for(server_id),
241            tracker: self.document_tracker.clone(),
242        }
243    }
244
245    /// Rebuilds `document_tracker` from `self.resource_limits` and
246    /// `self.extension_map`, whatever the two are currently set to.
247    ///
248    /// Called by every builder that touches either input ([`Self::with_extensions`],
249    /// [`Self::with_resource_limits`]), so each one only needs to set its own
250    /// field and call this -- it always reads *both* current values, so the
251    /// builders remain order-independent (see [`Self::with_resource_limits`])
252    /// without each one needing to know the other's field. A future builder
253    /// that adds a third tracker input should follow the same pattern:
254    /// update its own field, then call this.
255    fn rebuild_document_tracker(&mut self) {
256        self.document_tracker = Arc::new(DocumentTracker::new(
257            self.resource_limits,
258            (*self.extension_map).clone(),
259        ));
260    }
261
262    /// Configure custom file extension mappings.
263    ///
264    /// This method sets the extension map and updates the document tracker
265    /// to use the same mappings for language detection.
266    ///
267    /// Only called during single-owner setup, before the translator is
268    /// shared, so this replaces the `Arc`-wrapped fields wholesale.
269    #[must_use]
270    pub fn with_extensions(mut self, extension_map: HashMap<String, String>) -> Self {
271        self.extension_map = Arc::new(extension_map);
272        self.rebuild_document_tracker();
273        self
274    }
275
276    /// Configure resource limits (max open documents, max file size) for the
277    /// document tracker.
278    ///
279    /// Only called during single-owner setup, before the translator is
280    /// shared. This builder and [`Self::with_extensions`] may be called in
281    /// either order -- each rebuilds `document_tracker` from *both* of
282    /// `self.resource_limits`/`self.extension_map`'s current values,
283    /// instead of one of them starting fresh from
284    /// `ResourceLimits::default()`/an empty extension map, which previously
285    /// meant whichever builder ran last silently discarded the other's
286    /// effect.
287    #[must_use]
288    pub fn with_resource_limits(mut self, limits: ResourceLimits) -> Self {
289        self.resource_limits = limits;
290        self.rebuild_document_tracker();
291        self
292    }
293
294    /// Register an LSP client under its routing identity.
295    ///
296    /// Only called once per server, from `register_servers` during initial
297    /// background init. The respawn path does not reuse this method: it
298    /// needs the previous client back (to fail its pending requests) and
299    /// must also reset `document_tracker` for the swapped-in server, neither
300    /// of which this method does.
301    pub fn register_client(&self, id: impl Into<ServerId>, client: LspClient) {
302        lock_std(&self.lsp_clients).insert(id.into(), client);
303    }
304
305    /// Register an LSP server under its routing identity.
306    pub fn register_server(&self, id: impl Into<ServerId>, server: LspServer) {
307        lock_std(&self.lsp_servers).insert(id.into(), server);
308    }
309
310    /// Store the config needed to respawn `id` if its process dies later.
311    ///
312    /// Called once per server, right after a successful spawn (see the
313    /// crate-root `register_servers`); [`Self::respawn_if_dead`] is the only
314    /// reader.
315    pub(crate) fn register_server_config(&self, id: impl Into<ServerId>, config: ServerInitConfig) {
316        lock_std(&self.server_configs).insert(id.into(), config);
317    }
318
319    /// Number of currently registered LSP servers.
320    ///
321    /// Test-only: `lsp_servers` is private, so this is the one way a test
322    /// outside this module (e.g. `crate::tests`, exercising
323    /// [`Translator::shutdown_servers`] indirectly through `serve_with`'s
324    /// shutdown sequence) can observe that a registered server was actually
325    /// drained.
326    #[cfg(test)]
327    pub(crate) fn registered_server_count(&self) -> usize {
328        lock_std(&self.lsp_servers).len()
329    }
330
331    /// Snapshot of currently open document paths, used for MCP resource listing.
332    #[must_use]
333    pub fn open_document_paths(&self) -> Vec<PathBuf> {
334        self.document_tracker.open_paths()
335    }
336
337    /// Whether a document is currently tracked as open.
338    #[must_use]
339    pub fn is_document_open(&self, path: &Path) -> bool {
340        self.document_tracker.is_open(path)
341    }
342
343    /// The document tracker, shared with [`EncodingCtx`] so a cache-only
344    /// caller (e.g. `get_cached_diagnostics`) can still prefer tracked
345    /// in-memory content over a disk read when converting positions.
346    #[must_use]
347    pub(crate) const fn document_tracker(&self) -> &Arc<DocumentTracker> {
348        &self.document_tracker
349    }
350
351    /// Gracefully shut down every registered LSP server.
352    ///
353    /// Drains the registered LSP servers and, for each one concurrently,
354    /// sends the LSP `shutdown` request and `exit` notification via
355    /// [`LspServer::shutdown`], bounded by a fixed per-server timeout. A
356    /// server that errors or fails to respond in time is simply dropped
357    /// instead: its child process handle is `kill_on_drop(true)`, so the
358    /// process is killed rather than left running. Call this once, from the
359    /// top-level shutdown path, after the MCP transport has stopped
360    /// accepting new requests.
361    ///
362    /// # Limitations
363    ///
364    /// This only runs on the normal shutdown path (stdio EOF, `SIGTERM`/
365    /// `SIGINT`, or the HTTP transport's own graceful shutdown). This crate's
366    /// workspace `[profile.release]` builds with `panic = "abort"`, so a
367    /// panic reachable from a request handler or background pump task in a
368    /// release build still terminates the process without unwinding — this
369    /// method never runs, and spawned LSP children are orphaned exactly as
370    /// before this fix. Making that path safe would need process-group
371    /// isolation (`kill_on_drop` alone doesn't help, since no `Drop` runs
372    /// either); tracked separately, out of scope here.
373    ///
374    /// `pub(crate)` rather than `pub`: this is meant for exactly one call
375    /// site (`serve_with`'s post-transport shutdown sequence), after the MCP
376    /// transport is already down. An external caller invoking it mid-session
377    /// would drain `lsp_servers` while `lsp_clients` (routing table) still
378    /// points at the now-shut-down servers, so in-flight tool calls would
379    /// resolve to a client whose server is gone.
380    pub(crate) async fn shutdown_servers(&self) {
381        let servers: Vec<(ServerId, LspServer)> = lock_std(&self.lsp_servers).drain().collect();
382        if servers.is_empty() {
383            return;
384        }
385
386        let mut tasks = tokio::task::JoinSet::new();
387        for (id, server) in servers {
388            tasks.spawn(async move {
389                match tokio::time::timeout(SERVER_SHUTDOWN_TIMEOUT, server.shutdown()).await {
390                    Ok(Ok(())) => tracing::debug!(%id, "LSP server shut down gracefully"),
391                    Ok(Err(e)) => tracing::warn!(
392                        %id, error = %e,
393                        "LSP server shutdown handshake failed, killing process instead"
394                    ),
395                    Err(_) => tracing::warn!(
396                        %id, timeout = ?SERVER_SHUTDOWN_TIMEOUT,
397                        "LSP server did not shut down in time, killing process instead"
398                    ),
399                }
400            });
401        }
402        tasks.join_all().await;
403    }
404}
405
406impl Default for Translator {
407    fn default() -> Self {
408        Self::new()
409    }
410}
411
412#[cfg(test)]
413#[allow(clippy::unwrap_used, clippy::expect_used)]
414mod tests {
415    use std::collections::{HashMap, HashSet};
416    use std::path::PathBuf;
417
418    use tokio::time::Duration;
419
420    use super::*;
421    use crate::bridge::state::detect_language;
422    use crate::config::{ServerId, ToolKind, ToolRouter};
423    use crate::error::Error;
424
425    #[test]
426    fn test_translator_new() {
427        let translator = Translator::new();
428        assert_eq!(translator.workspace_roots.len(), 0);
429        assert_eq!(lock_std(&translator.lsp_clients).len(), 0);
430        assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
431    }
432
433    #[test]
434    fn test_set_workspace_roots() {
435        let mut translator = Translator::new();
436        let roots = vec![PathBuf::from("/test/root1"), PathBuf::from("/test/root2")];
437        translator.set_workspace_roots(roots.clone());
438        assert_eq!(*translator.workspace_roots, roots);
439    }
440
441    #[test]
442    fn test_register_server() {
443        let translator = Translator::new();
444
445        // Initial state: no servers registered
446        assert_eq!(lock_std(&translator.lsp_servers).len(), 0);
447
448        // The register_server method exists and is callable
449        // Full integration testing with real LspServer is done in integration tests
450        // This unit test verifies the method signature and basic functionality
451
452        // Note: We can't easily construct an LspServer in a unit test without async
453        // and a real LSP server process. The actual registration functionality is
454        // tested in integration tests (see rust_analyzer_tests.rs).
455        // This test verifies the data structure is properly initialized.
456    }
457
458    /// #241: `shutdown_servers` on an empty registry must return immediately
459    /// rather than blocking (e.g. on a `JoinSet` that's never populated).
460    #[tokio::test]
461    async fn test_shutdown_servers_empty_registry_returns_promptly() {
462        let translator = Translator::new();
463
464        let result =
465            tokio::time::timeout(Duration::from_secs(1), translator.shutdown_servers()).await;
466
467        assert!(
468            result.is_ok(),
469            "shutdown_servers must return promptly when no servers are registered"
470        );
471    }
472
473    /// #241: `shutdown_servers` must drain every registered `LspServer` —
474    /// this is the core behavior the issue is about (orphaned LSP children
475    /// on shutdown). Uses `fake_lsp_server()` (mock `echo`/`cat` child
476    /// processes, real `LspServer`, see `lsp::lifecycle`), which won't
477    /// answer the LSP `shutdown` handshake — proving the drain completes,
478    /// via the timeout/error fallback path, without hanging on
479    /// non-responsive servers.
480    #[tokio::test]
481    async fn test_shutdown_servers_drains_registered_servers() {
482        let translator = Translator::new();
483        translator.register_server("server-a", crate::lsp::fake_lsp_server());
484        translator.register_server("server-b", crate::lsp::fake_lsp_server());
485        assert_eq!(lock_std(&translator.lsp_servers).len(), 2);
486
487        // Bounded well above `SERVER_SHUTDOWN_TIMEOUT` (10s) so a genuine
488        // regression (a hang) still fails the test instead of the harness
489        // itself timing out ambiguously.
490        let result =
491            tokio::time::timeout(Duration::from_secs(20), translator.shutdown_servers()).await;
492
493        assert!(
494            result.is_ok(),
495            "shutdown_servers must not hang against non-responsive mock servers"
496        );
497        assert_eq!(
498            lock_std(&translator.lsp_servers).len(),
499            0,
500            "all registered servers must be drained"
501        );
502    }
503
504    #[test]
505    fn test_clear_expected_servers_reverts_to_no_server_after_all_routes_dropped() {
506        // Mirrors the real `serve_with` flow: `rebind_router` (called from
507        // `register_servers`/the all-failed path) drops routes to servers
508        // that never registered, then `clear_expected_servers` runs under
509        // the same lock. Subsequent lookups must fall back to
510        // NoServerForLanguage rather than keep implying the server is still
511        // on its way.
512        let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
513        let lang = detect_language(&path, &HashMap::new());
514        let id = ServerId::from(lang.clone());
515
516        let translator = Translator::new().with_router(ToolRouter::catch_all([(id.clone(), lang)]));
517        let mut expected = HashSet::new();
518        expected.insert(id);
519        translator.set_expected_servers(expected);
520
521        translator.rebind_router(&HashSet::new());
522        translator.clear_expected_servers();
523
524        let err = translator
525            .get_client_for_file(&path, ToolKind::Hover)
526            .unwrap_err();
527        assert!(matches!(err, Error::NoServerForLanguage(_)));
528    }
529
530    #[test]
531    fn test_translator_with_custom_extensions() {
532        let mut extension_map = HashMap::new();
533        extension_map.insert("nu".to_string(), "nushell".to_string());
534        extension_map.insert("customext".to_string(), "customlang".to_string());
535
536        let translator = Translator::new().with_extensions(extension_map.clone());
537
538        assert_eq!(translator.extension_map.len(), 2);
539        assert_eq!(
540            translator.extension_map.get("nu"),
541            Some(&"nushell".to_string())
542        );
543        assert_eq!(
544            translator.extension_map.get("customext"),
545            Some(&"customlang".to_string())
546        );
547    }
548
549    /// `with_resource_limits` called before `with_extensions` (the order
550    /// `serve()` uses) must reach `document_tracker`.
551    #[test]
552    fn test_with_resource_limits_applies_before_with_extensions() {
553        let limits = ResourceLimits {
554            max_documents: 1,
555            max_file_size: 0,
556        };
557        let translator = Translator::new()
558            .with_resource_limits(limits)
559            .with_extensions(HashMap::new());
560
561        translator
562            .document_tracker
563            .open(PathBuf::from("/tmp/a.rs"), "a".to_string())
564            .unwrap();
565        let err = translator
566            .document_tracker
567            .open(PathBuf::from("/tmp/b.rs"), "b".to_string())
568            .unwrap_err();
569        assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. }));
570    }
571
572    /// `with_resource_limits` called *after* `with_extensions` (the reverse
573    /// of `serve()`'s order) must still reach `document_tracker` -- the two
574    /// builders must not clobber each other regardless of call order. See
575    /// `Translator::with_resource_limits`'s docs.
576    ///
577    /// Uses a non-empty extension map (unlike the "before" test above) and
578    /// asserts it survived `with_resource_limits`'s rebuild by checking the
579    /// tracked document's resolved `language_id` -- a bug that dropped the
580    /// extension map (e.g. rebuilding from `HashMap::new()` instead of
581    /// `self.extension_map`) would leave `max_documents` correct but the
582    /// extension map silently empty, which the "before" test alone cannot
583    /// detect.
584    #[test]
585    fn test_with_resource_limits_applies_after_with_extensions() {
586        let limits = ResourceLimits {
587            max_documents: 1,
588            max_file_size: 0,
589        };
590        let translator = Translator::new()
591            .with_extensions(HashMap::from([("rs".to_string(), "rust".to_string())]))
592            .with_resource_limits(limits);
593
594        let path = PathBuf::from("/tmp/a.rs");
595        translator
596            .document_tracker
597            .open(path.clone(), "a".to_string())
598            .unwrap();
599        let err = translator
600            .document_tracker
601            .open(PathBuf::from("/tmp/b.rs"), "b".to_string())
602            .unwrap_err();
603        assert!(matches!(err, Error::DocumentLimitExceeded { max: 1, .. }));
604
605        let state = translator.document_tracker.close(&path).unwrap();
606        assert_eq!(state.language_id(), "rust");
607    }
608}