Skip to main content

asciidoc_parser/content/
substitution_group.rs

1use crate::{
2    HasSpan, Parser,
3    attributes::Attrlist,
4    content::{Content, Passthroughs, SubstitutionStep},
5    warnings::WarningType,
6};
7
8/// Each block and inline element has a default substitution group that is
9/// applied unless you customize the substitutions for a particular element.
10///
11/// `SubstitutionGroup` specifies the default or overridden substitution group
12/// to be applied.
13#[derive(Clone, Debug, Eq, Hash, PartialEq)]
14pub enum SubstitutionGroup {
15    /// The normal substitution group is applied to the majority of the AsciiDoc
16    /// block and inline elements except for specific elements described in the
17    /// next sections.
18    Normal,
19
20    /// The title substitution group is applied to section and block titles.
21    /// It uses the same substitution steps as Normal.
22    Title,
23
24    /// The header substitution group is applied to metadata lines (author and
25    /// revision information) in the document header. It’s also applied to the
26    /// values of attribute entries, regardless of whether those entries are
27    /// defined in the document header or body. Only special characters,
28    /// attribute references, and the inline pass macro are replaced in elements
29    /// that fall under the header group.
30    ///
31    /// You can use the inline pass macro in attribute entries to customize the
32    /// substitution types applied to the attribute’s value.
33    Header,
34
35    /// Literal, listing, and source blocks are processed using the verbatim
36    /// substitution group. Only special characters are replaced in these
37    /// blocks.
38    Verbatim,
39
40    /// No substitutions are applied to three of the elements in the pass
41    /// substitution group. These elements include the passthrough block, inline
42    /// pass macro, and triple plus macro.
43    ///
44    /// The inline single plus and double plus macros also belong to the pass
45    /// group. Only the special characters substitution is applied to these
46    /// elements.
47    Pass,
48
49    /// The none substitution group is applied to comment blocks. No
50    /// substitutions are applied to comments.
51    None,
52
53    /// The attribute entry value substitution group is applied to attribute
54    /// values. Only special characters and attribute references are applied to
55    /// these values.
56    AttributeEntryValue,
57
58    /// The STEM substitution group is applied to STEM (`stem`, `asciimath`, and
59    /// `latexmath`) content when no explicit substitution list is given. Only
60    /// the special characters substitution is applied (Asciidoctor's basic subs
61    /// for HTML output). Used by both the inline STEM macro and the STEM block.
62    Stem,
63
64    /// You can customize the substitutions applied to the content of an inline
65    /// pass macro by specifying one or more substitution values. Multiple
66    /// values must be separated by commas and may not contain any spaces. The
67    /// substitution value is either the formal name of a substitution type or
68    /// group, or its shorthand.
69    ///
70    /// See [Custom substitutions].
71    ///
72    /// [Custom substitutions]: https://docs.asciidoctor.org/asciidoc/latest/pass/pass-macro/#custom-substitutions
73    Custom(Vec<SubstitutionStep>),
74}
75
76/// The substitution steps applied by the normal substitution group.
77const NORMAL_STEPS: &[SubstitutionStep] = &[
78    SubstitutionStep::SpecialCharacters,
79    SubstitutionStep::Quotes,
80    SubstitutionStep::AttributeReferences,
81    SubstitutionStep::CharacterReplacements,
82    SubstitutionStep::Macros,
83    SubstitutionStep::PostReplacement,
84];
85
86/// The substitution steps applied by the verbatim substitution group.
87const VERBATIM_STEPS: &[SubstitutionStep] = &[
88    SubstitutionStep::SpecialCharacters,
89    SubstitutionStep::Callouts,
90];
91
92impl SubstitutionGroup {
93    /// Parse the custom substitution group syntax defined in [Custom
94    /// substitutions].
95    ///
96    /// Returns the resolved substitution group and the list of substitution
97    /// names that were not recognized. Mirroring Asciidoctor's `resolve_subs`,
98    /// an unrecognized name is skipped rather than invalidating the whole
99    /// list; callers that can record warnings should report the returned
100    /// invalid names.
101    ///
102    /// [Custom substitutions]: https://docs.asciidoctor.org/asciidoc/latest/pass/pass-macro/#custom-substitutions
103    pub(crate) fn from_custom_string(
104        start_from: Option<&Self>,
105        mut custom: &str,
106    ) -> (Self, Vec<String>) {
107        custom = custom.trim();
108
109        if custom == "none" {
110            return (Self::None, vec![]);
111        }
112
113        if custom == "n" || custom == "normal" {
114            return (Self::Normal, vec![]);
115        }
116
117        if custom == "v" || custom == "verbatim" {
118            return (Self::Verbatim, vec![]);
119        }
120
121        let mut tokens: Vec<&str> = custom.split(',').map(str::trim).collect();
122
123        // Ruby's `split(',')` drops trailing empty entries, so an empty string
124        // or a list of only separators (e.g. `subs=","`) yields no tokens at
125        // all and resolves to an empty substitution list, matching
126        // Asciidoctor's `resolve_subs` (which returns no subs without warning
127        // in that case).
128        while tokens.last() == Some(&"") {
129            tokens.pop();
130        }
131
132        let mut steps: Vec<SubstitutionStep> = vec![];
133        let mut invalid: Vec<String> = vec![];
134        let mut first = true;
135
136        for mut step in tokens {
137            let is_first = first;
138            first = false;
139
140            let append = if step.starts_with('+') {
141                step = &step[1..];
142                true
143            } else {
144                false
145            };
146
147            let prepend = if !append && step.ends_with('+') {
148                step = &step[0..step.len() - 1];
149                true
150            } else {
151                false
152            };
153
154            let subtract = if !append && !prepend && step.starts_with('-') {
155                step = &step[1..];
156                true
157            } else {
158                false
159            };
160
161            if is_first
162                && let Some(start_from) = start_from
163                && (append || prepend || subtract)
164            {
165                steps = start_from.steps().to_owned();
166            }
167
168            // Each name resolves to a list of steps, so a group name mid-list
169            // contributes its constituent steps like any other token. This
170            // matches Asciidoctor's `resolve_subs`, where every key resolves
171            // to an array of substitutions before the modifier is applied.
172            let resolved: &[SubstitutionStep] = match step {
173                "none" => &[],
174                "n" | "normal" => NORMAL_STEPS,
175                "v" | "verbatim" => VERBATIM_STEPS,
176                "c" | "specialcharacters" | "specialchars" => {
177                    &[SubstitutionStep::SpecialCharacters]
178                }
179                "q" | "quotes" => &[SubstitutionStep::Quotes],
180                "a" | "attributes" => &[SubstitutionStep::AttributeReferences],
181                "r" | "replacements" => &[SubstitutionStep::CharacterReplacements],
182                "m" | "macros" => &[SubstitutionStep::Macros],
183                "p" | "post_replacements" => &[SubstitutionStep::PostReplacement],
184                "callouts" => &[SubstitutionStep::Callouts],
185                _ => {
186                    // Removing an unrecognized name is a no-op rather than an
187                    // error: in Asciidoctor's `resolve_subs`, a `-` modifier
188                    // never adds the name to the candidate list, so it is
189                    // never reported as invalid.
190                    if !subtract {
191                        invalid.push(step.to_owned());
192                    }
193
194                    continue;
195                }
196            };
197
198            if prepend {
199                for (index, step) in resolved.iter().enumerate() {
200                    steps.insert(index, *step);
201                }
202            } else if subtract {
203                steps.retain(|s| !resolved.contains(s));
204            } else {
205                steps.extend_from_slice(resolved);
206            }
207        }
208
209        // De-duplicate the final list, first occurrence winning. Asciidoctor
210        // ensures each substitution runs at most once, so a step contributed by
211        // more than one token (e.g. the `quotes` in `quotes,normal`) is kept
212        // only in its earliest position.
213        let mut deduped: Vec<SubstitutionStep> = Vec::with_capacity(steps.len());
214        for step in steps {
215            if !deduped.contains(&step) {
216                deduped.push(step);
217            }
218        }
219
220        (Self::Custom(deduped), invalid)
221    }
222
223    pub(crate) fn apply(
224        &self,
225        content: &mut Content<'_>,
226        parser: &Parser,
227        attrlist: Option<&Attrlist>,
228    ) {
229        let steps = self.steps();
230
231        let passthroughs: Option<Passthroughs> =
232            if steps.contains(&SubstitutionStep::Macros) || self == &Self::Header {
233                Some(Passthroughs::extract_from(content, parser))
234            } else {
235                None
236            };
237
238        for step in steps {
239            step.apply(content, parser, attrlist);
240        }
241
242        if let Some(passthroughs) = passthroughs {
243            passthroughs.restore_to(content, parser);
244
245            // Retain the extracted passthroughs on the content so they remain
246            // observable after restore (see `Content::passthroughs`).
247            content.set_passthroughs(passthroughs.0);
248        }
249
250        // Capture any deferred cross-references as a placeholder template and
251        // render the unresolved fallback, so `rendered()` is clean even before
252        // references are resolved. This is a no-op when no cross-references were
253        // found.
254        content.finalize_deferred(&*parser.renderer);
255    }
256
257    /// Applies any block style masquerade and `subs` attribute override from
258    /// the block's attribute list.
259    ///
260    /// When `parser` is provided, unrecognized substitution names in the
261    /// `subs` attribute are recorded as warnings. Parse-time callers should
262    /// pass the parser; accessors that re-derive the group after parsing
263    /// should pass `None` so the warning is only recorded once.
264    pub(crate) fn override_via_attrlist(
265        &self,
266        attrlist: Option<&Attrlist>,
267        parser: Option<&Parser>,
268    ) -> Self {
269        let mut result = self.clone();
270
271        if let Some(attrlist) = attrlist {
272            // A declared block style reinterprets a simple-content (paragraph)
273            // block as another context, which can change the substitution group
274            // that applies. This masquerade only affects blocks whose default
275            // group is `Normal`: a delimited block's delimiter already fixes its
276            // group (verbatim, pass, stem, etc.), and Asciidoctor does not let a
277            // style keyword override it. So the mapping below is scoped to
278            // `Normal` blocks, matching Asciidoctor's parser.
279            if result == SubstitutionGroup::Normal
280                && let Some(block_style) = attrlist.nth_attribute(1).and_then(|a| a.block_style())
281            {
282                result = match block_style {
283                    // The verbatim masquerade styles (`literal`, `listing`, and
284                    // `source`) apply only special characters and callouts.
285                    "literal" | "listing" | "source" => SubstitutionGroup::Verbatim,
286
287                    // The `pass` style excludes the content from all
288                    // substitutions.
289                    "pass" => SubstitutionGroup::None,
290
291                    // Every other style (`normal`, `verse`, `quote`, `sidebar`,
292                    // `example`, admonitions, …) keeps the normal substitution
293                    // group.
294                    _ => result,
295                };
296            }
297
298            if let Some(subs) = attrlist.named_attribute("subs").map(|attr| attr.value()) {
299                let (sub_group, invalid) = Self::from_custom_string(Some(self), subs);
300
301                if !invalid.is_empty()
302                    && let Some(parser) = parser
303                {
304                    parser.record_substitution_warning(
305                        attrlist.span(),
306                        WarningType::InvalidSubstitutionTypeForBlock(invalid.join(", ")),
307                    );
308                }
309
310                result = sub_group;
311            }
312        }
313
314        result
315    }
316
317    /// Returns the ordered list of substitution [steps](SubstitutionStep) this
318    /// group applies, in the order they run.
319    ///
320    /// This is the resolved, expanded form of the group: a named group (e.g.
321    /// [`Normal`](Self::Normal) or [`Verbatim`](Self::Verbatim)) expands to its
322    /// fixed step sequence, and a [`Custom`](Self::Custom) group returns its
323    /// own steps. Useful for inspecting the substitutions in effect for a
324    /// block or an extracted [`Passthrough`](crate::content::Passthrough).
325    pub fn steps(&self) -> &[SubstitutionStep] {
326        match self {
327            Self::Normal | Self::Title => NORMAL_STEPS,
328
329            Self::Header | Self::AttributeEntryValue => &[
330                SubstitutionStep::SpecialCharacters,
331                SubstitutionStep::AttributeReferences,
332            ],
333
334            Self::Verbatim => VERBATIM_STEPS,
335
336            Self::Stem => &[SubstitutionStep::SpecialCharacters],
337
338            Self::Pass | Self::None => &[],
339
340            Self::Custom(steps) => steps,
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    #![allow(clippy::unwrap_used)]
348
349    mod stem {
350        use crate::{content::Content, strings::CowStr, tests::prelude::*};
351
352        #[test]
353        fn applies_special_characters_only() {
354            // The `Stem` group applies only the special characters substitution:
355            // `<` is escaped, but quotes (`*bold*`) and attribute references
356            // (`{color}`) are left untouched.
357            let mut content = Content::from(crate::Span::new("*a* < {color}"));
358            let p = Parser::default();
359            SubstitutionGroup::Stem.apply(&mut content, &p, None);
360            assert_eq!(
361                content.rendered,
362                CowStr::Boxed("*a* &lt; {color}".to_string().into_boxed_str())
363            );
364        }
365    }
366
367    mod from_custom_string {
368        use crate::{
369            content::{Content, SubstitutionStep},
370            strings::CowStr,
371            tests::prelude::*,
372        };
373
374        #[test]
375        fn empty() {
376            // An empty `subs` value resolves to an empty substitution list,
377            // matching Asciidoctor's `resolve_subs` (which returns no subs for
378            // a nil or empty string).
379            assert_eq!(
380                SubstitutionGroup::from_custom_string(None, ""),
381                (SubstitutionGroup::Custom(vec![]), vec![])
382            );
383        }
384
385        #[test]
386        fn empty_entries() {
387            // A list containing only separators resolves to an empty
388            // substitution list, without reporting any invalid names: Ruby's
389            // `split(',')` drops trailing empty entries, so `","` yields no
390            // tokens at all (issue #784).
391            assert_eq!(
392                SubstitutionGroup::from_custom_string(Some(&SubstitutionGroup::Verbatim), ","),
393                (SubstitutionGroup::Custom(vec![]), vec![])
394            );
395
396            assert_eq!(
397                SubstitutionGroup::from_custom_string(None, " , ,"),
398                (SubstitutionGroup::Custom(vec![]), vec![])
399            );
400
401            assert_eq!(
402                SubstitutionGroup::from_custom_string(None, "quotes,"),
403                (
404                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
405                    vec![]
406                )
407            );
408
409            // A leading or interior empty entry is not dropped by Ruby's
410            // `split(',')`; it resolves like any other unrecognized name, so
411            // it is skipped and reported (with an empty name), matching
412            // Asciidoctor's `resolve_subs`.
413            assert_eq!(
414                SubstitutionGroup::from_custom_string(None, "quotes,,macros"),
415                (
416                    SubstitutionGroup::Custom(vec![
417                        SubstitutionStep::Quotes,
418                        SubstitutionStep::Macros
419                    ]),
420                    vec!["".to_owned()]
421                )
422            );
423
424            // A leading empty entry counts as a (failed) first token, so a
425            // modifier that follows it does not start from the base group,
426            // matching Asciidoctor (where the empty entry makes the candidate
427            // list non-nil before the modifier is seen).
428            assert_eq!(
429                SubstitutionGroup::from_custom_string(
430                    Some(&SubstitutionGroup::Verbatim),
431                    ",+quotes"
432                ),
433                (
434                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
435                    vec!["".to_owned()]
436                )
437            );
438        }
439
440        #[test]
441        fn invalid_names() {
442            // An unrecognized name is skipped and reported; recognized names
443            // in the same list are still honored.
444            assert_eq!(
445                SubstitutionGroup::from_custom_string(None, "bogus"),
446                (SubstitutionGroup::Custom(vec![]), vec!["bogus".to_owned()])
447            );
448
449            assert_eq!(
450                SubstitutionGroup::from_custom_string(None, "bogus,quotes"),
451                (
452                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
453                    vec!["bogus".to_owned()]
454                )
455            );
456
457            // An appended unrecognized name still seeds the list from the
458            // base group before being skipped, matching Asciidoctor.
459            assert_eq!(
460                SubstitutionGroup::from_custom_string(
461                    Some(&SubstitutionGroup::Verbatim),
462                    "+bogus,quotes"
463                ),
464                (
465                    SubstitutionGroup::Custom(vec![
466                        SubstitutionStep::SpecialCharacters,
467                        SubstitutionStep::Callouts,
468                        SubstitutionStep::Quotes,
469                    ]),
470                    vec!["bogus".to_owned()]
471                )
472            );
473
474            // Removing an unrecognized name is a no-op, not an error: in
475            // Asciidoctor's `resolve_subs`, a `-` modifier never adds the
476            // name to the candidate list, so it is never reported as invalid.
477            assert_eq!(
478                SubstitutionGroup::from_custom_string(Some(&SubstitutionGroup::Verbatim), "-bogus"),
479                (
480                    SubstitutionGroup::Custom(vec![
481                        SubstitutionStep::SpecialCharacters,
482                        SubstitutionStep::Callouts,
483                    ]),
484                    vec![]
485                )
486            );
487        }
488
489        #[test]
490        fn none() {
491            assert_eq!(
492                SubstitutionGroup::from_custom_string(None, "none"),
493                (SubstitutionGroup::None, vec![])
494            );
495
496            // `none` mid-list resolves to an empty step list, like
497            // Asciidoctor's `SUB_GROUPS[:none]`.
498            assert_eq!(
499                SubstitutionGroup::from_custom_string(None, "quotes,none"),
500                (
501                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
502                    vec![]
503                )
504            );
505
506            assert_eq!(
507                SubstitutionGroup::from_custom_string(None, "nermal"),
508                (SubstitutionGroup::Custom(vec![]), vec!["nermal".to_owned()])
509            );
510        }
511
512        #[test]
513        fn normal() {
514            assert_eq!(
515                SubstitutionGroup::from_custom_string(None, "n"),
516                (SubstitutionGroup::Normal, vec![])
517            );
518
519            assert_eq!(
520                SubstitutionGroup::from_custom_string(None, "normal"),
521                (SubstitutionGroup::Normal, vec![])
522            );
523        }
524
525        #[test]
526        fn verbatim() {
527            assert_eq!(
528                SubstitutionGroup::from_custom_string(None, "v"),
529                (SubstitutionGroup::Verbatim, vec![])
530            );
531
532            assert_eq!(
533                SubstitutionGroup::from_custom_string(None, "verbatim"),
534                (SubstitutionGroup::Verbatim, vec![])
535            );
536
537            assert_eq!(
538                SubstitutionGroup::from_custom_string(None, "verboten"),
539                (
540                    SubstitutionGroup::Custom(vec![]),
541                    vec!["verboten".to_owned()]
542                )
543            );
544        }
545
546        #[test]
547        fn special_chars() {
548            assert_eq!(
549                SubstitutionGroup::from_custom_string(None, "c"),
550                (
551                    SubstitutionGroup::Custom(vec![SubstitutionStep::SpecialCharacters]),
552                    vec![]
553                )
554            );
555
556            assert_eq!(
557                SubstitutionGroup::from_custom_string(None, "specialchars"),
558                (
559                    SubstitutionGroup::Custom(vec![SubstitutionStep::SpecialCharacters]),
560                    vec![]
561                )
562            );
563        }
564
565        #[test]
566        fn quotes() {
567            assert_eq!(
568                SubstitutionGroup::from_custom_string(None, "q"),
569                (
570                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
571                    vec![]
572                )
573            );
574
575            assert_eq!(
576                SubstitutionGroup::from_custom_string(None, "quotes"),
577                (
578                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
579                    vec![]
580                )
581            );
582        }
583
584        #[test]
585        fn attributes() {
586            assert_eq!(
587                SubstitutionGroup::from_custom_string(None, "a"),
588                (
589                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences]),
590                    vec![]
591                )
592            );
593
594            assert_eq!(
595                SubstitutionGroup::from_custom_string(None, "attributes"),
596                (
597                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences]),
598                    vec![]
599                )
600            );
601        }
602
603        #[test]
604        fn replacements() {
605            assert_eq!(
606                SubstitutionGroup::from_custom_string(None, "r"),
607                (
608                    SubstitutionGroup::Custom(vec![SubstitutionStep::CharacterReplacements]),
609                    vec![]
610                )
611            );
612
613            assert_eq!(
614                SubstitutionGroup::from_custom_string(None, "replacements"),
615                (
616                    SubstitutionGroup::Custom(vec![SubstitutionStep::CharacterReplacements]),
617                    vec![]
618                )
619            );
620        }
621
622        #[test]
623        fn macros() {
624            assert_eq!(
625                SubstitutionGroup::from_custom_string(None, "m"),
626                (
627                    SubstitutionGroup::Custom(vec![SubstitutionStep::Macros]),
628                    vec![]
629                )
630            );
631
632            assert_eq!(
633                SubstitutionGroup::from_custom_string(None, "macros"),
634                (
635                    SubstitutionGroup::Custom(vec![SubstitutionStep::Macros]),
636                    vec![]
637                )
638            );
639        }
640
641        #[test]
642        fn post_replacements() {
643            assert_eq!(
644                SubstitutionGroup::from_custom_string(None, "p"),
645                (
646                    SubstitutionGroup::Custom(vec![SubstitutionStep::PostReplacement]),
647                    vec![]
648                )
649            );
650
651            assert_eq!(
652                SubstitutionGroup::from_custom_string(None, "post_replacements"),
653                (
654                    SubstitutionGroup::Custom(vec![SubstitutionStep::PostReplacement]),
655                    vec![]
656                )
657            );
658        }
659
660        #[test]
661        fn multiple() {
662            assert_eq!(
663                SubstitutionGroup::from_custom_string(None, "q,a"),
664                (
665                    SubstitutionGroup::Custom(vec![
666                        SubstitutionStep::Quotes,
667                        SubstitutionStep::AttributeReferences
668                    ]),
669                    vec![]
670                )
671            );
672
673            assert_eq!(
674                SubstitutionGroup::from_custom_string(None, "q, a"),
675                (
676                    SubstitutionGroup::Custom(vec![
677                        SubstitutionStep::Quotes,
678                        SubstitutionStep::AttributeReferences
679                    ]),
680                    vec![]
681                )
682            );
683
684            assert_eq!(
685                SubstitutionGroup::from_custom_string(None, "quotes,attributes"),
686                (
687                    SubstitutionGroup::Custom(vec![
688                        SubstitutionStep::Quotes,
689                        SubstitutionStep::AttributeReferences
690                    ]),
691                    vec![]
692                )
693            );
694
695            assert_eq!(
696                SubstitutionGroup::from_custom_string(None, "x,bogus,no such step"),
697                (
698                    SubstitutionGroup::Custom(vec![]),
699                    vec![
700                        "x".to_owned(),
701                        "bogus".to_owned(),
702                        "no such step".to_owned()
703                    ]
704                )
705            );
706        }
707
708        #[test]
709        fn subtraction() {
710            assert_eq!(
711                SubstitutionGroup::from_custom_string(None, "n,-r"),
712                (
713                    SubstitutionGroup::Custom(vec![
714                        SubstitutionStep::SpecialCharacters,
715                        SubstitutionStep::Quotes,
716                        SubstitutionStep::AttributeReferences,
717                        SubstitutionStep::Macros,
718                        SubstitutionStep::PostReplacement,
719                    ]),
720                    vec![]
721                )
722            );
723
724            assert_eq!(
725                SubstitutionGroup::from_custom_string(None, "n,-r,-r,-m"),
726                (
727                    SubstitutionGroup::Custom(vec![
728                        SubstitutionStep::SpecialCharacters,
729                        SubstitutionStep::Quotes,
730                        SubstitutionStep::AttributeReferences,
731                        SubstitutionStep::PostReplacement,
732                    ]),
733                    vec![]
734                )
735            );
736
737            assert_eq!(
738                SubstitutionGroup::from_custom_string(None, "v,-r"),
739                (
740                    SubstitutionGroup::Custom(vec![
741                        SubstitutionStep::SpecialCharacters,
742                        SubstitutionStep::Callouts,
743                    ]),
744                    vec![]
745                )
746            );
747
748            assert_eq!(
749                SubstitutionGroup::from_custom_string(None, "v,-c"),
750                (
751                    SubstitutionGroup::Custom(vec![SubstitutionStep::Callouts]),
752                    vec![]
753                )
754            );
755
756            assert_eq!(
757                SubstitutionGroup::from_custom_string(None, "v,-callouts"),
758                (
759                    SubstitutionGroup::Custom(vec![SubstitutionStep::SpecialCharacters,]),
760                    vec![]
761                )
762            );
763        }
764
765        #[test]
766        fn addition() {
767            // `n` expands to normal's steps (which already include
768            // replacements); the trailing `r` is de-duplicated away, matching
769            // Asciidoctor.
770            assert_eq!(
771                SubstitutionGroup::from_custom_string(None, "n,r"),
772                (
773                    SubstitutionGroup::Custom(vec![
774                        SubstitutionStep::SpecialCharacters,
775                        SubstitutionStep::Quotes,
776                        SubstitutionStep::AttributeReferences,
777                        SubstitutionStep::CharacterReplacements,
778                        SubstitutionStep::Macros,
779                        SubstitutionStep::PostReplacement,
780                    ]),
781                    vec![]
782                )
783            );
784
785            assert_eq!(
786                SubstitutionGroup::from_custom_string(None, "v,m"),
787                (
788                    SubstitutionGroup::Custom(vec![
789                        SubstitutionStep::SpecialCharacters,
790                        SubstitutionStep::Callouts,
791                        SubstitutionStep::Macros,
792                    ]),
793                    vec![]
794                )
795            );
796        }
797
798        #[test]
799        fn incremental() {
800            // `n` expands to normal's steps (which already include
801            // replacements); the trailing `r` is de-duplicated away, matching
802            // Asciidoctor.
803            assert_eq!(
804                SubstitutionGroup::from_custom_string(None, "n,r"),
805                (
806                    SubstitutionGroup::Custom(vec![
807                        SubstitutionStep::SpecialCharacters,
808                        SubstitutionStep::Quotes,
809                        SubstitutionStep::AttributeReferences,
810                        SubstitutionStep::CharacterReplacements,
811                        SubstitutionStep::Macros,
812                        SubstitutionStep::PostReplacement,
813                    ]),
814                    vec![]
815                )
816            );
817
818            assert_eq!(
819                SubstitutionGroup::from_custom_string(None, "v,m"),
820                (
821                    SubstitutionGroup::Custom(vec![
822                        SubstitutionStep::SpecialCharacters,
823                        SubstitutionStep::Callouts,
824                        SubstitutionStep::Macros,
825                    ]),
826                    vec![]
827                )
828            );
829        }
830
831        #[test]
832        fn group_name_mid_list_expands_in_place_and_dedups() {
833            // A group name (`normal`) appearing mid-list is expanded in place
834            // and appended to what came before, rather than resetting the
835            // accumulated steps. The leading `quotes` is preserved, and the
836            // redundant `quotes` from `normal`'s expansion is de-duplicated
837            // away, matching Asciidoctor's `resolve_subs`.
838            assert_eq!(
839                SubstitutionGroup::from_custom_string(None, "quotes,normal"),
840                (
841                    SubstitutionGroup::Custom(vec![
842                        SubstitutionStep::Quotes,
843                        SubstitutionStep::SpecialCharacters,
844                        SubstitutionStep::AttributeReferences,
845                        SubstitutionStep::CharacterReplacements,
846                        SubstitutionStep::Macros,
847                        SubstitutionStep::PostReplacement,
848                    ]),
849                    vec![]
850                )
851            );
852
853            // Same behavior for the shorthand `v` group name mid-list.
854            assert_eq!(
855                SubstitutionGroup::from_custom_string(None, "m,v"),
856                (
857                    SubstitutionGroup::Custom(vec![
858                        SubstitutionStep::Macros,
859                        SubstitutionStep::SpecialCharacters,
860                        SubstitutionStep::Callouts,
861                    ]),
862                    vec![]
863                )
864            );
865        }
866
867        #[test]
868        fn prepend() {
869            assert_eq!(
870                SubstitutionGroup::from_custom_string(
871                    Some(&SubstitutionGroup::Verbatim),
872                    "attributes+"
873                ),
874                (
875                    SubstitutionGroup::Custom(vec![
876                        SubstitutionStep::AttributeReferences,
877                        SubstitutionStep::SpecialCharacters,
878                        SubstitutionStep::Callouts,
879                    ]),
880                    vec![]
881                )
882            );
883
884            assert_eq!(
885                SubstitutionGroup::from_custom_string(None, "attributes+"),
886                (
887                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences,]),
888                    vec![]
889                )
890            );
891        }
892
893        #[test]
894        fn append() {
895            assert_eq!(
896                SubstitutionGroup::from_custom_string(
897                    Some(&SubstitutionGroup::Verbatim),
898                    "+attributes"
899                ),
900                (
901                    SubstitutionGroup::Custom(vec![
902                        SubstitutionStep::SpecialCharacters,
903                        SubstitutionStep::Callouts,
904                        SubstitutionStep::AttributeReferences,
905                    ]),
906                    vec![]
907                )
908            );
909
910            assert_eq!(
911                SubstitutionGroup::from_custom_string(None, "attributes+"),
912                (
913                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences,]),
914                    vec![]
915                )
916            );
917        }
918
919        #[test]
920        fn subtract() {
921            assert_eq!(
922                SubstitutionGroup::from_custom_string(
923                    Some(&SubstitutionGroup::Normal),
924                    "-attributes"
925                ),
926                (
927                    SubstitutionGroup::Custom(vec![
928                        SubstitutionStep::SpecialCharacters,
929                        SubstitutionStep::Quotes,
930                        SubstitutionStep::CharacterReplacements,
931                        SubstitutionStep::Macros,
932                        SubstitutionStep::PostReplacement,
933                    ]),
934                    vec![]
935                )
936            );
937
938            assert_eq!(
939                SubstitutionGroup::from_custom_string(None, "-attributes"),
940                (SubstitutionGroup::Custom(vec![]), vec![])
941            );
942        }
943
944        #[test]
945        fn custom_group_with_macros_preserves_passthroughs() {
946            let custom_group = SubstitutionGroup::from_custom_string(None, "q,m").0;
947
948            let mut content = Content::from(crate::Span::new(
949                "Text with +++pass<through>+++ icon:github[] content.",
950            ));
951            let p = Parser::default();
952            custom_group.apply(&mut content, &p, None);
953
954            assert!(!content.is_empty());
955            assert_eq!(
956                content.rendered,
957                CowStr::Boxed(
958                    "Text with pass<through> <span class=\"icon\">[github&#93;</span> content."
959                        .to_string()
960                        .into_boxed_str()
961                )
962            );
963        }
964    }
965
966    mod override_via_attrlist {
967        use crate::{
968            attributes::{Attrlist, AttrlistContext},
969            tests::prelude::*,
970        };
971
972        /// Resolve the substitution group that `base` maps to when the given
973        /// attribute list (block style, `subs=`, …) is applied.
974        fn resolve(base: SubstitutionGroup, attrlist: &str) -> SubstitutionGroup {
975            let p = Parser::default();
976            let attrlist = Attrlist::parse(crate::Span::new(attrlist), &p, AttrlistContext::Block)
977                .item
978                .item;
979
980            base.override_via_attrlist(Some(&attrlist), None)
981        }
982
983        #[test]
984        fn verbatim_masquerade_styles_promote_normal_to_verbatim() {
985            // On a simple-content (paragraph) block, the `literal`, `listing`,
986            // and `source` styles switch the substitution group to verbatim.
987            for style in ["literal", "listing", "source"] {
988                assert_eq!(
989                    resolve(SubstitutionGroup::Normal, style),
990                    SubstitutionGroup::Verbatim,
991                    "style `{style}` should map Normal to Verbatim"
992                );
993            }
994        }
995
996        #[test]
997        fn pass_style_suppresses_substitutions_on_normal() {
998            assert_eq!(
999                resolve(SubstitutionGroup::Normal, "pass"),
1000                SubstitutionGroup::None
1001            );
1002        }
1003
1004        #[test]
1005        fn non_masquerade_styles_keep_normal() {
1006            // Styles whose content model is simple (or compound) keep the normal
1007            // substitution group; e.g. `verse` uses normal subs even though its
1008            // content model is verbatim.
1009            for style in ["normal", "verse", "quote", "sidebar", "example"] {
1010                assert_eq!(
1011                    resolve(SubstitutionGroup::Normal, style),
1012                    SubstitutionGroup::Normal,
1013                    "style `{style}` should keep Normal"
1014                );
1015            }
1016        }
1017
1018        #[test]
1019        fn style_does_not_override_a_delimited_block_group() {
1020            // A delimited block's delimiter fixes its substitution group; a style
1021            // keyword must not override it (matching Asciidoctor). A `[pass]`
1022            // style on a `----`/`....` verbatim block keeps verbatim subs, and a
1023            // `[source]` style on a `++++` pass block keeps the pass group.
1024            assert_eq!(
1025                resolve(SubstitutionGroup::Verbatim, "pass"),
1026                SubstitutionGroup::Verbatim
1027            );
1028
1029            assert_eq!(
1030                resolve(SubstitutionGroup::Pass, "source"),
1031                SubstitutionGroup::Pass
1032            );
1033
1034            assert_eq!(
1035                resolve(SubstitutionGroup::Stem, "source"),
1036                SubstitutionGroup::Stem
1037            );
1038        }
1039
1040        #[test]
1041        fn subs_attribute_still_overrides() {
1042            // An explicit `subs=` attribute overrides the group regardless of the
1043            // block style, and takes precedence over the style masquerade.
1044            assert_eq!(
1045                resolve(SubstitutionGroup::Normal, "listing,subs=normal"),
1046                SubstitutionGroup::Normal
1047            );
1048
1049            assert_eq!(
1050                resolve(SubstitutionGroup::Verbatim, "subs=none"),
1051                SubstitutionGroup::None
1052            );
1053        }
1054
1055        #[test]
1056        fn warns_on_invalid_subs_name_and_honors_valid_names() {
1057            // Verified against Asciidoctor 2.0.26: the unrecognized name is
1058            // warned about and skipped, while the recognized `quotes` sub is
1059            // still applied. The `&` is left unescaped because
1060            // `specialchars` is not in the resolved list.
1061            let mut p = Parser::default();
1062            let doc = p.parse("[subs=\"bogus,quotes\"]\nabc *bold* &\ndef");
1063
1064            let block = doc.child_blocks().next().unwrap();
1065            assert_eq!(
1066                block.rendered_content(),
1067                Some("abc <strong>bold</strong> &\ndef")
1068            );
1069
1070            let warnings: Vec<_> = doc.warnings().collect();
1071            assert_eq!(warnings.len(), 1);
1072            assert_eq!(
1073                warnings.first().unwrap().warning,
1074                crate::warnings::WarningType::InvalidSubstitutionTypeForBlock("bogus".to_owned())
1075            );
1076        }
1077
1078        #[test]
1079        fn no_warning_for_empty_subs_list() {
1080            // An empty list (`subs=","`) resolves to no substitutions without
1081            // any warning, matching Asciidoctor (issue #784).
1082            let mut p = Parser::default();
1083            let doc = p.parse("[subs=\",\"]\n....\ncontent <here>\n....");
1084
1085            let block = doc.child_blocks().next().unwrap();
1086            assert_eq!(block.rendered_content(), Some("content <here>"));
1087
1088            assert_eq!(doc.warnings().count(), 0);
1089        }
1090    }
1091
1092    mod normal {
1093        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1094
1095        #[test]
1096        fn empty() {
1097            let mut content = Content::from(crate::Span::default());
1098            let p = Parser::default();
1099            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1100            assert!(content.is_empty());
1101            assert_eq!(content.rendered, CowStr::Borrowed(""));
1102        }
1103
1104        #[test]
1105        fn basic_non_empty_span() {
1106            let mut content = Content::from(crate::Span::new("blah"));
1107            let p = Parser::default();
1108            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1109            assert!(!content.is_empty());
1110            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1111        }
1112
1113        #[test]
1114        fn match_lt_and_gt() {
1115            let mut content = Content::from(crate::Span::new("bl<ah>"));
1116            let p = Parser::default();
1117            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1118            assert!(!content.is_empty());
1119            assert_eq!(
1120                content.rendered,
1121                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1122            );
1123        }
1124
1125        #[test]
1126        fn match_amp() {
1127            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1128            let p = Parser::default();
1129            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1130            assert!(!content.is_empty());
1131            assert_eq!(
1132                content.rendered,
1133                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1134            );
1135        }
1136
1137        #[test]
1138        fn strong_word() {
1139            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1140            let p = Parser::default();
1141            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1142            assert!(!content.is_empty());
1143            assert_eq!(
1144                content.rendered,
1145                CowStr::Boxed(
1146                    "One <strong>word</strong> is strong."
1147                        .to_string()
1148                        .into_boxed_str()
1149                )
1150            );
1151        }
1152
1153        #[test]
1154        fn strong_word_with_special_chars() {
1155            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1156            let p = Parser::default();
1157            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1158            assert!(!content.is_empty());
1159            assert_eq!(
1160                content.rendered,
1161                CowStr::Boxed(
1162                    "One <strong>wo&lt;r&gt;d</strong> is strong."
1163                        .to_string()
1164                        .into_boxed_str()
1165                )
1166            );
1167        }
1168
1169        #[test]
1170        fn marked_string_with_id() {
1171            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1172            let p = Parser::default();
1173            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1174            assert!(!content.is_empty());
1175            assert_eq!(
1176                content.rendered,
1177                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1178            );
1179        }
1180    }
1181
1182    mod attribute_entry_value {
1183        use crate::{
1184            content::Content, parser::ModificationContext, strings::CowStr, tests::prelude::*,
1185        };
1186
1187        #[test]
1188        fn empty() {
1189            let mut content = Content::from(crate::Span::default());
1190            let p = Parser::default();
1191            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1192            assert!(content.is_empty());
1193            assert_eq!(content.rendered, CowStr::Borrowed(""));
1194        }
1195
1196        #[test]
1197        fn basic_non_empty_span() {
1198            let mut content = Content::from(crate::Span::new("blah"));
1199            let p = Parser::default();
1200            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1201            assert!(!content.is_empty());
1202            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1203        }
1204
1205        #[test]
1206        fn match_lt_and_gt() {
1207            let mut content = Content::from(crate::Span::new("bl<ah>"));
1208            let p = Parser::default();
1209            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1210            assert!(!content.is_empty());
1211            assert_eq!(
1212                content.rendered,
1213                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1214            );
1215        }
1216
1217        #[test]
1218        fn match_amp() {
1219            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1220            let p = Parser::default();
1221            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1222            assert!(!content.is_empty());
1223            assert_eq!(
1224                content.rendered,
1225                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1226            );
1227        }
1228
1229        #[test]
1230        fn ignores_strong_word() {
1231            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1232            let p = Parser::default();
1233            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1234            assert!(!content.is_empty());
1235            assert_eq!(
1236                content.rendered,
1237                CowStr::Boxed("One *word* is strong.".to_string().into_boxed_str())
1238            );
1239        }
1240
1241        #[test]
1242        fn special_chars_and_attributes() {
1243            let mut content = Content::from(crate::Span::new("bl<ah> {color}"));
1244
1245            let p = Parser::default().with_intrinsic_attribute(
1246                "color",
1247                "red",
1248                ModificationContext::Anywhere,
1249            );
1250
1251            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1252            assert!(!content.is_empty());
1253            assert_eq!(
1254                content.rendered,
1255                CowStr::Boxed("bl&lt;ah&gt; red".to_string().into_boxed_str())
1256            );
1257        }
1258    }
1259
1260    mod header {
1261        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1262
1263        #[test]
1264        fn empty() {
1265            let mut content = Content::from(crate::Span::default());
1266            let p = Parser::default();
1267            SubstitutionGroup::Header.apply(&mut content, &p, None);
1268            assert!(content.is_empty());
1269            assert_eq!(content.rendered, CowStr::Borrowed(""));
1270        }
1271
1272        #[test]
1273        fn basic_non_empty_span() {
1274            let mut content = Content::from(crate::Span::new("blah"));
1275            let p = Parser::default();
1276            SubstitutionGroup::Header.apply(&mut content, &p, None);
1277            assert!(!content.is_empty());
1278            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1279        }
1280
1281        #[test]
1282        fn match_lt_and_gt() {
1283            let mut content = Content::from(crate::Span::new("bl<ah>"));
1284            let p = Parser::default();
1285            SubstitutionGroup::Header.apply(&mut content, &p, None);
1286            assert!(!content.is_empty());
1287            assert_eq!(
1288                content.rendered,
1289                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1290            );
1291        }
1292
1293        #[test]
1294        fn match_amp() {
1295            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1296            let p = Parser::default();
1297            SubstitutionGroup::Header.apply(&mut content, &p, None);
1298            assert!(!content.is_empty());
1299            assert_eq!(
1300                content.rendered,
1301                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1302            );
1303        }
1304
1305        #[test]
1306        fn ignores_strong_word() {
1307            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1308            let p = Parser::default();
1309            SubstitutionGroup::Header.apply(&mut content, &p, None);
1310            assert!(!content.is_empty());
1311            assert_eq!(content.rendered, CowStr::Borrowed("One *word* is strong."));
1312        }
1313
1314        #[test]
1315        fn ignores_strong_word_with_special_chars() {
1316            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1317            let p = Parser::default();
1318            SubstitutionGroup::Header.apply(&mut content, &p, None);
1319            assert!(!content.is_empty());
1320            assert_eq!(
1321                content.rendered,
1322                CowStr::Boxed("One *wo&lt;r&gt;d* is strong.".to_string().into_boxed_str())
1323            );
1324        }
1325
1326        #[test]
1327        fn ignores_marked_string_with_id() {
1328            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1329            let p = Parser::default();
1330            SubstitutionGroup::Header.apply(&mut content, &p, None);
1331            assert!(!content.is_empty());
1332            assert_eq!(content.rendered, CowStr::Borrowed("[#id]#a few words#"));
1333        }
1334    }
1335
1336    mod title {
1337        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1338
1339        #[test]
1340        fn empty() {
1341            let mut content = Content::from(crate::Span::default());
1342            let p = Parser::default();
1343            SubstitutionGroup::Title.apply(&mut content, &p, None);
1344            assert!(content.is_empty());
1345            assert_eq!(content.rendered, CowStr::Borrowed(""));
1346        }
1347
1348        #[test]
1349        fn basic_non_empty_span() {
1350            let mut content = Content::from(crate::Span::new("blah"));
1351            let p = Parser::default();
1352            SubstitutionGroup::Title.apply(&mut content, &p, None);
1353            assert!(!content.is_empty());
1354            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1355        }
1356
1357        #[test]
1358        fn match_lt_and_gt() {
1359            let mut content = Content::from(crate::Span::new("bl<ah>"));
1360            let p = Parser::default();
1361            SubstitutionGroup::Title.apply(&mut content, &p, None);
1362            assert!(!content.is_empty());
1363            assert_eq!(
1364                content.rendered,
1365                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1366            );
1367        }
1368
1369        #[test]
1370        fn match_amp() {
1371            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1372            let p = Parser::default();
1373            SubstitutionGroup::Title.apply(&mut content, &p, None);
1374            assert!(!content.is_empty());
1375            assert_eq!(
1376                content.rendered,
1377                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1378            );
1379        }
1380
1381        #[test]
1382        fn strong_word() {
1383            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1384            let p = Parser::default();
1385            SubstitutionGroup::Title.apply(&mut content, &p, None);
1386            assert!(!content.is_empty());
1387            assert_eq!(
1388                content.rendered,
1389                CowStr::Boxed(
1390                    "One <strong>word</strong> is strong."
1391                        .to_string()
1392                        .into_boxed_str()
1393                )
1394            );
1395        }
1396
1397        #[test]
1398        fn strong_word_with_special_chars() {
1399            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1400            let p = Parser::default();
1401            SubstitutionGroup::Title.apply(&mut content, &p, None);
1402            assert!(!content.is_empty());
1403            assert_eq!(
1404                content.rendered,
1405                CowStr::Boxed(
1406                    "One <strong>wo&lt;r&gt;d</strong> is strong."
1407                        .to_string()
1408                        .into_boxed_str()
1409                )
1410            );
1411        }
1412
1413        #[test]
1414        fn marked_string_with_id() {
1415            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1416            let p = Parser::default();
1417            SubstitutionGroup::Title.apply(&mut content, &p, None);
1418            assert!(!content.is_empty());
1419            assert_eq!(
1420                content.rendered,
1421                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1422            );
1423        }
1424
1425        #[test]
1426        fn title_behaves_same_as_normal() {
1427            let test_input = "One *wo<r>d* is strong with [#id]#marked text#.";
1428
1429            let mut title_content = Content::from(crate::Span::new(test_input));
1430            let mut normal_content = Content::from(crate::Span::new(test_input));
1431            let p = Parser::default();
1432
1433            SubstitutionGroup::Title.apply(&mut title_content, &p, None);
1434            SubstitutionGroup::Normal.apply(&mut normal_content, &p, None);
1435
1436            // Title should produce exactly the same result as Normal.
1437            assert_eq!(title_content.rendered, normal_content.rendered);
1438        }
1439    }
1440}