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