1use crate::doc_actions::generate_doc_comment_action;
6use crate::module_cache::ModuleCache;
7use crate::util::{get_word_at_position, span_to_range};
8use shape_ast::ast::{ImportItems, Item};
9use shape_ast::parser::parse_program;
10use std::collections::{HashMap, HashSet};
11use tower_lsp_server::ls_types::{
12 CodeAction, CodeActionKind, CodeActionOrCommand, Diagnostic, NumberOrString, Position, Range,
13 TextEdit, Uri, WorkspaceEdit,
14};
15
16fn diagnostic_code(diagnostic: &Diagnostic) -> Option<&str> {
22 match &diagnostic.code {
23 Some(NumberOrString::String(s)) => Some(s.as_str()),
24 _ => None,
25 }
26}
27
28pub fn get_code_actions(
30 text: &str,
31 uri: &Uri,
32 range: Range,
33 diagnostics: &[Diagnostic],
34 module_cache: Option<&ModuleCache>,
35 requested_kinds: Option<&[CodeActionKind]>,
36) -> Vec<CodeActionOrCommand> {
37 let mut actions = Vec::new();
38
39 if is_kind_requested(requested_kinds, CodeActionKind::QUICKFIX.as_str()) {
40 for diagnostic in diagnostics {
42 if ranges_overlap(range, diagnostic.range) {
43 if let Some(fix_actions) = get_quick_fixes(text, uri, diagnostic, module_cache) {
44 actions.extend(fix_actions);
45 }
46 }
47 }
48
49 if let Some(cache) = module_cache {
52 actions.extend(get_symbol_auto_import_actions(text, uri, range, cache));
53 }
54 }
55
56 if is_group_requested(requested_kinds, CodeActionKind::REFACTOR.as_str()) {
58 if let Some(doc_action) = generate_doc_comment_action(text, uri, range) {
59 actions.push(doc_action);
60 }
61 if let Some(refactor_actions) = get_refactor_actions(text, uri, range) {
62 actions.extend(refactor_actions);
63 }
64 }
65
66 if is_group_requested(requested_kinds, CodeActionKind::SOURCE.as_str()) {
68 if let Some(source_actions) =
69 get_source_actions(text, uri, range, diagnostics, module_cache, requested_kinds)
70 {
71 actions.extend(source_actions);
72 }
73 }
74
75 dedupe_actions(actions)
76}
77
78fn get_quick_fixes(
89 text: &str,
90 uri: &Uri,
91 diagnostic: &Diagnostic,
92 module_cache: Option<&ModuleCache>,
93) -> Option<Vec<CodeActionOrCommand>> {
94 let mut fixes = Vec::new();
95 let message = &diagnostic.message;
96 let code = diagnostic_code(diagnostic);
97
98 let is_undefined = matches!(code, Some("E0101"))
102 || (code.is_none() && (message.contains("undefined") || message.contains("not defined")));
103 if is_undefined {
104 if let Some(var_name) = extract_undefined_name(message) {
105 fixes.push(create_quick_fix(
106 format!("Declare variable '{}'", var_name),
107 uri.clone(),
108 vec![TextEdit {
109 range: Range {
110 start: Position {
111 line: diagnostic.range.start.line,
112 character: 0,
113 },
114 end: Position {
115 line: diagnostic.range.start.line,
116 character: 0,
117 },
118 },
119 new_text: format!("let {} = undefined;\n", var_name),
120 }],
121 diagnostic.clone(),
122 ));
123 }
124 }
125
126 let is_missing_semi = matches!(code, Some("E0004"))
130 || message.contains("expected ';'")
131 || message.contains("missing semicolon");
132 if is_missing_semi {
133 fixes.push(create_quick_fix(
134 "Add missing semicolon".to_string(),
135 uri.clone(),
136 vec![TextEdit {
137 range: Range {
138 start: diagnostic.range.end,
139 end: diagnostic.range.end,
140 },
141 new_text: ";".to_string(),
142 }],
143 diagnostic.clone(),
144 ));
145 }
146
147 let is_unclosed = matches!(code, Some("E0005") | Some("E0002"))
152 || message.contains("expected '}'")
153 || message.contains("unclosed");
154 if is_unclosed {
155 fixes.push(create_quick_fix(
156 "Add missing closing brace".to_string(),
157 uri.clone(),
158 vec![TextEdit {
159 range: Range {
160 start: diagnostic.range.end,
161 end: diagnostic.range.end,
162 },
163 new_text: "\n}".to_string(),
164 }],
165 diagnostic.clone(),
166 ));
167 }
168
169 let is_prefer_let = matches!(code, Some("W0104"))
176 || message.contains("prefer 'let'")
177 || message.contains("use 'let' instead of 'var'");
178 if is_prefer_let {
179 let line = get_line(text, diagnostic.range.start.line as usize);
180 if let Some(line_text) = line {
181 if let Some(var_pos) = line_text.find("var ") {
182 fixes.push(create_quick_fix(
183 "Change 'var' to 'let'".to_string(),
184 uri.clone(),
185 vec![TextEdit {
186 range: Range {
187 start: Position {
188 line: diagnostic.range.start.line,
189 character: var_pos as u32,
190 },
191 end: Position {
192 line: diagnostic.range.start.line,
193 character: (var_pos + 3) as u32,
194 },
195 },
196 new_text: "let".to_string(),
197 }],
198 diagnostic.clone(),
199 ));
200 }
201 }
202 }
203
204 let is_unknown_enum = matches!(code, Some("E0102") | Some("E0105"))
210 || message.contains("Unknown enum type")
211 || message.contains("Unknown variant");
212 if is_unknown_enum {
213 if let Some(cache) = module_cache {
214 if let Some(name) = extract_quoted_name(message) {
215 let symbols = if let Some(current_file) = uri.to_file_path() {
216 cache.find_exported_symbol_with_context(&name, current_file.as_ref(), None)
217 } else {
218 cache.find_exported_symbol(&name)
219 };
220 for (import_path, _export) in symbols {
221 fixes.push(create_quick_fix(
222 format!("Import '{}' from {}", name, import_path),
223 uri.clone(),
224 vec![TextEdit {
225 range: Range {
226 start: Position {
227 line: 0,
228 character: 0,
229 },
230 end: Position {
231 line: 0,
232 character: 0,
233 },
234 },
235 new_text: format!("from {} use {{ {} }}\n", import_path, name),
236 }],
237 diagnostic.clone(),
238 ));
239 }
240 }
241 }
242 }
243
244 let is_empty_match = matches!(code, Some("E0103"))
250 || message.contains("match expression requires at least one arm");
251 if is_empty_match {
252 if let Some((insert_pos, indent)) = find_match_arm_insert_position(text, diagnostic.range) {
253 let arm_indent = format!("{indent} ");
254 fixes.push(create_quick_fix(
255 "Add wildcard match arm".to_string(),
256 uri.clone(),
257 vec![TextEdit {
258 range: Range {
259 start: insert_pos,
260 end: insert_pos,
261 },
262 new_text: format!("{arm_indent}_ => {{\n{arm_indent}}},\n"),
263 }],
264 diagnostic.clone(),
265 ));
266 }
267 }
268
269 let is_non_exhaustive = matches!(code, Some("E0103"))
277 || message.contains("Non-exhaustive match");
278 if is_non_exhaustive
279 && let Some((enum_name, missing_variants)) = parse_non_exhaustive_match(message)
280 {
281 if let Some((insert_pos, indent)) = find_match_arm_insert_position(text, diagnostic.range) {
282 let arm_indent = format!("{indent} ");
283 let mut new_text = String::new();
284 for variant in missing_variants {
285 new_text.push_str(&format!(
286 "{arm_indent}{enum_name}::{variant} => {{\n{arm_indent}}},\n"
287 ));
288 }
289 fixes.push(create_quick_fix(
290 format!("Add missing match arms for {}", enum_name),
291 uri.clone(),
292 vec![TextEdit {
293 range: Range {
294 start: insert_pos,
295 end: insert_pos,
296 },
297 new_text,
298 }],
299 diagnostic.clone(),
300 ));
301 }
302 }
303
304 let is_missing_method = matches!(code, Some("E0401"))
309 || message.contains("Missing required method");
310 if is_missing_method {
311 if let Some(method_name) = extract_quoted_name(message) {
312 let impl_end_line = diagnostic.range.end.line;
314 fixes.push(create_quick_fix(
316 format!("Implement method '{}'", method_name),
317 uri.clone(),
318 vec![TextEdit {
319 range: Range {
320 start: Position {
321 line: impl_end_line,
322 character: 0,
323 },
324 end: Position {
325 line: impl_end_line,
326 character: 0,
327 },
328 },
329 new_text: format!(
330 " method {}() {{\n // TODO: implement\n }}\n",
331 method_name
332 ),
333 }],
334 diagnostic.clone(),
335 ));
336 }
337 }
338
339 let is_unused =
343 matches!(code, Some("W0102") | Some("W0103")) || message.contains("unused");
344 if is_unused {
345 if let Some(var_name) = extract_unused_name(message) {
346 if !var_name.starts_with('_') {
347 let line = get_line(text, diagnostic.range.start.line as usize);
348 if let Some(line_text) = line {
349 if let Some(name_pos) = line_text.find(&var_name) {
350 fixes.push(create_quick_fix(
351 format!("Prefix with underscore: _{}", var_name),
352 uri.clone(),
353 vec![TextEdit {
354 range: Range {
355 start: Position {
356 line: diagnostic.range.start.line,
357 character: name_pos as u32,
358 },
359 end: Position {
360 line: diagnostic.range.start.line,
361 character: (name_pos + var_name.len()) as u32,
362 },
363 },
364 new_text: format!("_{}", var_name),
365 }],
366 diagnostic.clone(),
367 ));
368 }
369 }
370 }
371 }
372 }
373
374 if fixes.is_empty() { None } else { Some(fixes) }
375}
376
377fn get_refactor_actions(text: &str, uri: &Uri, range: Range) -> Option<Vec<CodeActionOrCommand>> {
379 let mut actions = Vec::new();
380
381 let selected = get_text_in_range(text, range);
383 if selected.is_empty() {
384 return None;
385 }
386
387 if is_expression(&selected) {
389 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
390 title: "Extract to variable".to_string(),
391 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
392 diagnostics: None,
393 edit: Some(WorkspaceEdit {
394 changes: Some({
395 let mut changes = HashMap::new();
396 changes.insert(
397 uri.clone(),
398 vec![
399 TextEdit {
400 range: Range {
401 start: Position {
402 line: range.start.line,
403 character: 0,
404 },
405 end: Position {
406 line: range.start.line,
407 character: 0,
408 },
409 },
410 new_text: format!("let extracted = {};\n", selected),
411 },
412 TextEdit {
413 range,
414 new_text: "extracted".to_string(),
415 },
416 ],
417 );
418 changes
419 }),
420 document_changes: None,
421 change_annotations: None,
422 }),
423 command: None,
424 is_preferred: None,
425 disabled: None,
426 data: None,
427 }));
428 }
429
430 if selected.contains('\n') || selected.len() > 50 {
432 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
433 title: "Extract to function".to_string(),
434 kind: Some(CodeActionKind::REFACTOR_EXTRACT),
435 diagnostics: None,
436 edit: Some(WorkspaceEdit {
437 changes: Some({
438 let mut changes = HashMap::new();
439 changes.insert(
440 uri.clone(),
441 vec![
442 TextEdit {
443 range: Range {
444 start: Position {
445 line: 0,
446 character: 0,
447 },
448 end: Position {
449 line: 0,
450 character: 0,
451 },
452 },
453 new_text: format!(
454 "fn extractedFunction() {{\n {}\n}}\n\n",
455 selected.replace('\n', "\n ")
456 ),
457 },
458 TextEdit {
459 range,
460 new_text: "extractedFunction()".to_string(),
461 },
462 ],
463 );
464 changes
465 }),
466 document_changes: None,
467 change_annotations: None,
468 }),
469 command: None,
470 is_preferred: None,
471 disabled: None,
472 data: None,
473 }));
474 }
475
476 if selected.contains(" + ") && selected.contains('"') {
478 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
480 title: "Convert to template string".to_string(),
481 kind: Some(CodeActionKind::REFACTOR_REWRITE),
482 diagnostics: None,
483 edit: None, command: None,
485 is_preferred: None,
486 disabled: Some(tower_lsp_server::ls_types::CodeActionDisabled {
487 reason: "Complex conversion - manual edit recommended".to_string(),
488 }),
489 data: None,
490 }));
491 }
492
493 if actions.is_empty() {
494 None
495 } else {
496 Some(actions)
497 }
498}
499
500fn get_source_actions(
502 text: &str,
503 uri: &Uri,
504 range: Range,
505 diagnostics: &[Diagnostic],
506 module_cache: Option<&ModuleCache>,
507 requested_kinds: Option<&[CodeActionKind]>,
508) -> Option<Vec<CodeActionOrCommand>> {
509 let mut actions = Vec::new();
510
511 let import_ranges = import_statement_ranges(text);
512 let on_import_stmt = import_ranges.iter().any(|r| ranges_overlap(*r, range));
513 let organize_requested = is_kind_explicitly_requested(
514 requested_kinds,
515 CodeActionKind::SOURCE_ORGANIZE_IMPORTS.as_str(),
516 );
517
518 if !import_ranges.is_empty() && (organize_requested || on_import_stmt) {
521 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
522 title: "Organize imports".to_string(),
523 kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS),
524 diagnostics: None,
525 edit: None, command: None,
527 is_preferred: None,
528 disabled: None,
529 data: None,
530 }));
531 }
532
533 let fix_all_requested =
536 is_kind_explicitly_requested(requested_kinds, CodeActionKind::SOURCE_FIX_ALL.as_str());
537 let has_fixable_here = diagnostics
538 .iter()
539 .filter(|d| ranges_overlap(d.range, range))
540 .any(|d| get_quick_fixes(text, uri, d, module_cache).is_some());
541 if fix_all_requested || has_fixable_here {
542 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
543 title: "Fix all auto-fixable problems".to_string(),
544 kind: Some(CodeActionKind::SOURCE_FIX_ALL),
545 diagnostics: None,
546 edit: None, command: None,
548 is_preferred: None,
549 disabled: None,
550 data: None,
551 }));
552 }
553
554 if actions.is_empty() {
555 None
556 } else {
557 Some(actions)
558 }
559}
560
561fn create_quick_fix(
563 title: String,
564 uri: Uri,
565 edits: Vec<TextEdit>,
566 diagnostic: Diagnostic,
567) -> CodeActionOrCommand {
568 let mut changes = HashMap::new();
569 changes.insert(uri, edits);
570
571 CodeActionOrCommand::CodeAction(CodeAction {
572 title,
573 kind: Some(CodeActionKind::QUICKFIX),
574 diagnostics: Some(vec![diagnostic]),
575 edit: Some(WorkspaceEdit {
576 changes: Some(changes),
577 document_changes: None,
578 change_annotations: None,
579 }),
580 command: None,
581 is_preferred: Some(true),
582 disabled: None,
583 data: None,
584 })
585}
586
587fn extract_quoted_name(message: &str) -> Option<String> {
589 let start = message.find('\'')?;
590 let end = message[start + 1..].find('\'')?;
591 Some(message[start + 1..start + 1 + end].to_string())
592}
593
594fn extract_undefined_name(message: &str) -> Option<String> {
596 if let Some(start) = message.find('\'') {
598 if let Some(end) = message[start + 1..].find('\'') {
599 return Some(message[start + 1..start + 1 + end].to_string());
600 }
601 }
602 None
603}
604
605fn extract_unused_name(message: &str) -> Option<String> {
607 if let Some(start) = message.find('\'') {
609 if let Some(end) = message[start + 1..].find('\'') {
610 return Some(message[start + 1..start + 1 + end].to_string());
611 }
612 }
613 None
614}
615
616fn parse_non_exhaustive_match(message: &str) -> Option<(String, Vec<String>)> {
617 const PREFIX: &str = "Non-exhaustive match on '";
618 const MARKER: &str = "': missing variants ";
619 let after_prefix = message.strip_prefix(PREFIX)?;
620 let marker_pos = after_prefix.find(MARKER)?;
621 let enum_name = after_prefix[..marker_pos].trim().to_string();
622 if enum_name.is_empty() {
623 return None;
624 }
625 let variants_part = &after_prefix[marker_pos + MARKER.len()..];
626 let variants = variants_part
627 .split(',')
628 .map(|v| v.trim())
629 .filter(|v| !v.is_empty())
630 .map(|v| v.to_string())
631 .collect::<Vec<_>>();
632 if variants.is_empty() {
633 None
634 } else {
635 Some((enum_name, variants))
636 }
637}
638
639fn find_match_arm_insert_position(text: &str, range: Range) -> Option<(Position, String)> {
640 let lines: Vec<&str> = text.lines().collect();
641 if lines.is_empty() {
642 return None;
643 }
644 let start_line = range.start.line as usize;
645 let mut line_index = start_line.min(lines.len().saturating_sub(1));
646 while line_index < lines.len() {
647 let line = lines[line_index];
648 let trimmed = line.trim_start();
649 if trimmed.starts_with('}') {
650 let indent_len = line.len().saturating_sub(trimmed.len());
651 let indent = " ".repeat(indent_len);
652 return Some((
653 Position {
654 line: line_index as u32,
655 character: 0,
656 },
657 indent,
658 ));
659 }
660 line_index += 1;
661 }
662 None
663}
664
665fn ranges_overlap(a: Range, b: Range) -> bool {
667 !(a.end.line < b.start.line
668 || (a.end.line == b.start.line && a.end.character < b.start.character)
669 || b.end.line < a.start.line
670 || (b.end.line == a.start.line && b.end.character < a.start.character))
671}
672
673fn get_line(text: &str, line: usize) -> Option<&str> {
675 text.lines().nth(line)
676}
677
678fn get_text_in_range(text: &str, range: Range) -> String {
680 let lines: Vec<&str> = text.lines().collect();
681
682 if range.start.line == range.end.line {
683 if let Some(line) = lines.get(range.start.line as usize) {
685 let start = range.start.character as usize;
686 let end = range.end.character as usize;
687 if start < line.len() && end <= line.len() {
688 return line[start..end].to_string();
689 }
690 }
691 } else {
692 let mut result = String::new();
694
695 for (i, line) in lines.iter().enumerate() {
696 let line_num = i as u32;
697
698 if line_num < range.start.line {
699 continue;
700 }
701 if line_num > range.end.line {
702 break;
703 }
704
705 if line_num == range.start.line {
706 let start = range.start.character as usize;
707 if start < line.len() {
708 result.push_str(&line[start..]);
709 }
710 } else if line_num == range.end.line {
711 let end = range.end.character as usize;
712 if end <= line.len() {
713 result.push_str(&line[..end]);
714 }
715 } else {
716 result.push_str(line);
717 }
718
719 if line_num != range.end.line {
720 result.push('\n');
721 }
722 }
723
724 return result;
725 }
726
727 String::new()
728}
729
730fn is_expression(text: &str) -> bool {
732 let trimmed = text.trim();
733
734 if trimmed.is_empty() {
736 return false;
737 }
738
739 if trimmed.starts_with("let ")
741 || trimmed.starts_with("var ")
742 || trimmed.starts_with("const ")
743 || trimmed.starts_with("fn ")
744 || trimmed.starts_with("function ")
745 || trimmed.starts_with("if ")
746 || trimmed.starts_with("for ")
747 || trimmed.starts_with("while ")
748 || trimmed.starts_with("return ")
749 {
750 return false;
751 }
752
753 let test_code = format!("let _test = {};", trimmed);
755 parse_program(&test_code).is_ok()
756}
757
758fn is_kind_requested(requested_kinds: Option<&[CodeActionKind]>, target: &str) -> bool {
761 match requested_kinds {
762 None => true,
763 Some(kinds) if kinds.is_empty() => true,
764 Some(kinds) => kinds.iter().any(|k| {
765 let requested = k.as_str();
766 requested == target || target.starts_with(&format!("{requested}."))
767 }),
768 }
769}
770
771fn is_group_requested(requested_kinds: Option<&[CodeActionKind]>, group: &str) -> bool {
773 match requested_kinds {
774 None => true,
775 Some(kinds) if kinds.is_empty() => true,
776 Some(kinds) => kinds.iter().any(|k| {
777 let requested = k.as_str();
778 requested == group || requested.starts_with(&format!("{group}."))
779 }),
780 }
781}
782
783fn is_kind_explicitly_requested(requested_kinds: Option<&[CodeActionKind]>, target: &str) -> bool {
786 let Some(kinds) = requested_kinds else {
787 return false;
788 };
789 if kinds.is_empty() {
790 return false;
791 }
792 kinds.iter().any(|k| {
793 let requested = k.as_str();
794 requested == target || target.starts_with(&format!("{requested}."))
795 })
796}
797
798fn dedupe_actions(actions: Vec<CodeActionOrCommand>) -> Vec<CodeActionOrCommand> {
800 let mut seen = HashSet::new();
801 let mut deduped = Vec::new();
802
803 for action in actions {
804 let key = match &action {
805 CodeActionOrCommand::CodeAction(ca) => format!(
806 "{}::{}",
807 ca.kind.as_ref().map(|k| k.as_str()).unwrap_or(""),
808 ca.title
809 ),
810 CodeActionOrCommand::Command(cmd) => format!("command::{}", cmd.title),
811 };
812
813 if seen.insert(key) {
814 deduped.push(action);
815 }
816 }
817
818 deduped
819}
820
821fn import_statement_ranges(text: &str) -> Vec<Range> {
823 if let Ok(program) = parse_program(text) {
824 let mut ranges = Vec::new();
825 for item in &program.items {
826 if let Item::Import(_, span) = item {
827 ranges.push(span_to_range(text, span));
828 }
829 }
830 return ranges;
831 }
832
833 text.lines()
835 .enumerate()
836 .filter_map(|(line, raw)| {
837 let trimmed = raw.trim_start();
838 if trimmed.starts_with("from ") || trimmed.starts_with("use ") {
839 Some(Range {
840 start: Position {
841 line: line as u32,
842 character: 0,
843 },
844 end: Position {
845 line: line as u32,
846 character: raw.len() as u32,
847 },
848 })
849 } else {
850 None
851 }
852 })
853 .collect()
854}
855
856fn collect_imported_local_names(text: &str) -> HashSet<String> {
858 let Ok(program) = parse_program(text) else {
859 return HashSet::new();
860 };
861
862 let mut imported = HashSet::new();
863 for item in &program.items {
864 let Item::Import(import_stmt, _) = item else {
865 continue;
866 };
867 match &import_stmt.items {
868 ImportItems::Named(specs) => {
869 for spec in specs {
870 imported.insert(spec.alias.clone().unwrap_or_else(|| spec.name.clone()));
871 }
872 }
873 ImportItems::Namespace { name, alias } => {
874 imported.insert(alias.clone().unwrap_or_else(|| name.clone()));
875 }
876 }
877 }
878
879 imported
880}
881
882fn import_insert_position(text: &str) -> Position {
883 let import_ranges = import_statement_ranges(text);
884 if let Some(last_line) = import_ranges.iter().map(|r| r.end.line).max() {
885 Position {
886 line: last_line + 1,
887 character: 0,
888 }
889 } else {
890 Position {
891 line: 0,
892 character: 0,
893 }
894 }
895}
896
897fn get_symbol_auto_import_actions(
898 text: &str,
899 uri: &Uri,
900 range: Range,
901 cache: &ModuleCache,
902) -> Vec<CodeActionOrCommand> {
903 let Some(symbol) = symbol_at_or_in_range(text, range) else {
904 return Vec::new();
905 };
906 if !is_import_candidate_symbol(&symbol) {
907 return Vec::new();
908 }
909
910 let imported_names = collect_imported_local_names(text);
911 if imported_names.contains(&symbol) {
912 return Vec::new();
913 }
914
915 let matches = if let Some(current_file) = uri.to_file_path() {
916 cache.find_exported_symbol_with_context(&symbol, current_file.as_ref(), None)
917 } else {
918 cache.find_exported_symbol(&symbol)
919 };
920 if matches.is_empty() {
921 return Vec::new();
922 }
923
924 let mut out = Vec::new();
925 let insert_at = import_insert_position(text);
926 for (import_path, _export) in matches {
927 out.push(CodeActionOrCommand::CodeAction(CodeAction {
928 title: format!("Import '{}' from {}", symbol, import_path),
929 kind: Some(CodeActionKind::QUICKFIX),
930 diagnostics: None,
931 edit: Some(WorkspaceEdit {
932 changes: Some({
933 let mut changes = HashMap::new();
934 changes.insert(
935 uri.clone(),
936 vec![TextEdit {
937 range: Range {
938 start: insert_at,
939 end: insert_at,
940 },
941 new_text: format!("from {} use {{ {} }}\n", import_path, symbol),
942 }],
943 );
944 changes
945 }),
946 document_changes: None,
947 change_annotations: None,
948 }),
949 command: None,
950 is_preferred: Some(true),
951 disabled: None,
952 data: None,
953 }));
954 }
955
956 out
957}
958
959fn symbol_at_or_in_range(text: &str, range: Range) -> Option<String> {
960 let selected = get_text_in_range(text, range);
961 let selected = selected.trim();
962 if !selected.is_empty() && is_identifier(selected) {
963 return Some(selected.to_string());
964 }
965 get_word_at_position(text, range.start)
966}
967
968fn is_identifier(name: &str) -> bool {
969 let mut chars = name.chars();
970 let Some(first) = chars.next() else {
971 return false;
972 };
973 if !(first.is_ascii_alphabetic() || first == '_') {
974 return false;
975 }
976 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
977}
978
979fn is_import_candidate_symbol(name: &str) -> bool {
980 is_identifier(name) && name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986
987 #[test]
988 fn test_extract_undefined_name() {
989 assert_eq!(
990 extract_undefined_name("undefined variable 'foo'"),
991 Some("foo".to_string())
992 );
993 assert_eq!(
994 extract_undefined_name("'bar' is not defined"),
995 Some("bar".to_string())
996 );
997 assert_eq!(extract_undefined_name("some other message"), None);
998 }
999
1000 #[test]
1001 fn test_ranges_overlap() {
1002 let r1 = Range {
1003 start: Position {
1004 line: 1,
1005 character: 0,
1006 },
1007 end: Position {
1008 line: 1,
1009 character: 10,
1010 },
1011 };
1012 let r2 = Range {
1013 start: Position {
1014 line: 1,
1015 character: 5,
1016 },
1017 end: Position {
1018 line: 1,
1019 character: 15,
1020 },
1021 };
1022 let r3 = Range {
1023 start: Position {
1024 line: 2,
1025 character: 0,
1026 },
1027 end: Position {
1028 line: 2,
1029 character: 10,
1030 },
1031 };
1032
1033 assert!(ranges_overlap(r1, r2));
1034 assert!(!ranges_overlap(r1, r3));
1035 }
1036
1037 #[test]
1038 fn test_get_text_in_range() {
1039 let text = "let x = 42;\nlet y = 10;";
1040
1041 let range = Range {
1042 start: Position {
1043 line: 0,
1044 character: 4,
1045 },
1046 end: Position {
1047 line: 0,
1048 character: 5,
1049 },
1050 };
1051 assert_eq!(get_text_in_range(text, range), "x");
1052
1053 let range = Range {
1054 start: Position {
1055 line: 0,
1056 character: 8,
1057 },
1058 end: Position {
1059 line: 0,
1060 character: 10,
1061 },
1062 };
1063 assert_eq!(get_text_in_range(text, range), "42");
1064 }
1065
1066 #[test]
1067 fn test_is_expression() {
1068 assert!(is_expression("42"));
1069 assert!(is_expression("x + y"));
1070 assert!(is_expression("foo()"));
1071 assert!(!is_expression("let x = 42"));
1072 assert!(!is_expression("function foo() {}"));
1073 }
1074
1075 #[test]
1076 fn test_extract_quoted_name_from_compiler_errors() {
1077 assert_eq!(
1079 extract_quoted_name(
1080 "Unknown enum type 'Snapshot'. Make sure it is imported or defined."
1081 ),
1082 Some("Snapshot".to_string())
1083 );
1084 assert_eq!(
1085 extract_quoted_name("Unknown variant 'BadVariant' for enum 'Color'"),
1086 Some("BadVariant".to_string())
1087 );
1088 assert_eq!(extract_quoted_name("no quotes here"), None);
1089 }
1090
1091 #[test]
1092 fn test_missing_trait_method_quick_fix() {
1093 let text = "trait Q {\n filter(p): any;\n select(c): any\n}\nimpl Q for T {\n method filter(p) { self }\n}\n";
1094 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1095 let diagnostic = Diagnostic {
1096 range: Range {
1097 start: Position {
1098 line: 4,
1099 character: 0,
1100 },
1101 end: Position {
1102 line: 6,
1103 character: 1,
1104 },
1105 },
1106 severity: Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR),
1107 code: Some(tower_lsp_server::ls_types::NumberOrString::String(
1108 "E0401".to_string(),
1109 )),
1110 message: "Missing required method 'select' in impl Q for T.".to_string(),
1111 ..Default::default()
1112 };
1113 let actions = get_code_actions(text, &uri, diagnostic.range, &[diagnostic], None, None);
1114 assert!(
1115 actions.iter().any(|a| {
1116 if let CodeActionOrCommand::CodeAction(action) = a {
1117 action.title.contains("Implement method 'select'")
1118 } else {
1119 false
1120 }
1121 }),
1122 "Should have quick fix to implement missing method. Got: {:?}",
1123 actions
1124 .iter()
1125 .map(|a| match a {
1126 CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1127 CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1128 })
1129 .collect::<Vec<_>>()
1130 );
1131 }
1132
1133 #[test]
1134 fn test_auto_import_generates_valid_syntax() {
1135 let name = "Snapshot";
1137 let import_path = "std::core::snapshot";
1138 let import_text = format!("from {} use {{ {} }}\n", import_path, name);
1139 assert_eq!(import_text, "from std::core::snapshot use { Snapshot }\n");
1140 let full_code = format!("{}let x = 1\n", import_text);
1142 assert!(
1143 shape_ast::parser::parse_program(&full_code).is_ok(),
1144 "Generated import should be valid Shape syntax: {}",
1145 full_code
1146 );
1147 }
1148
1149 #[test]
1150 fn test_source_actions_organize_imports_only_on_import_lines() {
1151 let text = "from std::core::math use { abs }\nlet x = abs(1)\n";
1152 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1153
1154 let import_range = Range {
1155 start: Position {
1156 line: 0,
1157 character: 5,
1158 },
1159 end: Position {
1160 line: 0,
1161 character: 5,
1162 },
1163 };
1164 let non_import_range = Range {
1165 start: Position {
1166 line: 1,
1167 character: 4,
1168 },
1169 end: Position {
1170 line: 1,
1171 character: 4,
1172 },
1173 };
1174
1175 let on_import = get_code_actions(text, &uri, import_range, &[], None, None);
1176 let away_from_import = get_code_actions(text, &uri, non_import_range, &[], None, None);
1177
1178 let has_organize = |actions: &[CodeActionOrCommand]| {
1179 actions.iter().any(|a| {
1180 matches!(
1181 a,
1182 CodeActionOrCommand::CodeAction(CodeAction {
1183 kind: Some(kind),
1184 ..
1185 }) if kind == &CodeActionKind::SOURCE_ORGANIZE_IMPORTS
1186 )
1187 })
1188 };
1189
1190 assert!(has_organize(&on_import));
1191 assert!(!has_organize(&away_from_import));
1192 }
1193
1194 #[test]
1195 fn test_symbol_auto_import_action_from_cursor() {
1196 let text = "match snapshot() {\n Snapshot::Resumed => { }\n}\n";
1197 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1198 let range = Range {
1199 start: Position {
1200 line: 1,
1201 character: 3,
1202 },
1203 end: Position {
1204 line: 1,
1205 character: 11,
1206 },
1207 };
1208
1209 let cache = ModuleCache::new();
1210 let actions = get_code_actions(text, &uri, range, &[], Some(&cache), None);
1211 assert!(
1212 actions.iter().any(|a| {
1213 matches!(
1214 a,
1215 CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1216 if title.contains("Import 'Snapshot' from std::core::snapshot")
1217 )
1218 }),
1219 "Expected auto-import action for Snapshot. Got: {:?}",
1220 actions
1221 .iter()
1222 .map(|a| match a {
1223 CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1224 CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1225 })
1226 .collect::<Vec<_>>()
1227 );
1228 }
1229
1230 #[test]
1231 fn test_empty_match_quick_fix_adds_wildcard_arm() {
1232 let text = "fn afunc(c) {\n match c {\n\n }\n}\n";
1233 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1234 let diagnostic = Diagnostic {
1235 range: Range {
1236 start: Position {
1237 line: 1,
1238 character: 2,
1239 },
1240 end: Position {
1241 line: 3,
1242 character: 3,
1243 },
1244 },
1245 severity: None,
1246 code: None,
1247 code_description: None,
1248 source: Some("shape".to_string()),
1249 message: "match expression requires at least one arm".to_string(),
1250 related_information: None,
1251 tags: None,
1252 data: None,
1253 };
1254 let range = Range {
1255 start: Position {
1256 line: 2,
1257 character: 2,
1258 },
1259 end: Position {
1260 line: 2,
1261 character: 2,
1262 },
1263 };
1264
1265 let actions = get_code_actions(text, &uri, range, &[diagnostic], None, None);
1266 assert!(
1267 actions.iter().any(|a| matches!(
1268 a,
1269 CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1270 if title == "Add wildcard match arm"
1271 )),
1272 "Expected wildcard match-arm quick fix. Got: {:?}",
1273 actions
1274 .iter()
1275 .map(|a| match a {
1276 CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1277 CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1278 })
1279 .collect::<Vec<_>>()
1280 );
1281 }
1282
1283 #[test]
1284 fn test_non_exhaustive_match_quick_fix_adds_missing_arms() {
1285 let text = "match snapshot() {\n Snapshot::Resumed => { }\n}\n";
1286 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1287 let diagnostic = Diagnostic {
1288 range: Range {
1289 start: Position {
1290 line: 0,
1291 character: 0,
1292 },
1293 end: Position {
1294 line: 2,
1295 character: 1,
1296 },
1297 },
1298 severity: None,
1299 code: None,
1300 code_description: None,
1301 source: Some("shape".to_string()),
1302 message: "Non-exhaustive match on 'Snapshot': missing variants Hash".to_string(),
1303 related_information: None,
1304 tags: None,
1305 data: None,
1306 };
1307 let range = Range {
1308 start: Position {
1309 line: 1,
1310 character: 5,
1311 },
1312 end: Position {
1313 line: 1,
1314 character: 5,
1315 },
1316 };
1317
1318 let actions = get_code_actions(text, &uri, range, &[diagnostic], None, None);
1319 assert!(
1320 actions.iter().any(|a| matches!(
1321 a,
1322 CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1323 if title == "Add missing match arms for Snapshot"
1324 )),
1325 "Expected missing-arms quick fix. Got: {:?}",
1326 actions
1327 .iter()
1328 .map(|a| match a {
1329 CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1330 CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1331 })
1332 .collect::<Vec<_>>()
1333 );
1334 }
1335
1336 #[test]
1338 fn test_extract_quoted_name_no_quote_returns_none() {
1339 assert_eq!(extract_quoted_name("no quotes here"), None);
1340 }
1341
1342 #[test]
1343 fn test_extract_unused_name() {
1344 assert_eq!(
1345 extract_unused_name("'baz' is unused"),
1346 Some("baz".to_string())
1347 );
1348 assert_eq!(extract_unused_name("nothing here"), None);
1349 }
1350
1351 #[test]
1352 fn test_parse_non_exhaustive_match_basic() {
1353 let parsed =
1354 parse_non_exhaustive_match("Non-exhaustive match on 'Color': missing variants Red, Green, Blue");
1355 assert!(parsed.is_some());
1356 let (name, variants) = parsed.unwrap();
1357 assert_eq!(name, "Color");
1358 assert_eq!(variants, vec!["Red", "Green", "Blue"]);
1359 }
1360
1361 #[test]
1362 fn test_parse_non_exhaustive_match_no_prefix() {
1363 let parsed = parse_non_exhaustive_match("Some other message");
1364 assert!(parsed.is_none());
1365 }
1366
1367 #[test]
1368 fn test_parse_non_exhaustive_match_no_variants() {
1369 let parsed =
1370 parse_non_exhaustive_match("Non-exhaustive match on 'Foo': missing variants ");
1371 assert!(parsed.is_none());
1372 }
1373
1374 #[test]
1375 fn test_find_match_arm_insert_position_simple() {
1376 let text = "fn f() {\n match x {\n A => 1,\n }\n}\n";
1377 let range = Range {
1378 start: Position {
1379 line: 2,
1380 character: 0,
1381 },
1382 end: Position {
1383 line: 2,
1384 character: 10,
1385 },
1386 };
1387 let result = find_match_arm_insert_position(text, range);
1388 assert!(result.is_some(), "expected to find a closing brace position");
1389 let (pos, indent) = result.unwrap();
1390 assert_eq!(pos.line, 3);
1392 assert_eq!(indent, " ");
1394 }
1395
1396 #[test]
1397 fn test_find_match_arm_insert_position_no_closing_brace() {
1398 let text = "abc\ndef\n";
1399 let range = Range {
1400 start: Position {
1401 line: 0,
1402 character: 0,
1403 },
1404 end: Position {
1405 line: 0,
1406 character: 3,
1407 },
1408 };
1409 let result = find_match_arm_insert_position(text, range);
1410 assert!(result.is_none(), "expected None when no closing brace exists");
1411 }
1412
1413 #[test]
1414 fn test_get_line_returns_line_content() {
1415 let text = "first\nsecond\nthird";
1416 assert_eq!(get_line(text, 0), Some("first"));
1417 assert_eq!(get_line(text, 1), Some("second"));
1418 assert_eq!(get_line(text, 2), Some("third"));
1419 assert_eq!(get_line(text, 3), None);
1420 }
1421
1422 #[test]
1423 fn test_get_text_in_range_multiline() {
1424 let text = "abc\ndef\nghi";
1425 let range = Range {
1426 start: Position {
1427 line: 0,
1428 character: 1,
1429 },
1430 end: Position {
1431 line: 2,
1432 character: 2,
1433 },
1434 };
1435 let result = get_text_in_range(text, range);
1436 assert!(
1437 result.contains("bc") && result.contains("def") && result.contains("gh"),
1438 "expected multi-line text in {result:?}"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_is_expression_handles_statements() {
1444 assert!(!is_expression("let x = 1"));
1446 assert!(!is_expression("var y = 2"));
1447 assert!(!is_expression("const z = 3"));
1448 assert!(!is_expression("if x { 1 }"));
1449 assert!(!is_expression("for i in 0..10 { }"));
1450 assert!(!is_expression("while x { }"));
1451 assert!(!is_expression("return x"));
1452 assert!(!is_expression("fn foo() { }"));
1453 assert!(!is_expression(""));
1454 assert!(!is_expression(" "));
1455 }
1456
1457 #[test]
1458 fn test_is_kind_requested_none_allows_all() {
1459 assert!(is_kind_requested(None, "quickfix"));
1460 assert!(is_kind_requested(None, "refactor.extract"));
1461 }
1462
1463 #[test]
1464 fn test_is_kind_requested_empty_allows_all() {
1465 let empty: &[CodeActionKind] = &[];
1466 assert!(is_kind_requested(Some(empty), "quickfix"));
1467 }
1468
1469 #[test]
1470 fn test_is_kind_requested_exact_match() {
1471 let kinds = &[CodeActionKind::QUICKFIX];
1472 assert!(is_kind_requested(Some(kinds), "quickfix"));
1473 assert!(!is_kind_requested(Some(kinds), "refactor"));
1474 }
1475
1476 #[test]
1477 fn test_is_group_requested_with_parent_kind() {
1478 let kinds = &[CodeActionKind::SOURCE];
1479 assert!(is_group_requested(Some(kinds), "source"));
1480 assert!(is_kind_requested(
1482 Some(kinds),
1483 "source.organizeImports"
1484 ));
1485 }
1486
1487 #[test]
1488 fn test_dedupe_actions_removes_same_title() {
1489 let action1 = CodeActionOrCommand::CodeAction(CodeAction {
1490 title: "dup".to_string(),
1491 kind: Some(CodeActionKind::QUICKFIX),
1492 ..Default::default()
1493 });
1494 let action2 = CodeActionOrCommand::CodeAction(CodeAction {
1495 title: "dup".to_string(),
1496 kind: Some(CodeActionKind::QUICKFIX),
1497 ..Default::default()
1498 });
1499 let action3 = CodeActionOrCommand::CodeAction(CodeAction {
1500 title: "unique".to_string(),
1501 kind: Some(CodeActionKind::QUICKFIX),
1502 ..Default::default()
1503 });
1504 let deduped = dedupe_actions(vec![action1, action2, action3]);
1505 assert_eq!(deduped.len(), 2, "expected dedupe to collapse same-title actions");
1506 }
1507
1508 #[test]
1509 fn test_dedupe_actions_preserves_different_kinds() {
1510 let action1 = CodeActionOrCommand::CodeAction(CodeAction {
1511 title: "do x".to_string(),
1512 kind: Some(CodeActionKind::QUICKFIX),
1513 ..Default::default()
1514 });
1515 let action2 = CodeActionOrCommand::CodeAction(CodeAction {
1516 title: "do x".to_string(),
1517 kind: Some(CodeActionKind::REFACTOR),
1518 ..Default::default()
1519 });
1520 let deduped = dedupe_actions(vec![action1, action2]);
1521 assert_eq!(deduped.len(), 2, "different kinds + same title preserved");
1522 }
1523
1524 #[test]
1525 fn test_is_identifier() {
1526 assert!(is_identifier("foo"));
1527 assert!(is_identifier("_bar"));
1528 assert!(is_identifier("baz123"));
1529 assert!(!is_identifier(""));
1530 assert!(!is_identifier("123abc"));
1531 assert!(!is_identifier("foo bar"));
1532 assert!(!is_identifier("foo.bar"));
1533 }
1534
1535 #[test]
1536 fn test_is_import_candidate_symbol_requires_uppercase() {
1537 assert!(is_import_candidate_symbol("Foo"));
1539 assert!(is_import_candidate_symbol("Snapshot"));
1540 assert!(!is_import_candidate_symbol("foo"));
1541 assert!(!is_import_candidate_symbol("foo_bar"));
1542 assert!(!is_import_candidate_symbol(""));
1543 assert!(!is_import_candidate_symbol("123Foo"));
1544 }
1545
1546 #[test]
1547 fn test_import_statement_ranges_finds_from_use() {
1548 let text = "from std::core::math use { abs }\nlet x = 1\n";
1549 let ranges = import_statement_ranges(text);
1550 assert_eq!(ranges.len(), 1, "expected one import range");
1551 assert_eq!(ranges[0].start.line, 0);
1552 }
1553
1554 #[test]
1555 fn test_import_statement_ranges_handles_parse_failure() {
1556 let text = "from std::core::math\n";
1558 let ranges = import_statement_ranges(text);
1559 assert!(
1561 !ranges.is_empty(),
1562 "expected to find import range via parse or fallback"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_collect_imported_local_names_named() {
1568 let text = "from std::core::math use { abs, max }\nlet x = abs(1)\n";
1569 let names = collect_imported_local_names(text);
1570 assert!(names.contains("abs"));
1571 assert!(names.contains("max"));
1572 }
1573
1574 #[test]
1575 fn test_collect_imported_local_names_empty() {
1576 let text = "let x = 1\n";
1577 let names = collect_imported_local_names(text);
1578 assert!(names.is_empty());
1579 }
1580
1581 #[test]
1582 fn test_import_insert_position_after_existing_imports() {
1583 let text = "from std::core::math use { abs }\nlet x = 1\n";
1584 let pos = import_insert_position(text);
1585 assert!(
1587 pos.line >= 1,
1588 "expected insert position after import line, got {}",
1589 pos.line
1590 );
1591 assert_eq!(pos.character, 0);
1592 }
1593
1594 #[test]
1595 fn test_import_insert_position_no_imports() {
1596 let text = "let x = 1\n";
1597 let pos = import_insert_position(text);
1598 assert_eq!(pos.line, 0);
1599 assert_eq!(pos.character, 0);
1600 }
1601
1602 #[test]
1603 fn test_diagnostic_code_returns_string_form() {
1604 let d = Diagnostic {
1605 code: Some(NumberOrString::String("E0001".to_string())),
1606 ..Default::default()
1607 };
1608 assert_eq!(diagnostic_code(&d), Some("E0001"));
1609
1610 let d2 = Diagnostic {
1611 code: Some(NumberOrString::Number(42)),
1612 ..Default::default()
1613 };
1614 assert_eq!(diagnostic_code(&d2), None);
1616
1617 let d3 = Diagnostic {
1618 code: None,
1619 ..Default::default()
1620 };
1621 assert_eq!(diagnostic_code(&d3), None);
1622 }
1623
1624 #[test]
1630 fn test_quickfix_keys_off_e0101_code_without_message_substring() {
1631 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1632 let diagnostic = Diagnostic {
1633 range: Range {
1634 start: Position {
1635 line: 0,
1636 character: 8,
1637 },
1638 end: Position {
1639 line: 0,
1640 character: 21,
1641 },
1642 },
1643 severity: Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR),
1644 code: Some(NumberOrString::String("E0101".to_string())),
1646 message: "Identifier 'zzz_xyz' is not in scope".to_string(),
1647 ..Default::default()
1648 };
1649 let text = "let x = zzz_xyz + 1\n";
1650 let actions = get_code_actions(text, &uri, diagnostic.range, &[diagnostic], None, None);
1651 assert!(
1652 actions.iter().any(|a| matches!(
1653 a,
1654 CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1655 if title.contains("Declare variable 'zzz_xyz'")
1656 )),
1657 "expected declare-variable quickfix from E0101 code-keyed dispatch; got: {:?}",
1658 actions
1659 .iter()
1660 .map(|a| match a {
1661 CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1662 CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1663 })
1664 .collect::<Vec<_>>()
1665 );
1666 }
1667
1668 #[test]
1672 fn test_quickfix_keys_off_e0401_code_without_message_substring() {
1673 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1674 let diagnostic = Diagnostic {
1675 range: Range {
1676 start: Position {
1677 line: 0,
1678 character: 0,
1679 },
1680 end: Position {
1681 line: 1,
1682 character: 1,
1683 },
1684 },
1685 severity: Some(tower_lsp_server::ls_types::DiagnosticSeverity::ERROR),
1686 code: Some(NumberOrString::String("E0401".to_string())),
1687 message: "impl block omits trait method 'select'".to_string(),
1689 ..Default::default()
1690 };
1691 let text = "impl Q for T {\n}\n";
1692 let actions = get_code_actions(text, &uri, diagnostic.range, &[diagnostic], None, None);
1693 assert!(
1694 actions.iter().any(|a| matches!(
1695 a,
1696 CodeActionOrCommand::CodeAction(CodeAction { title, .. })
1697 if title.contains("Implement method 'select'")
1698 )),
1699 "expected implement-method quickfix from E0401 code-keyed dispatch; got: {:?}",
1700 actions
1701 .iter()
1702 .map(|a| match a {
1703 CodeActionOrCommand::CodeAction(action) => action.title.clone(),
1704 CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
1705 })
1706 .collect::<Vec<_>>()
1707 );
1708 }
1709
1710 #[test]
1711 fn test_get_code_actions_filters_to_requested_kind() {
1712 let text = "let x = 1";
1713 let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
1714 let actions = get_code_actions(
1716 text,
1717 &uri,
1718 Range {
1719 start: Position {
1720 line: 0,
1721 character: 0,
1722 },
1723 end: Position {
1724 line: 0,
1725 character: 5,
1726 },
1727 },
1728 &[],
1729 None,
1730 Some(&[CodeActionKind::REFACTOR]),
1731 );
1732 for action in &actions {
1733 if let CodeActionOrCommand::CodeAction(ca) = action {
1734 if let Some(kind) = &ca.kind {
1735 assert!(
1736 kind.as_str().starts_with("refactor"),
1737 "expected refactor-only action, got {}",
1738 kind.as_str()
1739 );
1740 }
1741 }
1742 }
1743 }
1744}