1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
use tower_lsp::lsp_types::*;
/// Return `InlineValueVariableLookup` entries for every `$variable` occurrence
/// within `range` in `source`.
///
/// The debug adapter uses these to look up live variable values from the
/// runtime when execution is paused at a breakpoint. We return every PHP
/// variable reference visible in the viewport so the adapter can fill them all
/// in without the language server needing debugger integration.
pub fn inline_values_in_range(source: &str, range: Range) -> Vec<InlineValue> {
let mut result = Vec::new();
// First-character predicate matches PHP's `[a-zA-Z_\x80-\xff]` — extended
// here to any Unicode alphabetic char so multi-byte identifiers (PHP
// sources are usually UTF-8 in practice) are scanned correctly.
let is_ident_start = |c: char| c.is_alphabetic() || c == '_';
let is_ident_cont = |c: char| c.is_alphanumeric() || c == '_';
for (line_idx, line) in source.lines().enumerate() {
let line_num = line_idx as u32;
if line_num < range.start.line || line_num > range.end.line {
continue;
}
// Per the LSP spec, the request is a Range — column boundaries on
// the first and last line must be respected. Mid-range lines are
// covered in full. Columns are UTF-16 code units.
let line_min_col: Option<u32> =
(line_num == range.start.line).then_some(range.start.character);
let line_max_col: Option<u32> = (line_num == range.end.line).then_some(range.end.character);
// Walk per-character so columns track UTF-16 code units correctly
// even when the source contains multi-byte characters.
let chars: Vec<(u32, char)> = {
let mut out = Vec::with_capacity(line.len());
let mut col: u32 = 0;
for ch in line.chars() {
out.push((col, ch));
col += ch.len_utf16() as u32;
}
out
};
let mut i = 0usize;
while i < chars.len() {
if chars[i].1 != '$' {
i += 1;
continue;
}
// Skip `$$` (variable variables) — too dynamic to be useful.
if chars.get(i + 1).map(|(_, c)| *c) == Some('$') {
i += 2;
continue;
}
let dollar_col = chars[i].0;
i += 1;
// Need at least one identifier-start character after the `$`.
let Some(&(_, first)) = chars.get(i) else {
continue;
};
if !is_ident_start(first) {
continue;
}
let name_start_idx = i;
while i < chars.len() && is_ident_cont(chars[i].1) {
i += 1;
}
let name_end_idx = i;
let var_name: String = chars[name_start_idx..name_end_idx]
.iter()
.map(|(_, c)| *c)
.collect();
// Omit `$this` — every method has it and it adds noise without value.
if var_name == "this" {
continue;
}
let end_col = chars.get(name_end_idx).map(|(c, _)| *c).unwrap_or_else(|| {
chars
.last()
.map(|(c, ch)| c + ch.len_utf16() as u32)
.unwrap_or(0)
});
// Skip occurrences that fall outside the requested range's
// column boundaries on the start/end lines.
if let Some(min) = line_min_col
&& dollar_col < min
{
continue;
}
if let Some(max) = line_max_col
&& end_col > max
{
continue;
}
result.push(InlineValue::VariableLookup(InlineValueVariableLookup {
range: Range {
start: Position {
line: line_num,
character: dollar_col,
},
end: Position {
line: line_num,
character: end_col,
},
},
// Provide the name without '$' so the DAP adapter can look it up
// by name in the current stack frame.
variable_name: Some(var_name),
case_sensitive_lookup: true,
}));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
Range {
start: Position {
line: sl,
character: sc,
},
end: Position {
line: el,
character: ec,
},
}
}
#[test]
fn finds_variables_in_range() {
let src = "<?php\n$foo = 1;\n$bar = 2;\n";
let vals = inline_values_in_range(src, range(1, 0, 2, 99));
assert_eq!(vals.len(), 2);
if let InlineValue::VariableLookup(v) = &vals[0] {
assert_eq!(v.variable_name.as_deref(), Some("foo"));
assert_eq!(v.range.start.line, 1);
} else {
panic!("expected VariableLookup");
}
}
#[test]
fn skips_this() {
let src = "<?php\n$this->foo = $bar;";
let vals = inline_values_in_range(src, range(1, 0, 1, 99));
assert_eq!(vals.len(), 1);
if let InlineValue::VariableLookup(v) = &vals[0] {
assert_eq!(v.variable_name.as_deref(), Some("bar"));
}
}
#[test]
fn excludes_lines_outside_range() {
let src = "<?php\n$x = 1;\n$y = 2;\n$z = 3;\n";
let vals = inline_values_in_range(src, range(2, 0, 2, 99));
assert_eq!(vals.len(), 1);
if let InlineValue::VariableLookup(v) = &vals[0] {
assert_eq!(v.variable_name.as_deref(), Some("y"));
}
}
#[test]
fn skips_variable_variables() {
let src = "<?php\n$$dynamic = 1;";
let vals = inline_values_in_range(src, range(1, 0, 1, 99));
assert!(vals.is_empty(), "variable-variables should be skipped");
}
}