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;