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