1use std::path::Path;
4
5use lsp_types::{
6 CompletionItem, CompletionItemKind, CompletionTextEdit, Documentation, InsertTextFormat,
7 MarkupContent, MarkupKind, Position, Range, TextEdit,
8};
9
10use crate::api;
11use crate::custom;
12use crate::isml::{self, Body};
13use crate::metadata::{Metadata, SITE_PREFERENCES};
14use crate::routes::Route;
15use crate::workspace::Workspace;
16
17const MAX_TEMPLATES: usize = 2000;
18
19#[derive(Debug, PartialEq, Eq)]
21pub enum Context {
22 TagName {
23 typed: usize,
24 },
25 AttributeName {
26 tag: String,
27 typed: usize,
28 },
29 AttributeValue {
30 tag: String,
31 attribute: String,
32 typed: usize,
33 },
34 CustomAttribute(custom::Pending),
36 SitePreference {
37 typed: usize,
38 },
39 RouteName {
41 typed: usize,
42 },
43 DwModule {
44 typed: usize,
45 },
46 DwImport {
49 typed: String,
51 },
52 DwDeclaration {
54 keyword: String,
55 typed: String,
56 },
57 DwMember {
58 class: String,
59 typed: usize,
60 },
61}
62
63pub fn context_at(text: &str, offset: usize, is_isml: bool) -> Option<Context> {
64 let chars: Vec<char> = text.chars().collect();
65 let offset = offset.min(chars.len());
66 let head: String = chars[..offset].iter().collect();
67
68 if let Some(pending) = custom::pending(&head) {
69 return Some(Context::CustomAttribute(pending));
70 }
71 if custom::is_pending_preference(&head) {
72 return Some(Context::SitePreference {
73 typed: typed_since_quote(&head),
74 });
75 }
76 if is_pending_route(&head) {
77 return Some(Context::RouteName {
78 typed: typed_since_quote(&head),
79 });
80 }
81 if is_pending_require(&head) {
82 return Some(Context::DwModule {
83 typed: typed_since_quote(&head),
84 });
85 }
86 if let Some(member) = pending_member(&head, text) {
87 return Some(member);
88 }
89 if let Some(declaration) = pending_declaration(&head) {
92 return Some(declaration);
93 }
94 if let Some(typed) = pending_import(&head) {
95 return Some(Context::DwImport { typed });
96 }
97 if is_isml {
98 return tag_context(&chars, offset);
99 }
100 None
101}
102
103fn tag_context(chars: &[char], offset: usize) -> Option<Context> {
104 let open = open_tag_start(chars, offset)?;
105 let inner: Vec<char> = chars[open + 1..offset].to_vec();
106 if inner.first().is_some_and(|c| *c == '/' || *c == '!') {
107 return None;
108 }
109
110 let Some(name_end) = inner.iter().position(|c| c.is_whitespace()) else {
111 return Some(Context::TagName { typed: inner.len() });
112 };
113 let tag: String = inner[..name_end].iter().collect();
114 if !tag.starts_with("is") {
115 return None;
116 }
117
118 let rest = &inner[name_end..];
119 match open_quote(rest) {
120 Some(quote) => Some(Context::AttributeValue {
121 tag,
122 attribute: attribute_before(&rest[..quote]),
123 typed: rest.len() - quote - 1,
124 }),
125 None => Some(Context::AttributeName {
126 tag,
127 typed: trailing_name_len(rest),
128 }),
129 }
130}
131
132fn open_tag_start(chars: &[char], offset: usize) -> Option<usize> {
133 let mut index = offset;
134 while index > 0 {
135 index -= 1;
136 match chars[index] {
137 '>' => return None,
138 '<' => return Some(index),
139 _ => {}
140 }
141 }
142 None
143}
144
145fn open_quote(chars: &[char]) -> Option<usize> {
147 let mut open: Option<usize> = None;
148 for (index, c) in chars.iter().enumerate() {
149 if *c != '\'' && *c != '"' {
150 continue;
151 }
152 match open {
153 Some(start) if chars[start] == *c => open = None,
154 Some(_) => {}
155 None => open = Some(index),
156 }
157 }
158 open
159}
160
161fn attribute_before(chars: &[char]) -> String {
162 let mut end = chars.len();
163 while end > 0 && (chars[end - 1].is_whitespace() || chars[end - 1] == '=') {
164 end -= 1;
165 }
166 let mut start = end;
167 while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '-') {
168 start -= 1;
169 }
170 chars[start..end].iter().collect()
171}
172
173fn trailing_name_len(chars: &[char]) -> usize {
174 let mut length = 0;
175 while length < chars.len() {
176 let c = chars[chars.len() - 1 - length];
177 if !c.is_alphanumeric() && c != '-' {
178 break;
179 }
180 length += 1;
181 }
182 length
183}
184
185fn is_pending_route(head: &str) -> bool {
186 const VERBS: [&str; 6] = ["get", "post", "use", "append", "prepend", "replace"];
187 let Some(quote) = head.rfind(['\'', '"']) else {
188 return false;
189 };
190 let before = head[..quote].trim_end();
191 let Some(call) = before.strip_suffix('(') else {
192 return false;
193 };
194 let call = call.trim_end();
195 VERBS
196 .iter()
197 .any(|verb| call.ends_with(&format!("server.{verb}")))
198}
199
200fn is_pending_require(head: &str) -> bool {
201 let Some(quote) = head.rfind(['\'', '"']) else {
202 return false;
203 };
204 let before = head[..quote].trim_end();
205 before
206 .strip_suffix('(')
207 .is_some_and(|call| call.trim_end().ends_with("require"))
208}
209
210fn pending_member(head: &str, text: &str) -> Option<Context> {
211 let typed: String = head
212 .chars()
213 .rev()
214 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
215 .collect();
216 let stem = &head[..head.len() - typed.len()];
217 let receiver: String = stem
218 .strip_suffix('.')?
219 .chars()
220 .rev()
221 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
222 .collect::<Vec<_>>()
223 .into_iter()
224 .rev()
225 .collect();
226 let class = api::bindings(text).get(&receiver)?.clone();
227 Some(Context::DwMember {
228 class,
229 typed: typed.chars().count(),
230 })
231}
232
233fn pending_declaration(head: &str) -> Option<Context> {
234 let line = head.rsplit('\n').next()?;
235 let typed = trailing_word(line);
236 let before = line[..line.len() - typed.len()].trim_end();
237 let keyword = ["var", "const", "let"]
238 .into_iter()
239 .find(|word| before.ends_with(word))?;
240 let head_of_line = before[..before.len() - keyword.len()].trim();
243 if !head_of_line.is_empty() {
244 return None;
245 }
246 Some(Context::DwDeclaration {
247 keyword: keyword.to_string(),
248 typed,
249 })
250}
251
252fn pending_import(head: &str) -> Option<String> {
254 let typed = trailing_word(head);
255 if typed.len() < 2 || !typed.to_ascii_lowercase().starts_with("dw") {
256 return None;
257 }
258 let before = &head[..head.len() - typed.len()];
260 match before.chars().next_back() {
261 Some('.') => None,
262 _ => Some(typed),
263 }
264}
265
266fn trailing_word(text: &str) -> String {
267 text.chars()
268 .rev()
269 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
270 .collect::<Vec<_>>()
271 .into_iter()
272 .rev()
273 .collect()
274}
275
276fn typed_since_quote(head: &str) -> usize {
277 match head.rfind(['\'', '"']) {
278 Some(quote) => head[quote + 1..].chars().count(),
279 None => 0,
280 }
281}
282
283pub struct Completer<'a> {
284 pub workspace: &'a Workspace,
285 pub metadata: &'a Metadata,
286 pub text: &'a str,
288 pub file: &'a Path,
290}
291
292impl Completer<'_> {
293 pub fn items(&self, context: &Context, cursor: Position) -> Vec<CompletionItem> {
294 match context {
295 Context::TagName { typed } => tag_items(replaced(cursor, *typed)),
296 Context::AttributeName { tag, typed } => attribute_items(tag, replaced(cursor, *typed)),
297 Context::AttributeValue {
298 tag,
299 attribute,
300 typed,
301 } => self.value_items(tag, attribute, replaced(cursor, *typed)),
302 Context::CustomAttribute(pending) => {
303 self.custom_items(pending.types, replaced(cursor, pending.typed))
304 }
305 Context::SitePreference { typed } => {
306 self.custom_items(&[SITE_PREFERENCES], replaced(cursor, *typed))
307 }
308 Context::RouteName { typed } => self.route_items(replaced(cursor, *typed)),
309 Context::DwModule { typed } => module_items(replaced(cursor, *typed)),
310 Context::DwImport { typed } => {
311 self.import_items(replaced(cursor, typed.chars().count()))
312 }
313 Context::DwDeclaration { keyword, typed } => {
314 declaration_items(replaced(cursor, typed.chars().count()), keyword, typed)
315 }
316 Context::DwMember { class, typed } => member_items(class, replaced(cursor, *typed)),
317 }
318 }
319
320 fn route_items(&self, range: Range) -> Vec<CompletionItem> {
322 let Some(controller) = self.file.file_stem().and_then(|stem| stem.to_str()) else {
323 return Vec::new();
324 };
325 self.workspace
326 .controllers()
327 .routes_of(controller)
328 .into_iter()
329 .map(|route| CompletionItem {
330 label: route.to_string(),
331 kind: Some(CompletionItemKind::METHOD),
332 detail: Some(
333 Route {
334 controller: controller.to_string(),
335 name: route.to_string(),
336 }
337 .endpoint(),
338 ),
339 text_edit: Some(edit(range, route.to_string())),
340 ..Default::default()
341 })
342 .collect()
343 }
344
345 fn value_items(&self, tag: &str, attribute: &str, range: Range) -> Vec<CompletionItem> {
346 if attribute == isml::TEMPLATE_ATTRIBUTE {
347 return self.template_items(range);
348 }
349 let Some(definition) = isml::tag(tag) else {
350 return Vec::new();
351 };
352 let Some(found) = definition
353 .attributes
354 .iter()
355 .find(|candidate| candidate.name == attribute)
356 else {
357 return Vec::new();
358 };
359 found
360 .values
361 .iter()
362 .map(|value| CompletionItem {
363 label: value.to_string(),
364 kind: Some(CompletionItemKind::VALUE),
365 text_edit: Some(edit(range, value.to_string())),
366 ..Default::default()
367 })
368 .collect()
369 }
370
371 fn template_items(&self, range: Range) -> Vec<CompletionItem> {
372 self.workspace
373 .templates()
374 .iter()
375 .take(MAX_TEMPLATES)
376 .map(|path| CompletionItem {
377 label: path.clone(),
378 kind: Some(CompletionItemKind::FILE),
379 text_edit: Some(edit(range, path.clone())),
380 ..Default::default()
381 })
382 .collect()
383 }
384
385 fn custom_items(&self, types: &[&str], range: Range) -> Vec<CompletionItem> {
387 let mut items: Vec<CompletionItem> = Vec::new();
388 for type_id in types {
389 let Some(attributes) = self.metadata.attributes_of(type_id) else {
390 continue;
391 };
392 for definition in attributes.values() {
393 if items.iter().any(|item| item.label == definition.id) {
394 continue;
395 }
396 items.push(CompletionItem {
397 label: definition.id.clone(),
398 kind: Some(CompletionItemKind::FIELD),
399 detail: Some(definition.detail()),
400 documentation: definition.documentation().map(markdown),
401 text_edit: Some(edit(range, definition.id.clone())),
402 ..Default::default()
403 });
404 }
405 }
406 items
407 }
408}
409
410impl Completer<'_> {
411 fn import_items(&self, range: Range) -> Vec<CompletionItem> {
414 let imports = api::imports(self.text);
415 api::api()
416 .classes()
417 .map(|(qualified, class)| {
418 let short = short_name(qualified);
419 let module = qualified.replace('.', "/");
420 let inserted = match imports.name_of(qualified) {
423 Some(name) => name.to_string(),
424 None => format!("{} {short} = require('{module}');", imports.keyword),
425 };
426 CompletionItem {
427 label: format!("dw{short}"),
428 kind: Some(CompletionItemKind::CLASS),
429 detail: Some(inserted.clone()),
430 documentation: (!class.description.is_empty())
431 .then(|| markdown(class.description.clone())),
432 text_edit: Some(edit(range, inserted)),
433 ..Default::default()
434 }
435 })
436 .collect()
437 }
438}
439
440fn declaration_items(range: Range, keyword: &str, typed: &str) -> Vec<CompletionItem> {
443 let shorthand = typed.to_ascii_lowercase().starts_with("dw");
444 api::api()
445 .classes()
446 .map(|(qualified, class)| {
447 let short = short_name(qualified);
448 let module = qualified.replace('.', "/");
449 CompletionItem {
450 label: short.to_string(),
451 kind: Some(CompletionItemKind::CLASS),
452 detail: Some(format!("{keyword} {short} = require('{module}');")),
453 documentation: (!class.description.is_empty())
454 .then(|| markdown(class.description.clone())),
455 filter_text: shorthand.then(|| format!("dw{short}")),
458 text_edit: Some(edit(range, format!("{short} = require('{module}');"))),
459 ..Default::default()
460 }
461 })
462 .collect()
463}
464
465fn short_name(qualified: &str) -> &str {
466 qualified.rsplit('.').next().unwrap_or(qualified)
467}
468
469fn module_items(range: Range) -> Vec<CompletionItem> {
470 api::api()
471 .modules()
472 .map(|(path, class)| CompletionItem {
473 label: path.clone(),
474 kind: Some(CompletionItemKind::MODULE),
475 documentation: (!class.description.is_empty())
476 .then(|| markdown(class.description.clone())),
477 text_edit: Some(edit(range, path)),
478 ..Default::default()
479 })
480 .collect()
481}
482
483fn member_items(class: &str, range: Range) -> Vec<CompletionItem> {
484 let Some(found) = api::api().class(class) else {
485 return Vec::new();
486 };
487 found
488 .members()
489 .map(|(member, kind)| CompletionItem {
490 label: member.name.clone(),
491 kind: Some(match kind {
492 api::MemberKind::Method => CompletionItemKind::METHOD,
493 api::MemberKind::Property => CompletionItemKind::PROPERTY,
494 api::MemberKind::Constant => CompletionItemKind::CONSTANT,
495 }),
496 detail: Some(member.shape.clone()),
497 documentation: (!member.description.is_empty())
498 .then(|| markdown(member.description.clone())),
499 text_edit: Some(edit(range, member.name.clone())),
500 ..Default::default()
501 })
502 .collect()
503}
504
505fn tag_items(range: Range) -> Vec<CompletionItem> {
506 isml::tags()
507 .iter()
508 .map(|tag| CompletionItem {
509 label: tag.name.to_string(),
510 kind: Some(CompletionItemKind::KEYWORD),
511 detail: Some(tag.summary.to_string()),
512 insert_text_format: Some(InsertTextFormat::SNIPPET),
513 text_edit: Some(edit(range, snippet(tag))),
514 ..Default::default()
515 })
516 .collect()
517}
518
519fn attribute_items(tag: &str, range: Range) -> Vec<CompletionItem> {
520 let Some(definition) = isml::tag(tag) else {
521 return Vec::new();
522 };
523 definition
524 .attributes
525 .iter()
526 .map(|attribute| CompletionItem {
527 label: attribute.name.to_string(),
528 kind: Some(CompletionItemKind::PROPERTY),
529 detail: Some(match attribute.required {
530 true => format!("required — {}", attribute.summary),
531 false => attribute.summary.to_string(),
532 }),
533 sort_text: Some(match attribute.required {
534 true => format!("0{}", attribute.name),
535 false => format!("1{}", attribute.name),
536 }),
537 insert_text_format: Some(InsertTextFormat::SNIPPET),
538 text_edit: Some(edit(range, format!("{}=\"$1\"", attribute.name))),
539 ..Default::default()
540 })
541 .collect()
542}
543
544fn snippet(tag: &isml::Tag) -> String {
546 let required: Vec<String> = tag
547 .attributes
548 .iter()
549 .filter(|attribute| attribute.required)
550 .enumerate()
551 .map(|(index, attribute)| format!(" {}=\"${}\"", attribute.name, index + 1))
552 .collect();
553 let attributes = required.concat();
554 let room = match required.is_empty() && !tag.attributes.is_empty() {
556 true => " $0",
557 false => "",
558 };
559
560 match tag.body {
561 Body::Empty => format!("{}{}{} />", tag.name, attributes, room),
562 Body::Inline => format!("{0}{1}>$0</{0}>", tag.name, attributes),
563 Body::Block => format!("{0}{1}>\n\t$0\n</{0}>", tag.name, attributes),
564 }
565}
566
567fn replaced(cursor: Position, typed: usize) -> Range {
568 let start = Position::new(cursor.line, cursor.character.saturating_sub(typed as u32));
569 Range::new(start, cursor)
570}
571
572fn edit(range: Range, text: String) -> CompletionTextEdit {
573 CompletionTextEdit::Edit(TextEdit {
574 range,
575 new_text: text,
576 })
577}
578
579fn markdown(value: String) -> Documentation {
580 Documentation::MarkupContent(MarkupContent {
581 kind: MarkupKind::Markdown,
582 value,
583 })
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn context(text: &str) -> Option<Context> {
591 context_at(text, text.chars().count(), true)
592 }
593
594 #[test]
595 fn completes_a_tag_name_after_the_angle_bracket() {
596 assert_eq!(context("<isi"), Some(Context::TagName { typed: 3 }));
597 assert_eq!(context(" <"), Some(Context::TagName { typed: 0 }));
598 }
599
600 #[test]
601 fn completes_an_attribute_name_inside_an_open_tag() {
602 assert_eq!(
603 context("<isloop items=\"${x}\" va"),
604 Some(Context::AttributeName {
605 tag: "isloop".into(),
606 typed: 2
607 })
608 );
609 }
610
611 #[test]
612 fn completes_an_attribute_value_inside_the_quotes() {
613 assert_eq!(
614 context("<isset name=\"a\" value=\"b\" scope=\"ses"),
615 Some(Context::AttributeValue {
616 tag: "isset".into(),
617 attribute: "scope".into(),
618 typed: 3
619 })
620 );
621 }
622
623 #[test]
624 fn leaves_plain_html_alone() {
625 assert_eq!(context("<div cla"), None);
626 assert_eq!(context("<isif condition=\"${x}\">text "), None);
627 }
628
629 #[test]
630 fn spans_a_tag_broken_over_several_lines() {
631 assert_eq!(
632 context("<isslot\n id=\"home\"\n conte"),
633 Some(Context::AttributeName {
634 tag: "isslot".into(),
635 typed: 5
636 })
637 );
638 }
639
640 #[test]
641 fn prefers_a_custom_attribute_inside_an_expression() {
642 assert_eq!(
643 context("<isif condition=\"${product.custom.gue"),
644 Some(Context::CustomAttribute(custom::Pending {
645 types: &["Product"],
646 typed: 3
647 }))
648 );
649 }
650
651 #[test]
652 fn completes_a_route_name_in_a_server_declaration() {
653 let text = "server.append('Sh";
654 assert_eq!(
655 context_at(text, text.chars().count(), false),
656 Some(Context::RouteName { typed: 2 })
657 );
658 }
659
660 #[test]
661 fn completes_a_declaration_being_written() {
662 let text = "'use strict';\nvar Trans";
663 assert_eq!(
664 context_at(text, text.chars().count(), false),
665 Some(Context::DwDeclaration {
666 keyword: "var".into(),
667 typed: "Trans".into()
668 })
669 );
670 }
671
672 #[test]
673 fn takes_the_shorthand_inside_a_declaration_too() {
674 let text = "const dwSite";
675 assert_eq!(
676 context_at(text, text.chars().count(), false),
677 Some(Context::DwDeclaration {
678 keyword: "const".into(),
679 typed: "dwSite".into()
680 })
681 );
682 }
683
684 #[test]
685 fn completes_a_bare_shorthand_as_an_import() {
686 let text = " dwSite";
687 assert_eq!(
688 context_at(text, text.chars().count(), false),
689 Some(Context::DwImport {
690 typed: "dwSite".into()
691 })
692 );
693 }
694
695 #[test]
696 fn leaves_a_word_that_merely_ends_in_a_keyword_alone() {
697 let text = "myvar Trans";
698 assert!(!matches!(
699 context_at(text, text.chars().count(), false),
700 Some(Context::DwDeclaration { .. })
701 ));
702 }
703
704 #[test]
705 fn completes_custom_attributes_in_plain_javascript_too() {
706 let text = "var x = order.custom.";
707 assert_eq!(
708 context_at(text, text.chars().count(), false),
709 Some(Context::CustomAttribute(custom::Pending {
710 types: &["Order"],
711 typed: 0
712 }))
713 );
714 }
715
716 #[test]
717 fn closes_an_empty_tag_and_leaves_the_cursor_in_the_first_attribute() {
718 let isinclude = isml::tag("isinclude").unwrap();
719 assert_eq!(snippet(isinclude), "isinclude $0 />");
720 let isif = isml::tag("isif").unwrap();
721 assert_eq!(snippet(isif), "isif condition=\"$1\">$0</isif>");
722 }
723}