1use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct DirectiveInstance {
21 pub name: String,
22 pub attrs: BTreeMap<String, String>,
23 pub label: String,
25 pub inner_md: String,
27 pub is_block: bool,
28 pub line: usize,
31}
32
33pub(crate) fn sentinel(idx: usize) -> String {
36 format!("<!--docgen-directive:{idx}-->")
37}
38
39fn is_name_start(c: char) -> bool {
41 c.is_ascii_alphabetic()
42}
43fn is_name_char(c: char) -> bool {
44 c.is_ascii_alphanumeric() || c == '_' || c == '-'
45}
46
47pub fn parse_attrs(s: &str) -> BTreeMap<String, String> {
51 let mut out = BTreeMap::new();
52 let chars: Vec<char> = s.chars().collect();
53 let mut i = 0;
54 while i < chars.len() {
55 if chars[i].is_whitespace() {
57 i += 1;
58 continue;
59 }
60 let key_start = i;
62 while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
63 i += 1;
64 }
65 let key: String = chars[key_start..i].iter().collect();
66 if key.is_empty() {
67 i += 1;
68 continue;
69 }
70 if i >= chars.len() || chars[i] != '=' {
72 out.insert(key, "true".to_string());
73 continue;
74 }
75 i += 1; let value = if i < chars.len() && chars[i] == '"' {
78 i += 1; let v_start = i;
80 while i < chars.len() && chars[i] != '"' {
81 i += 1;
82 }
83 let v: String = chars[v_start..i].iter().collect();
84 if i < chars.len() {
85 i += 1; }
87 v
88 } else {
89 let v_start = i;
90 while i < chars.len() && !chars[i].is_whitespace() {
91 i += 1;
92 }
93 chars[v_start..i].iter().collect()
94 };
95 out.insert(key, value);
96 }
97 out
98}
99
100fn parse_block_open(trimmed: &str) -> Option<(String, String)> {
103 let rest = trimmed.strip_prefix(":::")?;
104 let mut chars = rest.char_indices();
105 let (first_i, first) = chars.next()?;
106 debug_assert_eq!(first_i, 0);
107 if !is_name_start(first) {
108 return None;
109 }
110 let mut end = first.len_utf8();
111 for (i, c) in rest.char_indices().skip(1) {
112 if is_name_char(c) {
113 end = i + c.len_utf8();
114 } else {
115 break;
116 }
117 }
118 let name = &rest[..end];
119 let after = rest[end..].trim();
120 let attrs = if after.is_empty() {
122 String::new()
123 } else if after.starts_with('{') && after.ends_with('}') {
124 after[1..after.len() - 1].to_string()
125 } else {
126 return None;
127 };
128 Some((name.to_string(), attrs))
129}
130
131struct Fence {
136 ch: char,
137 len: usize,
138 has_info: bool,
139}
140
141fn parse_fence(line: &str) -> Option<Fence> {
145 let indent = line.len() - line.trim_start().len();
146 if indent > 3 {
147 return None; }
149 let rest = &line[indent..];
150 let ch = rest.chars().next()?;
151 if ch != '`' && ch != '~' {
152 return None;
153 }
154 let len = rest.chars().take_while(|&c| c == ch).count();
155 if len < 3 {
156 return None;
157 }
158 let info = rest.chars().skip(len).collect::<String>();
159 let info = info.trim();
160 if ch == '`' && info.contains('`') {
162 return None;
163 }
164 Some(Fence {
165 ch,
166 len,
167 has_info: !info.is_empty(),
168 })
169}
170
171pub fn extract(body_md: &str) -> (String, Vec<DirectiveInstance>) {
179 let mut instances: Vec<DirectiveInstance> = Vec::new();
180 let mut out_lines: Vec<String> = Vec::new();
181
182 let lines: Vec<&str> = body_md.split('\n').collect();
183 let mut i = 0;
184 let mut open_fence: Option<Fence> = None;
186 while i < lines.len() {
187 let line = lines[i];
188
189 if let Some(open) = &open_fence {
191 if let Some(f) = parse_fence(line) {
192 if f.ch == open.ch && f.len >= open.len && !f.has_info {
193 open_fence = None;
194 }
195 }
196 out_lines.push(line.to_string());
197 i += 1;
198 continue;
199 }
200 if let Some(f) = parse_fence(line) {
202 open_fence = Some(f);
203 out_lines.push(line.to_string());
204 i += 1;
205 continue;
206 }
207
208 let trimmed = line.trim();
209
210 if let Some(rest) = trimmed.strip_prefix('\\') {
212 if rest.starts_with(":::") || (rest.starts_with(':') && looks_like_leaf(rest)) {
213 let indent = &line[..line.len() - line.trim_start().len()];
214 out_lines.push(format!("{indent}{rest}"));
215 i += 1;
216 continue;
217 }
218 }
219
220 if let Some((name, attrs_str)) = parse_block_open(trimmed) {
222 let mut depth = 1;
224 let mut inner: Vec<&str> = Vec::new();
225 let mut j = i + 1;
226 let mut closed = false;
227 while j < lines.len() {
228 let t = lines[j].trim();
229 if t == ":::" {
230 depth -= 1;
231 if depth == 0 {
232 closed = true;
233 break;
234 }
235 } else if parse_block_open(t).is_some() {
236 depth += 1;
237 }
238 inner.push(lines[j]);
239 j += 1;
240 }
241 if closed {
242 let idx = instances.len();
243 instances.push(DirectiveInstance {
244 name,
245 attrs: parse_attrs(&attrs_str),
246 label: String::new(),
247 inner_md: inner.join("\n"),
248 is_block: true,
249 line: i + 1,
250 });
251 out_lines.push(sentinel(idx));
252 i = j + 1; continue;
254 }
255 }
257
258 out_lines.push(scan_leaf_line(line, i + 1, &mut instances));
260 i += 1;
261 }
262
263 (out_lines.join("\n"), instances)
264}
265
266fn looks_like_leaf(rest: &str) -> bool {
269 let body = &rest[1..];
270 let name_len = body
271 .char_indices()
272 .take_while(|(k, c)| {
273 if *k == 0 {
274 is_name_start(*c)
275 } else {
276 is_name_char(*c)
277 }
278 })
279 .map(|(_, c)| c.len_utf8())
280 .sum::<usize>();
281 if name_len == 0 {
282 return false;
283 }
284 matches!(body[name_len..].chars().next(), Some('[') | Some('{'))
285}
286
287fn scan_leaf_line(line: &str, line_no: usize, instances: &mut Vec<DirectiveInstance>) -> String {
292 let chars: Vec<char> = line.chars().collect();
293 let mut out = String::with_capacity(line.len());
294 let mut i = 0;
295 while i < chars.len() {
296 if chars[i] == '`' {
300 let run = (i..chars.len()).take_while(|&k| chars[k] == '`').count();
301 if let Some(end) = find_inline_code_close(&chars, i + run, run) {
302 out.extend(&chars[i..end]); i = end;
304 } else {
305 out.extend(&chars[i..i + run]);
307 i += run;
308 }
309 continue;
310 }
311 if chars[i] == ':' {
312 let prev_colon = i > 0 && chars[i - 1] == ':';
314 let next_colon = i + 1 < chars.len() && chars[i + 1] == ':';
315 if !prev_colon && !next_colon {
316 if let Some((mut inst, consumed)) = try_parse_leaf(&chars, i) {
317 inst.line = line_no;
318 let idx = instances.len();
319 instances.push(inst);
320 out.push_str(&sentinel(idx));
321 i += consumed;
322 continue;
323 }
324 }
325 }
326 out.push(chars[i]);
327 i += 1;
328 }
329 out
330}
331
332fn find_inline_code_close(chars: &[char], from: usize, run: usize) -> Option<usize> {
336 let mut j = from;
337 while j < chars.len() {
338 if chars[j] == '`' {
339 let close = (j..chars.len()).take_while(|&k| chars[k] == '`').count();
340 if close == run {
341 return Some(j + close);
342 }
343 j += close;
344 } else {
345 j += 1;
346 }
347 }
348 None
349}
350
351fn try_parse_leaf(chars: &[char], start: usize) -> Option<(DirectiveInstance, usize)> {
354 let mut i = start + 1; if i >= chars.len() || !is_name_start(chars[i]) {
356 return None;
357 }
358 let name_start = i;
359 while i < chars.len() && is_name_char(chars[i]) {
360 i += 1;
361 }
362 let name: String = chars[name_start..i].iter().collect();
363
364 let mut label = String::new();
368 let mut had_label = false;
369 if i < chars.len() && chars[i] == '[' {
370 i += 1; let label_start = i;
372 while i < chars.len() && chars[i] != ']' {
373 i += 1;
374 }
375 if i >= chars.len() {
376 return None; }
378 label = chars[label_start..i].iter().collect();
379 i += 1; had_label = true;
381 }
382
383 let mut attrs = BTreeMap::new();
385 let mut had_attrs = false;
386 if i < chars.len() && chars[i] == '{' {
387 i += 1; let a_start = i;
389 let mut in_quote = false;
392 while i < chars.len() && (in_quote || chars[i] != '}') {
393 if chars[i] == '"' {
394 in_quote = !in_quote;
395 }
396 i += 1;
397 }
398 if i >= chars.len() {
399 return None; }
401 let attrs_str: String = chars[a_start..i].iter().collect();
402 attrs = parse_attrs(&attrs_str);
403 i += 1; had_attrs = true;
405 }
406
407 if !had_label && !had_attrs {
408 return None;
409 }
410
411 Some((
412 DirectiveInstance {
413 name,
414 attrs,
415 label,
416 inner_md: String::new(),
417 is_block: false,
418 line: 0, },
420 i - start,
421 ))
422}
423
424pub fn substitute(
431 html: &str,
432 instances: &[DirectiveInstance],
433 registry: &docgen_components::Registry,
434 render_inner: &dyn Fn(&str) -> String,
435 resolve_include: &dyn Fn(&str) -> String,
436 render_plantuml: &dyn Fn(usize, &DirectiveInstance) -> String,
437) -> (String, std::collections::BTreeSet<String>) {
438 use docgen_components::DirectiveContext;
439 let mut used = std::collections::BTreeSet::new();
440 let mut out = html.to_string();
441 for (idx, inst) in instances.iter().enumerate() {
442 if inst.name == "plantuml" {
445 let rendered = render_plantuml(idx, inst);
446 out = out.replace(&sentinel(idx), &rendered);
447 continue;
448 }
449 if inst.name == "include" {
452 let src = inst.attrs.get("src").map(String::as_str).unwrap_or("");
453 let rendered = if src.is_empty() {
454 error_span("include", "missing `src`")
455 } else {
456 resolve_include(src)
457 };
458 out = out.replace(&sentinel(idx), &rendered);
459 continue;
460 }
461 let rendered = match registry.get(&inst.name) {
462 Some(component) => {
463 let content = if inst.is_block {
464 render_inner(&inst.inner_md)
465 } else {
466 String::new()
467 };
468 let ctx = DirectiveContext {
469 attrs: inst.attrs.clone(),
470 content,
471 label: inst.label.clone(),
472 id: format!("docgen-d-{idx}"),
473 };
474 match component.render(&ctx) {
475 Ok(h) => {
476 used.insert(inst.name.clone());
477 h
478 }
479 Err(_) => error_span(&inst.name, "template error"),
480 }
481 }
482 None => error_span(&inst.name, "unknown directive"),
483 };
484 out = out.replace(&sentinel(idx), &rendered);
485 }
486 (out, used)
487}
488
489pub(crate) fn error_span(name: &str, reason: &str) -> String {
492 let safe = crate::util::escape_html(name);
493 format!(
494 "<span class=\"docgen-directive-error\" data-directive=\"{safe}\">[docgen: {reason} `{safe}`]</span>"
495 )
496}
497
498#[cfg(test)]
499mod substitute_tests {
500 use super::*;
501
502 fn no_plantuml(_idx: usize, _inst: &DirectiveInstance) -> String {
504 String::new()
505 }
506
507 fn reg_with(name: &str, tpl: &str) -> docgen_components::Registry {
508 let mut r = docgen_components::Registry::empty();
509 r.insert(docgen_components::Component::from_parts(
510 name, tpl, None, None,
511 ));
512 r
513 }
514
515 #[test]
516 fn substitutes_known_block_component_and_renders_inner() {
517 let (html, inst) = extract(":::callout{type=note}\n**hi**\n:::\n");
518 let reg = reg_with(
519 "callout",
520 "<aside class=\"c--{{ attrs.type }}\">{{ content | safe }}</aside>",
521 );
522 let render_inner = |md: &str| format!("<p>{}</p>", md.trim().replace("**", ""));
523 let (out, used) = substitute(
524 &html,
525 &inst,
526 ®,
527 &render_inner,
528 &|_s| String::new(),
529 &no_plantuml,
530 );
531 assert!(out.contains("c--note"));
532 assert!(out.contains("<p>hi</p>"));
533 assert!(used.contains("callout"));
534 assert!(!out.contains("docgen-directive:")); }
536
537 #[test]
538 fn unknown_directive_becomes_marked_error_span_not_panic() {
539 let (html, inst) = extract(":bogus[x]{}\n");
540 let reg = docgen_components::Registry::empty();
541 let (out, used) = substitute(
542 &html,
543 &inst,
544 ®,
545 &|s| s.to_string(),
546 &|_s| String::new(),
547 &no_plantuml,
548 );
549 assert!(out.contains("docgen-directive-error"));
550 assert!(out.contains("unknown directive"));
551 assert!(out.contains("bogus"));
552 assert!(used.is_empty());
553 }
554
555 fn sentinel_doc() -> String {
557 format!("before {} after", sentinel(0))
558 }
559
560 #[test]
561 fn directive_name_in_error_is_escaped() {
562 let inst = vec![DirectiveInstance {
564 name: "<img>".into(),
565 attrs: Default::default(),
566 label: String::new(),
567 inner_md: String::new(),
568 is_block: false,
569 line: 1,
570 }];
571 let html = sentinel_doc();
572 let (out, _) = substitute(
573 &html,
574 &inst,
575 &docgen_components::Registry::empty(),
576 &|s| s.to_string(),
577 &|_s| String::new(),
578 &no_plantuml,
579 );
580 assert!(out.contains("<img>"));
581 assert!(!out.contains("<img>"));
582 }
583
584 #[test]
585 fn template_error_becomes_error_span_not_panic() {
586 let reg = reg_with("boom", "{{ content | nonexistent_filter }}");
588 let (html, inst) = extract(":::boom{}\nx\n:::\n");
589 let (out, used) = substitute(
590 &html,
591 &inst,
592 ®,
593 &|s| s.to_string(),
594 &|_s| String::new(),
595 &no_plantuml,
596 );
597 assert!(out.contains("docgen-directive-error"));
598 assert!(out.contains("template error"));
599 assert!(used.is_empty());
600 }
601}
602
603#[cfg(test)]
604mod extract_tests {
605 use super::*;
606
607 #[test]
608 fn parse_attrs_handles_bare_quoted_and_empty() {
609 let a = parse_attrs("type=warning title=\"Back up first\" wide");
610 assert_eq!(a.get("type").unwrap(), "warning");
611 assert_eq!(a.get("title").unwrap(), "Back up first");
612 assert_eq!(a.get("wide").unwrap(), "true");
613 assert!(parse_attrs("").is_empty());
614 }
615
616 #[test]
617 fn extracts_block_directive_with_inner_markdown() {
618 let src = ":::callout{type=warning title=\"Heads up\"}\nThis is **bold**.\n:::\n";
619 let (out, inst) = extract(src);
620 assert_eq!(inst.len(), 1);
621 assert!(inst[0].is_block);
622 assert_eq!(inst[0].name, "callout");
623 assert_eq!(inst[0].attrs.get("type").unwrap(), "warning");
624 assert_eq!(inst[0].inner_md.trim(), "This is **bold**.");
625 assert!(out.contains("<!--docgen-directive:0-->"));
626 assert!(!out.contains(":::"));
627 }
628
629 #[test]
630 fn extracts_leaf_directive_with_label_and_attrs() {
631 let src = "See :youtube[Intro]{id=abc123} now.\n";
632 let (out, inst) = extract(src);
633 assert_eq!(inst.len(), 1);
634 assert!(!inst[0].is_block);
635 assert_eq!(inst[0].name, "youtube");
636 assert_eq!(inst[0].label, "Intro");
637 assert_eq!(inst[0].attrs.get("id").unwrap(), "abc123");
638 assert!(out.contains("See <!--docgen-directive:0--> now."));
639 }
640
641 #[test]
642 fn nested_block_directives_match_outermost() {
643 let src = ":::callout{type=note}\nouter\n:::callout{type=warning}\ninner\n:::\n:::\n";
644 let (_out, inst) = extract(src);
645 assert_eq!(inst.len(), 1); assert!(inst[0].inner_md.contains(":::callout{type=warning}"));
647 assert!(inst[0].inner_md.contains("inner"));
648 }
649
650 #[test]
651 fn escaped_directive_is_left_literal() {
652 let src = "\\:::callout{}\nnot a directive\n:::\n";
653 let (out, inst) = extract(src);
654 assert!(inst.is_empty());
655 assert!(out.contains(":::callout{}")); }
657
658 #[test]
659 fn plain_text_with_colons_is_not_a_directive() {
660 let src = "time is 10:30 and ratio 3:4\n";
661 let (out, inst) = extract(src);
662 assert!(inst.is_empty());
663 assert_eq!(out, src);
664 }
665
666 #[test]
667 fn block_directive_inside_fenced_code_is_left_literal() {
668 let src = "```\n:::callout{type=note}\nhello\n:::\n```\n";
671 let (out, inst) = extract(src);
672 assert!(
673 inst.is_empty(),
674 "directive inside a code fence must not be extracted"
675 );
676 assert!(out.contains(":::callout{type=note}"));
677 assert!(out.contains("hello"));
678 assert!(!out.contains("docgen-directive"));
679 }
680
681 #[test]
682 fn block_directive_inside_tilde_fence_with_info_is_left_literal() {
683 let src = "~~~markdown\n:::callout{type=warning}\nBe careful.\n:::\n~~~\n";
684 let (out, inst) = extract(src);
685 assert!(inst.is_empty());
686 assert!(out.contains(":::callout{type=warning}"));
687 assert!(out.contains("Be careful."));
688 }
689
690 #[test]
691 fn leaf_directive_inside_inline_code_is_left_literal() {
692 let src = "Use `:youtube[x]{id=1}` syntax.\n";
693 let (out, inst) = extract(src);
694 assert!(
695 inst.is_empty(),
696 "directive inside inline code must not be extracted"
697 );
698 assert!(out.contains("`:youtube[x]{id=1}`"));
699 assert!(!out.contains("docgen-directive"));
700 }
701
702 #[test]
703 fn leaf_directive_outside_inline_code_on_same_line_still_parses() {
704 let src = "code `:a[x]{}` then :note[real]{} here\n";
706 let (_out, inst) = extract(src);
707 assert_eq!(inst.len(), 1);
708 assert_eq!(inst[0].name, "note");
709 assert_eq!(inst[0].label, "real");
710 }
711
712 #[test]
713 fn indented_code_fence_is_respected() {
714 let src = "- item\n\n ```\n :::callout{}\n body\n ```\n";
716 let (_out, inst) = extract(src);
717 assert!(inst.is_empty());
718 }
719
720 #[test]
721 fn block_and_leaf_directives_carry_their_opening_line() {
722 let src = "# Title\n\n:::callout{type=note}\nbody\n:::\n\nSee :youtube[x]{id=1} here.\n";
723 let (_out, inst) = extract(src);
724 assert_eq!(inst.len(), 2);
725 assert!(inst[0].is_block);
726 assert_eq!(inst[0].line, 3);
727 assert!(!inst[1].is_block);
728 assert_eq!(inst[1].line, 7);
729 }
730
731 #[test]
732 fn directive_line_after_fence_containing_colons_is_correct() {
733 let src = "```text\n:::\n```\n:note[x]{}\n";
736 let (_out, inst) = extract(src);
737 assert_eq!(inst.len(), 1);
738 assert_eq!(inst[0].name, "note");
739 assert_eq!(inst[0].line, 4);
740 }
741
742 #[test]
743 fn leaf_attrs_with_brace_inside_quoted_value() {
744 let src = ":note[hi]{title=\"a } b\"}\n";
746 let (_out, inst) = extract(src);
747 assert_eq!(inst.len(), 1);
748 assert_eq!(inst[0].attrs.get("title").unwrap(), "a } b");
749 }
750}