1use serde::{Deserialize, Serialize};
12
13#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "specta", derive(specta::Type))]
37pub struct EditorDivider {
38 pub range: EditorRange,
40 pub legacy: bool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[cfg_attr(feature = "specta", derive(specta::Type))]
47pub struct EditorShortcodeBlock {
48 pub open: EditorRange,
50 pub close: EditorRange,
52 pub name: String,
54 pub args: String,
56 pub dividers: Vec<EditorDivider>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "specta", derive(specta::Type))]
64pub struct EditorScanResult {
65 pub blocks: Vec<EditorShortcodeBlock>,
66 pub legacy_dash: bool,
71}
72
73pub 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 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 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 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 arity_stack.push(inner_arity);
139 depth += 1;
140 } else if let Some(current_arity) = arity_stack.last().copied() {
141 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 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
185fn 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
222fn 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 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
248fn 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
254fn 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 }); assert_eq!(b.close, EditorRange { from: 21, to: 24 }); assert!(b.dividers.is_empty());
292 assert!(!r.legacy_dash);
293 }
294
295 #[test]
296 fn nested_blocks_only_emit_outer() {
297 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 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 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 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 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 assert!(!r.legacy_dash);
377 }
378
379 #[test]
380 fn extra_plus_signs_are_not_divider() {
381 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 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 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 let md = ":::grid 2\n```\n:::\n```\n:::buttons\n:::\n:::\n";
447 let r = editor_scan(md);
448 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 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 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); }
477
478 #[test]
479 fn mismatched_arity_close_drops_both_blocks() {
480 let md = ":::grid\n::::buttons\nbody\n:::\n:::\n";
484 let r = editor_scan(md);
486 assert!(r.blocks.is_empty(),
487 "mismatched inner close should leave outer block unclosed too");
488 }
489}