Skip to main content

php_lsp/hover/
parsing.rs

1use php_ast::{NamespaceBody, Stmt, StmtKind, UseKind};
2
3/// Extract the receiver variable from immediately before `->word` or `?->word`
4/// at the cursor's exact column position.  Uses the column rather than
5/// `str::find()` so multiple method calls on the same line are handled
6/// correctly.
7pub fn extract_receiver_var_before_cursor(line: &str, cursor_col_utf16: usize) -> Option<String> {
8    let chars: Vec<char> = line.chars().collect();
9
10    // Convert UTF-16 cursor column to char index.
11    let mut utf16 = 0usize;
12    let mut char_idx = 0usize;
13    for ch in &chars {
14        if utf16 >= cursor_col_utf16 {
15            break;
16        }
17        utf16 += ch.len_utf16();
18        char_idx += 1;
19    }
20
21    // Find the start of the word under the cursor (expand left).
22    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
23    let mut word_start = char_idx;
24    while word_start > 0 && is_word_char(chars[word_start - 1]) {
25        word_start -= 1;
26    }
27
28    // Check for `?->` (3 chars) or `->` (2 chars) immediately before word_start.
29    let (is_arrow, arrow_end) = if word_start >= 3
30        && chars[word_start - 3] == '?'
31        && chars[word_start - 2] == '-'
32        && chars[word_start - 1] == '>'
33    {
34        (true, word_start - 3)
35    } else if word_start >= 2 && chars[word_start - 2] == '-' && chars[word_start - 1] == '>' {
36        (true, word_start - 2)
37    } else {
38        (false, 0)
39    };
40
41    if !is_arrow {
42        return None;
43    }
44
45    extract_name_from_chars_end(&chars[..arrow_end])
46}
47
48/// Extract the class name from immediately before `::` at the cursor's column.
49pub(crate) fn extract_static_class_before_cursor(
50    line: &str,
51    cursor_col_utf16: usize,
52) -> Option<String> {
53    let chars: Vec<char> = line.chars().collect();
54
55    let mut utf16 = 0usize;
56    let mut char_idx = 0usize;
57    for ch in &chars {
58        if utf16 >= cursor_col_utf16 {
59            break;
60        }
61        utf16 += ch.len_utf16();
62        char_idx += 1;
63    }
64
65    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
66    let mut word_start = char_idx;
67    while word_start > 0 && is_word_char(chars[word_start - 1]) {
68        word_start -= 1;
69    }
70
71    // For `Class::$prop`, skip the `$` before checking for `::`
72    if word_start > 0 && chars[word_start - 1] == '$' {
73        word_start -= 1;
74    }
75
76    if word_start < 2 || chars[word_start - 2] != ':' || chars[word_start - 1] != ':' {
77        return None;
78    }
79
80    let before_colons = &chars[..word_start - 2];
81    // Class name may contain `\` for FQN; extract the short name (last segment).
82    let is_name_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
83    let end = before_colons.len().saturating_sub(
84        before_colons
85            .iter()
86            .rev()
87            .take_while(|&&c| c == ' ' || c == '\t')
88            .count(),
89    );
90    let mut start = end;
91    while start > 0 && is_name_char(before_colons[start - 1]) {
92        start -= 1;
93    }
94    if start == end {
95        return None;
96    }
97    let full: String = before_colons[start..end].iter().collect();
98    // Return only the last segment so callers get a short name.
99    Some(full.rsplit('\\').next().unwrap_or(&full).to_owned())
100}
101
102/// Walk backwards through `chars`, skipping whitespace, and return the
103/// identifier (with `$` prefix if present) ending at the last non-space char.
104pub(crate) fn extract_name_from_chars_end(chars: &[char]) -> Option<String> {
105    let is_var_char = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
106    let end = chars.len()
107        - chars
108            .iter()
109            .rev()
110            .take_while(|&&c| c == ' ' || c == '\t')
111            .count();
112    if end == 0 {
113        return None;
114    }
115    let mut start = end;
116    while start > 0 && is_var_char(chars[start - 1]) {
117        start -= 1;
118    }
119    if start == end {
120        return None;
121    }
122    let name: String = chars[start..end].iter().collect();
123    if name.starts_with('$') && name.len() > 1 {
124        Some(name)
125    } else if !name.is_empty() && !name.starts_with('$') {
126        // Plain identifier (e.g. `$obj->getUser()->name` — the inner result):
127        // treat as a non-variable receiver; callers handle the `$` lookup.
128        Some(format!("${}", name))
129    } else {
130        None
131    }
132}
133
134/// Resolve a use-import alias to the short class name.
135///
136/// Given `use App\Foo as Bar`, hovering on `Bar` anywhere in the file should
137/// resolve to `Foo` so the declaration lookup succeeds.
138pub fn resolve_use_alias(stmts: &[Stmt<'_, '_>], word: &str) -> Option<String> {
139    for stmt in stmts {
140        match &stmt.kind {
141            StmtKind::Use(u) if u.kind == UseKind::Normal => {
142                for item in u.uses.iter() {
143                    if let Some(alias) = item.alias
144                        && alias == word
145                    {
146                        let fqn = item.name.to_string_repr();
147                        let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref()).to_owned();
148                        return Some(short);
149                    }
150                }
151            }
152            StmtKind::Namespace(ns) => {
153                if let NamespaceBody::Braced(inner) = &ns.body
154                    && let Some(s) = resolve_use_alias(inner, word)
155                {
156                    return Some(s);
157                }
158            }
159            _ => {}
160        }
161    }
162    None
163}