Skip to main content

php_lsp/
rename.rs

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
10/// Compute a WorkspaceEdit that renames every occurrence of `word` to `new_name`
11/// across all open documents (including the declaration site).
12pub 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
29/// Returns the range of the word at `position` if it's a renameable symbol.
30/// Used for `textDocument/prepareRename`.
31pub 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    // PHP keywords cannot be renamed; return None so editors disable the action.
38    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    // `is_word` intentionally excludes `$` so the range covers only the bare
45    // identifier name (not the sigil). `word_at` may return `$var` with the `$`,
46    // so we strip it before computing the range length to avoid an off-by-one.
47    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
156/// Rename a `$variable` (or parameter) within its enclosing function/method scope.
157/// Only produces edits within the single document `uri`; variables don't cross files.
158pub 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
206/// Rename a property (`->prop` / `?->prop` / class declaration) across all indexed
207/// documents.  Unlike variable rename, properties are not scope-bound and may appear
208/// in many files.
209pub 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        // Renaming `foo` should not rename `foobar` or `barfoo`.
327        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        // Verify that every edit replaces exactly "foo" (not "foobar" or "barfoo")
333        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        // Ensure that `foobar` and `barfoo` are not renamed: their line positions
343        // should not appear in the edits.
344        // Line 2 = `function foobar()`, line 3 = `function barfoo()`,
345        // line 5 = `foobar()` call, line 6 = `barfoo()` call.
346        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        // If file A defines `class Foo` and file B has `use Foo;`,
360        // renaming `Foo` should update the use statement too.
361        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        // File a.php: the class declaration should be renamed.
368        assert!(
369            changes.contains_key(&uri("/a.php")),
370            "should rename class declaration in a.php"
371        );
372
373        // File b.php: should have at least 2 edits — use statement + new expression.
374        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        // One of the edits should be on the `use Foo;` line (line 1).
385        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        // Only the $x in foo() should be renamed, not the one in bar()
411        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}