Skip to main content

deps_lsp/handlers/
code_actions.rs

1//! Code actions handler using ecosystem trait delegation.
2
3use crate::config::DepsConfig;
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::{
10    CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, Diagnostic, NumberOrString,
11    Range,
12};
13
14/// Handles code action requests using trait-based delegation.
15pub async fn handle_code_actions(
16    state: Arc<ServerState>,
17    params: CodeActionParams,
18    client: Client,
19    config: Arc<RwLock<DepsConfig>>,
20) -> Vec<CodeActionOrCommand> {
21    let uri = &params.text_document.uri;
22    let position = params.range.start;
23
24    // Ensure document is loaded (cold start support)
25    if !ensure_document_loaded(uri, Arc::clone(&state), client, Arc::clone(&config)).await {
26        tracing::warn!("Could not load document for code actions: {:?}", uri);
27        return vec![];
28    }
29
30    let offline = { config.read().await.network.offline };
31
32    // Own everything `generate_code_actions` needs and release the DashMap shard
33    // `Ref` before awaiting it: the default impl awaits a real registry fetch, so
34    // holding the guard across that await would block a concurrent
35    // `documents.get_mut` on the same shard for the duration (#319).
36    // `with_document` makes this structural rather than a convention to remember (#333).
37    let Some((
38        ecosystem,
39        ecosystem_id,
40        parse_result,
41        cached_versions,
42        resolved_versions,
43        vulnerabilities,
44        outcomes,
45        content,
46    )) = state
47        .with_document(uri, |doc| {
48            let ecosystem = state.ecosystem_registry.get(doc.ecosystem_id())?;
49            let parse_result = doc.parse_result_arc()?;
50            Some((
51                ecosystem,
52                doc.ecosystem,
53                parse_result,
54                doc.cached_versions.clone(),
55                doc.resolved_versions.clone(),
56                doc.vulnerabilities.clone(),
57                doc.outcomes.clone(),
58                doc.content.clone(),
59            ))
60        })
61        .flatten()
62    else {
63        return vec![];
64    };
65
66    let mut actions = ecosystem
67        .generate_code_actions(
68            parse_result.as_ref(),
69            position,
70            uri,
71            VersionData::new(&cached_versions, &resolved_versions)
72                .with_vulnerabilities(&vulnerabilities)
73                .with_outcomes(&outcomes)
74                .with_ecosystem(ecosystem_id)
75                .with_offline(offline),
76            &content,
77        )
78        .await;
79
80    bind_diagnostics(&mut actions, &params.context.diagnostics);
81
82    let actions = match params.context.only.as_deref() {
83        Some(only) if !only.is_empty() => filter_by_requested_kinds(actions, only),
84        _ => actions,
85    };
86
87    actions
88        .into_iter()
89        .map(CodeActionOrCommand::CodeAction)
90        .collect()
91}
92
93/// Binds a code action to the client-supplied diagnostics it resolves, so editors can
94/// surface it from the diagnostic's own lightbulb/quickfix affordance.
95///
96/// `deps-core` has no LSP request context, so a code-action producer in
97/// [`deps_core::lsp_helpers::generate_code_actions`] (the vulnerability fix, or the
98/// unsatisfiable-requirement fix) stashes `{"diagnostic_codes": [...], "diagnostic_range":
99/// <Range>}` in `CodeAction::data` instead. This matches against `diagnostics` — the set
100/// the client already sent in `CodeActionParams.context` — in three steps:
101///
102/// 1. Filter to entries this server emitted (`source == "deps-lsp"`) whose `code` names
103///    one of `diagnostic_codes`.
104/// 2. If at most one candidate remains, bind it with no further check. This is the common
105///    case — every vulnerability fix (a unique advisory id) and every single-unsatisfiable
106///    -dependency document — and matches today's behavior exactly, deliberately with no
107///    range check: `context.diagnostics` are the diagnostics the *client* holds from its
108///    last `publishDiagnostics`, which shift as the user types, while `diagnostic_range`
109///    is recomputed from the freshly re-parsed buffer. Requiring equality here would make
110///    an in-flight edit break a binding that works today.
111/// 3. Only when two or more candidates remain — reachable because
112///    `UNSATISFIABLE_DIAGNOSTIC_CODE` (and, since issue #473, GitHub Actions'
113///    `MUTABLE_REF_PIN_DIAGNOSTIC_CODE` — the more frequent case in practice, since it
114///    fires on every tag-pinned `uses:` step in a workflow) is a constant shared by
115///    every matching dependency in a document, unlike a unique advisory id — narrow to
116///    the candidates whose range *overlaps* `diagnostic_range`, so one action does not
117///    claim every same-code dependency in the document. Overlap, not equality, so a
118///    range shifted by an in-flight edit still matches. If the narrowing leaves nothing,
119///    fall back to the full code-matched set rather than binding nothing.
120///
121/// `data` is cleared afterward regardless of whether a match was found, so a stale payload
122/// can never be mistaken for a still-resolvable action.
123///
124/// Accepted tradeoff: because a diagnostic code can be a shared constant rather than a
125/// per-instance id (`UNSATISFIABLE_DIAGNOSTIC_CODE`, `MUTABLE_REF_PIN_DIAGNOSTIC_CODE`), if
126/// the client's diagnostic list happens to contain exactly one same-code diagnostic and it
127/// belongs to a *different* dependency than the one this action targets, step 2 still binds
128/// it (no range check on a single candidate). This is cosmetic mis-attribution of the
129/// editor's "fix this problem" affordance only — the `TextEdit` itself always comes from
130/// this action's own `dep.version_range()`, so no incorrect edit is possible.
131fn bind_diagnostics(actions: &mut [CodeAction], diagnostics: &[Diagnostic]) {
132    for action in actions {
133        let Some(data) = action.data.take() else {
134            continue;
135        };
136        let Some(codes) = data
137            .get("diagnostic_codes")
138            .and_then(serde_json::Value::as_array)
139        else {
140            continue;
141        };
142        let codes: Vec<&str> = codes.iter().filter_map(serde_json::Value::as_str).collect();
143
144        let candidates: Vec<&Diagnostic> = diagnostics
145            .iter()
146            .filter(|d| {
147                d.source.as_deref() == Some("deps-lsp")
148                    && matches!(&d.code, Some(NumberOrString::String(code)) if codes.contains(&code.as_str()))
149            })
150            .collect();
151
152        let matches: Vec<Diagnostic> = if candidates.len() <= 1 {
153            candidates.into_iter().cloned().collect()
154        } else {
155            let diagnostic_range = data
156                .get("diagnostic_range")
157                .and_then(|v| serde_json::from_value::<Range>(v.clone()).ok());
158
159            let overlapping: Vec<Diagnostic> = diagnostic_range
160                .map(|range| {
161                    candidates
162                        .iter()
163                        .filter(|d| ranges_overlap(&d.range, &range))
164                        .map(|d| (**d).clone())
165                        .collect()
166                })
167                .unwrap_or_default();
168
169            if overlapping.is_empty() {
170                candidates.into_iter().cloned().collect()
171            } else {
172                overlapping
173            }
174        };
175
176        if !matches.is_empty() {
177            action.diagnostics = Some(matches);
178        }
179    }
180}
181
182/// Whether `a` and `b` overlap, under LSP `Position`'s line-then-character ordering.
183fn ranges_overlap(a: &Range, b: &Range) -> bool {
184    a.start <= b.end && b.start <= a.end
185}
186
187/// Filters `actions` down to those matching one of the client-requested
188/// `only` kinds, using LSP's hierarchical kind matching rather than plain
189/// equality: a request for `refactor` also matches the more specific
190/// `refactor.extract`. Without this, a client asking only for quickfixes
191/// still received every plain "update to version X" `REFACTOR` action
192/// alongside the vulnerability-fix `QUICKFIX`.
193fn filter_by_requested_kinds(actions: Vec<CodeAction>, only: &[CodeActionKind]) -> Vec<CodeAction> {
194    actions
195        .into_iter()
196        .filter(|action| {
197            action
198                .kind
199                .as_ref()
200                .is_some_and(|kind| only.iter().any(|filter| kind_matches(kind, filter)))
201        })
202        .collect()
203}
204
205/// Whether `kind` is `filter` itself or one of `filter`'s sub-kinds
206/// (dot-separated, e.g. `refactor.extract` under `refactor`).
207fn kind_matches(kind: &CodeActionKind, filter: &CodeActionKind) -> bool {
208    let (kind, filter) = (kind.as_str(), filter.as_str());
209    kind == filter
210        || kind
211            .strip_prefix(filter)
212            .is_some_and(|rest| rest.starts_with('.'))
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::document::ServerState;
219    use crate::test_utils::test_helpers::create_test_client_and_config;
220    use deps_core::EcosystemId;
221    use tower_lsp_server::ls_types::{Position, Range, TextDocumentIdentifier};
222
223    // Generic tests (no feature flag required)
224
225    fn action(kind: CodeActionKind) -> CodeAction {
226        CodeAction {
227            title: "test action".to_string(),
228            kind: Some(kind),
229            ..Default::default()
230        }
231    }
232
233    #[test]
234    fn test_kind_matches_exact() {
235        assert!(kind_matches(
236            &CodeActionKind::QUICKFIX,
237            &CodeActionKind::QUICKFIX
238        ));
239    }
240
241    #[test]
242    fn test_kind_matches_sub_kind() {
243        assert!(kind_matches(
244            &CodeActionKind::REFACTOR_EXTRACT,
245            &CodeActionKind::REFACTOR
246        ));
247    }
248
249    #[test]
250    fn test_kind_matches_rejects_unrelated_prefix() {
251        // "refactoring" must not match a filter of "refactor" just because it
252        // shares a string prefix without a `.` boundary.
253        assert!(!kind_matches(
254            &CodeActionKind::from("refactoring"),
255            &CodeActionKind::REFACTOR
256        ));
257        assert!(!kind_matches(
258            &CodeActionKind::REFACTOR,
259            &CodeActionKind::QUICKFIX
260        ));
261    }
262
263    #[test]
264    fn test_filter_by_requested_kinds_keeps_only_matching() {
265        let actions = vec![
266            action(CodeActionKind::QUICKFIX),
267            action(CodeActionKind::REFACTOR),
268        ];
269
270        let filtered = filter_by_requested_kinds(actions, &[CodeActionKind::QUICKFIX]);
271
272        assert_eq!(filtered.len(), 1);
273        assert_eq!(filtered[0].kind, Some(CodeActionKind::QUICKFIX));
274    }
275
276    #[test]
277    fn test_filter_by_requested_kinds_drops_actions_with_no_kind() {
278        let actions = vec![CodeAction {
279            title: "no kind".to_string(),
280            kind: None,
281            ..Default::default()
282        }];
283
284        let filtered = filter_by_requested_kinds(actions, &[CodeActionKind::QUICKFIX]);
285
286        assert!(filtered.is_empty());
287    }
288
289    fn vuln_diagnostic(source: Option<&str>, code: Option<&str>) -> Diagnostic {
290        Diagnostic {
291            source: source.map(str::to_string),
292            code: code.map(|c| NumberOrString::String(c.to_string())),
293            ..Default::default()
294        }
295    }
296
297    fn ranged_diagnostic(source: &str, code: &str, range: Range) -> Diagnostic {
298        Diagnostic {
299            source: Some(source.to_string()),
300            code: Some(NumberOrString::String(code.to_string())),
301            range,
302            ..Default::default()
303        }
304    }
305
306    fn action_with_data(codes: &[&str], range: Range) -> CodeAction {
307        CodeAction {
308            title: "fix".to_string(),
309            kind: Some(CodeActionKind::QUICKFIX),
310            data: Some(serde_json::json!({
311                "diagnostic_codes": codes,
312                "diagnostic_range": range,
313            })),
314            ..Default::default()
315        }
316    }
317
318    #[test]
319    fn test_bind_diagnostics_matches_by_source_and_code() {
320        let mut actions = vec![CodeAction {
321            title: "fix".to_string(),
322            kind: Some(CodeActionKind::QUICKFIX),
323            data: Some(serde_json::json!({ "diagnostic_codes": ["RUSTSEC-1", "RUSTSEC-2"] })),
324            ..Default::default()
325        }];
326        let diagnostics = vec![
327            vuln_diagnostic(Some("deps-lsp"), Some("RUSTSEC-1")),
328            vuln_diagnostic(Some("deps-lsp"), Some("RUSTSEC-2")),
329            // Not matched: different source.
330            vuln_diagnostic(Some("other-source"), Some("RUSTSEC-1")),
331            // Not matched: unrelated advisory id.
332            vuln_diagnostic(Some("deps-lsp"), Some("RUSTSEC-999")),
333        ];
334
335        bind_diagnostics(&mut actions, &diagnostics);
336
337        let attached = actions[0].diagnostics.as_ref().expect("expected matches");
338        assert_eq!(attached.len(), 2);
339        // `data` is cleared once its ids have been transferred (critic M8).
340        assert!(actions[0].data.is_none());
341    }
342
343    #[test]
344    fn test_bind_diagnostics_no_match_clears_data_without_setting_diagnostics() {
345        let mut actions = vec![CodeAction {
346            title: "fix".to_string(),
347            kind: Some(CodeActionKind::QUICKFIX),
348            data: Some(serde_json::json!({ "diagnostic_codes": ["RUSTSEC-1"] })),
349            ..Default::default()
350        }];
351        let diagnostics = vec![vuln_diagnostic(Some("deps-lsp"), Some("RUSTSEC-999"))];
352
353        bind_diagnostics(&mut actions, &diagnostics);
354
355        assert!(actions[0].diagnostics.is_none());
356        assert!(actions[0].data.is_none());
357    }
358
359    #[test]
360    fn test_bind_diagnostics_action_without_data_is_untouched() {
361        let mut actions = vec![action(CodeActionKind::REFACTOR)];
362        let diagnostics = vec![vuln_diagnostic(Some("deps-lsp"), Some("RUSTSEC-1"))];
363
364        bind_diagnostics(&mut actions, &diagnostics);
365
366        assert!(actions[0].diagnostics.is_none());
367    }
368
369    #[test]
370    fn test_bind_diagnostics_single_candidate_binds_despite_shifted_range() {
371        // Today's behavior, must not regress (critic S2): a single code-matched candidate
372        // binds with no range check at all, even when the client-held diagnostic's range
373        // has drifted from the action's freshly-recomputed `diagnostic_range` (an in-flight
374        // edit above the dependency line shifts the client's held range but not the range
375        // recomputed from the current buffer).
376        let action_range = Range::new(Position::new(5, 0), Position::new(5, 10));
377        let shifted_range = Range::new(Position::new(9, 0), Position::new(9, 10));
378        let mut actions = vec![action_with_data(
379            &["unsatisfiable-requirement"],
380            action_range,
381        )];
382        let diagnostics = vec![ranged_diagnostic(
383            "deps-lsp",
384            "unsatisfiable-requirement",
385            shifted_range,
386        )];
387
388        bind_diagnostics(&mut actions, &diagnostics);
389
390        assert_eq!(
391            actions[0]
392                .diagnostics
393                .as_ref()
394                .expect("single candidate must bind with no range check")
395                .len(),
396            1
397        );
398    }
399
400    #[test]
401    fn test_bind_diagnostics_two_candidates_narrow_to_overlapping_one() {
402        // The anti-fan-out property `UNSATISFIABLE_DIAGNOSTIC_CODE` (a constant shared by
403        // every unsatisfiable dependency in a document) needs: with two code-matched
404        // diagnostics, only the one overlapping this action's own range binds.
405        let action_range = Range::new(Position::new(5, 0), Position::new(5, 10));
406        let overlapping = Range::new(Position::new(5, 2), Position::new(5, 8));
407        let elsewhere = Range::new(Position::new(20, 0), Position::new(20, 10));
408        let mut actions = vec![action_with_data(
409            &["unsatisfiable-requirement"],
410            action_range,
411        )];
412        let diagnostics = vec![
413            ranged_diagnostic("deps-lsp", "unsatisfiable-requirement", overlapping),
414            ranged_diagnostic("deps-lsp", "unsatisfiable-requirement", elsewhere),
415        ];
416
417        bind_diagnostics(&mut actions, &diagnostics);
418
419        let attached = actions[0].diagnostics.as_ref().expect("expected a match");
420        assert_eq!(attached.len(), 1);
421        assert_eq!(attached[0].range, overlapping);
422    }
423
424    #[test]
425    fn test_bind_diagnostics_two_candidates_no_overlap_falls_back_to_full_set() {
426        // If narrowing by range leaves nothing (both client-held ranges have drifted off
427        // the freshly-recomputed range), fall back to binding the full code-matched set
428        // rather than binding nothing.
429        let action_range = Range::new(Position::new(5, 0), Position::new(5, 10));
430        let first = Range::new(Position::new(20, 0), Position::new(20, 10));
431        let second = Range::new(Position::new(30, 0), Position::new(30, 10));
432        let mut actions = vec![action_with_data(
433            &["unsatisfiable-requirement"],
434            action_range,
435        )];
436        let diagnostics = vec![
437            ranged_diagnostic("deps-lsp", "unsatisfiable-requirement", first),
438            ranged_diagnostic("deps-lsp", "unsatisfiable-requirement", second),
439        ];
440
441        bind_diagnostics(&mut actions, &diagnostics);
442
443        assert_eq!(
444            actions[0]
445                .diagnostics
446                .as_ref()
447                .expect("expected the fallback full set")
448                .len(),
449            2
450        );
451    }
452
453    #[tokio::test]
454    async fn test_handle_code_actions_missing_document() {
455        let state = Arc::new(ServerState::new());
456        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
457
458        let params = CodeActionParams {
459            text_document: TextDocumentIdentifier { uri },
460            range: Range::new(Position::new(0, 0), Position::new(0, 0)),
461            context: Default::default(),
462            work_done_progress_params: Default::default(),
463            partial_result_params: Default::default(),
464        };
465
466        let (client, config) = create_test_client_and_config();
467        let result = handle_code_actions(state, params, client, config).await;
468        assert!(result.is_empty());
469    }
470
471    // Cargo-specific tests
472    #[cfg(feature = "cargo")]
473    mod cargo_tests {
474        use super::*;
475        use crate::document::DocumentState;
476
477        #[tokio::test]
478        async fn test_handle_code_actions() {
479            let state = Arc::new(ServerState::new());
480            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
481
482            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
483            let content = r#"[dependencies]
484serde = "1.0.0"
485"#
486            .to_string();
487
488            let parse_result = ecosystem
489                .parse_manifest(&content, &uri)
490                .await
491                .expect("Failed to parse manifest");
492
493            let doc_state =
494                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
495            state.update_document(uri.clone(), doc_state);
496
497            let params = CodeActionParams {
498                text_document: TextDocumentIdentifier { uri },
499                range: Range::new(Position::new(1, 9), Position::new(1, 16)),
500                context: Default::default(),
501                work_done_progress_params: Default::default(),
502                partial_result_params: Default::default(),
503            };
504
505            let (client, config) = create_test_client_and_config();
506            let _result = handle_code_actions(state, params, client, config).await;
507            // Test passes if no panic occurs
508        }
509
510        #[tokio::test]
511        async fn test_handle_code_actions_end_to_end_composition() {
512            // Drives `handle_code_actions` itself with vulnerability data, a
513            // `context.only` filter, and matching `context.diagnostics`
514            // together, confirming the wiring order (generate -> attach ->
515            // filter) end-to-end rather than only at the helper-unit level.
516            use deps_core::osv::{
517                Advisory, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus,
518                VulnSeverity, VulnerabilityMap,
519            };
520            use tower_lsp_server::ls_types::{CodeActionContext, Diagnostic};
521
522            let state = Arc::new(ServerState::new());
523            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
524
525            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
526            let content = r#"[dependencies]
527serde = "1.0.0"
528"#
529            .to_string();
530
531            let parse_result = ecosystem
532                .parse_manifest(&content, &uri)
533                .await
534                .expect("Failed to parse manifest");
535
536            let mut doc_state =
537                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
538
539            let mut vulnerabilities = VulnerabilityMap::new();
540            vulnerabilities.insert(
541                "serde".to_string(),
542                ScanOutcome::Vulnerable(DependencyVulnerabilities {
543                    advisories: Capped::new(
544                        vec![Arc::new(Advisory {
545                            id: "RUSTSEC-2020-0071".to_string(),
546                            modified: "2023-01-01T00:00:00Z".to_string(),
547                            summary: None,
548                            aliases: vec![],
549                            severity: VulnSeverity::High,
550                            cvss_vector: None,
551                            fixed_versions: vec!["1.0.5".to_string()],
552                            url: String::new(),
553                        })],
554                        1,
555                    ),
556                    fix_target_status: UpgradeStatus::CandidateClean {
557                        version: "1.0.5".to_string(),
558                    },
559                    upgrade_status: UpgradeStatus::NotChecked,
560                }),
561            );
562            doc_state.vulnerabilities = vulnerabilities;
563            state.update_document(uri.clone(), doc_state);
564
565            let params = CodeActionParams {
566                text_document: TextDocumentIdentifier { uri },
567                range: Range::new(Position::new(1, 9), Position::new(1, 16)),
568                context: CodeActionContext {
569                    diagnostics: vec![Diagnostic {
570                        source: Some("deps-lsp".to_string()),
571                        code: Some(NumberOrString::String("RUSTSEC-2020-0071".to_string())),
572                        ..Default::default()
573                    }],
574                    only: Some(vec![CodeActionKind::QUICKFIX]),
575                    ..Default::default()
576                },
577                work_done_progress_params: Default::default(),
578                partial_result_params: Default::default(),
579            };
580
581            let (client, config) = create_test_client_and_config();
582            let result = handle_code_actions(state, params, client, config).await;
583
584            assert_eq!(
585                result.len(),
586                1,
587                "context.only=[quickfix] must filter out the plain REFACTOR items: {result:?}"
588            );
589            let CodeActionOrCommand::CodeAction(action) = &result[0] else {
590                panic!("expected a CodeAction, got {:?}", result[0]);
591            };
592            assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
593            assert!(action.title.starts_with("Update to 1.0.5"));
594            assert!(
595                action.data.is_none(),
596                "data must be cleared once its ids are transferred into diagnostics"
597            );
598            let diagnostics = action
599                .diagnostics
600                .as_ref()
601                .expect("expected the matching client-supplied diagnostic to be bound");
602            assert_eq!(diagnostics.len(), 1);
603        }
604
605        #[tokio::test]
606        async fn test_handle_code_actions_no_parse_result() {
607            let state = Arc::new(ServerState::new());
608            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
609
610            let doc_state =
611                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
612            state.update_document(uri.clone(), doc_state);
613
614            let params = CodeActionParams {
615                text_document: TextDocumentIdentifier { uri },
616                range: Range::new(Position::new(0, 0), Position::new(0, 0)),
617                context: Default::default(),
618                work_done_progress_params: Default::default(),
619                partial_result_params: Default::default(),
620            };
621
622            let (client, config) = create_test_client_and_config();
623            let result = handle_code_actions(state, params, client, config).await;
624            assert!(result.is_empty());
625        }
626    }
627
628    // npm-specific tests
629    #[cfg(feature = "npm")]
630    mod npm_tests {
631        use super::*;
632        use crate::document::DocumentState;
633
634        #[tokio::test]
635        async fn test_handle_code_actions() {
636            let state = Arc::new(ServerState::new());
637            let uri = deps_core::test_util::test_uri("/test/package.json");
638
639            let ecosystem = state.ecosystem_registry.get("npm").unwrap();
640            let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string();
641
642            let parse_result = ecosystem
643                .parse_manifest(&content, &uri)
644                .await
645                .expect("Failed to parse manifest");
646
647            let doc_state =
648                DocumentState::new_from_parse_result(EcosystemId::Npm, content, parse_result);
649            state.update_document(uri.clone(), doc_state);
650
651            let params = CodeActionParams {
652                text_document: TextDocumentIdentifier { uri },
653                range: Range::new(Position::new(0, 25), Position::new(0, 32)),
654                context: Default::default(),
655                work_done_progress_params: Default::default(),
656                partial_result_params: Default::default(),
657            };
658
659            let (client, config) = create_test_client_and_config();
660            let _result = handle_code_actions(state, params, client, config).await;
661            // Test passes if no panic occurs
662        }
663    }
664
665    // Swift-specific tests
666    #[cfg(feature = "swift")]
667    mod swift_tests {
668        use super::*;
669        use crate::document::DocumentState;
670
671        #[tokio::test]
672        async fn test_handle_code_actions_exact_form_produces_vulnerability_fix() {
673            // Regression for #367, real end-to-end (`handle_code_actions` ->
674            // `SwiftEcosystem::generate_code_actions` -> the shared
675            // `deps_core::lsp_helpers::generate_code_actions`), not just the synthetic
676            // `CaLiteralDep` fixture `deps-core`'s own tests use. Reproduces the exact
677            // issue scenario: `.package(url: ..., .exact("4.50.0"))`. Uses OSV
678            // vulnerability data (registry-independent per FR-007) rather than a live
679            // registry fetch, so the assertion is deterministic and network-free —
680            // `context.only: [QUICKFIX]` additionally drops any REFACTOR items a live
681            // fetch might otherwise have produced.
682            use deps_core::osv::{
683                Advisory, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus,
684                VulnSeverity, VulnerabilityMap,
685            };
686            use tower_lsp_server::ls_types::{CodeActionContext, Diagnostic};
687
688            let state = Arc::new(ServerState::new());
689            let uri = deps_core::test_util::test_uri("/test/Package.swift");
690
691            let ecosystem = state.ecosystem_registry.get("swift").unwrap();
692            let content =
693                r#".package(url: "https://github.com/vapor/vapor", .exact("4.50.0"))"#.to_string();
694            let version_col = content.find("4.50.0").unwrap() as u32;
695
696            let parse_result = ecosystem
697                .parse_manifest(&content, &uri)
698                .await
699                .expect("Failed to parse manifest");
700
701            let mut doc_state =
702                DocumentState::new_from_parse_result(EcosystemId::Swift, content, parse_result);
703
704            // Keyed by `SwiftFormatter::normalize_package_name` (lowercased `owner/repo`),
705            // the lookup `build_vulnerability_fix_action` actually uses — not
706            // `osv_package_name`'s `github.com/{owner}/{repo}` (a distinct mapping, used
707            // only for the wire request OSV itself receives).
708            let mut vulnerabilities = VulnerabilityMap::new();
709            vulnerabilities.insert(
710                "vapor/vapor".to_string(),
711                ScanOutcome::Vulnerable(DependencyVulnerabilities {
712                    advisories: Capped::new(
713                        vec![Arc::new(Advisory {
714                            id: "GHSA-test-0001".to_string(),
715                            modified: "2023-01-01T00:00:00Z".to_string(),
716                            summary: None,
717                            aliases: vec![],
718                            severity: VulnSeverity::High,
719                            cvss_vector: None,
720                            fixed_versions: vec!["4.50.1".to_string()],
721                            url: String::new(),
722                        })],
723                        1,
724                    ),
725                    fix_target_status: UpgradeStatus::CandidateClean {
726                        version: "4.50.1".to_string(),
727                    },
728                    upgrade_status: UpgradeStatus::NotChecked,
729                }),
730            );
731            doc_state.vulnerabilities = vulnerabilities;
732            state.update_document(uri.clone(), doc_state);
733
734            let params = CodeActionParams {
735                text_document: TextDocumentIdentifier { uri },
736                range: Range::new(
737                    Position::new(0, version_col),
738                    Position::new(0, version_col + "4.50.0".len() as u32),
739                ),
740                context: CodeActionContext {
741                    diagnostics: vec![Diagnostic {
742                        source: Some("deps-lsp".to_string()),
743                        code: Some(NumberOrString::String("GHSA-test-0001".to_string())),
744                        ..Default::default()
745                    }],
746                    only: Some(vec![CodeActionKind::QUICKFIX]),
747                    ..Default::default()
748                },
749                work_done_progress_params: Default::default(),
750                partial_result_params: Default::default(),
751            };
752
753            let (client, config) = create_test_client_and_config();
754            let result = handle_code_actions(state, params, client, config).await;
755
756            assert_eq!(
757                result.len(),
758                1,
759                "the literal-span guard must accept the .exact(...) form and produce the \
760                 vulnerability-fix quickfix, the exact bug #367 reported: {result:?}"
761            );
762            let CodeActionOrCommand::CodeAction(action) = &result[0] else {
763                panic!("expected a CodeAction, got {:?}", result[0]);
764            };
765            assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
766            assert!(action.title.starts_with("Update to 4.50.1"));
767        }
768    }
769}