use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct EditorRange {
pub from: u32,
pub to: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct EditorDivider {
pub range: EditorRange,
pub legacy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct EditorShortcodeBlock {
pub open: EditorRange,
pub close: EditorRange,
pub name: String,
pub args: String,
pub dividers: Vec<EditorDivider>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
pub struct EditorScanResult {
pub blocks: Vec<EditorShortcodeBlock>,
pub legacy_dash: bool,
}
pub fn editor_scan(markdown: &str) -> EditorScanResult {
let mut blocks = Vec::new();
let mut legacy_dash = false;
let mut current: Option<PartialBlock> = None;
let mut depth: usize = 0;
let mut arity_stack: Vec<usize> = Vec::new();
let mut current_is_grid: bool = false;
let mut in_code_fence = false;
let mut code_fence_marker = String::new();
let mut offset: u32 = 0;
for line in markdown.split_inclusive('\n') {
let line_content = line.strip_suffix('\n').unwrap_or(line);
let line_len_without_newline = line_content.len();
let line_start = offset;
let line_end = offset + line_len_without_newline as u32;
if let Some(fence) = match_code_fence(line_content) {
if !in_code_fence {
in_code_fence = true;
code_fence_marker = fence.to_string();
} else if code_fence_marker
.chars()
.next()
.is_some_and(|marker_ch| fence.starts_with(marker_ch))
&& fence.len() >= code_fence_marker.len()
{
in_code_fence = false;
code_fence_marker.clear();
}
offset += line.len() as u32;
continue;
}
if in_code_fence {
offset += line.len() as u32;
continue;
}
if depth == 0 {
if let Some((arity, name, args)) = match_open_fence(line_content) {
current_is_grid = name == "grid";
current = Some(PartialBlock {
open: EditorRange {
from: line_start,
to: line_end,
},
name: name.to_string(),
args: args.to_string(),
dividers: Vec::new(),
});
arity_stack.push(arity);
depth = 1;
}
} else if let Some((inner_arity, _, _)) = match_open_fence(line_content) {
arity_stack.push(inner_arity);
depth += 1;
} else if let Some(current_arity) = arity_stack.last().copied() {
if is_close_fence(line_content, current_arity) {
arity_stack.pop();
depth -= 1;
if depth == 0 {
if let Some(partial) = current.take() {
blocks.push(EditorShortcodeBlock {
open: partial.open,
close: EditorRange {
from: line_start,
to: line_end,
},
name: partial.name,
args: partial.args,
dividers: partial.dividers,
});
}
current_is_grid = false;
}
} else if depth == 1 && current_is_grid {
if let Some(divider) =
match_divider(line_content, line_start, &mut legacy_dash)
{
if let Some(c) = current.as_mut() {
c.dividers.push(divider);
}
}
}
}
offset += line.len() as u32;
}
EditorScanResult {
blocks,
legacy_dash,
}
}
fn match_divider(
line: &str,
line_start: u32,
legacy_dash: &mut bool,
) -> Option<EditorDivider> {
let legacy = match line.trim() {
"+++" => false,
"---" => true,
_ => return None,
};
let leading_ws = (line.len() - line.trim_start().len()) as u32;
if legacy {
*legacy_dash = true;
}
Some(EditorDivider {
range: EditorRange {
from: line_start + leading_ws,
to: line_start + leading_ws + 3,
},
legacy,
})
}
struct PartialBlock {
open: EditorRange,
name: String,
args: String,
dividers: Vec<EditorDivider>,
}
fn match_open_fence(line: &str) -> Option<(usize, &str, &str)> {
let trimmed = line.trim_start();
let arity = trimmed.bytes().take_while(|b| *b == b':').count();
if arity < 3 {
return None;
}
let rest = trimmed.get(arity..)?;
let name_bytes = rest
.bytes()
.take_while(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-')
.count();
if name_bytes == 0 {
return None;
}
let name = rest.get(..name_bytes)?;
let after_name = rest.get(name_bytes..)?;
let args = after_name.trim();
Some((arity, name, args))
}
fn is_close_fence(line: &str, arity: usize) -> bool {
let trimmed = line.trim();
trimmed.len() == arity && trimmed.bytes().all(|b| b == b':')
}
fn match_code_fence(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let ch = trimmed.chars().next()?;
if ch != '`' && ch != '~' {
return None;
}
let len = trimmed.chars().take_while(|c| *c == ch).count();
if len < 3 {
return None;
}
trimmed.get(..len)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_returns_empty_result() {
let r = editor_scan("");
assert!(r.blocks.is_empty());
assert!(!r.legacy_dash);
}
#[test]
fn finds_single_grid_block_no_dividers() {
let md = ":::grid 2\nleft\nright\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
let b = &r.blocks[0];
assert_eq!(b.name, "grid");
assert_eq!(b.args, "2");
assert_eq!(b.open, EditorRange { from: 0, to: 9 }); assert_eq!(b.close, EditorRange { from: 21, to: 24 }); assert!(b.dividers.is_empty());
assert!(!r.legacy_dash);
}
#[test]
fn nested_blocks_only_emit_outer() {
let md = ":::grid 2\n:::buttons\n[a](#)\n:::\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].name, "grid");
}
#[test]
fn unclosed_block_is_dropped() {
let md = ":::grid 2\nleft\nright\n";
let r = editor_scan(md);
assert!(r.blocks.is_empty());
}
#[test]
fn two_sibling_blocks() {
let md = ":::buttons\n[a](#)\n:::\n\n:::gallery\n[]()\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 2);
assert_eq!(r.blocks[0].name, "buttons");
assert_eq!(r.blocks[1].name, "gallery");
}
#[test]
fn grid_with_canonical_plus_divider() {
let md = ":::grid 2\nleft\n+++\nright\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
let b = &r.blocks[0];
assert_eq!(b.dividers.len(), 1);
assert_eq!(b.dividers[0].range, EditorRange { from: 15, to: 18 });
assert!(!b.dividers[0].legacy);
assert!(!r.legacy_dash);
}
#[test]
fn grid_with_legacy_dash_divider_is_marked_per_divider_and_document_wide() {
let md = ":::grid 2\nleft\n---\nright\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].dividers.len(), 1);
assert!(r.blocks[0].dividers[0].legacy);
assert!(r.legacy_dash, "expected legacy_dash flag for --- divider");
}
#[test]
fn mixed_dividers_are_flagged_individually() {
let md = ":::grid 3\na\n+++\nb\n---\nc\n:::\n";
let r = editor_scan(md);
let dividers = &r.blocks[0].dividers;
assert_eq!(dividers.iter().map(|d| d.legacy).collect::<Vec<_>>(), vec![false, true]);
let d = dividers[1].range;
assert_eq!(&md[d.from as usize..d.to as usize], "---");
assert!(r.legacy_dash);
}
#[test]
fn dividers_only_at_top_depth() {
let md = ":::grid 2\n:::buttons\n---\n:::\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert!(r.blocks[0].dividers.is_empty());
assert!(!r.legacy_dash);
}
#[test]
fn extra_plus_signs_are_not_divider() {
let md = ":::grid 2\nleft\n++++\nright\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert!(r.blocks[0].dividers.is_empty());
}
#[test]
fn divider_with_leading_whitespace_is_recognized() {
let md = ":::grid 2\nleft\n +++\nright\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].dividers.len(), 1);
let div = r.blocks[0].dividers[0].range;
let line_text = &md[div.from as usize..div.to as usize];
assert_eq!(line_text, "+++");
}
#[test]
fn hyphenated_names_are_recognized() {
let md = ":::my-widget\nbody\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].name, "my-widget");
}
#[test]
fn shortcode_open_inside_code_fence_is_inert() {
let md = "```\n:::grid 2\n```\n";
let r = editor_scan(md);
assert!(r.blocks.is_empty());
}
#[test]
fn shortcode_open_after_closing_code_fence_works() {
let md = "```\nignored\n```\n:::buttons\n[a](#)\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].name, "buttons");
}
#[test]
fn close_fence_inside_code_fence_does_not_close_outer_shortcode() {
let md = ":::grid 2\n```\n:::\n```\n:::buttons\n:::\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].name, "grid");
}
#[test]
fn nested_four_colon_block_closes_correctly() {
let md = ":::grid 2\n::::buttons\n[a](#)\n::::\ncell two\n:::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1, "only outer grid should be emitted");
assert_eq!(r.blocks[0].name, "grid");
assert_eq!(r.blocks[0].close.from, 43);
}
#[test]
fn four_colon_top_level_block_is_recognized() {
let md = "::::gallery\nimg.jpg\n::::\n";
let r = editor_scan(md);
assert_eq!(r.blocks.len(), 1);
assert_eq!(r.blocks[0].name, "gallery");
assert_eq!(r.blocks[0].open.from, 0);
assert_eq!(r.blocks[0].close.from, 20); }
#[test]
fn mismatched_arity_close_drops_both_blocks() {
let md = ":::grid\n::::buttons\nbody\n:::\n:::\n";
let r = editor_scan(md);
assert!(r.blocks.is_empty(),
"mismatched inner close should leave outer block unclosed too");
}
}