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 current_is_grid: bool = false;
67
68 let mut in_code_fence = false;
69 let mut code_fence_marker = String::new();
70
71 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 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 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
158fn 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
198fn 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 let name_start_in_line = leading_ws + 3;
208 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
228fn is_close_fence(line: &str) -> bool {
230 line.trim() == ":::"
231}
232
233fn 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 }); assert_eq!(b.close, EditorRange { from: 21, to: 24 }); assert!(b.dividers.is_empty());
272 assert!(!r.legacy_dash);
273 }
274
275 #[test]
276 fn nested_blocks_only_emit_outer() {
277 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 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 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 assert!(!r.legacy_dash);
339 }
340
341 #[test]
342 fn extra_plus_signs_are_not_divider() {
343 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 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 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 let md = ":::grid 2\n```\n:::\n```\n:::buttons\n:::\n:::\n";
409 let r = editor_scan(md);
410 assert_eq!(r.blocks.len(), 1);
412 assert_eq!(r.blocks[0].name, "grid");
413 }
414}