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