Skip to main content

isml_lsp/
complete.rs

1//! Completion: [`context_at`] decides where the cursor is, then [`Completer`] what to offer there.
2
3use 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/// `typed` counts the characters already there, so the completion replaces them instead of appending.
20#[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    /// After `.custom.` on something with a known object type.
35    CustomAttribute(custom::Pending),
36    SitePreference {
37        typed: usize,
38    },
39    /// The first argument of `server.<verb>(`, which names a route.
40    RouteName {
41        typed: usize,
42    },
43    DwModule {
44        typed: usize,
45    },
46    /// A bare `dwSite` being typed, which stands for the class plus the
47    /// `require` that brings it in.
48    DwImport {
49        /// What has been typed, `dw` prefix included.
50        typed: String,
51    },
52    /// `var Trans` at the head of a file, where a `require` is being written.
53    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    // The declaration form is checked first: `var dwSite` is being written as
90    // a declaration, not as a bare reference.
91    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
145/// Offset of the quote opening the value the cursor sits in, if it is unclosed.
146fn 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    // Only at the start of a statement: `x = var` is not a declaration, and
241    // neither is an identifier that merely ends in those letters.
242    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
252/// A bare word starting with `dw`, which is the shorthand for importing a class.
253fn 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    // `x.dwSite` is a member access, not a name being introduced.
259    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    /// Decides where a new `require` goes.
287    pub text: &'a str,
288    /// Names the controller a bare route belongs to.
289    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    /// Routes this controller has elsewhere in the path: what a `server.append` here can attach to.
321    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    /// The union over candidate types: an ambiguous name may carry the attributes of either.
386    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    /// `dwSite` becomes `Site` plus `var Site = require('dw/system/Site');` in one keystroke,
412    /// unless the document already has it.
413    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                // Already bound higher up? Then the name alone is what was
421                // meant — declaring it twice would shadow it at best.
422                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
440/// `var Trans` completes to the whole declaration, in place — no second edit
441/// needed, because the cursor is already where the `require` belongs.
442fn 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                // Typing `var dwSite` should still find it, so the filter
456                // follows whichever spelling is being used.
457                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
544/// The whole tag, so accepting `isif` leaves a usable skeleton to fill in.
545fn 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    // A tag with optional attributes leaves the cursor where one would go.
555    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}