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, 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<EditorRange>,
}
#[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 has_newline = line.ends_with('\n');
let line_len_without_newline = if has_newline {
line.len() - 1
} else {
line.len()
};
let line_content = &line[..line_len_without_newline];
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 fence.starts_with(code_fence_marker.as_str().chars().next().unwrap())
&& 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 {
let current_arity = arity_stack.last().copied()
.expect("arity_stack is non-empty when depth > 0");
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_range) =
match_divider(line_content, line_start, &mut legacy_dash)
{
if let Some(c) = current.as_mut() {
c.dividers.push(divider_range);
}
}
}
}
offset += line.len() as u32;
}
EditorScanResult {
blocks,
legacy_dash,
}
}
fn match_divider(
line: &str,
line_start: u32,
legacy_dash: &mut bool,
) -> Option<EditorRange> {
let trimmed = line.trim();
let kind = match trimmed {
"+++" => DividerKind::Canonical,
"---" => DividerKind::LegacyDash,
_ => return None,
};
let leading_ws = (line.len() - line.trim_start().len()) as u32;
if matches!(kind, DividerKind::LegacyDash) {
*legacy_dash = true;
}
Some(EditorRange {
from: line_start + leading_ws,
to: line_start + leading_ws + 3,
})
}
enum DividerKind {
Canonical,
LegacyDash,
}
struct PartialBlock {
open: EditorRange,
name: String,
args: String,
dividers: Vec<EditorRange>,
}
fn match_open_fence(line: &str) -> Option<(usize, &str, &str)> {
let trimmed = line.trim_start();
let leading_ws = line.len() - trimmed.len();
let arity = trimmed.bytes().take_while(|b| *b == b':').count();
if arity < 3 {
return None;
}
let rest = &trimmed[arity..];
let name_start_in_line = leading_ws + 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 = &line[name_start_in_line..name_start_in_line + name_bytes];
let after_name = &line[name_start_in_line + 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;
}
let leading_ws = line.len() - trimmed.len();
Some(&line[leading_ws..leading_ws + 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], EditorRange { from: 15, to: 18 });
assert!(!r.legacy_dash);
}
#[test]
fn grid_with_legacy_dash_divider_sets_flag() {
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.legacy_dash, "expected legacy_dash flag for --- divider");
}
#[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];
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");
}
}