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) -> (String, std::collections::BTreeSet<String>) {
431 use docgen_components::DirectiveContext;
432 let mut used = std::collections::BTreeSet::new();
433 let mut out = html.to_string();
434 for (idx, inst) in instances.iter().enumerate() {
435 if inst.name == "include" {
438 let src = inst.attrs.get("src").map(String::as_str).unwrap_or("");
439 let rendered = if src.is_empty() {
440 error_span("include", "missing `src`")
441 } else {
442 resolve_include(src)
443 };
444 out = out.replace(&sentinel(idx), &rendered);
445 continue;
446 }
447 let rendered = match registry.get(&inst.name) {
448 Some(component) => {
449 let content = if inst.is_block {
450 render_inner(&inst.inner_md)
451 } else {
452 String::new()
453 };
454 let ctx = DirectiveContext {
455 attrs: inst.attrs.clone(),
456 content,
457 label: inst.label.clone(),
458 id: format!("docgen-d-{idx}"),
459 };
460 match component.render(&ctx) {
461 Ok(h) => {
462 used.insert(inst.name.clone());
463 h
464 }
465 Err(_) => error_span(&inst.name, "template error"),
466 }
467 }
468 None => error_span(&inst.name, "unknown directive"),
469 };
470 out = out.replace(&sentinel(idx), &rendered);
471 }
472 (out, used)
473}
474
475pub(crate) fn error_span(name: &str, reason: &str) -> String {
478 let safe = crate::util::escape_html(name);
479 format!(
480 "<span class=\"docgen-directive-error\" data-directive=\"{safe}\">[docgen: {reason} `{safe}`]</span>"
481 )
482}
483
484#[cfg(test)]
485mod substitute_tests {
486 use super::*;
487
488 fn reg_with(name: &str, tpl: &str) -> docgen_components::Registry {
489 let mut r = docgen_components::Registry::empty();
490 r.insert(docgen_components::Component::from_parts(
491 name, tpl, None, None,
492 ));
493 r
494 }
495
496 #[test]
497 fn substitutes_known_block_component_and_renders_inner() {
498 let (html, inst) = extract(":::callout{type=note}\n**hi**\n:::\n");
499 let reg = reg_with(
500 "callout",
501 "<aside class=\"c--{{ attrs.type }}\">{{ content | safe }}</aside>",
502 );
503 let render_inner = |md: &str| format!("<p>{}</p>", md.trim().replace("**", ""));
504 let (out, used) = substitute(&html, &inst, ®, &render_inner, &|_s| String::new());
505 assert!(out.contains("c--note"));
506 assert!(out.contains("<p>hi</p>"));
507 assert!(used.contains("callout"));
508 assert!(!out.contains("docgen-directive:")); }
510
511 #[test]
512 fn unknown_directive_becomes_marked_error_span_not_panic() {
513 let (html, inst) = extract(":bogus[x]{}\n");
514 let reg = docgen_components::Registry::empty();
515 let (out, used) = substitute(&html, &inst, ®, &|s| s.to_string(), &|_s| String::new());
516 assert!(out.contains("docgen-directive-error"));
517 assert!(out.contains("unknown directive"));
518 assert!(out.contains("bogus"));
519 assert!(used.is_empty());
520 }
521
522 fn sentinel_doc() -> String {
524 format!("before {} after", sentinel(0))
525 }
526
527 #[test]
528 fn directive_name_in_error_is_escaped() {
529 let inst = vec![DirectiveInstance {
531 name: "<img>".into(),
532 attrs: Default::default(),
533 label: String::new(),
534 inner_md: String::new(),
535 is_block: false,
536 }];
537 let html = sentinel_doc();
538 let (out, _) = substitute(
539 &html,
540 &inst,
541 &docgen_components::Registry::empty(),
542 &|s| s.to_string(),
543 &|_s| String::new(),
544 );
545 assert!(out.contains("<img>"));
546 assert!(!out.contains("<img>"));
547 }
548
549 #[test]
550 fn template_error_becomes_error_span_not_panic() {
551 let reg = reg_with("boom", "{{ content | nonexistent_filter }}");
553 let (html, inst) = extract(":::boom{}\nx\n:::\n");
554 let (out, used) = substitute(&html, &inst, ®, &|s| s.to_string(), &|_s| String::new());
555 assert!(out.contains("docgen-directive-error"));
556 assert!(out.contains("template error"));
557 assert!(used.is_empty());
558 }
559}
560
561#[cfg(test)]
562mod extract_tests {
563 use super::*;
564
565 #[test]
566 fn parse_attrs_handles_bare_quoted_and_empty() {
567 let a = parse_attrs("type=warning title=\"Back up first\" wide");
568 assert_eq!(a.get("type").unwrap(), "warning");
569 assert_eq!(a.get("title").unwrap(), "Back up first");
570 assert_eq!(a.get("wide").unwrap(), "true");
571 assert!(parse_attrs("").is_empty());
572 }
573
574 #[test]
575 fn extracts_block_directive_with_inner_markdown() {
576 let src = ":::callout{type=warning title=\"Heads up\"}\nThis is **bold**.\n:::\n";
577 let (out, inst) = extract(src);
578 assert_eq!(inst.len(), 1);
579 assert!(inst[0].is_block);
580 assert_eq!(inst[0].name, "callout");
581 assert_eq!(inst[0].attrs.get("type").unwrap(), "warning");
582 assert_eq!(inst[0].inner_md.trim(), "This is **bold**.");
583 assert!(out.contains("<!--docgen-directive:0-->"));
584 assert!(!out.contains(":::"));
585 }
586
587 #[test]
588 fn extracts_leaf_directive_with_label_and_attrs() {
589 let src = "See :youtube[Intro]{id=abc123} now.\n";
590 let (out, inst) = extract(src);
591 assert_eq!(inst.len(), 1);
592 assert!(!inst[0].is_block);
593 assert_eq!(inst[0].name, "youtube");
594 assert_eq!(inst[0].label, "Intro");
595 assert_eq!(inst[0].attrs.get("id").unwrap(), "abc123");
596 assert!(out.contains("See <!--docgen-directive:0--> now."));
597 }
598
599 #[test]
600 fn nested_block_directives_match_outermost() {
601 let src = ":::callout{type=note}\nouter\n:::callout{type=warning}\ninner\n:::\n:::\n";
602 let (_out, inst) = extract(src);
603 assert_eq!(inst.len(), 1); assert!(inst[0].inner_md.contains(":::callout{type=warning}"));
605 assert!(inst[0].inner_md.contains("inner"));
606 }
607
608 #[test]
609 fn escaped_directive_is_left_literal() {
610 let src = "\\:::callout{}\nnot a directive\n:::\n";
611 let (out, inst) = extract(src);
612 assert!(inst.is_empty());
613 assert!(out.contains(":::callout{}")); }
615
616 #[test]
617 fn plain_text_with_colons_is_not_a_directive() {
618 let src = "time is 10:30 and ratio 3:4\n";
619 let (out, inst) = extract(src);
620 assert!(inst.is_empty());
621 assert_eq!(out, src);
622 }
623
624 #[test]
625 fn block_directive_inside_fenced_code_is_left_literal() {
626 let src = "```\n:::callout{type=note}\nhello\n:::\n```\n";
629 let (out, inst) = extract(src);
630 assert!(
631 inst.is_empty(),
632 "directive inside a code fence must not be extracted"
633 );
634 assert!(out.contains(":::callout{type=note}"));
635 assert!(out.contains("hello"));
636 assert!(!out.contains("docgen-directive"));
637 }
638
639 #[test]
640 fn block_directive_inside_tilde_fence_with_info_is_left_literal() {
641 let src = "~~~markdown\n:::callout{type=warning}\nBe careful.\n:::\n~~~\n";
642 let (out, inst) = extract(src);
643 assert!(inst.is_empty());
644 assert!(out.contains(":::callout{type=warning}"));
645 assert!(out.contains("Be careful."));
646 }
647
648 #[test]
649 fn leaf_directive_inside_inline_code_is_left_literal() {
650 let src = "Use `:youtube[x]{id=1}` syntax.\n";
651 let (out, inst) = extract(src);
652 assert!(
653 inst.is_empty(),
654 "directive inside inline code must not be extracted"
655 );
656 assert!(out.contains("`:youtube[x]{id=1}`"));
657 assert!(!out.contains("docgen-directive"));
658 }
659
660 #[test]
661 fn leaf_directive_outside_inline_code_on_same_line_still_parses() {
662 let src = "code `:a[x]{}` then :note[real]{} here\n";
664 let (_out, inst) = extract(src);
665 assert_eq!(inst.len(), 1);
666 assert_eq!(inst[0].name, "note");
667 assert_eq!(inst[0].label, "real");
668 }
669
670 #[test]
671 fn indented_code_fence_is_respected() {
672 let src = "- item\n\n ```\n :::callout{}\n body\n ```\n";
674 let (_out, inst) = extract(src);
675 assert!(inst.is_empty());
676 }
677
678 #[test]
679 fn leaf_attrs_with_brace_inside_quoted_value() {
680 let src = ":note[hi]{title=\"a } b\"}\n";
682 let (_out, inst) = extract(src);
683 assert_eq!(inst.len(), 1);
684 assert_eq!(inst[0].attrs.get("title").unwrap(), "a } b");
685 }
686}