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 line.
3///
4/// Only acts when:
5/// - The cursor/selection is on or inside a variable name (e.g. `$extracted`).
6/// - There is exactly one visible assignment `$var = <expr>;` on a single line
7///   earlier in the same scope.
8/// - The RHS is a single-line expression (multi-line RHS is not supported).
9use std::collections::HashMap;
10
11use tower_lsp_server::ls_types::{
12    CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Uri, WorkspaceEdit,
13};
14
15use crate::text::word_at_position;
16
17pub fn inline_variable_actions(source: &str, range: Range, uri: &Uri) -> Vec<CodeActionOrCommand> {
18    // Determine the variable name under cursor (or at start of selection).
19    let cursor = range.start;
20    let var_name = match word_at_position(source, cursor) {
21        Some(w) if w.starts_with('$') => w,
22        _ => return vec![],
23    };
24
25    // Require exactly one visible assignment in the file. Multiple writes
26    // make inlining ambiguous (which RHS?) and unsafe (we'd silently drop
27    // one), so we refuse rather than guess.
28    let (assign_line_no, rhs) = match find_unique_assignment(source, &var_name, cursor.line) {
29        Some(v) => v,
30        None => return vec![],
31    };
32
33    // Collect all usages of `$var` in the source below the assignment line.
34    let usages = collect_usages(source, &var_name, assign_line_no + 1);
35    if usages.is_empty() {
36        return vec![];
37    }
38
39    // Build edits: replace each usage with the RHS, then delete the assignment line.
40    let mut edits: Vec<TextEdit> = usages
41        .into_iter()
42        .map(|usage_range| TextEdit {
43            range: usage_range,
44            new_text: rhs.clone(),
45        })
46        .collect();
47
48    // Delete the assignment line (including its newline).
49    edits.push(TextEdit {
50        range: Range {
51            start: Position {
52                line: assign_line_no,
53                character: 0,
54            },
55            end: Position {
56                line: assign_line_no + 1,
57                character: 0,
58            },
59        },
60        new_text: String::new(),
61    });
62
63    let mut changes = HashMap::new();
64    changes.insert(uri.clone(), edits);
65
66    vec![CodeActionOrCommand::CodeAction(CodeAction {
67        title: format!("Inline variable '{var_name}'"),
68        kind: Some(CodeActionKind::REFACTOR_INLINE),
69        edit: Some(WorkspaceEdit {
70            changes: Some(changes),
71            ..Default::default()
72        }),
73        ..Default::default()
74    })]
75}
76
77/// Find the single `$var = <expr>;` assignment in `source`. Returns
78/// `(line_number, rhs_text)` only if exactly one such line exists *and* it
79/// appears before `before_line` — any second write, before or after the
80/// cursor, disqualifies the inline. Compound assignments (`+=`, `-=`, …) and
81/// equality (`==`) are ignored.
82fn find_unique_assignment(source: &str, var_name: &str, before_line: u32) -> Option<(u32, String)> {
83    let lines: Vec<&str> = source.lines().collect();
84    let mut hit: Option<(u32, String)> = None;
85
86    for (i, line) in lines.iter().enumerate() {
87        let trimmed = line.trim();
88        let prefix = format!("{var_name} =");
89        let Some(rest) = trimmed.strip_prefix(prefix.as_str()) else {
90            continue;
91        };
92        // Reject `$var ==` (equality) — `strip_prefix("$var =")` matches both.
93        if rest.starts_with('=') {
94            continue;
95        }
96        // Cut at the statement's own terminating `;`, not just any trailing
97        // one — `$x = foo(); doSomethingElse();` must yield RHS `foo()`, not
98        // the whole rest of the line.
99        let Some((rhs_part, trailing)) = split_statement(rest) else {
100            continue;
101        };
102        let rhs = rhs_part.trim();
103        if rhs.is_empty() {
104            continue;
105        }
106        // A second statement on the same line can't be preserved by the
107        // whole-line deletion below — refuse rather than silently dropping it.
108        if !trailing.trim().is_empty() {
109            continue;
110        }
111        if hit.is_some() {
112            return None; // more than one write → ambiguous
113        }
114        hit = Some((i as u32, rhs.to_string()));
115    }
116
117    // The unique assignment must precede the cursor; otherwise usage collection
118    // (which only scans *below* the assignment) would miss the cursor's usage.
119    hit.filter(|(line_no, _)| *line_no < before_line)
120}
121
122/// Split `rest` (the source text after `$var =`) at its statement-terminating
123/// `;` — the first one appearing outside of any paren/bracket/brace nesting
124/// and outside of a string literal — returning `(rhs_expr, after_semicolon)`.
125/// `None` if no such `;` is found (malformed/incomplete line).
126fn split_statement(rest: &str) -> Option<(&str, &str)> {
127    let bytes = rest.as_bytes();
128    let mut depth: i32 = 0;
129    let mut i = 0usize;
130    while i < bytes.len() {
131        match bytes[i] {
132            b'(' | b'[' | b'{' => depth += 1,
133            b')' | b']' | b'}' => depth -= 1,
134            b'\'' | b'"' => {
135                let quote = bytes[i];
136                i += 1;
137                while i < bytes.len() {
138                    if bytes[i] == b'\\' {
139                        i += 2;
140                        continue;
141                    }
142                    if bytes[i] == quote {
143                        break;
144                    }
145                    i += 1;
146                }
147            }
148            b';' if depth == 0 => return Some((&rest[..i], &rest[i + 1..])),
149            _ => {}
150        }
151        i += 1;
152    }
153    None
154}
155
156/// Find all occurrences of `$var` in `source` at or after `from_line`.
157/// Returns LSP `Range`s covering each occurrence.
158fn collect_usages(source: &str, var_name: &str, from_line: u32) -> Vec<Range> {
159    let mut usages = Vec::new();
160    for (line_idx, line) in source.lines().enumerate() {
161        if (line_idx as u32) < from_line {
162            continue;
163        }
164        let mut search_from = 0usize;
165        while let Some(pos) = line[search_from..].find(var_name) {
166            let abs = search_from + pos;
167            // Word-boundary check: character before must not be alphanumeric/$/_
168            let before_ok = abs == 0
169                || line
170                    .as_bytes()
171                    .get(abs - 1)
172                    .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_');
173            // Character after must not be alphanumeric/_
174            let after_ok = line
175                .as_bytes()
176                .get(abs + var_name.len())
177                .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_');
178
179            if before_ok && after_ok {
180                // Skip if this looks like an assignment target: `$var =`
181                let after_var = line[abs + var_name.len()..].trim_start();
182                if after_var.starts_with('=') && !after_var.starts_with("==") {
183                    search_from = abs + var_name.len();
184                    continue;
185                }
186
187                let char_start = byte_col_to_utf16_col(line, abs);
188                let char_end = byte_col_to_utf16_col(line, abs + var_name.len());
189                usages.push(Range {
190                    start: Position {
191                        line: line_idx as u32,
192                        character: char_start as u32,
193                    },
194                    end: Position {
195                        line: line_idx as u32,
196                        character: char_end as u32,
197                    },
198                });
199            }
200            search_from = abs + 1;
201        }
202    }
203    usages
204}
205
206fn byte_col_to_utf16_col(line: &str, byte_col: usize) -> usize {
207    line[..byte_col.min(line.len())]
208        .chars()
209        .map(|c| c.len_utf16())
210        .sum()
211}