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}
29
30fn sentinel(idx: usize) -> String {
33 format!("<!--docgen-directive:{idx}-->")
34}
35
36fn is_name_start(c: char) -> bool {
38 c.is_ascii_alphabetic()
39}
40fn is_name_char(c: char) -> bool {
41 c.is_ascii_alphanumeric() || c == '_' || c == '-'
42}
43
44pub fn parse_attrs(s: &str) -> BTreeMap<String, String> {
48 let mut out = BTreeMap::new();
49 let chars: Vec<char> = s.chars().collect();
50 let mut i = 0;
51 while i < chars.len() {
52 if chars[i].is_whitespace() {
54 i += 1;
55 continue;
56 }
57 let key_start = i;
59 while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
60 i += 1;
61 }
62 let key: String = chars[key_start..i].iter().collect();
63 if key.is_empty() {
64 i += 1;
65 continue;
66 }
67 if i >= chars.len() || chars[i] != '=' {
69 out.insert(key, "true".to_string());
70 continue;
71 }
72 i += 1; let value = if i < chars.len() && chars[i] == '"' {
75 i += 1; let v_start = i;
77 while i < chars.len() && chars[i] != '"' {
78 i += 1;
79 }
80 let v: String = chars[v_start..i].iter().collect();
81 if i < chars.len() {
82 i += 1; }
84 v
85 } else {
86 let v_start = i;
87 while i < chars.len() && !chars[i].is_whitespace() {
88 i += 1;
89 }
90 chars[v_start..i].iter().collect()
91 };
92 out.insert(key, value);
93 }
94 out
95}
96
97fn parse_block_open(trimmed: &str) -> Option<(String, String)> {
100 let rest = trimmed.strip_prefix(":::")?;
101 let mut chars = rest.char_indices();
102 let (first_i, first) = chars.next()?;
103 debug_assert_eq!(first_i, 0);
104 if !is_name_start(first) {
105 return None;
106 }
107 let mut end = first.len_utf8();
108 for (i, c) in rest.char_indices().skip(1) {
109 if is_name_char(c) {
110 end = i + c.len_utf8();
111 } else {
112 break;
113 }
114 }
115 let name = &rest[..end];
116 let after = rest[end..].trim();
117 let attrs = if after.is_empty() {
119 String::new()
120 } else if after.starts_with('{') && after.ends_with('}') {
121 after[1..after.len() - 1].to_string()
122 } else {
123 return None;
124 };
125 Some((name.to_string(), attrs))
126}
127
128struct Fence {
133 ch: char,
134 len: usize,
135 has_info: bool,
136}
137
138fn parse_fence(line: &str) -> Option<Fence> {
142 let indent = line.len() - line.trim_start().len();
143 if indent > 3 {
144 return None; }
146 let rest = &line[indent..];
147 let ch = rest.chars().next()?;
148 if ch != '`' && ch != '~' {
149 return None;
150 }
151 let len = rest.chars().take_while(|&c| c == ch).count();
152 if len < 3 {
153 return None;
154 }
155 let info = rest.chars().skip(len).collect::<String>();
156 let info = info.trim();
157 if ch == '`' && info.contains('`') {
159 return None;
160 }
161 Some(Fence {
162 ch,
163 len,
164 has_info: !info.is_empty(),
165 })
166}
167
168pub fn extract(body_md: &str) -> (String, Vec<DirectiveInstance>) {
176 let mut instances: Vec<DirectiveInstance> = Vec::new();
177 let mut out_lines: Vec<String> = Vec::new();
178
179 let lines: Vec<&str> = body_md.split('\n').collect();
180 let mut i = 0;
181 let mut open_fence: Option<Fence> = None;
183 while i < lines.len() {
184 let line = lines[i];
185
186 if let Some(open) = &open_fence {
188 if let Some(f) = parse_fence(line) {
189 if f.ch == open.ch && f.len >= open.len && !f.has_info {
190 open_fence = None;
191 }
192 }
193 out_lines.push(line.to_string());
194 i += 1;
195 continue;
196 }
197 if let Some(f) = parse_fence(line) {
199 open_fence = Some(f);
200 out_lines.push(line.to_string());
201 i += 1;
202 continue;
203 }
204
205 let trimmed = line.trim();
206
207 if let Some(rest) = trimmed.strip_prefix('\\') {
209 if rest.starts_with(":::") || (rest.starts_with(':') && looks_like_leaf(rest)) {
210 let indent = &line[..line.len() - line.trim_start().len()];
211 out_lines.push(format!("{indent}{rest}"));
212 i += 1;
213 continue;
214 }
215 }
216
217 if let Some((name, attrs_str)) = parse_block_open(trimmed) {
219 let mut depth = 1;
221 let mut inner: Vec<&str> = Vec::new();
222 let mut j = i + 1;
223 let mut closed = false;
224 while j < lines.len() {
225 let t = lines[j].trim();
226 if t == ":::" {
227 depth -= 1;
228 if depth == 0 {
229 closed = true;
230 break;
231 }
232 } else if parse_block_open(t).is_some() {
233 depth += 1;
234 }
235 inner.push(lines[j]);
236 j += 1;
237 }
238 if closed {
239 let idx = instances.len();
240 instances.push(DirectiveInstance {
241 name,
242 attrs: parse_attrs(&attrs_str),
243 label: String::new(),
244 inner_md: inner.join("\n"),
245 is_block: true,
246 });
247 out_lines.push(sentinel(idx));
248 i = j + 1; continue;
250 }
251 }
253
254 out_lines.push(scan_leaf_line(line, &mut instances));
256 i += 1;
257 }
258
259 (out_lines.join("\n"), instances)
260}
261
262fn looks_like_leaf(rest: &str) -> bool {
265 let body = &rest[1..];
266 let name_len = body
267 .char_indices()
268 .take_while(|(k, c)| {
269 if *k == 0 {
270 is_name_start(*c)
271 } else {
272 is_name_char(*c)
273 }
274 })
275 .map(|(_, c)| c.len_utf8())
276 .sum::<usize>();
277 if name_len == 0 {
278 return false;
279 }
280 matches!(body[name_len..].chars().next(), Some('[') | Some('{'))
281}
282
283fn scan_leaf_line(line: &str, instances: &mut Vec<DirectiveInstance>) -> String {
288 let chars: Vec<char> = line.chars().collect();
289 let mut out = String::with_capacity(line.len());
290 let mut i = 0;
291 while i < chars.len() {
292 if chars[i] == '`' {
296 let run = (i..chars.len()).take_while(|&k| chars[k] == '`').count();
297 if let Some(end) = find_inline_code_close(&chars, i + run, run) {
298 out.extend(&chars[i..end]); i = end;
300 } else {
301 out.extend(&chars[i..i + run]);
303 i += run;
304 }
305 continue;
306 }
307 if chars[i] == ':' {
308 let prev_colon = i > 0 && chars[i - 1] == ':';
310 let next_colon = i + 1 < chars.len() && chars[i + 1] == ':';
311 if !prev_colon && !next_colon {
312 if let Some((inst, consumed)) = try_parse_leaf(&chars, i) {
313 let idx = instances.len();
314 instances.push(inst);
315 out.push_str(&sentinel(idx));
316 i += consumed;
317 continue;
318 }
319 }
320 }
321 out.push(chars[i]);
322 i += 1;
323 }
324 out
325}
326
327fn find_inline_code_close(chars: &[char], from: usize, run: usize) -> Option<usize> {
331 let mut j = from;
332 while j < chars.len() {
333 if chars[j] == '`' {
334 let close = (j..chars.len()).take_while(|&k| chars[k] == '`').count();
335 if close == run {
336 return Some(j + close);
337 }
338 j += close;
339 } else {
340 j += 1;
341 }
342 }
343 None
344}
345
346fn try_parse_leaf(chars: &[char], start: usize) -> Option<(DirectiveInstance, usize)> {
349 let mut i = start + 1; if i >= chars.len() || !is_name_start(chars[i]) {
351 return None;
352 }
353 let name_start = i;
354 while i < chars.len() && is_name_char(chars[i]) {
355 i += 1;
356 }
357 let name: String = chars[name_start..i].iter().collect();
358
359 let mut label = String::new();
363 let mut had_label = false;
364 if i < chars.len() && chars[i] == '[' {
365 i += 1; let label_start = i;
367 while i < chars.len() && chars[i] != ']' {
368 i += 1;
369 }
370 if i >= chars.len() {
371 return None; }
373 label = chars[label_start..i].iter().collect();
374 i += 1; had_label = true;
376 }
377
378 let mut attrs = BTreeMap::new();
380 let mut had_attrs = false;
381 if i < chars.len() && chars[i] == '{' {
382 i += 1; let a_start = i;
384 let mut in_quote = false;
387 while i < chars.len() && (in_quote || chars[i] != '}') {
388 if chars[i] == '"' {
389 in_quote = !in_quote;
390 }
391 i += 1;
392 }
393 if i >= chars.len() {
394 return None; }
396 let attrs_str: String = chars[a_start..i].iter().collect();
397 attrs = parse_attrs(&attrs_str);
398 i += 1; had_attrs = true;
400 }
401
402 if !had_label && !had_attrs {
403 return None;
404 }
405
406 Some((
407 DirectiveInstance {
408 name,
409 attrs,
410 label,
411 inner_md: String::new(),
412 is_block: false,
413 },
414 i - start,
415 ))
416}
417
418pub fn substitute(
425 html: &str,
426 instances: &[DirectiveInstance],
427 registry: &docgen_components::Registry,
428 render_inner: &dyn Fn(&str) -> String,
429 resolve_include: &dyn Fn(&str) -> String,
430 render_plantuml: &dyn Fn(usize, &DirectiveInstance) -> String,
431) -> (String, std::collections::BTreeSet<String>) {
432 use docgen_components::DirectiveContext;
433 let mut used = std::collections::BTreeSet::new();
434 let mut out = html.to_string();
435 for (idx, inst) in instances.iter().enumerate() {
436 if inst.name == "plantuml" {
439 let rendered = render_plantuml(idx, inst);
440 out = out.replace(&sentinel(idx), &rendered);
441 continue;
442 }
443 if inst.name == "include" {
446 let src = inst.attrs.get("src").map(String::as_str).unwrap_or("");
447 let rendered = if src.is_empty() {
448 error_span("include", "missing `src`")
449 } else {
450 resolve_include(src)
451 };
452 out = out.replace(&sentinel(idx), &rendered);
453 continue;
454 }
455 let rendered = match registry.get(&inst.name) {
456 Some(component) => {
457 let content = if inst.is_block {
458 render_inner(&inst.inner_md)
459 } else {
460 String::new()
461 };
462 let ctx = DirectiveContext {
463 attrs: inst.attrs.clone(),
464 content,
465 label: inst.label.clone(),
466 id: format!("docgen-d-{idx}"),
467 };
468 match component.render(&ctx) {
469 Ok(h) => {
470 used.insert(inst.name.clone());
471 h
472 }
473 Err(_) => error_span(&inst.name, "template error"),
474 }
475 }
476 None => error_span(&inst.name, "unknown directive"),
477 };
478 out = out.replace(&sentinel(idx), &rendered);
479 }
480 (out, used)
481}
482
483pub(crate) fn error_span(name: &str, reason: &str) -> String {
486 let safe = crate::util::escape_html(name);
487 format!(
488 "<span class=\"docgen-directive-error\" data-directive=\"{safe}\">[docgen: {reason} `{safe}`]</span>"
489 )
490}
491
492#[cfg(test)]
493mod substitute_tests {
494 use super::*;
495
496 fn no_plantuml(_idx: usize, _inst: &DirectiveInstance) -> String {
498 String::new()
499 }
500
501 fn reg_with(name: &str, tpl: &str) -> docgen_components::Registry {
502 let mut r = docgen_components::Registry::empty();
503 r.insert(docgen_components::Component::from_parts(
504 name, tpl, None, None,
505 ));
506 r
507 }
508
509 #[test]
510 fn substitutes_known_block_component_and_renders_inner() {
511 let (html, inst) = extract(":::callout{type=note}\n**hi**\n:::\n");
512 let reg = reg_with(
513 "callout",
514 "<aside class=\"c--{{ attrs.type }}\">{{ content | safe }}</aside>",
515 );
516 let render_inner = |md: &str| format!("<p>{}</p>", md.trim().replace("**", ""));
517 let (out, used) = substitute(
518 &html,
519 &inst,
520 ®,
521 &render_inner,
522 &|_s| String::new(),
523 &no_plantuml,
524 );
525 assert!(out.contains("c--note"));
526 assert!(out.contains("<p>hi</p>"));
527 assert!(used.contains("callout"));
528 assert!(!out.contains("docgen-directive:")); }
530
531 #[test]
532 fn unknown_directive_becomes_marked_error_span_not_panic() {
533 let (html, inst) = extract(":bogus[x]{}\n");
534 let reg = docgen_components::Registry::empty();
535 let (out, used) = substitute(
536 &html,
537 &inst,
538 ®,
539 &|s| s.to_string(),
540 &|_s| String::new(),
541 &no_plantuml,
542 );
543 assert!(out.contains("docgen-directive-error"));
544 assert!(out.contains("unknown directive"));
545 assert!(out.contains("bogus"));
546 assert!(used.is_empty());
547 }
548
549 fn sentinel_doc() -> String {
551 format!("before {} after", sentinel(0))
552 }
553
554 #[test]
555 fn directive_name_in_error_is_escaped() {
556 let inst = vec![DirectiveInstance {
558 name: "<img>".into(),
559 attrs: Default::default(),
560 label: String::new(),
561 inner_md: String::new(),
562 is_block: false,
563 }];
564 let html = sentinel_doc();
565 let (out, _) = substitute(
566 &html,
567 &inst,
568 &docgen_components::Registry::empty(),
569 &|s| s.to_string(),
570 &|_s| String::new(),
571 &no_plantuml,
572 );
573 assert!(out.contains("<img>"));
574 assert!(!out.contains("<img>"));
575 }
576
577 #[test]
578 fn template_error_becomes_error_span_not_panic() {
579 let reg = reg_with("boom", "{{ content | nonexistent_filter }}");
581 let (html, inst) = extract(":::boom{}\nx\n:::\n");
582 let (out, used) = substitute(
583 &html,
584 &inst,
585 ®,
586 &|s| s.to_string(),
587 &|_s| String::new(),
588 &no_plantuml,
589 );
590 assert!(out.contains("docgen-directive-error"));
591 assert!(out.contains("template error"));
592 assert!(used.is_empty());
593 }
594}
595
596#[cfg(test)]
597mod extract_tests {
598 use super::*;
599
600 #[test]
601 fn parse_attrs_handles_bare_quoted_and_empty() {
602 let a = parse_attrs("type=warning title=\"Back up first\" wide");
603 assert_eq!(a.get("type").unwrap(), "warning");
604 assert_eq!(a.get("title").unwrap(), "Back up first");
605 assert_eq!(a.get("wide").unwrap(), "true");
606 assert!(parse_attrs("").is_empty());
607 }
608
609 #[test]
610 fn extracts_block_directive_with_inner_markdown() {
611 let src = ":::callout{type=warning title=\"Heads up\"}\nThis is **bold**.\n:::\n";
612 let (out, inst) = extract(src);
613 assert_eq!(inst.len(), 1);
614 assert!(inst[0].is_block);
615 assert_eq!(inst[0].name, "callout");
616 assert_eq!(inst[0].attrs.get("type").unwrap(), "warning");
617 assert_eq!(inst[0].inner_md.trim(), "This is **bold**.");
618 assert!(out.contains("<!--docgen-directive:0-->"));
619 assert!(!out.contains(":::"));
620 }
621
622 #[test]
623 fn extracts_leaf_directive_with_label_and_attrs() {
624 let src = "See :youtube[Intro]{id=abc123} now.\n";
625 let (out, inst) = extract(src);
626 assert_eq!(inst.len(), 1);
627 assert!(!inst[0].is_block);
628 assert_eq!(inst[0].name, "youtube");
629 assert_eq!(inst[0].label, "Intro");
630 assert_eq!(inst[0].attrs.get("id").unwrap(), "abc123");
631 assert!(out.contains("See <!--docgen-directive:0--> now."));
632 }
633
634 #[test]
635 fn nested_block_directives_match_outermost() {
636 let src = ":::callout{type=note}\nouter\n:::callout{type=warning}\ninner\n:::\n:::\n";
637 let (_out, inst) = extract(src);
638 assert_eq!(inst.len(), 1); assert!(inst[0].inner_md.contains(":::callout{type=warning}"));
640 assert!(inst[0].inner_md.contains("inner"));
641 }
642
643 #[test]
644 fn escaped_directive_is_left_literal() {
645 let src = "\\:::callout{}\nnot a directive\n:::\n";
646 let (out, inst) = extract(src);
647 assert!(inst.is_empty());
648 assert!(out.contains(":::callout{}")); }
650
651 #[test]
652 fn plain_text_with_colons_is_not_a_directive() {
653 let src = "time is 10:30 and ratio 3:4\n";
654 let (out, inst) = extract(src);
655 assert!(inst.is_empty());
656 assert_eq!(out, src);
657 }
658
659 #[test]
660 fn block_directive_inside_fenced_code_is_left_literal() {
661 let src = "```\n:::callout{type=note}\nhello\n:::\n```\n";
664 let (out, inst) = extract(src);
665 assert!(
666 inst.is_empty(),
667 "directive inside a code fence must not be extracted"
668 );
669 assert!(out.contains(":::callout{type=note}"));
670 assert!(out.contains("hello"));
671 assert!(!out.contains("docgen-directive"));
672 }
673
674 #[test]
675 fn block_directive_inside_tilde_fence_with_info_is_left_literal() {
676 let src = "~~~markdown\n:::callout{type=warning}\nBe careful.\n:::\n~~~\n";
677 let (out, inst) = extract(src);
678 assert!(inst.is_empty());
679 assert!(out.contains(":::callout{type=warning}"));
680 assert!(out.contains("Be careful."));
681 }
682
683 #[test]
684 fn leaf_directive_inside_inline_code_is_left_literal() {
685 let src = "Use `:youtube[x]{id=1}` syntax.\n";
686 let (out, inst) = extract(src);
687 assert!(
688 inst.is_empty(),
689 "directive inside inline code must not be extracted"
690 );
691 assert!(out.contains("`:youtube[x]{id=1}`"));
692 assert!(!out.contains("docgen-directive"));
693 }
694
695 #[test]
696 fn leaf_directive_outside_inline_code_on_same_line_still_parses() {
697 let src = "code `:a[x]{}` then :note[real]{} here\n";
699 let (_out, inst) = extract(src);
700 assert_eq!(inst.len(), 1);
701 assert_eq!(inst[0].name, "note");
702 assert_eq!(inst[0].label, "real");
703 }
704
705 #[test]
706 fn indented_code_fence_is_respected() {
707 let src = "- item\n\n ```\n :::callout{}\n body\n ```\n";
709 let (_out, inst) = extract(src);
710 assert!(inst.is_empty());
711 }
712
713 #[test]
714 fn leaf_attrs_with_brace_inside_quoted_value() {
715 let src = ":note[hi]{title=\"a } b\"}\n";
717 let (_out, inst) = extract(src);
718 assert_eq!(inst.len(), 1);
719 assert_eq!(inst[0].attrs.get("title").unwrap(), "a } b");
720 }
721}