Skip to main content

php_lsp/actions/
inline_action.rs

1/// Code action: "Inline variable" — replaces all usages of a variable with its
2/// initializer expression and removes the assignment statement.
3///
4/// Only acts when:
5/// - The cursor/selection is on or inside a variable name (e.g. `$extracted`).
6/// - The variable is bound exactly once in its function scope, by a plain
7///   `$var = <expr>;` statement that precedes every read and sits in the same
8///   block as all of them.
9/// - Every read is a plain value: not interpolated, captured by a closure,
10///   checked by `isset`, assigned to, bound by reference or iterated by
11///   reference; nothing in the scope reads variables by name (`compact`, `$$`).
12/// - The initializer is safe to evaluate at each read:
13///   - a constant expression, anywhere;
14///   - variables and property/array reads, if nothing but writes to other
15///     locals and output runs between the assignment and the last read;
16///   - anything else, read once, unconditionally, in the next statement, with
17///     nothing observable evaluated before the read.
18use std::collections::HashMap;
19use std::ops::ControlFlow;
20
21use php_ast::{
22    Arg, AssignOp, BinaryOp, Block, ClassMember, ClassMemberKind, Expr, ExprKind, Span, Stmt,
23    StmtKind, UnaryPrefixOp,
24    visitor::{Visitor, walk_block, walk_class_member, walk_expr, walk_stmt},
25};
26use tower_lsp_server::ls_types::{
27    CodeAction, CodeActionKind, CodeActionOrCommand, DocumentHighlightKind, Range, TextEdit, Uri,
28    WorkspaceEdit,
29};
30
31use crate::document::ast::ParsedDoc;
32use crate::navigation::walk::collect_var_refs_in_scope;
33use crate::text::word_at_position;
34
35pub fn inline_variable_actions(
36    source: &str,
37    doc: &ParsedDoc,
38    range: Range,
39    uri: &Uri,
40    is_byref_param: &dyn Fn(ArgSlot<'_>) -> bool,
41) -> Vec<CodeActionOrCommand> {
42    let var_name = match word_at_position(source, range.start) {
43        Some(w) if w.starts_with('$') && w.len() > 1 => w,
44        _ => return vec![],
45    };
46    let name = &var_name[1..];
47    let sv = doc.view();
48    let stmts = &doc.program().stmts;
49
50    let mut refs = Vec::new();
51    collect_var_refs_in_scope(
52        stmts,
53        name,
54        sv.byte_of_position(range.start) as usize,
55        &mut refs,
56    );
57    refs.sort_by_key(|(span, _)| span.start);
58    refs.dedup_by_key(|(span, _)| span.start);
59
60    let mut writes = refs
61        .iter()
62        .filter(|(_, kind)| *kind == DocumentHighlightKind::WRITE);
63    let (Some(&(write_span, _)), None) = (writes.next(), writes.next()) else {
64        return vec![];
65    };
66    let reads: Vec<Span> = refs
67        .iter()
68        .filter(|(_, kind)| *kind == DocumentHighlightKind::READ)
69        .map(|(span, _)| *span)
70        .collect();
71
72    let mut finder = AssignmentFinder {
73        target: write_span,
74        containers: Vec::new(),
75        scopes: Vec::new(),
76        found: None,
77    };
78    for stmt in stmts.iter() {
79        let _ = finder.visit_stmt(stmt);
80    }
81    let Some(found) = finder.found else {
82        return vec![];
83    };
84    let Some(region) = found.container else {
85        return vec![];
86    };
87    let region = region.unwrap_or(Span::new(0, source.len() as u32));
88
89    let mut scan = ScopeScan::new(
90        source,
91        found.scope.unwrap_or(Span::new(0, source.len() as u32)),
92    );
93    for stmt in stmts.iter() {
94        let _ = scan.visit_stmt(stmt);
95    }
96    if scan.dynamic_scope
97        || scan
98            .bindings
99            .iter()
100            .any(|(span, bound)| bound == name && *span != write_span)
101    {
102        return vec![];
103    }
104    let usable = |read: &Span| {
105        read.start >= found.stmt.end
106            && contains(region, *read)
107            && !scan.zones.iter().any(|zone| contains(*zone, *read))
108    };
109    if reads.is_empty() || !reads.iter().all(usable) {
110        return vec![];
111    }
112    // Loops, closures and branches that run after the assignment, apart from it.
113    let after = |spans: &[Span], read: &Span| {
114        spans
115            .iter()
116            .filter(|span| span.start >= found.stmt.end && contains(**span, *read))
117            .map(|span| span.end)
118            .max()
119    };
120    let safe_to_evaluate_at_reads = match found.initializer {
121        Initializer::Constant => true,
122        Initializer::Pure => {
123            let cutoff = reads
124                .iter()
125                .map(|read| after(&scan.loops, read).unwrap_or(read.start))
126                .max()
127                .unwrap_or(found.stmt.end);
128            !reads
129                .iter()
130                .any(|read| after(&scan.arrow_fns, read).is_some())
131                && !found
132                    .value_vars
133                    .iter()
134                    .any(|var| scan.ref_bound.contains(var))
135                && !scan.effects.iter().any(|(span, effect)| {
136                    span.start >= found.stmt.end
137                        && span.end <= cutoff
138                        && match effect {
139                            Effect::Output => false,
140                            Effect::Write(var) => found.value_vars.contains(var),
141                            Effect::Other => true,
142                        }
143                })
144        }
145        Initializer::Effectful => {
146            let [read] = reads.as_slice() else {
147                return vec![];
148            };
149            let between = |span: &Span| span.start >= found.stmt.end && span.end <= read.start;
150            [&scan.loops, &scan.arrow_fns, &scan.conditionals]
151                .iter()
152                .all(|spans| after(spans, read).is_none())
153                && !scan.stmts.iter().any(between)
154                && !scan.effects.iter().any(|(span, _)| between(span))
155        }
156    };
157    if !safe_to_evaluate_at_reads {
158        return vec![];
159    }
160
161    let mut sites = ReadSites {
162        source,
163        reads: &reads,
164        args: Vec::new(),
165        callees: Vec::new(),
166        class_refs: Vec::new(),
167    };
168    for stmt in stmts.iter() {
169        let _ = sites.visit_stmt(stmt);
170    }
171    let callees = sites.callees;
172    let class_refs = sites.class_refs;
173    let class_name = found
174        .class_name
175        .map(|span| &source[span.start as usize..span.end as usize]);
176    if !class_refs.is_empty() && class_name.is_none() {
177        return vec![];
178    }
179    if sites.args.into_iter().any(is_byref_param) {
180        return vec![];
181    }
182
183    let rhs = &source[found.value.start as usize..found.value.end as usize];
184    let mut edits: Vec<TextEdit> = reads
185        .iter()
186        .map(|read| {
187            let new_text = if class_refs.contains(read)
188                && let Some(class_name) = class_name
189            {
190                class_name.to_string()
191            } else if callees.contains(read) {
192                if found.value_is_variable {
193                    rhs.to_string()
194                } else {
195                    format!("({rhs})")
196                }
197            } else if found.value_is_number && touches_dot(source, *read) {
198                // `1.'a'` and `'a'.1` lex as float literals.
199                format!("({rhs})")
200            } else if found.value_is_atomic || stands_alone(source, *read) {
201                rhs.to_string()
202            } else {
203                format!("({rhs})")
204            };
205            TextEdit {
206                range: Range {
207                    start: sv.position_of(read.start),
208                    end: sv.position_of(read.end),
209                },
210                new_text,
211            }
212        })
213        .collect();
214
215    let (del_start, del_end) = statement_deletion(source, found.stmt);
216    edits.push(TextEdit {
217        range: Range {
218            start: sv.position_of(del_start),
219            end: sv.position_of(del_end),
220        },
221        new_text: String::new(),
222    });
223
224    let mut changes = HashMap::new();
225    changes.insert(uri.clone(), edits);
226
227    vec![CodeActionOrCommand::CodeAction(CodeAction {
228        title: format!("Inline variable '{var_name}'"),
229        kind: Some(CodeActionKind::REFACTOR_INLINE),
230        edit: Some(WorkspaceEdit {
231            changes: Some(changes),
232            ..Default::default()
233        }),
234        ..Default::default()
235    })]
236}
237
238fn contains(outer: Span, inner: Span) -> bool {
239    outer.start <= inner.start && inner.end <= outer.end
240}
241
242fn touches_dot(source: &str, span: Span) -> bool {
243    source[..span.start as usize].ends_with('.') || source[span.end as usize..].starts_with('.')
244}
245
246/// How freely the initializer can be evaluated at the reads instead.
247enum Initializer {
248    /// Same value wherever and however often it's evaluated.
249    Constant,
250    /// No side effect, but the value depends on variables or object state.
251    Pure,
252    /// Calls, `new`, assignments: must run exactly once, where it ran before.
253    Effectful,
254}
255
256struct FoundAssignment {
257    stmt: Span,
258    value: Span,
259    initializer: Initializer,
260    value_is_atomic: bool,
261    value_is_variable: bool,
262    value_is_number: bool,
263    /// What replaces a read in a class position (`new`, `instanceof`, `::`):
264    /// the variable, or `Foo` of `Foo::class`.
265    class_name: Option<Span>,
266    /// Variables the initializer reads, other than `$this`.
267    value_vars: Vec<String>,
268    /// The innermost function, method or closure; `None` at the top level.
269    scope: Option<Span>,
270    /// `Some(None)`: a direct child of the scope's top-level statement list;
271    /// `Some(Some(block))`: a direct child of `block`; `None`: the body of a
272    /// brace-less `if`/loop/`switch` case, which runs conditionally.
273    container: Option<Option<Span>>,
274}
275
276/// Finds the `$var = <expr>;` statement whose target is `target` and the
277/// statement list it belongs to.
278struct AssignmentFinder {
279    target: Span,
280    /// Innermost-last: `Some(block)` for a block, `None` for a statement whose
281    /// body isn't a statement list.
282    containers: Vec<Option<Span>>,
283    /// Innermost-last enclosing functions, methods and closures.
284    scopes: Vec<Span>,
285    found: Option<FoundAssignment>,
286}
287
288impl AssignmentFinder {
289    fn in_scope(
290        &mut self,
291        span: Span,
292        walk: impl FnOnce(&mut Self) -> ControlFlow<()>,
293    ) -> ControlFlow<()> {
294        let containers = std::mem::take(&mut self.containers);
295        self.scopes.push(span);
296        let flow = walk(self);
297        self.scopes.pop();
298        self.containers = containers;
299        flow
300    }
301}
302
303impl<'arena, 'src> Visitor<'arena, 'src> for AssignmentFinder {
304    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
305        if let StmtKind::Expression(expr) = &stmt.kind
306            && let ExprKind::Assign(assign) = &expr.kind
307            && assign.target.span == self.target
308        {
309            if assign.op == AssignOp::Assign && !assign.by_ref {
310                let value = assign.value;
311                self.found = Some(FoundAssignment {
312                    stmt: stmt.span,
313                    value: value.span,
314                    initializer: if !is_repeatable(value) {
315                        Initializer::Effectful
316                    } else if reads_state(value) {
317                        Initializer::Pure
318                    } else {
319                        Initializer::Constant
320                    },
321                    value_is_atomic: is_atomic(value),
322                    value_is_variable: matches!(
323                        value.kind,
324                        ExprKind::Variable(_) | ExprKind::Parenthesized(_)
325                    ),
326                    value_is_number: matches!(value.kind, ExprKind::Int(_) | ExprKind::Float(_)),
327                    class_name: class_name(value),
328                    value_vars: variable_names(value),
329                    scope: self.scopes.last().copied(),
330                    container: match self.containers.last() {
331                        None => Some(None),
332                        Some(Some(block)) => Some(Some(*block)),
333                        Some(None) => None,
334                    },
335                });
336            }
337            return ControlFlow::Break(());
338        }
339        if let StmtKind::Function(_) = stmt.kind {
340            return self.in_scope(stmt.span, |this| walk_stmt(this, stmt));
341        }
342        let conditional_body = matches!(
343            stmt.kind,
344            StmtKind::If(_)
345                | StmtKind::While(_)
346                | StmtKind::For(_)
347                | StmtKind::Foreach(_)
348                | StmtKind::DoWhile(_)
349                | StmtKind::Switch(_)
350                | StmtKind::Declare(_)
351        );
352        if conditional_body {
353            self.containers.push(None);
354        }
355        let flow = walk_stmt(self, stmt);
356        if conditional_body {
357            self.containers.pop();
358        }
359        flow
360    }
361
362    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
363        if let ExprKind::Closure(_) = expr.kind {
364            return self.in_scope(expr.span, |this| walk_expr(this, expr));
365        }
366        walk_expr(self, expr)
367    }
368
369    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
370        if let ClassMemberKind::Method(_) = member.kind {
371            return self.in_scope(member.span, |this| walk_class_member(this, member));
372        }
373        walk_class_member(self, member)
374    }
375
376    fn visit_block(&mut self, block: &Block<'arena, 'src>) -> ControlFlow<()> {
377        self.containers.push(Some(block.span));
378        let flow = walk_block(self, block);
379        self.containers.pop();
380        flow
381    }
382}
383
384/// Whether evaluating `expr` twice gives the same value with no side effect,
385/// so it can replace more than one read. Calls and `new` don't qualify.
386fn is_repeatable(expr: &Expr<'_, '_>) -> bool {
387    match &expr.kind {
388        ExprKind::Int(_)
389        | ExprKind::Float(_)
390        | ExprKind::String(_)
391        | ExprKind::Nowdoc { .. }
392        | ExprKind::Bool(_)
393        | ExprKind::Null
394        | ExprKind::Variable(_)
395        | ExprKind::Identifier(_)
396        | ExprKind::MagicConst(_)
397        | ExprKind::ClassConstAccess(_) => true,
398        ExprKind::Parenthesized(inner) | ExprKind::Cast(_, inner) => is_repeatable(inner),
399        ExprKind::UnaryPrefix(unary) => {
400            !matches!(
401                unary.op,
402                UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement
403            ) && is_repeatable(unary.operand)
404        }
405        ExprKind::Binary(binary) => {
406            binary.op != BinaryOp::Pipe && is_repeatable(binary.left) && is_repeatable(binary.right)
407        }
408        ExprKind::PropertyAccess(access) | ExprKind::NullsafePropertyAccess(access) => {
409            is_repeatable(access.object) && is_repeatable(access.property)
410        }
411        ExprKind::StaticPropertyAccess(access) => is_repeatable(access.class),
412        ExprKind::ArrayAccess(access) => {
413            is_repeatable(access.array) && access.index.is_none_or(is_repeatable)
414        }
415        ExprKind::Array(elements) => elements.iter().all(|element| {
416            !element.by_ref
417                && element.key.as_ref().is_none_or(is_repeatable)
418                && is_repeatable(&element.value)
419        }),
420        _ => false,
421    }
422}
423
424fn class_name(expr: &Expr<'_, '_>) -> Option<Span> {
425    match &expr.kind {
426        ExprKind::Variable(_) => Some(expr.span),
427        ExprKind::ClassConstAccess(access)
428            if matches!(access.class.kind, ExprKind::Identifier(_))
429                && access
430                    .member
431                    .name_str()
432                    .is_some_and(|m| m.eq_ignore_ascii_case("class")) =>
433        {
434            Some(access.class.span)
435        }
436        _ => None,
437    }
438}
439
440fn variable_names(expr: &Expr<'_, '_>) -> Vec<String> {
441    struct Names(Vec<String>);
442    impl<'arena, 'src> Visitor<'arena, 'src> for Names {
443        fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
444            if let ExprKind::Variable(name) = &expr.kind
445                && name.as_str() != "this"
446                && !self.0.iter().any(|n| n == name.as_str())
447            {
448                self.0.push(name.as_str().to_string());
449            }
450            walk_expr(self, expr)
451        }
452    }
453    let mut names = Names(Vec::new());
454    let _ = names.visit_expr(expr);
455    names.0
456}
457
458/// Whether `expr` reads a variable or a static property.
459fn reads_state(expr: &Expr<'_, '_>) -> bool {
460    struct State(bool);
461    impl<'arena, 'src> Visitor<'arena, 'src> for State {
462        fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
463            if matches!(
464                expr.kind,
465                ExprKind::Variable(_)
466                    | ExprKind::StaticPropertyAccess(_)
467                    | ExprKind::StaticPropertyAccessDynamic { .. }
468            ) {
469                self.0 = true;
470                return ControlFlow::Break(());
471            }
472            walk_expr(self, expr)
473        }
474    }
475    let mut state = State(false);
476    let _ = state.visit_expr(expr);
477    state.0
478}
479
480/// A read passed as a whole argument: the callee's name starts at
481/// `callee_offset`, and the argument is the `position`-th or named `name`.
482pub struct ArgSlot<'a> {
483    pub callee_offset: u32,
484    pub position: usize,
485    pub name: Option<&'a str>,
486}
487
488/// Finds where the reads sit in their enclosing expressions.
489struct ReadSites<'s> {
490    source: &'s str,
491    reads: &'s [Span],
492    args: Vec<ArgSlot<'s>>,
493    /// Reads called as a function: `$read(...)`.
494    callees: Vec<Span>,
495    /// Reads naming a class: `new $read`, `$x instanceof $read`, `$read::f()`.
496    class_refs: Vec<Span>,
497}
498
499impl ReadSites<'_> {
500    fn note_class_ref(&mut self, span: Span) {
501        if self.reads.contains(&span) {
502            self.class_refs.push(span);
503        }
504    }
505
506    fn note_args(&mut self, callee_offset: u32, args: &[Arg<'_, '_>]) {
507        for (position, arg) in args.iter().enumerate() {
508            if let Some(value) = &arg.value
509                && !arg.unpack
510                && self.reads.contains(&value.span)
511            {
512                let source = self.source;
513                self.args.push(ArgSlot {
514                    callee_offset,
515                    position,
516                    name: arg.name.as_ref().map(|n| {
517                        let span = n.span();
518                        &source[span.start as usize..span.end as usize]
519                    }),
520                });
521            }
522        }
523    }
524}
525
526impl<'arena, 'src> Visitor<'arena, 'src> for ReadSites<'_> {
527    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
528        match &expr.kind {
529            ExprKind::FunctionCall(call) => {
530                if self.reads.contains(&call.name.span) {
531                    self.callees.push(call.name.span);
532                }
533                self.note_args(call.name.span.start, &call.args)
534            }
535            ExprKind::MethodCall(call) | ExprKind::NullsafeMethodCall(call) => {
536                self.note_args(call.method.span.start, &call.args)
537            }
538            ExprKind::StaticMethodCall(call) => {
539                self.note_class_ref(call.class.span);
540                self.note_args(call.method.span.start, &call.args)
541            }
542            ExprKind::StaticPropertyAccess(access) | ExprKind::ClassConstAccess(access) => {
543                self.note_class_ref(access.class.span)
544            }
545            ExprKind::New(new) => {
546                self.note_class_ref(new.class.span);
547                self.note_args(new.class.span.start, &new.args)
548            }
549            ExprKind::Binary(binary) if binary.op == BinaryOp::Instanceof => {
550                self.note_class_ref(binary.right.span)
551            }
552            _ => {}
553        }
554        walk_expr(self, expr)
555    }
556}
557
558/// Something evaluated in the scope that the initializer could observe.
559enum Effect {
560    /// Assigns a local: `$name = …`, `$name++`, `foreach (… as $name)`.
561    Write(String),
562    /// `echo`, `print`.
563    Output,
564    /// A call, `new`, a write to a property, element or reference, …
565    Other,
566}
567
568/// One pass over the inlined variable's scope, skipping nested functions.
569struct ScopeScan<'s> {
570    source: &'s str,
571    scope: Span,
572    /// Spans where a variable isn't a plain read.
573    zones: Vec<Span>,
574    /// Variables assigned, iterated into, unset, caught, or declared `global`/`static`.
575    bindings: Vec<(Span, String)>,
576    /// Variables bound by reference: `&$x`, `global $x`, `static $x`.
577    ref_bound: Vec<String>,
578    effects: Vec<(Span, Effect)>,
579    stmts: Vec<Span>,
580    /// The repeated part of each loop: everything but `for` init and `foreach` subject.
581    loops: Vec<Span>,
582    arrow_fns: Vec<Span>,
583    /// Branches that may not run: `if` bodies, `?:` arms, `&&` right operands, …
584    conditionals: Vec<Span>,
585    /// `compact`, `extract`, `$$x`, `eval`, `include`: variables used by name.
586    dynamic_scope: bool,
587}
588
589impl<'s> ScopeScan<'s> {
590    fn new(source: &'s str, scope: Span) -> Self {
591        Self {
592            source,
593            scope,
594            zones: Vec::new(),
595            bindings: Vec::new(),
596            ref_bound: Vec::new(),
597            effects: Vec::new(),
598            stmts: Vec::new(),
599            loops: Vec::new(),
600            arrow_fns: Vec::new(),
601            conditionals: Vec::new(),
602            dynamic_scope: false,
603        }
604    }
605
606    /// Records the variables an assignment-like `target` rebinds or mutates.
607    fn bind(&mut self, target: &Expr<'_, '_>, by_ref: bool) {
608        match &target.kind {
609            ExprKind::Variable(name) => {
610                self.bindings.push((target.span, name.to_string()));
611                if by_ref {
612                    self.ref_bound.push(name.to_string());
613                }
614            }
615            ExprKind::Array(elements) => {
616                for element in elements.iter() {
617                    self.bind(&element.value, element.by_ref);
618                }
619            }
620            ExprKind::ArrayAccess(access) => self.bind(access.array, by_ref),
621            ExprKind::PropertyAccess(access) | ExprKind::NullsafePropertyAccess(access) => {
622                self.bind(access.object, false)
623            }
624            ExprKind::Parenthesized(inner) => self.bind(inner, by_ref),
625            _ => {}
626        }
627    }
628
629    fn write(&mut self, span: Span, target: &Expr<'_, '_>) {
630        let effect = match &target.kind {
631            ExprKind::Variable(name) => Effect::Write(name.to_string()),
632            _ => Effect::Other,
633        };
634        self.effects.push((span, effect));
635    }
636}
637
638fn is_dynamic_scope_call(name: &Expr<'_, '_>) -> bool {
639    name.name_str().is_some_and(|name| {
640        let name = name.trim_start_matches('\\');
641        ["compact", "extract", "get_defined_vars", "parse_str"]
642            .iter()
643            .any(|f| name.eq_ignore_ascii_case(f))
644    })
645}
646
647impl<'arena, 'src> Visitor<'arena, 'src> for ScopeScan<'_> {
648    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
649        let scope_like = matches!(
650            stmt.kind,
651            StmtKind::Function(_)
652                | StmtKind::Class(_)
653                | StmtKind::Interface(_)
654                | StmtKind::Trait(_)
655                | StmtKind::Enum(_)
656        );
657        if !contains(self.scope, stmt.span) || (scope_like && stmt.span != self.scope) {
658            return if contains(stmt.span, self.scope) {
659                walk_stmt(self, stmt)
660            } else {
661                ControlFlow::Continue(())
662            };
663        }
664        self.stmts.push(stmt.span);
665        match &stmt.kind {
666            StmtKind::Echo(_) => self.effects.push((stmt.span, Effect::Output)),
667            StmtKind::Unset(targets) => {
668                self.zones.push(stmt.span);
669                self.effects.push((stmt.span, Effect::Other));
670                for target in targets.iter() {
671                    self.bind(target, false);
672                }
673            }
674            StmtKind::Global(vars) => {
675                self.zones.push(stmt.span);
676                self.effects.push((stmt.span, Effect::Other));
677                for var in vars.iter() {
678                    self.bind(var, true);
679                }
680            }
681            StmtKind::StaticVar(vars) => {
682                self.zones.push(stmt.span);
683                self.effects.push((stmt.span, Effect::Other));
684                for var in vars.iter() {
685                    let name = var.name.or_error().to_string();
686                    self.bindings.push((var.span, name.clone()));
687                    self.ref_bound.push(name);
688                }
689            }
690            StmtKind::Foreach(foreach) => {
691                let by_ref = self.source[..foreach.value.span.start as usize]
692                    .trim_end()
693                    .ends_with('&');
694                let first = foreach.key.as_ref().unwrap_or(&foreach.value);
695                self.loops.push(Span::new(first.span.start, stmt.span.end));
696                if by_ref {
697                    self.zones.push(foreach.expr.span);
698                    self.bind(&foreach.expr, true);
699                }
700                for target in foreach.key.iter().chain([&foreach.value]) {
701                    self.zones.push(target.span);
702                    self.write(target.span, target);
703                }
704                if let Some(key) = &foreach.key {
705                    self.bind(key, false);
706                }
707                self.bind(&foreach.value, by_ref);
708            }
709            StmtKind::For(for_stmt) => {
710                let first = for_stmt
711                    .condition
712                    .first()
713                    .or(for_stmt.update.first())
714                    .map_or(for_stmt.body.span.start, |expr| expr.span.start);
715                self.loops.push(Span::new(first, stmt.span.end));
716            }
717            StmtKind::While(_) | StmtKind::DoWhile(_) => self.loops.push(stmt.span),
718            StmtKind::If(if_stmt) => {
719                self.conditionals.push(if_stmt.then_branch.span);
720                self.conditionals
721                    .extend(if_stmt.elseif_branches.iter().map(|branch| branch.span));
722                self.conditionals
723                    .extend(if_stmt.else_branch.map(|branch| branch.span));
724            }
725            StmtKind::Switch(switch) => self.conditionals.push(switch.body.span),
726            StmtKind::TryCatch(try_catch) => {
727                for catch in try_catch.catches.iter() {
728                    self.conditionals.push(catch.span);
729                    if let Some(var) = catch.var {
730                        let span = Span::new(catch.span.start, catch.body.span.start);
731                        self.bindings.push((span, var.to_string()));
732                        self.effects.push((span, Effect::Write(var.to_string())));
733                    }
734                }
735            }
736            _ => {}
737        }
738        walk_stmt(self, stmt)
739    }
740
741    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
742        if contains(member.span, self.scope) {
743            walk_class_member(self, member)
744        } else {
745            ControlFlow::Continue(())
746        }
747    }
748
749    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
750        if !contains(self.scope, expr.span) {
751            return if contains(expr.span, self.scope) {
752                walk_expr(self, expr)
753            } else {
754                ControlFlow::Continue(())
755            };
756        }
757        match &expr.kind {
758            ExprKind::Closure(closure) if expr.span != self.scope => {
759                for use_var in closure.use_vars.iter() {
760                    self.zones.push(use_var.span);
761                    if use_var.by_ref {
762                        let name = use_var.name.trim_start_matches('$').to_string();
763                        self.bindings.push((use_var.span, name.clone()));
764                        self.ref_bound.push(name);
765                    }
766                }
767                return ControlFlow::Continue(());
768            }
769            ExprKind::AnonymousClass(_) => {
770                self.effects.push((expr.span, Effect::Other));
771                return ControlFlow::Continue(());
772            }
773            ExprKind::InterpolatedString(_) | ExprKind::Heredoc { .. } | ExprKind::Isset(_) => {
774                self.zones.push(expr.span)
775            }
776            ExprKind::ShellExec(_) => {
777                self.zones.push(expr.span);
778                self.effects.push((expr.span, Effect::Other));
779            }
780            ExprKind::Assign(assign) => {
781                self.zones.push(assign.target.span);
782                self.bind(assign.target, assign.by_ref);
783                if assign.by_ref {
784                    self.zones.push(assign.value.span);
785                    self.bind(assign.value, true);
786                    self.effects.push((expr.span, Effect::Other));
787                } else {
788                    self.write(expr.span, assign.target);
789                }
790            }
791            ExprKind::UnaryPrefix(unary)
792                if matches!(
793                    unary.op,
794                    UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement
795                ) =>
796            {
797                self.zones.push(unary.operand.span);
798                self.bind(unary.operand, false);
799                self.write(expr.span, unary.operand);
800            }
801            ExprKind::UnaryPostfix(unary) => {
802                self.zones.push(unary.operand.span);
803                self.bind(unary.operand, false);
804                self.write(expr.span, unary.operand);
805            }
806            ExprKind::Array(elements) => {
807                for element in elements.iter().filter(|element| element.by_ref) {
808                    self.zones.push(element.value.span);
809                    self.bind(&element.value, true);
810                }
811            }
812            ExprKind::FunctionCall(call) => {
813                self.dynamic_scope |= is_dynamic_scope_call(call.name);
814                self.effects.push((expr.span, Effect::Other));
815            }
816            ExprKind::NullsafeMethodCall(call) => {
817                self.conditionals
818                    .push(Span::new(call.method.span.start, expr.span.end));
819                self.effects.push((expr.span, Effect::Other));
820            }
821            ExprKind::MethodCall(_)
822            | ExprKind::StaticMethodCall(_)
823            | ExprKind::StaticDynMethodCall(_)
824            | ExprKind::New(_)
825            | ExprKind::Clone(_)
826            | ExprKind::CloneWith(..)
827            | ExprKind::Exit(_)
828            | ExprKind::Yield(_)
829            | ExprKind::ThrowExpr(_) => self.effects.push((expr.span, Effect::Other)),
830            ExprKind::Include(..) | ExprKind::Eval(_) | ExprKind::VariableVariable(_) => {
831                self.dynamic_scope = true;
832            }
833            ExprKind::Print(_) => self.effects.push((expr.span, Effect::Output)),
834            ExprKind::Ternary(ternary) => {
835                self.conditionals
836                    .extend(ternary.then_expr.map(|then| then.span));
837                self.conditionals.push(ternary.else_expr.span);
838            }
839            ExprKind::NullCoalesce(coalesce) => self.conditionals.push(coalesce.right.span),
840            ExprKind::Binary(binary)
841                if matches!(
842                    binary.op,
843                    BinaryOp::BooleanAnd
844                        | BinaryOp::BooleanOr
845                        | BinaryOp::LogicalAnd
846                        | BinaryOp::LogicalOr
847                ) =>
848            {
849                self.conditionals.push(binary.right.span)
850            }
851            ExprKind::Match(match_expr) => self
852                .conditionals
853                .extend(match_expr.arms.iter().map(|arm| arm.span)),
854            ExprKind::ArrowFunction(_) => self.arrow_fns.push(expr.span),
855            _ => {}
856        }
857        walk_expr(self, expr)
858    }
859}
860
861/// Whether `expr` binds tighter than any operator it could be placed next to.
862fn is_atomic(expr: &Expr<'_, '_>) -> bool {
863    matches!(
864        expr.kind,
865        ExprKind::Int(_)
866            | ExprKind::Float(_)
867            | ExprKind::String(_)
868            | ExprKind::InterpolatedString(_)
869            | ExprKind::Heredoc { .. }
870            | ExprKind::Nowdoc { .. }
871            | ExprKind::Bool(_)
872            | ExprKind::Null
873            | ExprKind::Variable(_)
874            | ExprKind::Identifier(_)
875            | ExprKind::FunctionCall(_)
876            | ExprKind::Array(_)
877            | ExprKind::ArrayAccess(_)
878            | ExprKind::Parenthesized(_)
879            | ExprKind::MagicConst(_)
880            | ExprKind::PropertyAccess(_)
881            | ExprKind::NullsafePropertyAccess(_)
882            | ExprKind::MethodCall(_)
883            | ExprKind::NullsafeMethodCall(_)
884            | ExprKind::StaticPropertyAccess(_)
885            | ExprKind::StaticMethodCall(_)
886            | ExprKind::ClassConstAccess(_)
887    )
888}
889
890/// Whether the read at `span` is a whole expression on its own — an argument,
891/// array element, assigned value, or `return`/`echo` operand — so it needs no
892/// parentheses whatever replaces it.
893fn stands_alone(source: &str, span: Span) -> bool {
894    let before = source[..span.start as usize].trim_end();
895    let after = source[span.end as usize..].trim_start();
896    let opens = before.ends_with(['(', '[', ','])
897        || before.ends_with("=>")
898        || (before.ends_with('=')
899            && !["==", "!=", "<=", ">="]
900                .iter()
901                .any(|op| before.ends_with(op)))
902        || ["return", "echo", "print"].iter().any(|kw| {
903            before.strip_suffix(kw).is_some_and(|rest| {
904                !rest.ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '$')
905            })
906        });
907    opens && after.starts_with([';', ')', ']', ','])
908}
909
910/// Byte range to delete for the statement at `stmt`: its whole line(s) when
911/// nothing else shares them, otherwise the statement and the space after it.
912fn statement_deletion(source: &str, stmt: Span) -> (u32, u32) {
913    let (start, end) = (stmt.start as usize, stmt.end as usize);
914    let line_start = source[..start].rfind('\n').map_or(0, |i| i + 1);
915    let rest_of_line = source[end..].find('\n').map_or(source.len(), |i| end + i);
916    if source[line_start..start].trim().is_empty() && source[end..rest_of_line].trim().is_empty() {
917        let line_end = (rest_of_line + 1).min(source.len());
918        return (line_start as u32, line_end as u32);
919    }
920    let trailing = source[end..rest_of_line].len() - source[end..rest_of_line].trim_start().len();
921    (start as u32, (end + trailing) as u32)
922}