Skip to main content

moss_core/ast/
attrs.rs

1//! Attribute-block parser for the unified shortcode grammar.
2//!
3//! Parses the `{ ... }` portion of an opening fence into structured
4//! classes, an optional id, and key/value pairs. Pure, no I/O.
5//!
6//! Grammar (from `docs/archive/2026-05-02-shortcode-grammar-design.md`):
7//!
8//! ```text
9//! Attrs    := "{" AttrItem (whitespace AttrItem)* "}"
10//! AttrItem := "." classname
11//!           | "#" id
12//!           | key "=" value
13//! key      := [A-Za-z][A-Za-z0-9_-]*
14//! value    := bareword | quoted
15//! bareword := (Unicode-alphanumeric | [:_/.\-])+
16//! quoted   := "\"" any-char-except-unescaped-quote* "\""
17//! ```
18//!
19//! Whitespace inside `{}` (spaces, tabs, newlines) all separate items
20//! identically — multi-line attribute blocks are first-class.
21//!
22//! The parser is forgiving on malformed bareword values (returns the
23//! malformed token verbatim rather than erroring); it errors only on
24//! structural problems (unterminated quote, bad key, no closing brace).
25//! Renderers are responsible for validating typed values like `cols=int`.
26
27/// Parsed attribute block.
28///
29/// `kvs` is a `Vec<(String, String)>` (not a `HashMap`) so iteration is
30/// stable and matches source order — important for deterministic HTML
31/// attribute output, snapshot tests, and diagnostics that point at the
32/// offending entry. Last-write-wins on duplicate keys is enforced in the
33/// parser, so callers can use [`AttrBlock::get`] without seeing stale
34/// values.
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct AttrBlock {
37    /// `.classname` shortcuts, in source order. Duplicates preserved so
38    /// the renderer can detect explicit doubles if it wants.
39    pub classes: Vec<String>,
40    /// Last `#id` shortcut wins (multiple ids is malformed but not fatal).
41    pub id: Option<String>,
42    /// `key=value` pairs in source order. Last write wins when a key
43    /// repeats — the parser drops earlier entries on collision.
44    pub kvs: Vec<(String, String)>,
45    /// Width flag (spec § P9): `body | wide | page | screen`. Recognized
46    /// as a bare token in the attribute block; `{full}` is normalized to
47    /// `"screen"`. Last write wins on repeats. `None` means the author did
48    /// not specify a width — emitters should omit `data-width` in that
49    /// case so the HTML stays sparse and themes can target the absence.
50    pub width: Option<&'static str>,
51}
52
53impl AttrBlock {
54    pub fn is_empty(&self) -> bool {
55        self.classes.is_empty()
56            && self.id.is_none()
57            && self.kvs.is_empty()
58            && self.width.is_none()
59    }
60
61    /// Convenience for renderers: get the value for a key.
62    pub fn get(&self, key: &str) -> Option<&str> {
63        self.kvs
64            .iter()
65            .find(|(k, _)| k == key)
66            .map(|(_, v)| v.as_str())
67    }
68
69    /// Space-joined class list, ready for `class="..."`.
70    pub fn class_string(&self) -> String {
71        self.classes.join(" ")
72    }
73
74    /// Set a key/value pair, replacing any existing entry for `key`
75    /// in place to preserve source order of unrelated entries.
76    fn set_kv(&mut self, key: String, value: String) {
77        if let Some(slot) = self.kvs.iter_mut().find(|(k, _)| k == &key) {
78            slot.1 = value;
79        } else {
80            self.kvs.push((key, value));
81        }
82    }
83}
84
85/// Errors parsing an attribute block.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum AttrError {
88    /// Input did not start with `{`.
89    MissingOpenBrace,
90    /// Block opened with `{` but no matching `}` was found.
91    UnclosedBrace,
92    /// A `"` opened but had no closing `"` before the block ended.
93    UnterminatedQuote,
94    /// A `key=` was followed by no value (end of input or whitespace).
95    EmptyValue { key: String },
96    /// A token started with `=` (no key before it) or had a malformed key.
97    InvalidKey { token: String },
98}
99
100/// Byte spans of one `key=value` item inside an attribute block.
101///
102/// Produced as a by-product of [`parse_attrs_spanned`] so a caller that
103/// wants to REWRITE one attribute value (rename tracking for
104/// `:::hero {image=…}`) can do a surgical byte replacement instead of
105/// re-serializing the block and losing the author's spacing.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct KvSpan {
108    /// The item's key.
109    pub key: String,
110    /// RAW value span, quotes INCLUDED, relative to `input`.
111    pub value: std::ops::Range<usize>,
112    /// The whole `key=value` item, relative to `input`.
113    pub item: std::ops::Range<usize>,
114    /// Only ever `None` or `Some('"')` — this grammar has no single-quote
115    /// form (`'` is not in [`is_bareword`], so `image='a b.jpg'` is an
116    /// `EmptyValue` error, not a quoted value).
117    pub quote: Option<char>,
118}
119
120/// Parse an attribute block.
121///
122/// `input` must include the surrounding braces: `{.foo key=bar}`.
123/// Whitespace between items (including newlines) is permitted.
124///
125/// Errors only on structural problems. Unrecognized characters at the start
126/// of an item — anything not `.`, `#`, or a valid key character — produce
127/// `InvalidKey { token }` so the caller can surface a useful diagnostic.
128pub fn parse_attrs(input: &str) -> Result<AttrBlock, AttrError> {
129    parse_attrs_spanned(input).map(|(block, _)| block)
130}
131
132/// [`parse_attrs`] plus one [`KvSpan`] per `key=value` item.
133///
134/// Same loop, same grammar, same errors — the spans are pushed by the
135/// existing `char_indices()` walk rather than by a second tokenizer, so
136/// both of this parser's quirks are preserved by construction: an empty
137/// `.`/`#` bareword is silently skipped, and the FIRST malformed item
138/// aborts the whole block (discarding everything accumulated so far).
139///
140/// # Parsing past the closing brace
141///
142/// `parse_attrs_spanned` returns `Ok` at the first `}` **at item position**,
143/// and a `}` inside a quoted value is consumed by `read_quoted`. That is what
144/// lets [`crate::ast::shortcode_extract::shortcode_asset_spans`] hand it
145/// `&source[brace_start..]` — the rest of the document — and get spans that
146/// terminate exactly where the gathered-args form would, without duplicating
147/// [`gather_multi_line_attrs`]. **If this grammar ever gains a nested `{`,
148/// that equivalence breaks silently.**
149pub fn parse_attrs_spanned(input: &str) -> Result<(AttrBlock, Vec<KvSpan>), AttrError> {
150    let mut kv_spans: Vec<KvSpan> = Vec::new();
151    let mut chars = input.char_indices().peekable();
152
153    // Expect leading `{`.
154    skip_ws(&mut chars);
155    match chars.next() {
156        Some((_, '{')) => {}
157        _ => return Err(AttrError::MissingOpenBrace),
158    }
159
160    let mut block = AttrBlock::default();
161
162    loop {
163        skip_ws(&mut chars);
164        match chars.peek().copied() {
165            None => return Err(AttrError::UnclosedBrace),
166            Some((_, '}')) => {
167                chars.next();
168                return Ok((block, kv_spans));
169            }
170            Some((_, '.')) => {
171                chars.next();
172                let class = read_bareword(&mut chars);
173                if !class.is_empty() {
174                    block.classes.push(class);
175                }
176            }
177            Some((_, '#')) => {
178                chars.next();
179                let id = read_bareword(&mut chars);
180                if !id.is_empty() {
181                    block.id = Some(id);
182                }
183            }
184            Some((item_start, c)) if is_key_start(c) => {
185                let key = read_key(&mut chars);
186                skip_ws_inline(&mut chars);
187                match chars.peek().copied() {
188                    Some((_, '=')) => {
189                        chars.next();
190                        skip_ws_inline(&mut chars);
191                        let value_start = chars.peek().map_or(input.len(), |&(i, _)| i);
192                        let quote = match chars.peek() {
193                            Some(&(_, '"')) => Some('"'),
194                            _ => None,
195                        };
196                        let value = read_value(&mut chars, &key)?;
197                        // `read_value` stops on the first char it did not
198                        // consume, so the peeked index IS the exclusive end.
199                        let value_end = chars.peek().map_or(input.len(), |&(i, _)| i);
200                        kv_spans.push(KvSpan {
201                            key: key.clone(),
202                            value: value_start..value_end,
203                            item: item_start..value_end,
204                            quote,
205                        });
206                        block.set_kv(key, value);
207                    }
208                    _ => {
209                        // Bare keyword (no `=value`). Spec § P9 reserves four
210                        // width tokens (`body | wide | page | screen`) plus
211                        // the alias `full` (→ `screen`) as bare flags for
212                        // hero / gallery / grid / embed / image-wrapper
213                        // sizing. Any other bare keyword is still an error.
214                        if let Some(width) = match_width_token(&key) {
215                            block.width = Some(width);
216                        } else {
217                            return Err(AttrError::InvalidKey { token: key });
218                        }
219                    }
220                }
221            }
222            Some((_, c)) => {
223                // Anything else at item-start is invalid. Capture the run
224                // up to the next whitespace/`}` for the diagnostic.
225                let mut token = String::new();
226                token.push(c);
227                chars.next();
228                while let Some(&(_, ch)) = chars.peek() {
229                    if ch.is_whitespace() || ch == '}' {
230                        break;
231                    }
232                    token.push(ch);
233                    chars.next();
234                }
235                return Err(AttrError::InvalidKey { token });
236            }
237        }
238    }
239}
240
241fn is_key_start(c: char) -> bool {
242    c.is_ascii_alphabetic()
243}
244
245/// Recognize the spec § P9 width tokens (`body | wide | page | screen | full`).
246///
247/// `full` is the author-facing alias for `screen` (the spec keeps both shapes
248/// because `{full}` reads naturally in authoring contexts while the emitted
249/// attribute value is the value-space term `screen`). All five tokens are
250/// ASCII lowercase per the grammar; the parser feeds keys verbatim, so any
251/// case-folding decision lives here.
252fn match_width_token(s: &str) -> Option<&'static str> {
253    match s {
254        "body" => Some("body"),
255        "wide" => Some("wide"),
256        "page" => Some("page"),
257        "screen" | "full" => Some("screen"),
258        _ => None,
259    }
260}
261
262fn is_key_continue(c: char) -> bool {
263    c.is_ascii_alphanumeric() || c == '_' || c == '-'
264}
265
266/// Whether `c` can appear in an unquoted attribute value.
267///
268/// Unicode-alphanumeric (not just ASCII) so a non-ASCII filename — `頭像.png`,
269/// `café.jpg` — can be written unquoted in `:::hero {image=…}` the same way
270/// it can appear bare anywhere else a path is written (gallery body,
271/// frontmatter, wikilinks). Before this widened, `read_value` treated the
272/// first non-ASCII byte as "not bareword", `parse_attrs` returned
273/// `EmptyValue`, and every caller's `.unwrap_or_default()` silently dropped
274/// the ENTIRE attribute block — not just the offending value, so `image=`
275/// disappeared along with `width`/`classes`/`mobile` and the hero rendered
276/// with no image at all. `required_quote` in src-tauri's `ref_rewrite.rs`
277/// must accept exactly this same set — it calls this function rather than
278/// keeping its own copy, so the two can't drift apart again.
279pub fn is_bareword(c: char) -> bool {
280    matches!(c, ':' | '/' | '.' | '-' | '_') || c.is_alphanumeric()
281}
282
283fn skip_ws<I>(iter: &mut std::iter::Peekable<I>)
284where
285    I: Iterator<Item = (usize, char)>,
286{
287    while let Some(&(_, c)) = iter.peek() {
288        if c.is_whitespace() {
289            iter.next();
290        } else {
291            break;
292        }
293    }
294}
295
296/// Like `skip_ws` but stops at the first newline — used after a `key`
297/// before checking for `=`. Newline-after-key without `=` is a malformed
298/// item, but the user might have written `key\n=value` which we should
299/// accept (whitespace is whitespace inside `{}`). Today we treat all
300/// whitespace identically — so this is currently equivalent to skip_ws.
301/// Kept as a separate helper in case future grammars distinguish.
302fn skip_ws_inline<I>(iter: &mut std::iter::Peekable<I>)
303where
304    I: Iterator<Item = (usize, char)>,
305{
306    skip_ws(iter);
307}
308
309fn read_bareword<I>(iter: &mut std::iter::Peekable<I>) -> String
310where
311    I: Iterator<Item = (usize, char)>,
312{
313    let mut s = String::new();
314    while let Some(&(_, c)) = iter.peek() {
315        if is_bareword(c) {
316            s.push(c);
317            iter.next();
318        } else {
319            break;
320        }
321    }
322    s
323}
324
325fn read_key<I>(iter: &mut std::iter::Peekable<I>) -> String
326where
327    I: Iterator<Item = (usize, char)>,
328{
329    let mut s = String::new();
330    if let Some(&(_, c)) = iter.peek() {
331        if is_key_start(c) {
332            s.push(c);
333            iter.next();
334        } else {
335            return s;
336        }
337    }
338    while let Some(&(_, c)) = iter.peek() {
339        if is_key_continue(c) {
340            s.push(c);
341            iter.next();
342        } else {
343            break;
344        }
345    }
346    s
347}
348
349fn read_value<I>(iter: &mut std::iter::Peekable<I>, key: &str) -> Result<String, AttrError>
350where
351    I: Iterator<Item = (usize, char)>,
352{
353    match iter.peek().copied() {
354        Some((_, '"')) => {
355            iter.next();
356            read_quoted(iter)
357        }
358        Some((_, c)) if is_bareword(c) => Ok(read_bareword(iter)),
359        _ => Err(AttrError::EmptyValue { key: key.to_string() }),
360    }
361}
362
363/// Read until the closing `"`, supporting `\"` and `\\` escapes.
364fn read_quoted<I>(iter: &mut std::iter::Peekable<I>) -> Result<String, AttrError>
365where
366    I: Iterator<Item = (usize, char)>,
367{
368    let mut s = String::new();
369    loop {
370        match iter.next() {
371            None => return Err(AttrError::UnterminatedQuote),
372            Some((_, '"')) => return Ok(s),
373            Some((_, '\\')) => match iter.next() {
374                None => return Err(AttrError::UnterminatedQuote),
375                Some((_, ch)) => s.push(ch),
376            },
377            Some((_, ch)) => s.push(ch),
378        }
379    }
380}
381
382// ── Multi-line opener support ────────────────────────────────────────
383//
384// The shortcode extractor needs to know when an opener line's `{`
385// doesn't close on the same line, so it can absorb subsequent lines
386// into the attribute block before parsing. These helpers live here
387// (next to `parse_attrs`) because they're grammar primitives, not
388// extraction helpers — any future consumer that sees a partial attr
389// block (e.g. an editor decoration that wants to highlight the live
390// state of a fenced div as the user types) needs them too.
391
392/// Quote-aware brace-depth tracker. Returns the depth after consuming
393/// `s`, starting from `start_depth`.
394///
395/// Tracks `"`-quoted strings so that `{` and `}` inside a value like
396/// `key="{name}"` don't shift the depth. Backslash escapes inside a
397/// string consume the next character verbatim.
398pub fn brace_depth(s: &str, start_depth: i32) -> i32 {
399    let mut depth = start_depth;
400    let mut in_quote = false;
401    let mut chars = s.chars();
402    while let Some(c) = chars.next() {
403        if in_quote {
404            match c {
405                '"' => in_quote = false,
406                '\\' => {
407                    chars.next();
408                }
409                _ => {}
410            }
411            continue;
412        }
413        match c {
414            '"' => in_quote = true,
415            '{' => depth += 1,
416            '}' => depth = (depth - 1).max(0),
417            _ => {}
418        }
419    }
420    depth
421}
422
423/// If `single_line_args` opens a `{` that doesn't close on the same line,
424/// scan `following_lines` and join them onto args until the brace closes.
425///
426/// Returns `(extended_args_owned, lines_consumed)` where:
427/// - `extended_args_owned = Some(s)` when multi-line scanning happened.
428///   `None` means the single-line args already balance and the caller
429///   should keep using the original `&str`.
430/// - `lines_consumed` is how many lines after the opener were absorbed
431///   into the attribute block. The body starts at
432///   `following_lines[lines_consumed..]`.
433///
434/// Brace balancing tracks ASCII `{` and `}` outside quoted strings. A
435/// `\"`-escape inside a quoted string is recognized so authored content
436/// like `key="say \"hi\""` doesn't desynchronize the scanner.
437///
438/// If the brace never closes (ill-formed input), returns the gathered
439/// content verbatim so the caller can pass it on; the attribute parser
440/// will emit a structural error and the block falls through to the
441/// pass-through path.
442pub fn gather_multi_line_attrs(
443    single_line_args: &str,
444    following_lines: &[&str],
445) -> (Option<String>, usize) {
446    let depth_after_first = brace_depth(single_line_args, 0);
447    if depth_after_first == 0 {
448        return (None, 0);
449    }
450
451    let mut combined = single_line_args.to_string();
452    let mut depth = depth_after_first;
453    let mut consumed = 0;
454    for &line in following_lines {
455        // Insert a newline so the attribute parser sees the line break
456        // as whitespace (its grammar treats all whitespace identically).
457        combined.push('\n');
458        combined.push_str(line);
459        consumed += 1;
460        depth = brace_depth(line, depth);
461        if depth == 0 {
462            return (Some(combined), consumed);
463        }
464    }
465
466    // Brace never closed within the document.
467    (Some(combined), consumed)
468}
469
470#[cfg(test)]
471#[path = "attrs_tests.rs"]
472mod tests;