Skip to main content

php_lsp/editing/
rename.rs

1use std::collections::HashMap;
2use std::ops::ControlFlow;
3
4use php_ast::visitor::{Visitor, walk_expr};
5use php_ast::{Expr, ExprKind};
6use tower_lsp_server::ls_types::{
7    DocumentHighlightKind, Position, Range, TextEdit, Uri, WorkspaceEdit,
8};
9
10use crate::document::ast::ParsedDoc;
11use crate::lang::is_php_keyword;
12use crate::navigation::walk::collect_var_refs_in_scope;
13use crate::text::utf16_code_units;
14
15/// Narrow `range` to the last whole-word, case-sensitive occurrence of
16/// `word` inside it — or reject the location entirely.
17///
18/// mir's posting spans cover the full written name — a qualified usage
19/// (`\App\Widget`), a `use` import item (`App\Widget as W`), or a property
20/// declaration (`$count`) — while a rename must replace only the renamed
21/// token: the final path segment, or the alias when that is what's being
22/// renamed. `None` means the span doesn't contain the word at all (an
23/// aliased usage site, or a name differing only in case — mir resolves
24/// per PHP's case-insensitive semantics, but rename edits follow the
25/// case-sensitive editor convention) and must not be edited.
26pub fn narrow_range_to_word(source: &str, range: Range, word: &str) -> Option<Range> {
27    if word.is_empty() || range.start.line != range.end.line {
28        return None;
29    }
30    let line = source.lines().nth(range.start.line as usize)?;
31    // UTF-16 column → byte offset within the line.
32    let byte_at_col = |col: u32| -> Option<usize> {
33        let mut u16s: u32 = 0;
34        if col == 0 {
35            return Some(0);
36        }
37        for (i, ch) in line.char_indices() {
38            if u16s == col {
39                return Some(i);
40            }
41            u16s += ch.len_utf16() as u32;
42        }
43        (u16s >= col).then_some(line.len())
44    };
45    let start_b = byte_at_col(range.start.character)?;
46    let end_b = byte_at_col(range.end.character)?;
47    let slice = line.get(start_b..end_b)?;
48    if slice == word {
49        return Some(range);
50    }
51
52    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
53    let mut found: Option<usize> = None;
54    let mut search = 0usize;
55    while let Some(pos) = slice[search..].find(word) {
56        let abs = search + pos;
57        let before_ok = abs == 0 || !slice[..abs].chars().next_back().is_some_and(is_word_char);
58        let after = abs + word.len();
59        let after_ok =
60            after >= slice.len() || !slice[after..].chars().next().is_some_and(is_word_char);
61        if before_ok && after_ok {
62            found = Some(abs);
63        }
64        search = after.max(abs + 1);
65    }
66    let off = found?;
67
68    let new_start = range.start.character + utf16_code_units(&slice[..off]);
69    Some(Range {
70        start: Position {
71            line: range.start.line,
72            character: new_start,
73        },
74        end: Position {
75            line: range.start.line,
76            character: new_start + utf16_code_units(word),
77        },
78    })
79}
80
81/// Sorts `edits` by start position and drops duplicate ranges — the shared
82/// finishing step for a `WorkspaceEdit`'s per-file edit list.
83pub fn sort_and_dedup_edits(edits: &mut Vec<TextEdit>) {
84    edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
85    edits.dedup_by(|a, b| a.range == b.range);
86}
87
88/// Returns the range of the word at `position` if it's a renameable symbol.
89/// Used for `textDocument/prepareRename`.
90pub fn prepare_rename(doc: &ParsedDoc, position: Position) -> Option<Range> {
91    use crate::text::word_at_position;
92    let source = doc.source();
93    let word = word_at_position(source, position)?;
94    if word.contains('\\') {
95        return None;
96    }
97    // PHP keywords cannot be renamed when used as keywords — but almost all
98    // of them (`match`, `list`, `enum`, `static`, `readonly`, `default`, ...)
99    // are valid method names, and any keyword-named method call/declaration
100    // is therefore a real renameable symbol, not a keyword use.
101    if is_php_keyword(&word) && !is_member_name_position(doc, position) {
102        return None;
103    }
104    // PHP superglobals ($_GET, $_POST, etc.) are part of the language runtime;
105    // renaming them breaks code, so we disable the action.
106    if is_superglobal(&word) {
107        return None;
108    }
109    let line = source.lines().nth(position.line as usize)?;
110    let col = position.character as usize;
111    let chars: Vec<char> = line.chars().collect();
112    // `is_word` intentionally excludes `$` so the range covers only the bare
113    // identifier name (not the sigil). `word_at` may return `$var` with the `$`,
114    // so we strip it before computing the range length to avoid an off-by-one.
115    let is_word = |c: char| c.is_alphanumeric() || c == '_';
116
117    // Find the character index at or before the cursor position (in UTF-16 code units)
118    let mut utf16_col = 0usize;
119    let mut char_idx = 0usize;
120    for (i, ch) in chars.iter().enumerate() {
121        // Check if cursor is within this character's UTF-16 span
122        let char_width = ch.len_utf16();
123        if utf16_col + char_width > col {
124            char_idx = i;
125            break;
126        }
127        utf16_col += char_width;
128        char_idx = i + 1;
129    }
130
131    // Find the start of the word by walking backwards
132    let mut left = char_idx;
133    while left > 0 && is_word(chars[left - 1]) {
134        left -= 1;
135    }
136    if chars.get(left) == Some(&'$') {
137        left += 1;
138    }
139
140    let bare_word = word.trim_start_matches('$');
141    let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
142    let end_utf16: u32 = start_utf16 + utf16_code_units(bare_word);
143    Some(Range {
144        start: Position {
145            line: position.line,
146            character: start_utf16,
147        },
148        end: Position {
149            line: position.line,
150            character: end_utf16,
151        },
152    })
153}
154
155/// True when the word at `position` sits in a position where PHP allows a
156/// keyword as an ordinary identifier: the member-name side of a method call,
157/// static method/const access, or property access (`->`, `?->`, `::`), or
158/// right after the `function` keyword (a method declaration — `function
159/// match(): void {}` only parses inside a class/trait/interface/enum body,
160/// since the same name at true top level is a syntax error, so this check
161/// needs no extra nesting context).
162///
163/// Member access is checked via the AST rather than the cursor's own line,
164/// so a receiver wrapped across lines (`$obj\n    ->list()`) still resolves
165/// — a same-line text check would miss the `->` on the previous line and
166/// wrongly block renaming a keyword-named method. Declarations stay a
167/// same-line text check: `function` and the method name are always adjacent
168/// tokens, and `Ident` carries no span for an AST-based lookup.
169fn is_member_name_position(doc: &ParsedDoc, position: Position) -> bool {
170    use crate::text::word_range_at;
171    let source = doc.source();
172    let Some(range) = word_range_at(source, position) else {
173        return false;
174    };
175    let offset = doc.view().byte_of_position(range.start);
176    if is_member_access_name(doc, offset) {
177        return true;
178    }
179    let Some(line) = source.lines().nth(range.start.line as usize) else {
180        return false;
181    };
182    let before = utf16_prefix(line, range.start.character).trim_end();
183    match before.strip_suffix("function") {
184        Some(rest) => rest
185            .chars()
186            .next_back()
187            .is_none_or(|c| !c.is_alphanumeric() && c != '_'),
188        None => false,
189    }
190}
191
192/// True when `offset` lands inside the member-name token of a method call,
193/// static method/const access, or property access anywhere in `doc`.
194fn is_member_access_name(doc: &ParsedDoc, offset: u32) -> bool {
195    struct Finder {
196        offset: u32,
197        found: bool,
198    }
199
200    fn at(span: php_ast::Span, offset: u32) -> bool {
201        span.start <= offset && offset < span.end
202    }
203
204    impl<'arena, 'src> Visitor<'arena, 'src> for Finder {
205        fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
206            if self.found {
207                return ControlFlow::Break(());
208            }
209            let hit = match &expr.kind {
210                ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
211                    at(m.method.span, self.offset)
212                }
213                ExprKind::StaticMethodCall(s) => at(s.method.span, self.offset),
214                ExprKind::StaticDynMethodCall(s) => at(s.method.span, self.offset),
215                ExprKind::PropertyAccess(p) | ExprKind::NullsafePropertyAccess(p) => {
216                    at(p.property.span, self.offset)
217                }
218                ExprKind::StaticPropertyAccess(s) | ExprKind::ClassConstAccess(s) => {
219                    at(s.member.span, self.offset)
220                }
221                _ => false,
222            };
223            if hit {
224                self.found = true;
225                return ControlFlow::Break(());
226            }
227            walk_expr(self, expr)
228        }
229    }
230
231    let mut finder = Finder {
232        offset,
233        found: false,
234    };
235    for stmt in doc.program().stmts.iter() {
236        let _ = finder.visit_stmt(stmt);
237    }
238    finder.found
239}
240
241/// The substring of `line` up to UTF-16 column `col`.
242fn utf16_prefix(line: &str, col: u32) -> &str {
243    let mut u16s = 0u32;
244    for (i, ch) in line.char_indices() {
245        if u16s >= col {
246            return &line[..i];
247        }
248        u16s += ch.len_utf16() as u32;
249    }
250    line
251}
252
253pub(crate) fn is_superglobal(word: &str) -> bool {
254    matches!(
255        word,
256        "$_GET"
257            | "$_POST"
258            | "$_REQUEST"
259            | "$_FILES"
260            | "$_COOKIE"
261            | "$_SESSION"
262            | "$_SERVER"
263            | "$_ENV"
264            | "$GLOBALS"
265            | "$this"
266    )
267}
268
269/// A variable rename's in-document edit, plus where to find named-argument call
270/// sites when the variable is a parameter of a named callable.
271pub struct VariableRename {
272    pub edit: WorkspaceEdit,
273    /// The callee's name token: the function or method name, or the class name
274    /// for a constructor, whose references are the calls passing `name:` arguments.
275    pub named_arg_callee: Option<Position>,
276}
277
278/// Rename a `$variable` (or parameter) within its enclosing function/method scope.
279/// Only produces edits within the single document `uri`; variables don't cross files.
280pub fn rename_variable(
281    var_name: &str,
282    new_name: &str,
283    uri: &Uri,
284    doc: &ParsedDoc,
285    position: Position,
286) -> VariableRename {
287    let bare = var_name.trim_start_matches('$');
288    let new_bare = new_name.trim_start_matches('$');
289    let new_text = format!("${new_bare}");
290
291    let stmts = &doc.program().stmts;
292    let sv = doc.view();
293    let byte_off = sv.byte_of_position(position) as usize;
294
295    let mut spans = Vec::new();
296    collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
297    let owner = param_owner(doc, bare, &spans);
298
299    let mut seen = std::collections::HashSet::new();
300    let mut edits: Vec<TextEdit> = spans
301        .into_iter()
302        .filter_map(|(span, _)| {
303            let span = variable_token_span(doc.source(), span, bare);
304            let start = sv.position_of(span.start);
305            let end = sv.position_of(span.end);
306            seen.insert((start.line, start.character))
307                .then_some(TextEdit {
308                    range: Range { start, end },
309                    new_text: new_text.clone(),
310                })
311        })
312        .collect();
313    if let Some(comment) = owner.as_ref().and_then(|o| o.doc_comment) {
314        edits.extend(
315            docblock_param_spans(comment, bare)
316                .into_iter()
317                .map(|span| TextEdit {
318                    range: sv.range_of(span),
319                    new_text: new_text.clone(),
320                }),
321        );
322    }
323    edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
324
325    let mut changes = HashMap::new();
326    if !edits.is_empty() {
327        changes.insert(uri.clone(), edits);
328    }
329
330    VariableRename {
331        edit: WorkspaceEdit {
332            changes: if changes.is_empty() {
333                None
334            } else {
335                Some(changes)
336            },
337            ..Default::default()
338        },
339        named_arg_callee: owner.and_then(|o| o.callee),
340    }
341}
342
343struct ParamOwner<'src> {
344    doc_comment: Option<php_ast::Comment<'src>>,
345    callee: Option<Position>,
346}
347
348/// The callable declaring the parameter `$name` whose span the scope walker
349/// collected, i.e. the parameter the renamed variable refers to.
350fn param_owner<'d>(
351    doc: &'d ParsedDoc,
352    name: &str,
353    spans: &[(php_ast::Span, DocumentHighlightKind)],
354) -> Option<ParamOwner<'d>> {
355    struct Finder<'a, 'src> {
356        sv: crate::document::ast::SourceView<'a>,
357        name: &'a str,
358        spans: &'a [(php_ast::Span, DocumentHighlightKind)],
359        class: Option<(&'src str, php_ast::Span)>,
360        found: Option<ParamOwner<'src>>,
361    }
362
363    impl<'src> Finder<'_, 'src> {
364        fn declares(&self, params: &[php_ast::Param<'_, 'src>]) -> bool {
365            params
366                .iter()
367                .any(|p| p.name == self.name && self.spans.iter().any(|(s, _)| *s == p.span))
368        }
369
370        fn method(&mut self, m: &php_ast::MethodDecl<'_, 'src>, span: php_ast::Span) -> bool {
371            if !self.declares(&m.params) {
372                return false;
373            }
374            let callee = if m.name.as_str() == Some("__construct") {
375                self.class
376                    .map(|(name, span)| self.sv.name_range_in_span(name, span).start)
377            } else {
378                m.name.as_str().map(|name| {
379                    self.sv
380                        .name_range_after_attrs(name, &m.attributes, span)
381                        .start
382                })
383            };
384            self.found = Some(ParamOwner {
385                doc_comment: m.doc_comment,
386                callee,
387            });
388            true
389        }
390    }
391
392    impl<'arena, 'src> Visitor<'arena, 'src> for Finder<'_, 'src> {
393        fn visit_stmt(&mut self, stmt: &php_ast::Stmt<'arena, 'src>) -> ControlFlow<()> {
394            match &stmt.kind {
395                php_ast::StmtKind::Function(f) if self.declares(&f.params) => {
396                    self.found = Some(ParamOwner {
397                        doc_comment: f.doc_comment,
398                        callee: f.name.as_str().map(|name| {
399                            self.sv
400                                .name_range_after_attrs(name, &f.attributes, stmt.span)
401                                .start
402                        }),
403                    });
404                    return ControlFlow::Break(());
405                }
406                php_ast::StmtKind::Class(c) => {
407                    let outer = self.class;
408                    self.class = c
409                        .name
410                        .and_then(|n| n.as_str())
411                        .map(|name| (name, stmt.span));
412                    let flow = php_ast::visitor::walk_stmt(self, stmt);
413                    self.class = outer;
414                    return flow;
415                }
416                php_ast::StmtKind::Interface(_)
417                | php_ast::StmtKind::Trait(_)
418                | php_ast::StmtKind::Enum(_) => {
419                    let outer = self.class.take();
420                    let flow = php_ast::visitor::walk_stmt(self, stmt);
421                    self.class = outer;
422                    return flow;
423                }
424                _ => {}
425            }
426            php_ast::visitor::walk_stmt(self, stmt)
427        }
428
429        fn visit_class_member(
430            &mut self,
431            member: &php_ast::ClassMember<'arena, 'src>,
432        ) -> ControlFlow<()> {
433            if let php_ast::ClassMemberKind::Method(m) = &member.kind
434                && self.method(m, member.span)
435            {
436                return ControlFlow::Break(());
437            }
438            php_ast::visitor::walk_class_member(self, member)
439        }
440
441        fn visit_enum_member(
442            &mut self,
443            member: &php_ast::EnumMember<'arena, 'src>,
444        ) -> ControlFlow<()> {
445            if let php_ast::EnumMemberKind::Method(m) = &member.kind
446                && self.method(m, member.span)
447            {
448                return ControlFlow::Break(());
449            }
450            php_ast::visitor::walk_enum_member(self, member)
451        }
452
453        fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
454            let declares = match &expr.kind {
455                ExprKind::Closure(c) => self.declares(&c.params),
456                ExprKind::ArrowFunction(a) => self.declares(&a.params),
457                _ => false,
458            };
459            if declares {
460                self.found = Some(ParamOwner {
461                    doc_comment: None,
462                    callee: None,
463                });
464                return ControlFlow::Break(());
465            }
466            walk_expr(self, expr)
467        }
468    }
469
470    let mut finder = Finder {
471        sv: doc.view(),
472        name,
473        spans,
474        class: None,
475        found: None,
476    };
477    let _ = finder.visit_program(doc.program());
478    finder.found
479}
480
481/// Spans of `$name` in the `@param` tags (`@psalm-param` and `@phpstan-param`
482/// included) of `comment`.
483fn docblock_param_spans(comment: php_ast::Comment<'_>, name: &str) -> Vec<php_ast::Span> {
484    let needle = format!("${name}");
485    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
486    let mut out = Vec::new();
487    let mut line_start = 0usize;
488    for line in comment.text.split_inclusive('\n') {
489        let tag = line.find("@param").or_else(|| {
490            line.find("@psalm-param")
491                .or_else(|| line.find("@phpstan-param"))
492        });
493        if let Some(tag) = tag {
494            let mut from = tag;
495            while let Some(pos) = line[from..].find(&needle) {
496                let at = from + pos;
497                let end = at + needle.len();
498                if !line[end..].chars().next().is_some_and(is_word_char) {
499                    let start = comment.span.start + (line_start + at) as u32;
500                    out.push(php_ast::Span::new(start, start + needle.len() as u32));
501                    break;
502                }
503                from = end;
504            }
505        }
506        line_start += line.len();
507    }
508    out
509}
510
511/// Ranges of the `name:` labels of named arguments in calls whose callee token
512/// (function or method name, or the class of a `new`) overlaps `callee_ranges`.
513pub fn named_arg_ranges(doc: &ParsedDoc, callee_ranges: &[Range], name: &str) -> Vec<Range> {
514    struct Finder<'a> {
515        callee_offsets: Vec<u32>,
516        name: &'a str,
517        out: Vec<php_ast::Span>,
518    }
519
520    impl<'arena, 'src> Visitor<'arena, 'src> for Finder<'_> {
521        fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
522            let call = match &expr.kind {
523                ExprKind::FunctionCall(c) => Some((c.name.span, &c.args)),
524                ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
525                    Some((m.method.span, &m.args))
526                }
527                ExprKind::StaticMethodCall(s) => Some((s.method.span, &s.args)),
528                ExprKind::New(n) => Some((n.class.span, &n.args)),
529                _ => None,
530            };
531            if let Some((callee, args)) = call
532                && self
533                    .callee_offsets
534                    .iter()
535                    .any(|&off| callee.start <= off && off < callee.end)
536            {
537                self.out.extend(
538                    args.iter()
539                        .filter_map(|a| a.name.as_ref())
540                        .filter(|n| n.to_string_repr() == self.name)
541                        .map(|n| n.span()),
542                );
543            }
544            walk_expr(self, expr)
545        }
546    }
547
548    let sv = doc.view();
549    let mut finder = Finder {
550        callee_offsets: callee_ranges
551            .iter()
552            .map(|r| sv.byte_of_position(r.start))
553            .collect(),
554        name,
555        out: Vec::new(),
556    };
557    let _ = finder.visit_program(doc.program());
558    finder
559        .out
560        .into_iter()
561        .map(|span| sv.range_of(span))
562        .collect()
563}
564
565/// The name token of the class whose constructor promotes `$name` to a
566/// property, when one of `ranges` edits that parameter.
567pub fn promoted_param_class(doc: &ParsedDoc, ranges: &[Range], name: &str) -> Option<Position> {
568    struct Finder<'a> {
569        source: &'a str,
570        sv: crate::document::ast::SourceView<'a>,
571        offsets: Vec<u32>,
572        name: &'a str,
573        found: Option<Position>,
574    }
575
576    impl<'arena, 'src> Visitor<'arena, 'src> for Finder<'_> {
577        fn visit_stmt(&mut self, stmt: &php_ast::Stmt<'arena, 'src>) -> ControlFlow<()> {
578            if let php_ast::StmtKind::Class(c) = &stmt.kind
579                && let Some(class_name) = c.name.and_then(|n| n.as_str())
580            {
581                let promotes = c.body.members.iter().any(|member| {
582                    let php_ast::ClassMemberKind::Method(m) = &member.kind else {
583                        return false;
584                    };
585                    m.name == "__construct"
586                        && m.params.iter().any(|p| {
587                            (p.visibility.is_some() || p.is_readonly)
588                                && p.name == self.name
589                                && self.offsets.iter().any(|&off| {
590                                    let token = variable_token_span(self.source, p.span, self.name);
591                                    token.start <= off && off < token.end
592                                })
593                        })
594                });
595                if promotes {
596                    self.found = Some(self.sv.name_range_in_span(class_name, stmt.span).start);
597                    return ControlFlow::Break(());
598                }
599            }
600            php_ast::visitor::walk_stmt(self, stmt)
601        }
602    }
603
604    let sv = doc.view();
605    let mut finder = Finder {
606        source: doc.source(),
607        sv,
608        offsets: ranges
609            .iter()
610            .map(|r| sv.byte_of_position(r.start))
611            .collect(),
612        name,
613        found: None,
614    };
615    let _ = finder.visit_program(doc.program());
616    finder.found
617}
618
619/// A parameter's span also covers its type and default; keep only `$name`.
620fn variable_token_span(source: &str, span: php_ast::Span, bare: &str) -> php_ast::Span {
621    let needle = format!("${bare}");
622    let Some(text) = source.get(span.start as usize..span.end as usize) else {
623        return span;
624    };
625    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
626    let mut from = 0;
627    while let Some(pos) = text[from..].find(&needle) {
628        let at = from + pos;
629        let end = at + needle.len();
630        if !text[end..].chars().next().is_some_and(is_word_char) {
631            let start = span.start + at as u32;
632            return php_ast::Span::new(start, start + needle.len() as u32);
633        }
634        from = end;
635    }
636    span
637}