Skip to main content

asciidoc_parser/content/
passthroughs.rs

1use std::{borrow::Cow, fmt::Write as _, sync::LazyLock};
2
3use regex::{Captures, Regex, Replacer};
4
5use crate::{
6    Parser, Span,
7    attributes::{Attrlist, AttrlistContext},
8    content::{Content, SubstitutionGroup, substitution_step::substitute_attributes_in_text},
9    parser::{QuoteScope, QuoteType},
10    warnings::WarningType,
11};
12
13/// Records one inline passthrough (`+++…+++`, `++…++`, `$$…$$`, `pass:[…]`, or
14/// an inline STEM macro) that the substitution pipeline extracted from a
15/// block's content before running the other substitutions, and restores
16/// afterward.
17///
18/// The collection of these for a block is observable via
19/// [`Content::passthroughs`](crate::content::Content::passthroughs), analogous
20/// to Asciidoctor's internal `@passthroughs` array. It exposes, for each
21/// entry, the stored (unescaped) source [`text`](Self::text) and the resolved
22/// [`subs`](Self::subs) that are applied to that text on restore.
23#[derive(Clone, Debug, Eq, Hash, PartialEq)]
24pub struct Passthrough {
25    pub(crate) text: String,
26    pub(crate) subs: SubstitutionGroup,
27    pub(crate) type_: Option<QuoteType>,
28    pub(crate) attrlist: Option<String>,
29}
30
31impl Passthrough {
32    /// Returns the stored, unescaped source text of this passthrough – the text
33    /// that is substituted back in (after applying [`subs`](Self::subs)) when
34    /// the passthrough is restored.
35    ///
36    /// This mirrors the `:text` of an entry in Asciidoctor's `@passthroughs`
37    /// array.
38    pub fn text(&self) -> &str {
39        &self.text
40    }
41
42    /// Returns the resolved substitution group applied to this passthrough's
43    /// [`text`](Self::text) when it is restored, i.e. the ordered set of
44    /// substitution steps in effect for it.
45    ///
46    /// For example, `+++…+++` resolves to [`SubstitutionGroup::None`] (no
47    /// substitutions), while `++…++` and `$$…$$` resolve to
48    /// [`SubstitutionGroup::Verbatim`] (special characters). This mirrors the
49    /// resolved `:subs` of an entry in Asciidoctor's `@passthroughs` array.
50    pub fn subs(&self) -> &SubstitutionGroup {
51        &self.subs
52    }
53}
54
55/// Saves content of passthrough (`+++`-bracketed) passages for later
56/// re-expansion.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub(crate) struct Passthroughs(pub(crate) Vec<Passthrough>);
59
60impl Passthroughs {
61    pub(crate) fn extract_from(content: &mut Content<'_>, parser: &Parser) -> Self {
62        let mut passthroughs = Self(vec![]);
63
64        // TRANSLATION GUIDE:
65        // * compat_mode => always false
66        // * passthroughs => self.saved_spans
67        // * old_behavior => appears to affect the entire span
68
69        {
70            let text = content.rendered.as_ref();
71            if text.contains("++") || text.contains("$$") || text.contains("ss:") {
72                let source = content.original();
73                let replacer = InlinePassMacroReplacer {
74                    passthroughs: &mut passthroughs,
75                    parser,
76                    source,
77                };
78
79                if let Cow::Owned(new_result) = INLINE_PASS_MACRO.replace_all(text, replacer) {
80                    content.rendered = new_result.into();
81                }
82            }
83        }
84
85        {
86            let text = content.rendered.as_ref();
87            if text.contains('+') || text.contains("-]") {
88                let replacer = InlinePassReplacer(&mut passthroughs);
89
90                if let Cow::Owned(new_result) = INLINE_PASS.replace_all(text, replacer) {
91                    content.rendered = new_result.into();
92                }
93            }
94        }
95
96        // Inline STEM macros (`stem:[…]`, `asciimath:[…]`, `latexmath:[…]`) are
97        // implicit passthroughs. They are extracted last so that any earlier
98        // passthrough placeholders nested inside a STEM expression are preserved
99        // and recursively restored. (Mirrors the `text.gsub InlineStemMacroRx`
100        // block in Ruby Asciidoctor's substitutors.rb.)
101        {
102            let text = content.rendered.as_ref();
103            if text.contains(':') && (text.contains("stem:") || text.contains("math:")) {
104                let original = content.original();
105                let replacer = InlineStemMacroReplacer {
106                    passthroughs: &mut passthroughs,
107                    parser,
108                    source: original,
109                };
110
111                if let Cow::Owned(new_result) = INLINE_STEM_MACRO.replace_all(text, replacer) {
112                    content.rendered = new_result.into();
113                }
114            }
115        }
116
117        passthroughs
118    }
119
120    pub(crate) fn restore_to(&self, content: &mut Content<'_>, parser: &Parser) {
121        if self.0.is_empty() {
122            return;
123        }
124
125        if let Cow::Owned(new_result) = PASS_WITH_INDEX.replace_all(
126            content.rendered().as_ref(),
127            PassthroughRestoreReplacer(self, parser),
128        ) {
129            content.rendered = new_result.into();
130        }
131
132        // A deferred cross-reference's explicit text is pulled out of the main
133        // rendered string before this point, so any passthrough placeholder it
134        // carries (e.g. `<<id, `+[literal]+`>>`) must be restored here too –
135        // otherwise the placeholder sentinels leak into the link text.
136        content.restore_deferred_xref_passthroughs(|text| {
137            if let Cow::Owned(restored) =
138                PASS_WITH_INDEX.replace_all(text, PassthroughRestoreReplacer(self, parser))
139            {
140                *text = restored;
141            }
142        });
143    }
144
145    pub(super) fn push(&mut self, passthrough: Passthrough, dest: &mut String) {
146        let index = self.0.len();
147        self.0.push(passthrough);
148
149        dest.push('\u{96}');
150
151        // Append the index in place with `write!`, avoiding the temporary
152        // `String` a `push_str(&format!(...))` would allocate.
153        let _ = write!(dest, "{index}");
154
155        dest.push('\u{97}');
156    }
157}
158
159/// Matches several variants of the passthrough inline macro, which may span
160/// multiple lines.
161///
162/// ## Examples
163///
164/// * `+++text+++`
165/// * `$$text$$`
166/// * `pass:quotes[text]`
167///
168/// NOTE: We have to support an empty `pass:[]` for compatibility with
169/// AsciiDoc.py.
170static INLINE_PASS_MACRO: LazyLock<Regex> = LazyLock::new(|| {
171    #[allow(clippy::unwrap_used)]
172    Regex::new(
173        r#"(?xs)
174        (?:
175            # Optional: attrlist
176            (?:
177                (\\?)              # Group 1: optional backslash before [
178                \[
179                    ([^\[\]]+)     # Group 2: attrlist contents
180                \]
181            )?
182            
183            (\\{0,2})              # Group 3: optional escape prefix (e.g., \ or \\)
184
185            # Passthrough span delimiters: +++, ++, or $$
186            (?:
187                (\+\+\+) (.*?) (\+\+\+) |   # Groups 4,5,6: triple plus
188                (\+\+)   (.*?) (\+\+)   |   # Groups 7,8,9: double plus
189                (\$\$)   (.*?) (\$\$)       # Groups 10,11,12: double dollar
190            )
191
192        |
193
194            # Alternative: pass-through directive
195            (\\?)                       # Group 13: optional escape before pass
196            pass:
197                ([a-z]+(?:,[a-z-]+)*)?  # Group 14: optional substitution step list
198                \[
199                     (|.*?[^\\])        # Group 15: optional content
200                                        # (avoiding escape of trailing bracket)
201                \]
202        )"#,
203    )
204    .unwrap()
205});
206
207#[derive(Debug)]
208struct InlinePassMacroReplacer<'p> {
209    passthroughs: &'p mut Passthroughs,
210    parser: &'p Parser,
211    source: Span<'p>,
212}
213
214impl Replacer for InlinePassMacroReplacer<'_> {
215    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
216        if caps.get(4).is_some() {
217            // +++
218            self.handle_quoted_text(caps, 5, dest);
219        } else if caps.get(7).is_some() {
220            // ++
221            self.handle_quoted_text(caps, 8, dest);
222        } else if caps.get(10).is_some() {
223            // %%
224            self.handle_quoted_text(caps, 11, dest);
225        } else {
226            // NOTE: We don't look for nested `pass:[]` macros.
227
228            if caps.get(13).is_some_and(|m| !m.as_str().is_empty()) {
229                // Honor escape of `pass:` macro.
230                dest.push_str("pass:");
231                if let Some(subs) = caps.get(14) {
232                    dest.push_str(subs.as_str());
233                }
234                dest.push('[');
235                dest.push_str(&caps[15]);
236                dest.push(']');
237                return;
238            }
239
240            // Resolve the substitution list. An explicit but unrecognized
241            // substitution name (e.g. `pass:bogus[…]`) is warned about and
242            // skipped while any recognized names in the list are still
243            // honored, mirroring Asciidoctor's `resolve_subs`.
244            let subs = match caps.get(14).map(|m| m.as_str()) {
245                None => SubstitutionGroup::None,
246                Some(subs_list) => {
247                    let (group, invalid) = SubstitutionGroup::from_custom_string(None, subs_list);
248
249                    if !invalid.is_empty() {
250                        self.parser.record_substitution_warning(
251                            self.source,
252                            WarningType::InvalidSubstitutionTypeForPassthroughMacro(
253                                invalid.join(", "),
254                            ),
255                        );
256                    }
257
258                    group
259                }
260            };
261
262            let mut text = caps[15].to_string();
263            if !text.is_empty() {
264                text = text.replace("\\]", "]");
265            }
266
267            self.passthroughs.push(
268                Passthrough {
269                    text,
270                    subs,
271                    type_: None,
272                    attrlist: None,
273                },
274                dest,
275            );
276        }
277    }
278}
279
280impl InlinePassMacroReplacer<'_> {
281    fn handle_quoted_text(
282        &mut self,
283        caps: &Captures<'_>,
284        quoted_text_index: usize,
285        dest: &mut String,
286    ) {
287        let escape_count = caps.get(3).map_or(0, |m| m.len());
288
289        let boundary = caps.get(4).or_else(|| caps.get(7)).or_else(|| caps.get(10));
290        let boundary = boundary.map(|m| m.as_str()).unwrap_or_default();
291
292        let quoted_text = caps.get(5).or_else(|| caps.get(8)).or_else(|| caps.get(11));
293        let quoted_text = quoted_text.map(|m| m.as_str()).unwrap_or_default();
294
295        let mut old_behavior = false;
296
297        let attrlist: Option<String> = if let Some(attrlist) = caps.get(2) {
298            let attrlist = attrlist.as_str();
299
300            if escape_count > 0 {
301                dest.push_str(caps[1].as_ref());
302                dest.push('[');
303                dest.push_str(caps[2].as_ref());
304                dest.push(']');
305                dest.push_str(&("\\".repeat(escape_count - 1)));
306                dest.push_str(caps[quoted_text_index - 1].as_ref());
307                dest.push_str(caps[quoted_text_index].as_ref());
308                dest.push_str(caps[quoted_text_index - 1].as_ref());
309                return;
310            }
311
312            if &caps[1] == "\\" {
313                dest.push_str(&format!("[{attrlist}]", attrlist = &caps[2]));
314                None
315            } else if boundary == "++" {
316                if attrlist == "x-" {
317                    old_behavior = true;
318                    Some("".to_owned())
319                } else if attrlist.ends_with(" x-") {
320                    old_behavior = true;
321                    Some(attrlist[0..attrlist.len() - 3].to_owned())
322                } else {
323                    Some(attrlist.to_owned())
324                }
325            } else {
326                Some(attrlist.to_owned())
327            }
328        } else if escape_count > 0 {
329            // NOTE: We don't look for nested unconstrained pass macros.
330            dest.push_str(&("\\".repeat(escape_count - 1)));
331            dest.push_str(boundary);
332            dest.push_str(quoted_text);
333            dest.push_str(boundary);
334            return;
335        } else {
336            None
337        };
338
339        let passthrough = if let Some(attrlist) = attrlist {
340            if old_behavior {
341                Passthrough {
342                    text: caps
343                        .get(quoted_text_index)
344                        .map(|m| m.as_str().to_owned())
345                        .unwrap_or_default(),
346                    subs: SubstitutionGroup::Normal,
347                    type_: Some(QuoteType::Monospaced),
348                    attrlist: Some(attrlist),
349                }
350            } else {
351                Passthrough {
352                    text: caps
353                        .get(quoted_text_index)
354                        .map(|m| m.as_str().to_owned())
355                        .unwrap_or_default(),
356                    subs: if boundary == "+++" {
357                        SubstitutionGroup::None
358                    } else {
359                        SubstitutionGroup::Verbatim
360                    },
361                    type_: Some(QuoteType::Unquoted),
362                    attrlist: Some(attrlist),
363                }
364            }
365        } else {
366            Passthrough {
367                text: caps
368                    .get(quoted_text_index)
369                    .map(|m| m.as_str().to_owned())
370                    .unwrap_or_default(),
371                subs: if boundary == "+++" {
372                    SubstitutionGroup::None
373                } else {
374                    SubstitutionGroup::Verbatim
375                },
376                type_: None,
377                attrlist: None,
378            }
379        };
380
381        self.passthroughs.push(passthrough, dest);
382    }
383}
384
385static PASS_WITH_INDEX: LazyLock<Regex> = LazyLock::new(|| {
386    #[allow(clippy::unwrap_used)]
387    Regex::new("\u{96}(\\d+)\u{97}").unwrap()
388});
389
390/// Matches an inline passthrough, which may span multiple lines.
391///
392/// ## Examples
393///
394/// * `+text+`
395/// * `[x-]+text+`
396/// * `[x-]\`text\``
397///
398/// NOTE: We do not support compat-mode in the Rust implementation.
399static INLINE_PASS: LazyLock<Regex> = LazyLock::new(|| {
400    #[allow(clippy::unwrap_used)]
401    Regex::new(
402        r#"(?xs)
403            (?:
404                                        # Option 1: [... x-] followed by `xxx`
405                \b{start-half}              # Must not follow a word
406                \[(x-|[^\[\]]+\ x-)\]       # Group 1: [attrlist] with x- suffix
407                \`(\S(?:.*?\S)??)\`         # Group 2: `...` content
408
409            |                           # --OR--
410                                        # Option 2: [...] followed by +xxx+
411                \b{start-half}              # Must not follow a word
412                \[([^\[\]]+)\]              # Group 3: [attrlist]
413                (\\{0,2})                   # Group 4: optional escapes
414                \+(\S(?:.*?\S)??)\+         # Group 5: +...+ content (surrounded by non-space)
415
416            |                           # --OR--
417                                        # Option 3: +xxx+ without attrlist
418                (?:^|([^\w;:\\]))           # Group 6: consume a preceding char, so a run
419                                        # of `+` tokenizes like Asciidoctor's `gsub`
420                                        # (which consumes one char before each match)
421                (\\)?                       # Group 7: optional escape
422                \+(\S(?:.*?\S)??)\+         # Group 8: +...+ content (surrounded by non-space)
423
424            )
425
426            \b{end-half}            # Must not be followed by a word character
427        "#,
428    )
429    .unwrap()
430});
431
432#[derive(Debug)]
433struct InlinePassReplacer<'p>(&'p mut Passthroughs);
434
435impl Replacer for InlinePassReplacer<'_> {
436    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
437        if dest.ends_with('\\') || dest.ends_with(':') || dest.ends_with(';') {
438            // Honor the prohibited prefix.
439            // EDGE CASE: Since we don't have lookarounds in Rust's regex, we have to retry
440            // the inline pass replacement here. Possible it might miss a few very obscure
441            // cases, but this should cover most cases where the attrlist is off-limits, but
442            // the quoted text is still subject to inline pass replacement.
443            let replacer = InlinePassReplacer(self.0);
444
445            // Split off the first character (a char boundary, since Option 3
446            // may consume a multi-byte preceding character) and retry on the
447            // remainder.
448            let first_len = caps[0].chars().next().map_or(0, char::len_utf8);
449            let (first, rem) = &caps[0].split_at(first_len);
450            dest.push_str(first);
451
452            let new_result = INLINE_PASS.replace_all(rem, replacer);
453            dest.push_str(&new_result);
454
455            return;
456        }
457
458        // Option 3 consumes one preceding character (mirroring Asciidoctor's
459        // `InlinePassRx`, whose leading group absorbs the character before the
460        // passthrough). Re-emit it verbatim ahead of whatever this match
461        // produces. Options 1 and 2 never capture it, so this is a no-op there.
462        let preceding = caps.get(6).map_or("", |m| m.as_str());
463        dest.push_str(preceding);
464
465        let escapes = caps.get(4).or_else(|| caps.get(7));
466        let escape_count = escapes.map_or(0, |m| m.len());
467
468        let format_mark = if caps.get(2).is_some() { '`' } else { '+' };
469        let orig_attrlist_body = caps.get(1).or_else(|| caps.get(3)).map(|m| m.as_str());
470
471        let (attrlist_body, old_behavior) = orig_attrlist_body.map_or((None, false), |m| {
472            if m == "x-" {
473                (Some("".to_string()), true)
474            } else if m.ends_with(" x-") {
475                (Some(m[0..m.len() - 3].to_string()), true)
476            } else {
477                (Some(m.to_string()), false)
478            }
479        });
480
481        let quoted_text = caps.get(2).or_else(|| caps.get(5)).or_else(|| caps.get(8));
482        let quoted_text = quoted_text.map_or("", |m| m.as_str());
483
484        if let Some(orig_attrlist_body) = orig_attrlist_body {
485            if escape_count > 0 {
486                // Honor the escape of the formatting mark.
487                dest.push('[');
488                dest.push_str(orig_attrlist_body);
489                dest.push(']');
490                dest.push_str(&("\\".repeat(escape_count - 1)));
491                dest.push(format_mark);
492                dest.push_str(quoted_text);
493                dest.push(format_mark);
494                return;
495            }
496        } else if escape_count > 0 {
497            // Honor the escape of the formatting mark.
498            dest.push_str(&("\\".repeat(escape_count - 1)));
499            dest.push(format_mark);
500            dest.push_str(quoted_text);
501            dest.push(format_mark);
502            return;
503        };
504
505        let subs = if attrlist_body.is_some() && old_behavior && format_mark != '`' {
506            SubstitutionGroup::Normal
507        } else {
508            SubstitutionGroup::Verbatim
509        };
510
511        let type_ = if attrlist_body.is_some() {
512            if old_behavior {
513                Some(QuoteType::Monospaced)
514            } else {
515                Some(QuoteType::Unquoted)
516            }
517        } else {
518            None
519        };
520
521        self.0.push(
522            Passthrough {
523                text: quoted_text.to_string(),
524                subs,
525                type_,
526                attrlist: attrlist_body,
527            },
528            dest,
529        );
530    }
531}
532
533/// Matches a STEM inline macro (`stem`, and its alternatives `asciimath` and
534/// `latexmath`), which may span multiple lines.
535///
536/// ## Examples
537///
538/// * `stem:[x^2]`
539/// * `asciimath:[x != 0]`
540/// * `latexmath:[\sqrt{4} = 2]`
541///
542/// The content group requires at least one character whose final character is
543/// not a backslash, so an empty macro (e.g. `stem:[]`) is not recognized.
544static INLINE_STEM_MACRO: LazyLock<Regex> = LazyLock::new(|| {
545    #[allow(clippy::unwrap_used)]
546    Regex::new(
547        r#"(?xs)
548            (\\?)                          # Group 1: optional escape
549            (stem|latexmath|asciimath)     # Group 2: notation
550            :
551            ([a-z]+(?:,[a-z-]+)*)?         # Group 3: optional substitution list
552            \[
553                (.*?[^\\])                 # Group 4: expression (last char not a backslash)
554            \]
555        "#,
556    )
557    .unwrap()
558});
559
560#[derive(Debug)]
561struct InlineStemMacroReplacer<'p> {
562    passthroughs: &'p mut Passthroughs,
563    parser: &'p Parser,
564    source: Span<'p>,
565}
566
567impl Replacer for InlineStemMacroReplacer<'_> {
568    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
569        // Honor the escape: drop the leading backslash and emit the macro
570        // literally.
571        if caps.get(1).is_some_and(|m| !m.as_str().is_empty()) {
572            dest.push_str(&caps[0][1..]);
573            return;
574        }
575
576        let type_ = match &caps[2] {
577            "latexmath" => QuoteType::LatexMath,
578            "asciimath" => QuoteType::AsciiMath,
579
580            // `stem`: the notation is resolved from the `stem` document
581            // attribute (defaulting to AsciiMath).
582            _ => stem_notation(self.parser),
583        };
584
585        // Unescape any escaped closing brackets in the expression.
586        let mut content = caps[4].to_string();
587        if content.contains("\\]") {
588            content = content.replace("\\]", "]");
589        }
590
591        // Drop legacy enclosing `$…$` around latexmath content (for backwards
592        // compatibility with AsciiDoc.py).
593        if type_ == QuoteType::LatexMath
594            && content.len() >= 2
595            && content.starts_with('$')
596            && content.ends_with('$')
597        {
598            content = content[1..content.len() - 1].to_string();
599        }
600
601        // Resolve the substitution group. When no explicit substitution list is
602        // given, HTML output applies only the special characters substitution.
603        // An unrecognized substitution name is warned about and skipped while
604        // any recognized names in the list are still honored, mirroring
605        // Asciidoctor's `resolve_subs`.
606        let subs = match caps.get(3).map(|m| m.as_str()) {
607            None => SubstitutionGroup::Stem,
608            Some(subs_list) => {
609                let (group, invalid) = SubstitutionGroup::from_custom_string(None, subs_list);
610
611                if !invalid.is_empty() {
612                    self.parser.record_substitution_warning(
613                        self.source,
614                        WarningType::InvalidSubstitutionTypeForStemMacro(invalid.join(", ")),
615                    );
616                }
617
618                group
619            }
620        };
621
622        self.passthroughs.push(
623            Passthrough {
624                text: content,
625                subs,
626                type_: Some(type_),
627                attrlist: None,
628            },
629            dest,
630        );
631    }
632}
633
634/// Resolves the STEM notation to apply for a bare `stem` macro or block from
635/// the `stem` document attribute. Any value other than `latexmath`, `latex`, or
636/// `tex` (including an unset, empty, or unrecognized value) maps to AsciiMath.
637fn stem_notation(parser: &Parser) -> QuoteType {
638    match parser.attribute_value("stem").as_maybe_str() {
639        Some("latexmath") | Some("latex") | Some("tex") => QuoteType::LatexMath,
640        _ => QuoteType::AsciiMath,
641    }
642}
643
644#[derive(Debug)]
645struct PassthroughRestoreReplacer<'p>(&'p Passthroughs, &'p Parser);
646
647impl Replacer for PassthroughRestoreReplacer<'_> {
648    fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
649        let index = caps[1].parse::<usize>().unwrap_or_default();
650
651        let Some(pass) = self.0.0.get(index) else {
652            dest.push_str(&format!(
653                "(INTERNAL ERROR: Unresolved passthrough index {index})"
654            ));
655            return;
656        };
657
658        let span = Span::new(&pass.text);
659
660        let mut subbed_text = Content::from(span);
661        pass.subs.apply(&mut subbed_text, self.1, None);
662
663        if let Some(type_) = pass.type_ {
664            // Resolve attribute references in the stored attrlist before parsing
665            // it. Inline passthroughs are extracted before the substitution
666            // pipeline runs, so – unlike the inline quoted-text path, whose
667            // attrlist is a slice of the already-substituted buffer – a reference
668            // embedded in a passthrough role (e.g. `['{myrole}']++x++`) would
669            // otherwise render verbatim. This mirrors Asciidoctor's
670            // `parse_quoted_text_attributes`, which runs `sub_attributes` on the
671            // attribute list.
672            let attrlist_body = pass.attrlist.as_ref().map(|attrlist_body| {
673                // The stored attrlist is owned text whose offsets do not refer
674                // to the document source, so any `warn`/`drop-line` warning this
675                // substitution records cannot be mapped back to a document span.
676                // Discard such warnings rather than surface them mislocated
677                // against the document root (mirrors the docinfo text path).
678                let saved = self.1.substitution_warnings_len();
679                let substituted = substitute_attributes_in_text(attrlist_body, self.1);
680                self.1.truncate_substitution_warnings(saved);
681                substituted
682            });
683
684            let attrlist = attrlist_body.as_ref().map(|attrlist_body| {
685                let span = Span::new(attrlist_body);
686                let maw = Attrlist::parse(span, self.1, AttrlistContext::Inline);
687                maw.item.item
688            });
689
690            let id = attrlist
691                .as_ref()
692                .and_then(|attrlist| attrlist.id().map(|id| id.to_string()));
693
694            let mut new_text = String::default();
695            self.1.renderer.render_quoted_substitution(
696                type_,
697                QuoteScope::Unconstrained,
698                attrlist,
699                id,
700                subbed_text.rendered(),
701                &mut new_text,
702            );
703
704            subbed_text.rendered = new_text.into();
705        }
706
707        if subbed_text.rendered().contains('\u{96}') {
708            // Recursively apply passthrough replacement and write the result.
709            let replacer = PassthroughRestoreReplacer(self.0, self.1);
710
711            let new_result = PASS_WITH_INDEX.replace_all(subbed_text.rendered().as_ref(), replacer);
712
713            dest.push_str(new_result.as_ref());
714        } else {
715            dest.push_str(subbed_text.rendered());
716        }
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    #![allow(clippy::indexing_slicing)]
723    #![allow(clippy::panic)]
724    #![allow(clippy::unwrap_used)]
725
726    use crate::{
727        content::{Passthroughs, SubstitutionStep, passthroughs::Passthrough},
728        tests::prelude::*,
729    };
730
731    #[test]
732    fn inline_double_plus_with_escaped_attrlist() {
733        let mut p = Parser::default();
734        let maw = crate::blocks::Block::parse(crate::Span::new(r#"abc \[attrs]++text++"#), &mut p);
735
736        let block = maw.item.unwrap().item;
737
738        assert_eq!(
739            block,
740            Block::Simple(SimpleBlock {
741                content: Content {
742                    original: Span {
743                        data: r#"abc \[attrs]++text++"#,
744                        line: 1,
745                        col: 1,
746                        offset: 0,
747                    },
748                    rendered: "abc [attrs]text",
749                },
750                source: Span {
751                    data: r#"abc \[attrs]++text++"#,
752                    line: 1,
753                    col: 1,
754                    offset: 0,
755                },
756                style: SimpleBlockStyle::Paragraph,
757                title_source: None,
758                title: None,
759                caption: None,
760                number: None,
761                anchor: None,
762                anchor_reftext: None,
763                attrlist: None,
764            },)
765        );
766    }
767
768    #[test]
769    fn content_exposes_extracted_passthrough_collection() {
770        // The inline passthroughs extracted while substituting a block's
771        // content are retained on the `Content` and observable afterward via
772        // the public `passthroughs()` accessor, exposing each entry's stored
773        // (unescaped) text and its resolved substitution list. (Not a direct
774        // port of a Ruby test; it exercises the accessor surfaced for spec
775        // verification.)
776        let mut p = Parser::default();
777
778        let maw = crate::blocks::Block::parse(
779            crate::Span::new("some ++<code>{code}</code>++ and +++{raw}+++ text"),
780            &mut p,
781        );
782
783        let crate::blocks::Block::Simple(block) = maw.item.unwrap().item else {
784            panic!("expected a simple block");
785        };
786
787        let passthroughs = block.content().passthroughs();
788
789        assert_eq!(passthroughs.len(), 2);
790
791        // `++…++` stores the text verbatim and applies the special-characters
792        // substitution on restore.
793        assert_eq!(passthroughs[0].text(), "<code>{code}</code>");
794        assert_eq!(passthroughs[0].subs(), &SubstitutionGroup::Verbatim);
795
796        // `+++…+++` stores the text verbatim and applies no substitutions.
797        assert_eq!(passthroughs[1].text(), "{raw}");
798        assert_eq!(passthroughs[1].subs(), &SubstitutionGroup::None);
799    }
800
801    #[test]
802    fn passthrough_attrlist_drop_line_does_not_leak_a_mislocated_warning() {
803        // A missing reference in a passthrough's stored attribute list is
804        // substituted against temporary (owned) text, so any warning it records
805        // carries an offset into that text rather than the document source.
806        // Such warnings must be discarded (the offset cannot be mapped back),
807        // not surfaced against the document root. Regression guard for the
808        // `drop-line`/`warn` attrlist path.
809        let mut p = Parser::default().with_intrinsic_attribute(
810            "attribute-missing",
811            "drop-line",
812            ModificationContext::ApiOnly,
813        );
814
815        let doc = p.parse("['{missing}']++x++");
816
817        assert_eq!(doc.warnings().count(), 0);
818    }
819
820    #[test]
821    fn content_without_passthroughs_exposes_an_empty_collection() {
822        // Plain content – and content whose substitution group never extracts
823        // passthroughs – exposes an empty collection rather than any sentinel.
824        let mut p = Parser::default();
825
826        let maw = crate::blocks::Block::parse(crate::Span::new("just plain prose"), &mut p);
827
828        let crate::blocks::Block::Simple(block) = maw.item.unwrap().item else {
829            panic!("expected a simple block");
830        };
831
832        assert!(block.content().passthroughs().is_empty());
833    }
834
835    #[test]
836    fn adds_warning_text_for_unresolved_passthrough_id() {
837        let mut content =
838            crate::content::Content::from(crate::Span::new("pass:q,a[*<{backend}>*]"));
839        let parser_for_extract = Parser::default();
840        let pt = Passthroughs::extract_from(&mut content, &parser_for_extract);
841
842        assert_eq!(
843            content,
844            Content {
845                original: Span {
846                    data: "pass:q,a[*<{backend}>*]",
847                    line: 1,
848                    col: 1,
849                    offset: 0,
850                },
851                rendered: "\u{96}0\u{97}",
852            }
853        );
854
855        assert_eq!(
856            pt,
857            Passthroughs(vec![Passthrough {
858                text: "*<{backend}>*".to_owned(),
859                subs: SubstitutionGroup::Custom(vec![
860                    SubstitutionStep::Quotes,
861                    SubstitutionStep::AttributeReferences,
862                ]),
863                type_: None,
864                attrlist: None,
865            },],)
866        );
867
868        let parser = Parser::default().with_intrinsic_attribute(
869            "backend",
870            "html5",
871            ModificationContext::ApiOnly,
872        );
873
874        pt.0[0].subs.apply(&mut content, &parser, None);
875
876        content.rendered = "\u{96}99\u{97}".into();
877
878        pt.restore_to(&mut content, &parser);
879
880        assert_eq!(
881            content,
882            Content {
883                original: Span {
884                    data: "pass:q,a[*<{backend}>*]",
885                    line: 1,
886                    col: 1,
887                    offset: 0,
888                },
889                rendered: "(INTERNAL ERROR: Unresolved passthrough index 99)",
890            }
891        );
892    }
893}