Skip to main content

asciidoc_parser/content/
substitution_group.rs

1use crate::{
2    Parser,
3    attributes::Attrlist,
4    content::{Content, Passthroughs, SubstitutionStep},
5};
6
7/// Each block and inline element has a default substitution group that is
8/// applied unless you customize the substitutions for a particular element.
9///
10/// `SubstitutionGroup` specifies the default or overridden substitution group
11/// to be applied.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub enum SubstitutionGroup {
14    /// The normal substitution group is applied to the majority of the AsciiDoc
15    /// block and inline elements except for specific elements described in the
16    /// next sections.
17    Normal,
18
19    /// The title substitution group is applied to section and block titles.
20    /// It uses the same substitution steps as Normal.
21    Title,
22
23    /// The header substitution group is applied to metadata lines (author and
24    /// revision information) in the document header. It’s also applied to the
25    /// values of attribute entries, regardless of whether those entries are
26    /// defined in the document header or body. Only special characters,
27    /// attribute references, and the inline pass macro are replaced in elements
28    /// that fall under the header group.
29    ///
30    /// You can use the inline pass macro in attribute entries to customize the
31    /// substitution types applied to the attribute’s value.
32    Header,
33
34    /// Literal, listing, and source blocks are processed using the verbatim
35    /// substitution group. Only special characters are replaced in these
36    /// blocks.
37    Verbatim,
38
39    /// No substitutions are applied to three of the elements in the pass
40    /// substitution group. These elements include the passthrough block, inline
41    /// pass macro, and triple plus macro.
42    ///
43    /// The inline single plus and double plus macros also belong to the pass
44    /// group. Only the special characters substitution is applied to these
45    /// elements.
46    Pass,
47
48    /// The none substitution group is applied to comment blocks. No
49    /// substitutions are applied to comments.
50    None,
51
52    /// The attribute entry value substitution group is applied to attribute
53    /// values. Only special characters and attribute references are applied to
54    /// these values.
55    AttributeEntryValue,
56
57    /// The STEM substitution group is applied to STEM (`stem`, `asciimath`, and
58    /// `latexmath`) content when no explicit substitution list is given. Only
59    /// the special characters substitution is applied (Asciidoctor's basic subs
60    /// for HTML output). Used by both the inline STEM macro and the STEM block.
61    Stem,
62
63    /// You can customize the substitutions applied to the content of an inline
64    /// pass macro by specifying one or more substitution values. Multiple
65    /// values must be separated by commas and may not contain any spaces. The
66    /// substitution value is either the formal name of a substitution type or
67    /// group, or its shorthand.
68    ///
69    /// See [Custom substitutions].
70    ///
71    /// [Custom substitutions]: https://docs.asciidoctor.org/asciidoc/latest/pass/pass-macro/#custom-substitutions
72    Custom(Vec<SubstitutionStep>),
73}
74
75impl SubstitutionGroup {
76    /// Parse the custom substitution group syntax defined in [Custom
77    /// substitutions].
78    ///
79    /// [Custom substitutions]: https://docs.asciidoctor.org/asciidoc/latest/pass/pass-macro/#custom-substitutions
80    pub(crate) fn from_custom_string(start_from: Option<&Self>, mut custom: &str) -> Option<Self> {
81        custom = custom.trim();
82
83        if custom == "none" {
84            return Some(Self::None);
85        }
86
87        if custom == "n" || custom == "normal" {
88            return Some(Self::Normal);
89        }
90
91        if custom == "v" || custom == "verbatim" {
92            return Some(Self::Verbatim);
93        }
94
95        let mut steps: Vec<SubstitutionStep> = vec![];
96
97        for (count, mut step) in custom.split(",").enumerate() {
98            step = step.trim();
99
100            // A group name (`normal`/`verbatim`) is expanded *in place*: its
101            // constituent steps are appended to the running list rather than
102            // replacing it. This matches Asciidoctor's `resolve_subs`, where a
103            // group name mid-list contributes its steps like any other token.
104            if step == "n" || step == "normal" {
105                steps.extend([
106                    SubstitutionStep::SpecialCharacters,
107                    SubstitutionStep::Quotes,
108                    SubstitutionStep::AttributeReferences,
109                    SubstitutionStep::CharacterReplacements,
110                    SubstitutionStep::Macros,
111                    SubstitutionStep::PostReplacement,
112                ]);
113                continue;
114            }
115
116            if step == "v" || step == "verbatim" {
117                steps.extend([
118                    SubstitutionStep::SpecialCharacters,
119                    SubstitutionStep::Callouts,
120                ]);
121                continue;
122            }
123
124            let append = if step.starts_with('+') {
125                step = &step[1..];
126                true
127            } else {
128                false
129            };
130
131            let prepend = if !append && step.ends_with('+') {
132                step = &step[0..step.len() - 1];
133                true
134            } else {
135                false
136            };
137
138            let subtract = if !append && !prepend && step.starts_with('-') {
139                step = &step[1..];
140                true
141            } else {
142                false
143            };
144
145            if count == 0
146                && let Some(start_from) = start_from
147                && (append || prepend || subtract)
148            {
149                steps = start_from.steps().to_owned();
150            }
151
152            let step = match step {
153                "c" | "specialcharacters" | "specialchars" => SubstitutionStep::SpecialCharacters,
154                "q" | "quotes" => SubstitutionStep::Quotes,
155                "a" | "attributes" => SubstitutionStep::AttributeReferences,
156                "r" | "replacements" => SubstitutionStep::CharacterReplacements,
157                "m" | "macros" => SubstitutionStep::Macros,
158                "p" | "post_replacements" => SubstitutionStep::PostReplacement,
159                "callouts" => SubstitutionStep::Callouts,
160                _ => {
161                    return None;
162                }
163            };
164
165            if prepend {
166                steps.insert(0, step);
167            } else if append {
168                steps.push(step);
169            } else if subtract {
170                steps.retain(|s| s != &step);
171            } else {
172                steps.push(step);
173            }
174        }
175
176        // De-duplicate the final list, first occurrence winning. Asciidoctor
177        // ensures each substitution runs at most once, so a step contributed by
178        // more than one token (e.g. the `quotes` in `quotes,normal`) is kept
179        // only in its earliest position.
180        let mut deduped: Vec<SubstitutionStep> = Vec::with_capacity(steps.len());
181        for step in steps {
182            if !deduped.contains(&step) {
183                deduped.push(step);
184            }
185        }
186
187        Some(Self::Custom(deduped))
188    }
189
190    pub(crate) fn apply(
191        &self,
192        content: &mut Content<'_>,
193        parser: &Parser,
194        attrlist: Option<&Attrlist>,
195    ) {
196        let steps = self.steps();
197
198        let passthroughs: Option<Passthroughs> =
199            if steps.contains(&SubstitutionStep::Macros) || self == &Self::Header {
200                Some(Passthroughs::extract_from(content, parser))
201            } else {
202                None
203            };
204
205        for step in steps {
206            step.apply(content, parser, attrlist);
207        }
208
209        if let Some(passthroughs) = passthroughs {
210            passthroughs.restore_to(content, parser);
211        }
212
213        // Capture any deferred cross-references as a placeholder template and
214        // render the unresolved fallback, so `rendered()` is clean even before
215        // references are resolved. This is a no-op when no cross-references were
216        // found.
217        content.finalize_deferred(&*parser.renderer);
218    }
219
220    pub(crate) fn override_via_attrlist(&self, attrlist: Option<&Attrlist>) -> Self {
221        let mut result = self.clone();
222
223        if let Some(attrlist) = attrlist {
224            // A declared block style reinterprets a simple-content (paragraph)
225            // block as another context, which can change the substitution group
226            // that applies. This masquerade only affects blocks whose default
227            // group is `Normal`: a delimited block's delimiter already fixes its
228            // group (verbatim, pass, stem, etc.), and Asciidoctor does not let a
229            // style keyword override it. So the mapping below is scoped to
230            // `Normal` blocks, matching Asciidoctor's parser.
231            if result == SubstitutionGroup::Normal
232                && let Some(block_style) = attrlist.nth_attribute(1).and_then(|a| a.block_style())
233            {
234                result = match block_style {
235                    // The verbatim masquerade styles (`literal`, `listing`, and
236                    // `source`) apply only special characters and callouts.
237                    "literal" | "listing" | "source" => SubstitutionGroup::Verbatim,
238
239                    // The `pass` style excludes the content from all
240                    // substitutions.
241                    "pass" => SubstitutionGroup::None,
242
243                    // Every other style (`normal`, `verse`, `quote`, `sidebar`,
244                    // `example`, admonitions, …) keeps the normal substitution
245                    // group.
246                    _ => result,
247                };
248            }
249
250            if let Some(sub_group) = attrlist
251                .named_attribute("subs")
252                .map(|attr| attr.value())
253                .and_then(|s| Self::from_custom_string(Some(self), s))
254            {
255                result = sub_group;
256            }
257        }
258
259        result
260    }
261
262    fn steps(&self) -> &[SubstitutionStep] {
263        match self {
264            Self::Normal | Self::Title => &[
265                SubstitutionStep::SpecialCharacters,
266                SubstitutionStep::Quotes,
267                SubstitutionStep::AttributeReferences,
268                SubstitutionStep::CharacterReplacements,
269                SubstitutionStep::Macros,
270                SubstitutionStep::PostReplacement,
271            ],
272
273            Self::Header | Self::AttributeEntryValue => &[
274                SubstitutionStep::SpecialCharacters,
275                SubstitutionStep::AttributeReferences,
276            ],
277
278            Self::Verbatim => &[
279                SubstitutionStep::SpecialCharacters,
280                SubstitutionStep::Callouts,
281            ],
282
283            Self::Stem => &[SubstitutionStep::SpecialCharacters],
284
285            Self::Pass | Self::None => &[],
286
287            Self::Custom(steps) => steps,
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    #![allow(clippy::unwrap_used)]
295
296    mod stem {
297        use crate::{content::Content, strings::CowStr, tests::prelude::*};
298
299        #[test]
300        fn applies_special_characters_only() {
301            // The `Stem` group applies only the special characters substitution:
302            // `<` is escaped, but quotes (`*bold*`) and attribute references
303            // (`{color}`) are left untouched.
304            let mut content = Content::from(crate::Span::new("*a* < {color}"));
305            let p = Parser::default();
306            SubstitutionGroup::Stem.apply(&mut content, &p, None);
307            assert_eq!(
308                content.rendered,
309                CowStr::Boxed("*a* &lt; {color}".to_string().into_boxed_str())
310            );
311        }
312    }
313
314    mod from_custom_string {
315        use crate::{
316            content::{Content, SubstitutionStep},
317            strings::CowStr,
318            tests::prelude::*,
319        };
320
321        #[test]
322        fn empty() {
323            assert_eq!(SubstitutionGroup::from_custom_string(None, ""), None);
324        }
325
326        #[test]
327        fn none() {
328            assert_eq!(
329                SubstitutionGroup::from_custom_string(None, "none"),
330                Some(SubstitutionGroup::None)
331            );
332
333            assert_eq!(SubstitutionGroup::from_custom_string(None, "nermal"), None);
334        }
335
336        #[test]
337        fn normal() {
338            assert_eq!(
339                SubstitutionGroup::from_custom_string(None, "n"),
340                Some(SubstitutionGroup::Normal)
341            );
342
343            assert_eq!(
344                SubstitutionGroup::from_custom_string(None, "normal"),
345                Some(SubstitutionGroup::Normal)
346            );
347
348            assert_eq!(SubstitutionGroup::from_custom_string(None, "nermal"), None);
349        }
350
351        #[test]
352        fn verbatim() {
353            assert_eq!(
354                SubstitutionGroup::from_custom_string(None, "v"),
355                Some(SubstitutionGroup::Verbatim)
356            );
357
358            assert_eq!(
359                SubstitutionGroup::from_custom_string(None, "verbatim"),
360                Some(SubstitutionGroup::Verbatim)
361            );
362
363            assert_eq!(
364                SubstitutionGroup::from_custom_string(None, "verboten"),
365                None
366            );
367        }
368
369        #[test]
370        fn special_chars() {
371            assert_eq!(
372                SubstitutionGroup::from_custom_string(None, "c"),
373                Some(SubstitutionGroup::Custom(vec![
374                    SubstitutionStep::SpecialCharacters
375                ]))
376            );
377
378            assert_eq!(
379                SubstitutionGroup::from_custom_string(None, "specialchars"),
380                Some(SubstitutionGroup::Custom(vec![
381                    SubstitutionStep::SpecialCharacters
382                ]))
383            );
384        }
385
386        #[test]
387        fn quotes() {
388            assert_eq!(
389                SubstitutionGroup::from_custom_string(None, "q"),
390                Some(SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]))
391            );
392
393            assert_eq!(
394                SubstitutionGroup::from_custom_string(None, "quotes"),
395                Some(SubstitutionGroup::Custom(vec![SubstitutionStep::Quotes]))
396            );
397        }
398
399        #[test]
400        fn attributes() {
401            assert_eq!(
402                SubstitutionGroup::from_custom_string(None, "a"),
403                Some(SubstitutionGroup::Custom(vec![
404                    SubstitutionStep::AttributeReferences
405                ]))
406            );
407
408            assert_eq!(
409                SubstitutionGroup::from_custom_string(None, "attributes"),
410                Some(SubstitutionGroup::Custom(vec![
411                    SubstitutionStep::AttributeReferences
412                ]))
413            );
414        }
415
416        #[test]
417        fn replacements() {
418            assert_eq!(
419                SubstitutionGroup::from_custom_string(None, "r"),
420                Some(SubstitutionGroup::Custom(vec![
421                    SubstitutionStep::CharacterReplacements
422                ]))
423            );
424
425            assert_eq!(
426                SubstitutionGroup::from_custom_string(None, "replacements"),
427                Some(SubstitutionGroup::Custom(vec![
428                    SubstitutionStep::CharacterReplacements
429                ]))
430            );
431        }
432
433        #[test]
434        fn macros() {
435            assert_eq!(
436                SubstitutionGroup::from_custom_string(None, "m"),
437                Some(SubstitutionGroup::Custom(vec![SubstitutionStep::Macros]))
438            );
439
440            assert_eq!(
441                SubstitutionGroup::from_custom_string(None, "macros"),
442                Some(SubstitutionGroup::Custom(vec![SubstitutionStep::Macros]))
443            );
444        }
445
446        #[test]
447        fn post_replacements() {
448            assert_eq!(
449                SubstitutionGroup::from_custom_string(None, "p"),
450                Some(SubstitutionGroup::Custom(vec![
451                    SubstitutionStep::PostReplacement
452                ]))
453            );
454
455            assert_eq!(
456                SubstitutionGroup::from_custom_string(None, "post_replacements"),
457                Some(SubstitutionGroup::Custom(vec![
458                    SubstitutionStep::PostReplacement
459                ]))
460            );
461        }
462
463        #[test]
464        fn multiple() {
465            assert_eq!(
466                SubstitutionGroup::from_custom_string(None, "q,a"),
467                Some(SubstitutionGroup::Custom(vec![
468                    SubstitutionStep::Quotes,
469                    SubstitutionStep::AttributeReferences
470                ]))
471            );
472
473            assert_eq!(
474                SubstitutionGroup::from_custom_string(None, "q, a"),
475                Some(SubstitutionGroup::Custom(vec![
476                    SubstitutionStep::Quotes,
477                    SubstitutionStep::AttributeReferences
478                ]))
479            );
480
481            assert_eq!(
482                SubstitutionGroup::from_custom_string(None, "quotes,attributes"),
483                Some(SubstitutionGroup::Custom(vec![
484                    SubstitutionStep::Quotes,
485                    SubstitutionStep::AttributeReferences
486                ]))
487            );
488
489            assert_eq!(
490                SubstitutionGroup::from_custom_string(None, "x,bogus,no such step"),
491                None
492            );
493        }
494
495        #[test]
496        fn subtraction() {
497            assert_eq!(
498                SubstitutionGroup::from_custom_string(None, "n,-r"),
499                Some(SubstitutionGroup::Custom(vec![
500                    SubstitutionStep::SpecialCharacters,
501                    SubstitutionStep::Quotes,
502                    SubstitutionStep::AttributeReferences,
503                    SubstitutionStep::Macros,
504                    SubstitutionStep::PostReplacement,
505                ]))
506            );
507
508            assert_eq!(
509                SubstitutionGroup::from_custom_string(None, "n,-r,-r,-m"),
510                Some(SubstitutionGroup::Custom(vec![
511                    SubstitutionStep::SpecialCharacters,
512                    SubstitutionStep::Quotes,
513                    SubstitutionStep::AttributeReferences,
514                    SubstitutionStep::PostReplacement,
515                ]))
516            );
517
518            assert_eq!(
519                SubstitutionGroup::from_custom_string(None, "v,-r"),
520                Some(SubstitutionGroup::Custom(vec![
521                    SubstitutionStep::SpecialCharacters,
522                    SubstitutionStep::Callouts,
523                ]))
524            );
525
526            assert_eq!(
527                SubstitutionGroup::from_custom_string(None, "v,-c"),
528                Some(SubstitutionGroup::Custom(vec![SubstitutionStep::Callouts]))
529            );
530
531            assert_eq!(
532                SubstitutionGroup::from_custom_string(None, "v,-callouts"),
533                Some(SubstitutionGroup::Custom(vec![
534                    SubstitutionStep::SpecialCharacters,
535                ]))
536            );
537        }
538
539        #[test]
540        fn addition() {
541            // `n` expands to normal's steps (which already include
542            // replacements); the trailing `r` is de-duplicated away, matching
543            // Asciidoctor.
544            assert_eq!(
545                SubstitutionGroup::from_custom_string(None, "n,r"),
546                Some(SubstitutionGroup::Custom(vec![
547                    SubstitutionStep::SpecialCharacters,
548                    SubstitutionStep::Quotes,
549                    SubstitutionStep::AttributeReferences,
550                    SubstitutionStep::CharacterReplacements,
551                    SubstitutionStep::Macros,
552                    SubstitutionStep::PostReplacement,
553                ]))
554            );
555
556            assert_eq!(
557                SubstitutionGroup::from_custom_string(None, "v,m"),
558                Some(SubstitutionGroup::Custom(vec![
559                    SubstitutionStep::SpecialCharacters,
560                    SubstitutionStep::Callouts,
561                    SubstitutionStep::Macros,
562                ]))
563            );
564        }
565
566        #[test]
567        fn incremental() {
568            // `n` expands to normal's steps (which already include
569            // replacements); the trailing `r` is de-duplicated away, matching
570            // Asciidoctor.
571            assert_eq!(
572                SubstitutionGroup::from_custom_string(None, "n,r"),
573                Some(SubstitutionGroup::Custom(vec![
574                    SubstitutionStep::SpecialCharacters,
575                    SubstitutionStep::Quotes,
576                    SubstitutionStep::AttributeReferences,
577                    SubstitutionStep::CharacterReplacements,
578                    SubstitutionStep::Macros,
579                    SubstitutionStep::PostReplacement,
580                ]))
581            );
582
583            assert_eq!(
584                SubstitutionGroup::from_custom_string(None, "v,m"),
585                Some(SubstitutionGroup::Custom(vec![
586                    SubstitutionStep::SpecialCharacters,
587                    SubstitutionStep::Callouts,
588                    SubstitutionStep::Macros,
589                ]))
590            );
591        }
592
593        #[test]
594        fn group_name_mid_list_expands_in_place_and_dedups() {
595            // A group name (`normal`) appearing mid-list is expanded in place
596            // and appended to what came before, rather than resetting the
597            // accumulated steps. The leading `quotes` is preserved, and the
598            // redundant `quotes` from `normal`'s expansion is de-duplicated
599            // away, matching Asciidoctor's `resolve_subs`.
600            assert_eq!(
601                SubstitutionGroup::from_custom_string(None, "quotes,normal"),
602                Some(SubstitutionGroup::Custom(vec![
603                    SubstitutionStep::Quotes,
604                    SubstitutionStep::SpecialCharacters,
605                    SubstitutionStep::AttributeReferences,
606                    SubstitutionStep::CharacterReplacements,
607                    SubstitutionStep::Macros,
608                    SubstitutionStep::PostReplacement,
609                ]))
610            );
611
612            // Same behavior for the shorthand `v` group name mid-list.
613            assert_eq!(
614                SubstitutionGroup::from_custom_string(None, "m,v"),
615                Some(SubstitutionGroup::Custom(vec![
616                    SubstitutionStep::Macros,
617                    SubstitutionStep::SpecialCharacters,
618                    SubstitutionStep::Callouts,
619                ]))
620            );
621        }
622
623        #[test]
624        fn prepend() {
625            assert_eq!(
626                SubstitutionGroup::from_custom_string(
627                    Some(&SubstitutionGroup::Verbatim),
628                    "attributes+"
629                ),
630                Some(SubstitutionGroup::Custom(vec![
631                    SubstitutionStep::AttributeReferences,
632                    SubstitutionStep::SpecialCharacters,
633                    SubstitutionStep::Callouts,
634                ]))
635            );
636
637            assert_eq!(
638                SubstitutionGroup::from_custom_string(None, "attributes+"),
639                Some(SubstitutionGroup::Custom(vec![
640                    SubstitutionStep::AttributeReferences,
641                ]))
642            );
643        }
644
645        #[test]
646        fn append() {
647            assert_eq!(
648                SubstitutionGroup::from_custom_string(
649                    Some(&SubstitutionGroup::Verbatim),
650                    "+attributes"
651                ),
652                Some(SubstitutionGroup::Custom(vec![
653                    SubstitutionStep::SpecialCharacters,
654                    SubstitutionStep::Callouts,
655                    SubstitutionStep::AttributeReferences,
656                ]))
657            );
658
659            assert_eq!(
660                SubstitutionGroup::from_custom_string(None, "attributes+"),
661                Some(SubstitutionGroup::Custom(vec![
662                    SubstitutionStep::AttributeReferences,
663                ]))
664            );
665        }
666
667        #[test]
668        fn subtract() {
669            assert_eq!(
670                SubstitutionGroup::from_custom_string(
671                    Some(&SubstitutionGroup::Normal),
672                    "-attributes"
673                ),
674                Some(SubstitutionGroup::Custom(vec![
675                    SubstitutionStep::SpecialCharacters,
676                    SubstitutionStep::Quotes,
677                    SubstitutionStep::CharacterReplacements,
678                    SubstitutionStep::Macros,
679                    SubstitutionStep::PostReplacement,
680                ]))
681            );
682
683            assert_eq!(
684                SubstitutionGroup::from_custom_string(None, "-attributes"),
685                Some(SubstitutionGroup::Custom(vec![]))
686            );
687        }
688
689        #[test]
690        fn custom_group_with_macros_preserves_passthroughs() {
691            let custom_group = SubstitutionGroup::from_custom_string(None, "q,m").unwrap();
692
693            let mut content = Content::from(crate::Span::new(
694                "Text with +++pass<through>+++ icon:github[] content.",
695            ));
696            let p = Parser::default();
697            custom_group.apply(&mut content, &p, None);
698
699            assert!(!content.is_empty());
700            assert_eq!(
701                content.rendered,
702                CowStr::Boxed(
703                    "Text with pass<through> <span class=\"icon\">[github&#93;</span> content."
704                        .to_string()
705                        .into_boxed_str()
706                )
707            );
708        }
709    }
710
711    mod override_via_attrlist {
712        use crate::{
713            attributes::{Attrlist, AttrlistContext},
714            tests::prelude::*,
715        };
716
717        /// Resolve the substitution group that `base` maps to when the given
718        /// attribute list (block style, `subs=`, …) is applied.
719        fn resolve(base: SubstitutionGroup, attrlist: &str) -> SubstitutionGroup {
720            let p = Parser::default();
721            let attrlist = Attrlist::parse(crate::Span::new(attrlist), &p, AttrlistContext::Block)
722                .item
723                .item;
724
725            base.override_via_attrlist(Some(&attrlist))
726        }
727
728        #[test]
729        fn verbatim_masquerade_styles_promote_normal_to_verbatim() {
730            // On a simple-content (paragraph) block, the `literal`, `listing`,
731            // and `source` styles switch the substitution group to verbatim.
732            for style in ["literal", "listing", "source"] {
733                assert_eq!(
734                    resolve(SubstitutionGroup::Normal, style),
735                    SubstitutionGroup::Verbatim,
736                    "style `{style}` should map Normal to Verbatim"
737                );
738            }
739        }
740
741        #[test]
742        fn pass_style_suppresses_substitutions_on_normal() {
743            assert_eq!(
744                resolve(SubstitutionGroup::Normal, "pass"),
745                SubstitutionGroup::None
746            );
747        }
748
749        #[test]
750        fn non_masquerade_styles_keep_normal() {
751            // Styles whose content model is simple (or compound) keep the normal
752            // substitution group; e.g. `verse` uses normal subs even though its
753            // content model is verbatim.
754            for style in ["normal", "verse", "quote", "sidebar", "example"] {
755                assert_eq!(
756                    resolve(SubstitutionGroup::Normal, style),
757                    SubstitutionGroup::Normal,
758                    "style `{style}` should keep Normal"
759                );
760            }
761        }
762
763        #[test]
764        fn style_does_not_override_a_delimited_block_group() {
765            // A delimited block's delimiter fixes its substitution group; a style
766            // keyword must not override it (matching Asciidoctor). A `[pass]`
767            // style on a `----`/`....` verbatim block keeps verbatim subs, and a
768            // `[source]` style on a `++++` pass block keeps the pass group.
769            assert_eq!(
770                resolve(SubstitutionGroup::Verbatim, "pass"),
771                SubstitutionGroup::Verbatim
772            );
773
774            assert_eq!(
775                resolve(SubstitutionGroup::Pass, "source"),
776                SubstitutionGroup::Pass
777            );
778
779            assert_eq!(
780                resolve(SubstitutionGroup::Stem, "source"),
781                SubstitutionGroup::Stem
782            );
783        }
784
785        #[test]
786        fn subs_attribute_still_overrides() {
787            // An explicit `subs=` attribute overrides the group regardless of the
788            // block style, and takes precedence over the style masquerade.
789            assert_eq!(
790                resolve(SubstitutionGroup::Normal, "listing,subs=normal"),
791                SubstitutionGroup::Normal
792            );
793
794            assert_eq!(
795                resolve(SubstitutionGroup::Verbatim, "subs=none"),
796                SubstitutionGroup::None
797            );
798        }
799    }
800
801    mod normal {
802        use crate::{content::Content, strings::CowStr, tests::prelude::*};
803
804        #[test]
805        fn empty() {
806            let mut content = Content::from(crate::Span::default());
807            let p = Parser::default();
808            SubstitutionGroup::Normal.apply(&mut content, &p, None);
809            assert!(content.is_empty());
810            assert_eq!(content.rendered, CowStr::Borrowed(""));
811        }
812
813        #[test]
814        fn basic_non_empty_span() {
815            let mut content = Content::from(crate::Span::new("blah"));
816            let p = Parser::default();
817            SubstitutionGroup::Normal.apply(&mut content, &p, None);
818            assert!(!content.is_empty());
819            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
820        }
821
822        #[test]
823        fn match_lt_and_gt() {
824            let mut content = Content::from(crate::Span::new("bl<ah>"));
825            let p = Parser::default();
826            SubstitutionGroup::Normal.apply(&mut content, &p, None);
827            assert!(!content.is_empty());
828            assert_eq!(
829                content.rendered,
830                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
831            );
832        }
833
834        #[test]
835        fn match_amp() {
836            let mut content = Content::from(crate::Span::new("bl<a&h>"));
837            let p = Parser::default();
838            SubstitutionGroup::Normal.apply(&mut content, &p, None);
839            assert!(!content.is_empty());
840            assert_eq!(
841                content.rendered,
842                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
843            );
844        }
845
846        #[test]
847        fn strong_word() {
848            let mut content = Content::from(crate::Span::new("One *word* is strong."));
849            let p = Parser::default();
850            SubstitutionGroup::Normal.apply(&mut content, &p, None);
851            assert!(!content.is_empty());
852            assert_eq!(
853                content.rendered,
854                CowStr::Boxed(
855                    "One <strong>word</strong> is strong."
856                        .to_string()
857                        .into_boxed_str()
858                )
859            );
860        }
861
862        #[test]
863        fn strong_word_with_special_chars() {
864            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
865            let p = Parser::default();
866            SubstitutionGroup::Normal.apply(&mut content, &p, None);
867            assert!(!content.is_empty());
868            assert_eq!(
869                content.rendered,
870                CowStr::Boxed(
871                    "One <strong>wo&lt;r&gt;d</strong> is strong."
872                        .to_string()
873                        .into_boxed_str()
874                )
875            );
876        }
877
878        #[test]
879        fn marked_string_with_id() {
880            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
881            let p = Parser::default();
882            SubstitutionGroup::Normal.apply(&mut content, &p, None);
883            assert!(!content.is_empty());
884            assert_eq!(
885                content.rendered,
886                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
887            );
888        }
889    }
890
891    mod attribute_entry_value {
892        use crate::{
893            content::Content, parser::ModificationContext, strings::CowStr, tests::prelude::*,
894        };
895
896        #[test]
897        fn empty() {
898            let mut content = Content::from(crate::Span::default());
899            let p = Parser::default();
900            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
901            assert!(content.is_empty());
902            assert_eq!(content.rendered, CowStr::Borrowed(""));
903        }
904
905        #[test]
906        fn basic_non_empty_span() {
907            let mut content = Content::from(crate::Span::new("blah"));
908            let p = Parser::default();
909            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
910            assert!(!content.is_empty());
911            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
912        }
913
914        #[test]
915        fn match_lt_and_gt() {
916            let mut content = Content::from(crate::Span::new("bl<ah>"));
917            let p = Parser::default();
918            SubstitutionGroup::Normal.apply(&mut content, &p, None);
919            assert!(!content.is_empty());
920            assert_eq!(
921                content.rendered,
922                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
923            );
924        }
925
926        #[test]
927        fn match_amp() {
928            let mut content = Content::from(crate::Span::new("bl<a&h>"));
929            let p = Parser::default();
930            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
931            assert!(!content.is_empty());
932            assert_eq!(
933                content.rendered,
934                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
935            );
936        }
937
938        #[test]
939        fn ignores_strong_word() {
940            let mut content = Content::from(crate::Span::new("One *word* is strong."));
941            let p = Parser::default();
942            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
943            assert!(!content.is_empty());
944            assert_eq!(
945                content.rendered,
946                CowStr::Boxed("One *word* is strong.".to_string().into_boxed_str())
947            );
948        }
949
950        #[test]
951        fn special_chars_and_attributes() {
952            let mut content = Content::from(crate::Span::new("bl<ah> {color}"));
953
954            let p = Parser::default().with_intrinsic_attribute(
955                "color",
956                "red",
957                ModificationContext::Anywhere,
958            );
959
960            SubstitutionGroup::AttributeEntryValue.apply(&mut content, &p, None);
961            assert!(!content.is_empty());
962            assert_eq!(
963                content.rendered,
964                CowStr::Boxed("bl&lt;ah&gt; red".to_string().into_boxed_str())
965            );
966        }
967    }
968
969    mod header {
970        use crate::{content::Content, strings::CowStr, tests::prelude::*};
971
972        #[test]
973        fn empty() {
974            let mut content = Content::from(crate::Span::default());
975            let p = Parser::default();
976            SubstitutionGroup::Header.apply(&mut content, &p, None);
977            assert!(content.is_empty());
978            assert_eq!(content.rendered, CowStr::Borrowed(""));
979        }
980
981        #[test]
982        fn basic_non_empty_span() {
983            let mut content = Content::from(crate::Span::new("blah"));
984            let p = Parser::default();
985            SubstitutionGroup::Header.apply(&mut content, &p, None);
986            assert!(!content.is_empty());
987            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
988        }
989
990        #[test]
991        fn match_lt_and_gt() {
992            let mut content = Content::from(crate::Span::new("bl<ah>"));
993            let p = Parser::default();
994            SubstitutionGroup::Header.apply(&mut content, &p, None);
995            assert!(!content.is_empty());
996            assert_eq!(
997                content.rendered,
998                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
999            );
1000        }
1001
1002        #[test]
1003        fn match_amp() {
1004            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1005            let p = Parser::default();
1006            SubstitutionGroup::Header.apply(&mut content, &p, None);
1007            assert!(!content.is_empty());
1008            assert_eq!(
1009                content.rendered,
1010                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1011            );
1012        }
1013
1014        #[test]
1015        fn ignores_strong_word() {
1016            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1017            let p = Parser::default();
1018            SubstitutionGroup::Header.apply(&mut content, &p, None);
1019            assert!(!content.is_empty());
1020            assert_eq!(content.rendered, CowStr::Borrowed("One *word* is strong."));
1021        }
1022
1023        #[test]
1024        fn ignores_strong_word_with_special_chars() {
1025            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1026            let p = Parser::default();
1027            SubstitutionGroup::Header.apply(&mut content, &p, None);
1028            assert!(!content.is_empty());
1029            assert_eq!(
1030                content.rendered,
1031                CowStr::Boxed("One *wo&lt;r&gt;d* is strong.".to_string().into_boxed_str())
1032            );
1033        }
1034
1035        #[test]
1036        fn ignores_marked_string_with_id() {
1037            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1038            let p = Parser::default();
1039            SubstitutionGroup::Header.apply(&mut content, &p, None);
1040            assert!(!content.is_empty());
1041            assert_eq!(content.rendered, CowStr::Borrowed("[#id]#a few words#"));
1042        }
1043    }
1044
1045    mod title {
1046        use crate::{content::Content, strings::CowStr, tests::prelude::*};
1047
1048        #[test]
1049        fn empty() {
1050            let mut content = Content::from(crate::Span::default());
1051            let p = Parser::default();
1052            SubstitutionGroup::Title.apply(&mut content, &p, None);
1053            assert!(content.is_empty());
1054            assert_eq!(content.rendered, CowStr::Borrowed(""));
1055        }
1056
1057        #[test]
1058        fn basic_non_empty_span() {
1059            let mut content = Content::from(crate::Span::new("blah"));
1060            let p = Parser::default();
1061            SubstitutionGroup::Title.apply(&mut content, &p, None);
1062            assert!(!content.is_empty());
1063            assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1064        }
1065
1066        #[test]
1067        fn match_lt_and_gt() {
1068            let mut content = Content::from(crate::Span::new("bl<ah>"));
1069            let p = Parser::default();
1070            SubstitutionGroup::Title.apply(&mut content, &p, None);
1071            assert!(!content.is_empty());
1072            assert_eq!(
1073                content.rendered,
1074                CowStr::Boxed("bl&lt;ah&gt;".to_string().into_boxed_str())
1075            );
1076        }
1077
1078        #[test]
1079        fn match_amp() {
1080            let mut content = Content::from(crate::Span::new("bl<a&h>"));
1081            let p = Parser::default();
1082            SubstitutionGroup::Title.apply(&mut content, &p, None);
1083            assert!(!content.is_empty());
1084            assert_eq!(
1085                content.rendered,
1086                CowStr::Boxed("bl&lt;a&amp;h&gt;".to_string().into_boxed_str())
1087            );
1088        }
1089
1090        #[test]
1091        fn strong_word() {
1092            let mut content = Content::from(crate::Span::new("One *word* is strong."));
1093            let p = Parser::default();
1094            SubstitutionGroup::Title.apply(&mut content, &p, None);
1095            assert!(!content.is_empty());
1096            assert_eq!(
1097                content.rendered,
1098                CowStr::Boxed(
1099                    "One <strong>word</strong> is strong."
1100                        .to_string()
1101                        .into_boxed_str()
1102                )
1103            );
1104        }
1105
1106        #[test]
1107        fn strong_word_with_special_chars() {
1108            let mut content = Content::from(crate::Span::new("One *wo<r>d* is strong."));
1109            let p = Parser::default();
1110            SubstitutionGroup::Title.apply(&mut content, &p, None);
1111            assert!(!content.is_empty());
1112            assert_eq!(
1113                content.rendered,
1114                CowStr::Boxed(
1115                    "One <strong>wo&lt;r&gt;d</strong> is strong."
1116                        .to_string()
1117                        .into_boxed_str()
1118                )
1119            );
1120        }
1121
1122        #[test]
1123        fn marked_string_with_id() {
1124            let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1125            let p = Parser::default();
1126            SubstitutionGroup::Title.apply(&mut content, &p, None);
1127            assert!(!content.is_empty());
1128            assert_eq!(
1129                content.rendered,
1130                CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1131            );
1132        }
1133
1134        #[test]
1135        fn title_behaves_same_as_normal() {
1136            let test_input = "One *wo<r>d* is strong with [#id]#marked text#.";
1137
1138            let mut title_content = Content::from(crate::Span::new(test_input));
1139            let mut normal_content = Content::from(crate::Span::new(test_input));
1140            let p = Parser::default();
1141
1142            SubstitutionGroup::Title.apply(&mut title_content, &p, None);
1143            SubstitutionGroup::Normal.apply(&mut normal_content, &p, None);
1144
1145            // Title should produce exactly the same result as Normal
1146            assert_eq!(title_content.rendered, normal_content.rendered);
1147        }
1148    }
1149}