Skip to main content

docgen_core/
directivepass.rs

1//! Source-level directive pre/post pass. `extract` rewrites raw markdown,
2//! replacing each directive with an HTML-comment sentinel and returning the
3//! parsed instances; `substitute` swaps sentinels for rendered component HTML
4//! after comrak has formatted the surrounding markdown.
5//!
6//! Why a source-level pre-pass and not a comrak AST pass: comrak 0.52 has no
7//! generic `:::` directive extension, and a block directive's inner content must
8//! itself be parsed as markdown. Reconstructing block boundaries from a flattened
9//! inline AST is fragile and loses the raw inner-markdown span we need. Operating
10//! on the raw body string before `parse_document` keeps the directive system
11//! orthogonal to comrak's AST passes (wikilink/math/mermaid still run on the
12//! rewritten source) and yields the verbatim inner-markdown span block directives
13//! require. The sentinel is an HTML comment so comrak passes it through verbatim
14//! (with `render.unsafe = true`); a post-pass substitutes the rendered HTML.
15
16use std::collections::BTreeMap;
17
18/// One directive found in a doc body.
19#[derive(Debug, Clone, PartialEq)]
20pub struct DirectiveInstance {
21    pub name: String,
22    pub attrs: BTreeMap<String, String>,
23    /// Leaf `[label]`; empty for block form.
24    pub label: String,
25    /// Block inner markdown; empty for leaf form.
26    pub inner_md: String,
27    pub is_block: bool,
28    /// 1-based line of the opening delimiter (`:::name…` or the line carrying
29    /// the leaf `:name[…]{…}`) in the body passed to [`extract`].
30    pub line: usize,
31}
32
33/// The sentinel a directive is replaced with in the rewritten source. `idx` is
34/// the instance index. An HTML comment so comrak passes it through verbatim.
35pub(crate) fn sentinel(idx: usize) -> String {
36    format!("<!--docgen-directive:{idx}-->")
37}
38
39/// True if `c` may start/continue a directive name (`[A-Za-z][A-Za-z0-9_-]*`).
40fn 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
47/// Parse an attr string (`type=warning title="x y" wide`) → ordered map. Total:
48/// malformed input degrades gracefully (best-effort token split), never panics.
49/// A bare key (`wide`) becomes `wide="true"`.
50pub 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        // Skip whitespace between tokens.
56        if chars[i].is_whitespace() {
57            i += 1;
58            continue;
59        }
60        // Read a key: up to `=` or whitespace.
61        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        // Bare key (no `=`): value "true".
71        if i >= chars.len() || chars[i] != '=' {
72            out.insert(key, "true".to_string());
73            continue;
74        }
75        // Consume `=` and read the value (quoted or bare).
76        i += 1; // skip '='
77        let value = if i < chars.len() && chars[i] == '"' {
78            i += 1; // skip opening quote
79            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; // skip closing quote
86            }
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
100/// Parse a `:::<name>{attrs}` open fence line (already trimmed). Returns
101/// `(name, attrs_str)` on success.
102fn 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    // After the name, only an optional `{...}` attr block (and nothing else).
121    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
131/// A fenced-code-block delimiter parsed from a line: the fence char (`` ` `` or
132/// `~`), its run length, and the line's leading indentation width. A closing
133/// fence must use the same char with a run length >= the opener's and carry no
134/// info string. Returned for any line that *could* open or close a fence.
135struct Fence {
136    ch: char,
137    len: usize,
138    has_info: bool,
139}
140
141/// If `line` is a fenced-code delimiter (`` ``` ``/`~~~`, possibly indented up to
142/// three spaces, with an optional info string), return its `Fence`. Mirrors
143/// CommonMark fence recognition closely enough to guard directive content.
144fn parse_fence(line: &str) -> Option<Fence> {
145    let indent = line.len() - line.trim_start().len();
146    if indent > 3 {
147        return None; // 4+ spaces is an indented code block, not a fence
148    }
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    // A backtick info string may not itself contain a backtick (CommonMark).
161    if ch == '`' && info.contains('`') {
162        return None;
163    }
164    Some(Fence {
165        ch,
166        len,
167        has_info: !info.is_empty(),
168    })
169}
170
171/// Pass 1: scan `body_md`, replace directives with sentinels, return instances
172/// (index-aligned with the sentinels). Unknown-vs-known is NOT decided here —
173/// every syntactic directive is extracted; resolution happens in `substitute`.
174///
175/// Code-aware: lines inside a fenced code block (and inline-code spans within a
176/// line) are emitted verbatim and never scanned for directives, so a doc that
177/// *documents* the directive syntax in a code sample keeps it literal.
178pub 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    // Open fence we are currently inside, if any.
185    let mut open_fence: Option<Fence> = None;
186    while i < lines.len() {
187        let line = lines[i];
188
189        // Inside a fenced code block: emit verbatim, only watching for the close.
190        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        // A fence opener starts a code block; skip directive scanning until close.
201        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        // Escaped directive opener: `\:::name...` → emit literal, drop backslash.
211        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        // Block directive open?
221        if let Some((name, attrs_str)) = parse_block_open(trimmed) {
222            // Collect inner lines until the matching `:::` close (depth-counted).
223            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; // skip past the closing `:::`
253                continue;
254            }
255            // Unterminated block: fall through, treat line as ordinary text.
256        }
257
258        // Otherwise scan the line for inline leaf directives.
259        out_lines.push(scan_leaf_line(line, i + 1, &mut instances));
260        i += 1;
261    }
262
263    (out_lines.join("\n"), instances)
264}
265
266/// Heuristic for the escape branch: does `rest` (after a leading `:`) look like a
267/// leaf directive `name[...]` or `name{...}`?
268fn 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
287/// Replace every inline `:name[label]{attrs}` leaf directive in `line` with its
288/// sentinel, appending instances. A `:::` block opener is never matched here
289/// (block openers are handled before this is called, and a `::` prefix is
290/// skipped). Plain `:` in prose (`10:30`) is left untouched.
291fn 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        // Inline code span: a run of N backticks is closed by the next run of
297        // exactly N backticks. Everything between is emitted verbatim and never
298        // scanned for directives (so `` `:note[x]{}` `` stays literal source).
299        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]); // open + content + close, verbatim
303                i = end;
304            } else {
305                // Unterminated run: emit the backticks literally and continue.
306                out.extend(&chars[i..i + run]);
307                i += run;
308            }
309            continue;
310        }
311        if chars[i] == ':' {
312            // Not a leaf if preceded or followed by another colon (`::`).
313            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
332/// Given a backtick run of length `run` opened just before `from`, return the
333/// index *past* the matching closing run (a run of exactly `run` backticks), or
334/// `None` if the span is unterminated on this line.
335fn 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
351/// Try to parse a leaf directive starting at `chars[start] == ':'`. Returns the
352/// instance and the number of chars consumed (including the leading `:`).
353fn try_parse_leaf(chars: &[char], start: usize) -> Option<(DirectiveInstance, usize)> {
354    let mut i = start + 1; // skip ':'
355    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    // Leaf form: an optional `[label]` then an optional `{attrs}`. At least one
365    // must be present — a bare `:name` is not a directive (so `:include{src=...}`
366    // parses, while plain `:foo` text does not).
367    let mut label = String::new();
368    let mut had_label = false;
369    if i < chars.len() && chars[i] == '[' {
370        i += 1; // skip '['
371        let label_start = i;
372        while i < chars.len() && chars[i] != ']' {
373            i += 1;
374        }
375        if i >= chars.len() {
376            return None; // unterminated label
377        }
378        label = chars[label_start..i].iter().collect();
379        i += 1; // skip ']'
380        had_label = true;
381    }
382
383    // Optional `{attrs}`.
384    let mut attrs = BTreeMap::new();
385    let mut had_attrs = false;
386    if i < chars.len() && chars[i] == '{' {
387        i += 1; // skip '{'
388        let a_start = i;
389        // Scan to the closing `}`, but skip any `}` inside a double-quoted value
390        // (mirrors parse_attrs' quote handling) so `{title="a } b"}` parses whole.
391        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; // unterminated attrs
400        }
401        let attrs_str: String = chars[a_start..i].iter().collect();
402        attrs = parse_attrs(&attrs_str);
403        i += 1; // skip '}'
404        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, // stamped by `scan_leaf_line`, which knows the line number
419        },
420        i - start,
421    ))
422}
423
424/// Pass 2: replace each `<!--docgen-directive:N-->` sentinel in `html` with the
425/// component's rendered HTML. `render_inner` renders a block directive's inner
426/// markdown to HTML (the full pipeline, recursively). Returns the substituted
427/// HTML and the set of component names that were actually rendered (for per-page
428/// island/style gating). An unknown directive (or a component whose template
429/// errors) becomes a clearly-marked inert error span — never a crash.
430pub 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        // `:::plantuml` is a built-in that renders a diagram (SVG) at build time
443        // via the injected renderer — not a registry component.
444        if inst.name == "plantuml" {
445            let rendered = render_plantuml(idx, inst);
446            out = out.replace(&sentinel(idx), &rendered);
447            continue;
448        }
449        // `:include{src=...}` is a built-in, file-transcluding directive — not a
450        // registry component. It renders the resolved partial's markdown here.
451        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
489/// An inert, clearly-marked error span for an unresolved/failed directive. The
490/// directive name is HTML-escaped so a malformed name cannot inject markup.
491pub(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    /// A no-op `render_plantuml` closure for tests that exercise other directives.
503    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            &reg,
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:")); // sentinel gone
535    }
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            &reg,
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    /// Build a doc that is just the sentinel for instance 0.
556    fn sentinel_doc() -> String {
557        format!("before {} after", sentinel(0))
558    }
559
560    #[test]
561    fn directive_name_in_error_is_escaped() {
562        // Craft an instance with a name that contains markup to exercise escaping.
563        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("&lt;img&gt;"));
581        assert!(!out.contains("<img>"));
582    }
583
584    #[test]
585    fn template_error_becomes_error_span_not_panic() {
586        // A template referencing an undefined filter fails to render.
587        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            &reg,
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); // only the outer is extracted at this level
646        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{}")); // literal, backslash removed
656    }
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        // A docs author showing the directive syntax in a ``` fence must keep it
669        // verbatim — comrak then renders the fence as a literal code block.
670        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        // Inline code is skipped, but a real directive elsewhere on the line works.
705        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        // A fence indented under a list item still guards its body.
715        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        // The `:::` inside the fence must count as ordinary lines, not shift
734        // the line number of the real directive below.
735        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        // The closing `}` scan must respect quotes so `}` inside a value is kept.
745        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}