Skip to main content

moss_core/ast/
editor_scan.rs

1//! Editor-facing shortcode scanner.
2//!
3//! Companion to `extract_shortcodes` (which returns typed AST nodes for the
4//! build pipeline). `editor_scan` returns source-position information needed
5//! by the CodeMirror plugin: opening-fence ranges, closing-fence ranges,
6//! cell-divider ranges, and a flag indicating whether legacy `---` dividers
7//! were used.
8//!
9//! Pure, no I/O. Safe to call from any Tauri thread.
10
11use serde::{Deserialize, Serialize};
12
13/// Half-open byte-offset range `[from, to)` into the original markdown
14/// source. Bytes, not characters: positions are taken straight from
15/// `&str` slicing math so they line up with what the editor receives over
16/// the wire (the markdown is shipped as a `String`, and CodeMirror's own
17/// position model is on the JS side; we never index the source as chars).
18///
19/// `u32` instead of `usize` so specta maps the fields to TS `number`
20/// rather than `string` (JS Number can represent the full u32 range
21/// precisely; a 64-bit `usize` cannot fit losslessly). 4 GiB markdown
22/// is not a real moss editor scenario.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "specta", derive(specta::Type))]
25pub struct EditorRange {
26    pub from: u32,
27    pub to: u32,
28}
29
30/// One shortcode block as seen by the editor.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "specta", derive(specta::Type))]
33pub struct EditorShortcodeBlock {
34    /// Opening fence line (e.g. `:::grid 2`).
35    pub open: EditorRange,
36    /// Closing fence line (e.g. `:::`).
37    pub close: EditorRange,
38    /// Shortcode name (e.g. "grid", "buttons").
39    pub name: String,
40    /// Trailing args after the name (e.g. "2", "{.primary}").
41    pub args: String,
42    /// Top-level cell divider lines (only the dividers at this block's depth;
43    /// nested-block dividers belong to their own block entry).
44    pub dividers: Vec<EditorRange>,
45}
46
47/// Result of editor-side shortcode scanning.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "specta", derive(specta::Type))]
50pub struct EditorScanResult {
51    pub blocks: Vec<EditorShortcodeBlock>,
52    /// True if any divider line in any grid block used the deprecated `---`
53    /// form. The build pipeline emits a stronger warning; the editor uses
54    /// this to surface a UI hint or console message.
55    pub legacy_dash: bool,
56}
57
58/// Scan markdown for top-level shortcode blocks, returning source-position
59/// information for the editor.
60pub fn editor_scan(markdown: &str) -> EditorScanResult {
61    let mut blocks = Vec::new();
62    let mut legacy_dash = false;
63
64    let mut current: Option<PartialBlock> = None;
65    let mut depth: usize = 0;
66    // Stack of arities for each nesting level. Entry 0 = outermost block's arity.
67    // This prevents an inner ::: from accidentally closing an outer :::grid.
68    let mut arity_stack: Vec<usize> = Vec::new();
69    let mut current_is_grid: bool = false;
70
71    let mut in_code_fence = false;
72    let mut code_fence_marker = String::new();
73
74    // `offset` is `u32` to match `EditorRange`'s field type. Documents larger
75    // than 4 GiB aren't a real editor scenario; we'd truncate silently rather
76    // than panic in that pathological case.
77    let mut offset: u32 = 0;
78    for line in markdown.split_inclusive('\n') {
79        let has_newline = line.ends_with('\n');
80        let line_len_without_newline = if has_newline {
81            line.len() - 1
82        } else {
83            line.len()
84        };
85        let line_content = &line[..line_len_without_newline];
86        let line_start = offset;
87        let line_end = offset + line_len_without_newline as u32;
88
89        // Code-fence tracking: stable ``` or ~~~ fences (length >= 3).
90        if let Some(fence) = match_code_fence(line_content) {
91            if !in_code_fence {
92                in_code_fence = true;
93                code_fence_marker = fence.to_string();
94            } else if fence.starts_with(code_fence_marker.as_str().chars().next().unwrap())
95                && fence.len() >= code_fence_marker.len()
96            {
97                in_code_fence = false;
98                code_fence_marker.clear();
99            }
100            offset += line.len() as u32;
101            continue;
102        }
103        if in_code_fence {
104            offset += line.len() as u32;
105            continue;
106        }
107
108        if depth == 0 {
109            if let Some((arity, name, args)) = match_open_fence(line_content) {
110                current_is_grid = name == "grid";
111                current = Some(PartialBlock {
112                    open: EditorRange {
113                        from: line_start,
114                        to: line_end,
115                    },
116                    name: name.to_string(),
117                    args: args.to_string(),
118                    dividers: Vec::new(),
119                });
120                arity_stack.push(arity);
121                depth = 1;
122            }
123        } else if let Some((inner_arity, _, _)) = match_open_fence(line_content) {
124            // Nested opener — push its arity; depth increments.
125            arity_stack.push(inner_arity);
126            depth += 1;
127        } else {
128            // Check if this line closes the CURRENT depth level's block.
129            // SAFETY: arity_stack.len() == depth, and depth > 0 in this branch.
130            let current_arity = arity_stack.last().copied()
131                .expect("arity_stack is non-empty when depth > 0");
132            if is_close_fence(line_content, current_arity) {
133                arity_stack.pop();
134                depth -= 1;
135                if depth == 0 {
136                    if let Some(partial) = current.take() {
137                        blocks.push(EditorShortcodeBlock {
138                            open: partial.open,
139                            close: EditorRange {
140                                from: line_start,
141                                to: line_end,
142                            },
143                            name: partial.name,
144                            args: partial.args,
145                            dividers: partial.dividers,
146                        });
147                    }
148                    current_is_grid = false;
149                }
150            } else if depth == 1 && current_is_grid {
151                // Divider check only applies at depth 1 inside a grid block,
152                // and only on lines that are not open/close fences.
153                if let Some(divider_range) =
154                    match_divider(line_content, line_start, &mut legacy_dash)
155                {
156                    if let Some(c) = current.as_mut() {
157                        c.dividers.push(divider_range);
158                    }
159                }
160            }
161        }
162
163        offset += line.len() as u32;
164    }
165
166    EditorScanResult {
167        blocks,
168        legacy_dash,
169    }
170}
171
172/// Match a grid divider line. Recognizes exactly `+++` (canonical) and
173/// exactly `---` (deprecated, sets `legacy_dash` to true). Both allow
174/// surrounding whitespace but the line must contain nothing else.
175///
176/// Returns the source range covering the `+++` or `---` characters only,
177/// excluding leading/trailing whitespace.
178fn match_divider(
179    line: &str,
180    line_start: u32,
181    legacy_dash: &mut bool,
182) -> Option<EditorRange> {
183    let trimmed = line.trim();
184    let kind = match trimmed {
185        "+++" => DividerKind::Canonical,
186        "---" => DividerKind::LegacyDash,
187        _ => return None,
188    };
189
190    let leading_ws = (line.len() - line.trim_start().len()) as u32;
191    if matches!(kind, DividerKind::LegacyDash) {
192        *legacy_dash = true;
193    }
194    Some(EditorRange {
195        from: line_start + leading_ws,
196        to: line_start + leading_ws + 3,
197    })
198}
199
200enum DividerKind {
201    Canonical,
202    LegacyDash,
203}
204
205struct PartialBlock {
206    open: EditorRange,
207    name: String,
208    args: String,
209    dividers: Vec<EditorRange>,
210}
211
212/// Match `:::name args...` or `::::name args...` etc. (arity ≥ 3).
213/// Returns `(arity, name, args)` if matched.
214fn match_open_fence(line: &str) -> Option<(usize, &str, &str)> {
215    let trimmed = line.trim_start();
216    let leading_ws = line.len() - trimmed.len();
217    let arity = trimmed.bytes().take_while(|b| *b == b':').count();
218    if arity < 3 {
219        return None;
220    }
221    let rest = &trimmed[arity..];
222    let name_start_in_line = leading_ws + arity;
223    // Match the name-char set used by `parse_shortcode_opener` in
224    // `shortcode_extract.rs`: alphanumeric, underscore, hyphen. Plugins can
225    // register names like `:::my-widget`, and the editor must recognize them
226    // or its depth counter drifts from the build pipeline.
227    let name_bytes = rest
228        .bytes()
229        .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-')
230        .count();
231    if name_bytes == 0 {
232        return None;
233    }
234    let name = &line[name_start_in_line..name_start_in_line + name_bytes];
235    let after_name = &line[name_start_in_line + name_bytes..];
236    let args = after_name.trim();
237    Some((arity, name, args))
238}
239
240/// Match a closing fence of the given arity (exactly `arity` colons, nothing else).
241fn is_close_fence(line: &str, arity: usize) -> bool {
242    let trimmed = line.trim();
243    trimmed.len() == arity && trimmed.bytes().all(|b| b == b':')
244}
245
246/// Return the fence string (sequence of `` ` `` or `~`) if the line is a
247/// fenced-code delimiter at the start of the line, else None.
248fn match_code_fence(line: &str) -> Option<&str> {
249    let trimmed = line.trim_start();
250    let ch = trimmed.chars().next()?;
251    if ch != '`' && ch != '~' {
252        return None;
253    }
254    let len = trimmed.chars().take_while(|c| *c == ch).count();
255    if len < 3 {
256        return None;
257    }
258    let leading_ws = line.len() - trimmed.len();
259    Some(&line[leading_ws..leading_ws + len])
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn empty_input_returns_empty_result() {
268        let r = editor_scan("");
269        assert!(r.blocks.is_empty());
270        assert!(!r.legacy_dash);
271    }
272
273    #[test]
274    fn finds_single_grid_block_no_dividers() {
275        let md = ":::grid 2\nleft\nright\n:::\n";
276        let r = editor_scan(md);
277
278        assert_eq!(r.blocks.len(), 1);
279        let b = &r.blocks[0];
280        assert_eq!(b.name, "grid");
281        assert_eq!(b.args, "2");
282        assert_eq!(b.open, EditorRange { from: 0, to: 9 });   // ":::grid 2"
283        assert_eq!(b.close, EditorRange { from: 21, to: 24 }); // ":::"
284        assert!(b.dividers.is_empty());
285        assert!(!r.legacy_dash);
286    }
287
288    #[test]
289    fn nested_blocks_only_emit_outer() {
290        // Outer :::grid contains a nested :::buttons. We only emit the outer
291        // block; the inner one's open/close fences don't escape.
292        let md = ":::grid 2\n:::buttons\n[a](#)\n:::\n:::\n";
293        let r = editor_scan(md);
294
295        assert_eq!(r.blocks.len(), 1);
296        assert_eq!(r.blocks[0].name, "grid");
297    }
298
299    #[test]
300    fn unclosed_block_is_dropped() {
301        let md = ":::grid 2\nleft\nright\n";
302        let r = editor_scan(md);
303        assert!(r.blocks.is_empty());
304    }
305
306    #[test]
307    fn two_sibling_blocks() {
308        let md = ":::buttons\n[a](#)\n:::\n\n:::gallery\n[]()\n:::\n";
309        let r = editor_scan(md);
310
311        assert_eq!(r.blocks.len(), 2);
312        assert_eq!(r.blocks[0].name, "buttons");
313        assert_eq!(r.blocks[1].name, "gallery");
314    }
315
316    #[test]
317    fn grid_with_canonical_plus_divider() {
318        let md = ":::grid 2\nleft\n+++\nright\n:::\n";
319        let r = editor_scan(md);
320
321        assert_eq!(r.blocks.len(), 1);
322        let b = &r.blocks[0];
323        assert_eq!(b.dividers.len(), 1);
324        // "+++" starts after ":::grid 2\nleft\n" (10 + 5 = 15) and is 3 chars long.
325        assert_eq!(b.dividers[0], EditorRange { from: 15, to: 18 });
326        assert!(!r.legacy_dash);
327    }
328
329    #[test]
330    fn grid_with_legacy_dash_divider_sets_flag() {
331        let md = ":::grid 2\nleft\n---\nright\n:::\n";
332        let r = editor_scan(md);
333
334        assert_eq!(r.blocks.len(), 1);
335        assert_eq!(r.blocks[0].dividers.len(), 1);
336        assert!(r.legacy_dash, "expected legacy_dash flag for --- divider");
337    }
338
339    #[test]
340    fn dividers_only_at_top_depth() {
341        // A nested :::buttons block contains a "---" line. That line is INSIDE
342        // the nested block, so the outer grid should have zero dividers. (The
343        // nested block isn't emitted at all per the nesting rule.)
344        let md = ":::grid 2\n:::buttons\n---\n:::\n:::\n";
345        let r = editor_scan(md);
346
347        assert_eq!(r.blocks.len(), 1);
348        assert!(r.blocks[0].dividers.is_empty());
349        // legacy_dash is also false because the --- was inside a buttons block,
350        // not a grid divider.
351        assert!(!r.legacy_dash);
352    }
353
354    #[test]
355    fn extra_plus_signs_are_not_divider() {
356        // ++++ (four pluses) is NOT a divider — strict 3-char match.
357        let md = ":::grid 2\nleft\n++++\nright\n:::\n";
358        let r = editor_scan(md);
359
360        assert_eq!(r.blocks.len(), 1);
361        assert!(r.blocks[0].dividers.is_empty());
362    }
363
364    #[test]
365    fn divider_with_leading_whitespace_is_recognized() {
366        let md = ":::grid 2\nleft\n  +++\nright\n:::\n";
367        let r = editor_scan(md);
368
369        assert_eq!(r.blocks.len(), 1);
370        assert_eq!(r.blocks[0].dividers.len(), 1);
371        // Range covers the "+++" only, not the leading spaces.
372        let div = r.blocks[0].dividers[0];
373        let line_text = &md[div.from as usize..div.to as usize];
374        assert_eq!(line_text, "+++");
375    }
376
377    #[test]
378    fn hyphenated_names_are_recognized() {
379        // Plugins can register shortcode names containing hyphens (e.g.
380        // `:::my-widget`). `parse_shortcode_opener` in shortcode_extract.rs
381        // accepts these; editor_scan must agree or its depth counter will
382        // drift on documents that use them.
383        let md = ":::my-widget\nbody\n:::\n";
384        let r = editor_scan(md);
385
386        assert_eq!(r.blocks.len(), 1);
387        assert_eq!(r.blocks[0].name, "my-widget");
388    }
389
390    #[test]
391    fn shortcode_open_inside_code_fence_is_inert() {
392        let md = "```\n:::grid 2\n```\n";
393        let r = editor_scan(md);
394        assert!(r.blocks.is_empty());
395    }
396
397    #[test]
398    fn shortcode_open_after_closing_code_fence_works() {
399        let md = "```\nignored\n```\n:::buttons\n[a](#)\n:::\n";
400        let r = editor_scan(md);
401        assert_eq!(r.blocks.len(), 1);
402        assert_eq!(r.blocks[0].name, "buttons");
403    }
404
405    #[test]
406    fn close_fence_inside_code_fence_does_not_close_outer_shortcode() {
407        // Without code-fence tracking, the ::: inside the ``` block would
408        // incorrectly close the :::grid block, and then :::buttons after the
409        // code fence would look like a second sibling block. With tracking,
410        // only the outer ::: after the code fence closes the grid block, so
411        // we see exactly 1 block (grid) and 0 extra sibling blocks.
412        //
413        // Structure:
414        //   :::grid 2        <- open grid (depth 0→1)
415        //   ```
416        //   :::              <- should be inert (inside code fence)
417        //   ```
418        //   :::buttons       <- opens nested shortcode (depth 1→2), not a sibling
419        //   :::              <- closes nested (depth 2→1)
420        //   :::              <- closes grid (depth 1→0)
421        let md = ":::grid 2\n```\n:::\n```\n:::buttons\n:::\n:::\n";
422        let r = editor_scan(md);
423        // With correct fence tracking: grid is the only top-level block.
424        assert_eq!(r.blocks.len(), 1);
425        assert_eq!(r.blocks[0].name, "grid");
426    }
427
428    #[test]
429    fn nested_four_colon_block_closes_correctly() {
430        // ::::buttons inside :::grid uses 4-colon arity.
431        // The outer :::grid must close at the final :::, not be corrupted.
432        // Byte offsets: ":::grid 2\n"=10, "::::buttons\n"=12, "[a](#)\n"=7, "::::\n"=5, "cell two\n"=9 → 43
433        let md = ":::grid 2\n::::buttons\n[a](#)\n::::\ncell two\n:::\n";
434        let r = editor_scan(md);
435
436        assert_eq!(r.blocks.len(), 1, "only outer grid should be emitted");
437        assert_eq!(r.blocks[0].name, "grid");
438        assert_eq!(r.blocks[0].close.from, 43);
439    }
440
441    #[test]
442    fn four_colon_top_level_block_is_recognized() {
443        // A top-level ::::gallery block (4-colon arity) must be scanned correctly.
444        let md = "::::gallery\nimg.jpg\n::::\n";
445        let r = editor_scan(md);
446
447        assert_eq!(r.blocks.len(), 1);
448        assert_eq!(r.blocks[0].name, "gallery");
449        assert_eq!(r.blocks[0].open.from, 0);
450        assert_eq!(r.blocks[0].close.from, 20); // after "::::gallery\nimg.jpg\n"
451    }
452
453    #[test]
454    fn mismatched_arity_close_drops_both_blocks() {
455        // If ::::buttons (4-colon) is closed with ::: (3-colon), the inner
456        // block never closes. The outer :::grid is also dropped as unclosed.
457        // Correct: malformed markup produces no blocks.
458        let md = ":::grid\n::::buttons\nbody\n:::\n:::\n";
459        //                              ^^^ wrong arity — should be ::::
460        let r = editor_scan(md);
461        assert!(r.blocks.is_empty(),
462            "mismatched inner close should leave outer block unclosed too");
463    }
464}