1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
5
6use crate::ast::ParsedDoc;
7use crate::references::find_references_with_use;
8use crate::walk::{collect_var_refs_in_scope, property_refs_in_stmts};
9
10pub fn rename(word: &str, new_name: &str, all_docs: &[(Url, Arc<ParsedDoc>)]) -> WorkspaceEdit {
13 let locations = find_references_with_use(word, all_docs, true);
14
15 let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
16 for loc in locations {
17 changes.entry(loc.uri).or_default().push(TextEdit {
18 range: loc.range,
19 new_text: new_name.to_string(),
20 });
21 }
22
23 WorkspaceEdit {
24 changes: Some(changes),
25 ..Default::default()
26 }
27}
28
29pub fn prepare_rename(source: &str, position: Position) -> Option<Range> {
32 use crate::util::word_at;
33 let word = word_at(source, position)?;
34 if word.contains('\\') {
35 return None;
36 }
37 if is_php_keyword(&word) {
39 return None;
40 }
41 let line = source.lines().nth(position.line as usize)?;
42 let col = position.character as usize;
43 let chars: Vec<char> = line.chars().collect();
44 let is_word = |c: char| c.is_alphanumeric() || c == '_';
48 let mut utf16_col = 0usize;
49 let mut char_idx = 0usize;
50 for ch in &chars {
51 if utf16_col >= col {
52 break;
53 }
54 utf16_col += ch.len_utf16();
55 char_idx += 1;
56 }
57 let mut left = char_idx;
58 while left > 0 && is_word(chars[left - 1]) {
59 left -= 1;
60 }
61
62 let bare_word = word.trim_start_matches('$');
63 let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
64 let end_utf16: u32 = start_utf16 + bare_word.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
65 Some(Range {
66 start: Position {
67 line: position.line,
68 character: start_utf16,
69 },
70 end: Position {
71 line: position.line,
72 character: end_utf16,
73 },
74 })
75}
76
77fn is_php_keyword(word: &str) -> bool {
78 matches!(
79 word,
80 "abstract"
81 | "and"
82 | "array"
83 | "as"
84 | "break"
85 | "callable"
86 | "case"
87 | "catch"
88 | "class"
89 | "clone"
90 | "const"
91 | "continue"
92 | "declare"
93 | "default"
94 | "die"
95 | "do"
96 | "echo"
97 | "else"
98 | "elseif"
99 | "empty"
100 | "enddeclare"
101 | "endfor"
102 | "endforeach"
103 | "endif"
104 | "endswitch"
105 | "endwhile"
106 | "enum"
107 | "eval"
108 | "exit"
109 | "extends"
110 | "final"
111 | "finally"
112 | "fn"
113 | "for"
114 | "foreach"
115 | "function"
116 | "global"
117 | "goto"
118 | "if"
119 | "implements"
120 | "include"
121 | "include_once"
122 | "instanceof"
123 | "insteadof"
124 | "interface"
125 | "isset"
126 | "list"
127 | "match"
128 | "namespace"
129 | "new"
130 | "null"
131 | "or"
132 | "print"
133 | "private"
134 | "protected"
135 | "public"
136 | "readonly"
137 | "require"
138 | "require_once"
139 | "return"
140 | "self"
141 | "static"
142 | "switch"
143 | "throw"
144 | "trait"
145 | "true"
146 | "false"
147 | "try"
148 | "use"
149 | "var"
150 | "while"
151 | "xor"
152 | "yield"
153 )
154}
155
156pub fn rename_variable(
159 var_name: &str,
160 new_name: &str,
161 uri: &Url,
162 doc: &ParsedDoc,
163 position: Position,
164) -> WorkspaceEdit {
165 let bare = var_name.trim_start_matches('$');
166 let new_bare = new_name.trim_start_matches('$');
167 let new_text = format!("${new_bare}");
168
169 let stmts = &doc.program().stmts;
170 let sv = doc.view();
171 let byte_off = sv.byte_of_position(position) as usize;
172
173 let mut spans = Vec::new();
174 collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
175
176 let mut seen = std::collections::HashSet::new();
177 let mut edits: Vec<TextEdit> = spans
178 .into_iter()
179 .filter_map(|span| {
180 let start = sv.position_of(span.start);
181 let end = sv.position_of(span.end);
182 seen.insert((start.line, start.character))
183 .then_some(TextEdit {
184 range: Range { start, end },
185 new_text: new_text.clone(),
186 })
187 })
188 .collect();
189 edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
190
191 let mut changes = HashMap::new();
192 if !edits.is_empty() {
193 changes.insert(uri.clone(), edits);
194 }
195
196 WorkspaceEdit {
197 changes: if changes.is_empty() {
198 None
199 } else {
200 Some(changes)
201 },
202 ..Default::default()
203 }
204}
205
206pub fn rename_property(
210 prop_name: &str,
211 new_name: &str,
212 all_docs: &[(Url, Arc<ParsedDoc>)],
213) -> WorkspaceEdit {
214 let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
215 for (uri, doc) in all_docs {
216 let sv = doc.view();
217 let mut spans = Vec::new();
218 property_refs_in_stmts(sv.source(), &doc.program().stmts, prop_name, &mut spans);
219 if !spans.is_empty() {
220 let mut seen = std::collections::HashSet::new();
221 let mut edits: Vec<TextEdit> = spans
222 .into_iter()
223 .filter_map(|span| {
224 let start = sv.position_of(span.start);
225 let end = sv.position_of(span.end);
226 seen.insert((start.line, start.character))
227 .then_some(TextEdit {
228 range: Range { start, end },
229 new_text: new_name.to_string(),
230 })
231 })
232 .collect();
233 edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
234 changes.insert(uri.clone(), edits);
235 }
236 }
237 WorkspaceEdit {
238 changes: if changes.is_empty() {
239 None
240 } else {
241 Some(changes)
242 },
243 ..Default::default()
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn uri(path: &str) -> Url {
252 Url::parse(&format!("file://{path}")).unwrap()
253 }
254
255 fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
256 (uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
257 }
258
259 fn pos(line: u32, character: u32) -> Position {
260 Position { line, character }
261 }
262
263 #[test]
264 fn rename_replaces_all_occurrences_in_single_file() {
265 let src = "<?php\nfunction greet() {}\ngreet();\ngreet();";
266 let docs = vec![doc("/a.php", src)];
267 let edit = rename("greet", "hello", &docs);
268 let changes = edit.changes.unwrap();
269 let edits = &changes[&uri("/a.php")];
270 assert!(
271 edits.len() >= 2,
272 "expected at least 2 edits, got {}",
273 edits.len()
274 );
275 assert!(edits.iter().all(|e| e.new_text == "hello"));
276 }
277
278 #[test]
279 fn rename_includes_declaration_site() {
280 let src = "<?php\nfunction greet() {}\ngreet();";
281 let docs = vec![doc("/a.php", src)];
282 let edit = rename("greet", "hello", &docs);
283 let changes = edit.changes.unwrap();
284 let edits = &changes[&uri("/a.php")];
285 assert!(edits.len() >= 2, "should include declaration");
286 }
287
288 #[test]
289 fn rename_across_files() {
290 let a = doc("/a.php", "<?php\nfunction helper() {}");
291 let b = doc("/b.php", "<?php\nhelper();");
292 let docs = vec![a, b];
293 let edit = rename("helper", "util", &docs);
294 let changes = edit.changes.unwrap();
295 assert!(
296 changes.contains_key(&uri("/a.php")),
297 "should rename declaration in a.php"
298 );
299 assert!(
300 changes.contains_key(&uri("/b.php")),
301 "should rename usage in b.php"
302 );
303 }
304
305 #[test]
306 fn prepare_rename_returns_word_range() {
307 let src = "<?php\nfunction greet() {}";
308 let result = prepare_rename(src, pos(1, 10));
309 assert!(result.is_some(), "expected range for 'greet'");
310 let range = result.unwrap();
311 assert_eq!(range.start.line, 1);
312 }
313
314 #[test]
315 fn prepare_rename_rejects_fqn() {
316 let src = "<?php\nFoo\\Bar::baz();";
317 let result = prepare_rename(src, pos(1, 5));
318 assert!(
319 result.is_none(),
320 "should not allow renaming FQNs with backslash"
321 );
322 }
323
324 #[test]
325 fn rename_does_not_match_partial_words() {
326 let src = "<?php\nfunction foo() {}\nfunction foobar() {}\nfunction barfoo() {}\nfoo();\nfoobar();\nbarfoo();";
328 let docs = vec![doc("/a.php", src)];
329 let edit = rename("foo", "baz", &docs);
330 let changes = edit.changes.unwrap();
331 let edits = &changes[&uri("/a.php")];
332 for e in edits {
334 assert_eq!(e.new_text, "baz", "all edits should replace with 'baz'");
335 let span_len = e.range.end.character - e.range.start.character;
336 assert_eq!(
337 span_len, 3,
338 "renamed span should be length 3 (the word 'foo'), got {} at {:?}",
339 span_len, e.range
340 );
341 }
342 let renamed_lines: Vec<u32> = edits.iter().map(|e| e.range.start.line).collect();
347 assert!(
348 !renamed_lines.contains(&5),
349 "foobar() call (line 5) should not be renamed"
350 );
351 assert!(
352 !renamed_lines.contains(&6),
353 "barfoo() call (line 6) should not be renamed"
354 );
355 }
356
357 #[test]
358 fn rename_updates_use_statement() {
359 let a = doc("/a.php", "<?php\nclass Foo {}");
362 let b = doc("/b.php", "<?php\nuse Foo;\n$x = new Foo();");
363 let docs = vec![a, b];
364 let edit = rename("Foo", "Bar", &docs);
365 let changes = edit.changes.unwrap();
366
367 assert!(
369 changes.contains_key(&uri("/a.php")),
370 "should rename class declaration in a.php"
371 );
372
373 let b_edits = &changes[&uri("/b.php")];
375 assert!(
376 b_edits.len() >= 2,
377 "expected at least 2 edits in b.php (use + new), got: {:?}",
378 b_edits
379 );
380 assert!(
381 b_edits.iter().all(|e| e.new_text == "Bar"),
382 "all edits in b.php should rename to 'Bar'"
383 );
384 let has_use_edit = b_edits.iter().any(|e| e.range.start.line == 1);
386 assert!(
387 has_use_edit,
388 "expected an edit on the use statement line (line 1) in b.php"
389 );
390 }
391
392 #[test]
393 fn rename_variable_within_function() {
394 let src = "<?php\nfunction foo() {\n $x = 1;\n echo $x;\n}";
395 let doc = Arc::new(ParsedDoc::parse(src.to_string()));
396 let edit = rename_variable("$x", "$y", &uri("/a.php"), &doc, pos(2, 5));
397 let changes = edit.changes.unwrap();
398 let edits = &changes[&uri("/a.php")];
399 assert!(edits.len() >= 2, "should rename both assignment and usage");
400 assert!(edits.iter().all(|e| e.new_text == "$y"));
401 }
402
403 #[test]
404 fn rename_variable_does_not_cross_function_boundary() {
405 let src = "<?php\nfunction foo() { $x = 1; }\nfunction bar() { $x = 2; }";
406 let doc = Arc::new(ParsedDoc::parse(src.to_string()));
407 let edit = rename_variable("$x", "$z", &uri("/a.php"), &doc, pos(1, 20));
408 let changes = edit.changes.unwrap();
409 let edits = &changes[&uri("/a.php")];
410 assert_eq!(edits.len(), 1, "should only rename within foo()");
412 }
413
414 #[test]
415 fn prepare_rename_allows_variables() {
416 let src = "<?php\n$foo = 1;";
417 let result = prepare_rename(src, pos(1, 1));
418 assert!(result.is_some(), "should allow renaming variables now");
419 }
420
421 #[test]
422 fn rename_property_renames_declaration_and_accesses() {
423 let src = "<?php\nclass Foo {\n public string $name;\n public function get() { return $this->name; }\n}";
424 let docs = vec![doc("/a.php", src)];
425 let edit = rename_property("name", "title", &docs);
426 let changes = edit.changes.unwrap();
427 let edits = &changes[&uri("/a.php")];
428 assert!(edits.len() >= 2, "should rename declaration and access");
429 assert!(edits.iter().all(|e| e.new_text == "title"));
430 }
431
432 #[test]
433 fn rename_property_works_across_files() {
434 let a = doc("/a.php", "<?php\nclass Foo {\n public int $count;\n}");
435 let b = doc("/b.php", "<?php\n$foo = new Foo();\necho $foo->count;");
436 let docs = vec![a, b];
437 let edit = rename_property("count", "total", &docs);
438 let changes = edit.changes.unwrap();
439 assert!(changes.contains_key(&uri("/a.php")), "declaration in a.php");
440 assert!(changes.contains_key(&uri("/b.php")), "access in b.php");
441 }
442}