Skip to main content

mcpls_core/bridge/translator/
diagnostics.rs

1//! Diagnostics pull/push merging, cache-derived diagnostics, and server
2//! log/message retrieval.
3
4use std::sync::{Arc, LazyLock};
5
6use lsp_types::{
7    DocumentDiagnosticParams, PartialResultParams, TextDocumentIdentifier, Uri,
8    WorkDoneProgressParams,
9};
10use tokio::sync::Mutex;
11use tracing::{debug, info};
12
13use super::Translator;
14use super::availability::{DiagnosticsAnswer, DiagnosticsAvailability, DiagnosticsOrigin};
15use super::dto::{
16    Diagnostic, DiagnosticsResult, DocumentDiagnosticsResult, ServerLogsResult,
17    ServerMessagesResult,
18};
19use super::enclosing::{Contextualized, ResultContext};
20use super::encoding_ctx::EncodingCtx;
21use super::pull_support::{PullProbe, PullSupport};
22use super::routing::PreparedDocument;
23use crate::bridge::encoding::PositionEncoding;
24use crate::bridge::notifications::{
25    BoundedDiagnostics, ChangeOutcome, LogLevel, PullStamp, PullWrite, ReportedSeverity,
26    SlotChange, SnapshotBefore, VersionCheck, message_as_str, reported_code,
27};
28use crate::bridge::{
29    ClientPath, DiagnosticInfo, DiagnosticSources, DiagnosticsKey, DocumentTracker,
30    NotificationCache, WorkspacePath, WorkspaceRoots, path_to_uri,
31};
32use crate::config::{ServerId, ToolKind};
33use crate::error::{Error, Result};
34use crate::lsp::{ConnectionId, UnclassifiedError};
35use crate::util::lock_std;
36
37/// Hand-rolled union of `textDocument/diagnostic`'s two possible result
38/// shapes.
39///
40/// `gen-lsp-types` types `DocumentDiagnosticRequest::Result` as the
41/// non-nullable `DocumentDiagnosticReport` alone, splitting the streaming
42/// `Partial` shape off into `RequestWithPartialResults::PartialResult`
43/// (`DocumentDiagnosticReportProgress`) -- binding this call to that typed
44/// result via `LspClient::request_typed` would turn a partial response into
45/// a deserialization error where today it degrades to an empty diagnostics
46/// list. This preserves the union gluon's `DocumentDiagnosticReportResult`
47/// used to provide, via the untyped `LspClient::request`.
48#[derive(serde::Serialize, serde::Deserialize)]
49#[serde(untagged)]
50enum DocumentDiagnosticReportResult {
51    Report(lsp_types::DocumentDiagnosticReport),
52    // The partial shape's content is never read -- matching this variant at
53    // all (rather than failing to deserialize) is the only thing that
54    // matters, so today's behavior of degrading to an empty diagnostics list
55    // is preserved.
56    Partial(
57        #[allow(
58            dead_code,
59            reason = "the partial-result payload is only deserialized, never read"
60        )]
61        lsp_types::DocumentDiagnosticReportPartialResult,
62    ),
63}
64
65/// `textDocument/diagnostic` with [`DocumentDiagnosticReportResult`] as its
66/// result, so it can go through `LspClient::request_typed_classified` and the
67/// caller decides how an error response is logged.
68enum PullDiagnosticRequest {}
69
70impl lsp_types::Request for PullDiagnosticRequest {
71    type Params = DocumentDiagnosticParams;
72    type Result = DocumentDiagnosticReportResult;
73    const METHOD: lsp_types::LspRequestMethod<'static> =
74        <lsp_types::DocumentDiagnosticRequest as lsp_types::Request>::METHOD;
75    const MESSAGE_DIRECTION: lsp_types::MessageDirection =
76        <lsp_types::DocumentDiagnosticRequest as lsp_types::Request>::MESSAGE_DIRECTION;
77}
78
79/// How a pull request ended.
80enum PullAttempt {
81    /// The server answered; holds the items of a full report.
82    Answered(Option<BoundedDiagnostics>),
83    /// No pull answered and none is expected to: the request was not sent, or a
84    /// server advertising no provider refused it with `-32601`.
85    PushOnly,
86    /// The request failed.
87    Failed(Error),
88}
89
90impl PullAttempt {
91    /// Where an answer built after this attempt came from.
92    const fn origin(&self) -> DiagnosticsOrigin {
93        match self {
94            Self::Answered(_) => DiagnosticsOrigin::Pull,
95            Self::PushOnly => DiagnosticsOrigin::PushCache,
96            Self::Failed(_) => DiagnosticsOrigin::CacheAfterFailedPull,
97        }
98    }
99}
100
101/// What a `textDocument/diagnostic` answer says about the file.
102enum PullReport {
103    /// A full report: the file's diagnostics as the server sees them now.
104    Full(Vec<lsp_types::Diagnostic>),
105    /// An `unchanged` or partial answer, which says nothing a cache could
106    /// replace its slot with.
107    NotStored,
108}
109
110impl From<DocumentDiagnosticReportResult> for PullReport {
111    fn from(response: DocumentDiagnosticReportResult) -> Self {
112        match response {
113            DocumentDiagnosticReportResult::Report(
114                lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(full),
115            ) => Self::Full(full.full_document_diagnostic_report.items),
116            DocumentDiagnosticReportResult::Report(
117                lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport(_),
118            )
119            | DocumentDiagnosticReportResult::Partial(_) => Self::NotStored,
120        }
121    }
122}
123
124/// Whether a pull report became the file's pulled slot.
125enum PullStorage {
126    /// Stored; the change it made to the slot.
127    Stored(SlotChange),
128    /// Only part of the result, not of the cache.
129    NotStored,
130}
131
132/// A pull report after it met the cache.
133struct PullSettlement {
134    /// Snapshot of the file's sources with the report in it.
135    sources: DiagnosticSources,
136    storage: PullStorage,
137    /// Files whose entries were evicted to make room for the report.
138    evicted: Vec<DiagnosticsKey>,
139    /// Whether the cache has an answer for the file, read in the same
140    /// critical section as `sources`.
141    availability: DiagnosticsAvailability,
142}
143
144/// Whether a server answered a request with JSON-RPC "method not found".
145fn is_method_not_found(error: &Error) -> bool {
146    matches!(
147        error,
148        Error::LspServerError { code, .. }
149            if lsp_types::ErrorCodes::from(*code) == lsp_types::ErrorCodes::MethodNotFound
150    )
151}
152
153/// Whether a server answered a request with an LSP code that says the result
154/// is not available right now, not that it never will be.
155fn is_transient_server_error(error: &Error) -> bool {
156    matches!(
157        error,
158        Error::LspServerError { code, .. }
159            if matches!(
160                lsp_types::LspErrorCodes::from(*code),
161                lsp_types::LspErrorCodes::RequestCancelled
162                    | lsp_types::LspErrorCodes::ContentModified
163                    | lsp_types::LspErrorCodes::ServerCancelled
164            )
165    )
166}
167
168/// How a failed probe pull moves the probe of its server.
169enum ProbeOutcome {
170    /// The server does not know the method: refused at once.
171    Refuse,
172    /// The request timed out.
173    TimedOut,
174    /// The server could not answer right now; nothing is learned.
175    Transient,
176}
177
178impl ProbeOutcome {
179    /// The finding in `error`, or `None` when it is no probe finding and
180    /// surfaces as it would for any pull.
181    fn of(error: &Error) -> Option<Self> {
182        if is_method_not_found(error) {
183            Some(Self::Refuse)
184        } else if matches!(error, Error::Timeout(_)) {
185            Some(Self::TimedOut)
186        } else if is_transient_server_error(error) {
187            Some(Self::Transient)
188        } else {
189            None
190        }
191    }
192}
193
194/// Shared, never-mutated empty `workspace_roots` for an `EncodingCtx` built
195/// where it's documented as never read -- avoids a per-poll `Arc` allocation.
196static EMPTY_WORKSPACE_ROOTS: LazyLock<WorkspaceRoots> = LazyLock::new(WorkspaceRoots::default);
197
198/// Convert an LSP diagnostic into the MCP-facing `Diagnostic` shape.
199///
200/// Shared by both the pull-model (`handle_diagnostics`) and cache-derived
201/// (`diagnostics_from_cache_entry`) diagnostic paths, so their output never
202/// diverges in formatting.
203pub(super) async fn diagnostic_to_mcp(
204    diag: &lsp_types::Diagnostic,
205    ctx: &EncodingCtx,
206    uri: &lsp_types::Uri,
207) -> Diagnostic {
208    Diagnostic {
209        range: ctx.normalize_range(uri, diag.range).await,
210        severity: ReportedSeverity::of(diag),
211        message: message_as_str(&diag.message).to_string(),
212        code: reported_code(diag).map(std::borrow::Cow::into_owned),
213    }
214}
215
216impl Translator {
217    /// Resolve the LSP-side cache key (URI) for a cached-diagnostics lookup.
218    ///
219    /// Split out from the cache read itself so callers (e.g. the
220    /// `get_cached_diagnostics` MCP tool) can do the path `canonicalize()` and
221    /// workspace-boundary check *before* taking the `NotificationCache` lock —
222    /// that lock is also needed by `diagnostics_pump` to store incoming
223    /// notifications, so nothing that isn't a plain map lookup should run
224    /// while it's held.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if the path is invalid or outside workspace boundaries.
229    pub async fn cached_diagnostics_uri(
230        workspace_roots: &WorkspaceRoots,
231        file_path: &ClientPath,
232    ) -> Result<Uri> {
233        Self::cached_diagnostics_path_and_uri(workspace_roots, file_path)
234            .await
235            .map(|(_, uri)| uri)
236    }
237
238    /// As [`Self::cached_diagnostics_uri`], but also returns the validated,
239    /// canonicalized path -- for a caller (e.g. `get_cached_diagnostics`,
240    /// `read_resource`) that needs it too, such as to resolve a
241    /// diagnostics-route server via [`Self::diagnostics_route_for_path`]
242    /// from the same canonical path the URI itself is derived from, rather
243    /// than re-canonicalizing or (worse) using an unvalidated raw path.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the path is invalid or outside workspace boundaries.
248    pub(crate) async fn cached_diagnostics_path_and_uri(
249        workspace_roots: &WorkspaceRoots,
250        file_path: &ClientPath,
251    ) -> Result<(WorkspacePath, Uri)> {
252        let validated_path = workspace_roots.validate(file_path).await?;
253
254        // Use path_to_uri (strips \\?\ on Windows) so the key matches what
255        // rust-analyzer stores in publishDiagnostics notifications.
256        let uri = path_to_uri(validated_path.as_path())?;
257        Ok((validated_path, uri))
258    }
259
260    /// Handle diagnostics request.
261    ///
262    /// Pulls `textDocument/diagnostic`, stores a full report as the file's
263    /// pulled slot in `notification_cache` next to whatever the server pushed
264    /// (`NotificationCache::store_pulled_diagnostics`), and returns the merged
265    /// view of both -- the same diagnostics `get_cached_diagnostics` and
266    /// `resources/read` return for the file right after. The push side is
267    /// merged in because rust-analyzer's pull endpoint omits
268    /// flycheck/clippy-sourced diagnostics, and empirically some native ones,
269    /// that are only ever delivered via push (#244). When the merged view
270    /// changed, subscribers of the file's `lsp-diagnostics://` resource are
271    /// notified before this returns (#574).
272    ///
273    /// A report is not stored when it is not a full report (`unchanged` and
274    /// partial results), when a pull issued later or a server clear got there
275    /// first, or when the document was resynced to another version while the
276    /// request was in flight; the result then still contains the report, but
277    /// the cache and the subscribers are left alone. If the pull request itself
278    /// fails (e.g. a timeout), a non-empty cache entry is returned as a
279    /// cache-only result instead of propagating the error, since the cache is
280    /// not required to be fresher than the pull response to be useful here.
281    ///
282    /// A server that advertises no `diagnosticProvider` is probed with its
283    /// first pull: `-32601` is not an error but the learned fact that it is
284    /// push-only, so this and every later call answers from the push cache
285    /// without a request (#666); the [`DiagnosticsAnswer`] says so in its
286    /// `origin` and says in its `availability` whether the cache knows the file.
287    ///
288    /// The cache is locked once, after the pull request settles (success or
289    /// failure), for the store and the snapshot -- never across the LSP
290    /// round-trip -- matching the lock-ordering discipline documented on
291    /// `cached_diagnostics_uri`. Merging, conversion and publishing run after
292    /// the lock is dropped. Like `get_cached_diagnostics`, the cache is treated
293    /// as eventually consistent: a pushed entry may reflect a slightly older
294    /// document version than the fresh pull result if an edit landed inside
295    /// the server's flycheck debounce window.
296    ///
297    /// Deliberately not gated on workspace-indexing readiness the way
298    /// `IndexingGate::Required` whole-workspace queries are (#445, see
299    /// `routing::IndexingGate`'s doc): this is a poll-based read, not a live
300    /// whole-workspace LSP request, so blocking it on
301    /// `Translator::wait_for_indexing_ready` would be the wrong fix shape.
302    /// The `get_diagnostics` MCP tool instead surfaces the routed server's
303    /// indexing state as an explicit `indexing_in_progress` flag alongside this
304    /// method's result.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the LSP pull request fails and the cache holds no
309    /// diagnostics for the file either, or if the file cannot be opened.
310    pub async fn handle_diagnostics(
311        &self,
312        file_path: ClientPath,
313        context: ResultContext,
314        notification_cache: &Mutex<NotificationCache>,
315    ) -> Result<DocumentDiagnosticsResult> {
316        let path = self.validate_path(&file_path).await?;
317        self.handle_validated_diagnostics(&path, context, notification_cache)
318            .await
319            .map(|answer| answer.result)
320    }
321
322    /// As [`Self::handle_diagnostics`], for a path the caller already
323    /// validated, so it is not canonicalized a second time, returning the
324    /// result with the file's availability and the origin of the answer.
325    ///
326    /// # Errors
327    ///
328    /// As [`Self::handle_diagnostics`], except for path validation.
329    pub(crate) async fn handle_validated_diagnostics(
330        &self,
331        path: &WorkspacePath,
332        context: ResultContext,
333        notification_cache: &Mutex<NotificationCache>,
334    ) -> Result<DiagnosticsAnswer> {
335        let doc = self
336            .prepare_document_for_path(path, ToolKind::Diagnostics)
337            .await?;
338        let (server_id, uri) = (doc.server_id(), doc.uri());
339        let ctx = self.encoding_ctx(server_id);
340        let support = self.pull_support(server_id);
341
342        let stamp = match self.document_tracker.synced_version(doc.path(), server_id) {
343            Some(version) if support.sends_pull() => Some(
344                notification_cache
345                    .lock()
346                    .await
347                    .begin_pull(server_id, version),
348            ),
349            _ => None,
350        };
351
352        let attempt = self.request_pull(&doc, support).await;
353        let origin = attempt.origin();
354        let (pulled, failure) = match attempt {
355            PullAttempt::Answered(pulled) => (pulled, None),
356            PullAttempt::PushOnly => (None, None),
357            PullAttempt::Failed(e) => (None, Some(e)),
358        };
359
360        let snapshot_before = match self.wiring.get() {
361            Some(wiring) if wiring.has_subscriptions().await => SnapshotBefore::Take,
362            _ => SnapshotBefore::Skip,
363        };
364        let PullSettlement {
365            sources,
366            storage,
367            evicted,
368            availability,
369        } = {
370            let mut cache = notification_cache.lock().await;
371            self.settle_pull(&mut cache, &doc, stamp, pulled, snapshot_before)
372        };
373        let diag_info = sources.merge();
374
375        if let Some(wiring) = self.wiring.get() {
376            if let PullStorage::Stored(slot) = storage
377                && wiring.has_subscriptions().await
378            {
379                let outcome = match slot {
380                    SlotChange::Identical => ChangeOutcome::Unchanged,
381                    SlotChange::Replaced { before } => before
382                        .map_or(ChangeOutcome::Changed, |before| {
383                            ChangeOutcome::of(before.merge().as_ref(), diag_info.as_ref())
384                        }),
385                };
386                match outcome {
387                    ChangeOutcome::Changed => wiring.publish_changed(uri).await,
388                    ChangeOutcome::Unchanged => {}
389                }
390            }
391            if !evicted.is_empty() {
392                wiring.publish_invalidated(&evicted).await;
393            }
394        }
395
396        let merged = Self::diagnostics_from_cache_entry(
397            diag_info.as_ref(),
398            ctx.encoding,
399            &self.document_tracker,
400        )
401        .await;
402        if let Some(e) = failure
403            && merged.diagnostics.is_empty()
404        {
405            return Err(e);
406        }
407
408        let Contextualized {
409            items: diagnostics,
410            enrichment,
411            positions_degraded,
412        } = self
413            .contextualize_diagnostics(
414                uri.as_ref(),
415                merged.diagnostics,
416                context,
417                merged.positions_degraded,
418            )
419            .await;
420        Ok(DiagnosticsAnswer {
421            result: DocumentDiagnosticsResult {
422                diagnostics,
423                positions_degraded,
424                enrichment,
425            },
426            availability,
427            origin,
428        })
429    }
430
431    /// Sends the pull request `support` calls for, and learns from how it
432    /// ends: a server advertising no provider that answers is pulled from now
433    /// on, one that refuses with `-32601` or times out twice in a row is not
434    /// pulled again (until it is replaced) and its refusal is logged at DEBUG
435    /// or INFO, not ERROR.
436    async fn request_pull(&self, doc: &PreparedDocument, support: PullSupport) -> PullAttempt {
437        if !support.sends_pull() {
438            return PullAttempt::PushOnly;
439        }
440        let (server_id, client, uri) = (doc.server_id(), doc.client(), doc.uri());
441        let params = DocumentDiagnosticParams {
442            text_document: TextDocumentIdentifier { uri: uri.clone() },
443            identifier: None,
444            previous_result_id: None,
445            work_done_progress_params: WorkDoneProgressParams::default(),
446            partial_result_params: PartialResultParams::default(),
447        };
448        let sent_with = if support == PullSupport::Probing {
449            self.pull_probe_of(server_id)
450        } else {
451            PullProbe::default()
452        };
453        let response = client
454            .request_typed_classified::<PullDiagnosticRequest>(params, client.request_timeout())
455            .await;
456        match response {
457            Ok(response) => {
458                if support == PullSupport::Probing {
459                    self.record_pull_probe(server_id, client.connection_id(), |_| {
460                        PullProbe::Answered
461                    });
462                }
463                PullAttempt::Answered(match PullReport::from(response) {
464                    PullReport::Full(mut items) => {
465                        let redactions = client.redactions();
466                        if !redactions.is_empty() {
467                            for d in &mut items {
468                                redactions.redact_diagnostic(d);
469                            }
470                        }
471                        Some(BoundedDiagnostics::new(uri, items))
472                    }
473                    PullReport::NotStored => None,
474                })
475            }
476            Err(unclassified) if support == PullSupport::Probing => {
477                self.conclude_probe(server_id, client.connection_id(), sent_with, unclassified)
478            }
479            Err(unclassified) => PullAttempt::Failed(unclassified.surface()),
480        }
481    }
482
483    /// Learns from a pull request that failed while the server was still being
484    /// probed. A server that refuses, or times out on a pull sent after an
485    /// earlier timeout, is answered from the push cache from now on, and so is
486    /// this call when the failure says nothing about the server; any other
487    /// failure surfaces. `sent_with` is the probe when the failed request was
488    /// sent.
489    fn conclude_probe(
490        &self,
491        server_id: &ServerId,
492        conn: ConnectionId,
493        sent_with: PullProbe,
494        unclassified: UnclassifiedError,
495    ) -> PullAttempt {
496        let Some(outcome) = ProbeOutcome::of(unclassified.error()) else {
497            return PullAttempt::Failed(unclassified.surface());
498        };
499        let error = unclassified.handled();
500        match outcome {
501            ProbeOutcome::Refuse => {
502                debug!(
503                    %server_id,
504                    %error,
505                    "server advertises no diagnostic provider and refused a pull; \
506                     answering from the push cache"
507                );
508                self.record_pull_probe(server_id, conn, |_| PullProbe::Refused);
509            }
510            ProbeOutcome::TimedOut => {
511                let probe =
512                    self.record_pull_probe(server_id, conn, |now| now.after_timeout(sent_with));
513                if probe == Some(PullProbe::Refused) {
514                    info!(
515                        %server_id,
516                        %error,
517                        "server advertises no diagnostic provider and timed out on two pulls \
518                         in a row; answering from the push cache until it is replaced \
519                         (restart_server)"
520                    );
521                } else {
522                    debug!(%server_id, %error, "probe pull timed out; answering from the push cache");
523                }
524            }
525            ProbeOutcome::Transient => debug!(
526                %server_id,
527                %error,
528                "probe pull could not be answered right now; answering from the push cache"
529            ),
530        }
531        PullAttempt::PushOnly
532    }
533
534    /// What is known about `id` answering `textDocument/diagnostic`.
535    pub(super) fn pull_support(&self, id: &ServerId) -> PullSupport {
536        lock_std(&self.servers).pull_support(id)
537    }
538
539    fn pull_probe_of(&self, id: &ServerId) -> PullProbe {
540        lock_std(&self.servers).pull_probe(id)
541    }
542
543    fn record_pull_probe(
544        &self,
545        id: &ServerId,
546        conn: ConnectionId,
547        next: impl FnOnce(PullProbe) -> PullProbe,
548    ) -> Option<PullProbe> {
549        lock_std(&self.servers).update_pull_probe(id, conn, next)
550    }
551
552    /// Meets a settled pull with the cache, under the caller's lock: stores
553    /// `pulled` when it may be stored, and snapshots the file's sources with
554    /// the report in them either way.
555    fn settle_pull(
556        &self,
557        cache: &mut NotificationCache,
558        doc: &PreparedDocument,
559        stamp: Option<PullStamp>,
560        pulled: Option<BoundedDiagnostics>,
561        snapshot_before: SnapshotBefore,
562    ) -> PullSettlement {
563        let (server_id, uri) = (doc.server_id(), doc.uri());
564        let unstored = |cache: &NotificationCache, items, version| PullSettlement {
565            sources: cache
566                .diagnostic_sources(uri)
567                .with_pulled(uri, version, items),
568            storage: PullStorage::NotStored,
569            evicted: Vec::new(),
570            availability: DiagnosticsAvailability::Published,
571        };
572        let Some(items) = pulled else {
573            return PullSettlement {
574                sources: cache.diagnostic_sources(uri),
575                storage: PullStorage::NotStored,
576                evicted: Vec::new(),
577                availability: cache.availability(uri, Some(server_id)),
578            };
579        };
580        let Some(stamp) = stamp else {
581            return unstored(cache, items, None);
582        };
583        let check = if self.document_tracker.synced_version(doc.path(), server_id)
584            == Some(stamp.version())
585        {
586            VersionCheck::Current
587        } else {
588            VersionCheck::Moved
589        };
590        match cache.store_pulled_diagnostics_with(
591            server_id,
592            uri,
593            stamp,
594            check,
595            items,
596            snapshot_before,
597        ) {
598            PullWrite::Stored { slot, evicted } => PullSettlement {
599                sources: cache.diagnostic_sources(uri),
600                storage: PullStorage::Stored(slot),
601                evicted,
602                availability: DiagnosticsAvailability::Published,
603            },
604            PullWrite::Discarded {
605                reason,
606                evicted,
607                items,
608            } => {
609                debug!(
610                    "discarding the pulled diagnostics of {}: {reason:?}",
611                    uri.as_ref()
612                );
613                PullSettlement {
614                    evicted,
615                    ..unstored(cache, items, Some(stamp.version()))
616                }
617            }
618        }
619    }
620
621    /// Convert a cached diagnostics entry into the MCP-facing result shape.
622    ///
623    /// Takes an already-cloned `Option<&DiagnosticInfo>` (out of the
624    /// `NotificationCache` lock) rather than the cache itself, so this
625    /// mapping — which is not a bounded operation for a large diagnostics set
626    /// — never runs while the cache is locked.
627    ///
628    /// `encoding` is the negotiated encoding of the server that published
629    /// these diagnostics; pass `PositionEncoding::Utf16` when no live server
630    /// context is available (e.g. a cache-only read with no resolved owner).
631    #[must_use]
632    pub async fn diagnostics_from_cache_entry(
633        diag_info: Option<&DiagnosticInfo>,
634        encoding: PositionEncoding,
635        tracker: &Arc<DocumentTracker>,
636    ) -> DiagnosticsResult {
637        match diag_info {
638            Some(diag_info) => {
639                // Workspace roots are never read here -- see
640                // `EMPTY_WORKSPACE_ROOTS`'s doc.
641                let ctx =
642                    EncodingCtx::new(encoding, tracker.clone(), EMPTY_WORKSPACE_ROOTS.clone());
643                let mut result = Vec::with_capacity(diag_info.diagnostics.len());
644                for d in &diag_info.diagnostics {
645                    result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await);
646                }
647                DiagnosticsResult {
648                    diagnostics: result,
649                    positions_degraded: ctx.positions_degraded(),
650                }
651            }
652            None => DiagnosticsResult {
653                diagnostics: Vec::new(),
654                positions_degraded: None,
655            },
656        }
657    }
658
659    /// Handle server logs request.
660    ///
661    /// Logs at least as severe as `min_level` are returned, or all of them
662    /// when it is `None`.
663    #[must_use]
664    pub fn handle_server_logs(
665        cache: &NotificationCache,
666        limit: usize,
667        min_level: Option<LogLevel>,
668    ) -> ServerLogsResult {
669        let logs = cache
670            .logs()
671            .iter()
672            .filter(|log| min_level.is_none_or(|min| log.level.meets(min)))
673            .take(limit)
674            .cloned()
675            .collect();
676
677        ServerLogsResult { logs }
678    }
679
680    /// Handle server messages request.
681    ///
682    /// # Errors
683    ///
684    /// This method does not return errors.
685    pub fn handle_server_messages(
686        cache: &NotificationCache,
687        limit: usize,
688    ) -> Result<ServerMessagesResult> {
689        let all_messages = cache.messages();
690        let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
691        Ok(ServerMessagesResult { messages })
692    }
693}
694
695#[cfg(test)]
696mod pull_tests;
697
698#[cfg(test)]
699mod tests {
700    use std::collections::HashMap;
701    use std::sync::Arc;
702    use std::time::Duration;
703    use std::{assert_matches, fs};
704
705    use tempfile::TempDir;
706    use tokio::io::BufReader;
707    use tokio::time::timeout;
708    use url::Url;
709
710    use super::*;
711    use crate::bridge::translator::dto::{DiagnosticSeverity, PositionDegradation};
712    use crate::bridge::translator::testing::*;
713    use crate::config::{FileExtension, LanguageId, ServerId, ToolRouter};
714    use crate::error::Error;
715    use crate::test_lsp::client_path;
716
717    /// Pins the upstream `lsp_types::DocumentDiagnosticParams` serde
718    /// attributes (`skip_serializing_if` on both optionals) across future
719    /// `gen-lsp-types` version bumps -- this behavior was previously
720    /// guaranteed by a hand-rolled `DiagnosticRequestParams` (see #166),
721    /// dropped in favor of direct construction once verified byte-identical.
722    #[test]
723    fn test_document_diagnostic_params_omit_optional_null_fields() {
724        let uri = lsp_types::Uri::from("file:///test.ts");
725        let params = DocumentDiagnosticParams {
726            text_document: TextDocumentIdentifier { uri },
727            identifier: None,
728            previous_result_id: None,
729            work_done_progress_params: WorkDoneProgressParams::default(),
730            partial_result_params: PartialResultParams::default(),
731        };
732        let value = serde_json::to_value(params).unwrap();
733
734        assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
735        assert!(value.get("identifier").is_none());
736        assert!(value.get("previousResultId").is_none());
737    }
738
739    #[tokio::test]
740    async fn test_handle_cached_diagnostics_empty() {
741        let cache = NotificationCache::new();
742        let temp_dir = TempDir::new().unwrap();
743        let test_file = temp_dir.path().join("test.rs");
744        fs::write(&test_file, "fn main() {}").unwrap();
745
746        let cache_key = Translator::cached_diagnostics_uri(
747            &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
748            &client_path(&test_file),
749        )
750        .await
751        .unwrap();
752        let diag_info = cache.diagnostics(&cache_key);
753        let diags = Translator::diagnostics_from_cache_entry(
754            diag_info.as_ref(),
755            PositionEncoding::Utf16,
756            &test_tracker(),
757        )
758        .await;
759        assert_eq!(diags.diagnostics.len(), 0);
760    }
761
762    #[test]
763    fn test_handle_server_logs_with_filter() {
764        let mut cache = NotificationCache::new();
765
766        // Add some logs
767        cache.store_log(LogLevel::Error, "error msg".to_string());
768        cache.store_log(LogLevel::Warning, "warning msg".to_string());
769        cache.store_log(LogLevel::Info, "info msg".to_string());
770        cache.store_log(LogLevel::Debug, "debug msg".to_string());
771
772        // Test with error filter
773        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Error));
774        assert_eq!(logs.logs.len(), 1);
775        assert_eq!(logs.logs[0].message, "error msg");
776
777        // Test with warning filter (includes error and warning)
778        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Warning));
779        assert_eq!(logs.logs.len(), 2);
780
781        // Test with info filter (excludes debug)
782        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Info));
783        assert_eq!(logs.logs.len(), 3);
784
785        // Test with debug filter (includes all)
786        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Debug));
787        assert_eq!(logs.logs.len(), 4);
788    }
789
790    #[test]
791    fn test_handle_server_messages_limit() {
792        use crate::bridge::notifications::MessageType;
793
794        let mut cache = NotificationCache::new();
795
796        // Add some messages
797        for i in 0..10 {
798            cache.store_message(MessageType::Info, format!("message {i}"));
799        }
800
801        // Test limit
802        let result = Translator::handle_server_messages(&cache, 5);
803        assert!(result.is_ok());
804        let messages = result.unwrap();
805        assert_eq!(messages.messages.len(), 5);
806        assert_eq!(messages.messages[0].message, "message 0");
807        assert_eq!(messages.messages[4].message, "message 4");
808
809        // Test limit larger than available
810        let result = Translator::handle_server_messages(&cache, 100);
811        assert!(result.is_ok());
812        let messages = result.unwrap();
813        assert_eq!(messages.messages.len(), 10);
814    }
815
816    #[tokio::test]
817    async fn test_handle_cached_diagnostics_with_data() {
818        let mut cache = NotificationCache::new();
819        let temp_dir = TempDir::new().unwrap();
820        let test_file = temp_dir.path().join("test.rs");
821        fs::write(&test_file, "fn main() {}").unwrap();
822
823        let canonical_path = test_file.canonicalize().unwrap();
824        let uri: lsp_types::Uri =
825            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
826        let diagnostic = lsp_types::Diagnostic {
827            range: lsp_types::Range {
828                start: lsp_types::Position {
829                    line: 0,
830                    character: 0,
831                },
832                end: lsp_types::Position {
833                    line: 0,
834                    character: 5,
835                },
836            },
837            severity: Some(lsp_types::DiagnosticSeverity::Error),
838            message: "test error".to_string().into(),
839            code: Some(lsp_types::Code::String("E001".to_string())),
840            source: None,
841            code_description: None,
842            related_information: None,
843            tags: None,
844            data: None,
845        };
846
847        cache.store_diagnostics(
848            &ServerId::from_static("rust"),
849            &uri,
850            Some(1),
851            vec![diagnostic],
852        );
853
854        let cache_key = Translator::cached_diagnostics_uri(
855            &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
856            &client_path(&test_file),
857        )
858        .await
859        .unwrap();
860        let diag_info = cache.diagnostics(&cache_key);
861        let diags = Translator::diagnostics_from_cache_entry(
862            diag_info.as_ref(),
863            PositionEncoding::Utf16,
864            &test_tracker(),
865        )
866        .await;
867        assert_eq!(diags.diagnostics.len(), 1);
868        assert_eq!(diags.diagnostics[0].message, "test error");
869        assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
870        assert_matches!(diags.diagnostics[0].severity, DiagnosticSeverity::Error);
871        assert_eq!(diags.diagnostics[0].range.start.line, 1);
872        assert_eq!(diags.diagnostics[0].range.start.character, 1);
873    }
874
875    #[tokio::test]
876    #[allow(
877        clippy::too_many_lines,
878        reason = "one scenario covering every severity"
879    )]
880    async fn test_handle_cached_diagnostics_multiple_severities() {
881        let mut cache = NotificationCache::new();
882        let temp_dir = TempDir::new().unwrap();
883        let test_file = temp_dir.path().join("test.rs");
884        fs::write(&test_file, "fn main() {}").unwrap();
885
886        let canonical_path = test_file.canonicalize().unwrap();
887        let uri: lsp_types::Uri =
888            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
889        let diagnostics = vec![
890            lsp_types::Diagnostic {
891                range: lsp_types::Range {
892                    start: lsp_types::Position {
893                        line: 0,
894                        character: 0,
895                    },
896                    end: lsp_types::Position {
897                        line: 0,
898                        character: 5,
899                    },
900                },
901                severity: Some(lsp_types::DiagnosticSeverity::Error),
902                message: "error".to_string().into(),
903                code: None,
904                source: None,
905                code_description: None,
906                related_information: None,
907                tags: None,
908                data: None,
909            },
910            lsp_types::Diagnostic {
911                range: lsp_types::Range {
912                    start: lsp_types::Position {
913                        line: 1,
914                        character: 0,
915                    },
916                    end: lsp_types::Position {
917                        line: 1,
918                        character: 5,
919                    },
920                },
921                severity: Some(lsp_types::DiagnosticSeverity::Warning),
922                message: "warning".to_string().into(),
923                code: None,
924                source: None,
925                code_description: None,
926                related_information: None,
927                tags: None,
928                data: None,
929            },
930            lsp_types::Diagnostic {
931                range: lsp_types::Range {
932                    start: lsp_types::Position {
933                        line: 2,
934                        character: 0,
935                    },
936                    end: lsp_types::Position {
937                        line: 2,
938                        character: 5,
939                    },
940                },
941                severity: Some(lsp_types::DiagnosticSeverity::Information),
942                message: "info".to_string().into(),
943                code: None,
944                source: None,
945                code_description: None,
946                related_information: None,
947                tags: None,
948                data: None,
949            },
950            lsp_types::Diagnostic {
951                range: lsp_types::Range {
952                    start: lsp_types::Position {
953                        line: 3,
954                        character: 0,
955                    },
956                    end: lsp_types::Position {
957                        line: 3,
958                        character: 5,
959                    },
960                },
961                severity: Some(lsp_types::DiagnosticSeverity::Hint),
962                message: "hint".to_string().into(),
963                code: None,
964                source: None,
965                code_description: None,
966                related_information: None,
967                tags: None,
968                data: None,
969            },
970        ];
971
972        cache.store_diagnostics(&ServerId::from_static("rust"), &uri, Some(1), diagnostics);
973
974        let cache_key = Translator::cached_diagnostics_uri(
975            &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
976            &client_path(&test_file),
977        )
978        .await
979        .unwrap();
980        let diag_info = cache.diagnostics(&cache_key);
981        let diags = Translator::diagnostics_from_cache_entry(
982            diag_info.as_ref(),
983            PositionEncoding::Utf16,
984            &test_tracker(),
985        )
986        .await;
987        assert_eq!(diags.diagnostics.len(), 4);
988        assert_matches!(diags.diagnostics[0].severity, DiagnosticSeverity::Error);
989        assert_matches!(diags.diagnostics[1].severity, DiagnosticSeverity::Warning);
990        assert_matches!(
991            diags.diagnostics[2].severity,
992            DiagnosticSeverity::Information
993        );
994        assert_matches!(diags.diagnostics[3].severity, DiagnosticSeverity::Hint);
995    }
996
997    #[tokio::test]
998    async fn test_handle_cached_diagnostics_with_numeric_code() {
999        let mut cache = NotificationCache::new();
1000        let temp_dir = TempDir::new().unwrap();
1001        let test_file = temp_dir.path().join("test.rs");
1002        fs::write(&test_file, "fn main() {}").unwrap();
1003
1004        let canonical_path = test_file.canonicalize().unwrap();
1005        let uri: lsp_types::Uri =
1006            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
1007        let diagnostic = lsp_types::Diagnostic {
1008            range: lsp_types::Range {
1009                start: lsp_types::Position {
1010                    line: 0,
1011                    character: 0,
1012                },
1013                end: lsp_types::Position {
1014                    line: 0,
1015                    character: 5,
1016                },
1017            },
1018            severity: Some(lsp_types::DiagnosticSeverity::Error),
1019            message: "test error".to_string().into(),
1020            code: Some(lsp_types::Code::Int(42)),
1021            source: None,
1022            code_description: None,
1023            related_information: None,
1024            tags: None,
1025            data: None,
1026        };
1027
1028        cache.store_diagnostics(
1029            &ServerId::from_static("rust"),
1030            &uri,
1031            Some(1),
1032            vec![diagnostic],
1033        );
1034
1035        let cache_key = Translator::cached_diagnostics_uri(
1036            &WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
1037            &client_path(&test_file),
1038        )
1039        .await
1040        .unwrap();
1041        let diag_info = cache.diagnostics(&cache_key);
1042        let diags = Translator::diagnostics_from_cache_entry(
1043            diag_info.as_ref(),
1044            PositionEncoding::Utf16,
1045            &test_tracker(),
1046        )
1047        .await;
1048        assert_eq!(diags.diagnostics.len(), 1);
1049        assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
1050    }
1051
1052    #[tokio::test]
1053    async fn test_handle_cached_diagnostics_invalid_path() {
1054        let dir = tempfile::TempDir::new().unwrap();
1055        let missing = dir.path().join("nonexistent/path/file.rs");
1056        let result = Translator::cached_diagnostics_uri(
1057            &WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap(),
1058            &client_path(&missing),
1059        )
1060        .await;
1061        assert_matches!(result, Err(Error::FileIo { .. }));
1062    }
1063
1064    /// #497: a non-UTF-16 server whose document cannot be resolved reports
1065    /// its positions as degraded, so the merged result carries the flag.
1066    #[tokio::test]
1067    async fn test_diagnostics_from_cache_entry_flags_degraded_positions() {
1068        let info = diag_info(vec![lsp_diag(
1069            0,
1070            10,
1071            lsp_types::DiagnosticSeverity::Warning,
1072            "unused import: `std::fmt`",
1073            None,
1074        )]);
1075
1076        let result = Translator::diagnostics_from_cache_entry(
1077            Some(&info),
1078            PositionEncoding::Utf8,
1079            &test_tracker(),
1080        )
1081        .await;
1082
1083        assert_eq!(
1084            result.positions_degraded,
1085            Some(PositionDegradation::Response)
1086        );
1087        let utf16 = Translator::diagnostics_from_cache_entry(
1088            Some(&info),
1089            PositionEncoding::Utf16,
1090            &test_tracker(),
1091        )
1092        .await;
1093        assert_eq!(utf16.positions_degraded, None);
1094    }
1095
1096    #[test]
1097    fn test_handle_server_logs_no_filter() {
1098        let mut cache = NotificationCache::new();
1099
1100        cache.store_log(LogLevel::Error, "error msg".to_string());
1101        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1102        cache.store_log(LogLevel::Info, "info msg".to_string());
1103        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1104
1105        let logs = Translator::handle_server_logs(&cache, 10, None);
1106        assert_eq!(logs.logs.len(), 4);
1107    }
1108
1109    #[test]
1110    fn test_handle_server_logs_error_filter_strict() {
1111        let mut cache = NotificationCache::new();
1112
1113        cache.store_log(LogLevel::Error, "error msg".to_string());
1114        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1115        cache.store_log(LogLevel::Info, "info msg".to_string());
1116
1117        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Error));
1118        assert_eq!(logs.logs.len(), 1);
1119        assert_eq!(logs.logs[0].message, "error msg");
1120    }
1121
1122    #[test]
1123    fn test_handle_server_logs_warning_filter_includes_errors() {
1124        let mut cache = NotificationCache::new();
1125
1126        cache.store_log(LogLevel::Error, "error msg".to_string());
1127        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1128        cache.store_log(LogLevel::Info, "info msg".to_string());
1129
1130        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Warning));
1131        assert_eq!(logs.logs.len(), 2);
1132    }
1133
1134    #[test]
1135    fn test_handle_server_logs_info_filter_excludes_debug() {
1136        let mut cache = NotificationCache::new();
1137
1138        cache.store_log(LogLevel::Error, "error msg".to_string());
1139        cache.store_log(LogLevel::Info, "info msg".to_string());
1140        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1141
1142        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Info));
1143        assert_eq!(logs.logs.len(), 2);
1144    }
1145
1146    #[test]
1147    fn test_handle_server_logs_debug_filter_includes_all() {
1148        let mut cache = NotificationCache::new();
1149
1150        cache.store_log(LogLevel::Error, "error msg".to_string());
1151        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1152        cache.store_log(LogLevel::Info, "info msg".to_string());
1153        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1154
1155        let logs = Translator::handle_server_logs(&cache, 10, Some(LogLevel::Debug));
1156        assert_eq!(logs.logs.len(), 4);
1157    }
1158
1159    #[test]
1160    fn test_handle_server_logs_limit_applies_after_filter() {
1161        let mut cache = NotificationCache::new();
1162
1163        for i in 0..10 {
1164            cache.store_log(LogLevel::Error, format!("error {i}"));
1165        }
1166
1167        let logs = Translator::handle_server_logs(&cache, 5, Some(LogLevel::Error));
1168        assert_eq!(logs.logs.len(), 5);
1169        assert_eq!(logs.logs[0].message, "error 0");
1170        assert_eq!(logs.logs[4].message, "error 4");
1171    }
1172
1173    #[test]
1174    fn test_handle_server_messages_empty() {
1175        let cache = NotificationCache::new();
1176
1177        let result = Translator::handle_server_messages(&cache, 10);
1178        assert!(result.is_ok());
1179        let messages = result.unwrap();
1180        assert_eq!(messages.messages.len(), 0);
1181    }
1182
1183    #[test]
1184    fn test_handle_server_messages_with_different_types() {
1185        use crate::bridge::notifications::MessageType;
1186
1187        let mut cache = NotificationCache::new();
1188
1189        cache.store_message(MessageType::Error, "error".to_string());
1190        cache.store_message(MessageType::Warning, "warning".to_string());
1191        cache.store_message(MessageType::Info, "info".to_string());
1192        cache.store_message(MessageType::Log, "log".to_string());
1193
1194        let result = Translator::handle_server_messages(&cache, 10);
1195        assert!(result.is_ok());
1196        let messages = result.unwrap();
1197        assert_eq!(messages.messages.len(), 4);
1198        assert_eq!(messages.messages[0].message, "error");
1199        assert_eq!(messages.messages[1].message, "warning");
1200        assert_eq!(messages.messages[2].message, "info");
1201        assert_eq!(messages.messages[3].message, "log");
1202    }
1203
1204    #[test]
1205    fn test_handle_server_messages_zero_limit() {
1206        use crate::bridge::notifications::MessageType;
1207
1208        let mut cache = NotificationCache::new();
1209
1210        cache.store_message(MessageType::Info, "test".to_string());
1211
1212        let result = Translator::handle_server_messages(&cache, 0);
1213        assert!(result.is_ok());
1214        let messages = result.unwrap();
1215        assert_eq!(messages.messages.len(), 0);
1216    }
1217
1218    #[tokio::test]
1219    async fn test_handle_cached_diagnostics_path_outside_workspace() {
1220        let temp_dir1 = TempDir::new().unwrap();
1221        let temp_dir2 = TempDir::new().unwrap();
1222
1223        let workspace_roots = vec![temp_dir1.path().to_path_buf()];
1224
1225        let test_file = temp_dir2.path().join("test.rs");
1226        fs::write(&test_file, "fn main() {}").unwrap();
1227
1228        let result = Translator::cached_diagnostics_uri(
1229            &WorkspaceRoots::from_paths(&workspace_roots).unwrap(),
1230            &client_path(&test_file),
1231        )
1232        .await;
1233        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
1234    }
1235
1236    /// #583: pulled diagnostics are redacted like pushed ones, before they are
1237    /// merged, so a pulled item and its already-redacted cached twin dedupe.
1238    #[tokio::test]
1239    async fn test_handle_diagnostics_redacts_pull_items_before_merging_with_the_cache() {
1240        let dir = TempDir::new().unwrap();
1241        let mut translator = Translator::new()
1242            .with_extensions(crate::test_lsp::test_extensions())
1243            .with_router(ToolRouter::catch_all([(
1244                ServerId::from_static("rust"),
1245                LanguageId::from_static("rust"),
1246            )]));
1247        translator
1248            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1249        let redactions = crate::redaction::Redactions::new([(
1250            "API_TOKEN".to_owned(),
1251            "SuperSecretValue123".to_owned(),
1252        )]);
1253        let (client, mut server, _lanes) =
1254            crate::test_lsp::fake_lsp_client_with_redactions(redactions);
1255        translator.register_client(ServerId::from_static("rust"), client);
1256
1257        let path = dir.path().join("lib.rs");
1258        fs::write(&path, "fn main() {}").unwrap();
1259        let path_str = path.to_string_lossy().to_string();
1260        let uri = path_to_uri(&path.canonicalize().unwrap()).unwrap();
1261        let notification_cache = Mutex::new(NotificationCache::new());
1262        notification_cache.lock().await.store_diagnostics(
1263            &ServerId::from_static("rust"),
1264            &uri,
1265            Some(1),
1266            vec![lsp_diag(
1267                0,
1268                4,
1269                lsp_types::DiagnosticSeverity::Warning,
1270                "leaked [redacted:API_TOKEN]",
1271                None,
1272            )],
1273        );
1274
1275        let translator = Arc::new(translator);
1276        let handle = {
1277            let translator = Arc::clone(&translator);
1278            tokio::spawn(async move {
1279                translator
1280                    .handle_diagnostics(
1281                        client_path(path_str),
1282                        ResultContext::None,
1283                        &notification_cache,
1284                    )
1285                    .await
1286            })
1287        };
1288
1289        let mut wire = BufReader::new(&mut server.write_stdout);
1290        let opened = read_framed_message(&mut wire).await;
1291        assert_eq!(opened["method"], "textDocument/didOpen");
1292        let request = read_framed_message(&mut wire).await;
1293        assert_eq!(request["method"], "textDocument/diagnostic");
1294        let range = serde_json::json!({
1295            "start": {"line": 0, "character": 0},
1296            "end": {"line": 0, "character": 4}
1297        });
1298        write_response(
1299            &mut server.read_half_stdin,
1300            &request["id"],
1301            serde_json::json!({
1302                "kind": "full",
1303                "items": [
1304                    {"range": range, "severity": 2, "message": "leaked SuperSecretValue123"},
1305                    {
1306                        "range": range,
1307                        "severity": 1,
1308                        "message": {"kind": "plaintext", "value": "other SuperSecretValue123"},
1309                        "data": {"hint": "SuperSecretValue123"}
1310                    }
1311                ]
1312            }),
1313        )
1314        .await;
1315
1316        let result = timeout(Duration::from_secs(2), handle)
1317            .await
1318            .unwrap()
1319            .unwrap()
1320            .unwrap();
1321
1322        let shown = serde_json::to_string(&result.diagnostics).unwrap();
1323        assert!(!shown.contains("SuperSecretValue123"), "{shown}");
1324        assert_eq!(result.diagnostics.len(), 2, "{shown}");
1325        assert!(shown.contains("leaked [redacted:API_TOKEN]"), "{shown}");
1326    }
1327
1328    /// S1 regression (#244): a push-only server (or one that times out)
1329    /// answering `textDocument/diagnostic` with an LSP error must not
1330    /// discard diagnostics `handle_diagnostics` already knows about from the
1331    /// cache -- it should return the cache-only result instead of `Err`.
1332    #[tokio::test]
1333    async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
1334        let dir = TempDir::new().unwrap();
1335        let mut extensions = HashMap::new();
1336        extensions.insert(
1337            FileExtension::from_static("rs"),
1338            LanguageId::from_static("rust"),
1339        );
1340
1341        let mut translator =
1342            Translator::new()
1343                .with_extensions(extensions)
1344                .with_router(ToolRouter::catch_all([(
1345                    ServerId::from_static("rust"),
1346                    LanguageId::from_static("rust"),
1347                )]));
1348        translator
1349            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1350
1351        let (client, mut server) = fake_lsp_client();
1352        translator.register_client(ServerId::from_static("rust"), client);
1353
1354        let path = dir.path().join("lib.rs");
1355        fs::write(&path, "fn main() {}").unwrap();
1356        let path_str = path.to_string_lossy().to_string();
1357
1358        // Prime the cache under the exact URI handle_diagnostics will look
1359        // up (path_to_uri over the canonicalized path, same as
1360        // document_tracker uses to open the document).
1361        let canonical = path.canonicalize().unwrap();
1362        let uri = path_to_uri(&canonical).unwrap();
1363        let notification_cache = Mutex::new(NotificationCache::new());
1364        {
1365            let mut cache = notification_cache.lock().await;
1366            cache.store_diagnostics(
1367                &ServerId::from_static("rust"),
1368                &uri,
1369                Some(1),
1370                vec![lsp_diag(
1371                    0,
1372                    4,
1373                    lsp_types::DiagnosticSeverity::Warning,
1374                    "unused import: `std::fmt`",
1375                    None,
1376                )],
1377            );
1378        }
1379
1380        let translator = Arc::new(translator);
1381        let handle = {
1382            let translator = Arc::clone(&translator);
1383            tokio::spawn(async move {
1384                translator
1385                    .handle_diagnostics(
1386                        client_path(path_str),
1387                        ResultContext::None,
1388                        &notification_cache,
1389                    )
1390                    .await
1391            })
1392        };
1393
1394        let mut wire = BufReader::new(&mut server.write_stdout);
1395        let opened = read_framed_message(&mut wire).await;
1396        assert_eq!(opened["method"], "textDocument/didOpen");
1397        let diag_request = read_framed_message(&mut wire).await;
1398        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1399        write_error_response(
1400            &mut server.read_half_stdin,
1401            &diag_request["id"],
1402            -32601,
1403            "method not found",
1404        )
1405        .await;
1406
1407        let result = timeout(Duration::from_secs(2), handle)
1408            .await
1409            .expect("handler call should not hang")
1410            .unwrap();
1411
1412        let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
1413        assert_eq!(diagnostics.diagnostics.len(), 1);
1414        assert_eq!(
1415            diagnostics.diagnostics[0].message,
1416            "unused import: `std::fmt`"
1417        );
1418    }
1419
1420    /// S4 lock-in for critic finding N1: `textDocument/diagnostic` keeps an
1421    /// untyped `LspClient::request` bound to the hand-rolled
1422    /// `DocumentDiagnosticReportResult` union specifically so that a
1423    /// streaming `Partial` response (the shape
1424    /// `DocumentDiagnosticRequest`'s typed `Result` cannot represent)
1425    /// degrades to an empty diagnostics list instead of a deserialization
1426    /// error. This test drives that exact response shape through the real
1427    /// `handle_diagnostics` handler.
1428    #[tokio::test]
1429    async fn test_handle_diagnostics_partial_response_degrades_to_empty_result() {
1430        let dir = TempDir::new().unwrap();
1431        let mut extensions = HashMap::new();
1432        extensions.insert(
1433            FileExtension::from_static("rs"),
1434            LanguageId::from_static("rust"),
1435        );
1436
1437        let mut translator =
1438            Translator::new()
1439                .with_extensions(extensions)
1440                .with_router(ToolRouter::catch_all([(
1441                    ServerId::from_static("rust"),
1442                    LanguageId::from_static("rust"),
1443                )]));
1444        translator
1445            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1446
1447        let (client, mut server) = fake_lsp_client();
1448        translator.register_client(ServerId::from_static("rust"), client);
1449
1450        let path = dir.path().join("lib.rs");
1451        fs::write(&path, "fn main() {}").unwrap();
1452        let path_str = path.to_string_lossy().to_string();
1453
1454        let notification_cache = Mutex::new(NotificationCache::new());
1455
1456        let translator = Arc::new(translator);
1457        let handle = {
1458            let translator = Arc::clone(&translator);
1459            tokio::spawn(async move {
1460                translator
1461                    .handle_diagnostics(
1462                        client_path(path_str),
1463                        ResultContext::None,
1464                        &notification_cache,
1465                    )
1466                    .await
1467            })
1468        };
1469
1470        let mut wire = BufReader::new(&mut server.write_stdout);
1471        let opened = read_framed_message(&mut wire).await;
1472        assert_eq!(opened["method"], "textDocument/didOpen");
1473        let diag_request = read_framed_message(&mut wire).await;
1474        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1475
1476        // A `DocumentDiagnosticReportPartialResult`: no `kind` field (so it
1477        // cannot be a `Report`), only `relatedDocuments`.
1478        write_response(
1479            &mut server.read_half_stdin,
1480            &diag_request["id"],
1481            serde_json::json!({ "relatedDocuments": {} }),
1482        )
1483        .await;
1484
1485        let result = timeout(Duration::from_secs(2), handle)
1486            .await
1487            .expect("handler call should not hang")
1488            .unwrap();
1489
1490        let diagnostics =
1491            result.expect("a Partial response must degrade to Ok(empty), not an error");
1492        assert!(
1493            diagnostics.diagnostics.is_empty(),
1494            "expected no diagnostics from a Partial response, got {diagnostics:?}"
1495        );
1496    }
1497
1498    /// S1 counterpart: when the cache is also empty, the pull error must
1499    /// still propagate -- there is nothing to fall back to.
1500    #[tokio::test]
1501    async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
1502        let dir = TempDir::new().unwrap();
1503        let mut extensions = HashMap::new();
1504        extensions.insert(
1505            FileExtension::from_static("rs"),
1506            LanguageId::from_static("rust"),
1507        );
1508
1509        let mut translator =
1510            Translator::new()
1511                .with_extensions(extensions)
1512                .with_router(ToolRouter::catch_all([(
1513                    ServerId::from_static("rust"),
1514                    LanguageId::from_static("rust"),
1515                )]));
1516        translator
1517            .set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
1518
1519        let (client, mut server) = fake_lsp_client();
1520        translator.register_client(ServerId::from_static("rust"), client);
1521
1522        let path = dir.path().join("lib.rs");
1523        fs::write(&path, "fn main() {}").unwrap();
1524        let path_str = path.to_string_lossy().to_string();
1525
1526        let notification_cache = Mutex::new(NotificationCache::new());
1527
1528        let translator = Arc::new(translator);
1529        let handle = {
1530            let translator = Arc::clone(&translator);
1531            tokio::spawn(async move {
1532                translator
1533                    .handle_diagnostics(
1534                        client_path(path_str),
1535                        ResultContext::None,
1536                        &notification_cache,
1537                    )
1538                    .await
1539            })
1540        };
1541
1542        let mut wire = BufReader::new(&mut server.write_stdout);
1543        let opened = read_framed_message(&mut wire).await;
1544        assert_eq!(opened["method"], "textDocument/didOpen");
1545        let diag_request = read_framed_message(&mut wire).await;
1546        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1547        write_error_response(
1548            &mut server.read_half_stdin,
1549            &diag_request["id"],
1550            -32603,
1551            "internal error",
1552        )
1553        .await;
1554
1555        let result = timeout(Duration::from_secs(2), handle)
1556            .await
1557            .expect("handler call should not hang")
1558            .unwrap();
1559
1560        assert!(
1561            result.is_err(),
1562            "pull error with no cache data must propagate, got {result:?}"
1563        );
1564    }
1565}