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::path::PathBuf;
5use std::sync::Arc;
6
7use lsp_types::{
8    DocumentDiagnosticParams, PartialResultParams, TextDocumentIdentifier, WorkDoneProgressParams,
9};
10use tokio::sync::Mutex;
11
12use super::Translator;
13use super::dto::{
14    Diagnostic, DiagnosticSeverity, DiagnosticsResult, Position2D, Range, ServerLogsResult,
15    ServerMessagesResult,
16};
17use super::encoding_ctx::EncodingCtx;
18use super::routing::validate_path_against_roots;
19use crate::bridge::encoding::PositionEncoding;
20use crate::bridge::notifications::message_as_str;
21use crate::bridge::{DiagnosticInfo, DocumentTracker, NotificationCache, path_to_uri};
22use crate::config::ToolKind;
23use crate::error::{Error, Result};
24
25/// Hand-rolled union of `textDocument/diagnostic`'s two possible result
26/// shapes.
27///
28/// `gen-lsp-types` types `DocumentDiagnosticRequest::Result` as the
29/// non-nullable `DocumentDiagnosticReport` alone, splitting the streaming
30/// `Partial` shape off into `RequestWithPartialResults::PartialResult`
31/// (`DocumentDiagnosticReportProgress`) -- binding this call to that typed
32/// result via `LspClient::request_typed` would turn a partial response into
33/// a deserialization error where today it degrades to an empty diagnostics
34/// list. This preserves the union gluon's `DocumentDiagnosticReportResult`
35/// used to provide, via the untyped `LspClient::request`.
36#[derive(serde::Deserialize)]
37#[serde(untagged)]
38enum DocumentDiagnosticReportResult {
39    Report(lsp_types::DocumentDiagnosticReport),
40    // The partial shape's content is never read -- matching this variant at
41    // all (rather than failing to deserialize) is the only thing that
42    // matters, so today's behavior of degrading to an empty diagnostics list
43    // is preserved.
44    Partial(#[allow(dead_code)] lsp_types::DocumentDiagnosticReportPartialResult),
45}
46
47/// Convert an LSP diagnostic into the MCP-facing `Diagnostic` shape.
48///
49/// Shared by both the pull-model (`handle_diagnostics`) and cache-derived
50/// (`diagnostics_from_cache_entry`) diagnostic paths, so their output never
51/// diverges in formatting — `merge_diagnostics`'s dedup logic depends on
52/// both sides mapping severity/code identically.
53pub(super) async fn diagnostic_to_mcp(
54    diag: &lsp_types::Diagnostic,
55    ctx: &EncodingCtx,
56    uri: &lsp_types::Uri,
57) -> Diagnostic {
58    Diagnostic {
59        range: ctx.normalize_range(uri, diag.range).await,
60        severity: match diag.severity {
61            Some(lsp_types::DiagnosticSeverity::Error) => DiagnosticSeverity::Error,
62            Some(lsp_types::DiagnosticSeverity::Warning) => DiagnosticSeverity::Warning,
63            Some(lsp_types::DiagnosticSeverity::Hint) => DiagnosticSeverity::Hint,
64            // INFORMATION and None (no severity reported) both fall here.
65            _ => DiagnosticSeverity::Information,
66        },
67        message: message_as_str(&diag.message).to_string(),
68        code: diag.code.as_ref().map(|c| match c {
69            lsp_types::Code::Int(n) => n.to_string(),
70            lsp_types::Code::String(s) => s.clone(),
71        }),
72    }
73}
74
75impl Translator {
76    /// Resolve the LSP-side cache key (URI string) for a cached-diagnostics lookup.
77    ///
78    /// Split out from the cache read itself so callers (e.g. the
79    /// `get_cached_diagnostics` MCP tool) can do the path `canonicalize()` and
80    /// workspace-boundary check *before* taking the `NotificationCache` lock —
81    /// that lock is also needed by `diagnostics_pump` to store incoming
82    /// notifications, so nothing that isn't a plain map lookup should run
83    /// while it's held.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the path is invalid or outside workspace boundaries.
88    pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
89        let path = PathBuf::from(file_path);
90        let validated_path = validate_path_against_roots(&path, workspace_roots)?;
91
92        // Use path_to_uri (strips \\?\ on Windows) so the key matches what
93        // rust-analyzer stores in publishDiagnostics notifications.
94        Ok(path_to_uri(&validated_path)?.to_string())
95    }
96
97    /// Handle diagnostics request.
98    ///
99    /// Merges the LSP pull-model response (`textDocument/diagnostic`) with
100    /// whatever is already cached from `textDocument/publishDiagnostics` push
101    /// notifications for the same file, so this returns the same diagnostics
102    /// `get_cached_diagnostics` would for the file at the same point in time
103    /// (see #244 — rust-analyzer's pull endpoint omits flycheck/clippy-sourced
104    /// diagnostics, and empirically also some native ones, that are only ever
105    /// delivered via the push path). If the pull request itself fails (e.g. a
106    /// push-only server answering `-32601`, or a timeout), a non-empty cache
107    /// entry is returned as a cache-only result instead of propagating the
108    /// error, since the cache is not required to be fresher than the pull
109    /// response to be useful here.
110    ///
111    /// The cache is read only after the pull request settles (success or
112    /// failure) and held only for the lookup itself — never across the LSP
113    /// round-trip — matching the lock-ordering discipline documented on
114    /// `cached_diagnostics_uri`. Like `get_cached_diagnostics`, the cache is
115    /// treated as eventually consistent: a cached entry may reflect a
116    /// slightly older document version than the fresh pull result if an edit
117    /// landed inside the server's flycheck debounce window.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the LSP pull request fails and the cache holds no
122    /// diagnostics for the file either, or if the file cannot be opened.
123    pub async fn handle_diagnostics(
124        &self,
125        file_path: String,
126        notification_cache: &Mutex<NotificationCache>,
127    ) -> Result<DiagnosticsResult> {
128        let (server_id, client, uri) = self
129            .prepare_document(&file_path, ToolKind::Diagnostics)
130            .await?;
131        let ctx = self.encoding_ctx(&server_id);
132
133        let params = DocumentDiagnosticParams {
134            text_document: TextDocumentIdentifier { uri: uri.clone() },
135            identifier: None,
136            previous_result_id: None,
137            work_done_progress_params: WorkDoneProgressParams::default(),
138            partial_result_params: PartialResultParams::default(),
139        };
140
141        let pull_response: Result<DocumentDiagnosticReportResult> = client
142            .request("textDocument/diagnostic", params, client.request_timeout())
143            .await;
144
145        let diag_info = {
146            let cache = notification_cache.lock().await;
147            cache.diagnostics(uri.as_ref()).cloned()
148        };
149
150        match pull_response {
151            Ok(response) => {
152                let items = match response {
153                    DocumentDiagnosticReportResult::Report(report) => match report {
154                        lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(
155                            full,
156                        ) => full.full_document_diagnostic_report.items,
157                        lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport(
158                            _,
159                        ) => vec![],
160                    },
161                    DocumentDiagnosticReportResult::Partial(_) => vec![],
162                };
163                let mut diagnostics = Vec::with_capacity(items.len());
164                for d in &items {
165                    diagnostics.push(diagnostic_to_mcp(d, &ctx, &uri).await);
166                }
167                let pull = DiagnosticsResult { diagnostics };
168                Ok(Self::merge_diagnostics(
169                    pull,
170                    diag_info.as_ref(),
171                    ctx.encoding,
172                    &self.document_tracker,
173                )
174                .await)
175            }
176            Err(e) => {
177                let cache_only = Self::diagnostics_from_cache_entry(
178                    diag_info.as_ref(),
179                    ctx.encoding,
180                    &self.document_tracker,
181                )
182                .await;
183                if cache_only.diagnostics.is_empty() {
184                    Err(e)
185                } else {
186                    Ok(cache_only)
187                }
188            }
189        }
190    }
191
192    /// Convert a cached diagnostics entry into the MCP-facing result shape.
193    ///
194    /// Takes an already-cloned `Option<&DiagnosticInfo>` (out of the
195    /// `NotificationCache` lock) rather than the cache itself, so this
196    /// mapping — which is not a bounded operation for a large diagnostics set
197    /// — never runs while the cache is locked.
198    ///
199    /// `encoding` is the negotiated encoding of the server that published
200    /// these diagnostics; pass `PositionEncoding::Utf16` when no live server
201    /// context is available (e.g. a cache-only read with no resolved owner).
202    #[must_use]
203    pub async fn diagnostics_from_cache_entry(
204        diag_info: Option<&DiagnosticInfo>,
205        encoding: PositionEncoding,
206        tracker: &Arc<DocumentTracker>,
207    ) -> DiagnosticsResult {
208        let diagnostics = match diag_info {
209            Some(diag_info) => {
210                let ctx = EncodingCtx {
211                    encoding,
212                    tracker: tracker.clone(),
213                };
214                let mut result = Vec::with_capacity(diag_info.diagnostics.len());
215                for d in &diag_info.diagnostics {
216                    result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await);
217                }
218                result
219            }
220            None => Vec::new(),
221        };
222
223        DiagnosticsResult { diagnostics }
224    }
225
226    /// Merge push-model diagnostics from the notification cache into a
227    /// pull-model (`textDocument/diagnostic`) result.
228    ///
229    /// rust-analyzer's pull endpoint omits diagnostics that are only ever
230    /// delivered via `textDocument/publishDiagnostics` push notifications —
231    /// not just flycheck/clippy lints, but empirically (verified against a
232    /// live rust-analyzer 1.97.1 session, see #244) some native diagnostics
233    /// too. Those are cached separately in `NotificationCache`.
234    ///
235    /// Where the *same* logical problem is reported through both paths, the
236    /// two representations were observed to differ in both `range` and
237    /// rendered `message`. Captured example, a "not all trait items
238    /// implemented" (E0046) error for one `impl` block: pull reported range
239    /// `(96,7)-(96,12)` (the trait name) with message "not all trait items
240    /// implemented, missing: `fn hello`"; the push notification for the same
241    /// error reported range `(95,1)-(95,32)` (the impl block) with message
242    /// "not all trait items implemented, missing: `hello`\nmissing `hello`
243    /// in implementation" — same `code`/`severity`, adjacent but distinct
244    /// ranges, different message text. Exact field equality never dedups
245    /// cases like that.
246    ///
247    /// Given that, a cache entry is treated as a duplicate of a pull entry
248    /// when both carry a `code`, the `(severity, code)` pair matches, *and*
249    /// the two ranges are either overlapping or start within
250    /// `DUPLICATE_RANGE_PROXIMITY_LINES` lines of each other — close
251    /// enough to be the same underlying model divergence, not two distinct
252    /// occurrences of the same error class (e.g. two unrelated `E0308`
253    /// mismatches at different call sites in one file, one caught only
254    /// natively and one only by flycheck). Diagnostics with no `code` fall
255    /// back to full-field equality, since there is no cheaper stable
256    /// identity available for them.
257    ///
258    /// Output is sorted by `(start.line, start.character)` so merged
259    /// cache-only entries don't land out of document order after the
260    /// pull-model ones.
261    #[must_use]
262    pub async fn merge_diagnostics(
263        mut pull: DiagnosticsResult,
264        diag_info: Option<&DiagnosticInfo>,
265        encoding: PositionEncoding,
266        tracker: &Arc<DocumentTracker>,
267    ) -> DiagnosticsResult {
268        /// Start-line distance within which same-code, same-severity
269        /// diagnostics from the two models are still considered the same
270        /// underlying problem. Derived from the captured E0046 case above
271        /// (1 line apart); wide enough to absorb span drift between
272        /// rust-analyzer's own spans and rustc's, narrow enough that two
273        /// genuinely distinct same-code errors elsewhere in a file are not
274        /// collapsed into one.
275        const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
276
277        fn position_le(a: &Position2D, b: &Position2D) -> bool {
278            (a.line, a.character) <= (b.line, b.character)
279        }
280
281        fn ranges_close(a: &Range, b: &Range) -> bool {
282            let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
283            overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
284        }
285
286        fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
287            pull.iter().any(|p| match (&candidate.code, &p.code) {
288                (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
289                    ranges_close(&p.range, &candidate.range)
290                }
291                _ => p == candidate,
292            })
293        }
294
295        let cached = Self::diagnostics_from_cache_entry(diag_info, encoding, tracker)
296            .await
297            .diagnostics;
298        let new_diagnostics: Vec<_> = cached
299            .into_iter()
300            .filter(|c| !is_duplicate(&pull.diagnostics, c))
301            .collect();
302        pull.diagnostics.extend(new_diagnostics);
303        pull.diagnostics
304            .sort_by_key(|d| (d.range.start.line, d.range.start.character));
305        pull
306    }
307
308    /// Handle server logs request.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if the `min_level` parameter is invalid.
313    pub fn handle_server_logs(
314        cache: &NotificationCache,
315        limit: usize,
316        min_level: Option<String>,
317    ) -> Result<ServerLogsResult> {
318        use crate::bridge::notifications::LogLevel;
319
320        let min_level_filter = if let Some(level_str) = min_level {
321            let level = match level_str.to_lowercase().as_str() {
322                "error" => LogLevel::Error,
323                "warning" => LogLevel::Warning,
324                "info" => LogLevel::Info,
325                "debug" => LogLevel::Debug,
326                _ => {
327                    return Err(Error::InvalidToolParams(format!(
328                        "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
329                    )));
330                }
331            };
332            Some(level)
333        } else {
334            None
335        };
336
337        let all_logs = cache.logs();
338
339        let logs: Vec<_> = all_logs
340            .iter()
341            .filter(|log| {
342                min_level_filter.is_none_or(|min| match min {
343                    LogLevel::Error => matches!(log.level, LogLevel::Error),
344                    LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
345                    LogLevel::Info => !matches!(log.level, LogLevel::Debug),
346                    LogLevel::Debug => true,
347                })
348            })
349            .take(limit)
350            .cloned()
351            .collect();
352
353        Ok(ServerLogsResult { logs })
354    }
355
356    /// Handle server messages request.
357    ///
358    /// # Errors
359    ///
360    /// This method does not return errors.
361    pub fn handle_server_messages(
362        cache: &NotificationCache,
363        limit: usize,
364    ) -> Result<ServerMessagesResult> {
365        let all_messages = cache.messages();
366        let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
367        Ok(ServerMessagesResult { messages })
368    }
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374    use std::collections::HashMap;
375    use std::fs;
376    use std::sync::Arc;
377    use std::time::Duration;
378
379    use tempfile::TempDir;
380    use tokio::io::BufReader;
381    use tokio::time::timeout;
382    use url::Url;
383
384    use super::*;
385    use crate::bridge::translator::testing::*;
386    use crate::config::{ServerId, ToolRouter};
387
388    /// Pins the upstream `lsp_types::DocumentDiagnosticParams` serde
389    /// attributes (`skip_serializing_if` on both optionals) across future
390    /// `gen-lsp-types` version bumps -- this behavior was previously
391    /// guaranteed by a hand-rolled `DiagnosticRequestParams` (see #166),
392    /// dropped in favor of direct construction once verified byte-identical.
393    #[test]
394    fn test_document_diagnostic_params_omit_optional_null_fields() {
395        let uri = lsp_types::Uri::from("file:///test.ts");
396        let params = DocumentDiagnosticParams {
397            text_document: TextDocumentIdentifier { uri },
398            identifier: None,
399            previous_result_id: None,
400            work_done_progress_params: WorkDoneProgressParams::default(),
401            partial_result_params: PartialResultParams::default(),
402        };
403        let value = serde_json::to_value(params).unwrap();
404
405        assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
406        assert!(value.get("identifier").is_none());
407        assert!(value.get("previousResultId").is_none());
408    }
409
410    #[tokio::test]
411    async fn test_handle_cached_diagnostics_empty() {
412        let cache = NotificationCache::new();
413        let temp_dir = TempDir::new().unwrap();
414        let test_file = temp_dir.path().join("test.rs");
415        fs::write(&test_file, "fn main() {}").unwrap();
416
417        let cache_key =
418            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
419        let diag_info = cache.diagnostics(&cache_key).cloned();
420        let diags = Translator::diagnostics_from_cache_entry(
421            diag_info.as_ref(),
422            PositionEncoding::Utf16,
423            &test_tracker(),
424        )
425        .await;
426        assert_eq!(diags.diagnostics.len(), 0);
427    }
428
429    #[test]
430    fn test_handle_server_logs_with_filter() {
431        use crate::bridge::notifications::LogLevel;
432
433        let mut cache = NotificationCache::new();
434
435        // Add some logs
436        cache.store_log(LogLevel::Error, "error msg".to_string());
437        cache.store_log(LogLevel::Warning, "warning msg".to_string());
438        cache.store_log(LogLevel::Info, "info msg".to_string());
439        cache.store_log(LogLevel::Debug, "debug msg".to_string());
440
441        // Test with error filter
442        let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
443        assert!(result.is_ok());
444        let logs = result.unwrap();
445        assert_eq!(logs.logs.len(), 1);
446        assert_eq!(logs.logs[0].message, "error msg");
447
448        // Test with warning filter (includes error and warning)
449        let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
450        assert!(result.is_ok());
451        let logs = result.unwrap();
452        assert_eq!(logs.logs.len(), 2);
453
454        // Test with info filter (excludes debug)
455        let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
456        assert!(result.is_ok());
457        let logs = result.unwrap();
458        assert_eq!(logs.logs.len(), 3);
459
460        // Test with debug filter (includes all)
461        let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
462        assert!(result.is_ok());
463        let logs = result.unwrap();
464        assert_eq!(logs.logs.len(), 4);
465
466        // Test with invalid filter
467        let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
468        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
469    }
470
471    #[test]
472    fn test_handle_server_messages_limit() {
473        use crate::bridge::notifications::MessageType;
474
475        let mut cache = NotificationCache::new();
476
477        // Add some messages
478        for i in 0..10 {
479            cache.store_message(MessageType::Info, format!("message {i}"));
480        }
481
482        // Test limit
483        let result = Translator::handle_server_messages(&cache, 5);
484        assert!(result.is_ok());
485        let messages = result.unwrap();
486        assert_eq!(messages.messages.len(), 5);
487        assert_eq!(messages.messages[0].message, "message 0");
488        assert_eq!(messages.messages[4].message, "message 4");
489
490        // Test limit larger than available
491        let result = Translator::handle_server_messages(&cache, 100);
492        assert!(result.is_ok());
493        let messages = result.unwrap();
494        assert_eq!(messages.messages.len(), 10);
495    }
496
497    #[tokio::test]
498    async fn test_handle_cached_diagnostics_with_data() {
499        let mut cache = NotificationCache::new();
500        let temp_dir = TempDir::new().unwrap();
501        let test_file = temp_dir.path().join("test.rs");
502        fs::write(&test_file, "fn main() {}").unwrap();
503
504        let canonical_path = test_file.canonicalize().unwrap();
505        let uri: lsp_types::Uri =
506            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
507        let diagnostic = lsp_types::Diagnostic {
508            range: lsp_types::Range {
509                start: lsp_types::Position {
510                    line: 0,
511                    character: 0,
512                },
513                end: lsp_types::Position {
514                    line: 0,
515                    character: 5,
516                },
517            },
518            severity: Some(lsp_types::DiagnosticSeverity::Error),
519            message: "test error".to_string().into(),
520            code: Some(lsp_types::Code::String("E001".to_string())),
521            source: None,
522            code_description: None,
523            related_information: None,
524            tags: None,
525            data: None,
526        };
527
528        cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
529
530        let cache_key =
531            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
532        let diag_info = cache.diagnostics(&cache_key).cloned();
533        let diags = Translator::diagnostics_from_cache_entry(
534            diag_info.as_ref(),
535            PositionEncoding::Utf16,
536            &test_tracker(),
537        )
538        .await;
539        assert_eq!(diags.diagnostics.len(), 1);
540        assert_eq!(diags.diagnostics[0].message, "test error");
541        assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
542        assert!(matches!(
543            diags.diagnostics[0].severity,
544            DiagnosticSeverity::Error
545        ));
546        assert_eq!(diags.diagnostics[0].range.start.line, 1);
547        assert_eq!(diags.diagnostics[0].range.start.character, 1);
548    }
549
550    #[tokio::test]
551    #[allow(clippy::too_many_lines)]
552    async fn test_handle_cached_diagnostics_multiple_severities() {
553        let mut cache = NotificationCache::new();
554        let temp_dir = TempDir::new().unwrap();
555        let test_file = temp_dir.path().join("test.rs");
556        fs::write(&test_file, "fn main() {}").unwrap();
557
558        let canonical_path = test_file.canonicalize().unwrap();
559        let uri: lsp_types::Uri =
560            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
561        let diagnostics = vec![
562            lsp_types::Diagnostic {
563                range: lsp_types::Range {
564                    start: lsp_types::Position {
565                        line: 0,
566                        character: 0,
567                    },
568                    end: lsp_types::Position {
569                        line: 0,
570                        character: 5,
571                    },
572                },
573                severity: Some(lsp_types::DiagnosticSeverity::Error),
574                message: "error".to_string().into(),
575                code: None,
576                source: None,
577                code_description: None,
578                related_information: None,
579                tags: None,
580                data: None,
581            },
582            lsp_types::Diagnostic {
583                range: lsp_types::Range {
584                    start: lsp_types::Position {
585                        line: 1,
586                        character: 0,
587                    },
588                    end: lsp_types::Position {
589                        line: 1,
590                        character: 5,
591                    },
592                },
593                severity: Some(lsp_types::DiagnosticSeverity::Warning),
594                message: "warning".to_string().into(),
595                code: None,
596                source: None,
597                code_description: None,
598                related_information: None,
599                tags: None,
600                data: None,
601            },
602            lsp_types::Diagnostic {
603                range: lsp_types::Range {
604                    start: lsp_types::Position {
605                        line: 2,
606                        character: 0,
607                    },
608                    end: lsp_types::Position {
609                        line: 2,
610                        character: 5,
611                    },
612                },
613                severity: Some(lsp_types::DiagnosticSeverity::Information),
614                message: "info".to_string().into(),
615                code: None,
616                source: None,
617                code_description: None,
618                related_information: None,
619                tags: None,
620                data: None,
621            },
622            lsp_types::Diagnostic {
623                range: lsp_types::Range {
624                    start: lsp_types::Position {
625                        line: 3,
626                        character: 0,
627                    },
628                    end: lsp_types::Position {
629                        line: 3,
630                        character: 5,
631                    },
632                },
633                severity: Some(lsp_types::DiagnosticSeverity::Hint),
634                message: "hint".to_string().into(),
635                code: None,
636                source: None,
637                code_description: None,
638                related_information: None,
639                tags: None,
640                data: None,
641            },
642        ];
643
644        cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), diagnostics);
645
646        let cache_key =
647            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
648        let diag_info = cache.diagnostics(&cache_key).cloned();
649        let diags = Translator::diagnostics_from_cache_entry(
650            diag_info.as_ref(),
651            PositionEncoding::Utf16,
652            &test_tracker(),
653        )
654        .await;
655        assert_eq!(diags.diagnostics.len(), 4);
656        assert!(matches!(
657            diags.diagnostics[0].severity,
658            DiagnosticSeverity::Error
659        ));
660        assert!(matches!(
661            diags.diagnostics[1].severity,
662            DiagnosticSeverity::Warning
663        ));
664        assert!(matches!(
665            diags.diagnostics[2].severity,
666            DiagnosticSeverity::Information
667        ));
668        assert!(matches!(
669            diags.diagnostics[3].severity,
670            DiagnosticSeverity::Hint
671        ));
672    }
673
674    #[tokio::test]
675    async fn test_handle_cached_diagnostics_with_numeric_code() {
676        let mut cache = NotificationCache::new();
677        let temp_dir = TempDir::new().unwrap();
678        let test_file = temp_dir.path().join("test.rs");
679        fs::write(&test_file, "fn main() {}").unwrap();
680
681        let canonical_path = test_file.canonicalize().unwrap();
682        let uri: lsp_types::Uri =
683            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
684        let diagnostic = lsp_types::Diagnostic {
685            range: lsp_types::Range {
686                start: lsp_types::Position {
687                    line: 0,
688                    character: 0,
689                },
690                end: lsp_types::Position {
691                    line: 0,
692                    character: 5,
693                },
694            },
695            severity: Some(lsp_types::DiagnosticSeverity::Error),
696            message: "test error".to_string().into(),
697            code: Some(lsp_types::Code::Int(42)),
698            source: None,
699            code_description: None,
700            related_information: None,
701            tags: None,
702            data: None,
703        };
704
705        cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
706
707        let cache_key =
708            Translator::cached_diagnostics_uri(&[], test_file.to_str().unwrap()).unwrap();
709        let diag_info = cache.diagnostics(&cache_key).cloned();
710        let diags = Translator::diagnostics_from_cache_entry(
711            diag_info.as_ref(),
712            PositionEncoding::Utf16,
713            &test_tracker(),
714        )
715        .await;
716        assert_eq!(diags.diagnostics.len(), 1);
717        assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
718    }
719
720    #[test]
721    fn test_handle_cached_diagnostics_invalid_path() {
722        let result = Translator::cached_diagnostics_uri(&[], "/nonexistent/path/file.rs");
723        assert!(matches!(result, Err(Error::FileIo { .. })));
724    }
725
726    #[tokio::test]
727    async fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
728        let pull = DiagnosticsResult {
729            diagnostics: vec![],
730        };
731        let cache = diag_info(vec![lsp_diag(
732            0,
733            10,
734            lsp_types::DiagnosticSeverity::Warning,
735            "unused import: `std::fmt`",
736            None,
737        )]);
738
739        let merged = Translator::merge_diagnostics(
740            pull,
741            Some(&cache),
742            PositionEncoding::Utf16,
743            &test_tracker(),
744        )
745        .await;
746
747        assert_eq!(merged.diagnostics.len(), 1);
748        assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
749        assert!(matches!(
750            merged.diagnostics[0].severity,
751            DiagnosticSeverity::Warning
752        ));
753    }
754
755    #[tokio::test]
756    async fn test_merge_diagnostics_exact_duplicate_not_repeated() {
757        // Same range/severity/message/code as the cache entry below, expressed
758        // in the 1-based MCP shape `diagnostics_from_cache_entry` would produce.
759        let pull_diag = Diagnostic {
760            range: Range {
761                start: Position2D {
762                    line: 1,
763                    character: 1,
764                },
765                end: Position2D {
766                    line: 1,
767                    character: 11,
768                },
769            },
770            severity: DiagnosticSeverity::Error,
771            message: "mismatched types".to_string(),
772            code: Some("E0308".to_string()),
773        };
774        let pull = DiagnosticsResult {
775            diagnostics: vec![pull_diag.clone()],
776        };
777        let cache = diag_info(vec![lsp_diag(
778            0,
779            10,
780            lsp_types::DiagnosticSeverity::Error,
781            "mismatched types",
782            Some("E0308"),
783        )]);
784
785        let merged = Translator::merge_diagnostics(
786            pull,
787            Some(&cache),
788            PositionEncoding::Utf16,
789            &test_tracker(),
790        )
791        .await;
792
793        assert_eq!(merged.diagnostics.len(), 1);
794        assert_eq!(merged.diagnostics[0], pull_diag);
795    }
796
797    #[tokio::test]
798    async fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
799        let pull_diag = Diagnostic {
800            range: Range {
801                start: Position2D {
802                    line: 1,
803                    character: 1,
804                },
805                end: Position2D {
806                    line: 1,
807                    character: 5,
808                },
809            },
810            severity: DiagnosticSeverity::Error,
811            message: "syntax error".to_string(),
812            code: None,
813        };
814        let pull = DiagnosticsResult {
815            diagnostics: vec![pull_diag.clone()],
816        };
817
818        let merged =
819            Translator::merge_diagnostics(pull, None, PositionEncoding::Utf16, &test_tracker())
820                .await;
821
822        assert_eq!(merged.diagnostics, vec![pull_diag]);
823    }
824
825    #[tokio::test]
826    async fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
827        let pull = DiagnosticsResult {
828            diagnostics: vec![],
829        };
830        let cache = diag_info(vec![
831            lsp_diag(
832                0,
833                10,
834                lsp_types::DiagnosticSeverity::Warning,
835                "unused import: `std::fmt`",
836                None,
837            ),
838            lsp_diag(
839                5,
840                8,
841                lsp_types::DiagnosticSeverity::Warning,
842                "function `helper` is never used",
843                None,
844            ),
845        ]);
846
847        let merged = Translator::merge_diagnostics(
848            pull,
849            Some(&cache),
850            PositionEncoding::Utf16,
851            &test_tracker(),
852        )
853        .await;
854
855        assert_eq!(merged.diagnostics.len(), 2);
856        assert!(
857            merged
858                .diagnostics
859                .iter()
860                .any(|d| d.message == "unused import: `std::fmt`")
861        );
862        assert!(
863            merged
864                .diagnostics
865                .iter()
866                .any(|d| d.message == "function `helper` is never used")
867        );
868    }
869
870    #[tokio::test]
871    async fn test_merge_diagnostics_same_range_different_message_not_deduped() {
872        let pull_diag = Diagnostic {
873            range: Range {
874                start: Position2D {
875                    line: 1,
876                    character: 1,
877                },
878                end: Position2D {
879                    line: 1,
880                    character: 11,
881                },
882            },
883            severity: DiagnosticSeverity::Error,
884            message: "mismatched types".to_string(),
885            code: None,
886        };
887        let pull = DiagnosticsResult {
888            diagnostics: vec![pull_diag],
889        };
890        // Same range and severity as the pull diagnostic, but a different
891        // message — must be treated as a distinct diagnostic, not a duplicate.
892        let cache = diag_info(vec![lsp_diag(
893            0,
894            10,
895            lsp_types::DiagnosticSeverity::Error,
896            "expected `i32`, found `&str`",
897            None,
898        )]);
899
900        let merged = Translator::merge_diagnostics(
901            pull,
902            Some(&cache),
903            PositionEncoding::Utf16,
904            &test_tracker(),
905        )
906        .await;
907
908        assert_eq!(merged.diagnostics.len(), 2);
909    }
910
911    /// Pins a cross-model duplicate shape verified empirically against a live
912    /// rust-analyzer 1.97.1 session (#244): the pull and push diagnostics for
913    /// the *same* "not all trait items implemented" (E0046) error had
914    /// different ranges (trait name vs. impl block) and different messages
915    /// (terse vs. rustc's full rendering), but shared `code` and `severity`.
916    /// Exact-field dedup would report this twice; the `(severity, code)`
917    /// fingerprint must collapse it to one entry.
918    #[tokio::test]
919    async fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
920        let pull_diag = Diagnostic {
921            range: Range {
922                start: Position2D {
923                    line: 96,
924                    character: 7,
925                },
926                end: Position2D {
927                    line: 96,
928                    character: 12,
929                },
930            },
931            severity: DiagnosticSeverity::Error,
932            message: "not all trait items implemented, missing: `fn hello`".to_string(),
933            code: Some("E0046".to_string()),
934        };
935        let pull = DiagnosticsResult {
936            diagnostics: vec![pull_diag.clone()],
937        };
938        // Same code and severity, but a different range and a longer,
939        // differently-worded message -- the rustc-rendered push side of the
940        // same underlying error.
941        let cache = diag_info(vec![lsp_diag(
942            94,
943            31,
944            lsp_types::DiagnosticSeverity::Error,
945            "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
946            Some("E0046"),
947        )]);
948
949        let merged = Translator::merge_diagnostics(
950            pull,
951            Some(&cache),
952            PositionEncoding::Utf16,
953            &test_tracker(),
954        )
955        .await;
956
957        assert_eq!(merged.diagnostics.len(), 1);
958        assert_eq!(merged.diagnostics[0], pull_diag);
959    }
960
961    /// Regression: `merge_diagnostics`'s `(severity, code)` fingerprint alone
962    /// is coarser than full-field equality and cannot tell apart two
963    /// genuinely distinct diagnostics that happen to share `code` and
964    /// `severity` -- e.g. two separate `E0308` mismatched-type errors at
965    /// different locations in the same file, one caught only by native
966    /// (pull) analysis and a second, unrelated one caught only by
967    /// flycheck/cargo check (cache), such as an error inside macro-expanded
968    /// code the native pass did not evaluate. This previously caused the
969    /// cache-only entry to be silently dropped -- reproducing #244's exact
970    /// failure mode, just relocated from "no merge" to "over-eager dedup".
971    ///
972    /// The range-proximity check on `is_duplicate` (see `merge_diagnostics`)
973    /// closes this: these two diagnostics are 45 lines apart, far outside
974    /// `DUPLICATE_RANGE_PROXIMITY_LINES`, so both must survive the merge.
975    #[tokio::test]
976    async fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept()
977     {
978        let pull_diag = Diagnostic {
979            range: Range {
980                start: Position2D {
981                    line: 5,
982                    character: 9,
983                },
984                end: Position2D {
985                    line: 5,
986                    character: 20,
987                },
988            },
989            severity: DiagnosticSeverity::Error,
990            message: "mismatched types: expected `i32`, found `&str`".to_string(),
991            code: Some("E0308".to_string()),
992        };
993        let pull = DiagnosticsResult {
994            diagnostics: vec![pull_diag.clone()],
995        };
996        // A second, unrelated E0308 at a completely different location with
997        // a completely different message -- a real, distinct diagnostic,
998        // not a duplicate of pull_diag.
999        let cache = diag_info(vec![lsp_diag(
1000            49,
1001            22,
1002            lsp_types::DiagnosticSeverity::Error,
1003            "mismatched types: expected `String`, found `Vec<u8>`",
1004            Some("E0308"),
1005        )]);
1006
1007        let merged = Translator::merge_diagnostics(
1008            pull,
1009            Some(&cache),
1010            PositionEncoding::Utf16,
1011            &test_tracker(),
1012        )
1013        .await;
1014
1015        assert_eq!(merged.diagnostics.len(), 2);
1016        assert_eq!(merged.diagnostics[0], pull_diag);
1017        assert_eq!(
1018            merged.diagnostics[1].message,
1019            "mismatched types: expected `String`, found `Vec<u8>`"
1020        );
1021    }
1022
1023    #[test]
1024    fn test_handle_server_logs_no_filter() {
1025        use crate::bridge::notifications::LogLevel;
1026
1027        let mut cache = NotificationCache::new();
1028
1029        cache.store_log(LogLevel::Error, "error msg".to_string());
1030        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1031        cache.store_log(LogLevel::Info, "info msg".to_string());
1032        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1033
1034        let result = Translator::handle_server_logs(&cache, 10, None);
1035        assert!(result.is_ok());
1036        let logs = result.unwrap();
1037        assert_eq!(logs.logs.len(), 4);
1038    }
1039
1040    #[test]
1041    fn test_handle_server_logs_error_filter_strict() {
1042        use crate::bridge::notifications::LogLevel;
1043
1044        let mut cache = NotificationCache::new();
1045
1046        cache.store_log(LogLevel::Error, "error msg".to_string());
1047        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1048        cache.store_log(LogLevel::Info, "info msg".to_string());
1049
1050        let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
1051        assert!(result.is_ok());
1052        let logs = result.unwrap();
1053        assert_eq!(logs.logs.len(), 1);
1054        assert_eq!(logs.logs[0].message, "error msg");
1055    }
1056
1057    #[test]
1058    fn test_handle_server_logs_warning_filter_includes_errors() {
1059        use crate::bridge::notifications::LogLevel;
1060
1061        let mut cache = NotificationCache::new();
1062
1063        cache.store_log(LogLevel::Error, "error msg".to_string());
1064        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1065        cache.store_log(LogLevel::Info, "info msg".to_string());
1066
1067        let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
1068        assert!(result.is_ok());
1069        let logs = result.unwrap();
1070        assert_eq!(logs.logs.len(), 2);
1071    }
1072
1073    #[test]
1074    fn test_handle_server_logs_info_filter_excludes_debug() {
1075        use crate::bridge::notifications::LogLevel;
1076
1077        let mut cache = NotificationCache::new();
1078
1079        cache.store_log(LogLevel::Error, "error msg".to_string());
1080        cache.store_log(LogLevel::Info, "info msg".to_string());
1081        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1082
1083        let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
1084        assert!(result.is_ok());
1085        let logs = result.unwrap();
1086        assert_eq!(logs.logs.len(), 2);
1087    }
1088
1089    #[test]
1090    fn test_handle_server_logs_debug_filter_includes_all() {
1091        use crate::bridge::notifications::LogLevel;
1092
1093        let mut cache = NotificationCache::new();
1094
1095        cache.store_log(LogLevel::Error, "error msg".to_string());
1096        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1097        cache.store_log(LogLevel::Info, "info msg".to_string());
1098        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1099
1100        let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
1101        assert!(result.is_ok());
1102        let logs = result.unwrap();
1103        assert_eq!(logs.logs.len(), 4);
1104    }
1105
1106    #[test]
1107    fn test_handle_server_logs_limit_applies_after_filter() {
1108        use crate::bridge::notifications::LogLevel;
1109
1110        let mut cache = NotificationCache::new();
1111
1112        for i in 0..10 {
1113            cache.store_log(LogLevel::Error, format!("error {i}"));
1114        }
1115
1116        let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
1117        assert!(result.is_ok());
1118        let logs = result.unwrap();
1119        assert_eq!(logs.logs.len(), 5);
1120        assert_eq!(logs.logs[0].message, "error 0");
1121        assert_eq!(logs.logs[4].message, "error 4");
1122    }
1123
1124    #[test]
1125    fn test_handle_server_logs_case_insensitive_level() {
1126        use crate::bridge::notifications::LogLevel;
1127
1128        let mut cache = NotificationCache::new();
1129
1130        cache.store_log(LogLevel::Error, "error msg".to_string());
1131
1132        let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
1133        assert!(result.is_ok());
1134
1135        let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
1136        assert!(result.is_ok());
1137
1138        let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
1139        assert!(result.is_ok());
1140    }
1141
1142    #[test]
1143    fn test_handle_server_messages_empty() {
1144        let cache = NotificationCache::new();
1145
1146        let result = Translator::handle_server_messages(&cache, 10);
1147        assert!(result.is_ok());
1148        let messages = result.unwrap();
1149        assert_eq!(messages.messages.len(), 0);
1150    }
1151
1152    #[test]
1153    fn test_handle_server_messages_with_different_types() {
1154        use crate::bridge::notifications::MessageType;
1155
1156        let mut cache = NotificationCache::new();
1157
1158        cache.store_message(MessageType::Error, "error".to_string());
1159        cache.store_message(MessageType::Warning, "warning".to_string());
1160        cache.store_message(MessageType::Info, "info".to_string());
1161        cache.store_message(MessageType::Log, "log".to_string());
1162
1163        let result = Translator::handle_server_messages(&cache, 10);
1164        assert!(result.is_ok());
1165        let messages = result.unwrap();
1166        assert_eq!(messages.messages.len(), 4);
1167        assert_eq!(messages.messages[0].message, "error");
1168        assert_eq!(messages.messages[1].message, "warning");
1169        assert_eq!(messages.messages[2].message, "info");
1170        assert_eq!(messages.messages[3].message, "log");
1171    }
1172
1173    #[test]
1174    fn test_handle_server_messages_zero_limit() {
1175        use crate::bridge::notifications::MessageType;
1176
1177        let mut cache = NotificationCache::new();
1178
1179        cache.store_message(MessageType::Info, "test".to_string());
1180
1181        let result = Translator::handle_server_messages(&cache, 0);
1182        assert!(result.is_ok());
1183        let messages = result.unwrap();
1184        assert_eq!(messages.messages.len(), 0);
1185    }
1186
1187    #[test]
1188    fn test_handle_cached_diagnostics_path_outside_workspace() {
1189        let temp_dir1 = TempDir::new().unwrap();
1190        let temp_dir2 = TempDir::new().unwrap();
1191
1192        let workspace_roots = vec![temp_dir1.path().to_path_buf()];
1193
1194        let test_file = temp_dir2.path().join("test.rs");
1195        fs::write(&test_file, "fn main() {}").unwrap();
1196
1197        let result =
1198            Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
1199        assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
1200    }
1201
1202    /// S1 regression (#244): a push-only server (or one that times out)
1203    /// answering `textDocument/diagnostic` with an LSP error must not
1204    /// discard diagnostics `handle_diagnostics` already knows about from the
1205    /// cache -- it should return the cache-only result instead of `Err`.
1206    #[tokio::test]
1207    async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
1208        let dir = TempDir::new().unwrap();
1209        let mut extensions = HashMap::new();
1210        extensions.insert("rs".to_string(), "rust".to_string());
1211
1212        let mut translator =
1213            Translator::new()
1214                .with_extensions(extensions)
1215                .with_router(ToolRouter::catch_all([(
1216                    ServerId::from("rust"),
1217                    "rust".to_string(),
1218                )]));
1219        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1220
1221        let (client, mut server) = fake_lsp_client();
1222        translator.register_client("rust".to_string(), client);
1223
1224        let path = dir.path().join("lib.rs");
1225        fs::write(&path, "fn main() {}").unwrap();
1226        let path_str = path.to_string_lossy().to_string();
1227
1228        // Prime the cache under the exact URI handle_diagnostics will look
1229        // up (path_to_uri over the canonicalized path, same as
1230        // document_tracker uses to open the document).
1231        let canonical = path.canonicalize().unwrap();
1232        let uri = path_to_uri(&canonical).unwrap();
1233        let notification_cache = Mutex::new(NotificationCache::new());
1234        {
1235            let mut cache = notification_cache.lock().await;
1236            cache.store_diagnostics(
1237                &ServerId::from("rust"),
1238                &uri,
1239                Some(1),
1240                vec![lsp_diag(
1241                    0,
1242                    4,
1243                    lsp_types::DiagnosticSeverity::Warning,
1244                    "unused import: `std::fmt`",
1245                    None,
1246                )],
1247            );
1248        }
1249
1250        let translator = Arc::new(translator);
1251        let handle = {
1252            let translator = Arc::clone(&translator);
1253            tokio::spawn(async move {
1254                translator
1255                    .handle_diagnostics(path_str, &notification_cache)
1256                    .await
1257            })
1258        };
1259
1260        let mut wire = BufReader::new(&mut server.write_stdout);
1261        let opened = read_framed_message(&mut wire).await;
1262        assert_eq!(opened["method"], "textDocument/didOpen");
1263        let diag_request = read_framed_message(&mut wire).await;
1264        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1265        write_error_response(
1266            &mut server.read_half_stdin,
1267            &diag_request["id"],
1268            -32601,
1269            "method not found",
1270        )
1271        .await;
1272
1273        let result = timeout(Duration::from_secs(2), handle)
1274            .await
1275            .expect("handler call should not hang")
1276            .unwrap();
1277
1278        let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
1279        assert_eq!(diagnostics.diagnostics.len(), 1);
1280        assert_eq!(
1281            diagnostics.diagnostics[0].message,
1282            "unused import: `std::fmt`"
1283        );
1284    }
1285
1286    /// S4 lock-in for critic finding N1: `textDocument/diagnostic` keeps an
1287    /// untyped `LspClient::request` bound to the hand-rolled
1288    /// `DocumentDiagnosticReportResult` union specifically so that a
1289    /// streaming `Partial` response (the shape
1290    /// `DocumentDiagnosticRequest`'s typed `Result` cannot represent)
1291    /// degrades to an empty diagnostics list instead of a deserialization
1292    /// error. This test drives that exact response shape through the real
1293    /// `handle_diagnostics` handler.
1294    #[tokio::test]
1295    async fn test_handle_diagnostics_partial_response_degrades_to_empty_result() {
1296        let dir = TempDir::new().unwrap();
1297        let mut extensions = HashMap::new();
1298        extensions.insert("rs".to_string(), "rust".to_string());
1299
1300        let mut translator =
1301            Translator::new()
1302                .with_extensions(extensions)
1303                .with_router(ToolRouter::catch_all([(
1304                    ServerId::from("rust"),
1305                    "rust".to_string(),
1306                )]));
1307        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1308
1309        let (client, mut server) = fake_lsp_client();
1310        translator.register_client("rust".to_string(), client);
1311
1312        let path = dir.path().join("lib.rs");
1313        fs::write(&path, "fn main() {}").unwrap();
1314        let path_str = path.to_string_lossy().to_string();
1315
1316        let notification_cache = Mutex::new(NotificationCache::new());
1317
1318        let translator = Arc::new(translator);
1319        let handle = {
1320            let translator = Arc::clone(&translator);
1321            tokio::spawn(async move {
1322                translator
1323                    .handle_diagnostics(path_str, &notification_cache)
1324                    .await
1325            })
1326        };
1327
1328        let mut wire = BufReader::new(&mut server.write_stdout);
1329        let opened = read_framed_message(&mut wire).await;
1330        assert_eq!(opened["method"], "textDocument/didOpen");
1331        let diag_request = read_framed_message(&mut wire).await;
1332        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1333
1334        // A `DocumentDiagnosticReportPartialResult`: no `kind` field (so it
1335        // cannot be a `Report`), only `relatedDocuments`.
1336        write_response(
1337            &mut server.read_half_stdin,
1338            &diag_request["id"],
1339            serde_json::json!({ "relatedDocuments": {} }),
1340        )
1341        .await;
1342
1343        let result = timeout(Duration::from_secs(2), handle)
1344            .await
1345            .expect("handler call should not hang")
1346            .unwrap();
1347
1348        let diagnostics =
1349            result.expect("a Partial response must degrade to Ok(empty), not an error");
1350        assert!(
1351            diagnostics.diagnostics.is_empty(),
1352            "expected no diagnostics from a Partial response, got {diagnostics:?}"
1353        );
1354    }
1355
1356    /// S1 counterpart: when the cache is also empty, the pull error must
1357    /// still propagate -- there is nothing to fall back to.
1358    #[tokio::test]
1359    async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
1360        let dir = TempDir::new().unwrap();
1361        let mut extensions = HashMap::new();
1362        extensions.insert("rs".to_string(), "rust".to_string());
1363
1364        let mut translator =
1365            Translator::new()
1366                .with_extensions(extensions)
1367                .with_router(ToolRouter::catch_all([(
1368                    ServerId::from("rust"),
1369                    "rust".to_string(),
1370                )]));
1371        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1372
1373        let (client, mut server) = fake_lsp_client();
1374        translator.register_client("rust".to_string(), client);
1375
1376        let path = dir.path().join("lib.rs");
1377        fs::write(&path, "fn main() {}").unwrap();
1378        let path_str = path.to_string_lossy().to_string();
1379
1380        let notification_cache = Mutex::new(NotificationCache::new());
1381
1382        let translator = Arc::new(translator);
1383        let handle = {
1384            let translator = Arc::clone(&translator);
1385            tokio::spawn(async move {
1386                translator
1387                    .handle_diagnostics(path_str, &notification_cache)
1388                    .await
1389            })
1390        };
1391
1392        let mut wire = BufReader::new(&mut server.write_stdout);
1393        let opened = read_framed_message(&mut wire).await;
1394        assert_eq!(opened["method"], "textDocument/didOpen");
1395        let diag_request = read_framed_message(&mut wire).await;
1396        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1397        write_error_response(
1398            &mut server.read_half_stdin,
1399            &diag_request["id"],
1400            -32601,
1401            "method not found",
1402        )
1403        .await;
1404
1405        let result = timeout(Duration::from_secs(2), handle)
1406            .await
1407            .expect("handler call should not hang")
1408            .unwrap();
1409
1410        assert!(
1411            result.is_err(),
1412            "pull error with no cache data must propagate, got {result:?}"
1413        );
1414    }
1415}