|
| 1 | +//! Replace FQCN with import code action. |
| 2 | +//! |
| 3 | +//! When the cursor is on a fully-qualified class name (leading `\`), offer |
| 4 | +//! a code action that inserts a `use` statement and replaces the FQCN at |
| 5 | +//! the call site with the short name. |
| 6 | +
|
| 7 | +use std::collections::HashMap; |
| 8 | + |
| 9 | +use tower_lsp::lsp_types::*; |
| 10 | + |
| 11 | +use crate::Backend; |
| 12 | +use crate::completion::use_edit::{analyze_use_block, build_use_edit, use_import_conflicts}; |
| 13 | +use crate::symbol_map::SymbolKind; |
| 14 | +use crate::util::{offset_to_position, position_to_byte_offset, short_name}; |
| 15 | + |
| 16 | +impl Backend { |
| 17 | + /// Collect "Replace FQCN with import" code actions when the cursor |
| 18 | + /// is on a fully-qualified class name. |
| 19 | + pub(crate) fn collect_replace_fqcn_actions( |
| 20 | + &self, |
| 21 | + uri: &str, |
| 22 | + content: &str, |
| 23 | + params: &CodeActionParams, |
| 24 | + out: &mut Vec<CodeActionOrCommand>, |
| 25 | + ) { |
| 26 | + let file_use_map: HashMap<String, String> = self.file_use_map(uri); |
| 27 | + let file_namespace: Option<String> = self.first_file_namespace(uri); |
| 28 | + |
| 29 | + let symbol_map = match self.symbol_maps.read().get(uri) { |
| 30 | + Some(sm) => sm.clone(), |
| 31 | + None => return, |
| 32 | + }; |
| 33 | + |
| 34 | + let request_start = position_to_byte_offset(content, params.range.start); |
| 35 | + let request_end = position_to_byte_offset(content, params.range.end); |
| 36 | + |
| 37 | + for span in &symbol_map.spans { |
| 38 | + if span.start as usize >= request_end || span.end as usize <= request_start { |
| 39 | + continue; |
| 40 | + } |
| 41 | + |
| 42 | + let (ref_name, is_fqn) = match &span.kind { |
| 43 | + SymbolKind::ClassReference { name, is_fqn, .. } => (name.as_str(), *is_fqn), |
| 44 | + _ => continue, |
| 45 | + }; |
| 46 | + |
| 47 | + // This action only applies to FQNs (leading `\` in source). |
| 48 | + if !is_fqn { |
| 49 | + continue; |
| 50 | + } |
| 51 | + |
| 52 | + // The stored name has no leading `\`, so it's already the FQN. |
| 53 | + let fqn = ref_name; |
| 54 | + let sn = short_name(fqn); |
| 55 | + |
| 56 | + // If the short name is already imported with the same FQN, |
| 57 | + // just offer to replace the inline FQN with the short name |
| 58 | + // (no new use statement needed). |
| 59 | + let already_imported = file_use_map.iter().any(|(alias, existing_fqn)| { |
| 60 | + alias.eq_ignore_ascii_case(sn) && existing_fqn.eq_ignore_ascii_case(fqn) |
| 61 | + }); |
| 62 | + |
| 63 | + // If there's a conflict (different class with same short name |
| 64 | + // already imported), skip. |
| 65 | + if !already_imported && use_import_conflicts(fqn, &file_use_map) { |
| 66 | + continue; |
| 67 | + } |
| 68 | + |
| 69 | + let doc_uri: Url = match uri.parse() { |
| 70 | + Ok(u) => u, |
| 71 | + Err(_) => continue, |
| 72 | + }; |
| 73 | + |
| 74 | + let mut edits: Vec<TextEdit> = Vec::new(); |
| 75 | + |
| 76 | + // Add use statement if not already imported. |
| 77 | + if !already_imported { |
| 78 | + let use_block = analyze_use_block(content); |
| 79 | + if let Some(use_edits) = build_use_edit(fqn, &use_block, &file_namespace) { |
| 80 | + edits.extend(use_edits); |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + // Replace the FQN at the call site with the short name. |
| 85 | + // The span covers the name without the leading `\`, but in |
| 86 | + // source the leading `\` is present, so we need to include it. |
| 87 | + let replace_start = if span.start > 0 { |
| 88 | + // Check if the character before the span is `\`. |
| 89 | + let before = &content[..span.start as usize]; |
| 90 | + if before.ends_with('\\') { |
| 91 | + span.start as usize - 1 |
| 92 | + } else { |
| 93 | + span.start as usize |
| 94 | + } |
| 95 | + } else { |
| 96 | + span.start as usize |
| 97 | + }; |
| 98 | + let replace_end = span.end as usize; |
| 99 | + |
| 100 | + let start_pos = offset_to_position(content, replace_start); |
| 101 | + let end_pos = offset_to_position(content, replace_end); |
| 102 | + |
| 103 | + edits.push(TextEdit { |
| 104 | + range: Range { |
| 105 | + start: start_pos, |
| 106 | + end: end_pos, |
| 107 | + }, |
| 108 | + new_text: sn.to_string(), |
| 109 | + }); |
| 110 | + |
| 111 | + let title = if already_imported { |
| 112 | + format!("Replace `\\{}` with short name `{}`", fqn, sn) |
| 113 | + } else { |
| 114 | + format!("Replace FQCN `\\{}` with import", fqn) |
| 115 | + }; |
| 116 | + |
| 117 | + let mut changes = HashMap::new(); |
| 118 | + changes.insert(doc_uri, edits); |
| 119 | + |
| 120 | + out.push(CodeActionOrCommand::CodeAction(CodeAction { |
| 121 | + title, |
| 122 | + kind: Some(CodeActionKind::REFACTOR), |
| 123 | + diagnostics: None, |
| 124 | + edit: Some(WorkspaceEdit { |
| 125 | + changes: Some(changes), |
| 126 | + document_changes: None, |
| 127 | + change_annotations: None, |
| 128 | + }), |
| 129 | + command: None, |
| 130 | + is_preferred: Some(false), |
| 131 | + disabled: None, |
| 132 | + data: None, |
| 133 | + })); |
| 134 | + |
| 135 | + break; |
| 136 | + } |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +#[cfg(test)] |
| 141 | +mod tests { |
| 142 | + use tower_lsp::lsp_types::*; |
| 143 | + |
| 144 | + fn code_action_titles(content: &str, cursor_offset: usize) -> Vec<String> { |
| 145 | + let backend = crate::Backend::new_test(); |
| 146 | + let uri = "file:///test.php"; |
| 147 | + backend.update_ast(uri, content); |
| 148 | + |
| 149 | + let pos = crate::util::offset_to_position(content, cursor_offset); |
| 150 | + let params = CodeActionParams { |
| 151 | + text_document: TextDocumentIdentifier { |
| 152 | + uri: uri.parse().unwrap(), |
| 153 | + }, |
| 154 | + range: Range { |
| 155 | + start: pos, |
| 156 | + end: pos, |
| 157 | + }, |
| 158 | + context: CodeActionContext { |
| 159 | + diagnostics: vec![], |
| 160 | + only: None, |
| 161 | + trigger_kind: None, |
| 162 | + }, |
| 163 | + work_done_progress_params: Default::default(), |
| 164 | + partial_result_params: Default::default(), |
| 165 | + }; |
| 166 | + let mut actions = Vec::new(); |
| 167 | + backend.collect_replace_fqcn_actions(uri, content, ¶ms, &mut actions); |
| 168 | + actions |
| 169 | + .into_iter() |
| 170 | + .map(|a| match a { |
| 171 | + CodeActionOrCommand::CodeAction(ca) => ca.title, |
| 172 | + _ => String::new(), |
| 173 | + }) |
| 174 | + .collect() |
| 175 | + } |
| 176 | + |
| 177 | + #[test] |
| 178 | + fn offers_action_on_fqcn() { |
| 179 | + let src = "<?php\nnamespace App;\n\n\\Illuminate\\Support\\Str::plural('test');\n"; |
| 180 | + // Cursor somewhere on the FQN (after the `\`) |
| 181 | + let offset = src.find("Illuminate\\Support\\Str").unwrap(); |
| 182 | + let titles = code_action_titles(src, offset); |
| 183 | + assert_eq!(titles.len(), 1); |
| 184 | + assert!(titles[0].contains("Replace FQCN")); |
| 185 | + assert!(titles[0].contains("Illuminate\\Support\\Str")); |
| 186 | + } |
| 187 | + |
| 188 | + #[test] |
| 189 | + fn no_action_on_short_name() { |
| 190 | + let src = |
| 191 | + "<?php\nnamespace App;\n\nuse Illuminate\\Support\\Str;\n\nStr::plural('test');\n"; |
| 192 | + let offset = src.find("Str::").unwrap(); |
| 193 | + let titles = code_action_titles(src, offset); |
| 194 | + assert!(titles.is_empty()); |
| 195 | + } |
| 196 | + |
| 197 | + #[test] |
| 198 | + fn reuses_existing_import() { |
| 199 | + let src = "<?php\nnamespace App;\n\nuse Illuminate\\Support\\Str;\n\n\\Illuminate\\Support\\Str::plural('test');\n"; |
| 200 | + let offset = src.find("\\Illuminate\\Support\\Str::").unwrap() + 1; |
| 201 | + let titles = code_action_titles(src, offset); |
| 202 | + assert_eq!(titles.len(), 1); |
| 203 | + assert!(titles[0].contains("short name")); |
| 204 | + } |
| 205 | + |
| 206 | + #[test] |
| 207 | + fn skips_conflicting_import() { |
| 208 | + let src = "<?php\nnamespace App;\n\nuse Other\\Str;\n\n\\Illuminate\\Support\\Str::plural('test');\n"; |
| 209 | + let offset = src.find("\\Illuminate\\Support\\Str::").unwrap() + 1; |
| 210 | + let titles = code_action_titles(src, offset); |
| 211 | + assert!(titles.is_empty()); |
| 212 | + } |
| 213 | +} |
0 commit comments