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, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "specta", derive(specta::Type))]
33pub struct EditorShortcodeBlock {
34 pub open: EditorRange,
36 pub close: EditorRange,
38 pub name: String,
40 pub args: String,
42 pub dividers: Vec<EditorRange>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[cfg_attr(feature = "specta", derive(specta::Type))]
50pub struct EditorScanResult {
51 pub blocks: Vec<EditorShortcodeBlock>,
52 pub legacy_dash: bool,
56}
57
58pub 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 arity_stack: Vec<usize> = Vec::new();
69 let mut current_is_grid: bool = false;
70
71 let mut in_code_fence = false;
72 let mut code_fence_marker = String::new();
73
74 let mut offset: u32 = 0;
78 for line in markdown.split_inclusive('\n') {
79 let has_newline = line.ends_with('\n');
80 let line_len_without_newline = if has_newline {
81 line.len() - 1
82 } else {
83 line.len()
84 };
85 let line_content = &line[..line_len_without_newline];
86 let line_start = offset;
87 let line_end = offset + line_len_without_newline as u32;
88
89 if let Some(fence) = match_code_fence(line_content) {
91 if !in_code_fence {
92 in_code_fence = true;
93 code_fence_marker = fence.to_string();
94 } else if fence.starts_with(code_fence_marker.as_str().chars().next().unwrap())
95 && fence.len() >= code_fence_marker.len()
96 {
97 in_code_fence = false;
98 code_fence_marker.clear();
99 }
100 offset += line.len() as u32;
101 continue;
102 }
103 if in_code_fence {
104 offset += line.len() as u32;
105 continue;
106 }
107
108 if depth == 0 {
109 if let Some((arity, name, args)) = match_open_fence(line_content) {
110 current_is_grid = name == "grid";
111 current = Some(PartialBlock {
112 open: EditorRange {
113 from: line_start,
114 to: line_end,
115 },
116 name: name.to_string(),
117 args: args.to_string(),
118 dividers: Vec::new(),
119 });
120 arity_stack.push(arity);
121 depth = 1;
122 }
123 } else if let Some((inner_arity, _, _)) = match_open_fence(line_content) {
124 arity_stack.push(inner_arity);
126 depth += 1;
127 } else {
128 let current_arity = arity_stack.last().copied()
131 .expect("arity_stack is non-empty when depth > 0");
132 if is_close_fence(line_content, current_arity) {
133 arity_stack.pop();
134 depth -= 1;
135 if depth == 0 {
136 if let Some(partial) = current.take() {
137 blocks.push(EditorShortcodeBlock {
138 open: partial.open,
139 close: EditorRange {
140 from: line_start,
141 to: line_end,
142 },
143 name: partial.name,
144 args: partial.args,
145 dividers: partial.dividers,
146 });
147 }
148 current_is_grid = false;
149 }
150 } else if depth == 1 && current_is_grid {
151 if let Some(divider_range) =
154 match_divider(line_content, line_start, &mut legacy_dash)
155 {
156 if let Some(c) = current.as_mut() {
157 c.dividers.push(divider_range);
158 }
159 }
160 }
161 }
162
163 offset += line.len() as u32;
164 }
165
166 EditorScanResult {
167 blocks,
168 legacy_dash,
169 }
170}
171
172fn match_divider(
179 line: &str,
180 line_start: u32,
181 legacy_dash: &mut bool,
182) -> Option<EditorRange> {
183 let trimmed = line.trim();
184 let kind = match trimmed {
185 "+++" => DividerKind::Canonical,
186 "---" => DividerKind::LegacyDash,
187 _ => return None,
188 };
189
190 let leading_ws = (line.len() - line.trim_start().len()) as u32;
191 if matches!(kind, DividerKind::LegacyDash) {
192 *legacy_dash = true;
193 }
194 Some(EditorRange {
195 from: line_start + leading_ws,
196 to: line_start + leading_ws + 3,
197 })
198}
199
200enum DividerKind {
201 Canonical,
202 LegacyDash,
203}
204
205struct PartialBlock {
206 open: EditorRange,
207 name: String,
208 args: String,
209 dividers: Vec<EditorRange>,
210}
211
212fn match_open_fence(line: &str) -> Option<(usize, &str, &str)> {
215 let trimmed = line.trim_start();
216 let leading_ws = line.len() - trimmed.len();
217 let arity = trimmed.bytes().take_while(|b| *b == b':').count();
218 if arity < 3 {
219 return None;
220 }
221 let rest = &trimmed[arity..];
222 let name_start_in_line = leading_ws + arity;
223 let name_bytes = rest
228 .bytes()
229 .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-')
230 .count();
231 if name_bytes == 0 {
232 return None;
233 }
234 let name = &line[name_start_in_line..name_start_in_line + name_bytes];
235 let after_name = &line[name_start_in_line + name_bytes..];
236 let args = after_name.trim();
237 Some((arity, name, args))
238}
239
240fn is_close_fence(line: &str, arity: usize) -> bool {
242 let trimmed = line.trim();
243 trimmed.len() == arity && trimmed.bytes().all(|b| b == b':')
244}
245
246fn match_code_fence(line: &str) -> Option<&str> {
249 let trimmed = line.trim_start();
250 let ch = trimmed.chars().next()?;
251 if ch != '`' && ch != '~' {
252 return None;
253 }
254 let len = trimmed.chars().take_while(|c| *c == ch).count();
255 if len < 3 {
256 return None;
257 }
258 let leading_ws = line.len() - trimmed.len();
259 Some(&line[leading_ws..leading_ws + len])
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn empty_input_returns_empty_result() {
268 let r = editor_scan("");
269 assert!(r.blocks.is_empty());
270 assert!(!r.legacy_dash);
271 }
272
273 #[test]
274 fn finds_single_grid_block_no_dividers() {
275 let md = ":::grid 2\nleft\nright\n:::\n";
276 let r = editor_scan(md);
277
278 assert_eq!(r.blocks.len(), 1);
279 let b = &r.blocks[0];
280 assert_eq!(b.name, "grid");
281 assert_eq!(b.args, "2");
282 assert_eq!(b.open, EditorRange { from: 0, to: 9 }); assert_eq!(b.close, EditorRange { from: 21, to: 24 }); assert!(b.dividers.is_empty());
285 assert!(!r.legacy_dash);
286 }
287
288 #[test]
289 fn nested_blocks_only_emit_outer() {
290 let md = ":::grid 2\n:::buttons\n[a](#)\n:::\n:::\n";
293 let r = editor_scan(md);
294
295 assert_eq!(r.blocks.len(), 1);
296 assert_eq!(r.blocks[0].name, "grid");
297 }
298
299 #[test]
300 fn unclosed_block_is_dropped() {
301 let md = ":::grid 2\nleft\nright\n";
302 let r = editor_scan(md);
303 assert!(r.blocks.is_empty());
304 }
305
306 #[test]
307 fn two_sibling_blocks() {
308 let md = ":::buttons\n[a](#)\n:::\n\n:::gallery\n[]()\n:::\n";
309 let r = editor_scan(md);
310
311 assert_eq!(r.blocks.len(), 2);
312 assert_eq!(r.blocks[0].name, "buttons");
313 assert_eq!(r.blocks[1].name, "gallery");
314 }
315
316 #[test]
317 fn grid_with_canonical_plus_divider() {
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 let b = &r.blocks[0];
323 assert_eq!(b.dividers.len(), 1);
324 assert_eq!(b.dividers[0], EditorRange { from: 15, to: 18 });
326 assert!(!r.legacy_dash);
327 }
328
329 #[test]
330 fn grid_with_legacy_dash_divider_sets_flag() {
331 let md = ":::grid 2\nleft\n---\nright\n:::\n";
332 let r = editor_scan(md);
333
334 assert_eq!(r.blocks.len(), 1);
335 assert_eq!(r.blocks[0].dividers.len(), 1);
336 assert!(r.legacy_dash, "expected legacy_dash flag for --- divider");
337 }
338
339 #[test]
340 fn dividers_only_at_top_depth() {
341 let md = ":::grid 2\n:::buttons\n---\n:::\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 assert!(!r.legacy_dash);
352 }
353
354 #[test]
355 fn extra_plus_signs_are_not_divider() {
356 let md = ":::grid 2\nleft\n++++\nright\n:::\n";
358 let r = editor_scan(md);
359
360 assert_eq!(r.blocks.len(), 1);
361 assert!(r.blocks[0].dividers.is_empty());
362 }
363
364 #[test]
365 fn divider_with_leading_whitespace_is_recognized() {
366 let md = ":::grid 2\nleft\n +++\nright\n:::\n";
367 let r = editor_scan(md);
368
369 assert_eq!(r.blocks.len(), 1);
370 assert_eq!(r.blocks[0].dividers.len(), 1);
371 let div = r.blocks[0].dividers[0];
373 let line_text = &md[div.from as usize..div.to as usize];
374 assert_eq!(line_text, "+++");
375 }
376
377 #[test]
378 fn hyphenated_names_are_recognized() {
379 let md = ":::my-widget\nbody\n:::\n";
384 let r = editor_scan(md);
385
386 assert_eq!(r.blocks.len(), 1);
387 assert_eq!(r.blocks[0].name, "my-widget");
388 }
389
390 #[test]
391 fn shortcode_open_inside_code_fence_is_inert() {
392 let md = "```\n:::grid 2\n```\n";
393 let r = editor_scan(md);
394 assert!(r.blocks.is_empty());
395 }
396
397 #[test]
398 fn shortcode_open_after_closing_code_fence_works() {
399 let md = "```\nignored\n```\n:::buttons\n[a](#)\n:::\n";
400 let r = editor_scan(md);
401 assert_eq!(r.blocks.len(), 1);
402 assert_eq!(r.blocks[0].name, "buttons");
403 }
404
405 #[test]
406 fn close_fence_inside_code_fence_does_not_close_outer_shortcode() {
407 let md = ":::grid 2\n```\n:::\n```\n:::buttons\n:::\n:::\n";
422 let r = editor_scan(md);
423 assert_eq!(r.blocks.len(), 1);
425 assert_eq!(r.blocks[0].name, "grid");
426 }
427
428 #[test]
429 fn nested_four_colon_block_closes_correctly() {
430 let md = ":::grid 2\n::::buttons\n[a](#)\n::::\ncell two\n:::\n";
434 let r = editor_scan(md);
435
436 assert_eq!(r.blocks.len(), 1, "only outer grid should be emitted");
437 assert_eq!(r.blocks[0].name, "grid");
438 assert_eq!(r.blocks[0].close.from, 43);
439 }
440
441 #[test]
442 fn four_colon_top_level_block_is_recognized() {
443 let md = "::::gallery\nimg.jpg\n::::\n";
445 let r = editor_scan(md);
446
447 assert_eq!(r.blocks.len(), 1);
448 assert_eq!(r.blocks[0].name, "gallery");
449 assert_eq!(r.blocks[0].open.from, 0);
450 assert_eq!(r.blocks[0].close.from, 20); }
452
453 #[test]
454 fn mismatched_arity_close_drops_both_blocks() {
455 let md = ":::grid\n::::buttons\nbody\n:::\n:::\n";
459 let r = editor_scan(md);
461 assert!(r.blocks.is_empty(),
462 "mismatched inner close should leave outer block unclosed too");
463 }
464}