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, Hash, 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 2) with its `name[:seed]`
504    // expression (group 3), or a plain attribute name (group 4). This mirrors
505    // the `counter2?:` branch of Asciidoctor's `AttributeReferenceRx`.
506    //
507    // Groups 1 and 5 capture the optional escaping backslash before the opening
508    // (`\{name}`) and closing (`{name\}`) brace, respectively; either one marks
509    // the reference escaped. This mirrors Asciidoctor's
510    // `(\\)?\{…(\\)?\}`, whose `$1`/`$4` capture the same two backslashes.
511    //
512    // The counter expression is matched non-greedily (`+?`) so a trailing
513    // escape backslash (`{counter:n\}`) is left for group 5 rather than being
514    // swallowed into the expression, again matching Asciidoctor's `#{CC_ANY}+?`.
515    //
516    // The attribute-name class `\w` (Unicode `\p{Word}`) accepts any Unicode
517    // word character, matching Asciidoctor's `#{CG_WORD}[#{CC_WORD}-]*`, so
518    // references such as `{café}` and `{سمن}` resolve. It is the same class
519    // used to recognize and sanitize an attribute-entry name (see
520    // `is_word_char`), so a name and a reference to it always agree.
521    #[allow(clippy::unwrap_used)]
522    Regex::new(r#"(\\)?\{(?:(counter2?):([^{}]+?)|(\w[\w-]*))(\\)?\}"#).unwrap()
523});
524
525/// How the processor handles a reference to a missing attribute, controlled by
526/// the [`attribute-missing`] document attribute.
527///
528/// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
529#[derive(Clone, Copy, Debug, Eq, PartialEq)]
530pub(crate) enum AttributeMissing {
531    /// Leave the reference in place (the default).
532    Skip,
533
534    /// Drop the reference, but not the line that contains it.
535    Drop,
536
537    /// Drop the entire line on which the reference occurs.
538    DropLine,
539
540    /// Leave the reference in place and record a warning.
541    Warn,
542}
543
544impl AttributeMissing {
545    /// Resolves the `attribute-missing` setting from `parser`. An absent or
546    /// unrecognized value falls back to [`Skip`](Self::Skip), matching
547    /// Asciidoctor.
548    pub(crate) fn from_parser(parser: &Parser) -> Self {
549        match parser.attribute_value("attribute-missing").as_maybe_str() {
550            Some("drop") => Self::Drop,
551            Some("drop-line") => Self::DropLine,
552            Some("warn") => Self::Warn,
553            _ => Self::Skip,
554        }
555    }
556}
557
558/// Locates `attribute-missing=warn` warnings within a single line.
559///
560/// # Why a per-line, positional correlation
561///
562/// Attribute references are replaced during the *attributes* substitution,
563/// which operates on [`Content::rendered`] – text that earlier steps (special
564/// characters, quotes) have already transformed, and from which passthroughs
565/// have been masked to placeholder tokens. A byte offset in that rendered text
566/// therefore has no constant delta back to the original source `Span`, so a
567/// warning cannot simply slice `content.original()` at the rendered offset.
568///
569/// Three approaches were considered (see issue #564):
570///
571/// 1. **Thread a source-offset map through every substitution step.** Fully
572///    general, but adds offset-tracking state to `Content` and every mutating
573///    step – a large surface and regression risk disproportionate to a
574///    diagnostic-only refinement.
575/// 2. **Scan the whole original source positionally.** Pair the *k*-th
576///    reference found in `rendered` with the *k*-th `{name}` in
577///    `content.original()`. Simple, but the raw source still contains `{name}`
578///    tokens that never reach substitution – inside removed comment lines and
579///    inside passthroughs – so the pairing drifts out of alignment.
580/// 3. **Per-line positional correlation (chosen).** Anchor each rendered line
581///    to the source `Span` of the line it came from (retained at construction,
582///    see [`Content::from_filtered_lines`]), then pair the *k*-th reference on
583///    the rendered line with the *k*-th `{name}` in that source line.
584///
585/// Approach 3 works because the two length-changing steps that run before
586/// attributes (special characters, quotes) neither add, remove, nor reorder
587/// `{…}`-shaped tokens and never introduce or remove a newline, so a rendered
588/// line and its source line carry the same reference tokens in the same order.
589/// Anchoring per line (rather than per block) sidesteps the
590/// removed-comment-line drift of approach 2 for free.
591///
592/// # Graceful degradation
593///
594/// The correlation is best-effort. When a precise span can't be trusted it
595/// falls back to [`fallback_source`](Self::fallback_source) (the whole-content
596/// span, i.e. the pre-#564 behavior):
597///
598/// - No source line is available ([`source_line`](Self::source_line) is
599///   `None`), e.g. for content not built line-by-line from source.
600/// - The retained line count no longer matches the rendered line count, e.g. a
601///   multi-line passthrough collapsed lines during extraction. The caller
602///   detects this and withholds the source line.
603/// - The *k*-th source-line match's text does not equal the rendered match,
604///   e.g. an inline passthrough on the same line masked an earlier reference
605///   and shifted the count. The [text check](Self::warning_source) catches the
606///   mismatch and degrades to the fallback rather than pointing at the wrong
607///   token.
608#[derive(Debug)]
609struct AttributeReplacer<'p> {
610    parser: &'p Parser,
611
612    /// How to handle a reference to a missing attribute.
613    mode: AttributeMissing,
614
615    /// Source span used to locate a recorded warning when a precise
616    /// per-reference span cannot be recovered. This is the whole content (or
617    /// line/target) span – the coarse fallback described in the type-level
618    /// docs.
619    fallback_source: Span<'p>,
620
621    /// Source `Span` of the line currently being processed, when known. Every
622    /// attribute reference on this line is located by slicing a subrange of
623    /// this span. `None` disables precise location (the warning uses
624    /// [`fallback_source`](Self::fallback_source)).
625    source_line: Option<Span<'p>>,
626
627    /// Byte ranges (into [`source_line`](Self::source_line)'s data) of every
628    /// `ATTRIBUTE_REFERENCE` match on the source line, in order. Populated only
629    /// in the diagnostic-recording modes ([`AttributeMissing::Warn`] and
630    /// [`AttributeMissing::DropLine`]) and only when `source_line` is set.
631    source_matches: Vec<Range<usize>>,
632
633    /// Index of the next reference to be processed on this line, into
634    /// [`source_matches`](Self::source_matches). The regex driver calls
635    /// [`replace_append`](Replacer::replace_append) once per match, left to
636    /// right, so this stays in step with the rendered matches.
637    match_index: usize,
638
639    /// Set to `true` when a (non-escaped) reference to a missing attribute is
640    /// dropped, under either [`AttributeMissing::Drop`] or
641    /// [`AttributeMissing::DropLine`], so the caller can drop the line: the
642    /// whole line in `drop-line` mode, or a line the dropped reference left
643    /// empty in `drop` mode (Asciidoctor's `reject_if_empty`).
644    missing_on_line: bool,
645}
646
647impl<'p> AttributeReplacer<'p> {
648    /// Builds the replacer for one line, precomputing the source-match ranges
649    /// used to locate `warn` warnings precisely.
650    ///
651    /// `source_line` is the source span the line was rendered from, or `None`
652    /// when no precise mapping is available. `fallback_source` is the coarse
653    /// span used when a precise location cannot be recovered.
654    fn new(
655        parser: &'p Parser,
656        mode: AttributeMissing,
657        fallback_source: Span<'p>,
658        source_line: Option<Span<'p>>,
659    ) -> Self {
660        // The per-reference ranges are only consulted when a warning may be
661        // recorded – `warn` mode, and `drop-line` mode (which records a
662        // diagnostic for each dropped reference) – so skip the extra scan
663        // otherwise.
664        let source_matches = match (mode, source_line) {
665            (AttributeMissing::Warn | AttributeMissing::DropLine, Some(line)) => {
666                ATTRIBUTE_REFERENCE
667                    .find_iter(line.data())
668                    .map(|m| m.range())
669                    .collect()
670            }
671            _ => Vec::new(),
672        };
673
674        Self {
675            parser,
676            mode,
677            fallback_source,
678            source_line,
679            source_matches,
680            match_index: 0,
681            missing_on_line: false,
682        }
683    }
684
685    /// Returns the source span to attribute a recorded warning to for the
686    /// reference at `index` on this line, whose matched text (including any
687    /// escape backslash) is `matched`.
688    ///
689    /// Falls back to [`fallback_source`](Self::fallback_source) unless a
690    /// retained source-line match at `index` exists *and* its text equals
691    /// `matched` – the text check guards against a correlation that has
692    /// drifted (see the type-level docs).
693    fn warning_source(&self, index: usize, matched: &str) -> Span<'p> {
694        if let Some(line) = self.source_line
695            && let Some(range) = self.source_matches.get(index)
696            && line.data().get(range.clone()) == Some(matched)
697        {
698            return line.slice(range.clone());
699        }
700
701        self.fallback_source
702    }
703}
704
705impl Replacer for AttributeReplacer<'_> {
706    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
707        // Consume this reference's position in the line so the next call lines up
708        // with the next source-line match, regardless of which branch handles it.
709        let match_index = self.match_index;
710        self.match_index += 1;
711
712        // A backslash immediately before the opening brace (`\{name}`) or before
713        // the closing brace (`{name\}`) – or both, as in `\{name\}` – escapes
714        // the reference: it is emitted literally with the escaping backslash(es)
715        // removed and left unexpanded, whether or not the attribute is set. An
716        // escaped reference is never treated as a missing reference, so it
717        // neither drops the line nor warns, and an escaped counter directive
718        // does not advance the counter. This mirrors Asciidoctor, whose
719        // `sub_attributes` returns `{#{name}}` when either its leading (`$1`) or
720        // trailing (`$4`) backslash capture is present, before any counter,
721        // missing-attribute, or resolution handling runs.
722        if caps.get(1).is_some() || caps.get(5).is_some() {
723            dest.push('{');
724
725            // Groups 2 (the `counter`/`counter2` directive) and 3 (its
726            // expression) participate together; a plain reference is group 4.
727            if let Some(directive) = caps.get(2) {
728                dest.push_str(directive.as_str());
729                dest.push(':');
730                dest.push_str(&caps[3]);
731            } else {
732                dest.push_str(&caps[4]);
733            }
734
735            dest.push('}');
736            return;
737        }
738
739        // A `counter`/`counter2` directive resolves (and advances) a counter
740        // rather than looking up an existing attribute.
741        if let Some(directive) = caps.get(2) {
742            // Group 3 always participates when group 2 does (same alternation
743            // branch). The expression is `name` or `name:seed`.
744            let mut parts = caps[3].splitn(2, ':');
745            let name = parts.next().unwrap_or_default();
746            let seed = parts.next();
747
748            let value = self.parser.counter(name, seed);
749
750            // `counter` displays the new value; `counter2` advances silently.
751            if directive.as_str() == "counter" {
752                dest.push_str(&value);
753            }
754            return;
755        }
756
757        // Otherwise this is a plain attribute reference (group 4).
758        let attr_name = &caps[4];
759
760        // Resolve the reference case-insensitively: attribute names are stored
761        // lower-cased (both an attribute-entry definition and an API-supplied
762        // attribute fold their name), so the lookup name is folded the same way
763        // here. This mirrors Asciidoctor's `sub_attributes`, which looks up
764        // `key = $2.downcase`. The original spelling is still what is emitted
765        // literally for a skipped or missing reference below.
766        let lookup_name = attribute_lookup_name(attr_name);
767
768        if !self.parser.has_attribute(&lookup_name) {
769            match self.mode {
770                AttributeMissing::Skip => dest.push_str(&caps[0]),
771                AttributeMissing::Drop => {
772                    // Drop the reference, leaving the rest of the line intact.
773                    // Flag that a missing reference was dropped here so the
774                    // caller can remove the line if the drop emptied it
775                    // (Asciidoctor's `reject_if_empty`).
776                    self.missing_on_line = true;
777                }
778                AttributeMissing::DropLine => {
779                    // Mark the line for removal; whatever is written to `dest`
780                    // here is discarded with it. Asciidoctor logs an `INFO`
781                    // message ("dropping line containing reference to missing
782                    // attribute") for each reference that triggers a drop, so
783                    // record the matching diagnostic here.
784                    self.missing_on_line = true;
785                    self.parser.record_substitution_warning(
786                        self.warning_source(match_index, &caps[0]),
787                        WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
788                    );
789                }
790                AttributeMissing::Warn => {
791                    dest.push_str(&caps[0]);
792                    self.parser.record_substitution_warning(
793                        self.warning_source(match_index, &caps[0]),
794                        WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
795                    );
796                }
797            }
798            return;
799        }
800
801        if let InterpretedValue::Value(value) = self.parser.attribute_value(&lookup_name) {
802            dest.push_str(value.as_ref());
803        }
804
805        // Language description is unclear as to what happens for "set" and
806        // "unset" attribute values. For now, we'll replace those with nothing.
807    }
808}
809
810/// Whether a line that dropped a missing reference under
811/// [`AttributeMissing::Drop`] should be treated as emptied (and therefore
812/// removed, per Asciidoctor's `reject_if_empty`).
813///
814/// A trailing `\r` left from a CRLF terminator is part of the line ending, not
815/// content: a line the drop reduced to just `\r` still counts as empty. The
816/// block pipeline strips `\r` before content is assembled, but free-standing
817/// text (a docinfo file) is split on `\n` with the `\r` intact, so this guard
818/// is what makes a CRLF reference-only line drop there.
819fn drop_emptied_line(replaced: &str) -> bool {
820    replaced.strip_suffix('\r').unwrap_or(replaced).is_empty()
821}
822
823fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
824    if !content.rendered.contains('{') {
825        return;
826    }
827
828    let mode = AttributeMissing::from_parser(parser);
829    let source = content.original();
830
831    // In the modes that record a diagnostic (`warn` and `drop-line`), anchor
832    // each rendered line to the source `Span` it came from so the warning can
833    // name the precise offset of the offending reference (see
834    // `AttributeReplacer`). The retained line spans are only trustworthy when
835    // they still line up one-to-one with the rendered lines; a mismatch (e.g. a
836    // multi-line passthrough that collapsed lines during extraction) withholds
837    // them, falling back to the coarse whole-content span.
838    let source_lines = if mode == AttributeMissing::Warn || mode == AttributeMissing::DropLine {
839        content
840            .source_lines()
841            .filter(|lines| lines.len() == content.rendered.split('\n').count())
842    } else {
843        None
844    };
845
846    // Attribute references are replaced line by line so that, in `drop-line`
847    // mode, an individual line carrying a missing reference can be removed
848    // without disturbing the lines around it. A reference cannot span a line
849    // break, so this matches what a single whole-text pass would produce for
850    // every other mode.
851    let mut out = String::with_capacity(content.rendered.len());
852    let mut changed = false;
853    let mut wrote_line = false;
854
855    for (index, line) in content.rendered.split('\n').enumerate() {
856        if !line.contains('{') {
857            if wrote_line {
858                out.push('\n');
859            }
860            out.push_str(line);
861            wrote_line = true;
862            continue;
863        }
864
865        // `index` enumerates the same split whose count the guard above matched
866        // against `source_lines.len()`, so the entry is always present; `.get`
867        // keeps the access panic-free regardless.
868        let source_line = source_lines.and_then(|lines| lines.get(index).copied());
869        let mut replacer = AttributeReplacer::new(parser, mode, source, source_line);
870
871        let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
872
873        if replacer.missing_on_line
874            && (mode == AttributeMissing::DropLine
875                || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
876        {
877            // Drop the entire line, including its line break: unconditionally
878            // in `drop-line` mode, or in `drop` mode when the dropped
879            // reference was all the line contained (Asciidoctor's
880            // `reject_if_empty`).
881            changed = true;
882            continue;
883        }
884
885        if let Cow::Owned(_) = replaced {
886            changed = true;
887        }
888
889        if wrote_line {
890            out.push('\n');
891        }
892        out.push_str(&replaced);
893        wrote_line = true;
894    }
895
896    // If nothing was replaced or dropped, leave the (borrowed) rendering as-is
897    // rather than paying for the rebuilt string.
898    if changed {
899        content.rendered = out.into();
900    }
901}
902
903/// Applies the attribute-references substitution to a block macro target (the
904/// portion between the `::` and the `[` of an `image::`, `video::`, or
905/// `audio::` macro), honoring the [`attribute-missing`] document attribute.
906///
907/// Block macro targets are always a single line, so (unlike
908/// [`apply_attributes`]) there is no line splitting. Returns `None` when the
909/// target references a missing attribute under
910/// [`AttributeMissing::DropLine`] – signaling that the entire block should be
911/// dropped – and otherwise returns the substituted target.
912///
913/// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
914pub(crate) fn substitute_attributes_in_macro_target<'src>(
915    target: Span<'src>,
916    parser: &Parser,
917) -> Option<CowStr<'src>> {
918    let text = target.data();
919
920    // Without a reference there is nothing to substitute (and nothing that
921    // could trigger a drop), so the borrowed target is returned as-is.
922    if !text.contains('{') {
923        return Some(text.into());
924    }
925
926    let mode = AttributeMissing::from_parser(parser);
927
928    // The target is a single source-backed line, so it doubles as both the
929    // precise per-reference anchor and the coarse fallback.
930    let mut replacer = AttributeReplacer::new(parser, mode, target, Some(target));
931
932    let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
933
934    if replacer.missing_on_line && mode == AttributeMissing::DropLine {
935        return None;
936    }
937
938    Some(replaced.into())
939}
940
941/// Applies the attribute-references substitution to free-standing text (such as
942/// the content of a [docinfo file]), honoring the [`attribute-missing`]
943/// document attribute, and returns the substituted result.
944///
945/// Unlike [`apply_attributes`], this operates on owned text that is not part of
946/// the document source. Substitution is performed line by line so that, in
947/// `drop-line` mode, an individual line carrying a missing reference can be
948/// removed without disturbing the lines around it.
949///
950/// Any `warn`-mode warnings it records on `parser` refer to offsets within
951/// `text` (not the document source); callers that do not want such warnings
952/// surfaced should discard them via
953/// [`Parser::truncate_substitution_warnings`](crate::Parser).
954///
955/// [docinfo file]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
956/// [`attribute-missing`]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unresolved-references/#missing
957pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
958    if !text.contains('{') {
959        return text.to_string();
960    }
961
962    let mode = AttributeMissing::from_parser(parser);
963    let source = Span::new(text);
964
965    let mut out = String::with_capacity(text.len());
966    let mut wrote_line = false;
967
968    for line in text.split('\n') {
969        if !line.contains('{') {
970            if wrote_line {
971                out.push('\n');
972            }
973            out.push_str(line);
974            wrote_line = true;
975            continue;
976        }
977
978        // This text is not backed by the document source (offsets refer into
979        // `text`, and callers discard these warnings), so no precise per-line
980        // anchor is supplied: warnings fall back to the whole-text span.
981        let mut replacer = AttributeReplacer::new(parser, mode, source, None);
982
983        let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
984
985        if replacer.missing_on_line
986            && (mode == AttributeMissing::DropLine
987                || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
988        {
989            // Drop the entire line, including its line break: unconditionally
990            // in `drop-line` mode, or in `drop` mode when the dropped
991            // reference was all the line contained (Asciidoctor's
992            // `reject_if_empty`).
993            continue;
994        }
995
996        if wrote_line {
997            out.push('\n');
998        }
999        out.push_str(&replaced);
1000        wrote_line = true;
1001    }
1002
1003    out
1004}
1005
1006/// Substitutes attribute references in a block anchor's reftext
1007/// (`[[id,reftext]]`) against the attributes in effect where the anchor
1008/// appears, returning the resolved text. This mirrors how attribute references
1009/// in a block ID (`[#install-{platform-id}]`) or a `reftext=` attribute are
1010/// resolved when the attribute list is parsed, so the reftext is registered in
1011/// the catalog with its attributes already expanded and a cross reference by
1012/// that text resolves.
1013///
1014/// The borrowed source text is returned unchanged when it holds no attribute
1015/// reference, avoiding an allocation in the common case.
1016pub(crate) fn substitute_attributes_in_reftext<'src>(
1017    reftext: Span<'src>,
1018    parser: &Parser,
1019) -> CowStr<'src> {
1020    if !reftext.data().contains('{') {
1021        return reftext.data().into();
1022    }
1023
1024    let mut content = Content::from(reftext);
1025    SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1026    CowStr::from(content.rendered.to_string())
1027}
1028
1029fn apply_character_replacements(
1030    content: &mut Content<'_>,
1031    renderer: &dyn InlineSubstitutionRenderer,
1032) {
1033    if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
1034        return;
1035    }
1036
1037    // Start borrowed: the sniff above only proves a replaceable character is
1038    // present, not that any pattern actually matches, so seeding an owned
1039    // working buffer up front would allocate even when nothing is rewritten
1040    // (a false-positive sniff). `owned` is materialized only once a pattern
1041    // first produces `Cow::Owned`, and reused thereafter.
1042    let mut owned: Option<String> = None;
1043
1044    for repl in &*REPLACEMENTS {
1045        let replacer = CharacterReplacer {
1046            type_: repl.type_.clone(),
1047            renderer,
1048        };
1049
1050        let replaced = {
1051            let haystack = owned
1052                .as_deref()
1053                .unwrap_or_else(|| content.rendered.as_ref());
1054
1055            match repl.pattern.replace_all(haystack, replacer) {
1056                Cow::Owned(new_result) => Some(new_result),
1057
1058                // A borrowed result means this pattern did not match, so no need
1059                // to pay for a new string allocation.
1060                Cow::Borrowed(_) => None,
1061            }
1062        };
1063
1064        if let Some(new_result) = replaced {
1065            owned = Some(new_result);
1066        }
1067    }
1068
1069    if let Some(rendered) = owned {
1070        content.rendered = rendered.into();
1071    }
1072}
1073
1074struct CharacterReplacement {
1075    type_: CharacterReplacementType,
1076    pattern: Regex,
1077}
1078
1079static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
1080    #[allow(clippy::unwrap_used)]
1081    Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
1082});
1083
1084// Adapted from REPLACEMENTS in Ruby Asciidoctor implementation,
1085// found in https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor.rb#L490.
1086//
1087// * NOTE: These substitutions are processed in the order they appear here and
1088//   the order in which they are replaced is important.
1089static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
1090    vec![
1091        CharacterReplacement {
1092            // Copyright `(C)`
1093            type_: CharacterReplacementType::Copyright,
1094            #[allow(clippy::unwrap_used)]
1095            pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
1096        },
1097        CharacterReplacement {
1098            // Registered `(R)`
1099            type_: CharacterReplacementType::Registered,
1100            #[allow(clippy::unwrap_used)]
1101            pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
1102        },
1103        CharacterReplacement {
1104            // Trademark `(TM)`
1105            type_: CharacterReplacementType::Trademark,
1106            #[allow(clippy::unwrap_used)]
1107            pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
1108        },
1109        CharacterReplacement {
1110            // Em dash surrounded by spaces ` -- `
1111            type_: CharacterReplacementType::EmDashSurroundedBySpaces,
1112            #[allow(clippy::unwrap_used)]
1113            pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
1114        },
1115        CharacterReplacement {
1116            // Em dash without spaces `--`
1117            type_: CharacterReplacementType::EmDashWithoutSpace,
1118            #[allow(clippy::unwrap_used)]
1119            pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
1120        },
1121        CharacterReplacement {
1122            // Ellipsis `...`
1123            type_: CharacterReplacementType::Ellipsis,
1124            #[allow(clippy::unwrap_used)]
1125            pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
1126        },
1127        CharacterReplacement {
1128            // Right single quote `\`'`
1129            type_: CharacterReplacementType::TypographicApostrophe,
1130            #[allow(clippy::unwrap_used)]
1131            pattern: Regex::new(r#"\\?`'"#).unwrap(),
1132        },
1133        CharacterReplacement {
1134            // Apostrophe (inside a word)
1135            type_: CharacterReplacementType::TypographicApostrophe,
1136            #[allow(clippy::unwrap_used)]
1137            pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
1138        },
1139        CharacterReplacement {
1140            // Right arrow `->`
1141            type_: CharacterReplacementType::SingleRightArrow,
1142            #[allow(clippy::unwrap_used)]
1143            pattern: Regex::new(r#"\\?-&gt;"#).unwrap(),
1144        },
1145        CharacterReplacement {
1146            // Right double arrow `=>`
1147            type_: CharacterReplacementType::DoubleRightArrow,
1148            #[allow(clippy::unwrap_used)]
1149            pattern: Regex::new(r#"\\?=&gt;"#).unwrap(),
1150        },
1151        CharacterReplacement {
1152            // Left arrow `<-`
1153            type_: CharacterReplacementType::SingleLeftArrow,
1154            #[allow(clippy::unwrap_used)]
1155            pattern: Regex::new(r#"\\?&lt;-"#).unwrap(),
1156        },
1157        CharacterReplacement {
1158            // Left double arrow `<=`
1159            type_: CharacterReplacementType::DoubleLeftArrow,
1160            #[allow(clippy::unwrap_used)]
1161            pattern: Regex::new(r#"\\?&lt;="#).unwrap(),
1162        },
1163        CharacterReplacement {
1164            // Restore entities
1165            type_: CharacterReplacementType::CharacterReference("".to_owned()),
1166            #[allow(clippy::unwrap_used)]
1167            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(),
1168        },
1169    ]
1170});
1171
1172#[derive(Debug)]
1173struct CharacterReplacer<'r> {
1174    type_: CharacterReplacementType,
1175    renderer: &'r dyn InlineSubstitutionRenderer,
1176}
1177
1178impl Replacer for CharacterReplacer<'_> {
1179    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1180        if caps[0].contains('\\') {
1181            // We have to replace since we aren't sure the backslash is the first char.
1182            let unescaped = &caps[0].replace("\\", "");
1183            dest.push_str(unescaped);
1184            return;
1185        }
1186
1187        match self.type_ {
1188            CharacterReplacementType::Copyright
1189            | CharacterReplacementType::Registered
1190            | CharacterReplacementType::Trademark
1191            | CharacterReplacementType::EmDashSurroundedBySpaces
1192            | CharacterReplacementType::Ellipsis
1193            | CharacterReplacementType::SingleLeftArrow
1194            | CharacterReplacementType::DoubleLeftArrow
1195            | CharacterReplacementType::SingleRightArrow
1196            | CharacterReplacementType::DoubleRightArrow => {
1197                self.renderer
1198                    .render_character_replacement(self.type_.clone(), dest);
1199            }
1200
1201            CharacterReplacementType::EmDashWithoutSpace => {
1202                dest.push_str(&caps[1]);
1203                self.renderer.render_character_replacement(
1204                    CharacterReplacementType::EmDashWithoutSpace,
1205                    dest,
1206                );
1207            }
1208
1209            CharacterReplacementType::TypographicApostrophe => {
1210                if let Some(before) = caps.get(1) {
1211                    dest.push_str(before.as_str());
1212                }
1213
1214                self.renderer.render_character_replacement(
1215                    CharacterReplacementType::TypographicApostrophe,
1216                    dest,
1217                );
1218
1219                if let Some(after) = caps.get(2) {
1220                    dest.push_str(after.as_str());
1221                }
1222            }
1223
1224            CharacterReplacementType::CharacterReference(_) => {
1225                self.renderer.render_character_replacement(
1226                    CharacterReplacementType::CharacterReference(caps[1].to_string()),
1227                    dest,
1228                );
1229            }
1230        }
1231    }
1232}
1233
1234fn apply_post_replacements(
1235    content: &mut Content<'_>,
1236    parser: &Parser,
1237    attrlist: Option<&Attrlist<'_>>,
1238) {
1239    if parser.is_attribute_set("hardbreaks-option")
1240        || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
1241    {
1242        let text = content.rendered.as_ref();
1243        if !text.contains('\n') {
1244            return;
1245        }
1246
1247        let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
1248        let last = lines.pop().unwrap_or_default();
1249
1250        let mut lines: Vec<String> = lines
1251            .iter()
1252            .map(|line| {
1253                let line = if line.ends_with(" +") {
1254                    &line[0..line.len() - 2]
1255                } else {
1256                    *line
1257                };
1258
1259                let mut line = line.to_owned();
1260                parser.renderer.render_line_break(&mut line);
1261                line
1262            })
1263            .collect();
1264
1265        lines.push(last.to_owned());
1266
1267        let new_result = lines.join("\n");
1268        content.rendered = new_result.into();
1269    } else {
1270        let rendered = content.rendered.as_ref();
1271
1272        // A hard line break is a ` +` at the end of a line. Because the
1273        // `HARD_LINE_BREAK` regex anchors on `$` in multiline mode – which
1274        // matches at the end of the haystack as well as before each `\n` – the
1275        // final line of the content is eligible too, even when it is not
1276        // followed by a newline (e.g. a one-line paragraph, a block title, or a
1277        // section title ending in ` +`). So the cheap pre-check requires only a
1278        // `+`, not also a `\n`.
1279        if !rendered.contains('+') {
1280            return;
1281        }
1282
1283        let replacer = PostReplacementReplacer(&*parser.renderer);
1284
1285        if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1286            content.rendered = new_result.into();
1287        }
1288    }
1289}
1290
1291#[derive(Debug)]
1292struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1293
1294impl Replacer for PostReplacementReplacer<'_> {
1295    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1296        dest.push_str(&caps[1]);
1297        self.0.render_line_break(dest);
1298    }
1299}
1300
1301static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1302    #[allow(clippy::unwrap_used)]
1303    Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1304});
1305
1306/// Processes [callouts] in literal, listing, and source blocks.
1307///
1308/// Callout numbers (`<1>`, `<.>`, or `<!--1-->` for XML) that appear at the end
1309/// of a line are replaced with the renderer's callout markup. Callouts may be
1310/// tucked behind a line comment (`//`, `#`, `--`, or `;;` by default, or a
1311/// custom prefix specified by the `line-comment` attribute), and a callout may
1312/// be escaped with a leading backslash to render it literally.
1313///
1314/// This substitution runs after [special characters] have been replaced, so the
1315/// angle brackets that delimit a callout appear in `content.rendered` as
1316/// `&lt;` and `&gt;`. This mirrors Asciidoctor's `sub_callouts` /
1317/// `CalloutSourceRx`.
1318///
1319/// [callouts]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
1320/// [special characters]: https://docs.asciidoctor.org/asciidoc/latest/subs/special-characters/
1321fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1322    // A callout's opening bracket is always rendered as `&lt;` by the special
1323    // characters substitution, so we can cheaply skip content without any.
1324    if !content.rendered.contains("&lt;") {
1325        return;
1326    }
1327
1328    // The `line-comment` attribute (block-level, falling back to document-level)
1329    // customizes or disables line-comment recognition:
1330    //
1331    // * absent -> default prefixes (`//`, `#`, `--`, `;;`) and XML callouts are
1332    //   recognized.
1333    // * present (custom) -> only the given prefix is recognized; XML callouts are
1334    //   not.
1335    // * present but empty -> no line-comment prefix is recognized; XML callouts are
1336    //   not.
1337    let line_comment: Option<String> = attrlist
1338        .and_then(|a| a.named_attribute("line-comment"))
1339        .map(|a| a.value().to_string())
1340        .or_else(|| {
1341            if parser.has_attribute("line-comment") {
1342                Some(
1343                    parser
1344                        .attribute_value("line-comment")
1345                        .as_maybe_str()
1346                        .unwrap_or("")
1347                        .to_string(),
1348                )
1349            } else {
1350                None
1351            }
1352        });
1353
1354    let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1355
1356    let replacer = CalloutReplacer {
1357        renderer: &*parser.renderer,
1358        parser,
1359        autonum: 0,
1360        tail: tail_rx,
1361    };
1362
1363    if let Cow::Owned(new_result) =
1364        replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1365    {
1366        content.rendered = new_result.into();
1367    }
1368}
1369
1370/// Callout regex for the default `line-comment` mode: recognizes the common
1371/// line-comment prefixes and XML callouts.
1372static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1373    #[allow(clippy::unwrap_used)]
1374    Regex::new(
1375        r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:&lt;!--(?P<xnum>\d+|\.)--&gt;|&lt;(?P<num>\d+|\.)&gt;)",
1376    )
1377    .unwrap()
1378});
1379
1380/// Trailing-position lookahead regex for the default `line-comment` mode.
1381static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1382    #[allow(clippy::unwrap_used)]
1383    Regex::new(r"^(?: ?\\?(?:&lt;!--(?:\d+|\.)--&gt;|&lt;(?:\d+|\.)&gt;))*(?:\n|$)").unwrap()
1384});
1385
1386/// Trailing-position lookahead regex for a custom or empty `line-comment` mode
1387/// (no XML callout form).
1388static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1389    #[allow(clippy::unwrap_used)]
1390    Regex::new(r"^(?: ?\\?&lt;(?:\d+|\.)&gt;)*(?:\n|$)").unwrap()
1391});
1392
1393/// Builds the `(callout, tail)` regex pair for the given `line-comment` mode.
1394///
1395/// The `callout` regex matches a single callout token (with the optional
1396/// line-comment prefix and escape that may precede it). The `tail` regex is
1397/// used to emulate Asciidoctor's trailing-position lookahead: a matched callout
1398/// is only honored when the remainder of its line consists solely of further
1399/// callouts. Rust's regex engine supports neither lookahead nor backreferences,
1400/// so the lookahead is applied manually against the post-match text.
1401///
1402/// The default-mode regexes and both tail regexes are constant, so they are
1403/// built once. Only a custom (non-empty) prefix requires building a regex from
1404/// the attribute value, which is borrowed otherwise.
1405fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1406    match line_comment {
1407        // Default: recognize the common line-comment prefixes and XML callouts.
1408        None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1409
1410        // A custom or empty `line-comment`: only the bare (non-XML) callout form
1411        // is recognized, optionally behind the custom prefix.
1412        Some(prefix) => {
1413            let prefix_pattern = if prefix.is_empty() {
1414                String::new()
1415            } else {
1416                format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1417            };
1418
1419            #[allow(clippy::unwrap_used)]
1420            let callout = Regex::new(&format!(
1421                r"{prefix_pattern}(?P<esc>\\)?&lt;(?P<num>\d+|\.)&gt;"
1422            ))
1423            .unwrap();
1424
1425            (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1426        }
1427    }
1428}
1429
1430/// Replacer that renders each trailing callout token, emulating Asciidoctor's
1431/// `sub_callouts`.
1432struct CalloutReplacer<'r> {
1433    renderer: &'r dyn InlineSubstitutionRenderer,
1434    parser: &'r Parser,
1435
1436    /// Running counter for automatically-numbered (`<.>`) callouts, scoped to a
1437    /// single block.
1438    autonum: u32,
1439
1440    /// Trailing-position lookahead regex (see [`build_callout_regexes`]).
1441    tail: &'r Regex,
1442}
1443
1444impl LookaheadReplacer for CalloutReplacer<'_> {
1445    fn replace_append(
1446        &mut self,
1447        caps: &Captures<'_>,
1448        dest: &mut String,
1449        after: &str,
1450    ) -> LookaheadResult {
1451        // Honor the trailing-position requirement: a callout is only recognized
1452        // when nothing but further callouts follows it on the line.
1453        if !self.tail.is_match(after) {
1454            dest.push_str(&caps[0]);
1455            return LookaheadResult::Continue;
1456        }
1457
1458        // Honor the escape: emit the matched text with the escaping backslash
1459        // removed so the callout renders literally.
1460        if caps.name("esc").is_some() {
1461            dest.push_str(&caps[0].replacen('\\', "", 1));
1462            return LookaheadResult::Continue;
1463        }
1464
1465        let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1466            (xnum.as_str(), true)
1467        } else {
1468            // The regex guarantees one of `xnum` or `num` is present.
1469            #[allow(clippy::unwrap_used)]
1470            (caps.name("num").unwrap().as_str(), false)
1471        };
1472
1473        let number = if number_raw == "." {
1474            self.autonum += 1;
1475            self.autonum.to_string()
1476        } else {
1477            number_raw.to_string()
1478        };
1479
1480        // Register this callout so the callout list that annotates this block
1481        // can be validated against the callouts it references.
1482        if let Ok(n) = number.parse::<u32>() {
1483            self.parser.register_callout(n);
1484        }
1485
1486        // Mirror Asciidoctor's guard resolution: a captured line-comment prefix
1487        // takes precedence; otherwise an XML callout uses the XML guard; failing
1488        // both, there is no guard.
1489        let guard = match caps.name("prefix") {
1490            Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1491            None if is_xml => CalloutGuard::Xml,
1492            None => CalloutGuard::LineComment(""),
1493        };
1494
1495        self.renderer.render_callout(
1496            &CalloutRenderParams {
1497                number: &number,
1498                guard,
1499                parser: self.parser,
1500            },
1501            dest,
1502        );
1503
1504        LookaheadResult::Continue
1505    }
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    #![allow(clippy::unwrap_used)]
1511
1512    mod special_characters {
1513        use crate::{
1514            content::{Content, SubstitutionStep},
1515            strings::CowStr,
1516            tests::prelude::*,
1517        };
1518
1519        #[test]
1520        fn empty() {
1521            let mut content = Content::from(crate::Span::default());
1522            let p = Parser::default();
1523            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1524            assert!(content.is_empty());
1525            assert_eq!(content.rendered, CowStr::Borrowed(""));
1526        }
1527
1528        #[test]
1529        fn basic_non_empty_span() {
1530            let mut content = Content::from(crate::Span::new("blah"));
1531            let p = Parser::default();
1532            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1533            assert!(!content.is_empty());
1534            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1535        }
1536
1537        #[test]
1538        fn match_lt_and_gt() {
1539            let mut content = Content::from(crate::Span::new("bl<ah>"));
1540            let p = Parser::default();
1541            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1542            assert!(!content.is_empty());
1543            assert_eq!(
1544                content.rendered,
1545                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1546            );
1547        }
1548
1549        #[test]
1550        fn match_amp() {
1551            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1552            let p = Parser::default();
1553            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1554            assert!(!content.is_empty());
1555            assert_eq!(
1556                content.rendered,
1557                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1558            );
1559        }
1560    }
1561
1562    mod quotes {
1563        use crate::{
1564            content::{Content, SubstitutionStep},
1565            strings::CowStr,
1566            tests::prelude::*,
1567        };
1568
1569        #[test]
1570        fn empty() {
1571            let mut content = Content::from(crate::Span::default());
1572            let p = Parser::default();
1573            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1574            assert!(content.is_empty());
1575            assert_eq!(content.rendered, CowStr::Borrowed(""));
1576        }
1577
1578        #[test]
1579        fn basic_non_empty_span() {
1580            let mut content = Content::from(crate::Span::new("blah"));
1581            let p = Parser::default();
1582            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1583            assert!(!content.is_empty());
1584            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1585        }
1586
1587        #[test]
1588        fn ignore_lt_and_gt() {
1589            let mut content = Content::from(crate::Span::new("bl<ah>"));
1590            let p = Parser::default();
1591            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1592            assert!(!content.is_empty());
1593            assert_eq!(
1594                content.rendered,
1595                CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1596            );
1597        }
1598
1599        #[test]
1600        fn strong_word() {
1601            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1602            let p = Parser::default();
1603            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1604            assert!(!content.is_empty());
1605            assert_eq!(
1606                content.rendered,
1607                CowStr::Boxed(
1608                    "One <strong>word</strong> is strong."
1609                        .to_string()
1610                        .into_boxed_str()
1611                )
1612            );
1613        }
1614
1615        #[test]
1616        fn marked_string_with_id() {
1617            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1618            let p = Parser::default();
1619            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1620            assert!(!content.is_empty());
1621            assert_eq!(
1622                content.rendered,
1623                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1624            );
1625        }
1626
1627        #[test]
1628        fn unconstrained_marked_string_with_id_is_registered() {
1629            // An ID assigned to *unconstrained* quoted text (here, `##...##`)
1630            // is rendered as the element's `id` and registered in the catalog
1631            // so the phrase can be the target of a cross reference.
1632            let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1633
1634            assert_eq!(
1635                doc.child_blocks()
1636                    .next()
1637                    .unwrap()
1638                    .rendered_content()
1639                    .unwrap(),
1640                r#"<span id="the_id">marked text</span>"#
1641            );
1642
1643            assert!(doc.catalog().contains_id("the_id"));
1644        }
1645
1646        #[test]
1647        fn multibyte_leading_char_before_constrained_monospace() {
1648            // The constrained-monospace leading boundary group matches any
1649            // non-word Unicode scalar, so it can begin with a multi-byte
1650            // character. When the failed look-ahead skips past that leading
1651            // character, the skip width must honor the character boundary
1652            // rather than assuming a single byte.
1653            for leading in ["€", "中", "🎉"] {
1654                let source = format!("{leading}`code``");
1655                let mut content = Content::from(crate::Span::new(&source));
1656                let p = Parser::default();
1657
1658                // Must not panic on the multi-byte leading character.
1659                SubstitutionStep::Quotes.apply(&mut content, &p, None);
1660
1661                assert!(content.rendered.starts_with(leading));
1662            }
1663        }
1664
1665        #[test]
1666        fn escaped_leading_backtick_before_constrained_monospace() {
1667            // When the leading boundary character is a backslash, the failed
1668            // look-ahead skips the backslash plus the following backtick (both
1669            // ASCII, so two bytes). Exercises the escape branch of the skip
1670            // width and confirms the escaped text is preserved verbatim.
1671            let mut content = Content::from(crate::Span::new(r"\`code``"));
1672            let p = Parser::default();
1673
1674            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1675
1676            assert_eq!(
1677                content.rendered,
1678                CowStr::Boxed(r"\`code``".to_string().into_boxed_str())
1679            );
1680        }
1681    }
1682
1683    mod attribute_references {
1684        use crate::{
1685            content::{Content, SubstitutionStep},
1686            strings::CowStr,
1687            tests::prelude::*,
1688        };
1689
1690        #[test]
1691        fn empty() {
1692            let mut content = Content::from(crate::Span::default());
1693            let p = Parser::default();
1694            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1695            assert!(content.is_empty());
1696            assert_eq!(content.rendered, CowStr::Borrowed(""));
1697        }
1698
1699        #[test]
1700        fn basic_non_empty_span() {
1701            let mut content = Content::from(crate::Span::new("blah"));
1702            let p = Parser::default();
1703            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1704            assert!(!content.is_empty());
1705            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1706        }
1707
1708        #[test]
1709        fn ignore_non_match() {
1710            let mut content = Content::from(crate::Span::new("bl{ah}"));
1711            let p = Parser::default();
1712            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1713            assert!(!content.is_empty());
1714            assert_eq!(
1715                content.rendered,
1716                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1717            );
1718        }
1719
1720        #[test]
1721        fn escaped_reference_to_unset_attribute_drops_backslash() {
1722            // `ah` is a valid attribute name but is unset. An escaped reference
1723            // still has its backslash removed and is passed through literally,
1724            // matching Asciidoctor (see issue #667).
1725            let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1726            let p = Parser::default();
1727            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1728            assert!(!content.is_empty());
1729            assert_eq!(
1730                content.rendered,
1731                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1732            );
1733        }
1734
1735        #[test]
1736        fn replace_sp_match() {
1737            let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1738            let p = Parser::default();
1739            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1740            assert!(!content.is_empty());
1741            assert_eq!(
1742                content.rendered,
1743                CowStr::Boxed("bl ah".to_string().into_boxed_str())
1744            );
1745        }
1746
1747        #[test]
1748        fn ignore_escaped_sp_match() {
1749            let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1750            let p = Parser::default();
1751            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1752            assert!(!content.is_empty());
1753            assert_eq!(
1754                content.rendered,
1755                CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1756            );
1757        }
1758
1759        #[test]
1760        fn counter_directive_displays_and_advances() {
1761            let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1762            let p = Parser::default();
1763            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1764            assert_eq!(
1765                content.rendered,
1766                CowStr::Boxed("1-2".to_string().into_boxed_str())
1767            );
1768        }
1769
1770        #[test]
1771        fn counter2_directive_advances_silently() {
1772            let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1773            let p = Parser::default();
1774            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1775            assert_eq!(
1776                content.rendered,
1777                CowStr::Boxed("2".to_string().into_boxed_str())
1778            );
1779        }
1780
1781        #[test]
1782        fn escaped_counter_directive_is_literal_and_does_not_advance() {
1783            let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1784            let p = Parser::default();
1785            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1786            assert_eq!(
1787                content.rendered,
1788                CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1789            );
1790        }
1791
1792        #[test]
1793        fn escaped_reference_with_both_braces_escaped_drops_backslashes() {
1794            // `\{name\}` escapes the reference the same way `\{name}` does: both
1795            // backslashes are removed and the reference is left unexpanded, even
1796            // when the attribute is set. This is the form Asciidoctor produces
1797            // for `\{group-id\}`.
1798            let p = Parser::default().with_intrinsic_attribute(
1799                "group-id",
1800                "42",
1801                crate::parser::ModificationContext::Anywhere,
1802            );
1803
1804            let mut content = Content::from(crate::Span::new("\\{group-id\\}"));
1805            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1806            assert_eq!(
1807                content.rendered,
1808                CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1809            );
1810        }
1811
1812        #[test]
1813        fn escaped_reference_with_only_trailing_brace_escaped_drops_backslash() {
1814            // A backslash before the closing brace alone (`{name\}`) also escapes
1815            // the reference, matching Asciidoctor's trailing-backslash capture.
1816            let p = Parser::default().with_intrinsic_attribute(
1817                "group-id",
1818                "42",
1819                crate::parser::ModificationContext::Anywhere,
1820            );
1821
1822            let mut content = Content::from(crate::Span::new("{group-id\\}"));
1823            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1824            assert_eq!(
1825                content.rendered,
1826                CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1827            );
1828        }
1829
1830        #[test]
1831        fn escaped_counter_with_trailing_backslash_is_literal_and_does_not_advance() {
1832            // A trailing escape backslash on a counter directive (`{counter:n\}`)
1833            // emits the reference literally (without the backslash) and does not
1834            // advance the counter, so the following unescaped reference is `1`.
1835            let mut content = Content::from(crate::Span::new("{counter:n\\} {counter:n}"));
1836            let p = Parser::default();
1837            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1838            assert_eq!(
1839                content.rendered,
1840                CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1841            );
1842        }
1843
1844        mod attribute_missing {
1845            #![allow(clippy::indexing_slicing)]
1846
1847            use crate::{
1848                Span,
1849                content::{Content, SubstitutionGroup, SubstitutionStep},
1850                parser::ModificationContext,
1851                tests::prelude::*,
1852                warnings::WarningType,
1853            };
1854
1855            fn parser_with_mode(mode: &str) -> Parser {
1856                Parser::default().with_intrinsic_attribute(
1857                    "attribute-missing",
1858                    mode,
1859                    ModificationContext::Anywhere,
1860                )
1861            }
1862
1863            fn render(text: &str, parser: &Parser) -> String {
1864                let mut content = Content::from(crate::Span::new(text));
1865                SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1866                content.rendered.to_string()
1867            }
1868
1869            /// Builds a `Content` that carries the per-line source spans (as a
1870            /// real block would), so the precise `warn`-location correlation is
1871            /// exercised. Each line's span is a subrange of the root, mirroring
1872            /// what block construction retains via
1873            /// [`Content::from_filtered_lines`].
1874            fn content_with_source_lines(text: &'static str) -> Content<'static> {
1875                let root = Span::new(text);
1876                let lines: Vec<&str> = text.split('\n').collect();
1877
1878                let mut spans = Vec::with_capacity(lines.len());
1879                let mut offset = 0;
1880                for line in &lines {
1881                    spans.push(root.slice(offset..offset + line.len()));
1882
1883                    // Advance past the line and the '\n' that split consumed.
1884                    offset += line.len() + 1;
1885                }
1886
1887                Content::from_filtered_lines(root, &lines, spans)
1888            }
1889
1890            /// Asserts that `warning`'s recorded offset/length select exactly
1891            /// `expected` out of `text`, i.e. the warning points at that
1892            /// precise reference in the original source.
1893            fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1894                assert_eq!(
1895                    &text[warning.offset..warning.offset + warning.len],
1896                    expected
1897                );
1898            }
1899
1900            #[test]
1901            fn skip_is_default() {
1902                let p = Parser::default();
1903                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1904                assert!(p.take_substitution_warnings().is_empty());
1905            }
1906
1907            #[test]
1908            fn skip_explicit() {
1909                let p = parser_with_mode("skip");
1910                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1911            }
1912
1913            #[test]
1914            fn unknown_value_falls_back_to_skip() {
1915                let p = parser_with_mode("bogus");
1916                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1917            }
1918
1919            #[test]
1920            fn drop_removes_only_the_reference() {
1921                let p = parser_with_mode("drop");
1922                assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1923            }
1924
1925            #[test]
1926            fn drop_keeps_resolvable_references() {
1927                let p = parser_with_mode("drop");
1928                assert_eq!(render("a {sp}b {missing} c", &p), "a  b  c");
1929            }
1930
1931            #[test]
1932            fn drop_removes_line_that_only_contained_the_reference() {
1933                // A line consisting solely of an unresolved reference is
1934                // dropped entirely, not left as a blank line (issue #730).
1935                let p = parser_with_mode("drop");
1936                assert_eq!(render("Line 1\n{missing}\nLine 2", &p), "Line 1\nLine 2");
1937            }
1938
1939            #[test]
1940            fn drop_keeps_a_line_the_reference_did_not_empty() {
1941                // The line still has other content after the reference is
1942                // dropped, so it survives (only the reference is removed).
1943                let p = parser_with_mode("drop");
1944                assert_eq!(
1945                    render("Line 1\ntext {missing}\nLine 2", &p),
1946                    "Line 1\ntext \nLine 2"
1947                );
1948            }
1949
1950            #[test]
1951            fn drop_removes_a_leading_or_trailing_reference_only_line() {
1952                let p = parser_with_mode("drop");
1953                assert_eq!(render("{missing}\nLine 2", &p), "Line 2");
1954                assert_eq!(render("Line 1\n{missing}", &p), "Line 1");
1955            }
1956
1957            #[test]
1958            fn drop_can_empty_the_content() {
1959                // A single line that is only an unresolved reference drops to
1960                // empty content, mirroring `drop-line`.
1961                let p = parser_with_mode("drop");
1962                assert_eq!(render("{missing}", &p), "");
1963            }
1964
1965            #[test]
1966            fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1967                // The line becomes empty, but not because a *missing* reference
1968                // was dropped, so it is retained.
1969                let p = parser_with_mode("drop");
1970                assert_eq!(render("Line 1\n{empty}\nLine 2", &p), "Line 1\n\nLine 2");
1971            }
1972
1973            #[test]
1974            fn drop_line_removes_the_whole_line() {
1975                let p = parser_with_mode("drop-line");
1976                assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1977            }
1978
1979            #[test]
1980            fn drop_line_only_drops_lines_with_a_missing_reference() {
1981                let p = parser_with_mode("drop-line");
1982                assert_eq!(
1983                    render("first {sp}line\nsecond {missing} line\nthird line", &p),
1984                    "first  line\nthird line"
1985                );
1986            }
1987
1988            #[test]
1989            fn drop_line_can_empty_the_content() {
1990                let p = parser_with_mode("drop-line");
1991                assert_eq!(render("{missing}", &p), "");
1992            }
1993
1994            #[test]
1995            fn drop_line_records_a_warning_for_the_dropped_reference() {
1996                // Dropping the line is silent in Asciidoctor's output, but it
1997                // logs an `INFO` diagnostic naming the missing attribute; the
1998                // parser records the matching warning.
1999                let p = parser_with_mode("drop-line");
2000                assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
2001
2002                let warnings = p.take_substitution_warnings();
2003                assert_eq!(warnings.len(), 1);
2004                assert_eq!(
2005                    warnings[0].warning,
2006                    WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2007                );
2008            }
2009
2010            #[test]
2011            fn drop_line_records_one_warning_per_missing_reference() {
2012                // Two missing references on the same dropped line each produce a
2013                // diagnostic, matching Asciidoctor's per-reference logging.
2014                let p = parser_with_mode("drop-line");
2015                assert_eq!(render("a {x} b {y} c\ntail", &p), "tail");
2016                assert_eq!(p.take_substitution_warnings().len(), 2);
2017            }
2018
2019            #[test]
2020            fn drop_line_does_not_warn_for_a_line_without_a_missing_reference() {
2021                // Only the line carrying the missing reference is dropped and
2022                // warned about; a line whose references all resolve is untouched.
2023                let p = parser_with_mode("drop-line");
2024                assert_eq!(
2025                    render("first {sp}line\nsecond {missing} line\nthird line", &p),
2026                    "first  line\nthird line"
2027                );
2028
2029                let warnings = p.take_substitution_warnings();
2030                assert_eq!(warnings.len(), 1);
2031                assert_eq!(
2032                    warnings[0].warning,
2033                    WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2034                );
2035            }
2036
2037            #[test]
2038            fn drop_line_points_at_the_precise_reference() {
2039                // With per-line source spans retained, the drop-line diagnostic
2040                // names the exact offending reference rather than the whole line.
2041                let p = parser_with_mode("drop-line");
2042                let text = "first {alpha} line\nsecond {beta} line";
2043                let mut content = content_with_source_lines(text);
2044                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2045
2046                let warnings = p.take_substitution_warnings();
2047                assert_eq!(warnings.len(), 2);
2048                assert_spans(&warnings[0], text, "{alpha}");
2049                assert_spans(&warnings[1], text, "{beta}");
2050            }
2051
2052            #[test]
2053            fn drop_line_falls_back_to_whole_span_without_source_lines() {
2054                // `Content::from` retains no per-line spans, so the drop-line
2055                // diagnostic degrades to the whole-content span rather than
2056                // misreporting a location.
2057                let p = parser_with_mode("drop-line");
2058                let text = "x {foo} y";
2059                let mut content = Content::from(Span::new(text));
2060                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2061
2062                let warnings = p.take_substitution_warnings();
2063                assert_eq!(warnings.len(), 1);
2064                assert_eq!(warnings[0].offset, 0);
2065                assert_eq!(warnings[0].len, text.len());
2066            }
2067
2068            /// Exercises the free-standing text path (used for docinfo file
2069            /// content), which applies the same `attribute-missing` handling as
2070            /// [`render`] but through [`substitute_attributes_in_text`] rather
2071            /// than the block substitution pipeline.
2072            mod free_standing_text {
2073                use super::parser_with_mode;
2074                use crate::content::substitute_attributes_in_text;
2075
2076                #[test]
2077                fn drop_removes_line_that_only_contained_the_reference() {
2078                    let p = parser_with_mode("drop");
2079                    assert_eq!(
2080                        substitute_attributes_in_text("Line 1\n{missing}\nLine 2", &p),
2081                        "Line 1\nLine 2"
2082                    );
2083                }
2084
2085                #[test]
2086                fn drop_keeps_a_line_the_reference_did_not_empty() {
2087                    let p = parser_with_mode("drop");
2088                    assert_eq!(
2089                        substitute_attributes_in_text("Line 1\ntext {missing}\nLine 2", &p),
2090                        "Line 1\ntext \nLine 2"
2091                    );
2092                }
2093
2094                #[test]
2095                fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
2096                    let p = parser_with_mode("drop");
2097                    assert_eq!(
2098                        substitute_attributes_in_text("Line 1\n{empty}\nLine 2", &p),
2099                        "Line 1\n\nLine 2"
2100                    );
2101                }
2102
2103                #[test]
2104                fn drop_line_removes_the_whole_line() {
2105                    let p = parser_with_mode("drop-line");
2106                    assert_eq!(
2107                        substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2108                        "Line 1\nLine 2"
2109                    );
2110                }
2111
2112                #[test]
2113                fn drop_line_records_a_warning() {
2114                    // The diagnostic is recorded on the parser even on the
2115                    // free-standing text path; a docinfo caller separately
2116                    // discards it via `truncate_substitution_warnings`.
2117                    use crate::warnings::WarningType;
2118
2119                    let p = parser_with_mode("drop-line");
2120                    assert_eq!(
2121                        substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2122                        "Line 1\nLine 2"
2123                    );
2124
2125                    let warnings = p.take_substitution_warnings();
2126                    assert_eq!(warnings.len(), 1);
2127                    assert_eq!(
2128                        warnings[0].warning,
2129                        WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2130                    );
2131                }
2132
2133                #[test]
2134                fn drop_removes_a_crlf_reference_only_line() {
2135                    // The `\r` left by a CRLF terminator does not keep the line
2136                    // from counting as emptied by the dropped reference, so the
2137                    // whole `\r\n` line is removed.
2138                    let p = parser_with_mode("drop");
2139                    assert_eq!(
2140                        substitute_attributes_in_text("Line 1\r\n{missing}\r\nLine 2", &p),
2141                        "Line 1\r\nLine 2"
2142                    );
2143                }
2144
2145                #[test]
2146                fn drop_keeps_a_crlf_line_the_reference_did_not_empty() {
2147                    let p = parser_with_mode("drop");
2148                    assert_eq!(
2149                        substitute_attributes_in_text("Line 1\r\ntext {missing}\r\nLine 2", &p),
2150                        "Line 1\r\ntext \r\nLine 2"
2151                    );
2152                }
2153            }
2154
2155            #[test]
2156            fn warn_leaves_the_reference_and_records_a_warning() {
2157                let p = parser_with_mode("warn");
2158                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
2159
2160                let warnings = p.take_substitution_warnings();
2161                assert_eq!(warnings.len(), 1);
2162                assert_eq!(
2163                    warnings[0].warning,
2164                    WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2165                );
2166            }
2167
2168            #[test]
2169            fn warn_records_one_warning_per_missing_reference() {
2170                let p = parser_with_mode("warn");
2171                assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
2172                assert_eq!(p.take_substitution_warnings().len(), 2);
2173            }
2174
2175            #[test]
2176            fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
2177                // An escaped reference has its backslash removed and is passed
2178                // through literally; it is never treated as a missing reference,
2179                // so even under `drop-line` the line survives and no warning is
2180                // recorded.
2181                let p = parser_with_mode("drop-line");
2182                assert_eq!(
2183                    render("In the path /items/\\{id}, x.", &p),
2184                    "In the path /items/{id}, x."
2185                );
2186                assert!(p.take_substitution_warnings().is_empty());
2187            }
2188
2189            // The tests below use `content_with_source_lines` so the precise
2190            // per-reference `warn` location (issue #564) is exercised; the
2191            // `render`-based tests above go through `Content::from`, which
2192            // retains no source lines and so falls back to the whole-content
2193            // span.
2194
2195            #[test]
2196            fn warn_points_at_the_precise_reference() {
2197                let p = parser_with_mode("warn");
2198                let text = "Hello, {name}!";
2199                let mut content = content_with_source_lines(text);
2200                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2201
2202                let warnings = p.take_substitution_warnings();
2203                assert_eq!(warnings.len(), 1);
2204                assert_spans(&warnings[0], text, "{name}");
2205            }
2206
2207            #[test]
2208            fn warn_locates_multiple_references_on_one_line() {
2209                let p = parser_with_mode("warn");
2210                let text = "a {x} b {y} c";
2211                let mut content = content_with_source_lines(text);
2212                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2213
2214                let warnings = p.take_substitution_warnings();
2215                assert_eq!(warnings.len(), 2);
2216                assert_spans(&warnings[0], text, "{x}");
2217                assert_spans(&warnings[1], text, "{y}");
2218
2219                // The two references must resolve to distinct offsets.
2220                assert_ne!(warnings[0].offset, warnings[1].offset);
2221            }
2222
2223            #[test]
2224            fn warn_locates_references_across_multiple_lines() {
2225                // The acceptance case from issue #564: several distinct
2226                // references on different lines of one block, each pointed at
2227                // individually rather than at the shared whole-block span.
2228                let p = parser_with_mode("warn");
2229                let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
2230                let mut content = content_with_source_lines(text);
2231                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2232
2233                let warnings = p.take_substitution_warnings();
2234                assert_eq!(warnings.len(), 3);
2235                assert_spans(&warnings[0], text, "{alpha}");
2236                assert_spans(&warnings[1], text, "{beta}");
2237                assert_spans(&warnings[2], text, "{gamma}");
2238            }
2239
2240            #[test]
2241            fn warn_distinguishes_repeated_reference_occurrences() {
2242                let p = parser_with_mode("warn");
2243                let text = "{dup} and again {dup}";
2244                let mut content = content_with_source_lines(text);
2245                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2246
2247                let warnings = p.take_substitution_warnings();
2248                assert_eq!(warnings.len(), 2);
2249                assert_spans(&warnings[0], text, "{dup}");
2250                assert_spans(&warnings[1], text, "{dup}");
2251
2252                // Same text, but the two occurrences are at different offsets.
2253                assert_eq!(warnings[0].offset, 0);
2254                assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
2255            }
2256
2257            #[test]
2258            fn warn_span_survives_earlier_special_character_expansion() {
2259                // The key regression guard: special characters run before the
2260                // attributes step and lengthen the rendered text (`<` -> `&lt;`),
2261                // so a naive rendered-offset would be wrong. The warning must
2262                // still name the reference's *original* source offset.
2263                let p = parser_with_mode("warn");
2264                let text = "a < b {foo} c";
2265                let mut content = content_with_source_lines(text);
2266                SubstitutionGroup::Normal.apply(&mut content, &p, None);
2267
2268                // Sanity check that the earlier step really did shift offsets.
2269                assert!(content.rendered().contains("&lt;"));
2270
2271                let warnings = p.take_substitution_warnings();
2272                assert_eq!(warnings.len(), 1);
2273                assert_spans(&warnings[0], text, "{foo}");
2274                assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2275            }
2276
2277            #[test]
2278            fn warn_span_survives_earlier_quote_expansion() {
2279                // Same guard as above, but for the quotes step, which wraps
2280                // `*bold*` in markup ahead of the attributes step.
2281                let p = parser_with_mode("warn");
2282                let text = "*bold* {foo}";
2283                let mut content = content_with_source_lines(text);
2284                SubstitutionGroup::Normal.apply(&mut content, &p, None);
2285
2286                assert!(content.rendered().contains("<strong>"));
2287
2288                let warnings = p.take_substitution_warnings();
2289                assert_eq!(warnings.len(), 1);
2290                assert_spans(&warnings[0], text, "{foo}");
2291                assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2292            }
2293
2294            #[test]
2295            fn warn_falls_back_to_whole_span_without_source_lines() {
2296                // `Content::from` retains no per-line spans, so the warning
2297                // degrades to the whole-content span (the pre-#564 behavior)
2298                // rather than misreporting a location.
2299                let p = parser_with_mode("warn");
2300                let text = "x {foo} y";
2301                let mut content = Content::from(Span::new(text));
2302                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2303
2304                let warnings = p.take_substitution_warnings();
2305                assert_eq!(warnings.len(), 1);
2306                assert_eq!(warnings[0].offset, 0);
2307                assert_eq!(warnings[0].len, text.len());
2308            }
2309        }
2310    }
2311
2312    mod callouts {
2313        use crate::{
2314            content::{Content, SubstitutionStep},
2315            parser::ModificationContext,
2316            strings::CowStr,
2317            tests::prelude::*,
2318        };
2319
2320        /// Builds a `Content` whose `rendered` text is `text` (as if special
2321        /// characters had already been substituted), applies the callouts step,
2322        /// and returns the resulting rendered text.
2323        fn render_callouts(text: &str, parser: &Parser) -> String {
2324            let mut content = Content::from(crate::Span::new(text));
2325
2326            // `Content::from` copies the source verbatim into `rendered`, which
2327            // is exactly the post-special-characters state we want to exercise.
2328            SubstitutionStep::Callouts.apply(&mut content, parser, None);
2329            content.rendered.to_string()
2330        }
2331
2332        #[test]
2333        fn empty() {
2334            let mut content = Content::from(crate::Span::default());
2335            let p = Parser::default();
2336            SubstitutionStep::Callouts.apply(&mut content, &p, None);
2337            assert!(content.is_empty());
2338            assert_eq!(content.rendered, CowStr::Borrowed(""));
2339        }
2340
2341        #[test]
2342        fn no_callouts() {
2343            let p = Parser::default();
2344            assert_eq!(render_callouts("just some text", &p), "just some text");
2345        }
2346
2347        #[test]
2348        fn lt_without_callout_is_untouched() {
2349            let p = Parser::default();
2350            assert_eq!(render_callouts("a &lt;b&gt; c", &p), "a &lt;b&gt; c");
2351        }
2352
2353        #[test]
2354        fn basic_explicit() {
2355            let p = Parser::default();
2356            assert_eq!(
2357                render_callouts("require 'x' &lt;1&gt;", &p),
2358                r#"require 'x' <b class="conum">(1)</b>"#
2359            );
2360        }
2361
2362        #[test]
2363        fn line_comment_prefix_preserved() {
2364            let p = Parser::default();
2365            assert_eq!(
2366                render_callouts("puts 'x' # &lt;1&gt;", &p),
2367                r#"puts 'x' # <b class="conum">(1)</b>"#
2368            );
2369        }
2370
2371        #[test]
2372        fn multiple_on_one_line() {
2373            let p = Parser::default();
2374            assert_eq!(
2375                render_callouts("puts x &lt;5&gt;&lt;6&gt;", &p),
2376                r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
2377            );
2378        }
2379
2380        #[test]
2381        fn not_at_end_of_line() {
2382            let p = Parser::default();
2383            assert_eq!(
2384                render_callouts("puts \"&lt;1&gt; in the middle\"", &p),
2385                "puts \"&lt;1&gt; in the middle\""
2386            );
2387        }
2388
2389        #[test]
2390        fn auto_numbering() {
2391            let p = Parser::default();
2392            assert_eq!(
2393                render_callouts("a &lt;.&gt;\nb &lt;.&gt;\nc &lt;.&gt;", &p),
2394                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
2395            );
2396        }
2397
2398        #[test]
2399        fn mixed_numbering_ignores_explicit() {
2400            // Auto-numbering is not aware of explicit numbers.
2401            let p = Parser::default();
2402            assert_eq!(
2403                render_callouts("a &lt;.&gt;\nb &lt;1&gt;\nc &lt;.&gt;", &p),
2404                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
2405            );
2406        }
2407
2408        #[test]
2409        fn xml_callout() {
2410            let p = Parser::default();
2411            assert_eq!(
2412                render_callouts("&lt;child/&gt; &lt;!--1--&gt;", &p),
2413                r#"&lt;child/&gt; &lt;!--<b class="conum">(1)</b>--&gt;"#
2414            );
2415        }
2416
2417        #[test]
2418        fn half_xml_comment_is_not_a_callout() {
2419            let p = Parser::default();
2420            assert_eq!(
2421                render_callouts("First line &lt;1--&gt;", &p),
2422                "First line &lt;1--&gt;"
2423            );
2424        }
2425
2426        #[test]
2427        fn escaped_callout() {
2428            let p = Parser::default();
2429            assert_eq!(
2430                render_callouts("require 'x' # \\&lt;1&gt;", &p),
2431                "require 'x' # &lt;1&gt;"
2432            );
2433        }
2434
2435        #[test]
2436        fn icons_font() {
2437            let p = Parser::default().with_intrinsic_attribute(
2438                "icons",
2439                "font",
2440                ModificationContext::Anywhere,
2441            );
2442            assert_eq!(
2443                render_callouts("puts x # &lt;1&gt;", &p),
2444                r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
2445            );
2446        }
2447
2448        #[test]
2449        fn icons_image() {
2450            let p = Parser::default().with_intrinsic_attribute(
2451                "icons",
2452                "",
2453                ModificationContext::Anywhere,
2454            );
2455            assert_eq!(
2456                render_callouts("puts x &lt;1&gt;", &p),
2457                r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2458            );
2459        }
2460
2461        #[test]
2462        fn custom_line_comment_prefix() {
2463            // `line-comment=%` (Erlang). Only `%` is recognized as a prefix.
2464            let mut content = Content::from(crate::Span::new("hello() -> % &lt;1&gt;"));
2465            let attrlist = crate::attributes::Attrlist::parse(
2466                crate::Span::new("source,erlang,line-comment=%"),
2467                &Parser::default(),
2468                crate::attributes::AttrlistContext::Block,
2469            )
2470            .item
2471            .item;
2472            let p = Parser::default();
2473            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2474            assert_eq!(
2475                content.rendered.to_string(),
2476                r#"hello() -> % <b class="conum">(1)</b>"#
2477            );
2478        }
2479
2480        #[test]
2481        fn disabled_line_comment_preserves_leading_chars() {
2482            // `line-comment=` (empty) disables prefix recognition, so the `--`
2483            // before the callout is preserved verbatim.
2484            let mut content = Content::from(crate::Span::new("-- &lt;1&gt;"));
2485            let attrlist = crate::attributes::Attrlist::parse(
2486                crate::Span::new("source,asciidoc,line-comment="),
2487                &Parser::default(),
2488                crate::attributes::AttrlistContext::Block,
2489            )
2490            .item
2491            .item;
2492            let p = Parser::default();
2493            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2494            assert_eq!(
2495                content.rendered.to_string(),
2496                r#"-- <b class="conum">(1)</b>"#
2497            );
2498        }
2499
2500        #[test]
2501        fn document_line_comment_attribute() {
2502            // The `line-comment` attribute can be set at the document level
2503            // (here, with no block attrlist), and is honored as a fallback.
2504            let p = Parser::default().with_intrinsic_attribute(
2505                "line-comment",
2506                "%",
2507                ModificationContext::Anywhere,
2508            );
2509            assert_eq!(
2510                render_callouts("hello() -> % &lt;1&gt;", &p),
2511                r#"hello() -> % <b class="conum">(1)</b>"#
2512            );
2513        }
2514    }
2515}