Skip to main content

logicaffeine_lsp/
template.rs

1//! The LGX template vocabulary the editor completes and paints with.
2//!
3//! A `View:` / `Head:` body is HTML whose `{…}` groups are either a TAG — `{if c:}`,
4//! `{for each x in xs:}`, `{end}` — or a HOLE: a LOGOS expression, optionally piped through
5//! filters (`{price | money}`). The template parser in `logicaffeine_language::lgx::template` owns
6//! the grammar; this module holds the two things an editor needs from it, once: the phrase that
7//! opens each tag (with the shape `MalformedTag` teaches), and the clause words inside a tag.
8
9/// Every template tag: the phrase that opens it, and its shape as `MalformedTag` spells it.
10///
11/// A longer phrase precedes any phrase it begins with (`else if` before `else`), so the first
12/// match against a tag's text is the right one.
13pub const TAGS: &[(&str, &str)] = &[
14    ("else if", "{else if condition:}"),
15    ("else", "{else:}"),
16    ("if", "{if condition:} … {end}"),
17    (
18        "for each",
19        "{for each item in items[, keyed by key][, at index i]:} … {end}",
20    ),
21    ("inspect", "{inspect value:} {when Pattern:} … {end}"),
22    ("when", "{when Pattern:}"),
23    ("let", "{let name be expression}"),
24    ("extends", "{extends Layout}"),
25    ("block", "{block name:} … {end}"),
26    ("children", "{children} — the caller's markup"),
27    (
28        "raw",
29        "{raw expression} — unescaped, so only for markup you trust",
30    ),
31    ("end", "{end}"),
32];
33
34/// The clause words a `{for each …:}` tag carries after its pattern.
35pub const FOR_EACH_CLAUSES: &[&str] = &["in", "keyed by", "at index"];
36
37/// The tag phrase `inner` (a `{…}` group's text, braces excluded) opens with, and the byte length
38/// of the phrase within `inner` including any leading whitespace — `None` for a hole.
39pub fn tag_phrase(inner: &str) -> Option<(&'static str, usize)> {
40    let trimmed = inner.trim_start();
41    let lead = inner.len() - trimmed.len();
42    TAGS.iter().map(|(phrase, _)| *phrase).find_map(|phrase| {
43        let rest = trimmed.strip_prefix(phrase)?;
44        let ends_word = rest
45            .chars()
46            .next()
47            .is_none_or(|c| !c.is_alphanumeric() && c != '_');
48        ends_word.then_some((phrase, lead + phrase.len()))
49    })
50}
51
52/// A `{# … #}` template comment.
53pub fn is_comment(inner: &str) -> bool {
54    inner.trim_start().starts_with('#')
55}
56
57/// The byte ranges of `text`'s top-level `|` separators — the filter pipes of a hole. A `|` inside
58/// a string or a bracketed group is part of an expression, not a pipe.
59pub fn filter_pipes(text: &str) -> Vec<usize> {
60    let mut pipes = Vec::new();
61    let mut depth = 0usize;
62    let mut in_string = false;
63    let mut escaped = false;
64    for (i, c) in text.char_indices() {
65        if in_string {
66            match c {
67                _ if escaped => escaped = false,
68                '\\' => escaped = true,
69                '"' => in_string = false,
70                _ => {}
71            }
72            continue;
73        }
74        match c {
75            '"' => in_string = true,
76            '(' | '[' | '{' => depth += 1,
77            ')' | ']' | '}' => depth = depth.saturating_sub(1),
78            '|' if depth == 0 => pipes.push(i),
79            _ => {}
80        }
81    }
82    pipes
83}
84
85#[cfg(test)]
86#[path = "../../logicaffeine_tests/unit/logicaffeine_lsp/template/tests.rs"]
87mod tests;