Skip to main content

asciidoc_parser/content/
substitution_step.rs

1use std::{borrow::Cow, ops::Range, sync::LazyLock};
2
3use regex::{Captures, Regex, RegexBuilder, Replacer};
4
5use crate::{
6    Parser, Span,
7    attributes::{Attrlist, AttrlistContext},
8    content::Content,
9    document::{InterpretedValue, RefType},
10    internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
11    parser::{
12        CalloutGuard, CalloutRenderParams, CharacterReplacementType, InlineSubstitutionRenderer,
13        QuoteScope, QuoteType, SpecialCharacter,
14    },
15    strings::CowStr,
16    warnings::WarningType,
17};
18
19/// Each substitution type replaces characters, markup, attribute references,
20/// and macros in text with the appropriate output for a given converter. When a
21/// document is processed, up to six substitution types may be carried out
22/// depending on the block or inline element’s assigned substitution group. The
23/// processor runs the substitutions in the following order:
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SubstitutionStep {
26    /// Searches for three characters (`<`, `>`, `&`) and replaces them with
27    /// their named character references.
28    SpecialCharacters,
29
30    /// Replacement of formatting markup on inline elements.
31    Quotes,
32
33    /// Replacement of attribute references by the values they reference.
34    AttributeReferences,
35
36    /// Replaces textual characters such as marks, arrows, and dashes and
37    /// replaces them with the decimal format of their Unicode code point, i.e.,
38    /// a numeric character reference.
39    CharacterReplacements,
40
41    /// Replaces a macro’s content with the appropriate built-in and
42    /// user-defined configuration.
43    Macros,
44
45    /// Replaces the line break character, `+` with a line-end marker.
46    PostReplacement,
47
48    /// Processes callouts in literal, listing, and source blocks.
49    Callouts,
50}
51
52impl SubstitutionStep {
53    pub(crate) fn apply(
54        &self,
55        content: &mut Content<'_>,
56        parser: &Parser,
57        attrlist: Option<&Attrlist<'_>>,
58    ) {
59        match self {
60            Self::SpecialCharacters => {
61                apply_special_characters(content, &*parser.renderer);
62            }
63            Self::Quotes => {
64                apply_quotes(content, parser);
65            }
66            Self::AttributeReferences => {
67                apply_attributes(content, parser);
68            }
69            Self::CharacterReplacements => {
70                apply_character_replacements(content, &*parser.renderer);
71            }
72            Self::Macros => {
73                super::macros::apply_macros(content, parser);
74            }
75            Self::PostReplacement => {
76                apply_post_replacements(content, parser, attrlist);
77            }
78            Self::Callouts => {
79                apply_callouts(content, parser, attrlist);
80            }
81        }
82    }
83}
84
85fn apply_special_characters(content: &mut Content<'_>, renderer: &dyn InlineSubstitutionRenderer) {
86    if !content.rendered.contains(['<', '>', '&']) {
87        return;
88    }
89
90    let mut result: Cow<'_, str> = content.rendered.to_string().into();
91    let replacer = SpecialCharacterReplacer { renderer };
92
93    if let Cow::Owned(new_result) = SPECIAL_CHARS.replace_all(&result, replacer) {
94        result = new_result.into();
95    }
96
97    content.rendered = result.into();
98}
99
100static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
101    #[allow(clippy::unwrap_used)]
102    Regex::new("[<>&]").unwrap()
103});
104
105#[derive(Debug)]
106struct SpecialCharacterReplacer<'r> {
107    renderer: &'r dyn InlineSubstitutionRenderer,
108}
109
110impl Replacer for SpecialCharacterReplacer<'_> {
111    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
112        // The SPECIAL_CHARS regex only matches '<', '>', and '&'. This sequence is
113        // specifically constructed to avoid having any unreachable code.
114        let ch = &caps[0];
115
116        if ch == "<" {
117            self.renderer
118                .render_special_character(SpecialCharacter::Lt, dest);
119        } else if ch == ">" {
120            self.renderer
121                .render_special_character(SpecialCharacter::Gt, dest);
122        } else if ch == "&" {
123            self.renderer
124                .render_special_character(SpecialCharacter::Ampersand, dest);
125        }
126
127        // No other cases _should_ occur, but if they do, we'll fail safely by
128        // not writing anything into dest.
129    }
130}
131
132static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
133    #[allow(clippy::unwrap_used)]
134    Regex::new("[*_`#^~]").unwrap()
135});
136
137struct QuoteSub {
138    type_: QuoteType,
139    scope: QuoteScope,
140    pattern: Regex,
141}
142
143// Adapted from QUOTE_SUBS in Ruby Asciidoctor implementation,
144// found in https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor.rb#L440.
145//
146// Translation notes:
147// * The `\m` modifier on Ruby regex means the `.` pattern *can* match a new
148//   line. We use the `.dot_matches_new_line(true)` option on `RegexBuilder` to
149//   implement this instead.
150// * The `(?!#{CG_WORD})` look-ahead syntax is not available in Rust regex. It
151//   looks like the `\b{end-half}` pattern can take its place. (This pattern
152//   requires that a non-word character or end of haystack follow the match
153//   point.)
154// * `#{CC_ALL}` just means any character (`.`).
155// * Replace `#{QuoteAttributeListRxt}` with `\\[([^\\[\\]]+)\\]`. (This seems
156//   preferable to having yet another level of backslash escaping.)
157//
158// Notes from the original Ruby implementation:
159// * Unconstrained quotes can appear anywhere.
160// * Constrained quotes must be bordered by non-word characters.
161// * NOTE: These substitutions are processed in the order they appear here and
162//   the order in which they are replaced is important.
163static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
164    vec![
165        QuoteSub {
166            // **strong**
167            type_: QuoteType::Strong,
168            scope: QuoteScope::Unconstrained,
169            #[allow(clippy::unwrap_used)]
170            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
171                .dot_matches_new_line(true)
172                .build()
173                .unwrap(),
174        },
175        QuoteSub {
176            // *strong*
177            type_: QuoteType::Strong,
178            scope: QuoteScope::Constrained,
179            #[allow(clippy::unwrap_used)]
180            pattern: RegexBuilder::new(
181                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
182            )
183            .dot_matches_new_line(true)
184            .build()
185            .unwrap(),
186        },
187        QuoteSub {
188            // "`double-quoted`"
189            type_: QuoteType::DoubleQuote,
190            scope: QuoteScope::Constrained,
191            #[allow(clippy::unwrap_used)]
192            pattern: RegexBuilder::new(
193                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
194            )
195            .dot_matches_new_line(true)
196            .build()
197            .unwrap(),
198        },
199        QuoteSub {
200            // '`single-quoted`'
201            type_: QuoteType::SingleQuote,
202            scope: QuoteScope::Constrained,
203            #[allow(clippy::unwrap_used)]
204            pattern: RegexBuilder::new(
205                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
206            )
207            .dot_matches_new_line(true)
208            .build()
209            .unwrap(),
210        },
211        QuoteSub {
212            // ``monospaced``
213            type_: QuoteType::Monospaced,
214            scope: QuoteScope::Unconstrained,
215            #[allow(clippy::unwrap_used)]
216            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
217                .dot_matches_new_line(true)
218                .build()
219                .unwrap(),
220        },
221        QuoteSub {
222            // `monospaced`
223            type_: QuoteType::Monospaced,
224            scope: QuoteScope::Constrained,
225            #[allow(clippy::unwrap_used)]
226            pattern: RegexBuilder::new(
227                r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
228                // NB: We don't have look-ahead in Rust Regex, so we might miss some edge cases
229                // because Ruby's version matches `(?![#{CC_WORD}"'`])` which is slightly more
230                // detailed than our `\b{end-half}`.
231            )
232            .dot_matches_new_line(true)
233            .build()
234            .unwrap(),
235        },
236        QuoteSub {
237            // __emphasis__
238            type_: QuoteType::Emphasis,
239            scope: QuoteScope::Unconstrained,
240            #[allow(clippy::unwrap_used)]
241            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
242                .dot_matches_new_line(true)
243                .build()
244                .unwrap(),
245        },
246        QuoteSub {
247            // _emphasis_
248            type_: QuoteType::Emphasis,
249            scope: QuoteScope::Constrained,
250            #[allow(clippy::unwrap_used)]
251            pattern: RegexBuilder::new(
252                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
253            )
254            .dot_matches_new_line(true)
255            .build()
256            .unwrap(),
257        },
258        QuoteSub {
259            // ##mark##
260            type_: QuoteType::Mark,
261            scope: QuoteScope::Unconstrained,
262            #[allow(clippy::unwrap_used)]
263            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
264                .dot_matches_new_line(true)
265                .build()
266                .unwrap(),
267        },
268        QuoteSub {
269            // #mark#
270            type_: QuoteType::Mark,
271            scope: QuoteScope::Constrained,
272            #[allow(clippy::unwrap_used)]
273            pattern: RegexBuilder::new(
274                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
275            )
276            .dot_matches_new_line(true)
277            .build()
278            .unwrap(),
279        },
280        QuoteSub {
281            // ^superscript^
282            type_: QuoteType::Superscript,
283            scope: QuoteScope::Unconstrained,
284            #[allow(clippy::unwrap_used)]
285            pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
286        },
287        QuoteSub {
288            // ~subscript~
289            type_: QuoteType::Subscript,
290            scope: QuoteScope::Unconstrained,
291            #[allow(clippy::unwrap_used)]
292            pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
293        },
294    ]
295});
296
297#[derive(Debug)]
298struct QuoteReplacer<'r> {
299    type_: QuoteType,
300    scope: QuoteScope,
301    parser: &'r Parser,
302}
303
304impl LookaheadReplacer for QuoteReplacer<'_> {
305    fn replace_append(
306        &mut self,
307        caps: &Captures<'_>,
308        dest: &mut String,
309        after: &str,
310    ) -> LookaheadResult {
311        // Adapted from Asciidoctor#convert_quoted_text, found in
312        // https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor/substitutors.rb#L1419-L1445.
313
314        // The regex crate doesn't have a sophisticated lookahead mode, so we patch
315        // it up here.
316
317        if self.type_ == QuoteType::Monospaced
318            && self.scope == QuoteScope::Constrained
319            && after.starts_with(['"', '\'', '`'])
320        {
321            let skip_ahead = if caps[0].starts_with('\\') { 2 } else { 1 };
322            dest.push_str(&caps[0][0..skip_ahead]);
323            return LookaheadResult::SkipAheadAndRetry(skip_ahead);
324        }
325
326        let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
327            let maybe_attrs = caps.get(2).map(|a| a.as_str());
328            if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
329                Some(format!(
330                    "[{attrs}]",
331                    attrs = maybe_attrs.unwrap_or_default()
332                ))
333            } else {
334                dest.push_str(&caps[0][1..]);
335                return LookaheadResult::Continue;
336            }
337        } else {
338            None
339        };
340
341        match self.scope {
342            QuoteScope::Constrained => {
343                if let Some(attrs) = unescaped_attrs {
344                    dest.push_str(&attrs);
345                    self.parser.renderer.render_quoted_substitition(
346                        self.type_, self.scope, None, None, &caps[3], dest,
347                    );
348                } else {
349                    let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
350                        if let Some(attrlist) = caps.get(2) {
351                            let type_ = if self.type_ == QuoteType::Mark {
352                                QuoteType::Unquoted
353                            } else {
354                                self.type_
355                            };
356
357                            (
358                                Some(
359                                    Attrlist::parse(
360                                        crate::Span::new(attrlist.as_str()),
361                                        self.parser,
362                                        AttrlistContext::Inline,
363                                    )
364                                    .item
365                                    .item,
366                                ),
367                                type_,
368                            )
369                        } else {
370                            (None, self.type_)
371                        };
372
373                    if let Some(prefix) = caps.get(1) {
374                        dest.push_str(prefix.as_str());
375                    }
376
377                    let id = attrlist
378                        .as_ref()
379                        .and_then(|a| a.id().map(|s| s.to_string()));
380
381                    // Assigning an ID to inline quoted text (e.g.,
382                    // `[#free_the_world]#free the world#`) makes that phrase
383                    // referenceable, so register it in the catalog. A duplicate
384                    // ID here is non-fatal (first registration wins).
385                    if let Some(id) = &id {
386                        let _ = self.parser.register_ref(id, None, RefType::Anchor);
387                    }
388
389                    self.parser.renderer.render_quoted_substitition(
390                        type_, self.scope, attrlist, id, &caps[3], dest,
391                    );
392                }
393            }
394
395            QuoteScope::Unconstrained => {
396                let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
397                    if let Some(attrlist) = caps.get(1) {
398                        let type_ = if self.type_ == QuoteType::Mark {
399                            QuoteType::Unquoted
400                        } else {
401                            self.type_
402                        };
403
404                        (
405                            Some(
406                                Attrlist::parse(
407                                    crate::Span::new(attrlist.as_str()),
408                                    self.parser,
409                                    AttrlistContext::Inline,
410                                )
411                                .item
412                                .item,
413                            ),
414                            type_,
415                        )
416                    } else {
417                        (None, self.type_)
418                    };
419
420                let id = attrlist
421                    .as_ref()
422                    .and_then(|a| a.id().map(|s| s.to_string()));
423
424                // Assigning an ID to inline quoted text (e.g.,
425                // `[#free_the_world]#free the world#`) makes that phrase
426                // referenceable, so register it in the catalog. A duplicate ID
427                // here is non-fatal (first registration wins).
428                if let Some(id) = &id {
429                    let _ = self.parser.register_ref(id, None, RefType::Anchor);
430                }
431
432                self.parser
433                    .renderer
434                    .render_quoted_substitition(type_, self.scope, attrlist, id, &caps[2], dest);
435            }
436        }
437
438        LookaheadResult::Continue
439    }
440}
441
442fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
443    if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
444        return;
445    }
446
447    let mut result: Cow<'_, str> = content.rendered.to_string().into();
448
449    for sub in &*QUOTE_SUBS {
450        let replacer = QuoteReplacer {
451            type_: sub.type_,
452            scope: sub.scope,
453            parser,
454        };
455
456        if let Cow::Owned(new_result) = replace_with_lookahead(&sub.pattern, &result, replacer) {
457            result = new_result.into();
458        }
459        // If it's Cow::Borrowed, there was no match for this pattern, so no
460        // need to pay for a new string allocation.
461    }
462
463    content.rendered = result.into();
464}
465
466static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
467    // Either a `counter`/`counter2` directive (group 1) with its `name[:seed]`
468    // expression (group 2), or a plain attribute name (group 3). This mirrors
469    // the `counter2?:` branch of Asciidoctor's `AttributeReferenceRx`.
470    #[allow(clippy::unwrap_used)]
471    Regex::new(r#"\\?\{(?:(counter2?):([^{}]+)|([A-Za-z0-9_][A-Za-z0-9_-]*))\}"#).unwrap()
472});
473
474/// How the processor handles a reference to a missing attribute, controlled by
475/// the [`attribute-missing`] document attribute.
476///
477/// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
478#[derive(Clone, Copy, Debug, Eq, PartialEq)]
479enum AttributeMissing {
480    /// Leave the reference in place (the default).
481    Skip,
482
483    /// Drop the reference, but not the line that contains it.
484    Drop,
485
486    /// Drop the entire line on which the reference occurs.
487    DropLine,
488
489    /// Leave the reference in place and record a warning.
490    Warn,
491}
492
493impl AttributeMissing {
494    /// Resolves the `attribute-missing` setting from `parser`. An absent or
495    /// unrecognized value falls back to [`Skip`](Self::Skip), matching
496    /// Asciidoctor.
497    fn from_parser(parser: &Parser) -> Self {
498        match parser.attribute_value("attribute-missing").as_maybe_str() {
499            Some("drop") => Self::Drop,
500            Some("drop-line") => Self::DropLine,
501            Some("warn") => Self::Warn,
502            _ => Self::Skip,
503        }
504    }
505}
506
507/// Locates `attribute-missing=warn` warnings within a single line.
508///
509/// # Why a per-line, positional correlation
510///
511/// Attribute references are replaced during the *attributes* substitution,
512/// which operates on [`Content::rendered`] — text that earlier steps (special
513/// characters, quotes) have already transformed, and from which passthroughs
514/// have been masked to placeholder tokens. A byte offset in that rendered text
515/// therefore has no constant delta back to the original source `Span`, so a
516/// warning cannot simply slice `content.original()` at the rendered offset.
517///
518/// Three approaches were considered (see issue #564):
519///
520/// 1. **Thread a source-offset map through every substitution step.** Fully
521///    general, but adds offset-tracking state to `Content` and every mutating
522///    step — a large surface and regression risk disproportionate to a
523///    diagnostic-only refinement.
524/// 2. **Scan the whole original source positionally.** Pair the *k*-th
525///    reference found in `rendered` with the *k*-th `{name}` in
526///    `content.original()`. Simple, but the raw source still contains `{name}`
527///    tokens that never reach substitution — inside removed comment lines and
528///    inside passthroughs — so the pairing drifts out of alignment.
529/// 3. **Per-line positional correlation (chosen).** Anchor each rendered line
530///    to the source `Span` of the line it came from (retained at construction,
531///    see [`Content::from_filtered_lines`]), then pair the *k*-th reference on
532///    the rendered line with the *k*-th `{name}` in that source line.
533///
534/// Approach 3 works because the two length-changing steps that run before
535/// attributes (special characters, quotes) neither add, remove, nor reorder
536/// `{…}`-shaped tokens and never introduce or remove a newline, so a rendered
537/// line and its source line carry the same reference tokens in the same order.
538/// Anchoring per line (rather than per block) sidesteps the
539/// removed-comment-line drift of approach 2 for free.
540///
541/// # Graceful degradation
542///
543/// The correlation is best-effort. When a precise span can't be trusted it
544/// falls back to [`fallback_source`](Self::fallback_source) (the whole-content
545/// span, i.e. the pre-#564 behavior):
546///
547/// - No source line is available ([`source_line`](Self::source_line) is
548///   `None`), e.g. for content not built line-by-line from source.
549/// - The retained line count no longer matches the rendered line count, e.g. a
550///   multi-line passthrough collapsed lines during extraction. The caller
551///   detects this and withholds the source line.
552/// - The *k*-th source-line match's text does not equal the rendered match,
553///   e.g. an inline passthrough on the same line masked an earlier reference
554///   and shifted the count. The [text check](Self::warning_source) catches the
555///   mismatch and degrades to the fallback rather than pointing at the wrong
556///   token.
557#[derive(Debug)]
558struct AttributeReplacer<'p> {
559    parser: &'p Parser,
560
561    /// How to handle a reference to a missing attribute.
562    mode: AttributeMissing,
563
564    /// Source span used to locate a `warn` warning when a precise per-reference
565    /// span cannot be recovered. This is the whole content (or line/target)
566    /// span — the coarse fallback described in the type-level docs.
567    fallback_source: Span<'p>,
568
569    /// Source `Span` of the line currently being processed, when known. Every
570    /// attribute reference on this line is located by slicing a subrange of
571    /// this span. `None` disables precise location (the warning uses
572    /// [`fallback_source`](Self::fallback_source)).
573    source_line: Option<Span<'p>>,
574
575    /// Byte ranges (into [`source_line`](Self::source_line)'s data) of every
576    /// `ATTRIBUTE_REFERENCE` match on the source line, in order. Populated only
577    /// in [`AttributeMissing::Warn`] mode and only when `source_line` is set.
578    source_matches: Vec<Range<usize>>,
579
580    /// Index of the next reference to be processed on this line, into
581    /// [`source_matches`](Self::source_matches). The regex driver calls
582    /// [`replace_append`](Replacer::replace_append) once per match, left to
583    /// right, so this stays in step with the rendered matches.
584    match_index: usize,
585
586    /// Set to `true` when a (non-escaped) reference to a missing attribute is
587    /// encountered, so the caller can drop the whole line in
588    /// [`AttributeMissing::DropLine`] mode.
589    missing_on_line: bool,
590}
591
592impl<'p> AttributeReplacer<'p> {
593    /// Builds the replacer for one line, precomputing the source-match ranges
594    /// used to locate `warn` warnings precisely.
595    ///
596    /// `source_line` is the source span the line was rendered from, or `None`
597    /// when no precise mapping is available. `fallback_source` is the coarse
598    /// span used when a precise location cannot be recovered.
599    fn new(
600        parser: &'p Parser,
601        mode: AttributeMissing,
602        fallback_source: Span<'p>,
603        source_line: Option<Span<'p>>,
604    ) -> Self {
605        // The per-reference ranges are only consulted in `warn` mode, so skip the
606        // extra scan otherwise.
607        let source_matches = match (mode, source_line) {
608            (AttributeMissing::Warn, Some(line)) => ATTRIBUTE_REFERENCE
609                .find_iter(line.data())
610                .map(|m| m.range())
611                .collect(),
612            _ => Vec::new(),
613        };
614
615        Self {
616            parser,
617            mode,
618            fallback_source,
619            source_line,
620            source_matches,
621            match_index: 0,
622            missing_on_line: false,
623        }
624    }
625
626    /// Returns the source span to attribute a `warn` warning to for the
627    /// reference at `index` on this line, whose matched text (including any
628    /// leading escape backslash) is `matched`.
629    ///
630    /// Falls back to [`fallback_source`](Self::fallback_source) unless a
631    /// retained source-line match at `index` exists *and* its text equals
632    /// `matched` — the text check guards against a correlation that has
633    /// drifted (see the type-level docs).
634    fn warning_source(&self, index: usize, matched: &str) -> Span<'p> {
635        if let Some(line) = self.source_line
636            && let Some(range) = self.source_matches.get(index)
637            && line.data().get(range.clone()) == Some(matched)
638        {
639            return line.slice(range.clone());
640        }
641
642        self.fallback_source
643    }
644}
645
646impl Replacer for AttributeReplacer<'_> {
647    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
648        // Consume this reference's position in the line so the next call lines up
649        // with the next source-line match, regardless of which branch handles it.
650        let match_index = self.match_index;
651        self.match_index += 1;
652
653        let escaped = caps[0].starts_with('\\');
654
655        // A `counter`/`counter2` directive resolves (and advances) a counter
656        // rather than looking up an existing attribute.
657        if let Some(directive) = caps.get(1) {
658            if escaped {
659                // An escaped counter reference is emitted literally (without the
660                // backslash) and does not advance the counter.
661                dest.push_str(&caps[0][1..]);
662                return;
663            }
664
665            // Group 2 always participates when group 1 does (same alternation
666            // branch). The expression is `name` or `name:seed`.
667            let mut parts = caps[2].splitn(2, ':');
668            let name = parts.next().unwrap_or_default();
669            let seed = parts.next();
670
671            let value = self.parser.counter(name, seed);
672
673            // `counter` displays the new value; `counter2` advances silently.
674            if directive.as_str() == "counter" {
675                dest.push_str(&value);
676            }
677            return;
678        }
679
680        // Otherwise this is a plain attribute reference (group 3).
681        let attr_name = &caps[3];
682
683        // An escaped reference (e.g. `\{id}`) is emitted literally with the
684        // escaping backslash removed, whether or not the attribute is set. It is
685        // never treated as a missing reference, so it neither drops the line nor
686        // warns. This mirrors Asciidoctor, whose `sub_attributes` returns the
687        // match minus its leading backslash before any missing-attribute
688        // handling runs.
689        if escaped {
690            dest.push_str(&caps[0][1..]);
691            return;
692        }
693
694        if !self.parser.has_attribute(attr_name) {
695            match self.mode {
696                AttributeMissing::Skip => dest.push_str(&caps[0]),
697                AttributeMissing::Drop => {
698                    // Drop the reference, leaving the rest of the line intact.
699                }
700                AttributeMissing::DropLine => {
701                    // Mark the line for removal; whatever is written to `dest`
702                    // here is discarded with it.
703                    self.missing_on_line = true;
704                }
705                AttributeMissing::Warn => {
706                    dest.push_str(&caps[0]);
707                    self.parser.record_substitution_warning(
708                        self.warning_source(match_index, &caps[0]),
709                        WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
710                    );
711                }
712            }
713            return;
714        }
715
716        if let InterpretedValue::Value(value) = self.parser.attribute_value(attr_name) {
717            dest.push_str(value.as_ref());
718        }
719        // Language description is unclear as to what happens for "set" and
720        // "unset" attribute values. For now, we'll replace those with nothing.
721    }
722}
723
724fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
725    if !content.rendered.contains('{') {
726        return;
727    }
728
729    let mode = AttributeMissing::from_parser(parser);
730    let source = content.original();
731
732    // In `warn` mode, anchor each rendered line to the source `Span` it came
733    // from so a warning can name the precise offset of the offending reference
734    // (see `AttributeReplacer`). The retained line spans are only trustworthy
735    // when they still line up one-to-one with the rendered lines; a mismatch
736    // (e.g. a multi-line passthrough that collapsed lines during extraction)
737    // withholds them, falling back to the coarse whole-content span.
738    let source_lines = if mode == AttributeMissing::Warn {
739        content
740            .source_lines()
741            .filter(|lines| lines.len() == content.rendered.split('\n').count())
742    } else {
743        None
744    };
745
746    // Attribute references are replaced line by line so that, in `drop-line`
747    // mode, an individual line carrying a missing reference can be removed
748    // without disturbing the lines around it. A reference cannot span a line
749    // break, so this matches what a single whole-text pass would produce for
750    // every other mode.
751    let mut out = String::with_capacity(content.rendered.len());
752    let mut changed = false;
753    let mut wrote_line = false;
754
755    for (index, line) in content.rendered.split('\n').enumerate() {
756        if !line.contains('{') {
757            if wrote_line {
758                out.push('\n');
759            }
760            out.push_str(line);
761            wrote_line = true;
762            continue;
763        }
764
765        // `index` enumerates the same split whose count the guard above matched
766        // against `source_lines.len()`, so the entry is always present; `.get`
767        // keeps the access panic-free regardless.
768        let source_line = source_lines.and_then(|lines| lines.get(index).copied());
769        let mut replacer = AttributeReplacer::new(parser, mode, source, source_line);
770
771        let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
772
773        if replacer.missing_on_line && mode == AttributeMissing::DropLine {
774            // Drop the entire line, including its line break.
775            changed = true;
776            continue;
777        }
778
779        if let Cow::Owned(_) = replaced {
780            changed = true;
781        }
782
783        if wrote_line {
784            out.push('\n');
785        }
786        out.push_str(&replaced);
787        wrote_line = true;
788    }
789
790    // If nothing was replaced or dropped, leave the (borrowed) rendering as-is
791    // rather than paying for the rebuilt string.
792    if changed {
793        content.rendered = out.into();
794    }
795}
796
797/// Applies the attribute-references substitution to a block macro target (the
798/// portion between the `::` and the `[` of an `image::`, `video::`, or
799/// `audio::` macro), honoring the [`attribute-missing`] document attribute.
800///
801/// Block macro targets are always a single line, so (unlike
802/// [`apply_attributes`]) there is no line splitting. Returns `None` when the
803/// target references a missing attribute under
804/// [`AttributeMissing::DropLine`] — signaling that the entire block should be
805/// dropped — and otherwise returns the substituted target.
806///
807/// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
808pub(crate) fn substitute_attributes_in_macro_target<'src>(
809    target: Span<'src>,
810    parser: &Parser,
811) -> Option<CowStr<'src>> {
812    let text = target.data();
813
814    // Without a reference there is nothing to substitute (and nothing that
815    // could trigger a drop), so the borrowed target is returned as-is.
816    if !text.contains('{') {
817        return Some(text.into());
818    }
819
820    let mode = AttributeMissing::from_parser(parser);
821
822    // The target is a single source-backed line, so it doubles as both the
823    // precise per-reference anchor and the coarse fallback.
824    let mut replacer = AttributeReplacer::new(parser, mode, target, Some(target));
825
826    let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
827
828    if replacer.missing_on_line && mode == AttributeMissing::DropLine {
829        return None;
830    }
831
832    Some(replaced.into())
833}
834
835/// Applies the attribute-references substitution to free-standing text (such as
836/// the content of a [docinfo file]), honoring the [`attribute-missing`]
837/// document attribute, and returns the substituted result.
838///
839/// Unlike [`apply_attributes`], this operates on owned text that is not part of
840/// the document source. Substitution is performed line by line so that, in
841/// `drop-line` mode, an individual line carrying a missing reference can be
842/// removed without disturbing the lines around it.
843///
844/// Any `warn`-mode warnings it records on `parser` refer to offsets within
845/// `text` (not the document source); callers that do not want such warnings
846/// surfaced should discard them via
847/// [`Parser::truncate_substitution_warnings`](crate::Parser).
848///
849/// [docinfo file]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
850/// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
851pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
852    if !text.contains('{') {
853        return text.to_string();
854    }
855
856    let mode = AttributeMissing::from_parser(parser);
857    let source = Span::new(text);
858
859    let mut out = String::with_capacity(text.len());
860    let mut wrote_line = false;
861
862    for line in text.split('\n') {
863        if !line.contains('{') {
864            if wrote_line {
865                out.push('\n');
866            }
867            out.push_str(line);
868            wrote_line = true;
869            continue;
870        }
871
872        // This text is not backed by the document source (offsets refer into
873        // `text`, and callers discard these warnings), so no precise per-line
874        // anchor is supplied: warnings fall back to the whole-text span.
875        let mut replacer = AttributeReplacer::new(parser, mode, source, None);
876
877        let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
878
879        if replacer.missing_on_line && mode == AttributeMissing::DropLine {
880            // Drop the entire line, including its line break.
881            continue;
882        }
883
884        if wrote_line {
885            out.push('\n');
886        }
887        out.push_str(&replaced);
888        wrote_line = true;
889    }
890
891    out
892}
893
894fn apply_character_replacements(
895    content: &mut Content<'_>,
896    renderer: &dyn InlineSubstitutionRenderer,
897) {
898    if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
899        return;
900    }
901
902    let mut result: Cow<'_, str> = content.rendered.to_string().into();
903
904    for repl in &*REPLACEMENTS {
905        let replacer = CharacterReplacer {
906            type_: repl.type_.clone(),
907            renderer,
908        };
909
910        if let Cow::Owned(new_result) = repl.pattern.replace_all(&result, replacer) {
911            result = new_result.into();
912        }
913        // If it's Cow::Borrowed, there was no match for this pattern, so no
914        // need to pay for a new string allocation.
915    }
916
917    content.rendered = result.into();
918}
919
920struct CharacterReplacement {
921    type_: CharacterReplacementType,
922    pattern: Regex,
923}
924
925static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
926    #[allow(clippy::unwrap_used)]
927    Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
928});
929
930// Adapted from REPLACEMENTS in Ruby Asciidoctor implementation,
931// found in https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor.rb#L490.
932//
933// * NOTE: These substitutions are processed in the order they appear here and
934//   the order in which they are replaced is important.
935static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
936    vec![
937        CharacterReplacement {
938            // Copyright `(C)`
939            type_: CharacterReplacementType::Copyright,
940            #[allow(clippy::unwrap_used)]
941            pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
942        },
943        CharacterReplacement {
944            // Registered `(R)`
945            type_: CharacterReplacementType::Registered,
946            #[allow(clippy::unwrap_used)]
947            pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
948        },
949        CharacterReplacement {
950            // Trademark `(TM)`
951            type_: CharacterReplacementType::Trademark,
952            #[allow(clippy::unwrap_used)]
953            pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
954        },
955        CharacterReplacement {
956            // Em dash surrounded by spaces ` -- `
957            type_: CharacterReplacementType::EmDashSurroundedBySpaces,
958            #[allow(clippy::unwrap_used)]
959            pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
960        },
961        CharacterReplacement {
962            // Em dash without spaces `--`
963            type_: CharacterReplacementType::EmDashWithoutSpace,
964            #[allow(clippy::unwrap_used)]
965            pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
966        },
967        CharacterReplacement {
968            // Ellipsis `...`
969            type_: CharacterReplacementType::Ellipsis,
970            #[allow(clippy::unwrap_used)]
971            pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
972        },
973        CharacterReplacement {
974            // Right single quote `\`'`
975            type_: CharacterReplacementType::TypographicApostrophe,
976            #[allow(clippy::unwrap_used)]
977            pattern: Regex::new(r#"\\?`'"#).unwrap(),
978        },
979        CharacterReplacement {
980            // Apostrophe (inside a word)
981            type_: CharacterReplacementType::TypographicApostrophe,
982            #[allow(clippy::unwrap_used)]
983            pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
984        },
985        CharacterReplacement {
986            // Right arrow `->`
987            type_: CharacterReplacementType::SingleRightArrow,
988            #[allow(clippy::unwrap_used)]
989            pattern: Regex::new(r#"\\?-&gt;"#).unwrap(),
990        },
991        CharacterReplacement {
992            // Right double arrow `=>`
993            type_: CharacterReplacementType::DoubleRightArrow,
994            #[allow(clippy::unwrap_used)]
995            pattern: Regex::new(r#"\\?=&gt;"#).unwrap(),
996        },
997        CharacterReplacement {
998            // Left arrow `<-`
999            type_: CharacterReplacementType::SingleLeftArrow,
1000            #[allow(clippy::unwrap_used)]
1001            pattern: Regex::new(r#"\\?&lt;-"#).unwrap(),
1002        },
1003        CharacterReplacement {
1004            // Left double arrow `<=`
1005            type_: CharacterReplacementType::DoubleLeftArrow,
1006            #[allow(clippy::unwrap_used)]
1007            pattern: Regex::new(r#"\\?&lt;="#).unwrap(),
1008        },
1009        CharacterReplacement {
1010            // Restore entities
1011            type_: CharacterReplacementType::CharacterReference("".to_owned()),
1012            #[allow(clippy::unwrap_used)]
1013            pattern: Regex::new(r#"\\?&amp;((?:[a-zA-Z][a-zA-Z]+\d{0,2}|#\d\d\d{0,4}|#x[\da-fA-F][\da-fA-F][\da-fA-F]{0,3}));"#).unwrap(),
1014        },
1015    ]
1016});
1017
1018#[derive(Debug)]
1019struct CharacterReplacer<'r> {
1020    type_: CharacterReplacementType,
1021    renderer: &'r dyn InlineSubstitutionRenderer,
1022}
1023
1024impl Replacer for CharacterReplacer<'_> {
1025    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1026        if caps[0].contains('\\') {
1027            // We have to replace since we aren't sure the backslash is the first char.
1028            let unescaped = &caps[0].replace("\\", "");
1029            dest.push_str(unescaped);
1030            return;
1031        }
1032
1033        match self.type_ {
1034            CharacterReplacementType::Copyright
1035            | CharacterReplacementType::Registered
1036            | CharacterReplacementType::Trademark
1037            | CharacterReplacementType::EmDashSurroundedBySpaces
1038            | CharacterReplacementType::Ellipsis
1039            | CharacterReplacementType::SingleLeftArrow
1040            | CharacterReplacementType::DoubleLeftArrow
1041            | CharacterReplacementType::SingleRightArrow
1042            | CharacterReplacementType::DoubleRightArrow => {
1043                self.renderer
1044                    .render_character_replacement(self.type_.clone(), dest);
1045            }
1046
1047            CharacterReplacementType::EmDashWithoutSpace => {
1048                dest.push_str(&caps[1]);
1049                self.renderer.render_character_replacement(
1050                    CharacterReplacementType::EmDashWithoutSpace,
1051                    dest,
1052                );
1053            }
1054
1055            CharacterReplacementType::TypographicApostrophe => {
1056                if let Some(before) = caps.get(1) {
1057                    dest.push_str(before.as_str());
1058                }
1059
1060                self.renderer.render_character_replacement(
1061                    CharacterReplacementType::TypographicApostrophe,
1062                    dest,
1063                );
1064
1065                if let Some(after) = caps.get(2) {
1066                    dest.push_str(after.as_str());
1067                }
1068            }
1069
1070            CharacterReplacementType::CharacterReference(_) => {
1071                self.renderer.render_character_replacement(
1072                    CharacterReplacementType::CharacterReference(caps[1].to_string()),
1073                    dest,
1074                );
1075            }
1076        }
1077    }
1078}
1079
1080fn apply_post_replacements(
1081    content: &mut Content<'_>,
1082    parser: &Parser,
1083    attrlist: Option<&Attrlist<'_>>,
1084) {
1085    if parser.is_attribute_set("hardbreaks-option")
1086        || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
1087    {
1088        let text = content.rendered.as_ref();
1089        if !text.contains('\n') {
1090            return;
1091        }
1092
1093        let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
1094        let last = lines.pop().unwrap_or_default();
1095
1096        let mut lines: Vec<String> = lines
1097            .iter()
1098            .map(|line| {
1099                let line = if line.ends_with(" +") {
1100                    &line[0..line.len() - 2]
1101                } else {
1102                    *line
1103                };
1104
1105                let mut line = line.to_owned();
1106                parser.renderer.render_line_break(&mut line);
1107                line
1108            })
1109            .collect();
1110
1111        lines.push(last.to_owned());
1112
1113        let new_result = lines.join("\n");
1114        content.rendered = new_result.into();
1115    } else {
1116        let rendered = content.rendered.as_ref();
1117        if !(rendered.contains('+') && rendered.contains('\n')) {
1118            return;
1119        }
1120
1121        let replacer = PostReplacementReplacer(&*parser.renderer);
1122
1123        if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1124            content.rendered = new_result.into();
1125        }
1126    }
1127}
1128
1129#[derive(Debug)]
1130struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1131
1132impl Replacer for PostReplacementReplacer<'_> {
1133    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1134        dest.push_str(&caps[1]);
1135        self.0.render_line_break(dest);
1136    }
1137}
1138
1139static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1140    #[allow(clippy::unwrap_used)]
1141    Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1142});
1143
1144/// Processes [callouts] in literal, listing, and source blocks.
1145///
1146/// Callout numbers (`<1>`, `<.>`, or `<!--1-->` for XML) that appear at the end
1147/// of a line are replaced with the renderer's callout markup. Callouts may be
1148/// tucked behind a line comment (`//`, `#`, `--`, or `;;` by default, or a
1149/// custom prefix specified by the `line-comment` attribute), and a callout may
1150/// be escaped with a leading backslash to render it literally.
1151///
1152/// This substitution runs after [special characters] have been replaced, so the
1153/// angle brackets that delimit a callout appear in `content.rendered` as
1154/// `&lt;` and `&gt;`. This mirrors Asciidoctor's `sub_callouts` /
1155/// `CalloutSourceRx`.
1156///
1157/// [callouts]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
1158/// [special characters]: https://docs.asciidoctor.org/asciidoc/latest/subs/special-characters/
1159fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1160    // A callout's opening bracket is always rendered as `&lt;` by the special
1161    // characters substitution, so we can cheaply skip content without any.
1162    if !content.rendered.contains("&lt;") {
1163        return;
1164    }
1165
1166    // The `line-comment` attribute (block-level, falling back to document-level)
1167    // customizes or disables line-comment recognition:
1168    //
1169    // * absent -> default prefixes (`//`, `#`, `--`, `;;`) and XML callouts are
1170    //   recognized.
1171    // * present (custom) -> only the given prefix is recognized; XML callouts are
1172    //   not.
1173    // * present but empty -> no line-comment prefix is recognized; XML callouts are
1174    //   not.
1175    let line_comment: Option<String> = attrlist
1176        .and_then(|a| a.named_attribute("line-comment"))
1177        .map(|a| a.value().to_string())
1178        .or_else(|| {
1179            if parser.has_attribute("line-comment") {
1180                Some(
1181                    parser
1182                        .attribute_value("line-comment")
1183                        .as_maybe_str()
1184                        .unwrap_or("")
1185                        .to_string(),
1186                )
1187            } else {
1188                None
1189            }
1190        });
1191
1192    let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1193
1194    let replacer = CalloutReplacer {
1195        renderer: &*parser.renderer,
1196        parser,
1197        autonum: 0,
1198        tail: tail_rx,
1199    };
1200
1201    if let Cow::Owned(new_result) =
1202        replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1203    {
1204        content.rendered = new_result.into();
1205    }
1206}
1207
1208/// Callout regex for the default `line-comment` mode: recognizes the common
1209/// line-comment prefixes and XML callouts.
1210static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1211    #[allow(clippy::unwrap_used)]
1212    Regex::new(
1213        r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:&lt;!--(?P<xnum>\d+|\.)--&gt;|&lt;(?P<num>\d+|\.)&gt;)",
1214    )
1215    .unwrap()
1216});
1217
1218/// Trailing-position lookahead regex for the default `line-comment` mode.
1219static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1220    #[allow(clippy::unwrap_used)]
1221    Regex::new(r"^(?: ?\\?(?:&lt;!--(?:\d+|\.)--&gt;|&lt;(?:\d+|\.)&gt;))*(?:\n|$)").unwrap()
1222});
1223
1224/// Trailing-position lookahead regex for a custom or empty `line-comment` mode
1225/// (no XML callout form).
1226static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1227    #[allow(clippy::unwrap_used)]
1228    Regex::new(r"^(?: ?\\?&lt;(?:\d+|\.)&gt;)*(?:\n|$)").unwrap()
1229});
1230
1231/// Builds the `(callout, tail)` regex pair for the given `line-comment` mode.
1232///
1233/// The `callout` regex matches a single callout token (with the optional
1234/// line-comment prefix and escape that may precede it). The `tail` regex is
1235/// used to emulate Asciidoctor's trailing-position lookahead: a matched callout
1236/// is only honored when the remainder of its line consists solely of further
1237/// callouts. Rust's regex engine supports neither lookahead nor backreferences,
1238/// so the lookahead is applied manually against the post-match text.
1239///
1240/// The default-mode regexes and both tail regexes are constant, so they are
1241/// built once. Only a custom (non-empty) prefix requires building a regex from
1242/// the attribute value, which is borrowed otherwise.
1243fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1244    match line_comment {
1245        // Default: recognize the common line-comment prefixes and XML callouts.
1246        None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1247
1248        // A custom or empty `line-comment`: only the bare (non-XML) callout form
1249        // is recognized, optionally behind the custom prefix.
1250        Some(prefix) => {
1251            let prefix_pattern = if prefix.is_empty() {
1252                String::new()
1253            } else {
1254                format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1255            };
1256
1257            #[allow(clippy::unwrap_used)]
1258            let callout = Regex::new(&format!(
1259                r"{prefix_pattern}(?P<esc>\\)?&lt;(?P<num>\d+|\.)&gt;"
1260            ))
1261            .unwrap();
1262
1263            (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1264        }
1265    }
1266}
1267
1268/// Replacer that renders each trailing callout token, emulating Asciidoctor's
1269/// `sub_callouts`.
1270struct CalloutReplacer<'r> {
1271    renderer: &'r dyn InlineSubstitutionRenderer,
1272    parser: &'r Parser,
1273
1274    /// Running counter for automatically-numbered (`<.>`) callouts, scoped to a
1275    /// single block.
1276    autonum: u32,
1277
1278    /// Trailing-position lookahead regex (see [`build_callout_regexes`]).
1279    tail: &'r Regex,
1280}
1281
1282impl LookaheadReplacer for CalloutReplacer<'_> {
1283    fn replace_append(
1284        &mut self,
1285        caps: &Captures<'_>,
1286        dest: &mut String,
1287        after: &str,
1288    ) -> LookaheadResult {
1289        // Honor the trailing-position requirement: a callout is only recognized
1290        // when nothing but further callouts follows it on the line.
1291        if !self.tail.is_match(after) {
1292            dest.push_str(&caps[0]);
1293            return LookaheadResult::Continue;
1294        }
1295
1296        // Honor the escape: emit the matched text with the escaping backslash
1297        // removed so the callout renders literally.
1298        if caps.name("esc").is_some() {
1299            dest.push_str(&caps[0].replacen('\\', "", 1));
1300            return LookaheadResult::Continue;
1301        }
1302
1303        let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1304            (xnum.as_str(), true)
1305        } else {
1306            // The regex guarantees one of `xnum` or `num` is present.
1307            #[allow(clippy::unwrap_used)]
1308            (caps.name("num").unwrap().as_str(), false)
1309        };
1310
1311        let number = if number_raw == "." {
1312            self.autonum += 1;
1313            self.autonum.to_string()
1314        } else {
1315            number_raw.to_string()
1316        };
1317
1318        // Register this callout so the callout list that annotates this block
1319        // can be validated against the callouts it references.
1320        if let Ok(n) = number.parse::<u32>() {
1321            self.parser.register_callout(n);
1322        }
1323
1324        // Mirror Asciidoctor's guard resolution: a captured line-comment prefix
1325        // takes precedence; otherwise an XML callout uses the XML guard; failing
1326        // both, there is no guard.
1327        let guard = match caps.name("prefix") {
1328            Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1329            None if is_xml => CalloutGuard::Xml,
1330            None => CalloutGuard::LineComment(""),
1331        };
1332
1333        self.renderer.render_callout(
1334            &CalloutRenderParams {
1335                number: &number,
1336                guard,
1337                parser: self.parser,
1338            },
1339            dest,
1340        );
1341
1342        LookaheadResult::Continue
1343    }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348    #![allow(clippy::unwrap_used)]
1349
1350    mod special_characters {
1351        use crate::{
1352            content::{Content, SubstitutionStep},
1353            strings::CowStr,
1354            tests::prelude::*,
1355        };
1356
1357        #[test]
1358        fn empty() {
1359            let mut content = Content::from(crate::Span::default());
1360            let p = Parser::default();
1361            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1362            assert!(content.is_empty());
1363            assert_eq!(content.rendered, CowStr::Borrowed(""));
1364        }
1365
1366        #[test]
1367        fn basic_non_empty_span() {
1368            let mut content = Content::from(crate::Span::new("blah"));
1369            let p = Parser::default();
1370            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1371            assert!(!content.is_empty());
1372            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1373        }
1374
1375        #[test]
1376        fn match_lt_and_gt() {
1377            let mut content = Content::from(crate::Span::new("bl<ah>"));
1378            let p = Parser::default();
1379            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1380            assert!(!content.is_empty());
1381            assert_eq!(
1382                content.rendered,
1383                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1384            );
1385        }
1386
1387        #[test]
1388        fn match_amp() {
1389            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1390            let p = Parser::default();
1391            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1392            assert!(!content.is_empty());
1393            assert_eq!(
1394                content.rendered,
1395                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1396            );
1397        }
1398    }
1399
1400    mod quotes {
1401        use crate::{
1402            content::{Content, SubstitutionStep},
1403            strings::CowStr,
1404            tests::prelude::*,
1405        };
1406
1407        #[test]
1408        fn empty() {
1409            let mut content = Content::from(crate::Span::default());
1410            let p = Parser::default();
1411            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1412            assert!(content.is_empty());
1413            assert_eq!(content.rendered, CowStr::Borrowed(""));
1414        }
1415
1416        #[test]
1417        fn basic_non_empty_span() {
1418            let mut content = Content::from(crate::Span::new("blah"));
1419            let p = Parser::default();
1420            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1421            assert!(!content.is_empty());
1422            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1423        }
1424
1425        #[test]
1426        fn ignore_lt_and_gt() {
1427            let mut content = Content::from(crate::Span::new("bl<ah>"));
1428            let p = Parser::default();
1429            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1430            assert!(!content.is_empty());
1431            assert_eq!(
1432                content.rendered,
1433                CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1434            );
1435        }
1436
1437        #[test]
1438        fn strong_word() {
1439            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1440            let p = Parser::default();
1441            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1442            assert!(!content.is_empty());
1443            assert_eq!(
1444                content.rendered,
1445                CowStr::Boxed(
1446                    "One <strong>word</strong> is strong."
1447                        .to_string()
1448                        .into_boxed_str()
1449                )
1450            );
1451        }
1452
1453        #[test]
1454        fn marked_string_with_id() {
1455            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1456            let p = Parser::default();
1457            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1458            assert!(!content.is_empty());
1459            assert_eq!(
1460                content.rendered,
1461                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1462            );
1463        }
1464
1465        #[test]
1466        fn unconstrained_marked_string_with_id_is_registered() {
1467            // An ID assigned to *unconstrained* quoted text (here, `##...##`)
1468            // is rendered as the element's `id` and registered in the catalog
1469            // so the phrase can be the target of a cross reference.
1470            let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1471
1472            assert_eq!(
1473                doc.nested_blocks()
1474                    .next()
1475                    .unwrap()
1476                    .rendered_content()
1477                    .unwrap(),
1478                r#"<span id="the_id">marked text</span>"#
1479            );
1480
1481            assert!(doc.catalog().contains_id("the_id"));
1482        }
1483    }
1484
1485    mod attribute_references {
1486        use crate::{
1487            content::{Content, SubstitutionStep},
1488            strings::CowStr,
1489            tests::prelude::*,
1490        };
1491
1492        #[test]
1493        fn empty() {
1494            let mut content = Content::from(crate::Span::default());
1495            let p = Parser::default();
1496            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1497            assert!(content.is_empty());
1498            assert_eq!(content.rendered, CowStr::Borrowed(""));
1499        }
1500
1501        #[test]
1502        fn basic_non_empty_span() {
1503            let mut content = Content::from(crate::Span::new("blah"));
1504            let p = Parser::default();
1505            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1506            assert!(!content.is_empty());
1507            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1508        }
1509
1510        #[test]
1511        fn ignore_non_match() {
1512            let mut content = Content::from(crate::Span::new("bl{ah}"));
1513            let p = Parser::default();
1514            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1515            assert!(!content.is_empty());
1516            assert_eq!(
1517                content.rendered,
1518                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1519            );
1520        }
1521
1522        #[test]
1523        fn escaped_reference_to_unset_attribute_drops_backslash() {
1524            // `ah` is a valid attribute name but is unset. An escaped reference
1525            // still has its backslash removed and is passed through literally,
1526            // matching Asciidoctor (see issue #667).
1527            let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1528            let p = Parser::default();
1529            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1530            assert!(!content.is_empty());
1531            assert_eq!(
1532                content.rendered,
1533                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1534            );
1535        }
1536
1537        #[test]
1538        fn replace_sp_match() {
1539            let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1540            let p = Parser::default();
1541            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1542            assert!(!content.is_empty());
1543            assert_eq!(
1544                content.rendered,
1545                CowStr::Boxed("bl ah".to_string().into_boxed_str())
1546            );
1547        }
1548
1549        #[test]
1550        fn ignore_escaped_sp_match() {
1551            let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1552            let p = Parser::default();
1553            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1554            assert!(!content.is_empty());
1555            assert_eq!(
1556                content.rendered,
1557                CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1558            );
1559        }
1560
1561        #[test]
1562        fn counter_directive_displays_and_advances() {
1563            let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1564            let p = Parser::default();
1565            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1566            assert_eq!(
1567                content.rendered,
1568                CowStr::Boxed("1-2".to_string().into_boxed_str())
1569            );
1570        }
1571
1572        #[test]
1573        fn counter2_directive_advances_silently() {
1574            let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1575            let p = Parser::default();
1576            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1577            assert_eq!(
1578                content.rendered,
1579                CowStr::Boxed("2".to_string().into_boxed_str())
1580            );
1581        }
1582
1583        #[test]
1584        fn escaped_counter_directive_is_literal_and_does_not_advance() {
1585            let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1586            let p = Parser::default();
1587            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1588            assert_eq!(
1589                content.rendered,
1590                CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1591            );
1592        }
1593
1594        mod attribute_missing {
1595            #![allow(clippy::indexing_slicing)]
1596
1597            use crate::{
1598                Span,
1599                content::{Content, SubstitutionGroup, SubstitutionStep},
1600                parser::ModificationContext,
1601                tests::prelude::*,
1602                warnings::WarningType,
1603            };
1604
1605            fn parser_with_mode(mode: &str) -> Parser {
1606                Parser::default().with_intrinsic_attribute(
1607                    "attribute-missing",
1608                    mode,
1609                    ModificationContext::Anywhere,
1610                )
1611            }
1612
1613            fn render(text: &str, parser: &Parser) -> String {
1614                let mut content = Content::from(crate::Span::new(text));
1615                SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1616                content.rendered.to_string()
1617            }
1618
1619            /// Builds a `Content` that carries the per-line source spans (as a
1620            /// real block would), so the precise `warn`-location correlation is
1621            /// exercised. Each line's span is a subrange of the root, mirroring
1622            /// what block construction retains via
1623            /// [`Content::from_filtered_lines`].
1624            fn content_with_source_lines(text: &'static str) -> Content<'static> {
1625                let root = Span::new(text);
1626                let lines: Vec<&str> = text.split('\n').collect();
1627
1628                let mut spans = Vec::with_capacity(lines.len());
1629                let mut offset = 0;
1630                for line in &lines {
1631                    spans.push(root.slice(offset..offset + line.len()));
1632                    // Advance past the line and the '\n' that split consumed.
1633                    offset += line.len() + 1;
1634                }
1635
1636                Content::from_filtered_lines(root, &lines, spans)
1637            }
1638
1639            /// Asserts that `warning`'s recorded offset/length select exactly
1640            /// `expected` out of `text`, i.e. the warning points at that
1641            /// precise reference in the original source.
1642            fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1643                assert_eq!(
1644                    &text[warning.offset..warning.offset + warning.len],
1645                    expected
1646                );
1647            }
1648
1649            #[test]
1650            fn skip_is_default() {
1651                let p = Parser::default();
1652                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1653                assert!(p.take_substitution_warnings().is_empty());
1654            }
1655
1656            #[test]
1657            fn skip_explicit() {
1658                let p = parser_with_mode("skip");
1659                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1660            }
1661
1662            #[test]
1663            fn unknown_value_falls_back_to_skip() {
1664                let p = parser_with_mode("bogus");
1665                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1666            }
1667
1668            #[test]
1669            fn drop_removes_only_the_reference() {
1670                let p = parser_with_mode("drop");
1671                assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1672            }
1673
1674            #[test]
1675            fn drop_keeps_resolvable_references() {
1676                let p = parser_with_mode("drop");
1677                assert_eq!(render("a {sp}b {missing} c", &p), "a  b  c");
1678            }
1679
1680            #[test]
1681            fn drop_line_removes_the_whole_line() {
1682                let p = parser_with_mode("drop-line");
1683                assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1684            }
1685
1686            #[test]
1687            fn drop_line_only_drops_lines_with_a_missing_reference() {
1688                let p = parser_with_mode("drop-line");
1689                assert_eq!(
1690                    render("first {sp}line\nsecond {missing} line\nthird line", &p),
1691                    "first  line\nthird line"
1692                );
1693            }
1694
1695            #[test]
1696            fn drop_line_can_empty_the_content() {
1697                let p = parser_with_mode("drop-line");
1698                assert_eq!(render("{missing}", &p), "");
1699            }
1700
1701            #[test]
1702            fn warn_leaves_the_reference_and_records_a_warning() {
1703                let p = parser_with_mode("warn");
1704                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1705
1706                let warnings = p.take_substitution_warnings();
1707                assert_eq!(warnings.len(), 1);
1708                assert_eq!(
1709                    warnings[0].warning,
1710                    WarningType::SkippingReferenceToMissingAttribute("name".to_string())
1711                );
1712            }
1713
1714            #[test]
1715            fn warn_records_one_warning_per_missing_reference() {
1716                let p = parser_with_mode("warn");
1717                assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
1718                assert_eq!(p.take_substitution_warnings().len(), 2);
1719            }
1720
1721            #[test]
1722            fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
1723                // An escaped reference has its backslash removed and is passed
1724                // through literally; it is never treated as a missing reference,
1725                // so even under `drop-line` the line survives and no warning is
1726                // recorded.
1727                let p = parser_with_mode("drop-line");
1728                assert_eq!(
1729                    render("In the path /items/\\{id}, x.", &p),
1730                    "In the path /items/{id}, x."
1731                );
1732                assert!(p.take_substitution_warnings().is_empty());
1733            }
1734
1735            // The tests below use `content_with_source_lines` so the precise
1736            // per-reference `warn` location (issue #564) is exercised; the
1737            // `render`-based tests above go through `Content::from`, which
1738            // retains no source lines and so falls back to the whole-content
1739            // span.
1740
1741            #[test]
1742            fn warn_points_at_the_precise_reference() {
1743                let p = parser_with_mode("warn");
1744                let text = "Hello, {name}!";
1745                let mut content = content_with_source_lines(text);
1746                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1747
1748                let warnings = p.take_substitution_warnings();
1749                assert_eq!(warnings.len(), 1);
1750                assert_spans(&warnings[0], text, "{name}");
1751            }
1752
1753            #[test]
1754            fn warn_locates_multiple_references_on_one_line() {
1755                let p = parser_with_mode("warn");
1756                let text = "a {x} b {y} c";
1757                let mut content = content_with_source_lines(text);
1758                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1759
1760                let warnings = p.take_substitution_warnings();
1761                assert_eq!(warnings.len(), 2);
1762                assert_spans(&warnings[0], text, "{x}");
1763                assert_spans(&warnings[1], text, "{y}");
1764                // The two references must resolve to distinct offsets.
1765                assert_ne!(warnings[0].offset, warnings[1].offset);
1766            }
1767
1768            #[test]
1769            fn warn_locates_references_across_multiple_lines() {
1770                // The acceptance case from issue #564: several distinct
1771                // references on different lines of one block, each pointed at
1772                // individually rather than at the shared whole-block span.
1773                let p = parser_with_mode("warn");
1774                let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
1775                let mut content = content_with_source_lines(text);
1776                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1777
1778                let warnings = p.take_substitution_warnings();
1779                assert_eq!(warnings.len(), 3);
1780                assert_spans(&warnings[0], text, "{alpha}");
1781                assert_spans(&warnings[1], text, "{beta}");
1782                assert_spans(&warnings[2], text, "{gamma}");
1783            }
1784
1785            #[test]
1786            fn warn_distinguishes_repeated_reference_occurrences() {
1787                let p = parser_with_mode("warn");
1788                let text = "{dup} and again {dup}";
1789                let mut content = content_with_source_lines(text);
1790                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1791
1792                let warnings = p.take_substitution_warnings();
1793                assert_eq!(warnings.len(), 2);
1794                assert_spans(&warnings[0], text, "{dup}");
1795                assert_spans(&warnings[1], text, "{dup}");
1796                // Same text, but the two occurrences are at different offsets.
1797                assert_eq!(warnings[0].offset, 0);
1798                assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
1799            }
1800
1801            #[test]
1802            fn warn_span_survives_earlier_special_character_expansion() {
1803                // The key regression guard: special characters run before the
1804                // attributes step and lengthen the rendered text (`<` -> `&lt;`),
1805                // so a naive rendered-offset would be wrong. The warning must
1806                // still name the reference's *original* source offset.
1807                let p = parser_with_mode("warn");
1808                let text = "a < b {foo} c";
1809                let mut content = content_with_source_lines(text);
1810                SubstitutionGroup::Normal.apply(&mut content, &p, None);
1811
1812                // Sanity check that the earlier step really did shift offsets.
1813                assert!(content.rendered().contains("&lt;"));
1814
1815                let warnings = p.take_substitution_warnings();
1816                assert_eq!(warnings.len(), 1);
1817                assert_spans(&warnings[0], text, "{foo}");
1818                assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
1819            }
1820
1821            #[test]
1822            fn warn_span_survives_earlier_quote_expansion() {
1823                // Same guard as above, but for the quotes step, which wraps
1824                // `*bold*` in markup ahead of the attributes step.
1825                let p = parser_with_mode("warn");
1826                let text = "*bold* {foo}";
1827                let mut content = content_with_source_lines(text);
1828                SubstitutionGroup::Normal.apply(&mut content, &p, None);
1829
1830                assert!(content.rendered().contains("<strong>"));
1831
1832                let warnings = p.take_substitution_warnings();
1833                assert_eq!(warnings.len(), 1);
1834                assert_spans(&warnings[0], text, "{foo}");
1835                assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
1836            }
1837
1838            #[test]
1839            fn warn_falls_back_to_whole_span_without_source_lines() {
1840                // `Content::from` retains no per-line spans, so the warning
1841                // degrades to the whole-content span (the pre-#564 behavior)
1842                // rather than misreporting a location.
1843                let p = parser_with_mode("warn");
1844                let text = "x {foo} y";
1845                let mut content = Content::from(Span::new(text));
1846                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1847
1848                let warnings = p.take_substitution_warnings();
1849                assert_eq!(warnings.len(), 1);
1850                assert_eq!(warnings[0].offset, 0);
1851                assert_eq!(warnings[0].len, text.len());
1852            }
1853        }
1854    }
1855
1856    mod callouts {
1857        use crate::{
1858            content::{Content, SubstitutionStep},
1859            parser::ModificationContext,
1860            strings::CowStr,
1861            tests::prelude::*,
1862        };
1863
1864        /// Builds a `Content` whose `rendered` text is `text` (as if special
1865        /// characters had already been substituted), applies the callouts step,
1866        /// and returns the resulting rendered text.
1867        fn render_callouts(text: &str, parser: &Parser) -> String {
1868            let mut content = Content::from(crate::Span::new(text));
1869            // `Content::from` copies the source verbatim into `rendered`, which
1870            // is exactly the post-special-characters state we want to exercise.
1871            SubstitutionStep::Callouts.apply(&mut content, parser, None);
1872            content.rendered.to_string()
1873        }
1874
1875        #[test]
1876        fn empty() {
1877            let mut content = Content::from(crate::Span::default());
1878            let p = Parser::default();
1879            SubstitutionStep::Callouts.apply(&mut content, &p, None);
1880            assert!(content.is_empty());
1881            assert_eq!(content.rendered, CowStr::Borrowed(""));
1882        }
1883
1884        #[test]
1885        fn no_callouts() {
1886            let p = Parser::default();
1887            assert_eq!(render_callouts("just some text", &p), "just some text");
1888        }
1889
1890        #[test]
1891        fn lt_without_callout_is_untouched() {
1892            let p = Parser::default();
1893            assert_eq!(render_callouts("a &lt;b&gt; c", &p), "a &lt;b&gt; c");
1894        }
1895
1896        #[test]
1897        fn basic_explicit() {
1898            let p = Parser::default();
1899            assert_eq!(
1900                render_callouts("require 'x' &lt;1&gt;", &p),
1901                r#"require 'x' <b class="conum">(1)</b>"#
1902            );
1903        }
1904
1905        #[test]
1906        fn line_comment_prefix_preserved() {
1907            let p = Parser::default();
1908            assert_eq!(
1909                render_callouts("puts 'x' # &lt;1&gt;", &p),
1910                r#"puts 'x' # <b class="conum">(1)</b>"#
1911            );
1912        }
1913
1914        #[test]
1915        fn multiple_on_one_line() {
1916            let p = Parser::default();
1917            assert_eq!(
1918                render_callouts("puts x &lt;5&gt;&lt;6&gt;", &p),
1919                r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
1920            );
1921        }
1922
1923        #[test]
1924        fn not_at_end_of_line() {
1925            let p = Parser::default();
1926            assert_eq!(
1927                render_callouts("puts \"&lt;1&gt; in the middle\"", &p),
1928                "puts \"&lt;1&gt; in the middle\""
1929            );
1930        }
1931
1932        #[test]
1933        fn auto_numbering() {
1934            let p = Parser::default();
1935            assert_eq!(
1936                render_callouts("a &lt;.&gt;\nb &lt;.&gt;\nc &lt;.&gt;", &p),
1937                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
1938            );
1939        }
1940
1941        #[test]
1942        fn mixed_numbering_ignores_explicit() {
1943            // Auto-numbering is not aware of explicit numbers.
1944            let p = Parser::default();
1945            assert_eq!(
1946                render_callouts("a &lt;.&gt;\nb &lt;1&gt;\nc &lt;.&gt;", &p),
1947                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
1948            );
1949        }
1950
1951        #[test]
1952        fn xml_callout() {
1953            let p = Parser::default();
1954            assert_eq!(
1955                render_callouts("&lt;child/&gt; &lt;!--1--&gt;", &p),
1956                r#"&lt;child/&gt; &lt;!--<b class="conum">(1)</b>--&gt;"#
1957            );
1958        }
1959
1960        #[test]
1961        fn half_xml_comment_is_not_a_callout() {
1962            let p = Parser::default();
1963            assert_eq!(
1964                render_callouts("First line &lt;1--&gt;", &p),
1965                "First line &lt;1--&gt;"
1966            );
1967        }
1968
1969        #[test]
1970        fn escaped_callout() {
1971            let p = Parser::default();
1972            assert_eq!(
1973                render_callouts("require 'x' # \\&lt;1&gt;", &p),
1974                "require 'x' # &lt;1&gt;"
1975            );
1976        }
1977
1978        #[test]
1979        fn icons_font() {
1980            let p = Parser::default().with_intrinsic_attribute(
1981                "icons",
1982                "font",
1983                ModificationContext::Anywhere,
1984            );
1985            assert_eq!(
1986                render_callouts("puts x # &lt;1&gt;", &p),
1987                r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
1988            );
1989        }
1990
1991        #[test]
1992        fn icons_image() {
1993            let p = Parser::default().with_intrinsic_attribute(
1994                "icons",
1995                "",
1996                ModificationContext::Anywhere,
1997            );
1998            assert_eq!(
1999                render_callouts("puts x &lt;1&gt;", &p),
2000                r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2001            );
2002        }
2003
2004        #[test]
2005        fn custom_line_comment_prefix() {
2006            // line-comment=% (Erlang). Only `%` is recognized as a prefix.
2007            let mut content = Content::from(crate::Span::new("hello() -> % &lt;1&gt;"));
2008            let attrlist = crate::attributes::Attrlist::parse(
2009                crate::Span::new("source,erlang,line-comment=%"),
2010                &Parser::default(),
2011                crate::attributes::AttrlistContext::Block,
2012            )
2013            .item
2014            .item;
2015            let p = Parser::default();
2016            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2017            assert_eq!(
2018                content.rendered.to_string(),
2019                r#"hello() -> % <b class="conum">(1)</b>"#
2020            );
2021        }
2022
2023        #[test]
2024        fn disabled_line_comment_preserves_leading_chars() {
2025            // line-comment= (empty) disables prefix recognition, so the `--`
2026            // before the callout is preserved verbatim.
2027            let mut content = Content::from(crate::Span::new("-- &lt;1&gt;"));
2028            let attrlist = crate::attributes::Attrlist::parse(
2029                crate::Span::new("source,asciidoc,line-comment="),
2030                &Parser::default(),
2031                crate::attributes::AttrlistContext::Block,
2032            )
2033            .item
2034            .item;
2035            let p = Parser::default();
2036            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2037            assert_eq!(
2038                content.rendered.to_string(),
2039                r#"-- <b class="conum">(1)</b>"#
2040            );
2041        }
2042
2043        #[test]
2044        fn document_line_comment_attribute() {
2045            // The `line-comment` attribute can be set at the document level
2046            // (here, with no block attrlist), and is honored as a fallback.
2047            let p = Parser::default().with_intrinsic_attribute(
2048                "line-comment",
2049                "%",
2050                ModificationContext::Anywhere,
2051            );
2052            assert_eq!(
2053                render_callouts("hello() -> % &lt;1&gt;", &p),
2054                r#"hello() -> % <b class="conum">(1)</b>"#
2055            );
2056        }
2057    }
2058}