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