Skip to main content

bubbles/compiler/
markup.rs

1//! Scanner for inline markup (`[name]…[/name]`, `[name /]`) combined with
2//! `{expr}` interpolation. Both are parsed in one left-to-right pass.
3
4/// A token produced by scanning text that may contain `{expr}` and `[markup]` syntax.
5#[derive(Debug, PartialEq, Eq)]
6pub enum TextToken<'a> {
7    /// A literal run of text with no substitution or markup.
8    Literal(&'a str),
9    /// The source text between `{` and `}`.
10    Expr(&'a str),
11    /// An opening markup tag: `[name]` or `[name key=val …]`.
12    MarkupOpen {
13        /// Tag name, e.g. `wave` in `[wave]`.
14        name: &'a str,
15        /// Zero or more `key=value` pairs.
16        properties: Vec<(&'a str, &'a str)>,
17    },
18    /// A closing markup tag: `[/name]`.
19    MarkupClose {
20        /// Tag name, e.g. `wave` in `[/wave]`.
21        name: &'a str,
22    },
23    /// A self-closing markup tag: `[name /]` or `[name key=val … /]`.
24    MarkupSelfClose {
25        /// Tag name, e.g. `pause` in `[pause /]`.
26        name: &'a str,
27        /// Zero or more `key=value` pairs.
28        properties: Vec<(&'a str, &'a str)>,
29    },
30}
31
32/// Errors returned by [`scan_text_segments`].
33#[derive(Debug, PartialEq, Eq)]
34pub enum MarkupScanError {
35    /// An unclosed `{` at the given byte offset.
36    UnclosedBrace(usize),
37    /// An unclosed `[` at the given byte offset.
38    UnclosedBracket(usize),
39}
40
41impl MarkupScanError {
42    /// Renders a human-readable message naming the unclosed delimiter, the
43    /// `context` it occurred in (e.g. `"line text"`), and the offending `raw` text.
44    #[must_use]
45    pub fn describe(&self, context: &str, raw: &str) -> String {
46        let delimiter = match self {
47            Self::UnclosedBrace(_) => '{',
48            Self::UnclosedBracket(_) => '[',
49        };
50        format!("unclosed `{delimiter}` in {context}: `{raw}`")
51    }
52}
53
54/// Converts borrowed `(key, value)` markup properties into owned pairs.
55#[must_use]
56pub fn owned_properties(properties: &[(&str, &str)]) -> Vec<(String, String)> {
57    properties
58        .iter()
59        .map(|&(key, value)| (key.to_owned(), value.to_owned()))
60        .collect()
61}
62
63/// Scans `text` for `{expr}` and `[markup]` syntax, yielding tokens in order.
64///
65/// **Markup rules:**
66/// - `[identifier]` or `[identifier key=val …]` → [`TextToken::MarkupOpen`]
67/// - `[/identifier]` → [`TextToken::MarkupClose`]
68/// - `[identifier /]` or `[identifier key=val … /]` → [`TextToken::MarkupSelfClose`]
69/// - `[…]` whose content does not match any of the above → emitted verbatim
70///   as part of a [`TextToken::Literal`]
71///
72/// An unclosed `{` or `[` (no matching `}` / `]` before end of input) is
73/// always an error regardless of the content inside.
74///
75/// # Errors
76///
77/// Returns [`MarkupScanError::UnclosedBrace`] or [`MarkupScanError::UnclosedBracket`]
78/// with the byte offset of the unmatched delimiter.
79pub fn scan_text_segments(text: &str) -> Result<Vec<TextToken<'_>>, MarkupScanError> {
80    let mut tokens = Vec::new();
81    let bytes = text.as_bytes();
82    let mut i = 0usize;
83    let mut lit_start = 0usize;
84
85    macro_rules! flush_literal {
86        () => {
87            if lit_start < i {
88                tokens.push(TextToken::Literal(&text[lit_start..i]));
89            }
90        };
91    }
92
93    while i < bytes.len() {
94        match bytes[i] {
95            b'{' => {
96                let brace_start = i;
97                let rest = &text[i + 1..];
98                let close = rest
99                    .find('}')
100                    .ok_or(MarkupScanError::UnclosedBrace(brace_start))?;
101                flush_literal!();
102                tokens.push(TextToken::Expr(&rest[..close]));
103                i = i + 1 + close + 1;
104                lit_start = i;
105            }
106            b'[' => {
107                let bracket_start = i;
108                let rest = &text[i + 1..];
109                let close_rel = rest
110                    .find(']')
111                    .ok_or(MarkupScanError::UnclosedBracket(bracket_start))?;
112                let inner = &rest[..close_rel];
113                if let Some(tok) = try_parse_markup(inner) {
114                    flush_literal!();
115                    tokens.push(tok);
116                    i = i + 1 + close_rel + 1;
117                    lit_start = i;
118                } else {
119                    // Not markup – include the `[` in the current literal run
120                    // and let the scanner continue character-by-character.
121                    i += 1;
122                }
123            }
124            _ => {
125                i += 1;
126            }
127        }
128    }
129
130    if lit_start < text.len() {
131        tokens.push(TextToken::Literal(&text[lit_start..]));
132    }
133
134    Ok(tokens)
135}
136
137/// Returns `true` if `s` is a valid markup identifier (`[a-zA-Z_][a-zA-Z0-9_-]*`).
138fn is_identifier(s: &str) -> bool {
139    let mut chars = s.chars();
140    chars.next().is_some_and(|c| {
141        (c.is_ascii_alphabetic() || c == '_')
142            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
143    })
144}
145
146/// Parses zero or more `key=value` pairs separated by whitespace.
147///
148/// Returns `None` if any pair is malformed (missing `=` or non-identifier key).
149fn parse_properties(s: &str) -> Option<Vec<(&str, &str)>> {
150    if s.is_empty() {
151        return Some(Vec::new());
152    }
153    let mut props = Vec::new();
154    for part in s.split_whitespace() {
155        let eq = part.find('=')?;
156        let key = &part[..eq];
157        let val = &part[eq + 1..];
158        if !is_identifier(key) {
159            return None;
160        }
161        props.push((key, val));
162    }
163    Some(props)
164}
165
166/// Attempts to parse the content between `[` and `]` as a markup token.
167///
168/// Returns `None` if the content does not match the markup grammar, in which
169/// case the caller should treat the entire `[…]` as literal text.
170fn try_parse_markup(inner: &str) -> Option<TextToken<'_>> {
171    // Close tag: `/identifier`
172    if let Some(name_part) = inner.strip_prefix('/') {
173        let name = name_part.trim_start();
174        if is_identifier(name) && name.len() == name_part.len() {
175            return Some(TextToken::MarkupClose { name });
176        }
177        return None;
178    }
179
180    // Self-closing: content ends with ` /`
181    let (content, self_close) = inner
182        .strip_suffix(" /")
183        .map_or((inner, false), |rest| (rest, true));
184
185    // Split into name and optional property string on the first space
186    let (name, props_src) = content
187        .find(' ')
188        .map_or((content, ""), |sp| (&content[..sp], &content[sp + 1..]));
189
190    if !is_identifier(name) {
191        return None;
192    }
193
194    let properties = parse_properties(props_src)?;
195
196    if self_close {
197        Some(TextToken::MarkupSelfClose { name, properties })
198    } else {
199        Some(TextToken::MarkupOpen { name, properties })
200    }
201}
202
203#[cfg(test)]
204#[path = "markup_tests.rs"]
205mod tests;