Skip to main content

deps_lsp/handlers/
diagnostics.rs

1//! Diagnostics handler using ecosystem trait delegation.
2
3use crate::config::{DepsConfig, DiagnosticsConfig};
4use crate::document::{ServerState, ensure_document_loaded};
5use deps_core::VersionData;
6use std::sync::Arc;
7use tokio::sync::RwLock;
8use tower_lsp_server::Client;
9use tower_lsp_server::ls_types::{Diagnostic, Uri};
10
11/// Handles diagnostic requests using trait-based delegation.
12pub async fn handle_diagnostics(
13    state: Arc<ServerState>,
14    uri: &Uri,
15    config: &DiagnosticsConfig,
16    client: Client,
17    full_config: Arc<RwLock<DepsConfig>>,
18) -> Vec<Diagnostic> {
19    // Ensure document is loaded (cold start support)
20    if !ensure_document_loaded(uri, Arc::clone(&state), client, Arc::clone(&full_config)).await {
21        tracing::warn!("Could not load document for diagnostics: {:?}", uri);
22        return vec![];
23    }
24
25    // Snapshot before generating diagnostics (Copy value, no lock held across the call)
26    let (freshness, offline) = {
27        let full_config = full_config.read().await;
28        (
29            full_config.freshness.to_settings(),
30            full_config.network.offline,
31        )
32    };
33    let severities = config.to_severities();
34
35    generate_diagnostics_internal(state, uri, freshness, severities, offline).await
36}
37
38/// Internal diagnostic generation without cold start support.
39///
40/// This is used when we know the document is already loaded (e.g., from background tasks).
41pub(crate) async fn generate_diagnostics_internal(
42    state: Arc<ServerState>,
43    uri: &Uri,
44    freshness: deps_core::FreshnessSettings,
45    severities: deps_core::DiagnosticSeverities,
46    offline: bool,
47) -> Vec<Diagnostic> {
48    // Own everything `generate_diagnostics` needs and release the DashMap shard `Ref`
49    // before awaiting it (#333): `with_document` only ever hands `extract` a borrowed
50    // `&DocumentState` synchronously, so the guard can't leak across the `.await` below.
51    let Some(extracted) = state.with_document(uri, |doc| {
52        let Some(ecosystem) = state.ecosystem_registry.get(doc.ecosystem_id()) else {
53            tracing::warn!(
54                "Ecosystem not found for diagnostics: {}",
55                doc.ecosystem_id()
56            );
57            return None;
58        };
59
60        // Skip diagnostics while versions are still loading to avoid
61        // false "Unknown package" warnings from empty cache
62        // TODO(critic): bound this skip by loading_started_at.elapsed() — see #592 residual
63        if doc.loading_state == deps_core::LoadingState::Loading {
64            return None;
65        }
66
67        let parse_result = doc.parse_result_arc()?;
68        Some((
69            ecosystem,
70            doc.ecosystem,
71            parse_result,
72            doc.cached_versions.clone(),
73            doc.resolved_versions.clone(),
74            doc.vulnerabilities.clone(),
75            doc.outcomes.clone(),
76        ))
77    }) else {
78        tracing::warn!("Document not found for diagnostics: {:?}", uri);
79        return vec![];
80    };
81
82    let Some((
83        ecosystem,
84        ecosystem_id,
85        parse_result,
86        cached_versions,
87        resolved_versions,
88        vulnerabilities,
89        outcomes,
90    )) = extracted
91    else {
92        return vec![];
93    };
94
95    ecosystem
96        .generate_diagnostics(
97            parse_result.as_ref(),
98            VersionData::new(&cached_versions, &resolved_versions)
99                .with_vulnerabilities(&vulnerabilities)
100                .with_outcomes(&outcomes)
101                .with_ecosystem(ecosystem_id)
102                .with_offline(offline),
103            uri,
104            freshness,
105            severities,
106        )
107        .await
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::config::DiagnosticsConfig;
114    use crate::document::ServerState;
115    use crate::test_utils::test_helpers::create_test_client_and_config;
116    use deps_core::EcosystemId;
117
118    // Generic tests (no feature flag required)
119
120    #[tokio::test]
121    async fn test_handle_diagnostics_missing_document() {
122        let state = Arc::new(ServerState::new());
123        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
124        let config = DiagnosticsConfig::default();
125
126        let (client, full_config) = create_test_client_and_config();
127        let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
128        assert!(result.is_empty());
129    }
130
131    /// #333 liveness regression: `handle_diagnostics` must release the DashMap shard
132    /// `Ref` on the document *before* awaiting `Ecosystem::generate_diagnostics`, so a
133    /// concurrent `documents.get_mut` on the same URI (e.g. a `didChange`) is never
134    /// blocked behind an in-flight (or stuck) diagnostics generation.
135    ///
136    /// `BlockingEcosystem::generate_diagnostics` waits on a `Barrier` before blocking
137    /// forever (`std::future::pending`), standing in for an override that performs real
138    /// I/O — the worst case for a shard `Ref` held across the call. The test only
139    /// proceeds to race the writer once that future has demonstrably started executing
140    /// (via the barrier); a concurrent write racing here must complete almost
141    /// immediately, proving the `Ref` was already dropped before the call was awaited.
142    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
143    async fn test_concurrent_document_write_not_blocked_by_in_flight_diagnostics() {
144        use crate::document::DocumentState;
145        use crate::test_utils::blocking_ecosystem::{
146            BlockingEcosystem, BlockingHook, MockParseResult,
147        };
148        use deps_core::ParseResult;
149        use tokio::sync::Barrier;
150
151        let state = Arc::new(ServerState::new());
152        let started = Arc::new(Barrier::new(2));
153        state
154            .ecosystem_registry
155            .register(Arc::new(BlockingEcosystem {
156                started: Arc::clone(&started),
157                hook: BlockingHook::Diagnostics,
158            }));
159
160        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
161        let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
162        let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
163        let doc = DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
164        state.update_document(uri.clone(), doc);
165
166        let config = DiagnosticsConfig::default();
167        let (client, full_config) = create_test_client_and_config();
168
169        let handler_task = tokio::spawn({
170            let state = Arc::clone(&state);
171            let uri = uri.clone();
172            async move { handle_diagnostics(state, &uri, &config, client, full_config).await }
173        });
174
175        // Block until `generate_diagnostics` has actually started executing — i.e.
176        // `handle_diagnostics` has reached (and is now inside) the await — before
177        // racing the writer below. Timeout-wrapped so a regression that makes the
178        // handler never reach the awaited call fails loudly instead of hanging forever.
179        tokio::time::timeout(std::time::Duration::from_secs(5), started.wait())
180            .await
181            .expect("handle_diagnostics did not reach generate_diagnostics within 5s");
182
183        // Spawned onto its own task (rather than awaited inline) deliberately: see
184        // `completion.rs`'s equivalent #319 regression test for why `DashMap::get_mut`
185        // needs a real async yield point to race against `tokio::time::timeout`.
186        let write_task = tokio::spawn({
187            let state = Arc::clone(&state);
188            let uri = uri.clone();
189            async move {
190                state.documents.get_mut(&uri).unwrap().set_loading();
191            }
192        });
193        let write_result =
194            tokio::time::timeout(std::time::Duration::from_millis(500), write_task).await;
195
196        handler_task.abort();
197
198        assert!(
199            write_result.is_ok(),
200            "#333 regression: a concurrent documents.get_mut on the same URI must not \
201             block on an in-flight generate_diagnostics call — the DashMap shard Ref \
202             must be dropped before the call is awaited, not after it"
203        );
204    }
205
206    // Severity wiring tests (issue #224): confirm `DiagnosticsConfig`'s
207    // outdated/unknown severity fields actually reach the emitted diagnostics,
208    // and that default config preserves the pre-existing hardcoded severities.
209    #[cfg(feature = "cargo")]
210    mod severity_wiring_tests {
211        use super::*;
212        use crate::document::DocumentState;
213        use std::collections::HashMap;
214        use tower_lsp_server::ls_types::DiagnosticSeverity;
215
216        #[tokio::test]
217        async fn test_unknown_package_uses_configured_severity() {
218            let state = Arc::new(ServerState::new());
219            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
220            let config = DiagnosticsConfig {
221                unknown_severity: DiagnosticSeverity::ERROR,
222                ..DiagnosticsConfig::default()
223            };
224
225            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
226            let content = r#"[dependencies]
227serde = "1.0.0"
228"#
229            .to_string();
230            let parse_result = ecosystem
231                .parse_manifest(&content, &uri)
232                .await
233                .expect("Failed to parse manifest");
234
235            let doc_state =
236                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
237            state.update_document(uri.clone(), doc_state);
238
239            let (client, full_config) = create_test_client_and_config();
240            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
241
242            assert_eq!(result.len(), 1);
243            assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
244        }
245
246        #[tokio::test]
247        async fn test_unknown_package_default_severity_unchanged() {
248            let state = Arc::new(ServerState::new());
249            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
250            let config = DiagnosticsConfig::default();
251
252            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
253            let content = r#"[dependencies]
254serde = "1.0.0"
255"#
256            .to_string();
257            let parse_result = ecosystem
258                .parse_manifest(&content, &uri)
259                .await
260                .expect("Failed to parse manifest");
261
262            let doc_state =
263                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
264            state.update_document(uri.clone(), doc_state);
265
266            let (client, full_config) = create_test_client_and_config();
267            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
268
269            assert_eq!(result.len(), 1);
270            assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
271        }
272
273        #[tokio::test]
274        async fn test_outdated_dependency_uses_configured_severity() {
275            let state = Arc::new(ServerState::new());
276            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
277            let config = DiagnosticsConfig {
278                outdated_severity: DiagnosticSeverity::ERROR,
279                ..DiagnosticsConfig::default()
280            };
281
282            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
283            let content = r#"[dependencies]
284serde = "1.0.0"
285"#
286            .to_string();
287            let parse_result = ecosystem
288                .parse_manifest(&content, &uri)
289                .await
290                .expect("Failed to parse manifest");
291
292            let mut doc_state =
293                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
294            let mut cached = HashMap::new();
295            // `available` must include the declared "1.0.0" alongside "2.0.0" — a
296            // `latest_only` single-element list containing only "2.0.0" would make the
297            // declared requirement look unsatisfiable (no published version matches "1.0.0")
298            // and fire the mutually-exclusive WARNING instead of this outdated HINT/ERROR.
299            cached.insert(
300                "serde".into(),
301                deps_core::PackageVersions {
302                    latest: "2.0.0".into(),
303                    available: std::sync::Arc::from(vec!["2.0.0".into(), "1.0.0".into()]),
304                    yanked: std::sync::Arc::from(Vec::new()),
305                    published_at: None,
306                },
307            );
308            doc_state.update_cached_versions(cached);
309            state.update_document(uri.clone(), doc_state);
310
311            let (client, full_config) = create_test_client_and_config();
312            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
313
314            assert_eq!(result.len(), 1);
315            assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
316            assert!(result[0].message.contains("Newer version available"));
317        }
318
319        #[tokio::test]
320        async fn test_outdated_dependency_default_severity_unchanged() {
321            let state = Arc::new(ServerState::new());
322            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
323            let config = DiagnosticsConfig::default();
324
325            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
326            let content = r#"[dependencies]
327serde = "1.0.0"
328"#
329            .to_string();
330            let parse_result = ecosystem
331                .parse_manifest(&content, &uri)
332                .await
333                .expect("Failed to parse manifest");
334
335            let mut doc_state =
336                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
337            let mut cached = HashMap::new();
338            // See the sibling test above for why `available` must include "1.0.0", not
339            // just "2.0.0".
340            cached.insert(
341                "serde".into(),
342                deps_core::PackageVersions {
343                    latest: "2.0.0".into(),
344                    available: std::sync::Arc::from(vec!["2.0.0".into(), "1.0.0".into()]),
345                    yanked: std::sync::Arc::from(Vec::new()),
346                    published_at: None,
347                },
348            );
349            doc_state.update_cached_versions(cached);
350            state.update_document(uri.clone(), doc_state);
351
352            let (client, full_config) = create_test_client_and_config();
353            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
354
355            assert_eq!(result.len(), 1);
356            assert_eq!(result[0].severity, Some(DiagnosticSeverity::HINT));
357        }
358
359        #[tokio::test]
360        async fn test_unsatisfiable_requirement_uses_configured_severity() {
361            let state = Arc::new(ServerState::new());
362            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
363            let config = DiagnosticsConfig {
364                unsatisfiable_severity: DiagnosticSeverity::ERROR,
365                ..DiagnosticsConfig::default()
366            };
367
368            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
369            let content = "[dependencies]\nserde = \"99\"\n".to_string();
370            let parse_result = ecosystem
371                .parse_manifest(&content, &uri)
372                .await
373                .expect("Failed to parse manifest");
374
375            let mut doc_state =
376                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
377            let mut cached = HashMap::new();
378            cached.insert(
379                "serde".into(),
380                deps_core::PackageVersions {
381                    latest: "1.0.214".into(),
382                    available: std::sync::Arc::from(vec!["1.0.214".into(), "1.0.213".into()]),
383                    yanked: std::sync::Arc::from(Vec::new()),
384                    published_at: None,
385                },
386            );
387            doc_state.update_cached_versions(cached);
388            state.update_document(uri.clone(), doc_state);
389
390            let (client, full_config) = create_test_client_and_config();
391            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
392
393            assert_eq!(result.len(), 1);
394            assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
395            assert!(result[0].message.contains("No published version satisfies"));
396        }
397    }
398
399    // Cargo-specific tests
400    #[cfg(feature = "cargo")]
401    mod cargo_tests {
402        use super::*;
403        use crate::document::DocumentState;
404
405        #[tokio::test]
406        async fn test_handle_diagnostics() {
407            let state = Arc::new(ServerState::new());
408            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
409            let config = DiagnosticsConfig::default();
410
411            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
412            let content = r#"[dependencies]
413serde = "1.0.0"
414"#
415            .to_string();
416
417            let parse_result = ecosystem
418                .parse_manifest(&content, &uri)
419                .await
420                .expect("Failed to parse manifest");
421
422            let doc_state =
423                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
424            state.update_document(uri.clone(), doc_state);
425
426            let (client, full_config) = create_test_client_and_config();
427            let _result = handle_diagnostics(state, &uri, &config, client, full_config).await;
428            // Test passes if no panic occurs
429        }
430
431        #[tokio::test]
432        async fn test_handle_diagnostics_no_parse_result() {
433            let state = Arc::new(ServerState::new());
434            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
435            let config = DiagnosticsConfig::default();
436
437            let doc_state =
438                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
439            state.update_document(uri.clone(), doc_state);
440
441            let (client, full_config) = create_test_client_and_config();
442            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
443            assert!(result.is_empty());
444        }
445
446        /// End-to-end coverage for issue #206's unsatisfiable-requirement diagnostic,
447        /// through the real `DocumentState` -> `Ecosystem::generate_diagnostics` ->
448        /// `generate_diagnostics_from_cache` -> `CargoFormatter::compile_requirement` path
449        /// (not just the pure `requirement_is_unsatisfiable` function).
450        #[tokio::test]
451        async fn test_handle_diagnostics_unsatisfiable_requirement_yields_one_warning() {
452            let state = Arc::new(ServerState::new());
453            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
454            let config = DiagnosticsConfig::default();
455
456            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
457            let content = "[dependencies]\nserde = \"99\"\n".to_string();
458            let parse_result = ecosystem
459                .parse_manifest(&content, &uri)
460                .await
461                .expect("Failed to parse manifest");
462
463            let mut doc_state =
464                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
465            let mut cached = std::collections::HashMap::new();
466            cached.insert(
467                "serde".into(),
468                deps_core::PackageVersions {
469                    latest: "1.0.214".into(),
470                    available: std::sync::Arc::from(vec!["1.0.214".into(), "1.0.213".into()]),
471                    yanked: std::sync::Arc::from(Vec::new()),
472                    published_at: None,
473                },
474            );
475            doc_state.update_cached_versions(cached);
476            state.update_document(uri.clone(), doc_state);
477
478            let (client, full_config) = create_test_client_and_config();
479            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
480
481            assert_eq!(result.len(), 1, "expected exactly one diagnostic");
482            assert_eq!(
483                result[0].severity,
484                Some(tower_lsp_server::ls_types::DiagnosticSeverity::WARNING)
485            );
486            assert!(result[0].message.contains("No published version satisfies"));
487            assert!(
488                !result
489                    .iter()
490                    .any(|d| d.message.contains("Newer version available")),
491                "the unsatisfiable WARNING must replace the outdated HINT, not add to it"
492            );
493        }
494
495        /// SC-005: an empty `available` list (still loading, or a registry that never
496        /// populated it) must suppress the check entirely rather than treating "nothing
497        /// fetched yet" as "nothing published".
498        #[tokio::test]
499        async fn test_handle_diagnostics_unsatisfiable_requirement_empty_available_yields_nothing()
500        {
501            let state = Arc::new(ServerState::new());
502            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
503            let config = DiagnosticsConfig::default();
504
505            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
506            let content = "[dependencies]\nserde = \"99\"\n".to_string();
507            let parse_result = ecosystem
508                .parse_manifest(&content, &uri)
509                .await
510                .expect("Failed to parse manifest");
511
512            let mut doc_state =
513                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
514            let mut cached = std::collections::HashMap::new();
515            cached.insert(
516                "serde".into(),
517                deps_core::PackageVersions::latest_without_list("1.0.214"),
518            );
519            doc_state.update_cached_versions(cached);
520            state.update_document(uri.clone(), doc_state);
521
522            let (client, full_config) = create_test_client_and_config();
523            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
524            assert!(
525                !result
526                    .iter()
527                    .any(|d| d.message.contains("No published version satisfies")),
528                "an empty available list must suppress the unsatisfiable check, got: {result:?}"
529            );
530        }
531
532        /// NFR-004: a satisfiable-but-outdated dependency alongside an unsatisfiable one
533        /// must still get its usual "Newer version available" HINT.
534        #[tokio::test]
535        async fn test_handle_diagnostics_unsatisfiable_and_outdated_side_by_side() {
536            let state = Arc::new(ServerState::new());
537            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
538            let config = DiagnosticsConfig::default();
539
540            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
541            let content = "[dependencies]\nserde = \"99\"\ntokio = \"1.0\"\n".to_string();
542            let parse_result = ecosystem
543                .parse_manifest(&content, &uri)
544                .await
545                .expect("Failed to parse manifest");
546
547            let mut doc_state =
548                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
549            let mut cached = std::collections::HashMap::new();
550            cached.insert(
551                "serde".into(),
552                deps_core::PackageVersions {
553                    latest: "1.0.214".into(),
554                    available: std::sync::Arc::from(vec!["1.0.214".into()]),
555                    yanked: std::sync::Arc::from(Vec::new()),
556                    published_at: None,
557                },
558            );
559            cached.insert(
560                "tokio".into(),
561                deps_core::PackageVersions {
562                    latest: "2.0.0".into(),
563                    // Includes an older version satisfying "1.0" (^1.0) so this dependency
564                    // is genuinely outdated-but-satisfiable, not unsatisfiable — an
565                    // available list containing only `latest` would make every requirement
566                    // that latest doesn't itself satisfy look unsatisfiable.
567                    available: std::sync::Arc::from(vec!["2.0.0".into(), "1.5.0".into()]),
568                    yanked: std::sync::Arc::from(Vec::new()),
569                    published_at: None,
570                },
571            );
572            doc_state.update_cached_versions(cached);
573            state.update_document(uri.clone(), doc_state);
574
575            let (client, full_config) = create_test_client_and_config();
576            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
577
578            assert_eq!(result.len(), 2, "expected one warning and one hint");
579            assert!(
580                result
581                    .iter()
582                    .any(|d| d.message.contains("No published version satisfies"))
583            );
584            assert!(
585                result
586                    .iter()
587                    .any(|d| d.message.contains("Newer version available"))
588            );
589        }
590
591        /// End-to-end coverage for issue #247: a dependency pinned to an exact version that
592        /// the registry reports as yanked must produce the yanked diagnostic through the real
593        /// `DocumentState` -> `Ecosystem::generate_diagnostics` ->
594        /// `generate_diagnostics_from_cache` -> `CargoFormatter::compile_requirement` path —
595        /// the same live path the LSP server actually calls, not just the pure
596        /// `requirement_matches_only_yanked` function.
597        #[tokio::test]
598        async fn test_handle_diagnostics_yanked_only_match_yields_one_warning() {
599            let state = Arc::new(ServerState::new());
600            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
601            let config = DiagnosticsConfig::default();
602
603            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
604            let content = "[dependencies]\nserde = \"=1.0.213\"\n".to_string();
605            let parse_result = ecosystem
606                .parse_manifest(&content, &uri)
607                .await
608                .expect("Failed to parse manifest");
609
610            let mut doc_state =
611                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
612            let mut cached = std::collections::HashMap::new();
613            cached.insert(
614                "serde".into(),
615                deps_core::PackageVersions {
616                    latest: "1.0.214".into(),
617                    available: std::sync::Arc::from(vec!["1.0.214".into(), "1.0.213".into()]),
618                    yanked: std::sync::Arc::from(vec![(
619                        "1.0.213".into(),
620                        deps_core::RemovalStatus::Yanked,
621                    )]),
622                    published_at: None,
623                },
624            );
625            doc_state.update_cached_versions(cached);
626            state.update_document(uri.clone(), doc_state);
627
628            let (client, full_config) = create_test_client_and_config();
629            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
630
631            assert_eq!(result.len(), 1, "expected exactly one diagnostic");
632            assert_eq!(
633                result[0].severity,
634                Some(tower_lsp_server::ls_types::DiagnosticSeverity::WARNING)
635            );
636            assert_eq!(
637                result[0].message,
638                "This version has been yanked; latest is 1.0.214"
639            );
640        }
641    }
642
643    // npm-specific tests
644    #[cfg(feature = "npm")]
645    mod npm_tests {
646        use super::*;
647        use crate::document::DocumentState;
648        use deps_core::DiagnosticMessages;
649
650        #[tokio::test]
651        async fn test_handle_diagnostics() {
652            let state = Arc::new(ServerState::new());
653            let uri = deps_core::test_util::test_uri("/test/package.json");
654            let config = DiagnosticsConfig::default();
655
656            let ecosystem = state.ecosystem_registry.get("npm").unwrap();
657            let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string();
658
659            let parse_result = ecosystem
660                .parse_manifest(&content, &uri)
661                .await
662                .expect("Failed to parse manifest");
663
664            let doc_state =
665                DocumentState::new_from_parse_result(EcosystemId::Npm, content, parse_result);
666            state.update_document(uri.clone(), doc_state);
667
668            let (client, full_config) = create_test_client_and_config();
669            let _result = handle_diagnostics(state, &uri, &config, client, full_config).await;
670            // Test passes if no panic occurs
671        }
672
673        /// #436 S1 regression: after #436 narrowed npm's fix to only suppress the
674        /// manifest-requirement-level yanked diagnostic (`NpmFormatter::yanked_diagnostic_applies_to`
675        /// now unconditionally `false`), the independent #263 in-use-version yanked diagnostic
676        /// must still fire through the real `DocumentState` -> `Ecosystem::generate_diagnostics`
677        /// -> `generate_diagnostics_from_cache` path — the same live path the LSP server
678        /// actually calls, using the real `NpmFormatter`/`NpmRegistry`-backed npm ecosystem
679        /// (not a generic `MockRegistry`).
680        ///
681        /// Mirrors the canonical `npm deprecate left-pad@"<1.0.2" "..."` scenario: the
682        /// manifest declares a range (`^1.0.0`), `latest` (1.0.2) is clean, but the
683        /// lockfile-resolved in-use version (1.0.1) is flagged. This is exactly the coverage
684        /// the critic's S1 finding said `Registry::reports_yanked() == false` would have
685        /// silently killed had npm's first #436 pass gone unrevised.
686        #[tokio::test]
687        async fn test_handle_diagnostics_in_use_version_yanked_still_fires_post_436() {
688            let state = Arc::new(ServerState::new());
689            let uri = deps_core::test_util::test_uri("/test/package.json");
690            let config = DiagnosticsConfig::default();
691
692            let ecosystem = state.ecosystem_registry.get("npm").unwrap();
693            let content = r#"{"dependencies": {"left-pad": "^1.0.0"}}"#.to_string();
694            let parse_result = ecosystem
695                .parse_manifest(&content, &uri)
696                .await
697                .expect("Failed to parse manifest");
698
699            let mut doc_state =
700                DocumentState::new_from_parse_result(EcosystemId::Npm, content, parse_result);
701
702            let mut cached = std::collections::HashMap::new();
703            cached.insert(
704                "left-pad".into(),
705                deps_core::PackageVersions {
706                    latest: "1.0.2".into(),
707                    available: std::sync::Arc::from(vec![
708                        "1.0.2".into(),
709                        "1.0.1".into(),
710                        "1.0.0".into(),
711                    ]),
712                    yanked: std::sync::Arc::from(Vec::new()),
713                    published_at: None,
714                },
715            );
716            doc_state.update_cached_versions(cached);
717
718            // Lockfile resolves the "^1.0.0" range to the old, flagged 1.0.1 — `latest`
719            // itself is clean, so this is only reachable via the lockfile-resolved
720            // in-use-version check (#263), not the manifest-requirement check (#247).
721            let mut resolved = std::collections::HashMap::new();
722            resolved.insert("left-pad".into(), "1.0.1".into());
723            doc_state.update_resolved_versions(resolved);
724
725            doc_state.replace_outcomes(deps_core::DependencyOutcomes::new().with_yanked(
726                "left-pad",
727                ("1.0.1".into(), deps_core::RemovalStatus::AdvisoryDeprecated),
728            ));
729
730            state.update_document(uri.clone(), doc_state);
731
732            let (client, full_config) = create_test_client_and_config();
733            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
734
735            assert_eq!(
736                result.len(),
737                1,
738                "expected exactly one diagnostic, got {result:?}"
739            );
740            assert_eq!(
741                result[0].severity,
742                Some(tower_lsp_server::ls_types::DiagnosticSeverity::WARNING)
743            );
744            assert_eq!(
745                result[0].message,
746                format!("{} (1.0.1)", deps_npm::NpmFormatter.yanked_message())
747            );
748        }
749
750        /// #436 S1 companion: the manifest-requirement-level yanked diagnostic (#247) must
751        /// stay suppressed for npm even for an exact-pin requirement — the one shape the
752        /// pre-#436 restriction still let through. Deliberately isolated from the #263 path
753        /// (no `resolved_versions`/`yanked_versions` set) so this proves
754        /// `NpmFormatter::yanked_diagnostic_applies_to`'s unconditional `false` is doing real
755        /// work here, not merely benefiting from the `yanked_263_diagnostic_pushed` dedup
756        /// guard the sibling test above would also satisfy on its own.
757        #[tokio::test]
758        async fn test_handle_diagnostics_manifest_requirement_yanked_stays_suppressed_for_exact_pin()
759         {
760            let state = Arc::new(ServerState::new());
761            let uri = deps_core::test_util::test_uri("/test/package.json");
762            let config = DiagnosticsConfig::default();
763
764            let ecosystem = state.ecosystem_registry.get("npm").unwrap();
765            // Bare exact pin (npm's ordinary package.json style, no `=` marker) — the
766            // shape `yanked_diagnostic_applies_to` still allowed through pre-#436.
767            let content = r#"{"dependencies": {"old-pkg": "1.0.1"}}"#.to_string();
768            let parse_result = ecosystem
769                .parse_manifest(&content, &uri)
770                .await
771                .expect("Failed to parse manifest");
772
773            let mut doc_state =
774                DocumentState::new_from_parse_result(EcosystemId::Npm, content, parse_result);
775
776            let mut cached = std::collections::HashMap::new();
777            cached.insert(
778                "old-pkg".into(),
779                deps_core::PackageVersions {
780                    // The pin is satisfiable only by a flagged version — pre-#436, this
781                    // exact shape fired the #247 "yanked" diagnostic.
782                    latest: "1.0.1".into(),
783                    available: std::sync::Arc::from(vec!["1.0.1".into()]),
784                    yanked: std::sync::Arc::from(vec![(
785                        "1.0.1".into(),
786                        deps_core::RemovalStatus::AdvisoryDeprecated,
787                    )]),
788                    published_at: None,
789                },
790            );
791            doc_state.update_cached_versions(cached);
792            // No `resolved_versions`/`yanked_versions` — the #263 in-use-version path has
793            // nothing to match against, isolating this assertion to the #247 path alone.
794            state.update_document(uri.clone(), doc_state);
795
796            let (client, full_config) = create_test_client_and_config();
797            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
798
799            assert!(
800                result.is_empty(),
801                "expected no diagnostics for an exact pin satisfiable only by a flagged \
802                 version — the #247 manifest-requirement diagnostic must stay suppressed for \
803                 npm regardless of requirement shape (#436), got {result:?}"
804            );
805        }
806    }
807
808    // Deno-specific tests
809    #[cfg(feature = "deno")]
810    mod deno_tests {
811        use super::*;
812        use crate::document::DocumentState;
813        use deps_core::DiagnosticMessages;
814
815        /// #448 regression: mirrors npm_tests'
816        /// `test_handle_diagnostics_manifest_requirement_yanked_stays_suppressed_for_exact_pin`
817        /// through the real `DocumentState` -> `Ecosystem::generate_diagnostics` ->
818        /// `generate_diagnostics_from_cache` -> `DenoFormatter::yanked_diagnostic_applies_to`
819        /// path — an exact-pin `npm:` specifier in `deno.json` satisfiable only by a flagged
820        /// version must NOT surface the #247 manifest-requirement yanked diagnostic, exactly
821        /// like the equivalent `package.json` dependency (fixes the #436 M1 divergence).
822        #[tokio::test]
823        async fn test_handle_diagnostics_npm_scheme_exact_pin_yanked_stays_suppressed() {
824            let state = Arc::new(ServerState::new());
825            let uri = deps_core::test_util::test_uri("/test/deno.json");
826            let config = DiagnosticsConfig::default();
827
828            let ecosystem = state.ecosystem_registry.get("deno").unwrap();
829            let content = r#"{"imports": {"lodash": "npm:lodash@4.17.20"}}"#.to_string();
830            let parse_result = ecosystem
831                .parse_manifest(&content, &uri)
832                .await
833                .expect("Failed to parse manifest");
834
835            let mut doc_state =
836                DocumentState::new_from_parse_result(EcosystemId::Deno, content, parse_result);
837
838            let mut cached = std::collections::HashMap::new();
839            cached.insert(
840                "npm:lodash".into(),
841                deps_core::PackageVersions {
842                    // The pin is satisfiable only by a flagged version — for `package.json`
843                    // this exact shape used to fire the #247 "yanked" diagnostic pre-#436.
844                    latest: "4.17.20".into(),
845                    available: std::sync::Arc::from(vec!["4.17.20".into()]),
846                    yanked: std::sync::Arc::from(vec![(
847                        "4.17.20".into(),
848                        deps_core::RemovalStatus::AdvisoryDeprecated,
849                    )]),
850                    published_at: None,
851                },
852            );
853            doc_state.update_cached_versions(cached);
854            // No `resolved_versions`/`yanked_versions` — isolates this assertion to the #247
855            // manifest-requirement path, same as the npm companion test.
856            state.update_document(uri.clone(), doc_state);
857
858            let (client, full_config) = create_test_client_and_config();
859            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
860
861            assert!(
862                result.is_empty(),
863                "expected no diagnostics for an exact-pin npm: specifier satisfiable only by a \
864                 flagged version — the #247 manifest-requirement diagnostic must stay \
865                 suppressed for deno's npm: scheme (#448), got {result:?}"
866            );
867        }
868
869        /// #448/#454: an exact-pin `jsr:` specifier satisfiable only by a flagged version
870        /// fires the #247 diagnostic, proving the scheme split actually discriminates
871        /// `jsr:` from `npm:` end-to-end, not just in the isolated
872        /// `yanked_diagnostic_applies_to` unit tests.
873        #[tokio::test]
874        async fn test_handle_diagnostics_jsr_scheme_exact_pin_yanked_still_fires() {
875            let state = Arc::new(ServerState::new());
876            let uri = deps_core::test_util::test_uri("/test/deno.json");
877            let config = DiagnosticsConfig::default();
878
879            let ecosystem = state.ecosystem_registry.get("deno").unwrap();
880            let content = r#"{"imports": {"@std/fs": "jsr:@std/fs@1.0.0"}}"#.to_string();
881            let parse_result = ecosystem
882                .parse_manifest(&content, &uri)
883                .await
884                .expect("Failed to parse manifest");
885
886            let mut doc_state =
887                DocumentState::new_from_parse_result(EcosystemId::Deno, content, parse_result);
888
889            let mut cached = std::collections::HashMap::new();
890            cached.insert(
891                "jsr:@std/fs".into(),
892                deps_core::PackageVersions {
893                    latest: "1.0.1".into(),
894                    available: std::sync::Arc::from(vec!["1.0.1".into(), "1.0.0".into()]),
895                    yanked: std::sync::Arc::from(vec![(
896                        "1.0.0".into(),
897                        deps_core::RemovalStatus::Yanked,
898                    )]),
899                    published_at: None,
900                },
901            );
902            doc_state.update_cached_versions(cached);
903            state.update_document(uri.clone(), doc_state);
904
905            let (client, full_config) = create_test_client_and_config();
906            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
907
908            assert_eq!(
909                result.len(),
910                1,
911                "expected the #247 diagnostic to still fire for an exact-pin jsr: specifier, \
912                 got {result:?}"
913            );
914            assert_eq!(
915                result[0].message,
916                format!(
917                    "{}; latest is 1.0.1",
918                    deps_deno::DenoFormatter.yanked_message()
919                )
920            );
921        }
922
923        /// #454: the actual bug fix, proven end-to-end — a `jsr:` *range* requirement
924        /// satisfiable only by yanked versions must now surface the #247
925        /// manifest-requirement diagnostic too, matching Cargo/PyPI/Dart's behavior for the
926        /// equivalent case (previously this was silent: `yanked_diagnostic_applies_to`
927        /// rejected any non-exact-pin `jsr:` requirement). Exactly one diagnostic fires,
928        /// confirming this does not double up with any package-level deprecation (#205)
929        /// signal — deno has no such diagnostic for `jsr:` in the first place.
930        #[tokio::test]
931        async fn test_handle_diagnostics_jsr_scheme_range_yanked_only_now_fires() {
932            let state = Arc::new(ServerState::new());
933            let uri = deps_core::test_util::test_uri("/test/deno.json");
934            let config = DiagnosticsConfig::default();
935
936            let ecosystem = state.ecosystem_registry.get("deno").unwrap();
937            let content = r#"{"imports": {"@std/fs": "jsr:@std/fs@^1.0.0"}}"#.to_string();
938            let parse_result = ecosystem
939                .parse_manifest(&content, &uri)
940                .await
941                .expect("Failed to parse manifest");
942
943            let mut doc_state =
944                DocumentState::new_from_parse_result(EcosystemId::Deno, content, parse_result);
945
946            let mut cached = std::collections::HashMap::new();
947            cached.insert(
948                "jsr:@std/fs".into(),
949                deps_core::PackageVersions {
950                    // Every version matching "^1.0.0" is yanked — the concrete #454 bug
951                    // scenario, which previously produced zero diagnostic signal.
952                    latest: "1.0.1".into(),
953                    available: std::sync::Arc::from(vec!["1.0.1".into(), "1.0.0".into()]),
954                    yanked: std::sync::Arc::from(vec![
955                        ("1.0.1".into(), deps_core::RemovalStatus::Yanked),
956                        ("1.0.0".into(), deps_core::RemovalStatus::Yanked),
957                    ]),
958                    published_at: None,
959                },
960            );
961            doc_state.update_cached_versions(cached);
962            // No `resolved_versions`/`yanked_versions` — isolates this assertion to the #247
963            // manifest-requirement path, same as the sibling exact-pin test.
964            state.update_document(uri.clone(), doc_state);
965
966            let (client, full_config) = create_test_client_and_config();
967            let result = handle_diagnostics(state, &uri, &config, client, full_config).await;
968
969            assert_eq!(
970                result.len(),
971                1,
972                "expected exactly one diagnostic for a jsr: range satisfiable only by yanked \
973                 versions (#454), got {result:?}"
974            );
975            assert_eq!(
976                result[0].message,
977                format!(
978                    "{}; latest is 1.0.1",
979                    deps_deno::DenoFormatter.yanked_message()
980                )
981            );
982        }
983    }
984
985    // PyPI-specific tests
986    #[cfg(feature = "pypi")]
987    mod pypi_tests {
988        use super::*;
989        use crate::document::DocumentState;
990
991        #[tokio::test]
992        async fn test_handle_diagnostics() {
993            let state = Arc::new(ServerState::new());
994            let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
995            let config = DiagnosticsConfig::default();
996
997            let ecosystem = state.ecosystem_registry.get("pypi").unwrap();
998            let content = r#"[project]
999dependencies = ["requests>=2.0.0"]
1000"#
1001            .to_string();
1002
1003            let parse_result = ecosystem
1004                .parse_manifest(&content, &uri)
1005                .await
1006                .expect("Failed to parse manifest");
1007
1008            let doc_state =
1009                DocumentState::new_from_parse_result(EcosystemId::Pypi, content, parse_result);
1010            state.update_document(uri.clone(), doc_state);
1011
1012            let (client, full_config) = create_test_client_and_config();
1013            let _result = handle_diagnostics(state, &uri, &config, client, full_config).await;
1014            // Test passes if no panic occurs
1015        }
1016    }
1017}