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
246        // Capture any deferred cross-references as a placeholder template and
247        // render the unresolved fallback, so `rendered()` is clean even before
248        // references are resolved. This is a no-op when no cross-references were
249        // found.
250        content.finalize_deferred(&*parser.renderer);
251    }
252
253    /// Applies any block style masquerade and `subs` attribute override from
254    /// the block's attribute list.
255    ///
256    /// When `parser` is provided, unrecognized substitution names in the
257    /// `subs` attribute are recorded as warnings. Parse-time callers should
258    /// pass the parser; accessors that re-derive the group after parsing
259    /// should pass `None` so the warning is only recorded once.
260    pub(crate) fn override_via_attrlist(
261        &self,
262        attrlist: Option<&Attrlist>,
263        parser: Option<&Parser>,
264    ) -> Self {
265        let mut result = self.clone();
266
267        if let Some(attrlist) = attrlist {
268            // A declared block style reinterprets a simple-content (paragraph)
269            // block as another context, which can change the substitution group
270            // that applies. This masquerade only affects blocks whose default
271            // group is `Normal`: a delimited block's delimiter already fixes its
272            // group (verbatim, pass, stem, etc.), and Asciidoctor does not let a
273            // style keyword override it. So the mapping below is scoped to
274            // `Normal` blocks, matching Asciidoctor's parser.
275            if result == SubstitutionGroup::Normal
276                && let Some(block_style) = attrlist.nth_attribute(1).and_then(|a| a.block_style())
277            {
278                result = match block_style {
279                    // The verbatim masquerade styles (`literal`, `listing`, and
280                    // `source`) apply only special characters and callouts.
281                    "literal" | "listing" | "source" => SubstitutionGroup::Verbatim,
282
283                    // The `pass` style excludes the content from all
284                    // substitutions.
285                    "pass" => SubstitutionGroup::None,
286
287                    // Every other style (`normal`, `verse`, `quote`, `sidebar`,
288                    // `example`, admonitions, …) keeps the normal substitution
289                    // group.
290                    _ => result,
291                };
292            }
293
294            if let Some(subs) = attrlist.named_attribute("subs").map(|attr| attr.value()) {
295                let (sub_group, invalid) = Self::from_custom_string(Some(self), subs);
296
297                if !invalid.is_empty()
298                    && let Some(parser) = parser
299                {
300                    parser.record_substitution_warning(
301                        attrlist.span(),
302                        WarningType::InvalidSubstitutionTypeForBlock(invalid.join(", ")),
303                    );
304                }
305
306                result = sub_group;
307            }
308        }
309
310        result
311    }
312
313    fn steps(&self) -> &[SubstitutionStep] {
314        match self {
315            Self::Normal | Self::Title => NORMAL_STEPS,
316
317            Self::Header | Self::AttributeEntryValue => &[
318                SubstitutionStep::SpecialCharacters,
319                SubstitutionStep::AttributeReferences,
320            ],
321
322            Self::Verbatim => VERBATIM_STEPS,
323
324            Self::Stem => &[SubstitutionStep::SpecialCharacters],
325
326            Self::Pass | Self::None => &[],
327
328            Self::Custom(steps) => steps,
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    #![allow(clippy::unwrap_used)]
336
337    mod stem {
338        use crate::{content::Content, strings::CowStr, tests::prelude::*};
339
340        #[test]
341        fn applies_special_characters_only() {
342            // The `Stem` group applies only the special characters substitution:
343            // `<` is escaped, but quotes (`*bold*`) and attribute references
344            // (`{color}`) are left untouched.
345            let mut content = Content::from(crate::Span::new("*a* < {color}"));
346            let p = Parser::default();
347            SubstitutionGroup::Stem.apply(&mut content, &p, None);
348            assert_eq!(
349                content.rendered,
350                CowStr::Boxed("*a* &lt; {color}".to_string().into_boxed_str())
351            );
352        }
353    }
354
355    mod from_custom_string {
356        use crate::{
357            content::{Content, SubstitutionStep},
358            strings::CowStr,
359            tests::prelude::*,
360        };
361
362        #[test]
363        fn empty() {
364            // An empty `subs` value resolves to an empty substitution list,
365            // matching Asciidoctor's `resolve_subs` (which returns no subs for
366            // a nil or empty string).
367            assert_eq!(
368                SubstitutionGroup::from_custom_string(None, ""),
369                (SubstitutionGroup::Custom(vec![]), vec![])
370            );
371        }
372
373        #[test]
374        fn empty_entries() {
375            // A list containing only separators resolves to an empty
376            // substitution list, without reporting any invalid names: Ruby's
377            // `split(',')` drops trailing empty entries, so `","` yields no
378            // tokens at all (issue #784).
379            assert_eq!(
380                SubstitutionGroup::from_custom_string(Some(&SubstitutionGroup::Verbatim), ","),
381                (SubstitutionGroup::Custom(vec![]), vec![])
382            );
383
384            assert_eq!(
385                SubstitutionGroup::from_custom_string(None, " , ,"),
386                (SubstitutionGroup::Custom(vec![]), vec![])
387            );
388
389            assert_eq!(
390                SubstitutionGroup::from_custom_string(None, "quotes,"),
391                (
392                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
393                    vec![]
394                )
395            );
396
397            // A leading or interior empty entry is not dropped by Ruby's
398            // `split(',')`; it resolves like any other unrecognized name, so
399            // it is skipped and reported (with an empty name), matching
400            // Asciidoctor's `resolve_subs`.
401            assert_eq!(
402                SubstitutionGroup::from_custom_string(None, "quotes,,macros"),
403                (
404                    SubstitutionGroup::Custom(vec![
405                        SubstitutionStep::Quotes,
406                        SubstitutionStep::Macros
407                    ]),
408                    vec!["".to_owned()]
409                )
410            );
411
412            // A leading empty entry counts as a (failed) first token, so a
413            // modifier that follows it does not start from the base group,
414            // matching Asciidoctor (where the empty entry makes the candidate
415            // list non-nil before the modifier is seen).
416            assert_eq!(
417                SubstitutionGroup::from_custom_string(
418                    Some(&SubstitutionGroup::Verbatim),
419                    ",+quotes"
420                ),
421                (
422                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
423                    vec!["".to_owned()]
424                )
425            );
426        }
427
428        #[test]
429        fn invalid_names() {
430            // An unrecognized name is skipped and reported; recognized names
431            // in the same list are still honored.
432            assert_eq!(
433                SubstitutionGroup::from_custom_string(None, "bogus"),
434                (SubstitutionGroup::Custom(vec![]), vec!["bogus".to_owned()])
435            );
436
437            assert_eq!(
438                SubstitutionGroup::from_custom_string(None, "bogus,quotes"),
439                (
440                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
441                    vec!["bogus".to_owned()]
442                )
443            );
444
445            // An appended unrecognized name still seeds the list from the
446            // base group before being skipped, matching Asciidoctor.
447            assert_eq!(
448                SubstitutionGroup::from_custom_string(
449                    Some(&SubstitutionGroup::Verbatim),
450                    "+bogus,quotes"
451                ),
452                (
453                    SubstitutionGroup::Custom(vec![
454                        SubstitutionStep::SpecialCharacters,
455                        SubstitutionStep::Callouts,
456                        SubstitutionStep::Quotes,
457                    ]),
458                    vec!["bogus".to_owned()]
459                )
460            );
461
462            // Removing an unrecognized name is a no-op, not an error: in
463            // Asciidoctor's `resolve_subs`, a `-` modifier never adds the
464            // name to the candidate list, so it is never reported as invalid.
465            assert_eq!(
466                SubstitutionGroup::from_custom_string(Some(&SubstitutionGroup::Verbatim), "-bogus"),
467                (
468                    SubstitutionGroup::Custom(vec![
469                        SubstitutionStep::SpecialCharacters,
470                        SubstitutionStep::Callouts,
471                    ]),
472                    vec![]
473                )
474            );
475        }
476
477        #[test]
478        fn none() {
479            assert_eq!(
480                SubstitutionGroup::from_custom_string(None, "none"),
481                (SubstitutionGroup::None, vec![])
482            );
483
484            // `none` mid-list resolves to an empty step list, like
485            // Asciidoctor's `SUB_GROUPS[:none]`.
486            assert_eq!(
487                SubstitutionGroup::from_custom_string(None, "quotes,none"),
488                (
489                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
490                    vec![]
491                )
492            );
493
494            assert_eq!(
495                SubstitutionGroup::from_custom_string(None, "nermal"),
496                (SubstitutionGroup::Custom(vec![]), vec!["nermal".to_owned()])
497            );
498        }
499
500        #[test]
501        fn normal() {
502            assert_eq!(
503                SubstitutionGroup::from_custom_string(None, "n"),
504                (SubstitutionGroup::Normal, vec![])
505            );
506
507            assert_eq!(
508                SubstitutionGroup::from_custom_string(None, "normal"),
509                (SubstitutionGroup::Normal, vec![])
510            );
511        }
512
513        #[test]
514        fn verbatim() {
515            assert_eq!(
516                SubstitutionGroup::from_custom_string(None, "v"),
517                (SubstitutionGroup::Verbatim, vec![])
518            );
519
520            assert_eq!(
521                SubstitutionGroup::from_custom_string(None, "verbatim"),
522                (SubstitutionGroup::Verbatim, vec![])
523            );
524
525            assert_eq!(
526                SubstitutionGroup::from_custom_string(None, "verboten"),
527                (
528                    SubstitutionGroup::Custom(vec![]),
529                    vec!["verboten".to_owned()]
530                )
531            );
532        }
533
534        #[test]
535        fn special_chars() {
536            assert_eq!(
537                SubstitutionGroup::from_custom_string(None, "c"),
538                (
539                    SubstitutionGroup::Custom(vec![SubstitutionStep::SpecialCharacters]),
540                    vec![]
541                )
542            );
543
544            assert_eq!(
545                SubstitutionGroup::from_custom_string(None, "specialchars"),
546                (
547                    SubstitutionGroup::Custom(vec![SubstitutionStep::SpecialCharacters]),
548                    vec![]
549                )
550            );
551        }
552
553        #[test]
554        fn quotes() {
555            assert_eq!(
556                SubstitutionGroup::from_custom_string(None, "q"),
557                (
558                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
559                    vec![]
560                )
561            );
562
563            assert_eq!(
564                SubstitutionGroup::from_custom_string(None, "quotes"),
565                (
566                    SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]),
567                    vec![]
568                )
569            );
570        }
571
572        #[test]
573        fn attributes() {
574            assert_eq!(
575                SubstitutionGroup::from_custom_string(None, "a"),
576                (
577                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences]),
578                    vec![]
579                )
580            );
581
582            assert_eq!(
583                SubstitutionGroup::from_custom_string(None, "attributes"),
584                (
585                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences]),
586                    vec![]
587                )
588            );
589        }
590
591        #[test]
592        fn replacements() {
593            assert_eq!(
594                SubstitutionGroup::from_custom_string(None, "r"),
595                (
596                    SubstitutionGroup::Custom(vec![SubstitutionStep::CharacterReplacements]),
597                    vec![]
598                )
599            );
600
601            assert_eq!(
602                SubstitutionGroup::from_custom_string(None, "replacements"),
603                (
604                    SubstitutionGroup::Custom(vec![SubstitutionStep::CharacterReplacements]),
605                    vec![]
606                )
607            );
608        }
609
610        #[test]
611        fn macros() {
612            assert_eq!(
613                SubstitutionGroup::from_custom_string(None, "m"),
614                (
615                    SubstitutionGroup::Custom(vec![SubstitutionStep::Macros]),
616                    vec![]
617                )
618            );
619
620            assert_eq!(
621                SubstitutionGroup::from_custom_string(None, "macros"),
622                (
623                    SubstitutionGroup::Custom(vec![SubstitutionStep::Macros]),
624                    vec![]
625                )
626            );
627        }
628
629        #[test]
630        fn post_replacements() {
631            assert_eq!(
632                SubstitutionGroup::from_custom_string(None, "p"),
633                (
634                    SubstitutionGroup::Custom(vec![SubstitutionStep::PostReplacement]),
635                    vec![]
636                )
637            );
638
639            assert_eq!(
640                SubstitutionGroup::from_custom_string(None, "post_replacements"),
641                (
642                    SubstitutionGroup::Custom(vec![SubstitutionStep::PostReplacement]),
643                    vec![]
644                )
645            );
646        }
647
648        #[test]
649        fn multiple() {
650            assert_eq!(
651                SubstitutionGroup::from_custom_string(None, "q,a"),
652                (
653                    SubstitutionGroup::Custom(vec![
654                        SubstitutionStep::Quotes,
655                        SubstitutionStep::AttributeReferences
656                    ]),
657                    vec![]
658                )
659            );
660
661            assert_eq!(
662                SubstitutionGroup::from_custom_string(None, "q, a"),
663                (
664                    SubstitutionGroup::Custom(vec![
665                        SubstitutionStep::Quotes,
666                        SubstitutionStep::AttributeReferences
667                    ]),
668                    vec![]
669                )
670            );
671
672            assert_eq!(
673                SubstitutionGroup::from_custom_string(None, "quotes,attributes"),
674                (
675                    SubstitutionGroup::Custom(vec![
676                        SubstitutionStep::Quotes,
677                        SubstitutionStep::AttributeReferences
678                    ]),
679                    vec![]
680                )
681            );
682
683            assert_eq!(
684                SubstitutionGroup::from_custom_string(None, "x,bogus,no such step"),
685                (
686                    SubstitutionGroup::Custom(vec![]),
687                    vec![
688                        "x".to_owned(),
689                        "bogus".to_owned(),
690                        "no such step".to_owned()
691                    ]
692                )
693            );
694        }
695
696        #[test]
697        fn subtraction() {
698            assert_eq!(
699                SubstitutionGroup::from_custom_string(None, "n,-r"),
700                (
701                    SubstitutionGroup::Custom(vec![
702                        SubstitutionStep::SpecialCharacters,
703                        SubstitutionStep::Quotes,
704                        SubstitutionStep::AttributeReferences,
705                        SubstitutionStep::Macros,
706                        SubstitutionStep::PostReplacement,
707                    ]),
708                    vec![]
709                )
710            );
711
712            assert_eq!(
713                SubstitutionGroup::from_custom_string(None, "n,-r,-r,-m"),
714                (
715                    SubstitutionGroup::Custom(vec![
716                        SubstitutionStep::SpecialCharacters,
717                        SubstitutionStep::Quotes,
718                        SubstitutionStep::AttributeReferences,
719                        SubstitutionStep::PostReplacement,
720                    ]),
721                    vec![]
722                )
723            );
724
725            assert_eq!(
726                SubstitutionGroup::from_custom_string(None, "v,-r"),
727                (
728                    SubstitutionGroup::Custom(vec![
729                        SubstitutionStep::SpecialCharacters,
730                        SubstitutionStep::Callouts,
731                    ]),
732                    vec![]
733                )
734            );
735
736            assert_eq!(
737                SubstitutionGroup::from_custom_string(None, "v,-c"),
738                (
739                    SubstitutionGroup::Custom(vec![SubstitutionStep::Callouts]),
740                    vec![]
741                )
742            );
743
744            assert_eq!(
745                SubstitutionGroup::from_custom_string(None, "v,-callouts"),
746                (
747                    SubstitutionGroup::Custom(vec![SubstitutionStep::SpecialCharacters,]),
748                    vec![]
749                )
750            );
751        }
752
753        #[test]
754        fn addition() {
755            // `n` expands to normal's steps (which already include
756            // replacements); the trailing `r` is de-duplicated away, matching
757            // Asciidoctor.
758            assert_eq!(
759                SubstitutionGroup::from_custom_string(None, "n,r"),
760                (
761                    SubstitutionGroup::Custom(vec![
762                        SubstitutionStep::SpecialCharacters,
763                        SubstitutionStep::Quotes,
764                        SubstitutionStep::AttributeReferences,
765                        SubstitutionStep::CharacterReplacements,
766                        SubstitutionStep::Macros,
767                        SubstitutionStep::PostReplacement,
768                    ]),
769                    vec![]
770                )
771            );
772
773            assert_eq!(
774                SubstitutionGroup::from_custom_string(None, "v,m"),
775                (
776                    SubstitutionGroup::Custom(vec![
777                        SubstitutionStep::SpecialCharacters,
778                        SubstitutionStep::Callouts,
779                        SubstitutionStep::Macros,
780                    ]),
781                    vec![]
782                )
783            );
784        }
785
786        #[test]
787        fn incremental() {
788            // `n` expands to normal's steps (which already include
789            // replacements); the trailing `r` is de-duplicated away, matching
790            // Asciidoctor.
791            assert_eq!(
792                SubstitutionGroup::from_custom_string(None, "n,r"),
793                (
794                    SubstitutionGroup::Custom(vec![
795                        SubstitutionStep::SpecialCharacters,
796                        SubstitutionStep::Quotes,
797                        SubstitutionStep::AttributeReferences,
798                        SubstitutionStep::CharacterReplacements,
799                        SubstitutionStep::Macros,
800                        SubstitutionStep::PostReplacement,
801                    ]),
802                    vec![]
803                )
804            );
805
806            assert_eq!(
807                SubstitutionGroup::from_custom_string(None, "v,m"),
808                (
809                    SubstitutionGroup::Custom(vec![
810                        SubstitutionStep::SpecialCharacters,
811                        SubstitutionStep::Callouts,
812                        SubstitutionStep::Macros,
813                    ]),
814                    vec![]
815                )
816            );
817        }
818
819        #[test]
820        fn group_name_mid_list_expands_in_place_and_dedups() {
821            // A group name (`normal`) appearing mid-list is expanded in place
822            // and appended to what came before, rather than resetting the
823            // accumulated steps. The leading `quotes` is preserved, and the
824            // redundant `quotes` from `normal`'s expansion is de-duplicated
825            // away, matching Asciidoctor's `resolve_subs`.
826            assert_eq!(
827                SubstitutionGroup::from_custom_string(None, "quotes,normal"),
828                (
829                    SubstitutionGroup::Custom(vec![
830                        SubstitutionStep::Quotes,
831                        SubstitutionStep::SpecialCharacters,
832                        SubstitutionStep::AttributeReferences,
833                        SubstitutionStep::CharacterReplacements,
834                        SubstitutionStep::Macros,
835                        SubstitutionStep::PostReplacement,
836                    ]),
837                    vec![]
838                )
839            );
840
841            // Same behavior for the shorthand `v` group name mid-list.
842            assert_eq!(
843                SubstitutionGroup::from_custom_string(None, "m,v"),
844                (
845                    SubstitutionGroup::Custom(vec![
846                        SubstitutionStep::Macros,
847                        SubstitutionStep::SpecialCharacters,
848                        SubstitutionStep::Callouts,
849                    ]),
850                    vec![]
851                )
852            );
853        }
854
855        #[test]
856        fn prepend() {
857            assert_eq!(
858                SubstitutionGroup::from_custom_string(
859                    Some(&SubstitutionGroup::Verbatim),
860                    "attributes+"
861                ),
862                (
863                    SubstitutionGroup::Custom(vec![
864                        SubstitutionStep::AttributeReferences,
865                        SubstitutionStep::SpecialCharacters,
866                        SubstitutionStep::Callouts,
867                    ]),
868                    vec![]
869                )
870            );
871
872            assert_eq!(
873                SubstitutionGroup::from_custom_string(None, "attributes+"),
874                (
875                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences,]),
876                    vec![]
877                )
878            );
879        }
880
881        #[test]
882        fn append() {
883            assert_eq!(
884                SubstitutionGroup::from_custom_string(
885                    Some(&SubstitutionGroup::Verbatim),
886                    "+attributes"
887                ),
888                (
889                    SubstitutionGroup::Custom(vec![
890                        SubstitutionStep::SpecialCharacters,
891                        SubstitutionStep::Callouts,
892                        SubstitutionStep::AttributeReferences,
893                    ]),
894                    vec![]
895                )
896            );
897
898            assert_eq!(
899                SubstitutionGroup::from_custom_string(None, "attributes+"),
900                (
901                    SubstitutionGroup::Custom(vec![SubstitutionStep::AttributeReferences,]),
902                    vec![]
903                )
904            );
905        }
906
907        #[test]
908        fn subtract() {
909            assert_eq!(
910                SubstitutionGroup::from_custom_string(
911                    Some(&SubstitutionGroup::Normal),
912                    "-attributes"
913                ),
914                (
915                    SubstitutionGroup::Custom(vec![
916                        SubstitutionStep::SpecialCharacters,
917                        SubstitutionStep::Quotes,
918                        SubstitutionStep::CharacterReplacements,
919                        SubstitutionStep::Macros,
920                        SubstitutionStep::PostReplacement,
921                    ]),
922                    vec![]
923                )
924            );
925
926            assert_eq!(
927                SubstitutionGroup::from_custom_string(None, "-attributes"),
928                (SubstitutionGroup::Custom(vec![]), vec![])
929            );
930        }
931
932        #[test]
933        fn custom_group_with_macros_preserves_passthroughs() {
934            let custom_group = SubstitutionGroup::from_custom_string(None, "q,m").0;
935
936            let mut content = Content::from(crate::Span::new(
937                "Text with +++pass<through>+++ icon:github[] content.",
938            ));
939            let p = Parser::default();
940            custom_group.apply(&mut content, &p, None);
941
942            assert!(!content.is_empty());
943            assert_eq!(
944                content.rendered,
945                CowStr::Boxed(
946                    "Text with pass<through> <span class=\"icon\">[github&#93;</span> content."
947                        .to_string()
948                        .into_boxed_str()
949                )
950            );
951        }
952    }
953
954    mod override_via_attrlist {
955        use crate::{
956            attributes::{Attrlist, AttrlistContext},
957            tests::prelude::*,
958        };
959
960        /// Resolve the substitution group that `base` maps to when the given
961        /// attribute list (block style, `subs=`, …) is applied.
962        fn resolve(base: SubstitutionGroup, attrlist: &str) -> SubstitutionGroup {
963            let p = Parser::default();
964            let attrlist = Attrlist::parse(crate::Span::new(attrlist), &p, AttrlistContext::Block)
965                .item
966                .item;
967
968            base.override_via_attrlist(Some(&attrlist), None)
969        }
970
971        #[test]
972        fn verbatim_masquerade_styles_promote_normal_to_verbatim() {
973            // On a simple-content (paragraph) block, the `literal`, `listing`,
974            // and `source` styles switch the substitution group to verbatim.
975            for style in ["literal", "listing", "source"] {
976                assert_eq!(
977                    resolve(SubstitutionGroup::Normal, style),
978                    SubstitutionGroup::Verbatim,
979                    "style `{style}` should map Normal to Verbatim"
980                );
981            }
982        }
983
984        #[test]
985        fn pass_style_suppresses_substitutions_on_normal() {
986            assert_eq!(
987                resolve(SubstitutionGroup::Normal, "pass"),
988                SubstitutionGroup::None
989            );
990        }
991
992        #[test]
993        fn non_masquerade_styles_keep_normal() {
994            // Styles whose content model is simple (or compound) keep the normal
995            // substitution group; e.g. `verse` uses normal subs even though its
996            // content model is verbatim.
997            for style in ["normal", "verse", "quote", "sidebar", "example"] {
998                assert_eq!(
999                    resolve(SubstitutionGroup::Normal, style),
1000                    SubstitutionGroup::Normal,
1001                    "style `{style}` should keep Normal"
1002                );
1003            }
1004        }
1005
1006        #[test]
1007        fn style_does_not_override_a_delimited_block_group() {
1008            // A delimited block's delimiter fixes its substitution group; a style
1009            // keyword must not override it (matching Asciidoctor). A `[pass]`
1010            // style on a `----`/`....` verbatim block keeps verbatim subs, and a
1011            // `[source]` style on a `++++` pass block keeps the pass group.
1012            assert_eq!(
1013                resolve(SubstitutionGroup::Verbatim, "pass"),
1014                SubstitutionGroup::Verbatim
1015            );
1016
1017            assert_eq!(
1018                resolve(SubstitutionGroup::Pass, "source"),
1019                SubstitutionGroup::Pass
1020            );
1021
1022            assert_eq!(
1023                resolve(SubstitutionGroup::Stem, "source"),
1024                SubstitutionGroup::Stem
1025            );
1026        }
1027
1028        #[test]
1029        fn subs_attribute_still_overrides() {
1030            // An explicit `subs=` attribute overrides the group regardless of the
1031            // block style, and takes precedence over the style masquerade.
1032            assert_eq!(
1033                resolve(SubstitutionGroup::Normal, "listing,subs=normal"),
1034                SubstitutionGroup::Normal
1035            );
1036
1037            assert_eq!(
1038                resolve(SubstitutionGroup::Verbatim, "subs=none"),
1039                SubstitutionGroup::None
1040            );
1041        }
1042
1043        #[test]
1044        fn warns_on_invalid_subs_name_and_honors_valid_names() {
1045            // Verified against Asciidoctor 2.0.26: the unrecognized name is
1046            // warned about and skipped, while the recognized `quotes` sub is
1047            // still applied. The `&` is left unescaped because
1048            // `specialchars` is not in the resolved list.
1049            let mut p = Parser::default();
1050            let doc = p.parse("[subs=\"bogus,quotes\"]\nabc *bold* &\ndef");
1051
1052            let block = doc.child_blocks().next().unwrap();
1053            assert_eq!(
1054                block.rendered_content(),
1055                Some("abc <strong>bold</strong> &\ndef")
1056            );
1057
1058            let warnings: Vec<_> = doc.warnings().collect();
1059            assert_eq!(warnings.len(), 1);
1060            assert_eq!(
1061                warnings.first().unwrap().warning,
1062                crate::warnings::WarningType::InvalidSubstitutionTypeForBlock("bogus".to_owned())
1063            );
1064        }
1065
1066        #[test]
1067        fn no_warning_for_empty_subs_list() {
1068            // An empty list (`subs=","`) resolves to no substitutions without
1069            // any warning, matching Asciidoctor (issue #784).
1070            let mut p = Parser::default();
1071            let doc = p.parse("[subs=\",\"]\n....\ncontent <here>\n....");
1072
1073            let block = doc.child_blocks().next().unwrap();
1074            assert_eq!(block.rendered_content(), Some("content <here>"));
1075
1076            assert_eq!(doc.warnings().count(), 0);
1077        }
1078    }
1079
1080    mod normal {
1081        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1082
1083        #[test]
1084        fn empty() {
1085            let mut content = Content::from(crate::Span::default());
1086            let p = Parser::default();
1087            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1088            assert!(content.is_empty());
1089            assert_eq!(content.rendered, CowStr::Borrowed(""));
1090        }
1091
1092        #[test]
1093        fn basic_non_empty_span() {
1094            let mut content = Content::from(crate::Span::new("blah"));
1095            let p = Parser::default();
1096            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1097            assert!(!content.is_empty());
1098            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1099        }
1100
1101        #[test]
1102        fn match_lt_and_gt() {
1103            let mut content = Content::from(crate::Span::new("bl<ah>"));
1104            let p = Parser::default();
1105            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1106            assert!(!content.is_empty());
1107            assert_eq!(
1108                content.rendered,
1109                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1110            );
1111        }
1112
1113        #[test]
1114        fn match_amp() {
1115            let mut content = Content::from(crate::Span::new("bl<a&h>"));
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;a&amp;h&gt;".to_string().into_boxed_str())
1122            );
1123        }
1124
1125        #[test]
1126        fn strong_word() {
1127            let mut content = Content::from(crate::Span::new("One *word* is strong."));
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(
1134                    "One <strong>word</strong> is strong."
1135                        .to_string()
1136                        .into_boxed_str()
1137                )
1138            );
1139        }
1140
1141        #[test]
1142        fn strong_word_with_special_chars() {
1143            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1144            let p = Parser::default();
1145            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1146            assert!(!content.is_empty());
1147            assert_eq!(
1148                content.rendered,
1149                CowStr::Boxed(
1150                    "One <strong>wo&lt;r&gt;d</strong> is strong."
1151                        .to_string()
1152                        .into_boxed_str()
1153                )
1154            );
1155        }
1156
1157        #[test]
1158        fn marked_string_with_id() {
1159            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1160            let p = Parser::default();
1161            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1162            assert!(!content.is_empty());
1163            assert_eq!(
1164                content.rendered,
1165                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1166            );
1167        }
1168    }
1169
1170    mod attribute_entry_value {
1171        use crate::{
1172            content::Content, parser::ModificationContext, strings::CowStr, tests::prelude::*,
1173        };
1174
1175        #[test]
1176        fn empty() {
1177            let mut content = Content::from(crate::Span::default());
1178            let p = Parser::default();
1179            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1180            assert!(content.is_empty());
1181            assert_eq!(content.rendered, CowStr::Borrowed(""));
1182        }
1183
1184        #[test]
1185        fn basic_non_empty_span() {
1186            let mut content = Content::from(crate::Span::new("blah"));
1187            let p = Parser::default();
1188            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1189            assert!(!content.is_empty());
1190            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1191        }
1192
1193        #[test]
1194        fn match_lt_and_gt() {
1195            let mut content = Content::from(crate::Span::new("bl<ah>"));
1196            let p = Parser::default();
1197            SubstitutionGroup::Normal.apply(&mut content, &p, None);
1198            assert!(!content.is_empty());
1199            assert_eq!(
1200                content.rendered,
1201                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1202            );
1203        }
1204
1205        #[test]
1206        fn match_amp() {
1207            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1208            let p = Parser::default();
1209            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1210            assert!(!content.is_empty());
1211            assert_eq!(
1212                content.rendered,
1213                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1214            );
1215        }
1216
1217        #[test]
1218        fn ignores_strong_word() {
1219            let mut content = Content::from(crate::Span::new("One *word* is strong."));
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("One *word* is strong.".to_string().into_boxed_str())
1226            );
1227        }
1228
1229        #[test]
1230        fn special_chars_and_attributes() {
1231            let mut content = Content::from(crate::Span::new("bl<ah> {color}"));
1232
1233            let p = Parser::default().with_intrinsic_attribute(
1234                "color",
1235                "red",
1236                ModificationContext::Anywhere,
1237            );
1238
1239            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
1240            assert!(!content.is_empty());
1241            assert_eq!(
1242                content.rendered,
1243                CowStr::Boxed("bl&lt;ah&gt; red".to_string().into_boxed_str())
1244            );
1245        }
1246    }
1247
1248    mod header {
1249        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1250
1251        #[test]
1252        fn empty() {
1253            let mut content = Content::from(crate::Span::default());
1254            let p = Parser::default();
1255            SubstitutionGroup::Header.apply(&mut content, &p, None);
1256            assert!(content.is_empty());
1257            assert_eq!(content.rendered, CowStr::Borrowed(""));
1258        }
1259
1260        #[test]
1261        fn basic_non_empty_span() {
1262            let mut content = Content::from(crate::Span::new("blah"));
1263            let p = Parser::default();
1264            SubstitutionGroup::Header.apply(&mut content, &p, None);
1265            assert!(!content.is_empty());
1266            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1267        }
1268
1269        #[test]
1270        fn match_lt_and_gt() {
1271            let mut content = Content::from(crate::Span::new("bl<ah>"));
1272            let p = Parser::default();
1273            SubstitutionGroup::Header.apply(&mut content, &p, None);
1274            assert!(!content.is_empty());
1275            assert_eq!(
1276                content.rendered,
1277                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1278            );
1279        }
1280
1281        #[test]
1282        fn match_amp() {
1283            let mut content = Content::from(crate::Span::new("bl<a&h>"));
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;a&amp;h&gt;".to_string().into_boxed_str())
1290            );
1291        }
1292
1293        #[test]
1294        fn ignores_strong_word() {
1295            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1296            let p = Parser::default();
1297            SubstitutionGroup::Header.apply(&mut content, &p, None);
1298            assert!(!content.is_empty());
1299            assert_eq!(content.rendered, CowStr::Borrowed("One *word* is strong."));
1300        }
1301
1302        #[test]
1303        fn ignores_strong_word_with_special_chars() {
1304            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1305            let p = Parser::default();
1306            SubstitutionGroup::Header.apply(&mut content, &p, None);
1307            assert!(!content.is_empty());
1308            assert_eq!(
1309                content.rendered,
1310                CowStr::Boxed("One *wo&lt;r&gt;d* is strong.".to_string().into_boxed_str())
1311            );
1312        }
1313
1314        #[test]
1315        fn ignores_marked_string_with_id() {
1316            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1317            let p = Parser::default();
1318            SubstitutionGroup::Header.apply(&mut content, &p, None);
1319            assert!(!content.is_empty());
1320            assert_eq!(content.rendered, CowStr::Borrowed("[#id]#a few words#"));
1321        }
1322    }
1323
1324    mod title {
1325        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1326
1327        #[test]
1328        fn empty() {
1329            let mut content = Content::from(crate::Span::default());
1330            let p = Parser::default();
1331            SubstitutionGroup::Title.apply(&mut content, &p, None);
1332            assert!(content.is_empty());
1333            assert_eq!(content.rendered, CowStr::Borrowed(""));
1334        }
1335
1336        #[test]
1337        fn basic_non_empty_span() {
1338            let mut content = Content::from(crate::Span::new("blah"));
1339            let p = Parser::default();
1340            SubstitutionGroup::Title.apply(&mut content, &p, None);
1341            assert!(!content.is_empty());
1342            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1343        }
1344
1345        #[test]
1346        fn match_lt_and_gt() {
1347            let mut content = Content::from(crate::Span::new("bl<ah>"));
1348            let p = Parser::default();
1349            SubstitutionGroup::Title.apply(&mut content, &p, None);
1350            assert!(!content.is_empty());
1351            assert_eq!(
1352                content.rendered,
1353                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1354            );
1355        }
1356
1357        #[test]
1358        fn match_amp() {
1359            let mut content = Content::from(crate::Span::new("bl<a&h>"));
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;a&amp;h&gt;".to_string().into_boxed_str())
1366            );
1367        }
1368
1369        #[test]
1370        fn strong_word() {
1371            let mut content = Content::from(crate::Span::new("One *word* is strong."));
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(
1378                    "One <strong>word</strong> is strong."
1379                        .to_string()
1380                        .into_boxed_str()
1381                )
1382            );
1383        }
1384
1385        #[test]
1386        fn strong_word_with_special_chars() {
1387            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1388            let p = Parser::default();
1389            SubstitutionGroup::Title.apply(&mut content, &p, None);
1390            assert!(!content.is_empty());
1391            assert_eq!(
1392                content.rendered,
1393                CowStr::Boxed(
1394                    "One <strong>wo&lt;r&gt;d</strong> is strong."
1395                        .to_string()
1396                        .into_boxed_str()
1397                )
1398            );
1399        }
1400
1401        #[test]
1402        fn marked_string_with_id() {
1403            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1404            let p = Parser::default();
1405            SubstitutionGroup::Title.apply(&mut content, &p, None);
1406            assert!(!content.is_empty());
1407            assert_eq!(
1408                content.rendered,
1409                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1410            );
1411        }
1412
1413        #[test]
1414        fn title_behaves_same_as_normal() {
1415            let test_input = "One *wo<r>d* is strong with [#id]#marked text#.";
1416
1417            let mut title_content = Content::from(crate::Span::new(test_input));
1418            let mut normal_content = Content::from(crate::Span::new(test_input));
1419            let p = Parser::default();
1420
1421            SubstitutionGroup::Title.apply(&mut title_content, &p, None);
1422            SubstitutionGroup::Normal.apply(&mut normal_content, &p, None);
1423
1424            // Title should produce exactly the same result as Normal.
1425            assert_eq!(title_content.rendered, normal_content.rendered);
1426        }
1427    }
1428}