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        if !(rendered.contains('+') && rendered.contains('\n')) {
1272            return;
1273        }
1274
1275        let replacer = PostReplacementReplacer(&*parser.renderer);
1276
1277        if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1278            content.rendered = new_result.into();
1279        }
1280    }
1281}
1282
1283#[derive(Debug)]
1284struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1285
1286impl Replacer for PostReplacementReplacer<'_> {
1287    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1288        dest.push_str(&caps[1]);
1289        self.0.render_line_break(dest);
1290    }
1291}
1292
1293static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1294    #[allow(clippy::unwrap_used)]
1295    Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1296});
1297
1298/// Processes [callouts] in literal, listing, and source blocks.
1299///
1300/// Callout numbers (`<1>`, `<.>`, or `<!--1-->` for XML) that appear at the end
1301/// of a line are replaced with the renderer's callout markup. Callouts may be
1302/// tucked behind a line comment (`//`, `#`, `--`, or `;;` by default, or a
1303/// custom prefix specified by the `line-comment` attribute), and a callout may
1304/// be escaped with a leading backslash to render it literally.
1305///
1306/// This substitution runs after [special characters] have been replaced, so the
1307/// angle brackets that delimit a callout appear in `content.rendered` as
1308/// `&lt;` and `&gt;`. This mirrors Asciidoctor's `sub_callouts` /
1309/// `CalloutSourceRx`.
1310///
1311/// [callouts]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
1312/// [special characters]: https://docs.asciidoctor.org/asciidoc/latest/subs/special-characters/
1313fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1314    // A callout's opening bracket is always rendered as `&lt;` by the special
1315    // characters substitution, so we can cheaply skip content without any.
1316    if !content.rendered.contains("&lt;") {
1317        return;
1318    }
1319
1320    // The `line-comment` attribute (block-level, falling back to document-level)
1321    // customizes or disables line-comment recognition:
1322    //
1323    // * absent -> default prefixes (`//`, `#`, `--`, `;;`) and XML callouts are
1324    //   recognized.
1325    // * present (custom) -> only the given prefix is recognized; XML callouts are
1326    //   not.
1327    // * present but empty -> no line-comment prefix is recognized; XML callouts are
1328    //   not.
1329    let line_comment: Option<String> = attrlist
1330        .and_then(|a| a.named_attribute("line-comment"))
1331        .map(|a| a.value().to_string())
1332        .or_else(|| {
1333            if parser.has_attribute("line-comment") {
1334                Some(
1335                    parser
1336                        .attribute_value("line-comment")
1337                        .as_maybe_str()
1338                        .unwrap_or("")
1339                        .to_string(),
1340                )
1341            } else {
1342                None
1343            }
1344        });
1345
1346    let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1347
1348    let replacer = CalloutReplacer {
1349        renderer: &*parser.renderer,
1350        parser,
1351        autonum: 0,
1352        tail: tail_rx,
1353    };
1354
1355    if let Cow::Owned(new_result) =
1356        replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1357    {
1358        content.rendered = new_result.into();
1359    }
1360}
1361
1362/// Callout regex for the default `line-comment` mode: recognizes the common
1363/// line-comment prefixes and XML callouts.
1364static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1365    #[allow(clippy::unwrap_used)]
1366    Regex::new(
1367        r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:&lt;!--(?P<xnum>\d+|\.)--&gt;|&lt;(?P<num>\d+|\.)&gt;)",
1368    )
1369    .unwrap()
1370});
1371
1372/// Trailing-position lookahead regex for the default `line-comment` mode.
1373static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1374    #[allow(clippy::unwrap_used)]
1375    Regex::new(r"^(?: ?\\?(?:&lt;!--(?:\d+|\.)--&gt;|&lt;(?:\d+|\.)&gt;))*(?:\n|$)").unwrap()
1376});
1377
1378/// Trailing-position lookahead regex for a custom or empty `line-comment` mode
1379/// (no XML callout form).
1380static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1381    #[allow(clippy::unwrap_used)]
1382    Regex::new(r"^(?: ?\\?&lt;(?:\d+|\.)&gt;)*(?:\n|$)").unwrap()
1383});
1384
1385/// Builds the `(callout, tail)` regex pair for the given `line-comment` mode.
1386///
1387/// The `callout` regex matches a single callout token (with the optional
1388/// line-comment prefix and escape that may precede it). The `tail` regex is
1389/// used to emulate Asciidoctor's trailing-position lookahead: a matched callout
1390/// is only honored when the remainder of its line consists solely of further
1391/// callouts. Rust's regex engine supports neither lookahead nor backreferences,
1392/// so the lookahead is applied manually against the post-match text.
1393///
1394/// The default-mode regexes and both tail regexes are constant, so they are
1395/// built once. Only a custom (non-empty) prefix requires building a regex from
1396/// the attribute value, which is borrowed otherwise.
1397fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1398    match line_comment {
1399        // Default: recognize the common line-comment prefixes and XML callouts.
1400        None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1401
1402        // A custom or empty `line-comment`: only the bare (non-XML) callout form
1403        // is recognized, optionally behind the custom prefix.
1404        Some(prefix) => {
1405            let prefix_pattern = if prefix.is_empty() {
1406                String::new()
1407            } else {
1408                format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1409            };
1410
1411            #[allow(clippy::unwrap_used)]
1412            let callout = Regex::new(&format!(
1413                r"{prefix_pattern}(?P<esc>\\)?&lt;(?P<num>\d+|\.)&gt;"
1414            ))
1415            .unwrap();
1416
1417            (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1418        }
1419    }
1420}
1421
1422/// Replacer that renders each trailing callout token, emulating Asciidoctor's
1423/// `sub_callouts`.
1424struct CalloutReplacer<'r> {
1425    renderer: &'r dyn InlineSubstitutionRenderer,
1426    parser: &'r Parser,
1427
1428    /// Running counter for automatically-numbered (`<.>`) callouts, scoped to a
1429    /// single block.
1430    autonum: u32,
1431
1432    /// Trailing-position lookahead regex (see [`build_callout_regexes`]).
1433    tail: &'r Regex,
1434}
1435
1436impl LookaheadReplacer for CalloutReplacer<'_> {
1437    fn replace_append(
1438        &mut self,
1439        caps: &Captures<'_>,
1440        dest: &mut String,
1441        after: &str,
1442    ) -> LookaheadResult {
1443        // Honor the trailing-position requirement: a callout is only recognized
1444        // when nothing but further callouts follows it on the line.
1445        if !self.tail.is_match(after) {
1446            dest.push_str(&caps[0]);
1447            return LookaheadResult::Continue;
1448        }
1449
1450        // Honor the escape: emit the matched text with the escaping backslash
1451        // removed so the callout renders literally.
1452        if caps.name("esc").is_some() {
1453            dest.push_str(&caps[0].replacen('\\', "", 1));
1454            return LookaheadResult::Continue;
1455        }
1456
1457        let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1458            (xnum.as_str(), true)
1459        } else {
1460            // The regex guarantees one of `xnum` or `num` is present.
1461            #[allow(clippy::unwrap_used)]
1462            (caps.name("num").unwrap().as_str(), false)
1463        };
1464
1465        let number = if number_raw == "." {
1466            self.autonum += 1;
1467            self.autonum.to_string()
1468        } else {
1469            number_raw.to_string()
1470        };
1471
1472        // Register this callout so the callout list that annotates this block
1473        // can be validated against the callouts it references.
1474        if let Ok(n) = number.parse::<u32>() {
1475            self.parser.register_callout(n);
1476        }
1477
1478        // Mirror Asciidoctor's guard resolution: a captured line-comment prefix
1479        // takes precedence; otherwise an XML callout uses the XML guard; failing
1480        // both, there is no guard.
1481        let guard = match caps.name("prefix") {
1482            Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1483            None if is_xml => CalloutGuard::Xml,
1484            None => CalloutGuard::LineComment(""),
1485        };
1486
1487        self.renderer.render_callout(
1488            &CalloutRenderParams {
1489                number: &number,
1490                guard,
1491                parser: self.parser,
1492            },
1493            dest,
1494        );
1495
1496        LookaheadResult::Continue
1497    }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502    #![allow(clippy::unwrap_used)]
1503
1504    mod special_characters {
1505        use crate::{
1506            content::{Content, SubstitutionStep},
1507            strings::CowStr,
1508            tests::prelude::*,
1509        };
1510
1511        #[test]
1512        fn empty() {
1513            let mut content = Content::from(crate::Span::default());
1514            let p = Parser::default();
1515            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1516            assert!(content.is_empty());
1517            assert_eq!(content.rendered, CowStr::Borrowed(""));
1518        }
1519
1520        #[test]
1521        fn basic_non_empty_span() {
1522            let mut content = Content::from(crate::Span::new("blah"));
1523            let p = Parser::default();
1524            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1525            assert!(!content.is_empty());
1526            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1527        }
1528
1529        #[test]
1530        fn match_lt_and_gt() {
1531            let mut content = Content::from(crate::Span::new("bl<ah>"));
1532            let p = Parser::default();
1533            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1534            assert!(!content.is_empty());
1535            assert_eq!(
1536                content.rendered,
1537                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1538            );
1539        }
1540
1541        #[test]
1542        fn match_amp() {
1543            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1544            let p = Parser::default();
1545            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1546            assert!(!content.is_empty());
1547            assert_eq!(
1548                content.rendered,
1549                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1550            );
1551        }
1552    }
1553
1554    mod quotes {
1555        use crate::{
1556            content::{Content, SubstitutionStep},
1557            strings::CowStr,
1558            tests::prelude::*,
1559        };
1560
1561        #[test]
1562        fn empty() {
1563            let mut content = Content::from(crate::Span::default());
1564            let p = Parser::default();
1565            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1566            assert!(content.is_empty());
1567            assert_eq!(content.rendered, CowStr::Borrowed(""));
1568        }
1569
1570        #[test]
1571        fn basic_non_empty_span() {
1572            let mut content = Content::from(crate::Span::new("blah"));
1573            let p = Parser::default();
1574            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1575            assert!(!content.is_empty());
1576            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1577        }
1578
1579        #[test]
1580        fn ignore_lt_and_gt() {
1581            let mut content = Content::from(crate::Span::new("bl<ah>"));
1582            let p = Parser::default();
1583            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1584            assert!(!content.is_empty());
1585            assert_eq!(
1586                content.rendered,
1587                CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1588            );
1589        }
1590
1591        #[test]
1592        fn strong_word() {
1593            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1594            let p = Parser::default();
1595            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1596            assert!(!content.is_empty());
1597            assert_eq!(
1598                content.rendered,
1599                CowStr::Boxed(
1600                    "One <strong>word</strong> is strong."
1601                        .to_string()
1602                        .into_boxed_str()
1603                )
1604            );
1605        }
1606
1607        #[test]
1608        fn marked_string_with_id() {
1609            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1610            let p = Parser::default();
1611            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1612            assert!(!content.is_empty());
1613            assert_eq!(
1614                content.rendered,
1615                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1616            );
1617        }
1618
1619        #[test]
1620        fn unconstrained_marked_string_with_id_is_registered() {
1621            // An ID assigned to *unconstrained* quoted text (here, `##...##`)
1622            // is rendered as the element's `id` and registered in the catalog
1623            // so the phrase can be the target of a cross reference.
1624            let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1625
1626            assert_eq!(
1627                doc.child_blocks()
1628                    .next()
1629                    .unwrap()
1630                    .rendered_content()
1631                    .unwrap(),
1632                r#"<span id="the_id">marked text</span>"#
1633            );
1634
1635            assert!(doc.catalog().contains_id("the_id"));
1636        }
1637
1638        #[test]
1639        fn multibyte_leading_char_before_constrained_monospace() {
1640            // The constrained-monospace leading boundary group matches any
1641            // non-word Unicode scalar, so it can begin with a multi-byte
1642            // character. When the failed look-ahead skips past that leading
1643            // character, the skip width must honor the character boundary
1644            // rather than assuming a single byte.
1645            for leading in ["€", "中", "🎉"] {
1646                let source = format!("{leading}`code``");
1647                let mut content = Content::from(crate::Span::new(&source));
1648                let p = Parser::default();
1649
1650                // Must not panic on the multi-byte leading character.
1651                SubstitutionStep::Quotes.apply(&mut content, &p, None);
1652
1653                assert!(content.rendered.starts_with(leading));
1654            }
1655        }
1656
1657        #[test]
1658        fn escaped_leading_backtick_before_constrained_monospace() {
1659            // When the leading boundary character is a backslash, the failed
1660            // look-ahead skips the backslash plus the following backtick (both
1661            // ASCII, so two bytes). Exercises the escape branch of the skip
1662            // width and confirms the escaped text is preserved verbatim.
1663            let mut content = Content::from(crate::Span::new(r"\`code``"));
1664            let p = Parser::default();
1665
1666            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1667
1668            assert_eq!(
1669                content.rendered,
1670                CowStr::Boxed(r"\`code``".to_string().into_boxed_str())
1671            );
1672        }
1673    }
1674
1675    mod attribute_references {
1676        use crate::{
1677            content::{Content, SubstitutionStep},
1678            strings::CowStr,
1679            tests::prelude::*,
1680        };
1681
1682        #[test]
1683        fn empty() {
1684            let mut content = Content::from(crate::Span::default());
1685            let p = Parser::default();
1686            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1687            assert!(content.is_empty());
1688            assert_eq!(content.rendered, CowStr::Borrowed(""));
1689        }
1690
1691        #[test]
1692        fn basic_non_empty_span() {
1693            let mut content = Content::from(crate::Span::new("blah"));
1694            let p = Parser::default();
1695            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1696            assert!(!content.is_empty());
1697            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1698        }
1699
1700        #[test]
1701        fn ignore_non_match() {
1702            let mut content = Content::from(crate::Span::new("bl{ah}"));
1703            let p = Parser::default();
1704            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1705            assert!(!content.is_empty());
1706            assert_eq!(
1707                content.rendered,
1708                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1709            );
1710        }
1711
1712        #[test]
1713        fn escaped_reference_to_unset_attribute_drops_backslash() {
1714            // `ah` is a valid attribute name but is unset. An escaped reference
1715            // still has its backslash removed and is passed through literally,
1716            // matching Asciidoctor (see issue #667).
1717            let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1718            let p = Parser::default();
1719            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1720            assert!(!content.is_empty());
1721            assert_eq!(
1722                content.rendered,
1723                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1724            );
1725        }
1726
1727        #[test]
1728        fn replace_sp_match() {
1729            let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1730            let p = Parser::default();
1731            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1732            assert!(!content.is_empty());
1733            assert_eq!(
1734                content.rendered,
1735                CowStr::Boxed("bl ah".to_string().into_boxed_str())
1736            );
1737        }
1738
1739        #[test]
1740        fn ignore_escaped_sp_match() {
1741            let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1742            let p = Parser::default();
1743            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1744            assert!(!content.is_empty());
1745            assert_eq!(
1746                content.rendered,
1747                CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1748            );
1749        }
1750
1751        #[test]
1752        fn counter_directive_displays_and_advances() {
1753            let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1754            let p = Parser::default();
1755            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1756            assert_eq!(
1757                content.rendered,
1758                CowStr::Boxed("1-2".to_string().into_boxed_str())
1759            );
1760        }
1761
1762        #[test]
1763        fn counter2_directive_advances_silently() {
1764            let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1765            let p = Parser::default();
1766            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1767            assert_eq!(
1768                content.rendered,
1769                CowStr::Boxed("2".to_string().into_boxed_str())
1770            );
1771        }
1772
1773        #[test]
1774        fn escaped_counter_directive_is_literal_and_does_not_advance() {
1775            let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1776            let p = Parser::default();
1777            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1778            assert_eq!(
1779                content.rendered,
1780                CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1781            );
1782        }
1783
1784        #[test]
1785        fn escaped_reference_with_both_braces_escaped_drops_backslashes() {
1786            // `\{name\}` escapes the reference the same way `\{name}` does: both
1787            // backslashes are removed and the reference is left unexpanded, even
1788            // when the attribute is set. This is the form Asciidoctor produces
1789            // for `\{group-id\}`.
1790            let p = Parser::default().with_intrinsic_attribute(
1791                "group-id",
1792                "42",
1793                crate::parser::ModificationContext::Anywhere,
1794            );
1795
1796            let mut content = Content::from(crate::Span::new("\\{group-id\\}"));
1797            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1798            assert_eq!(
1799                content.rendered,
1800                CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1801            );
1802        }
1803
1804        #[test]
1805        fn escaped_reference_with_only_trailing_brace_escaped_drops_backslash() {
1806            // A backslash before the closing brace alone (`{name\}`) also escapes
1807            // the reference, matching Asciidoctor's trailing-backslash capture.
1808            let p = Parser::default().with_intrinsic_attribute(
1809                "group-id",
1810                "42",
1811                crate::parser::ModificationContext::Anywhere,
1812            );
1813
1814            let mut content = Content::from(crate::Span::new("{group-id\\}"));
1815            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1816            assert_eq!(
1817                content.rendered,
1818                CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1819            );
1820        }
1821
1822        #[test]
1823        fn escaped_counter_with_trailing_backslash_is_literal_and_does_not_advance() {
1824            // A trailing escape backslash on a counter directive (`{counter:n\}`)
1825            // emits the reference literally (without the backslash) and does not
1826            // advance the counter, so the following unescaped reference is `1`.
1827            let mut content = Content::from(crate::Span::new("{counter:n\\} {counter:n}"));
1828            let p = Parser::default();
1829            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1830            assert_eq!(
1831                content.rendered,
1832                CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1833            );
1834        }
1835
1836        mod attribute_missing {
1837            #![allow(clippy::indexing_slicing)]
1838
1839            use crate::{
1840                Span,
1841                content::{Content, SubstitutionGroup, SubstitutionStep},
1842                parser::ModificationContext,
1843                tests::prelude::*,
1844                warnings::WarningType,
1845            };
1846
1847            fn parser_with_mode(mode: &str) -> Parser {
1848                Parser::default().with_intrinsic_attribute(
1849                    "attribute-missing",
1850                    mode,
1851                    ModificationContext::Anywhere,
1852                )
1853            }
1854
1855            fn render(text: &str, parser: &Parser) -> String {
1856                let mut content = Content::from(crate::Span::new(text));
1857                SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1858                content.rendered.to_string()
1859            }
1860
1861            /// Builds a `Content` that carries the per-line source spans (as a
1862            /// real block would), so the precise `warn`-location correlation is
1863            /// exercised. Each line's span is a subrange of the root, mirroring
1864            /// what block construction retains via
1865            /// [`Content::from_filtered_lines`].
1866            fn content_with_source_lines(text: &'static str) -> Content<'static> {
1867                let root = Span::new(text);
1868                let lines: Vec<&str> = text.split('\n').collect();
1869
1870                let mut spans = Vec::with_capacity(lines.len());
1871                let mut offset = 0;
1872                for line in &lines {
1873                    spans.push(root.slice(offset..offset + line.len()));
1874
1875                    // Advance past the line and the '\n' that split consumed.
1876                    offset += line.len() + 1;
1877                }
1878
1879                Content::from_filtered_lines(root, &lines, spans)
1880            }
1881
1882            /// Asserts that `warning`'s recorded offset/length select exactly
1883            /// `expected` out of `text`, i.e. the warning points at that
1884            /// precise reference in the original source.
1885            fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1886                assert_eq!(
1887                    &text[warning.offset..warning.offset + warning.len],
1888                    expected
1889                );
1890            }
1891
1892            #[test]
1893            fn skip_is_default() {
1894                let p = Parser::default();
1895                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1896                assert!(p.take_substitution_warnings().is_empty());
1897            }
1898
1899            #[test]
1900            fn skip_explicit() {
1901                let p = parser_with_mode("skip");
1902                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1903            }
1904
1905            #[test]
1906            fn unknown_value_falls_back_to_skip() {
1907                let p = parser_with_mode("bogus");
1908                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1909            }
1910
1911            #[test]
1912            fn drop_removes_only_the_reference() {
1913                let p = parser_with_mode("drop");
1914                assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1915            }
1916
1917            #[test]
1918            fn drop_keeps_resolvable_references() {
1919                let p = parser_with_mode("drop");
1920                assert_eq!(render("a {sp}b {missing} c", &p), "a  b  c");
1921            }
1922
1923            #[test]
1924            fn drop_removes_line_that_only_contained_the_reference() {
1925                // A line consisting solely of an unresolved reference is
1926                // dropped entirely, not left as a blank line (issue #730).
1927                let p = parser_with_mode("drop");
1928                assert_eq!(render("Line 1\n{missing}\nLine 2", &p), "Line 1\nLine 2");
1929            }
1930
1931            #[test]
1932            fn drop_keeps_a_line_the_reference_did_not_empty() {
1933                // The line still has other content after the reference is
1934                // dropped, so it survives (only the reference is removed).
1935                let p = parser_with_mode("drop");
1936                assert_eq!(
1937                    render("Line 1\ntext {missing}\nLine 2", &p),
1938                    "Line 1\ntext \nLine 2"
1939                );
1940            }
1941
1942            #[test]
1943            fn drop_removes_a_leading_or_trailing_reference_only_line() {
1944                let p = parser_with_mode("drop");
1945                assert_eq!(render("{missing}\nLine 2", &p), "Line 2");
1946                assert_eq!(render("Line 1\n{missing}", &p), "Line 1");
1947            }
1948
1949            #[test]
1950            fn drop_can_empty_the_content() {
1951                // A single line that is only an unresolved reference drops to
1952                // empty content, mirroring `drop-line`.
1953                let p = parser_with_mode("drop");
1954                assert_eq!(render("{missing}", &p), "");
1955            }
1956
1957            #[test]
1958            fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1959                // The line becomes empty, but not because a *missing* reference
1960                // was dropped, so it is retained.
1961                let p = parser_with_mode("drop");
1962                assert_eq!(render("Line 1\n{empty}\nLine 2", &p), "Line 1\n\nLine 2");
1963            }
1964
1965            #[test]
1966            fn drop_line_removes_the_whole_line() {
1967                let p = parser_with_mode("drop-line");
1968                assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1969            }
1970
1971            #[test]
1972            fn drop_line_only_drops_lines_with_a_missing_reference() {
1973                let p = parser_with_mode("drop-line");
1974                assert_eq!(
1975                    render("first {sp}line\nsecond {missing} line\nthird line", &p),
1976                    "first  line\nthird line"
1977                );
1978            }
1979
1980            #[test]
1981            fn drop_line_can_empty_the_content() {
1982                let p = parser_with_mode("drop-line");
1983                assert_eq!(render("{missing}", &p), "");
1984            }
1985
1986            #[test]
1987            fn drop_line_records_a_warning_for_the_dropped_reference() {
1988                // Dropping the line is silent in Asciidoctor's output, but it
1989                // logs an `INFO` diagnostic naming the missing attribute; the
1990                // parser records the matching warning.
1991                let p = parser_with_mode("drop-line");
1992                assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1993
1994                let warnings = p.take_substitution_warnings();
1995                assert_eq!(warnings.len(), 1);
1996                assert_eq!(
1997                    warnings[0].warning,
1998                    WarningType::SkippingReferenceToMissingAttribute("name".to_string())
1999                );
2000            }
2001
2002            #[test]
2003            fn drop_line_records_one_warning_per_missing_reference() {
2004                // Two missing references on the same dropped line each produce a
2005                // diagnostic, matching Asciidoctor's per-reference logging.
2006                let p = parser_with_mode("drop-line");
2007                assert_eq!(render("a {x} b {y} c\ntail", &p), "tail");
2008                assert_eq!(p.take_substitution_warnings().len(), 2);
2009            }
2010
2011            #[test]
2012            fn drop_line_does_not_warn_for_a_line_without_a_missing_reference() {
2013                // Only the line carrying the missing reference is dropped and
2014                // warned about; a line whose references all resolve is untouched.
2015                let p = parser_with_mode("drop-line");
2016                assert_eq!(
2017                    render("first {sp}line\nsecond {missing} line\nthird line", &p),
2018                    "first  line\nthird line"
2019                );
2020
2021                let warnings = p.take_substitution_warnings();
2022                assert_eq!(warnings.len(), 1);
2023                assert_eq!(
2024                    warnings[0].warning,
2025                    WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2026                );
2027            }
2028
2029            #[test]
2030            fn drop_line_points_at_the_precise_reference() {
2031                // With per-line source spans retained, the drop-line diagnostic
2032                // names the exact offending reference rather than the whole line.
2033                let p = parser_with_mode("drop-line");
2034                let text = "first {alpha} line\nsecond {beta} line";
2035                let mut content = content_with_source_lines(text);
2036                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2037
2038                let warnings = p.take_substitution_warnings();
2039                assert_eq!(warnings.len(), 2);
2040                assert_spans(&warnings[0], text, "{alpha}");
2041                assert_spans(&warnings[1], text, "{beta}");
2042            }
2043
2044            #[test]
2045            fn drop_line_falls_back_to_whole_span_without_source_lines() {
2046                // `Content::from` retains no per-line spans, so the drop-line
2047                // diagnostic degrades to the whole-content span rather than
2048                // misreporting a location.
2049                let p = parser_with_mode("drop-line");
2050                let text = "x {foo} y";
2051                let mut content = Content::from(Span::new(text));
2052                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2053
2054                let warnings = p.take_substitution_warnings();
2055                assert_eq!(warnings.len(), 1);
2056                assert_eq!(warnings[0].offset, 0);
2057                assert_eq!(warnings[0].len, text.len());
2058            }
2059
2060            /// Exercises the free-standing text path (used for docinfo file
2061            /// content), which applies the same `attribute-missing` handling as
2062            /// [`render`] but through [`substitute_attributes_in_text`] rather
2063            /// than the block substitution pipeline.
2064            mod free_standing_text {
2065                use super::parser_with_mode;
2066                use crate::content::substitute_attributes_in_text;
2067
2068                #[test]
2069                fn drop_removes_line_that_only_contained_the_reference() {
2070                    let p = parser_with_mode("drop");
2071                    assert_eq!(
2072                        substitute_attributes_in_text("Line 1\n{missing}\nLine 2", &p),
2073                        "Line 1\nLine 2"
2074                    );
2075                }
2076
2077                #[test]
2078                fn drop_keeps_a_line_the_reference_did_not_empty() {
2079                    let p = parser_with_mode("drop");
2080                    assert_eq!(
2081                        substitute_attributes_in_text("Line 1\ntext {missing}\nLine 2", &p),
2082                        "Line 1\ntext \nLine 2"
2083                    );
2084                }
2085
2086                #[test]
2087                fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
2088                    let p = parser_with_mode("drop");
2089                    assert_eq!(
2090                        substitute_attributes_in_text("Line 1\n{empty}\nLine 2", &p),
2091                        "Line 1\n\nLine 2"
2092                    );
2093                }
2094
2095                #[test]
2096                fn drop_line_removes_the_whole_line() {
2097                    let p = parser_with_mode("drop-line");
2098                    assert_eq!(
2099                        substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2100                        "Line 1\nLine 2"
2101                    );
2102                }
2103
2104                #[test]
2105                fn drop_line_records_a_warning() {
2106                    // The diagnostic is recorded on the parser even on the
2107                    // free-standing text path; a docinfo caller separately
2108                    // discards it via `truncate_substitution_warnings`.
2109                    use crate::warnings::WarningType;
2110
2111                    let p = parser_with_mode("drop-line");
2112                    assert_eq!(
2113                        substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2114                        "Line 1\nLine 2"
2115                    );
2116
2117                    let warnings = p.take_substitution_warnings();
2118                    assert_eq!(warnings.len(), 1);
2119                    assert_eq!(
2120                        warnings[0].warning,
2121                        WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2122                    );
2123                }
2124
2125                #[test]
2126                fn drop_removes_a_crlf_reference_only_line() {
2127                    // The `\r` left by a CRLF terminator does not keep the line
2128                    // from counting as emptied by the dropped reference, so the
2129                    // whole `\r\n` line is removed.
2130                    let p = parser_with_mode("drop");
2131                    assert_eq!(
2132                        substitute_attributes_in_text("Line 1\r\n{missing}\r\nLine 2", &p),
2133                        "Line 1\r\nLine 2"
2134                    );
2135                }
2136
2137                #[test]
2138                fn drop_keeps_a_crlf_line_the_reference_did_not_empty() {
2139                    let p = parser_with_mode("drop");
2140                    assert_eq!(
2141                        substitute_attributes_in_text("Line 1\r\ntext {missing}\r\nLine 2", &p),
2142                        "Line 1\r\ntext \r\nLine 2"
2143                    );
2144                }
2145            }
2146
2147            #[test]
2148            fn warn_leaves_the_reference_and_records_a_warning() {
2149                let p = parser_with_mode("warn");
2150                assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
2151
2152                let warnings = p.take_substitution_warnings();
2153                assert_eq!(warnings.len(), 1);
2154                assert_eq!(
2155                    warnings[0].warning,
2156                    WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2157                );
2158            }
2159
2160            #[test]
2161            fn warn_records_one_warning_per_missing_reference() {
2162                let p = parser_with_mode("warn");
2163                assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
2164                assert_eq!(p.take_substitution_warnings().len(), 2);
2165            }
2166
2167            #[test]
2168            fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
2169                // An escaped reference has its backslash removed and is passed
2170                // through literally; it is never treated as a missing reference,
2171                // so even under `drop-line` the line survives and no warning is
2172                // recorded.
2173                let p = parser_with_mode("drop-line");
2174                assert_eq!(
2175                    render("In the path /items/\\{id}, x.", &p),
2176                    "In the path /items/{id}, x."
2177                );
2178                assert!(p.take_substitution_warnings().is_empty());
2179            }
2180
2181            // The tests below use `content_with_source_lines` so the precise
2182            // per-reference `warn` location (issue #564) is exercised; the
2183            // `render`-based tests above go through `Content::from`, which
2184            // retains no source lines and so falls back to the whole-content
2185            // span.
2186
2187            #[test]
2188            fn warn_points_at_the_precise_reference() {
2189                let p = parser_with_mode("warn");
2190                let text = "Hello, {name}!";
2191                let mut content = content_with_source_lines(text);
2192                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2193
2194                let warnings = p.take_substitution_warnings();
2195                assert_eq!(warnings.len(), 1);
2196                assert_spans(&warnings[0], text, "{name}");
2197            }
2198
2199            #[test]
2200            fn warn_locates_multiple_references_on_one_line() {
2201                let p = parser_with_mode("warn");
2202                let text = "a {x} b {y} c";
2203                let mut content = content_with_source_lines(text);
2204                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2205
2206                let warnings = p.take_substitution_warnings();
2207                assert_eq!(warnings.len(), 2);
2208                assert_spans(&warnings[0], text, "{x}");
2209                assert_spans(&warnings[1], text, "{y}");
2210
2211                // The two references must resolve to distinct offsets.
2212                assert_ne!(warnings[0].offset, warnings[1].offset);
2213            }
2214
2215            #[test]
2216            fn warn_locates_references_across_multiple_lines() {
2217                // The acceptance case from issue #564: several distinct
2218                // references on different lines of one block, each pointed at
2219                // individually rather than at the shared whole-block span.
2220                let p = parser_with_mode("warn");
2221                let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
2222                let mut content = content_with_source_lines(text);
2223                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2224
2225                let warnings = p.take_substitution_warnings();
2226                assert_eq!(warnings.len(), 3);
2227                assert_spans(&warnings[0], text, "{alpha}");
2228                assert_spans(&warnings[1], text, "{beta}");
2229                assert_spans(&warnings[2], text, "{gamma}");
2230            }
2231
2232            #[test]
2233            fn warn_distinguishes_repeated_reference_occurrences() {
2234                let p = parser_with_mode("warn");
2235                let text = "{dup} and again {dup}";
2236                let mut content = content_with_source_lines(text);
2237                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2238
2239                let warnings = p.take_substitution_warnings();
2240                assert_eq!(warnings.len(), 2);
2241                assert_spans(&warnings[0], text, "{dup}");
2242                assert_spans(&warnings[1], text, "{dup}");
2243
2244                // Same text, but the two occurrences are at different offsets.
2245                assert_eq!(warnings[0].offset, 0);
2246                assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
2247            }
2248
2249            #[test]
2250            fn warn_span_survives_earlier_special_character_expansion() {
2251                // The key regression guard: special characters run before the
2252                // attributes step and lengthen the rendered text (`<` -> `&lt;`),
2253                // so a naive rendered-offset would be wrong. The warning must
2254                // still name the reference's *original* source offset.
2255                let p = parser_with_mode("warn");
2256                let text = "a < b {foo} c";
2257                let mut content = content_with_source_lines(text);
2258                SubstitutionGroup::Normal.apply(&mut content, &p, None);
2259
2260                // Sanity check that the earlier step really did shift offsets.
2261                assert!(content.rendered().contains("&lt;"));
2262
2263                let warnings = p.take_substitution_warnings();
2264                assert_eq!(warnings.len(), 1);
2265                assert_spans(&warnings[0], text, "{foo}");
2266                assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2267            }
2268
2269            #[test]
2270            fn warn_span_survives_earlier_quote_expansion() {
2271                // Same guard as above, but for the quotes step, which wraps
2272                // `*bold*` in markup ahead of the attributes step.
2273                let p = parser_with_mode("warn");
2274                let text = "*bold* {foo}";
2275                let mut content = content_with_source_lines(text);
2276                SubstitutionGroup::Normal.apply(&mut content, &p, None);
2277
2278                assert!(content.rendered().contains("<strong>"));
2279
2280                let warnings = p.take_substitution_warnings();
2281                assert_eq!(warnings.len(), 1);
2282                assert_spans(&warnings[0], text, "{foo}");
2283                assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2284            }
2285
2286            #[test]
2287            fn warn_falls_back_to_whole_span_without_source_lines() {
2288                // `Content::from` retains no per-line spans, so the warning
2289                // degrades to the whole-content span (the pre-#564 behavior)
2290                // rather than misreporting a location.
2291                let p = parser_with_mode("warn");
2292                let text = "x {foo} y";
2293                let mut content = Content::from(Span::new(text));
2294                SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2295
2296                let warnings = p.take_substitution_warnings();
2297                assert_eq!(warnings.len(), 1);
2298                assert_eq!(warnings[0].offset, 0);
2299                assert_eq!(warnings[0].len, text.len());
2300            }
2301        }
2302    }
2303
2304    mod callouts {
2305        use crate::{
2306            content::{Content, SubstitutionStep},
2307            parser::ModificationContext,
2308            strings::CowStr,
2309            tests::prelude::*,
2310        };
2311
2312        /// Builds a `Content` whose `rendered` text is `text` (as if special
2313        /// characters had already been substituted), applies the callouts step,
2314        /// and returns the resulting rendered text.
2315        fn render_callouts(text: &str, parser: &Parser) -> String {
2316            let mut content = Content::from(crate::Span::new(text));
2317
2318            // `Content::from` copies the source verbatim into `rendered`, which
2319            // is exactly the post-special-characters state we want to exercise.
2320            SubstitutionStep::Callouts.apply(&mut content, parser, None);
2321            content.rendered.to_string()
2322        }
2323
2324        #[test]
2325        fn empty() {
2326            let mut content = Content::from(crate::Span::default());
2327            let p = Parser::default();
2328            SubstitutionStep::Callouts.apply(&mut content, &p, None);
2329            assert!(content.is_empty());
2330            assert_eq!(content.rendered, CowStr::Borrowed(""));
2331        }
2332
2333        #[test]
2334        fn no_callouts() {
2335            let p = Parser::default();
2336            assert_eq!(render_callouts("just some text", &p), "just some text");
2337        }
2338
2339        #[test]
2340        fn lt_without_callout_is_untouched() {
2341            let p = Parser::default();
2342            assert_eq!(render_callouts("a &lt;b&gt; c", &p), "a &lt;b&gt; c");
2343        }
2344
2345        #[test]
2346        fn basic_explicit() {
2347            let p = Parser::default();
2348            assert_eq!(
2349                render_callouts("require 'x' &lt;1&gt;", &p),
2350                r#"require 'x' <b class="conum">(1)</b>"#
2351            );
2352        }
2353
2354        #[test]
2355        fn line_comment_prefix_preserved() {
2356            let p = Parser::default();
2357            assert_eq!(
2358                render_callouts("puts 'x' # &lt;1&gt;", &p),
2359                r#"puts 'x' # <b class="conum">(1)</b>"#
2360            );
2361        }
2362
2363        #[test]
2364        fn multiple_on_one_line() {
2365            let p = Parser::default();
2366            assert_eq!(
2367                render_callouts("puts x &lt;5&gt;&lt;6&gt;", &p),
2368                r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
2369            );
2370        }
2371
2372        #[test]
2373        fn not_at_end_of_line() {
2374            let p = Parser::default();
2375            assert_eq!(
2376                render_callouts("puts \"&lt;1&gt; in the middle\"", &p),
2377                "puts \"&lt;1&gt; in the middle\""
2378            );
2379        }
2380
2381        #[test]
2382        fn auto_numbering() {
2383            let p = Parser::default();
2384            assert_eq!(
2385                render_callouts("a &lt;.&gt;\nb &lt;.&gt;\nc &lt;.&gt;", &p),
2386                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
2387            );
2388        }
2389
2390        #[test]
2391        fn mixed_numbering_ignores_explicit() {
2392            // Auto-numbering is not aware of explicit numbers.
2393            let p = Parser::default();
2394            assert_eq!(
2395                render_callouts("a &lt;.&gt;\nb &lt;1&gt;\nc &lt;.&gt;", &p),
2396                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
2397            );
2398        }
2399
2400        #[test]
2401        fn xml_callout() {
2402            let p = Parser::default();
2403            assert_eq!(
2404                render_callouts("&lt;child/&gt; &lt;!--1--&gt;", &p),
2405                r#"&lt;child/&gt; &lt;!--<b class="conum">(1)</b>--&gt;"#
2406            );
2407        }
2408
2409        #[test]
2410        fn half_xml_comment_is_not_a_callout() {
2411            let p = Parser::default();
2412            assert_eq!(
2413                render_callouts("First line &lt;1--&gt;", &p),
2414                "First line &lt;1--&gt;"
2415            );
2416        }
2417
2418        #[test]
2419        fn escaped_callout() {
2420            let p = Parser::default();
2421            assert_eq!(
2422                render_callouts("require 'x' # \\&lt;1&gt;", &p),
2423                "require 'x' # &lt;1&gt;"
2424            );
2425        }
2426
2427        #[test]
2428        fn icons_font() {
2429            let p = Parser::default().with_intrinsic_attribute(
2430                "icons",
2431                "font",
2432                ModificationContext::Anywhere,
2433            );
2434            assert_eq!(
2435                render_callouts("puts x # &lt;1&gt;", &p),
2436                r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
2437            );
2438        }
2439
2440        #[test]
2441        fn icons_image() {
2442            let p = Parser::default().with_intrinsic_attribute(
2443                "icons",
2444                "",
2445                ModificationContext::Anywhere,
2446            );
2447            assert_eq!(
2448                render_callouts("puts x &lt;1&gt;", &p),
2449                r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2450            );
2451        }
2452
2453        #[test]
2454        fn custom_line_comment_prefix() {
2455            // `line-comment=%` (Erlang). Only `%` is recognized as a prefix.
2456            let mut content = Content::from(crate::Span::new("hello() -> % &lt;1&gt;"));
2457            let attrlist = crate::attributes::Attrlist::parse(
2458                crate::Span::new("source,erlang,line-comment=%"),
2459                &Parser::default(),
2460                crate::attributes::AttrlistContext::Block,
2461            )
2462            .item
2463            .item;
2464            let p = Parser::default();
2465            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2466            assert_eq!(
2467                content.rendered.to_string(),
2468                r#"hello() -> % <b class="conum">(1)</b>"#
2469            );
2470        }
2471
2472        #[test]
2473        fn disabled_line_comment_preserves_leading_chars() {
2474            // `line-comment=` (empty) disables prefix recognition, so the `--`
2475            // before the callout is preserved verbatim.
2476            let mut content = Content::from(crate::Span::new("-- &lt;1&gt;"));
2477            let attrlist = crate::attributes::Attrlist::parse(
2478                crate::Span::new("source,asciidoc,line-comment="),
2479                &Parser::default(),
2480                crate::attributes::AttrlistContext::Block,
2481            )
2482            .item
2483            .item;
2484            let p = Parser::default();
2485            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2486            assert_eq!(
2487                content.rendered.to_string(),
2488                r#"-- <b class="conum">(1)</b>"#
2489            );
2490        }
2491
2492        #[test]
2493        fn document_line_comment_attribute() {
2494            // The `line-comment` attribute can be set at the document level
2495            // (here, with no block attrlist), and is honored as a fallback.
2496            let p = Parser::default().with_intrinsic_attribute(
2497                "line-comment",
2498                "%",
2499                ModificationContext::Anywhere,
2500            );
2501            assert_eq!(
2502                render_callouts("hello() -> % &lt;1&gt;", &p),
2503                r#"hello() -> % <b class="conum">(1)</b>"#
2504            );
2505        }
2506    }
2507}