Skip to main content

moss_core/
shortcode_tokens.rs

1//! Tokenizer for shortcode opening, closing, and divider lines.
2//!
3//! Pure Rust, zero I/O, zero async. Takes a single line and returns
4//! a flat list of tokens with byte offsets.
5
6use serde::{Deserialize, Serialize};
7
8// ---------------------------------------------------------------------------
9// Public types
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub enum ShortcodeTokenType {
15    Fence,
16    Name,
17    Number,
18    Ratio,
19    BraceOpen,
20    BraceClose,
21    ClassName,
22    Divider,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ShortcodeToken {
27    #[serde(rename = "type")]
28    pub token_type: ShortcodeTokenType,
29    pub from: usize,
30    pub to: usize,
31}
32
33// ---------------------------------------------------------------------------
34// Tokenizers
35// ---------------------------------------------------------------------------
36
37/// Tokenize an opening shortcode line such as `:::grid 3 1:2 {.profiles}`.
38pub fn tokenize_opening_line(line: &str) -> Vec<ShortcodeToken> {
39    let bytes = line.as_bytes();
40    let len = bytes.len();
41    let mut pos = skip_whitespace(bytes, 0);
42    let mut tokens = Vec::new();
43
44    // 1. Expect ":::"
45    if !bytes[pos..].starts_with(b":::") {
46        return tokens;
47    }
48    tokens.push(ShortcodeToken {
49        token_type: ShortcodeTokenType::Fence,
50        from: pos,
51        to: pos + 3,
52    });
53    pos += 3;
54
55    // 2. Expect a name: [a-zA-Z_]\w*
56    if pos < len && is_name_start(bytes[pos]) {
57        let start = pos;
58        pos += 1;
59        while pos < len && is_word_char(bytes[pos]) {
60            pos += 1;
61        }
62        tokens.push(ShortcodeToken {
63            token_type: ShortcodeTokenType::Name,
64            from: start,
65            to: pos,
66        });
67    }
68
69    // 3. Arguments loop
70    while pos < len {
71        // Skip whitespace between arguments
72        let new_pos = skip_whitespace(bytes, pos);
73        if new_pos >= len {
74            break;
75        }
76        pos = new_pos;
77
78        // Try ratio first (digit+:digit+) — must check before plain number
79        if let Some(end) = try_ratio(bytes, pos) {
80            tokens.push(ShortcodeToken {
81                token_type: ShortcodeTokenType::Ratio,
82                from: pos,
83                to: end,
84            });
85            pos = end;
86            continue;
87        }
88
89        // Try number
90        if bytes[pos].is_ascii_digit() {
91            let start = pos;
92            while pos < len && bytes[pos].is_ascii_digit() {
93                pos += 1;
94            }
95            tokens.push(ShortcodeToken {
96                token_type: ShortcodeTokenType::Number,
97                from: start,
98                to: pos,
99            });
100            continue;
101        }
102
103        // Brace open
104        if bytes[pos] == b'{' {
105            tokens.push(ShortcodeToken {
106                token_type: ShortcodeTokenType::BraceOpen,
107                from: pos,
108                to: pos + 1,
109            });
110            pos += 1;
111            continue;
112        }
113
114        // Class name: . followed by [a-zA-Z_] then [\w-]*
115        if bytes[pos] == b'.'
116            && pos + 1 < len
117            && is_name_start(bytes[pos + 1])
118        {
119            let start = pos;
120            pos += 2; // skip '.' and first char
121            while pos < len && is_class_char(bytes[pos]) {
122                pos += 1;
123            }
124            tokens.push(ShortcodeToken {
125                token_type: ShortcodeTokenType::ClassName,
126                from: start,
127                to: pos,
128            });
129            continue;
130        }
131
132        // Brace close
133        if bytes[pos] == b'}' {
134            tokens.push(ShortcodeToken {
135                token_type: ShortcodeTokenType::BraceClose,
136                from: pos,
137                to: pos + 1,
138            });
139            pos += 1;
140            continue;
141        }
142
143        // Unknown character — consume and skip
144        pos += 1;
145    }
146
147    tokens
148}
149
150/// Tokenize a closing shortcode line (`:::`).
151pub fn tokenize_closing_line(line: &str) -> Vec<ShortcodeToken> {
152    let bytes = line.as_bytes();
153    let pos = skip_whitespace(bytes, 0);
154    let mut tokens = Vec::new();
155
156    if bytes[pos..].starts_with(b":::") {
157        tokens.push(ShortcodeToken {
158            token_type: ShortcodeTokenType::Fence,
159            from: pos,
160            to: pos + 3,
161        });
162    }
163
164    tokens
165}
166
167/// Tokenize a divider line (`+++` canonical, `---` deprecated).
168pub fn tokenize_divider_line(line: &str) -> Vec<ShortcodeToken> {
169    let bytes = line.as_bytes();
170    let pos = skip_whitespace(bytes, 0);
171    let mut tokens = Vec::new();
172
173    if bytes[pos..].starts_with(b"+++") || bytes[pos..].starts_with(b"---") {
174        tokens.push(ShortcodeToken {
175            token_type: ShortcodeTokenType::Divider,
176            from: pos,
177            to: pos + 3,
178        });
179    }
180
181    tokens
182}
183
184// ---------------------------------------------------------------------------
185// HTML rendering
186// ---------------------------------------------------------------------------
187
188/// Produce syntax-highlighted HTML for a shortcode line.
189///
190/// Each token is wrapped in `<span class="hl-TYPE">`. Gaps between tokens
191/// (including leading whitespace) are HTML-escaped and emitted as plain text.
192pub fn tokens_to_html(line: &str, tokens: &[ShortcodeToken]) -> String {
193    let mut out = String::with_capacity(line.len() * 2);
194    let mut cursor = 0;
195
196    for tok in tokens {
197        // Emit gap before this token
198        if tok.from > cursor {
199            // Token offsets are produced by the byte-cursor tokenizer above,
200            // which only advances on ASCII bytes (`:`, digits, `{`, `}`, `.`,
201            // and ASCII name/word chars). Every recorded `from`/`to` therefore
202            // lies on a UTF-8 char boundary.
203            #[allow(clippy::string_slice)]
204            html_escape_into(&line[cursor..tok.from], &mut out);
205        }
206        let class = css_class(tok.token_type);
207        out.push_str("<span class=\"");
208        out.push_str(class);
209        out.push_str("\">");
210        // Same invariant as the gap slice above: tokenizer only records ASCII
211        // byte offsets, so `from`/`to` are char-boundary safe.
212        #[allow(clippy::string_slice)]
213        html_escape_into(&line[tok.from..tok.to], &mut out);
214        out.push_str("</span>");
215        cursor = tok.to;
216    }
217
218    // Trailing text after last token
219    if cursor < line.len() {
220        // `cursor` was last set to a token's `to` field — an ASCII byte offset
221        // produced by the tokenizer, so it sits on a char boundary.
222        #[allow(clippy::string_slice)]
223        html_escape_into(&line[cursor..], &mut out);
224    }
225
226    out
227}
228
229// ---------------------------------------------------------------------------
230// Helpers
231// ---------------------------------------------------------------------------
232
233fn skip_whitespace(bytes: &[u8], mut pos: usize) -> usize {
234    while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
235        pos += 1;
236    }
237    pos
238}
239
240fn is_name_start(b: u8) -> bool {
241    b.is_ascii_alphabetic() || b == b'_'
242}
243
244fn is_word_char(b: u8) -> bool {
245    b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
246}
247
248/// CSS class names can contain hyphens in addition to word chars.
249fn is_class_char(b: u8) -> bool {
250    is_word_char(b) || b == b'-'
251}
252
253/// Try to match `\d+:\d+` starting at `pos`.
254/// Returns `Some(end)` if matched, `None` otherwise.
255fn try_ratio(bytes: &[u8], pos: usize) -> Option<usize> {
256    let len = bytes.len();
257    if pos >= len || !bytes[pos].is_ascii_digit() {
258        return None;
259    }
260
261    // Consume first digit run
262    let mut i = pos;
263    while i < len && bytes[i].is_ascii_digit() {
264        i += 1;
265    }
266
267    // Must see ':'
268    if i >= len || bytes[i] != b':' {
269        return None;
270    }
271    i += 1;
272
273    // Must see at least one digit after ':'
274    if i >= len || !bytes[i].is_ascii_digit() {
275        return None;
276    }
277    while i < len && bytes[i].is_ascii_digit() {
278        i += 1;
279    }
280
281    Some(i)
282}
283
284fn css_class(tt: ShortcodeTokenType) -> &'static str {
285    match tt {
286        ShortcodeTokenType::Fence => "hl-punct",
287        ShortcodeTokenType::Name => "hl-tag",
288        ShortcodeTokenType::Number => "hl-attr",
289        ShortcodeTokenType::Ratio => "hl-val",
290        ShortcodeTokenType::BraceOpen => "hl-brace",
291        ShortcodeTokenType::BraceClose => "hl-brace",
292        ShortcodeTokenType::ClassName => "hl-val",
293        ShortcodeTokenType::Divider => "hl-punct",
294    }
295}
296
297fn html_escape_into(s: &str, out: &mut String) {
298    for ch in s.chars() {
299        match ch {
300            '&' => out.push_str("&amp;"),
301            '<' => out.push_str("&lt;"),
302            '>' => out.push_str("&gt;"),
303            '"' => out.push_str("&quot;"),
304            _ => out.push(ch),
305        }
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    // Fixture format mirrors the JSON file.
318    #[derive(Deserialize)]
319    struct Fixture {
320        description: String,
321        input: String,
322        kind: String,
323        expected: Vec<ShortcodeToken>,
324    }
325
326    const FIXTURES: &str = include_str!("../../../tests/fixtures/shortcode-tokens.json");
327
328    #[test]
329    fn fixture_driven_tests() {
330        let fixtures: Vec<Fixture> =
331            serde_json::from_str(FIXTURES).expect("failed to parse fixtures JSON");
332
333        for fixture in &fixtures {
334            let tokens = match fixture.kind.as_str() {
335                "opening" => tokenize_opening_line(&fixture.input),
336                "closing" => tokenize_closing_line(&fixture.input),
337                "divider" => tokenize_divider_line(&fixture.input),
338                other => panic!("unknown kind {:?} in fixture {:?}", other, fixture.description),
339            };
340
341            assert_eq!(
342                tokens, fixture.expected,
343                "FAILED: {}\n  input: {:?}\n  got:      {:?}\n  expected: {:?}",
344                fixture.description, fixture.input, tokens, fixture.expected,
345            );
346        }
347    }
348
349    #[test]
350    fn tokens_to_html_basic() {
351        let line = ":::grid 3";
352        let tokens = tokenize_opening_line(line);
353        let html = tokens_to_html(line, &tokens);
354        assert_eq!(
355            html,
356            "<span class=\"hl-punct\">:::</span>\
357             <span class=\"hl-tag\">grid</span> \
358             <span class=\"hl-attr\">3</span>"
359        );
360    }
361
362    #[test]
363    fn tokens_to_html_escapes_special_chars() {
364        // Fabricate a line with special HTML characters in a gap.
365        let line = ":::tag <>&\"";
366        let tokens = tokenize_opening_line(line);
367        let html = tokens_to_html(line, &tokens);
368        // The gap after the name token contains ` <>&"` — only the gap text
369        // gets escaped (the name "tag" is clean).
370        assert!(html.contains("&lt;&gt;&amp;&quot;"), "html was: {html}");
371    }
372
373    #[test]
374    fn tokens_to_html_closing() {
375        let line = "  :::";
376        let tokens = tokenize_closing_line(line);
377        let html = tokens_to_html(line, &tokens);
378        assert_eq!(html, "  <span class=\"hl-punct\">:::</span>");
379    }
380
381    #[test]
382    fn tokens_to_html_divider() {
383        let line = "---";
384        let tokens = tokenize_divider_line(line);
385        let html = tokens_to_html(line, &tokens);
386        assert_eq!(html, "<span class=\"hl-punct\">---</span>");
387    }
388}