Skip to main content

php_lsp/hover/
parsing.rs

1use php_ast::{NamespaceBody, Stmt, StmtKind, UseKind};
2
3use crate::text::fqn_short_name;
4
5/// Extract the receiver variable from immediately before `->word` or `?->word`
6/// at the cursor's exact column position.  Uses the column rather than
7/// `str::find()` so multiple method calls on the same line are handled
8/// correctly.
9pub fn extract_receiver_var_before_cursor(line: &str, cursor_col_utf16: usize) -> Option<String> {
10    let chars: Vec<char> = line.chars().collect();
11
12    // Convert UTF-16 cursor column to char index.
13    let mut utf16 = 0usize;
14    let mut char_idx = 0usize;
15    for ch in &chars {
16        if utf16 >= cursor_col_utf16 {
17            break;
18        }
19        utf16 += ch.len_utf16();
20        char_idx += 1;
21    }
22
23    // Find the start of the word under the cursor (expand left).
24    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
25    let mut word_start = char_idx;
26    while word_start > 0 && is_word_char(chars[word_start - 1]) {
27        word_start -= 1;
28    }
29
30    // Check for `?->` (3 chars) or `->` (2 chars) immediately before word_start.
31    let (is_arrow, arrow_end) = if word_start >= 3
32        && chars[word_start - 3] == '?'
33        && chars[word_start - 2] == '-'
34        && chars[word_start - 1] == '>'
35    {
36        (true, word_start - 3)
37    } else if word_start >= 2 && chars[word_start - 2] == '-' && chars[word_start - 1] == '>' {
38        (true, word_start - 2)
39    } else {
40        (false, 0)
41    };
42
43    if !is_arrow {
44        return None;
45    }
46
47    extract_name_from_chars_end(&chars[..arrow_end])
48}
49
50/// The cursor's member name follows `->`/`?->` on a receiver other than `$this`.
51pub fn is_arrow_access_on_non_this_receiver(line: &str, cursor_col_utf16: usize) -> bool {
52    let chars: Vec<char> = line.chars().collect();
53    let mut utf16 = 0usize;
54    let mut char_idx = 0usize;
55    for ch in &chars {
56        if utf16 >= cursor_col_utf16 {
57            break;
58        }
59        utf16 += ch.len_utf16();
60        char_idx += 1;
61    }
62    let mut word_start = char_idx;
63    while word_start > 0
64        && (chars[word_start - 1].is_alphanumeric() || chars[word_start - 1] == '_')
65    {
66        word_start -= 1;
67    }
68    let arrow_end = if word_start >= 3 && chars[word_start - 3..word_start] == ['?', '-', '>'] {
69        word_start - 3
70    } else if word_start >= 2 && chars[word_start - 2..word_start] == ['-', '>'] {
71        word_start - 2
72    } else {
73        return false;
74    };
75    let receiver: String = chars[..arrow_end].iter().collect();
76    !receiver.trim_end().ends_with("$this")
77}
78
79/// Extract the class name from immediately before `::` at the cursor's column.
80pub fn extract_static_class_before_cursor(line: &str, cursor_col_utf16: usize) -> Option<String> {
81    let chars: Vec<char> = line.chars().collect();
82
83    let mut utf16 = 0usize;
84    let mut char_idx = 0usize;
85    for ch in &chars {
86        if utf16 >= cursor_col_utf16 {
87            break;
88        }
89        utf16 += ch.len_utf16();
90        char_idx += 1;
91    }
92
93    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
94    let mut word_start = char_idx;
95    while word_start > 0 && is_word_char(chars[word_start - 1]) {
96        word_start -= 1;
97    }
98
99    // For `Class::$prop`, skip the `$` before checking for `::`
100    if word_start > 0 && chars[word_start - 1] == '$' {
101        word_start -= 1;
102    }
103
104    if word_start < 2 || chars[word_start - 2] != ':' || chars[word_start - 1] != ':' {
105        return None;
106    }
107
108    let before_colons = &chars[..word_start - 2];
109    // Class name may contain `\` for FQN; extract the short name (last segment).
110    let is_name_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
111    let end = before_colons.len().saturating_sub(
112        before_colons
113            .iter()
114            .rev()
115            .take_while(|&&c| c == ' ' || c == '\t')
116            .count(),
117    );
118    let mut start = end;
119    while start > 0 && is_name_char(before_colons[start - 1]) {
120        start -= 1;
121    }
122    if start == end {
123        return None;
124    }
125    let full: String = before_colons[start..end].iter().collect();
126    // Return only the last segment so callers get a short name.
127    Some(fqn_short_name(&full).to_owned())
128}
129
130/// Walk backwards through `chars`, skipping whitespace, and return the
131/// identifier (with `$` prefix if present) ending at the last non-space char.
132pub(crate) fn extract_name_from_chars_end(chars: &[char]) -> Option<String> {
133    let is_var_char = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
134    let end = chars.len()
135        - chars
136            .iter()
137            .rev()
138            .take_while(|&&c| c == ' ' || c == '\t')
139            .count();
140    if end == 0 {
141        return None;
142    }
143    let mut start = end;
144    while start > 0 && is_var_char(chars[start - 1]) {
145        start -= 1;
146    }
147    if start == end {
148        return None;
149    }
150    let name: String = chars[start..end].iter().collect();
151    if name.starts_with('$') && name.len() > 1 {
152        Some(name)
153    } else if !name.is_empty() && !name.starts_with('$') {
154        // Plain identifier (e.g. `$obj->getUser()->name` — the inner result):
155        // treat as a non-variable receiver; callers handle the `$` lookup.
156        Some(format!("${}", name))
157    } else {
158        None
159    }
160}
161
162/// Resolve a use-import alias to the short class name.
163///
164/// Given `use App\Foo as Bar`, hovering on `Bar` anywhere in the file should
165/// resolve to `Foo` so the declaration lookup succeeds.
166pub fn resolve_use_alias(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
167    resolve_use_alias_fqn(stmts, word).map(|(short, _)| short)
168}
169
170/// Like [`resolve_use_alias`], but also returns the full FQN the alias
171/// resolves to, so callers can disambiguate between same-named classes in
172/// different namespaces (e.g. many vendored `Factory` classes all locally
173/// aliased to `FactoryContract`) instead of matching on the short name alone.
174pub fn resolve_use_alias_fqn(stmts: &[Stmt<'_, '_>], word: &str) -> Option<(String, String)> {
175    for stmt in stmts {
176        match &stmt.kind {
177            StmtKind::Use(u) if u.kind == UseKind::Normal => {
178                for item in u.uses.iter() {
179                    if let Some(alias) = item.alias
180                        && alias == word
181                    {
182                        let fqn = item.name.to_string_repr().into_owned();
183                        let short = fqn_short_name(&fqn).to_owned();
184                        return Some((short, fqn));
185                    }
186                }
187            }
188            StmtKind::Namespace(ns) => {
189                if let NamespaceBody::Braced(inner) = &ns.body
190                    && let Some(s) = resolve_use_alias_fqn(&inner.stmts, word)
191                {
192                    return Some(s);
193                }
194            }
195            _ => {}
196        }
197    }
198    None
199}
200
201#[cfg(test)]
202mod tests {
203    use super::is_arrow_access_on_non_this_receiver as foreign;
204
205    #[test]
206    fn arrow_on_this_is_not_foreign() {
207        assert!(!foreign("$this->run()", 8));
208        assert!(!foreign("$this?->run()", 9));
209    }
210
211    #[test]
212    fn arrow_on_property_or_variable_is_foreign() {
213        assert!(foreign("$this->sut->run()", 13));
214        assert!(foreign("$obj->run()", 7));
215        assert!(foreign("$notthis->run()", 11));
216    }
217
218    #[test]
219    fn non_arrow_positions_are_not_foreign() {
220        assert!(!foreign("run()", 2));
221        assert!(!foreign("Foo::run()", 7));
222    }
223}