1#[derive(Debug, PartialEq, Eq)]
2pub enum Reference {
3 Template(String),
5 Module(String),
7 Resource { key: String, bundle: String },
9 Route {
12 controller: Option<String>,
13 name: String,
14 },
15}
16
17pub 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 start: usize,
44}
45
46fn 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 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
98fn 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
133fn 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}