Skip to main content

isml_lsp/
reference.rs

1#[derive(Debug, PartialEq, Eq)]
2pub enum Reference {
3    /// `<isinclude template="account/dashboard"/>`
4    Template(String),
5    /// `require('*/cartridge/scripts/helpers/brandHelper')`
6    Module(String),
7    /// `Resource.msg('label.profile.firstname', 'account', null)`
8    Resource { key: String, bundle: String },
9    /// `server.append('Show', ...)` or a full endpoint like `URLUtils.url('Account-Show')`;
10    /// only the latter names the controller, otherwise it is the file being edited.
11    Route {
12        controller: Option<String>,
13        name: String,
14    },
15}
16
17/// `column` is a character offset into `line`.
18pub fn at_cursor(line: &str, column: usize) -> Option<Reference> {
19    let chars: Vec<char> = line.chars().collect();
20    let column = column.min(chars.len());
21
22    let literal = string_literal_at(&chars, column)?;
23    let before = chars[..literal.start].iter().collect::<String>();
24
25    if let Some(reference) = resource_call(&chars, column) {
26        return Some(reference);
27    }
28    if ends_with_call(&before, "require") {
29        return Some(Reference::Module(literal.text));
30    }
31    if let Some(route) = route_at(&before, &literal.text) {
32        return Some(route);
33    }
34    if is_template_attribute(&before) {
35        return Some(Reference::Template(literal.text));
36    }
37    None
38}
39
40struct StringLiteral {
41    text: String,
42    /// Index of the opening quote.
43    start: usize,
44}
45
46/// The quoted literal containing `column`, scanning the line from the left so
47/// that quotes nested inside an outer attribute value resolve to the inner one.
48fn string_literal_at(chars: &[char], column: usize) -> Option<StringLiteral> {
49    let mut index = 0;
50    while index < chars.len() {
51        let quote = chars[index];
52        if quote != '\'' && quote != '"' {
53            index += 1;
54            continue;
55        }
56        let content_start = index + 1;
57        let mut end = content_start;
58        while end < chars.len() && chars[end] != quote {
59            end += 1;
60        }
61        if end >= chars.len() {
62            return None;
63        }
64        if (content_start..=end).contains(&column) {
65            // `value="${require('x')}"` — prefer the inner literal the cursor
66            // is actually on over the attribute value wrapping it.
67            if let Some(nested) =
68                string_literal_at(&chars[content_start..end], column - content_start)
69            {
70                return Some(StringLiteral {
71                    text: nested.text,
72                    start: content_start + nested.start,
73                });
74            }
75            return Some(StringLiteral {
76                text: chars[content_start..end].iter().collect(),
77                start: index,
78            });
79        }
80        index = end + 1;
81    }
82    None
83}
84
85fn ends_with_call(before: &str, name: &str) -> bool {
86    let trimmed = before.trim_end();
87    let Some(head) = trimmed.strip_suffix('(') else {
88        return false;
89    };
90    let head = head.trim_end();
91    if !head.ends_with(name) {
92        return false;
93    }
94    let preceding = head[..head.len() - name.len()].chars().next_back();
95    !matches!(preceding, Some(c) if c.is_alphanumeric() || c == '_' || c == '$')
96}
97
98/// A route is either the first argument of a `server.<verb>(` call, or an
99/// endpoint written out in full anywhere at all.
100fn route_at(before: &str, literal: &str) -> Option<Reference> {
101    const VERBS: [&str; 6] = ["get", "post", "use", "append", "prepend", "replace"];
102    if VERBS
103        .iter()
104        .any(|verb| ends_with_call(before, &format!("server.{verb}")))
105        && is_route_name(literal)
106    {
107        return Some(Reference::Route {
108            controller: None,
109            name: literal.to_string(),
110        });
111    }
112    let (controller, name) = literal.split_once('-')?;
113    (is_route_name(controller) && is_route_name(name)).then(|| Reference::Route {
114        controller: Some(controller.to_string()),
115        name: name.to_string(),
116    })
117}
118
119fn is_route_name(value: &str) -> bool {
120    !value.is_empty()
121        && value.starts_with(|c: char| c.is_ascii_uppercase())
122        && value.chars().all(|c| c.is_alphanumeric() || c == '_')
123}
124
125fn is_template_attribute(before: &str) -> bool {
126    let trimmed = before.trim_end();
127    let Some(head) = trimmed.strip_suffix('=') else {
128        return false;
129    };
130    head.trim_end().to_ascii_lowercase().ends_with("template")
131}
132
133/// `Resource.msg('key', 'bundle', null)`, `Resource.msgf(...)` and the Lit
134/// client equivalent `i18nMessage('key', 'bundle')`. Either argument resolves
135/// to the same place, so the whole call is matched rather than one literal.
136fn resource_call(chars: &[char], column: usize) -> Option<Reference> {
137    let line: String = chars.iter().collect();
138    for open in call_openings(&line) {
139        if column < open {
140            continue;
141        }
142        let close = matching_paren(chars, open)?;
143        if column > close {
144            continue;
145        }
146        let args = string_arguments(&chars[open + 1..close]);
147        if args.len() < 2 {
148            continue;
149        }
150        return Some(Reference::Resource {
151            key: args[0].clone(),
152            bundle: args[1].clone(),
153        });
154    }
155    None
156}
157
158fn call_openings(line: &str) -> Vec<usize> {
159    const NAMES: [&str; 3] = ["Resource.msgf", "Resource.msg", "i18nMessage"];
160    let chars: Vec<char> = line.chars().collect();
161    let mut openings = Vec::new();
162    for (index, c) in chars.iter().enumerate() {
163        if *c != '(' {
164            continue;
165        }
166        let head: String = chars[..index].iter().collect::<String>().trim_end().into();
167        if NAMES.iter().any(|name| head.ends_with(name)) {
168            openings.push(index);
169        }
170    }
171    openings
172}
173
174fn matching_paren(chars: &[char], open: usize) -> Option<usize> {
175    let mut depth = 0;
176    let mut index = open;
177    while index < chars.len() {
178        match chars[index] {
179            '(' => depth += 1,
180            ')' => {
181                depth -= 1;
182                if depth == 0 {
183                    return Some(index);
184                }
185            }
186            '\'' | '"' => {
187                let quote = chars[index];
188                index += 1;
189                while index < chars.len() && chars[index] != quote {
190                    index += 1;
191                }
192            }
193            _ => {}
194        }
195        index += 1;
196    }
197    None
198}
199
200fn string_arguments(chars: &[char]) -> Vec<String> {
201    let mut arguments = Vec::new();
202    let mut index = 0;
203    while index < chars.len() {
204        let quote = chars[index];
205        if quote == '\'' || quote == '"' {
206            let start = index + 1;
207            let mut end = start;
208            while end < chars.len() && chars[end] != quote {
209                end += 1;
210            }
211            arguments.push(chars[start..end.min(chars.len())].iter().collect());
212            index = end + 1;
213        } else {
214            index += 1;
215        }
216    }
217    arguments
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn column_of(line: &str, needle: &str) -> usize {
225        line.find(needle).unwrap() + 1
226    }
227
228    #[test]
229    fn finds_a_template_attribute() {
230        let line = r#"    <isinclude template="account/dashboard" />"#;
231        assert_eq!(
232            at_cursor(line, column_of(line, "account/dashboard")),
233            Some(Reference::Template("account/dashboard".into()))
234        );
235    }
236
237    #[test]
238    fn finds_a_require_path() {
239        let line = r#"var helper = require('*/cartridge/scripts/helpers/brandHelper');"#;
240        assert_eq!(
241            at_cursor(line, column_of(line, "*/cartridge")),
242            Some(Reference::Module(
243                "*/cartridge/scripts/helpers/brandHelper".into()
244            ))
245        );
246    }
247
248    #[test]
249    fn finds_a_require_inside_an_isml_expression() {
250        let line =
251            r#"<isset name="a" value="${require('~/cartridge/scripts/x').y}" scope="page"/>"#;
252        assert_eq!(
253            at_cursor(line, column_of(line, "~/cartridge")),
254            Some(Reference::Module("~/cartridge/scripts/x".into()))
255        );
256    }
257
258    #[test]
259    fn finds_a_resource_key_from_either_argument() {
260        let line = r#"<isprint value="${Resource.msg('label.x.y', 'account', null)}"/>"#;
261        let expected = Reference::Resource {
262            key: "label.x.y".into(),
263            bundle: "account".into(),
264        };
265        assert_eq!(
266            at_cursor(line, column_of(line, "label.x.y")),
267            Some(expected)
268        );
269        assert!(matches!(
270            at_cursor(line, column_of(line, "account")),
271            Some(Reference::Resource { .. })
272        ));
273    }
274
275    #[test]
276    fn finds_a_route_in_a_server_declaration() {
277        let line = "server.append('Show', function (req, res, next) {});";
278        assert_eq!(
279            at_cursor(line, column_of(line, "Show")),
280            Some(Reference::Route {
281                controller: None,
282                name: "Show".into()
283            })
284        );
285    }
286
287    #[test]
288    fn finds_an_endpoint_written_out_in_full() {
289        let line = "var url = URLUtils.url('Account-Show').toString();";
290        assert_eq!(
291            at_cursor(line, column_of(line, "Account-Show")),
292            Some(Reference::Route {
293                controller: Some("Account".into()),
294                name: "Show".into()
295            })
296        );
297    }
298
299    #[test]
300    fn ignores_a_hyphenated_string_that_is_not_an_endpoint() {
301        let line = r#"<div class="product-tile"></div>"#;
302        assert_eq!(at_cursor(line, column_of(line, "product-tile")), None);
303    }
304
305    #[test]
306    fn ignores_an_unrelated_string() {
307        let line = r#"<div class="product-tile"></div>"#;
308        assert_eq!(at_cursor(line, column_of(line, "product-tile")), None);
309    }
310
311    #[test]
312    fn ignores_a_lookalike_identifier_before_the_parenthesis() {
313        let line = r#"var x = myrequire('./a');"#;
314        assert_eq!(at_cursor(line, column_of(line, "./a")), None);
315    }
316}