Skip to main content

php_lsp/editing/
rename.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
5
6use crate::document::ast::ParsedDoc;
7use crate::navigation::references::find_references_with_use;
8use crate::navigation::walk::{collect_var_refs_in_scope, property_refs_in_stmts};
9use crate::text::utf16_code_units;
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::navigation::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::text::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    // PHP superglobals ($_GET, $_POST, etc.) are part of the language runtime;
53    // renaming them breaks code, so we disable the action.
54    if is_superglobal(&word) {
55        return None;
56    }
57    let line = source.lines().nth(position.line as usize)?;
58    let col = position.character as usize;
59    let chars: Vec<char> = line.chars().collect();
60    // `is_word` intentionally excludes `$` so the range covers only the bare
61    // identifier name (not the sigil). `word_at` may return `$var` with the `$`,
62    // so we strip it before computing the range length to avoid an off-by-one.
63    let is_word = |c: char| c.is_alphanumeric() || c == '_';
64
65    // Find the character index at or before the cursor position (in UTF-16 code units)
66    let mut utf16_col = 0usize;
67    let mut char_idx = 0usize;
68    for (i, ch) in chars.iter().enumerate() {
69        // Check if cursor is within this character's UTF-16 span
70        let char_width = ch.len_utf16();
71        if utf16_col + char_width > col {
72            char_idx = i;
73            break;
74        }
75        utf16_col += char_width;
76        char_idx = i + 1;
77    }
78
79    // Find the start of the word by walking backwards
80    let mut left = char_idx;
81    while left > 0 && is_word(chars[left - 1]) {
82        left -= 1;
83    }
84
85    let bare_word = word.trim_start_matches('$');
86    let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
87    let end_utf16: u32 = start_utf16 + utf16_code_units(bare_word);
88    Some(Range {
89        start: Position {
90            line: position.line,
91            character: start_utf16,
92        },
93        end: Position {
94            line: position.line,
95            character: end_utf16,
96        },
97    })
98}
99
100fn is_php_keyword(word: &str) -> bool {
101    matches!(
102        word,
103        "abstract"
104            | "and"
105            | "array"
106            | "as"
107            | "break"
108            | "callable"
109            | "case"
110            | "catch"
111            | "class"
112            | "clone"
113            | "const"
114            | "continue"
115            | "declare"
116            | "default"
117            | "die"
118            | "do"
119            | "echo"
120            | "else"
121            | "elseif"
122            | "empty"
123            | "enddeclare"
124            | "endfor"
125            | "endforeach"
126            | "endif"
127            | "endswitch"
128            | "endwhile"
129            | "enum"
130            | "eval"
131            | "exit"
132            | "extends"
133            | "final"
134            | "finally"
135            | "fn"
136            | "for"
137            | "foreach"
138            | "function"
139            | "global"
140            | "goto"
141            | "if"
142            | "implements"
143            | "include"
144            | "include_once"
145            | "instanceof"
146            | "insteadof"
147            | "interface"
148            | "isset"
149            | "list"
150            | "match"
151            | "namespace"
152            | "new"
153            | "null"
154            | "or"
155            | "print"
156            | "private"
157            | "protected"
158            | "public"
159            | "readonly"
160            | "require"
161            | "require_once"
162            | "return"
163            | "self"
164            | "static"
165            | "switch"
166            | "throw"
167            | "trait"
168            | "true"
169            | "false"
170            | "try"
171            | "use"
172            | "var"
173            | "while"
174            | "xor"
175            | "yield"
176    )
177}
178
179fn is_superglobal(word: &str) -> bool {
180    matches!(
181        word,
182        "$_GET"
183            | "$_POST"
184            | "$_REQUEST"
185            | "$_FILES"
186            | "$_COOKIE"
187            | "$_SESSION"
188            | "$_SERVER"
189            | "$_ENV"
190            | "$GLOBALS"
191    )
192}
193
194/// Rename a `$variable` (or parameter) within its enclosing function/method scope.
195/// Only produces edits within the single document `uri`; variables don't cross files.
196pub fn rename_variable(
197    var_name: &str,
198    new_name: &str,
199    uri: &Url,
200    doc: &ParsedDoc,
201    position: Position,
202) -> WorkspaceEdit {
203    let bare = var_name.trim_start_matches('$');
204    let new_bare = new_name.trim_start_matches('$');
205    let new_text = format!("${new_bare}");
206
207    let stmts = &doc.program().stmts;
208    let sv = doc.view();
209    let byte_off = sv.byte_of_position(position) as usize;
210
211    let mut spans = Vec::new();
212    collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
213
214    let mut seen = std::collections::HashSet::new();
215    let mut edits: Vec<TextEdit> = spans
216        .into_iter()
217        .filter_map(|(span, _)| {
218            let start = sv.position_of(span.start);
219            let end = sv.position_of(span.end);
220            seen.insert((start.line, start.character))
221                .then_some(TextEdit {
222                    range: Range { start, end },
223                    new_text: new_text.clone(),
224                })
225        })
226        .collect();
227    edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
228
229    let mut changes = HashMap::new();
230    if !edits.is_empty() {
231        changes.insert(uri.clone(), edits);
232    }
233
234    WorkspaceEdit {
235        changes: if changes.is_empty() {
236            None
237        } else {
238            Some(changes)
239        },
240        ..Default::default()
241    }
242}
243
244/// Rename a property (`->prop` / `?->prop` / class declaration) across all indexed
245/// documents.  Unlike variable rename, properties are not scope-bound and may appear
246/// in many files.
247pub fn rename_property(
248    prop_name: &str,
249    new_name: &str,
250    all_docs: &[(Url, Arc<ParsedDoc>)],
251) -> WorkspaceEdit {
252    let mut changes: HashMap<Url, Vec<TextEdit>> = HashMap::new();
253    for (uri, doc) in all_docs {
254        let sv = doc.view();
255        let mut spans = Vec::new();
256        property_refs_in_stmts(sv.source(), &doc.program().stmts, prop_name, &mut spans);
257        if !spans.is_empty() {
258            let mut seen = std::collections::HashSet::new();
259            let mut edits: Vec<TextEdit> = spans
260                .into_iter()
261                .filter_map(|span| {
262                    let start = sv.position_of(span.start);
263                    let end = sv.position_of(span.end);
264                    seen.insert((start.line, start.character))
265                        .then_some(TextEdit {
266                            range: Range { start, end },
267                            new_text: new_name.to_string(),
268                        })
269                })
270                .collect();
271            edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
272            changes.insert(uri.clone(), edits);
273        }
274    }
275    WorkspaceEdit {
276        changes: if changes.is_empty() {
277            None
278        } else {
279            Some(changes)
280        },
281        ..Default::default()
282    }
283}