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;