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    let mut current_is_grid: bool = false;
67
68    let mut in_code_fence = false;
69    let mut code_fence_marker = String::new();
70
71    // `offset` is `u32` to match `EditorRange`'s field type. Documents larger
72    // than 4 GiB aren't a real editor scenario; we'd truncate silently rather
73    // than panic in that pathological case.
74    let mut offset: u32 = 0;
75    for line in markdown.split_inclusive('\n') {
76        let has_newline = line.ends_with('\n');
77        let line_len_without_newline = if has_newline {
78            line.len() - 1
79        } else {
80            line.len()
81        };
82        let line_content = &line[..line_len_without_newline];
83        let line_start = offset;
84        let line_end = offset + line_len_without_newline as u32;
85
86        // Code-fence tracking: stable ``` or ~~~ fences (length >= 3).
87        if let Some(fence) = match_code_fence(line_content) {
88            if !in_code_fence {
89                in_code_fence = true;
90                code_fence_marker = fence.to_string();
91            } else if fence.starts_with(code_fence_marker.as_str().chars().next().unwrap())
92                && fence.len() >= code_fence_marker.len()
93            {
94                in_code_fence = false;
95                code_fence_marker.clear();
96            }
97            offset += line.len() as u32;
98            continue;
99        }
100        if in_code_fence {
101            offset += line.len() as u32;
102            continue;
103        }
104
105        if depth == 0 {
106            if let Some((name, args)) = match_open_fence(line_content) {
107                current_is_grid = name == "grid";
108                current = Some(PartialBlock {
109                    open: EditorRange {
110                        from: line_start,
111                        to: line_end,
112                    },
113                    name: name.to_string(),
114                    args: args.to_string(),
115                    dividers: Vec::new(),
116                });
117                depth = 1;
118            }
119        } else if match_open_fence(line_content).is_some() {
120            depth += 1;
121        } else if is_close_fence(line_content) {
122            depth -= 1;
123            if depth == 0 {
124                if let Some(partial) = current.take() {
125                    blocks.push(EditorShortcodeBlock {
126                        open: partial.open,
127                        close: EditorRange {
128                            from: line_start,
129                            to: line_end,
130                        },
131                        name: partial.name,
132                        args: partial.args,
133                        dividers: partial.dividers,
134                    });
135                }
136                current_is_grid = false;
137            }
138        } else if depth == 1 && current_is_grid {
139            // Divider check only applies at depth 1 inside a grid block.
140            if let Some(divider_range) =
141                match_divider(line_content, line_start, &mut legacy_dash)
142            {
143                if let Some(c) = current.as_mut() {
144                    c.dividers.push(divider_range);
145                }
146            }
147        }
148
149        offset += line.len() as u32;
150    }
151
152    EditorScanResult {
153        blocks,
154        legacy_dash,
155    }
156}
157
158/// Match a grid divider line. Recognizes exactly `+++` (canonical) and
159/// exactly `---` (deprecated, sets `legacy_dash` to true). Both allow
160/// surrounding whitespace but the line must contain nothing else.
161///
162/// Returns the source range covering the `+++` or `---` characters only,
163/// excluding leading/trailing whitespace.
164fn match_divider(
165    line: &str,
166    line_start: u32,
167    legacy_dash: &mut bool,
168) -> Option<EditorRange> {
169    let trimmed = line.trim();
170    let kind = match trimmed {
171        "+++" => DividerKind::Canonical,
172        "---" => DividerKind::LegacyDash,
173        _ => return None,
174    };
175
176    let leading_ws = (line.len() - line.trim_start().len()) as u32;
177    if matches!(kind, DividerKind::LegacyDash) {
178        *legacy_dash = true;
179    }
180    Some(EditorRange {
181        from: line_start + leading_ws,
182        to: line_start + leading_ws + 3,
183    })
184}
185
186enum DividerKind {
187    Canonical,
188    LegacyDash,
189}
190
191struct PartialBlock {
192    open: EditorRange,
193    name: String,
194    args: String,
195    dividers: Vec<EditorRange>,
196}
197
198/// Match `:::name args...` opening fence. Returns `(name, args)` if matched.
199/// Mirrors the `SHORTCODE_OPEN` regex previously in `cm-shortcode.ts` but
200/// without depending on the regex crate.
201fn match_open_fence(line: &str) -> Option<(&str, &str)> {
202    let trimmed = line.trim_start();
203    let leading_ws = line.len() - trimmed.len();
204    let rest = trimmed.strip_prefix(":::")?;
205
206    // The name starts immediately after `:::` (no space).
207    let name_start_in_line = leading_ws + 3;
208    // Match the name-char set used by `parse_shortcode_opener` in
209    // `shortcode_extract.rs`: alphanumeric, underscore, hyphen. Plugins can
210    // register names like `:::my-widget`, and the editor must recognize them
211    // or its depth counter drifts from the build pipeline.
212    let name_bytes = rest
213        .bytes()
214        .take_while(|b| {
215            b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-'
216        })
217        .count();
218    if name_bytes == 0 {
219        return None;
220    }
221    let name = &line[name_start_in_line..name_start_in_line + name_bytes];
222
223    let after_name = &line[name_start_in_line + name_bytes..];
224    let args = after_name.trim();
225    Some((name, args))
226}
227
228/// Match `:::` closing fence (no name).
229fn is_close_fence(line: &str) -> bool {
230    line.trim() == ":::"
231}
232
233/// Return the fence string (sequence of `` ` `` or `~`) if the line is a
234/// fenced-code delimiter at the start of the line, else None.
235fn match_code_fence(line: &str) -> Option<&str> {
236    let trimmed = line.trim_start();
237    let ch = trimmed.chars().next()?;
238    if ch != '`' && ch != '~' {
239        return None;
240    }
241    let len = trimmed.chars().take_while(|c| *c == ch).count();
242    if len < 3 {
243        return None;
244    }
245    let leading_ws = line.len() - trimmed.len();
246    Some(&line[leading_ws..leading_ws + len])
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn empty_input_returns_empty_result() {
255        let r = editor_scan("");
256        assert!(r.blocks.is_empty());
257        assert!(!r.legacy_dash);
258    }
259
260    #[test]
261    fn finds_single_grid_block_no_dividers() {
262        let md = ":::grid 2\nleft\nright\n:::\n";
263        let r = editor_scan(md);
264
265        assert_eq!(r.blocks.len(), 1);
266        let b = &r.blocks[0];
267        assert_eq!(b.name, "grid");
268        assert_eq!(b.args, "2");
269        assert_eq!(b.open, EditorRange { from: 0, to: 9 });   // ":::grid 2"
270        assert_eq!(b.close, EditorRange { from: 21, to: 24 }); // ":::"
271        assert!(b.dividers.is_empty());
272        assert!(!r.legacy_dash);
273    }
274
275    #[test]
276    fn nested_blocks_only_emit_outer() {
277        // Outer :::grid contains a nested :::buttons. We only emit the outer
278        // block; the inner one's open/close fences don't escape.
279        let md = ":::grid 2\n:::buttons\n[a](#)\n:::\n:::\n";
280        let r = editor_scan(md);
281
282        assert_eq!(r.blocks.len(), 1);
283        assert_eq!(r.blocks[0].name, "grid");
284    }
285
286    #[test]
287    fn unclosed_block_is_dropped() {
288        let md = ":::grid 2\nleft\nright\n";
289        let r = editor_scan(md);
290        assert!(r.blocks.is_empty());
291    }
292
293    #[test]
294    fn two_sibling_blocks() {
295        let md = ":::buttons\n[a](#)\n:::\n\n:::gallery\n[]()\n:::\n";
296        let r = editor_scan(md);
297
298        assert_eq!(r.blocks.len(), 2);
299        assert_eq!(r.blocks[0].name, "buttons");
300        assert_eq!(r.blocks[1].name, "gallery");
301    }
302
303    #[test]
304    fn grid_with_canonical_plus_divider() {
305        let md = ":::grid 2\nleft\n+++\nright\n:::\n";
306        let r = editor_scan(md);
307
308        assert_eq!(r.blocks.len(), 1);
309        let b = &r.blocks[0];
310        assert_eq!(b.dividers.len(), 1);
311        // "+++" starts after ":::grid 2\nleft\n" (10 + 5 = 15) and is 3 chars long.
312        assert_eq!(b.dividers[0], EditorRange { from: 15, to: 18 });
313        assert!(!r.legacy_dash);
314    }
315
316    #[test]
317    fn grid_with_legacy_dash_divider_sets_flag() {
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        assert_eq!(r.blocks[0].dividers.len(), 1);
323        assert!(r.legacy_dash, "expected legacy_dash flag for --- divider");
324    }
325
326    #[test]
327    fn dividers_only_at_top_depth() {
328        // A nested :::buttons block contains a "---" line. That line is INSIDE
329        // the nested block, so the outer grid should have zero dividers. (The
330        // nested block isn't emitted at all per the nesting rule.)
331        let md = ":::grid 2\n:::buttons\n---\n:::\n:::\n";
332        let r = editor_scan(md);
333
334        assert_eq!(r.blocks.len(), 1);
335        assert!(r.blocks[0].dividers.is_empty());
336        // legacy_dash is also false because the --- was inside a buttons block,
337        // not a grid divider.
338        assert!(!r.legacy_dash);
339    }
340
341    #[test]
342    fn extra_plus_signs_are_not_divider() {
343        // ++++ (four pluses) is NOT a divider — strict 3-char match.
344        let md = ":::grid 2\nleft\n++++\nright\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    }
350
351    #[test]
352    fn divider_with_leading_whitespace_is_recognized() {
353        let md = ":::grid 2\nleft\n  +++\nright\n:::\n";
354        let r = editor_scan(md);
355
356        assert_eq!(r.blocks.len(), 1);
357        assert_eq!(r.blocks[0].dividers.len(), 1);
358        // Range covers the "+++" only, not the leading spaces.
359        let div = r.blocks[0].dividers[0];
360        let line_text = &md[div.from as usize..div.to as usize];
361        assert_eq!(line_text, "+++");
362    }
363
364    #[test]
365    fn hyphenated_names_are_recognized() {
366        // Plugins can register shortcode names containing hyphens (e.g.
367        // `:::my-widget`). `parse_shortcode_opener` in shortcode_extract.rs
368        // accepts these; editor_scan must agree or its depth counter will
369        // drift on documents that use them.
370        let md = ":::my-widget\nbody\n:::\n";
371        let r = editor_scan(md);
372
373        assert_eq!(r.blocks.len(), 1);
374        assert_eq!(r.blocks[0].name, "my-widget");
375    }
376
377    #[test]
378    fn shortcode_open_inside_code_fence_is_inert() {
379        let md = "```\n:::grid 2\n```\n";
380        let r = editor_scan(md);
381        assert!(r.blocks.is_empty());
382    }
383
384    #[test]
385    fn shortcode_open_after_closing_code_fence_works() {
386        let md = "```\nignored\n```\n:::buttons\n[a](#)\n:::\n";
387        let r = editor_scan(md);
388        assert_eq!(r.blocks.len(), 1);
389        assert_eq!(r.blocks[0].name, "buttons");
390    }
391
392    #[test]
393    fn close_fence_inside_code_fence_does_not_close_outer_shortcode() {
394        // Without code-fence tracking, the ::: inside the ``` block would
395        // incorrectly close the :::grid block, and then :::buttons after the
396        // code fence would look like a second sibling block. With tracking,
397        // only the outer ::: after the code fence closes the grid block, so
398        // we see exactly 1 block (grid) and 0 extra sibling blocks.
399        //
400        // Structure:
401        //   :::grid 2        <- open grid (depth 0→1)
402        //   ```
403        //   :::              <- should be inert (inside code fence)
404        //   ```
405        //   :::buttons       <- opens nested shortcode (depth 1→2), not a sibling
406        //   :::              <- closes nested (depth 2→1)
407        //   :::              <- closes grid (depth 1→0)
408        let md = ":::grid 2\n```\n:::\n```\n:::buttons\n:::\n:::\n";
409        let r = editor_scan(md);
410        // With correct fence tracking: grid is the only top-level block.
411        assert_eq!(r.blocks.len(), 1);
412        assert_eq!(r.blocks[0].name, "grid");
413    }
414}