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::util::utf16_code_units;
9use crate::walk::{collect_var_refs_in_scope, property_refs_in_stmts};
10
11/// Compute a WorkspaceEdit that renames every occurrence of `word` to `new_name`
12/// across all open documents (including the declaration site).
13pub fn rename(
14    word: &str,
15    new_name: &str,
16    all_docs: &[(Url, Arc<ParsedDoc>)],
17    target_fqn: Option<&str>,
18) -> WorkspaceEdit {
19    use crate::references::find_references_with_target;
20
21    let locations = match target_fqn {
22        Some(fqn) => find_references_with_target(word, all_docs, true, None, fqn),
23        None => find_references_with_use(word, all_docs, true),
24    };
25
26    let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
27    for loc in locations {
28        changes.entry(loc.uri).or_default().push(TextEdit {
29            range: loc.range,
30            new_text: new_name.to_string(),
31        });
32    }
33
34    WorkspaceEdit {
35        changes: Some(changes),
36        ..Default::default()
37    }
38}
39
40/// Returns the range of the word at `position` if it's a renameable symbol.
41/// Used for `textDocument/prepareRename`.
42pub fn prepare_rename(source: &str, position: Position) -> Option<Range> {
43    use crate::util::word_at_position;
44    let word = word_at_position(source, position)?;
45    if word.contains('\\') {
46        return None;
47    }
48    // PHP keywords cannot be renamed; return None so editors disable the action.
49    if is_php_keyword(&word) {
50        return None;
51    }
52    let line = source.lines().nth(position.line as usize)?;
53    let col = position.character as usize;
54    let chars: Vec<char> = line.chars().collect();
55    // `is_word` intentionally excludes `$` so the range covers only the bare
56    // identifier name (not the sigil). `word_at` may return `$var` with the `$`,
57    // so we strip it before computing the range length to avoid an off-by-one.
58    let is_word = |c: char| c.is_alphanumeric() || c == '_';
59
60    // Find the character index at or before the cursor position (in UTF-16 code units)
61    let mut utf16_col = 0usize;
62    let mut char_idx = 0usize;
63    for (i, ch) in chars.iter().enumerate() {
64        // Check if cursor is within this character's UTF-16 span
65        let char_width = ch.len_utf16();
66        if utf16_col + char_width > col {
67            char_idx = i;
68            break;
69        }
70        utf16_col += char_width;
71        char_idx = i + 1;
72    }
73
74    // Find the start of the word by walking backwards
75    let mut left = char_idx;
76    while left > 0 && is_word(chars[left - 1]) {
77        left -= 1;
78    }
79
80    let bare_word = word.trim_start_matches('$');
81    let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
82    let end_utf16: u32 = start_utf16 + utf16_code_units(bare_word);
83    Some(Range {
84        start: Position {
85            line: position.line,
86            character: start_utf16,
87        },
88        end: Position {
89            line: position.line,
90            character: end_utf16,
91        },
92    })
93}
94
95fn is_php_keyword(word: &str) -> bool {
96    matches!(
97        word,
98        "abstract"
99            | "and"
100            | "array"
101            | "as"
102            | "break"
103            | "callable"
104            | "case"
105            | "catch"
106            | "class"
107            | "clone"
108            | "const"
109            | "continue"
110            | "declare"
111            | "default"
112            | "die"
113            | "do"
114            | "echo"
115            | "else"
116            | "elseif"
117            | "empty"
118            | "enddeclare"
119            | "endfor"
120            | "endforeach"
121            | "endif"
122            | "endswitch"
123            | "endwhile"
124            | "enum"
125            | "eval"
126            | "exit"
127            | "extends"
128            | "final"
129            | "finally"
130            | "fn"
131            | "for"
132            | "foreach"
133            | "function"
134            | "global"
135            | "goto"
136            | "if"
137            | "implements"
138            | "include"
139            | "include_once"
140            | "instanceof"
141            | "insteadof"
142            | "interface"
143            | "isset"
144            | "list"
145            | "match"
146            | "namespace"
147            | "new"
148            | "null"
149            | "or"
150            | "print"
151            | "private"
152            | "protected"
153            | "public"
154            | "readonly"
155            | "require"
156            | "require_once"
157            | "return"
158            | "self"
159            | "static"
160            | "switch"
161            | "throw"
162            | "trait"
163            | "true"
164            | "false"
165            | "try"
166            | "use"
167            | "var"
168            | "while"
169            | "xor"
170            | "yield"
171    )
172}
173
174/// Rename a `$variable` (or parameter) within its enclosing function/method scope.
175/// Only produces edits within the single document `uri`; variables don't cross files.
176pub fn rename_variable(
177    var_name: &str,
178    new_name: &str,
179    uri: &Url,
180    doc: &ParsedDoc,
181    position: Position,
182) -> WorkspaceEdit {
183    let bare = var_name.trim_start_matches('$');
184    let new_bare = new_name.trim_start_matches('$');
185    let new_text = format!("${new_bare}");
186
187    let stmts = &doc.program().stmts;
188    let sv = doc.view();
189    let byte_off = sv.byte_of_position(position) as usize;
190
191    let mut spans = Vec::new();
192    collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
193
194    let mut seen = std::collections::HashSet::new();
195    let mut edits: Vec<TextEdit> = spans
196        .into_iter()
197        .filter_map(|(span, _)| {
198            let start = sv.position_of(span.start);
199            let end = sv.position_of(span.end);
200            seen.insert((start.line, start.character))
201                .then_some(TextEdit {
202                    range: Range { start, end },
203                    new_text: new_text.clone(),
204                })
205        })
206        .collect();
207    edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
208
209    let mut changes = HashMap::new();
210    if !edits.is_empty() {
211        changes.insert(uri.clone(), edits);
212    }
213
214    WorkspaceEdit {
215        changes: if changes.is_empty() {
216            None
217        } else {
218            Some(changes)
219        },
220        ..Default::default()
221    }
222}
223
224/// Rename a property (`->prop` / `?->prop` / class declaration) across all indexed
225/// documents.  Unlike variable rename, properties are not scope-bound and may appear
226/// in many files.
227pub fn rename_property(
228    prop_name: &str,
229    new_name: &str,
230    all_docs: &[(Url, Arc<ParsedDoc>)],
231) -> WorkspaceEdit {
232    let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
233    for (uri, doc) in all_docs {
234        let sv = doc.view();
235        let mut spans = Vec::new();
236        property_refs_in_stmts(sv.source(), &doc.program().stmts, prop_name, &mut spans);
237        if !spans.is_empty() {
238            let mut seen = std::collections::HashSet::new();
239            let mut edits: Vec<TextEdit> = spans
240                .into_iter()
241                .filter_map(|span| {
242                    let start = sv.position_of(span.start);
243                    let end = sv.position_of(span.end);
244                    seen.insert((start.line, start.character))
245                        .then_some(TextEdit {
246                            range: Range { start, end },
247                            new_text: new_name.to_string(),
248                        })
249                })
250                .collect();
251            edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
252            changes.insert(uri.clone(), edits);
253        }
254    }
255    WorkspaceEdit {
256        changes: if changes.is_empty() {
257            None
258        } else {
259            Some(changes)
260        },
261        ..Default::default()
262    }
263}