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::lsp_types::{
12    CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Url, WorkspaceEdit,
13};
14
15use crate::text::word_at_position;
16
17pub fn inline_variable_actions(source: &str, range: Range, uri: &Url) -> 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        let rhs = rest.trim().trim_end_matches(';').trim();
97        if rhs.is_empty() {
98            continue;
99        }
100        if hit.is_some() {
101            return None; // more than one write → ambiguous
102        }
103        hit = Some((i as u32, rhs.to_string()));
104    }
105
106    // The unique assignment must precede the cursor; otherwise usage collection
107    // (which only scans *below* the assignment) would miss the cursor's usage.
108    hit.filter(|(line_no, _)| *line_no < before_line)
109}
110
111/// Find all occurrences of `$var` in `source` at or after `from_line`.
112/// Returns LSP `Range`s covering each occurrence.
113fn collect_usages(source: &str, var_name: &str, from_line: u32) -> Vec<Range> {
114    let mut usages = Vec::new();
115    for (line_idx, line) in source.lines().enumerate() {
116        if (line_idx as u32) < from_line {
117            continue;
118        }
119        let mut search_from = 0usize;
120        while let Some(pos) = line[search_from..].find(var_name) {
121            let abs = search_from + pos;
122            // Word-boundary check: character before must not be alphanumeric/$/_
123            let before_ok = abs == 0
124                || line
125                    .as_bytes()
126                    .get(abs - 1)
127                    .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_');
128            // Character after must not be alphanumeric/_
129            let after_ok = line
130                .as_bytes()
131                .get(abs + var_name.len())
132                .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_');
133
134            if before_ok && after_ok {
135                // Skip if this looks like an assignment target: `$var =`
136                let after_var = line[abs + var_name.len()..].trim_start();
137                if after_var.starts_with('=') && !after_var.starts_with("==") {
138                    search_from = abs + var_name.len();
139                    continue;
140                }
141
142                let char_start = byte_col_to_utf16_col(line, abs);
143                let char_end = byte_col_to_utf16_col(line, abs + var_name.len());
144                usages.push(Range {
145                    start: Position {
146                        line: line_idx as u32,
147                        character: char_start as u32,
148                    },
149                    end: Position {
150                        line: line_idx as u32,
151                        character: char_end as u32,
152                    },
153                });
154            }
155            search_from = abs + 1;
156        }
157    }
158    usages
159}
160
161fn byte_col_to_utf16_col(line: &str, byte_col: usize) -> usize {
162    line[..byte_col.min(line.len())]
163        .chars()
164        .map(|c| c.len_utf16())
165        .sum()
166}