Skip to main content

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}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
124        Range {
125            start: Position {
126                line: sl,
127                character: sc,
128            },
129            end: Position {
130                line: el,
131                character: ec,
132            },
133        }
134    }
135
136    #[test]
137    fn finds_variables_in_range() {
138        let src = "<?php\n$foo = 1;\n$bar = 2;\n";
139        let vals = inline_values_in_range(src, range(1, 0, 2, 99));
140        assert_eq!(vals.len(), 2);
141        if let InlineValue::VariableLookup(v) = &vals[0] {
142            assert_eq!(v.variable_name.as_deref(), Some("foo"));
143            assert_eq!(v.range.start.line, 1);
144        } else {
145            panic!("expected VariableLookup");
146        }
147    }
148
149    #[test]
150    fn skips_this() {
151        let src = "<?php\n$this->foo = $bar;";
152        let vals = inline_values_in_range(src, range(1, 0, 1, 99));
153        assert_eq!(vals.len(), 1);
154        if let InlineValue::VariableLookup(v) = &vals[0] {
155            assert_eq!(v.variable_name.as_deref(), Some("bar"));
156        }
157    }
158
159    #[test]
160    fn excludes_lines_outside_range() {
161        let src = "<?php\n$x = 1;\n$y = 2;\n$z = 3;\n";
162        let vals = inline_values_in_range(src, range(2, 0, 2, 99));
163        assert_eq!(vals.len(), 1);
164        if let InlineValue::VariableLookup(v) = &vals[0] {
165            assert_eq!(v.variable_name.as_deref(), Some("y"));
166        }
167    }
168
169    #[test]
170    fn skips_variable_variables() {
171        let src = "<?php\n$$dynamic = 1;";
172        let vals = inline_values_in_range(src, range(1, 0, 1, 99));
173        assert!(vals.is_empty(), "variable-variables should be skipped");
174    }
175}