1use 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
14pub 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 = ¶ms.text_document.uri;
22 let position = params.range.start;
23
24 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 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, ¶ms.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
93fn 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
182fn ranges_overlap(a: &Range, b: &Range) -> bool {
184 a.start <= b.end && b.start <= a.end
185}
186
187fn 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
205fn 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 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 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 vuln_diagnostic(Some("other-source"), Some("RUSTSEC-1")),
331 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 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 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 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 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 #[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 }
509
510 #[tokio::test]
511 async fn test_handle_code_actions_end_to_end_composition() {
512 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 #[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 }
663 }
664
665 #[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 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 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}