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,
7    attributes::{Attrlist, AttrlistContext},
8    content::Content,
9    document::InterpretedValue,
10    internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
11    parser::{
12        CalloutGuard, CalloutRenderParams, CharacterReplacementType, InlineSubstitutionRenderer,
13        QuoteScope, QuoteType, SpecialCharacter,
14    },
15};
16
17/// Each substitution type replaces characters, markup, attribute references,
18/// and macros in text with the appropriate output for a given converter. When a
19/// document is processed, up to six substitution types may be carried out
20/// depending on the block or inline element’s assigned substitution group. The
21/// processor runs the substitutions in the following order:
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum SubstitutionStep {
24    /// Searches for three characters (`<`, `>`, `&`) and replaces them with
25    /// their named character references.
26    SpecialCharacters,
27
28    /// Replacement of formatting markup on inline elements.
29    Quotes,
30
31    /// Replacement of attribute references by the values they reference.
32    AttributeReferences,
33
34    /// Replaces textual characters such as marks, arrows, and dashes and
35    /// replaces them with the decimal format of their Unicode code point, i.e.,
36    /// a numeric character reference.
37    CharacterReplacements,
38
39    /// Replaces a macro’s content with the appropriate built-in and
40    /// user-defined configuration.
41    Macros,
42
43    /// Replaces the line break character, `+` with a line-end marker.
44    PostReplacement,
45
46    /// Processes callouts in literal, listing, and source blocks.
47    Callouts,
48}
49
50impl SubstitutionStep {
51    pub(crate) fn apply(
52        &self,
53        content: &mut Content<'_>,
54        parser: &Parser,
55        attrlist: Option<&Attrlist<'_>>,
56    ) {
57        match self {
58            Self::SpecialCharacters => {
59                apply_special_characters(content, &*parser.renderer);
60            }
61            Self::Quotes => {
62                apply_quotes(content, parser);
63            }
64            Self::AttributeReferences => {
65                apply_attributes(content, parser);
66            }
67            Self::CharacterReplacements => {
68                apply_character_replacements(content, &*parser.renderer);
69            }
70            Self::Macros => {
71                super::macros::apply_macros(content, parser);
72            }
73            Self::PostReplacement => {
74                apply_post_replacements(content, parser, attrlist);
75            }
76            Self::Callouts => {
77                apply_callouts(content, parser, attrlist);
78            }
79        }
80    }
81}
82
83fn apply_special_characters(content: &mut Content<'_>, renderer: &dyn InlineSubstitutionRenderer) {
84    if !content.rendered.contains(['<', '>', '&']) {
85        return;
86    }
87
88    let mut result: Cow<'_, str> = content.rendered.to_string().into();
89    let replacer = SpecialCharacterReplacer { renderer };
90
91    if let Cow::Owned(new_result) = SPECIAL_CHARS.replace_all(&result, replacer) {
92        result = new_result.into();
93    }
94
95    content.rendered = result.into();
96}
97
98static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
99    #[allow(clippy::unwrap_used)]
100    Regex::new("[<>&]").unwrap()
101});
102
103#[derive(Debug)]
104struct SpecialCharacterReplacer<'r> {
105    renderer: &'r dyn InlineSubstitutionRenderer,
106}
107
108impl Replacer for SpecialCharacterReplacer<'_> {
109    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
110        // The SPECIAL_CHARS regex only matches '<', '>', and '&'. This sequence is
111        // specifically constructed to avoid having any unreachable code.
112        let ch = &caps[0];
113
114        if ch == "<" {
115            self.renderer
116                .render_special_character(SpecialCharacter::Lt, dest);
117        } else if ch == ">" {
118            self.renderer
119                .render_special_character(SpecialCharacter::Gt, dest);
120        } else if ch == "&" {
121            self.renderer
122                .render_special_character(SpecialCharacter::Ampersand, dest);
123        }
124
125        // No other cases _should_ occur, but if they do, we'll fail safely by
126        // not writing anything into dest.
127    }
128}
129
130static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
131    #[allow(clippy::unwrap_used)]
132    Regex::new("[*_`#^~]").unwrap()
133});
134
135struct QuoteSub {
136    type_: QuoteType,
137    scope: QuoteScope,
138    pattern: Regex,
139}
140
141// Adapted from QUOTE_SUBS in Ruby Asciidoctor implementation,
142// found in https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor.rb#L440.
143//
144// Translation notes:
145// * The `\m` modifier on Ruby regex means the `.` pattern *can* match a new
146//   line. We use the `.dot_matches_new_line(true)` option on `RegexBuilder` to
147//   implement this instead.
148// * The `(?!#{CG_WORD})` look-ahead syntax is not available in Rust regex. It
149//   looks like the `\b{end-half}` pattern can take its place. (This pattern
150//   requires that a non-word character or end of haystack follow the match
151//   point.)
152// * `#{CC_ALL}` just means any character (`.`).
153// * Replace `#{QuoteAttributeListRxt}` with `\\[([^\\[\\]]+)\\]`. (This seems
154//   preferable to having yet another level of backslash escaping.)
155//
156// Notes from the original Ruby implementation:
157// * Unconstrained quotes can appear anywhere.
158// * Constrained quotes must be bordered by non-word characters.
159// * NOTE: These substitutions are processed in the order they appear here and
160//   the order in which they are replaced is important.
161static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
162    vec![
163        QuoteSub {
164            // **strong**
165            type_: QuoteType::Strong,
166            scope: QuoteScope::Unconstrained,
167            #[allow(clippy::unwrap_used)]
168            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
169                .dot_matches_new_line(true)
170                .build()
171                .unwrap(),
172        },
173        QuoteSub {
174            // *strong*
175            type_: QuoteType::Strong,
176            scope: QuoteScope::Constrained,
177            #[allow(clippy::unwrap_used)]
178            pattern: RegexBuilder::new(
179                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
180            )
181            .dot_matches_new_line(true)
182            .build()
183            .unwrap(),
184        },
185        QuoteSub {
186            // "`double-quoted`"
187            type_: QuoteType::DoubleQuote,
188            scope: QuoteScope::Constrained,
189            #[allow(clippy::unwrap_used)]
190            pattern: RegexBuilder::new(
191                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
192            )
193            .dot_matches_new_line(true)
194            .build()
195            .unwrap(),
196        },
197        QuoteSub {
198            // '`single-quoted`'
199            type_: QuoteType::SingleQuote,
200            scope: QuoteScope::Constrained,
201            #[allow(clippy::unwrap_used)]
202            pattern: RegexBuilder::new(
203                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
204            )
205            .dot_matches_new_line(true)
206            .build()
207            .unwrap(),
208        },
209        QuoteSub {
210            // ``monospaced``
211            type_: QuoteType::Monospaced,
212            scope: QuoteScope::Unconstrained,
213            #[allow(clippy::unwrap_used)]
214            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
215                .dot_matches_new_line(true)
216                .build()
217                .unwrap(),
218        },
219        QuoteSub {
220            // `monospaced`
221            type_: QuoteType::Monospaced,
222            scope: QuoteScope::Constrained,
223            #[allow(clippy::unwrap_used)]
224            pattern: RegexBuilder::new(
225                r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
226                // NB: We don't have look-ahead in Rust Regex, so we might miss some edge cases
227                // because Ruby's version matches `(?![#{CC_WORD}"'`])` which is slightly more
228                // detailed than our `\b{end-half}`.
229            )
230            .dot_matches_new_line(true)
231            .build()
232            .unwrap(),
233        },
234        QuoteSub {
235            // __emphasis__
236            type_: QuoteType::Emphasis,
237            scope: QuoteScope::Unconstrained,
238            #[allow(clippy::unwrap_used)]
239            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
240                .dot_matches_new_line(true)
241                .build()
242                .unwrap(),
243        },
244        QuoteSub {
245            // _emphasis_
246            type_: QuoteType::Emphasis,
247            scope: QuoteScope::Constrained,
248            #[allow(clippy::unwrap_used)]
249            pattern: RegexBuilder::new(
250                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
251            )
252            .dot_matches_new_line(true)
253            .build()
254            .unwrap(),
255        },
256        QuoteSub {
257            // ##mark##
258            type_: QuoteType::Mark,
259            scope: QuoteScope::Unconstrained,
260            #[allow(clippy::unwrap_used)]
261            pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
262                .dot_matches_new_line(true)
263                .build()
264                .unwrap(),
265        },
266        QuoteSub {
267            // #mark#
268            type_: QuoteType::Mark,
269            scope: QuoteScope::Constrained,
270            #[allow(clippy::unwrap_used)]
271            pattern: RegexBuilder::new(
272                r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
273            )
274            .dot_matches_new_line(true)
275            .build()
276            .unwrap(),
277        },
278        QuoteSub {
279            // ^superscript^
280            type_: QuoteType::Superscript,
281            scope: QuoteScope::Unconstrained,
282            #[allow(clippy::unwrap_used)]
283            pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
284        },
285        QuoteSub {
286            // ~subscript~
287            type_: QuoteType::Subscript,
288            scope: QuoteScope::Unconstrained,
289            #[allow(clippy::unwrap_used)]
290            pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
291        },
292    ]
293});
294
295#[derive(Debug)]
296struct QuoteReplacer<'r> {
297    type_: QuoteType,
298    scope: QuoteScope,
299    parser: &'r Parser,
300}
301
302impl LookaheadReplacer for QuoteReplacer<'_> {
303    fn replace_append(
304        &mut self,
305        caps: &Captures<'_>,
306        dest: &mut String,
307        after: &str,
308    ) -> LookaheadResult {
309        // Adapted from Asciidoctor#convert_quoted_text, found in
310        // https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor/substitutors.rb#L1419-L1445.
311
312        // The regex crate doesn't have a sophisticated lookahead mode, so we patch
313        // it up here.
314
315        if self.type_ == QuoteType::Monospaced
316            && self.scope == QuoteScope::Constrained
317            && after.starts_with(['"', '\'', '`'])
318        {
319            let skip_ahead = if caps[0].starts_with('\\') { 2 } else { 1 };
320            dest.push_str(&caps[0][0..skip_ahead]);
321            return LookaheadResult::SkipAheadAndRetry(skip_ahead);
322        }
323
324        let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
325            let maybe_attrs = caps.get(2).map(|a| a.as_str());
326            if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
327                Some(format!(
328                    "[{attrs}]",
329                    attrs = maybe_attrs.unwrap_or_default()
330                ))
331            } else {
332                dest.push_str(&caps[0][1..]);
333                return LookaheadResult::Continue;
334            }
335        } else {
336            None
337        };
338
339        match self.scope {
340            QuoteScope::Constrained => {
341                if let Some(attrs) = unescaped_attrs {
342                    dest.push_str(&attrs);
343                    self.parser.renderer.render_quoted_substitition(
344                        self.type_, self.scope, None, None, &caps[3], dest,
345                    );
346                } else {
347                    let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
348                        if let Some(attrlist) = caps.get(2) {
349                            let type_ = if self.type_ == QuoteType::Mark {
350                                QuoteType::Unquoted
351                            } else {
352                                self.type_
353                            };
354
355                            (
356                                Some(
357                                    Attrlist::parse(
358                                        crate::Span::new(attrlist.as_str()),
359                                        self.parser,
360                                        AttrlistContext::Inline,
361                                    )
362                                    .item
363                                    .item,
364                                ),
365                                type_,
366                            )
367                        } else {
368                            (None, self.type_)
369                        };
370
371                    if let Some(prefix) = caps.get(1) {
372                        dest.push_str(prefix.as_str());
373                    }
374
375                    let id = attrlist
376                        .as_ref()
377                        .and_then(|a| a.id().map(|s| s.to_string()));
378
379                    self.parser.renderer.render_quoted_substitition(
380                        type_, self.scope, attrlist, id, &caps[3], dest,
381                    );
382                }
383            }
384
385            QuoteScope::Unconstrained => {
386                let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
387                    if let Some(attrlist) = caps.get(1) {
388                        let type_ = if self.type_ == QuoteType::Mark {
389                            QuoteType::Unquoted
390                        } else {
391                            self.type_
392                        };
393
394                        (
395                            Some(
396                                Attrlist::parse(
397                                    crate::Span::new(attrlist.as_str()),
398                                    self.parser,
399                                    AttrlistContext::Inline,
400                                )
401                                .item
402                                .item,
403                            ),
404                            type_,
405                        )
406                    } else {
407                        (None, self.type_)
408                    };
409
410                let id = attrlist
411                    .as_ref()
412                    .and_then(|a| a.id().map(|s| s.to_string()));
413
414                self.parser
415                    .renderer
416                    .render_quoted_substitition(type_, self.scope, attrlist, id, &caps[2], dest);
417            }
418        }
419
420        LookaheadResult::Continue
421    }
422}
423
424fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
425    if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
426        return;
427    }
428
429    let mut result: Cow<'_, str> = content.rendered.to_string().into();
430
431    for sub in &*QUOTE_SUBS {
432        let replacer = QuoteReplacer {
433            type_: sub.type_,
434            scope: sub.scope,
435            parser,
436        };
437
438        if let Cow::Owned(new_result) = replace_with_lookahead(&sub.pattern, &result, replacer) {
439            result = new_result.into();
440        }
441        // If it's Cow::Borrowed, there was no match for this pattern, so no
442        // need to pay for a new string allocation.
443    }
444
445    content.rendered = result.into();
446}
447
448static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
449    #[allow(clippy::unwrap_used)]
450    Regex::new(r#"\\?\{([A-Za-z0-9_][A-Za-z0-9_-]*)\}"#).unwrap()
451});
452
453#[derive(Debug)]
454struct AttributeReplacer<'p>(&'p Parser);
455
456impl Replacer for AttributeReplacer<'_> {
457    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
458        let attr_name = &caps[1];
459
460        // TO DO: Handle alternative responses ('skip', etc.) for missing attributes.
461        if !self.0.has_attribute(attr_name) {
462            dest.push_str(&caps[0]);
463            return;
464        }
465
466        if caps[0].starts_with('\\') {
467            dest.push_str(&caps[0][1..]);
468            return;
469        }
470
471        if let InterpretedValue::Value(value) = self.0.attribute_value(attr_name) {
472            dest.push_str(value.as_ref());
473        }
474        // Language description is unclear as to what happens for "set" and
475        // "unset" attribute values. For now, we'll replace those with nothing.
476    }
477}
478
479fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
480    if !content.rendered.contains('{') {
481        return;
482    }
483
484    let mut result: Cow<'_, str> = content.rendered.to_string().into();
485
486    if let Cow::Owned(new_result) =
487        ATTRIBUTE_REFERENCE.replace_all(&result, AttributeReplacer(parser))
488    {
489        result = new_result.into();
490    }
491    // If it's Cow::Borrowed, there was no match for this pattern, so no
492    // need to pay for a new string allocation.
493
494    content.rendered = result.into();
495}
496
497fn apply_character_replacements(
498    content: &mut Content<'_>,
499    renderer: &dyn InlineSubstitutionRenderer,
500) {
501    if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
502        return;
503    }
504
505    let mut result: Cow<'_, str> = content.rendered.to_string().into();
506
507    for repl in &*REPLACEMENTS {
508        let replacer = CharacterReplacer {
509            type_: repl.type_.clone(),
510            renderer,
511        };
512
513        if let Cow::Owned(new_result) = repl.pattern.replace_all(&result, replacer) {
514            result = new_result.into();
515        }
516        // If it's Cow::Borrowed, there was no match for this pattern, so no
517        // need to pay for a new string allocation.
518    }
519
520    content.rendered = result.into();
521}
522
523struct CharacterReplacement {
524    type_: CharacterReplacementType,
525    pattern: Regex,
526}
527
528static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
529    #[allow(clippy::unwrap_used)]
530    Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
531});
532
533// Adapted from REPLACEMENTS in Ruby Asciidoctor implementation,
534// found in https://github.com/asciidoctor/asciidoctor/blob/main/lib/asciidoctor.rb#L490.
535//
536// * NOTE: These substitutions are processed in the order they appear here and
537//   the order in which they are replaced is important.
538static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
539    vec![
540        CharacterReplacement {
541            // Copyright `(C)`
542            type_: CharacterReplacementType::Copyright,
543            #[allow(clippy::unwrap_used)]
544            pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
545        },
546        CharacterReplacement {
547            // Registered `(R)`
548            type_: CharacterReplacementType::Registered,
549            #[allow(clippy::unwrap_used)]
550            pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
551        },
552        CharacterReplacement {
553            // Trademark `(TM)`
554            type_: CharacterReplacementType::Trademark,
555            #[allow(clippy::unwrap_used)]
556            pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
557        },
558        CharacterReplacement {
559            // Em dash surrounded by spaces ` -- `
560            type_: CharacterReplacementType::EmDashSurroundedBySpaces,
561            #[allow(clippy::unwrap_used)]
562            pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
563        },
564        CharacterReplacement {
565            // Em dash without spaces `--`
566            type_: CharacterReplacementType::EmDashWithoutSpace,
567            #[allow(clippy::unwrap_used)]
568            pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
569        },
570        CharacterReplacement {
571            // Ellipsis `...`
572            type_: CharacterReplacementType::Ellipsis,
573            #[allow(clippy::unwrap_used)]
574            pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
575        },
576        CharacterReplacement {
577            // Right single quote `\`'`
578            type_: CharacterReplacementType::TypographicApostrophe,
579            #[allow(clippy::unwrap_used)]
580            pattern: Regex::new(r#"\\?`'"#).unwrap(),
581        },
582        CharacterReplacement {
583            // Apostrophe (inside a word)
584            type_: CharacterReplacementType::TypographicApostrophe,
585            #[allow(clippy::unwrap_used)]
586            pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
587        },
588        CharacterReplacement {
589            // Right arrow `->`
590            type_: CharacterReplacementType::SingleRightArrow,
591            #[allow(clippy::unwrap_used)]
592            pattern: Regex::new(r#"\\?-&gt;"#).unwrap(),
593        },
594        CharacterReplacement {
595            // Right double arrow `=>`
596            type_: CharacterReplacementType::DoubleRightArrow,
597            #[allow(clippy::unwrap_used)]
598            pattern: Regex::new(r#"\\?=&gt;"#).unwrap(),
599        },
600        CharacterReplacement {
601            // Left arrow `<-`
602            type_: CharacterReplacementType::SingleLeftArrow,
603            #[allow(clippy::unwrap_used)]
604            pattern: Regex::new(r#"\\?&lt;-"#).unwrap(),
605        },
606        CharacterReplacement {
607            // Left double arrow `<=`
608            type_: CharacterReplacementType::DoubleLeftArrow,
609            #[allow(clippy::unwrap_used)]
610            pattern: Regex::new(r#"\\?&lt;="#).unwrap(),
611        },
612        CharacterReplacement {
613            // Restore entities
614            type_: CharacterReplacementType::CharacterReference("".to_owned()),
615            #[allow(clippy::unwrap_used)]
616            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(),
617        },
618    ]
619});
620
621#[derive(Debug)]
622struct CharacterReplacer<'r> {
623    type_: CharacterReplacementType,
624    renderer: &'r dyn InlineSubstitutionRenderer,
625}
626
627impl Replacer for CharacterReplacer<'_> {
628    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
629        if caps[0].contains('\\') {
630            // We have to replace since we aren't sure the backslash is the first char.
631            let unescaped = &caps[0].replace("\\", "");
632            dest.push_str(unescaped);
633            return;
634        }
635
636        match self.type_ {
637            CharacterReplacementType::Copyright
638            | CharacterReplacementType::Registered
639            | CharacterReplacementType::Trademark
640            | CharacterReplacementType::EmDashSurroundedBySpaces
641            | CharacterReplacementType::Ellipsis
642            | CharacterReplacementType::SingleLeftArrow
643            | CharacterReplacementType::DoubleLeftArrow
644            | CharacterReplacementType::SingleRightArrow
645            | CharacterReplacementType::DoubleRightArrow => {
646                self.renderer
647                    .render_character_replacement(self.type_.clone(), dest);
648            }
649
650            CharacterReplacementType::EmDashWithoutSpace => {
651                dest.push_str(&caps[1]);
652                self.renderer.render_character_replacement(
653                    CharacterReplacementType::EmDashWithoutSpace,
654                    dest,
655                );
656            }
657
658            CharacterReplacementType::TypographicApostrophe => {
659                if let Some(before) = caps.get(1) {
660                    dest.push_str(before.as_str());
661                }
662
663                self.renderer.render_character_replacement(
664                    CharacterReplacementType::TypographicApostrophe,
665                    dest,
666                );
667
668                if let Some(after) = caps.get(2) {
669                    dest.push_str(after.as_str());
670                }
671            }
672
673            CharacterReplacementType::CharacterReference(_) => {
674                self.renderer.render_character_replacement(
675                    CharacterReplacementType::CharacterReference(caps[1].to_string()),
676                    dest,
677                );
678            }
679        }
680    }
681}
682
683fn apply_post_replacements(
684    content: &mut Content<'_>,
685    parser: &Parser,
686    attrlist: Option<&Attrlist<'_>>,
687) {
688    if parser.is_attribute_set("hardbreaks-option")
689        || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
690    {
691        let text = content.rendered.as_ref();
692        if !text.contains('\n') {
693            return;
694        }
695
696        let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
697        let last = lines.pop().unwrap_or_default();
698
699        let mut lines: Vec<String> = lines
700            .iter()
701            .map(|line| {
702                let line = if line.ends_with(" +") {
703                    &line[0..line.len() - 2]
704                } else {
705                    *line
706                };
707
708                let mut line = line.to_owned();
709                parser.renderer.render_line_break(&mut line);
710                line
711            })
712            .collect();
713
714        lines.push(last.to_owned());
715
716        let new_result = lines.join("\n");
717        content.rendered = new_result.into();
718    } else {
719        let rendered = content.rendered.as_ref();
720        if !(rendered.contains('+') && rendered.contains('\n')) {
721            return;
722        }
723
724        let replacer = PostReplacementReplacer(&*parser.renderer);
725
726        if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
727            content.rendered = new_result.into();
728        }
729    }
730}
731
732#[derive(Debug)]
733struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
734
735impl Replacer for PostReplacementReplacer<'_> {
736    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
737        dest.push_str(&caps[1]);
738        self.0.render_line_break(dest);
739    }
740}
741
742static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
743    #[allow(clippy::unwrap_used)]
744    Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
745});
746
747/// Processes [callouts] in literal, listing, and source blocks.
748///
749/// Callout numbers (`<1>`, `<.>`, or `<!--1-->` for XML) that appear at the end
750/// of a line are replaced with the renderer's callout markup. Callouts may be
751/// tucked behind a line comment (`//`, `#`, `--`, or `;;` by default, or a
752/// custom prefix specified by the `line-comment` attribute), and a callout may
753/// be escaped with a leading backslash to render it literally.
754///
755/// This substitution runs after [special characters] have been replaced, so the
756/// angle brackets that delimit a callout appear in `content.rendered` as
757/// `&lt;` and `&gt;`. This mirrors Asciidoctor's `sub_callouts` /
758/// `CalloutSourceRx`.
759///
760/// [callouts]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
761/// [special characters]: https://docs.asciidoctor.org/asciidoc/latest/subs/special-characters/
762fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
763    // A callout's opening bracket is always rendered as `&lt;` by the special
764    // characters substitution, so we can cheaply skip content without any.
765    if !content.rendered.contains("&lt;") {
766        return;
767    }
768
769    // The `line-comment` attribute (block-level, falling back to document-level)
770    // customizes or disables line-comment recognition:
771    //
772    // * absent -> default prefixes (`//`, `#`, `--`, `;;`) and XML callouts are
773    //   recognized.
774    // * present (custom) -> only the given prefix is recognized; XML callouts are
775    //   not.
776    // * present but empty -> no line-comment prefix is recognized; XML callouts are
777    //   not.
778    let line_comment: Option<String> = attrlist
779        .and_then(|a| a.named_attribute("line-comment"))
780        .map(|a| a.value().to_string())
781        .or_else(|| {
782            if parser.has_attribute("line-comment") {
783                Some(
784                    parser
785                        .attribute_value("line-comment")
786                        .as_maybe_str()
787                        .unwrap_or("")
788                        .to_string(),
789                )
790            } else {
791                None
792            }
793        });
794
795    let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
796
797    let replacer = CalloutReplacer {
798        renderer: &*parser.renderer,
799        parser,
800        autonum: 0,
801        tail: tail_rx,
802    };
803
804    if let Cow::Owned(new_result) =
805        replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
806    {
807        content.rendered = new_result.into();
808    }
809}
810
811/// Callout regex for the default `line-comment` mode: recognizes the common
812/// line-comment prefixes and XML callouts.
813static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
814    #[allow(clippy::unwrap_used)]
815    Regex::new(
816        r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:&lt;!--(?P<xnum>\d+|\.)--&gt;|&lt;(?P<num>\d+|\.)&gt;)",
817    )
818    .unwrap()
819});
820
821/// Trailing-position lookahead regex for the default `line-comment` mode.
822static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
823    #[allow(clippy::unwrap_used)]
824    Regex::new(r"^(?: ?\\?(?:&lt;!--(?:\d+|\.)--&gt;|&lt;(?:\d+|\.)&gt;))*(?:\n|$)").unwrap()
825});
826
827/// Trailing-position lookahead regex for a custom or empty `line-comment` mode
828/// (no XML callout form).
829static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
830    #[allow(clippy::unwrap_used)]
831    Regex::new(r"^(?: ?\\?&lt;(?:\d+|\.)&gt;)*(?:\n|$)").unwrap()
832});
833
834/// Builds the `(callout, tail)` regex pair for the given `line-comment` mode.
835///
836/// The `callout` regex matches a single callout token (with the optional
837/// line-comment prefix and escape that may precede it). The `tail` regex is
838/// used to emulate Asciidoctor's trailing-position lookahead: a matched callout
839/// is only honored when the remainder of its line consists solely of further
840/// callouts. Rust's regex engine supports neither lookahead nor backreferences,
841/// so the lookahead is applied manually against the post-match text.
842///
843/// The default-mode regexes and both tail regexes are constant, so they are
844/// built once. Only a custom (non-empty) prefix requires building a regex from
845/// the attribute value, which is borrowed otherwise.
846fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
847    match line_comment {
848        // Default: recognize the common line-comment prefixes and XML callouts.
849        None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
850
851        // A custom or empty `line-comment`: only the bare (non-XML) callout form
852        // is recognized, optionally behind the custom prefix.
853        Some(prefix) => {
854            let prefix_pattern = if prefix.is_empty() {
855                String::new()
856            } else {
857                format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
858            };
859
860            #[allow(clippy::unwrap_used)]
861            let callout = Regex::new(&format!(
862                r"{prefix_pattern}(?P<esc>\\)?&lt;(?P<num>\d+|\.)&gt;"
863            ))
864            .unwrap();
865
866            (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
867        }
868    }
869}
870
871/// Replacer that renders each trailing callout token, emulating Asciidoctor's
872/// `sub_callouts`.
873struct CalloutReplacer<'r> {
874    renderer: &'r dyn InlineSubstitutionRenderer,
875    parser: &'r Parser,
876
877    /// Running counter for automatically-numbered (`<.>`) callouts, scoped to a
878    /// single block.
879    autonum: u32,
880
881    /// Trailing-position lookahead regex (see [`build_callout_regexes`]).
882    tail: &'r Regex,
883}
884
885impl LookaheadReplacer for CalloutReplacer<'_> {
886    fn replace_append(
887        &mut self,
888        caps: &Captures<'_>,
889        dest: &mut String,
890        after: &str,
891    ) -> LookaheadResult {
892        // Honor the trailing-position requirement: a callout is only recognized
893        // when nothing but further callouts follows it on the line.
894        if !self.tail.is_match(after) {
895            dest.push_str(&caps[0]);
896            return LookaheadResult::Continue;
897        }
898
899        // Honor the escape: emit the matched text with the escaping backslash
900        // removed so the callout renders literally.
901        if caps.name("esc").is_some() {
902            dest.push_str(&caps[0].replacen('\\', "", 1));
903            return LookaheadResult::Continue;
904        }
905
906        let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
907            (xnum.as_str(), true)
908        } else {
909            // The regex guarantees one of `xnum` or `num` is present.
910            #[allow(clippy::unwrap_used)]
911            (caps.name("num").unwrap().as_str(), false)
912        };
913
914        let number = if number_raw == "." {
915            self.autonum += 1;
916            self.autonum.to_string()
917        } else {
918            number_raw.to_string()
919        };
920
921        // Register this callout so the callout list that annotates this block
922        // can be validated against the callouts it references.
923        if let Ok(n) = number.parse::<u32>() {
924            self.parser.register_callout(n);
925        }
926
927        // Mirror Asciidoctor's guard resolution: a captured line-comment prefix
928        // takes precedence; otherwise an XML callout uses the XML guard; failing
929        // both, there is no guard.
930        let guard = match caps.name("prefix") {
931            Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
932            None if is_xml => CalloutGuard::Xml,
933            None => CalloutGuard::LineComment(""),
934        };
935
936        self.renderer.render_callout(
937            &CalloutRenderParams {
938                number: &number,
939                guard,
940                parser: self.parser,
941            },
942            dest,
943        );
944
945        LookaheadResult::Continue
946    }
947}
948
949#[cfg(test)]
950mod tests {
951    #![allow(clippy::unwrap_used)]
952
953    mod special_characters {
954        use crate::{
955            content::{Content, SubstitutionStep},
956            strings::CowStr,
957            tests::prelude::*,
958        };
959
960        #[test]
961        fn empty() {
962            let mut content = Content::from(crate::Span::default());
963            let p = Parser::default();
964            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
965            assert!(content.is_empty());
966            assert_eq!(content.rendered, CowStr::Borrowed(""));
967        }
968
969        #[test]
970        fn basic_non_empty_span() {
971            let mut content = Content::from(crate::Span::new("blah"));
972            let p = Parser::default();
973            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
974            assert!(!content.is_empty());
975            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
976        }
977
978        #[test]
979        fn match_lt_and_gt() {
980            let mut content = Content::from(crate::Span::new("bl<ah>"));
981            let p = Parser::default();
982            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
983            assert!(!content.is_empty());
984            assert_eq!(
985                content.rendered,
986                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
987            );
988        }
989
990        #[test]
991        fn match_amp() {
992            let mut content = Content::from(crate::Span::new("bl<a&h>"));
993            let p = Parser::default();
994            SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
995            assert!(!content.is_empty());
996            assert_eq!(
997                content.rendered,
998                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
999            );
1000        }
1001    }
1002
1003    mod quotes {
1004        use crate::{
1005            content::{Content, SubstitutionStep},
1006            strings::CowStr,
1007            tests::prelude::*,
1008        };
1009
1010        #[test]
1011        fn empty() {
1012            let mut content = Content::from(crate::Span::default());
1013            let p = Parser::default();
1014            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1015            assert!(content.is_empty());
1016            assert_eq!(content.rendered, CowStr::Borrowed(""));
1017        }
1018
1019        #[test]
1020        fn basic_non_empty_span() {
1021            let mut content = Content::from(crate::Span::new("blah"));
1022            let p = Parser::default();
1023            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1024            assert!(!content.is_empty());
1025            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1026        }
1027
1028        #[test]
1029        fn ignore_lt_and_gt() {
1030            let mut content = Content::from(crate::Span::new("bl<ah>"));
1031            let p = Parser::default();
1032            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1033            assert!(!content.is_empty());
1034            assert_eq!(
1035                content.rendered,
1036                CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1037            );
1038        }
1039
1040        #[test]
1041        fn strong_word() {
1042            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1043            let p = Parser::default();
1044            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1045            assert!(!content.is_empty());
1046            assert_eq!(
1047                content.rendered,
1048                CowStr::Boxed(
1049                    "One <strong>word</strong> is strong."
1050                        .to_string()
1051                        .into_boxed_str()
1052                )
1053            );
1054        }
1055
1056        #[test]
1057        fn marked_string_with_id() {
1058            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1059            let p = Parser::default();
1060            SubstitutionStep::Quotes.apply(&mut content, &p, None);
1061            assert!(!content.is_empty());
1062            assert_eq!(
1063                content.rendered,
1064                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1065            );
1066        }
1067    }
1068
1069    mod attribute_references {
1070        use crate::{
1071            content::{Content, SubstitutionStep},
1072            strings::CowStr,
1073            tests::prelude::*,
1074        };
1075
1076        #[test]
1077        fn empty() {
1078            let mut content = Content::from(crate::Span::default());
1079            let p = Parser::default();
1080            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1081            assert!(content.is_empty());
1082            assert_eq!(content.rendered, CowStr::Borrowed(""));
1083        }
1084
1085        #[test]
1086        fn basic_non_empty_span() {
1087            let mut content = Content::from(crate::Span::new("blah"));
1088            let p = Parser::default();
1089            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1090            assert!(!content.is_empty());
1091            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1092        }
1093
1094        #[test]
1095        fn ignore_non_match() {
1096            let mut content = Content::from(crate::Span::new("bl{ah}"));
1097            let p = Parser::default();
1098            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1099            assert!(!content.is_empty());
1100            assert_eq!(
1101                content.rendered,
1102                CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1103            );
1104        }
1105
1106        #[test]
1107        fn ignore_escaped_non_match() {
1108            let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1109            let p = Parser::default();
1110            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1111            assert!(!content.is_empty());
1112            assert_eq!(
1113                content.rendered,
1114                CowStr::Boxed("bl\\{ah}".to_string().into_boxed_str())
1115            );
1116        }
1117
1118        #[test]
1119        fn replace_sp_match() {
1120            let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1121            let p = Parser::default();
1122            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1123            assert!(!content.is_empty());
1124            assert_eq!(
1125                content.rendered,
1126                CowStr::Boxed("bl ah".to_string().into_boxed_str())
1127            );
1128        }
1129
1130        #[test]
1131        fn ignore_escaped_sp_match() {
1132            let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1133            let p = Parser::default();
1134            SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1135            assert!(!content.is_empty());
1136            assert_eq!(
1137                content.rendered,
1138                CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1139            );
1140        }
1141    }
1142
1143    mod callouts {
1144        use crate::{
1145            content::{Content, SubstitutionStep},
1146            parser::ModificationContext,
1147            strings::CowStr,
1148            tests::prelude::*,
1149        };
1150
1151        /// Builds a `Content` whose `rendered` text is `text` (as if special
1152        /// characters had already been substituted), applies the callouts step,
1153        /// and returns the resulting rendered text.
1154        fn render_callouts(text: &str, parser: &Parser) -> String {
1155            let mut content = Content::from(crate::Span::new(text));
1156            // `Content::from` copies the source verbatim into `rendered`, which
1157            // is exactly the post-special-characters state we want to exercise.
1158            SubstitutionStep::Callouts.apply(&mut content, parser, None);
1159            content.rendered.to_string()
1160        }
1161
1162        #[test]
1163        fn empty() {
1164            let mut content = Content::from(crate::Span::default());
1165            let p = Parser::default();
1166            SubstitutionStep::Callouts.apply(&mut content, &p, None);
1167            assert!(content.is_empty());
1168            assert_eq!(content.rendered, CowStr::Borrowed(""));
1169        }
1170
1171        #[test]
1172        fn no_callouts() {
1173            let p = Parser::default();
1174            assert_eq!(render_callouts("just some text", &p), "just some text");
1175        }
1176
1177        #[test]
1178        fn lt_without_callout_is_untouched() {
1179            let p = Parser::default();
1180            assert_eq!(render_callouts("a &lt;b&gt; c", &p), "a &lt;b&gt; c");
1181        }
1182
1183        #[test]
1184        fn basic_explicit() {
1185            let p = Parser::default();
1186            assert_eq!(
1187                render_callouts("require 'x' &lt;1&gt;", &p),
1188                r#"require 'x' <b class="conum">(1)</b>"#
1189            );
1190        }
1191
1192        #[test]
1193        fn line_comment_prefix_preserved() {
1194            let p = Parser::default();
1195            assert_eq!(
1196                render_callouts("puts 'x' # &lt;1&gt;", &p),
1197                r#"puts 'x' # <b class="conum">(1)</b>"#
1198            );
1199        }
1200
1201        #[test]
1202        fn multiple_on_one_line() {
1203            let p = Parser::default();
1204            assert_eq!(
1205                render_callouts("puts x &lt;5&gt;&lt;6&gt;", &p),
1206                r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
1207            );
1208        }
1209
1210        #[test]
1211        fn not_at_end_of_line() {
1212            let p = Parser::default();
1213            assert_eq!(
1214                render_callouts("puts \"&lt;1&gt; in the middle\"", &p),
1215                "puts \"&lt;1&gt; in the middle\""
1216            );
1217        }
1218
1219        #[test]
1220        fn auto_numbering() {
1221            let p = Parser::default();
1222            assert_eq!(
1223                render_callouts("a &lt;.&gt;\nb &lt;.&gt;\nc &lt;.&gt;", &p),
1224                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
1225            );
1226        }
1227
1228        #[test]
1229        fn mixed_numbering_ignores_explicit() {
1230            // Auto-numbering is not aware of explicit numbers.
1231            let p = Parser::default();
1232            assert_eq!(
1233                render_callouts("a &lt;.&gt;\nb &lt;1&gt;\nc &lt;.&gt;", &p),
1234                "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
1235            );
1236        }
1237
1238        #[test]
1239        fn xml_callout() {
1240            let p = Parser::default();
1241            assert_eq!(
1242                render_callouts("&lt;child/&gt; &lt;!--1--&gt;", &p),
1243                r#"&lt;child/&gt; &lt;!--<b class="conum">(1)</b>--&gt;"#
1244            );
1245        }
1246
1247        #[test]
1248        fn half_xml_comment_is_not_a_callout() {
1249            let p = Parser::default();
1250            assert_eq!(
1251                render_callouts("First line &lt;1--&gt;", &p),
1252                "First line &lt;1--&gt;"
1253            );
1254        }
1255
1256        #[test]
1257        fn escaped_callout() {
1258            let p = Parser::default();
1259            assert_eq!(
1260                render_callouts("require 'x' # \\&lt;1&gt;", &p),
1261                "require 'x' # &lt;1&gt;"
1262            );
1263        }
1264
1265        #[test]
1266        fn icons_font() {
1267            let p = Parser::default().with_intrinsic_attribute(
1268                "icons",
1269                "font",
1270                ModificationContext::Anywhere,
1271            );
1272            assert_eq!(
1273                render_callouts("puts x # &lt;1&gt;", &p),
1274                r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
1275            );
1276        }
1277
1278        #[test]
1279        fn icons_image() {
1280            let p = Parser::default().with_intrinsic_attribute(
1281                "icons",
1282                "",
1283                ModificationContext::Anywhere,
1284            );
1285            assert_eq!(
1286                render_callouts("puts x &lt;1&gt;", &p),
1287                r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
1288            );
1289        }
1290
1291        #[test]
1292        fn custom_line_comment_prefix() {
1293            // line-comment=% (Erlang). Only `%` is recognized as a prefix.
1294            let mut content = Content::from(crate::Span::new("hello() -> % &lt;1&gt;"));
1295            let attrlist = crate::attributes::Attrlist::parse(
1296                crate::Span::new("source,erlang,line-comment=%"),
1297                &Parser::default(),
1298                crate::attributes::AttrlistContext::Block,
1299            )
1300            .item
1301            .item;
1302            let p = Parser::default();
1303            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
1304            assert_eq!(
1305                content.rendered.to_string(),
1306                r#"hello() -> % <b class="conum">(1)</b>"#
1307            );
1308        }
1309
1310        #[test]
1311        fn disabled_line_comment_preserves_leading_chars() {
1312            // line-comment= (empty) disables prefix recognition, so the `--`
1313            // before the callout is preserved verbatim.
1314            let mut content = Content::from(crate::Span::new("-- &lt;1&gt;"));
1315            let attrlist = crate::attributes::Attrlist::parse(
1316                crate::Span::new("source,asciidoc,line-comment="),
1317                &Parser::default(),
1318                crate::attributes::AttrlistContext::Block,
1319            )
1320            .item
1321            .item;
1322            let p = Parser::default();
1323            SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
1324            assert_eq!(
1325                content.rendered.to_string(),
1326                r#"-- <b class="conum">(1)</b>"#
1327            );
1328        }
1329
1330        #[test]
1331        fn document_line_comment_attribute() {
1332            // The `line-comment` attribute can be set at the document level
1333            // (here, with no block attrlist), and is honored as a fallback.
1334            let p = Parser::default().with_intrinsic_attribute(
1335                "line-comment",
1336                "%",
1337                ModificationContext::Anywhere,
1338            );
1339            assert_eq!(
1340                render_callouts("hello() -> % &lt;1&gt;", &p),
1341                r#"hello() -> % <b class="conum">(1)</b>"#
1342            );
1343        }
1344    }
1345}