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