php_lsp/actions/
inline_action.rs1use 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 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 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 let usages = collect_usages(source, &var_name, assign_line_no + 1);
35 if usages.is_empty() {
36 return vec![];
37 }
38
39 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 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
77fn 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 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; }
103 hit = Some((i as u32, rhs.to_string()));
104 }
105
106 hit.filter(|(line_no, _)| *line_no < before_line)
109}
110
111fn 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 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 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 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}