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/// Extract the class name from immediately before `::` at the cursor's column.
51pub fn extract_static_class_before_cursor(line: &str, cursor_col_utf16: usize) -> Option<String> {
52    let chars: Vec<char> = line.chars().collect();
53
54    let mut utf16 = 0usize;
55    let mut char_idx = 0usize;
56    for ch in &chars {
57        if utf16 >= cursor_col_utf16 {
58            break;
59        }
60        utf16 += ch.len_utf16();
61        char_idx += 1;
62    }
63
64    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
65    let mut word_start = char_idx;
66    while word_start > 0 && is_word_char(chars[word_start - 1]) {
67        word_start -= 1;
68    }
69
70    // For `Class::$prop`, skip the `$` before checking for `::`
71    if word_start > 0 && chars[word_start - 1] == '$' {
72        word_start -= 1;
73    }
74
75    if word_start < 2 || chars[word_start - 2] != ':' || chars[word_start - 1] != ':' {
76        return None;
77    }
78
79    let before_colons = &chars[..word_start - 2];
80    // Class name may contain `\` for FQN; extract the short name (last segment).
81    let is_name_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
82    let end = before_colons.len().saturating_sub(
83        before_colons
84            .iter()
85            .rev()
86            .take_while(|&&c| c == ' ' || c == '\t')
87            .count(),
88    );
89    let mut start = end;
90    while start > 0 && is_name_char(before_colons[start - 1]) {
91        start -= 1;
92    }
93    if start == end {
94        return None;
95    }
96    let full: String = before_colons[start..end].iter().collect();
97    // Return only the last segment so callers get a short name.
98    Some(fqn_short_name(&full).to_owned())
99}
100
101/// Walk backwards through `chars`, skipping whitespace, and return the
102/// identifier (with `$` prefix if present) ending at the last non-space char.
103pub(crate) fn extract_name_from_chars_end(chars: &[char]) -> Option<String> {
104    let is_var_char = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
105    let end = chars.len()
106        - chars
107            .iter()
108            .rev()
109            .take_while(|&&c| c == ' ' || c == '\t')
110            .count();
111    if end == 0 {
112        return None;
113    }
114    let mut start = end;
115    while start > 0 && is_var_char(chars[start - 1]) {
116        start -= 1;
117    }
118    if start == end {
119        return None;
120    }
121    let name: String = chars[start..end].iter().collect();
122    if name.starts_with('$') && name.len() > 1 {
123        Some(name)
124    } else if !name.is_empty() && !name.starts_with('$') {
125        // Plain identifier (e.g. `$obj->getUser()->name` — the inner result):
126        // treat as a non-variable receiver; callers handle the `$` lookup.
127        Some(format!("${}", name))
128    } else {
129        None
130    }
131}
132
133/// Resolve a use-import alias to the short class name.
134///
135/// Given `use App\Foo as Bar`, hovering on `Bar` anywhere in the file should
136/// resolve to `Foo` so the declaration lookup succeeds.
137pub fn resolve_use_alias(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
138    resolve_use_alias_fqn(stmts, word).map(|(short, _)| short)
139}
140
141/// Like [`resolve_use_alias`], but also returns the full FQN the alias
142/// resolves to, so callers can disambiguate between same-named classes in
143/// different namespaces (e.g. many vendored `Factory` classes all locally
144/// aliased to `FactoryContract`) instead of matching on the short name alone.
145pub fn resolve_use_alias_fqn(stmts: &[Stmt<'_, '_>], word: &str) -> Option<(String, String)> {
146    for stmt in stmts {
147        match &stmt.kind {
148            StmtKind::Use(u) if u.kind == UseKind::Normal => {
149                for item in u.uses.iter() {
150                    if let Some(alias) = item.alias
151                        && alias == word
152                    {
153                        let fqn = item.name.to_string_repr().into_owned();
154                        let short = fqn_short_name(&fqn).to_owned();
155                        return Some((short, fqn));
156                    }
157                }
158            }
159            StmtKind::Namespace(ns) => {
160                if let NamespaceBody::Braced(inner) = &ns.body
161                    && let Some(s) = resolve_use_alias_fqn(&inner.stmts, word)
162                {
163                    return Some(s);
164                }
165            }
166            _ => {}
167        }
168    }
169    None
170}