Skip to main content

asciidoc_parser/content/
substitution_step.rs

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