php_lsp/analysis/inline_value.rs
1use tower_lsp::lsp_types::*;
2
3/// Return `InlineValueVariableLookup` entries for every `$variable` occurrence
4/// within `range` in `source`.
5///
6/// The debug adapter uses these to look up live variable values from the
7/// runtime when execution is paused at a breakpoint. We return every PHP
8/// variable reference visible in the viewport so the adapter can fill them all
9/// in without the language server needing debugger integration.
10pub fn inline_values_in_range(source: &str, range: Range) -> Vec<InlineValue> {
11 let mut result = Vec::new();
12
13 // First-character predicate matches PHP's `[a-zA-Z_\x80-\xff]` — extended
14 // here to any Unicode alphabetic char so multi-byte identifiers (PHP
15 // sources are usually UTF-8 in practice) are scanned correctly.
16 let is_ident_start = |c: char| c.is_alphabetic() || c == '_';
17 let is_ident_cont = |c: char| c.is_alphanumeric() || c == '_';
18
19 for (line_idx, line) in source.lines().enumerate() {
20 let line_num = line_idx as u32;
21 if line_num < range.start.line || line_num > range.end.line {
22 continue;
23 }
24 // Per the LSP spec, the request is a Range — column boundaries on
25 // the first and last line must be respected. Mid-range lines are
26 // covered in full. Columns are UTF-16 code units.
27 let line_min_col: Option<u32> =
28 (line_num == range.start.line).then_some(range.start.character);
29 let line_max_col: Option<u32> = (line_num == range.end.line).then_some(range.end.character);
30
31 // Walk per-character so columns track UTF-16 code units correctly
32 // even when the source contains multi-byte characters.
33 let chars: Vec<(u32, char)> = {
34 let mut out = Vec::with_capacity(line.len());
35 let mut col: u32 = 0;
36 for ch in line.chars() {
37 out.push((col, ch));
38 col += ch.len_utf16() as u32;
39 }
40 out
41 };
42
43 let mut i = 0usize;
44 while i < chars.len() {
45 if chars[i].1 != '$' {
46 i += 1;
47 continue;
48 }
49 // Skip `$$` (variable variables) — too dynamic to be useful.
50 if chars.get(i + 1).map(|(_, c)| *c) == Some('$') {
51 i += 2;
52 continue;
53 }
54 let dollar_col = chars[i].0;
55 i += 1;
56 // Need at least one identifier-start character after the `$`.
57 let Some(&(_, first)) = chars.get(i) else {
58 continue;
59 };
60 if !is_ident_start(first) {
61 continue;
62 }
63 let name_start_idx = i;
64 while i < chars.len() && is_ident_cont(chars[i].1) {
65 i += 1;
66 }
67 let name_end_idx = i;
68 let var_name: String = chars[name_start_idx..name_end_idx]
69 .iter()
70 .map(|(_, c)| *c)
71 .collect();
72
73 // Omit `$this` — every method has it and it adds noise without value.
74 if var_name == "this" {
75 continue;
76 }
77
78 let end_col = chars.get(name_end_idx).map(|(c, _)| *c).unwrap_or_else(|| {
79 chars
80 .last()
81 .map(|(c, ch)| c + ch.len_utf16() as u32)
82 .unwrap_or(0)
83 });
84
85 // Skip occurrences that fall outside the requested range's
86 // column boundaries on the start/end lines.
87 if let Some(min) = line_min_col
88 && dollar_col < min
89 {
90 continue;
91 }
92 if let Some(max) = line_max_col
93 && end_col > max
94 {
95 continue;
96 }
97 result.push(InlineValue::VariableLookup(InlineValueVariableLookup {
98 range: Range {
99 start: Position {
100 line: line_num,
101 character: dollar_col,
102 },
103 end: Position {
104 line: line_num,
105 character: end_col,
106 },
107 },
108 // Provide the name without '$' so the DAP adapter can look it up
109 // by name in the current stack frame.
110 variable_name: Some(var_name),
111 case_sensitive_lookup: true,
112 }));
113 }
114 }
115
116 result
117}