Skip to main content

acdc_parser/model/
substitution.rs

1//! Substitution types and application for `AsciiDoc` content.
2//!
3//! # Parser and converter responsibilities
4//!
5//! Substitution handling depends on the content context. The parser resolves
6//! named groups and applies substitutions that create inline structure. It also
7//! records substitutions that require output-specific rendering on the AST.
8//!
9//! Inline passthroughs are processed in their requested order before the parser
10//! returns the AST. Ordinary blocks retain their substitution specification so
11//! converters can apply the block's output-specific behavior.
12//!
13//! Converters handle the format-specific parts:
14//!
15//! - **`SpecialChars`** - HTML converter escapes `<`, `>`, `&` to entities.
16//!   Other converters may handle differently (e.g., terminal needs no escaping).
17//!
18//! - **Replacements** - Typography transformations (em-dashes, arrows, ellipsis).
19//!   Output varies by format (HTML entities vs Unicode characters).
20//!
21//! Formatting, macros, line breaks, and callouts become structured AST nodes
22//! when their substitution is active. Each converter decides how those nodes
23//! appear in its output.
24
25use std::borrow::Cow;
26
27use serde::Serialize;
28
29use crate::DocumentAttributes;
30
31const SUBSTITUTION_STAGE_COUNT: usize = 7;
32const DISABLED_SUBSTITUTION: u8 = u8::MAX;
33const DEFAULT_PARSER_SUBSTITUTIONS: &[Substitution] = &[
34    Substitution::SpecialChars,
35    Substitution::Quotes,
36    Substitution::Attributes,
37    Substitution::Replacements,
38    Substitution::Macros,
39    Substitution::PostReplacements,
40    Substitution::Callouts,
41];
42
43/// The enabled structural substitutions and their effective source order.
44///
45/// A disabled stage has the maximum rank. This keeps all ordering decisions in
46/// one value instead of adding a Boolean for every pair of stages.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) struct SubstitutionPlan {
49    ranks: [u8; SUBSTITUTION_STAGE_COUNT],
50}
51
52impl SubstitutionPlan {
53    pub(crate) fn from_substitutions(substitutions: &[Substitution]) -> Self {
54        let mut ranks = [DISABLED_SUBSTITUTION; SUBSTITUTION_STAGE_COUNT];
55        for (rank, substitution) in substitutions.iter().enumerate() {
56            if let Some(slot) =
57                substitution_stage_index(substitution).and_then(|index| ranks.get_mut(index))
58            {
59                *slot = u8::try_from(rank).unwrap_or(u8::MAX - 1);
60            }
61        }
62        Self { ranks }
63    }
64
65    pub(crate) fn only(substitution: &Substitution) -> Self {
66        Self::from_substitutions(std::slice::from_ref(substitution))
67    }
68
69    #[cfg(feature = "pre-spec-subs")]
70    pub(crate) fn for_block_spec(spec: &SubstitutionSpec) -> Self {
71        // Parser defaults include callout discovery for verbatim blocks. Resolve once:
72        // filling missing stages afterwards would restore removed group members.
73        Self::from_substitutions(&spec.resolve(DEFAULT_PARSER_SUBSTITUTIONS))
74    }
75
76    pub(crate) fn enabled(self, substitution: &Substitution) -> bool {
77        substitution_stage_index(substitution)
78            .and_then(|index| self.ranks.get(index))
79            .is_some_and(|rank| *rank != DISABLED_SUBSTITUTION)
80    }
81
82    pub(crate) fn precedes(self, first: &Substitution, second: &Substitution) -> bool {
83        let (Some(first), Some(second)) = (
84            substitution_stage_index(first),
85            substitution_stage_index(second),
86        ) else {
87            return false;
88        };
89        let (Some(first), Some(second)) = (self.ranks.get(first), self.ranks.get(second)) else {
90            return false;
91        };
92        *first != DISABLED_SUBSTITUTION && *second != DISABLED_SUBSTITUTION && first < second
93    }
94
95    pub(crate) fn through(mut self, substitution: &Substitution) -> Self {
96        if let Some(last) = substitution_stage_index(substitution)
97            .and_then(|index| self.ranks.get(index))
98            .copied()
99        {
100            for rank in &mut self.ranks {
101                if *rank > last {
102                    *rank = DISABLED_SUBSTITUTION;
103                }
104            }
105        }
106        self
107    }
108
109    pub(crate) fn after(self, substitution: &Substitution) -> Vec<Substitution> {
110        let mut stages: Vec<_> = DEFAULT_PARSER_SUBSTITUTIONS
111            .iter()
112            .filter(|stage| self.precedes(substitution, stage))
113            .cloned()
114            .collect();
115        stages.sort_by_key(|stage| {
116            substitution_stage_index(stage)
117                .and_then(|index| self.ranks.get(index))
118                .copied()
119        });
120        stages
121    }
122}
123
124impl Default for SubstitutionPlan {
125    fn default() -> Self {
126        Self::from_substitutions(DEFAULT_PARSER_SUBSTITUTIONS)
127    }
128}
129
130const fn substitution_stage_index(substitution: &Substitution) -> Option<usize> {
131    match substitution {
132        Substitution::SpecialChars => Some(0),
133        Substitution::Attributes => Some(1),
134        Substitution::Replacements => Some(2),
135        Substitution::Macros => Some(3),
136        Substitution::PostReplacements => Some(4),
137        Substitution::Quotes => Some(5),
138        Substitution::Callouts => Some(6),
139        Substitution::Normal | Substitution::Verbatim => None,
140    }
141}
142
143/// An `AsciiDoc` substitution or named substitution group.
144#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize)]
145#[serde(rename_all = "snake_case")]
146#[non_exhaustive]
147pub enum Substitution {
148    SpecialChars,
149    Attributes,
150    Replacements,
151    Macros,
152    PostReplacements,
153    Normal,
154    Verbatim,
155    Quotes,
156    Callouts,
157}
158
159impl std::fmt::Display for Substitution {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        let name = match self {
162            Self::SpecialChars => "special_chars",
163            Self::Attributes => "attributes",
164            Self::Replacements => "replacements",
165            Self::Macros => "macros",
166            Self::PostReplacements => "post_replacements",
167            Self::Normal => "normal",
168            Self::Verbatim => "verbatim",
169            Self::Quotes => "quotes",
170            Self::Callouts => "callouts",
171        };
172        write!(f, "{name}")
173    }
174}
175
176/// Parse a substitution name into a `Substitution` enum variant.
177///
178/// Returns `None` for unknown substitution types, which are logged and skipped.
179pub(crate) fn parse_substitution(value: &str) -> Option<Substitution> {
180    let substitution = substitution_named(value);
181    if substitution.is_none() {
182        tracing::error!(
183            substitution = %value,
184            "unknown substitution type, ignoring - check for typos"
185        );
186    }
187    substitution
188}
189
190fn substitution_named(value: &str) -> Option<Substitution> {
191    match value {
192        "attributes" | "a" => Some(Substitution::Attributes),
193        "replacements" | "r" => Some(Substitution::Replacements),
194        "macros" | "m" => Some(Substitution::Macros),
195        "post_replacements" | "p" => Some(Substitution::PostReplacements),
196        "normal" | "n" => Some(Substitution::Normal),
197        "verbatim" | "v" => Some(Substitution::Verbatim),
198        "quotes" | "q" => Some(Substitution::Quotes),
199        "callouts" => Some(Substitution::Callouts),
200        "specialchars" | "specialcharacters" | "c" => Some(Substitution::SpecialChars),
201        _ => None,
202    }
203}
204
205/// Default substitutions for header content.
206pub const HEADER: &[Substitution] = &[Substitution::SpecialChars, Substitution::Attributes];
207
208/// Default substitutions for normal content (paragraphs, etc).
209pub const NORMAL: &[Substitution] = &[
210    Substitution::SpecialChars,
211    Substitution::Quotes,
212    Substitution::Attributes,
213    Substitution::Replacements,
214    Substitution::Macros,
215    Substitution::PostReplacements,
216];
217
218/// Default substitutions for verbatim blocks (listing, literal).
219pub const VERBATIM: &[Substitution] = &[Substitution::SpecialChars, Substitution::Callouts];
220
221/// The inline `verbatim` group excludes block-only callout processing.
222const PASSTHROUGH_VERBATIM: &[Substitution] = &[Substitution::SpecialChars];
223
224#[derive(Clone, Copy)]
225enum GroupContext {
226    #[cfg(feature = "pre-spec-subs")]
227    Block,
228    Passthrough,
229}
230
231fn substitution_members(substitution: &Substitution, context: GroupContext) -> &[Substitution] {
232    match (substitution, context) {
233        (Substitution::Normal, _) => NORMAL,
234        #[cfg(feature = "pre-spec-subs")]
235        (Substitution::Verbatim, GroupContext::Block) => VERBATIM,
236        (Substitution::Verbatim, GroupContext::Passthrough) => PASSTHROUGH_VERBATIM,
237        (
238            Substitution::SpecialChars
239            | Substitution::Attributes
240            | Substitution::Replacements
241            | Substitution::Macros
242            | Substitution::PostReplacements
243            | Substitution::Quotes
244            | Substitution::Callouts,
245            _,
246        ) => std::slice::from_ref(substitution),
247    }
248}
249
250fn append_expanded_substitution(
251    result: &mut Vec<Substitution>,
252    substitution: &Substitution,
253    context: GroupContext,
254) {
255    for member in substitution_members(substitution, context) {
256        if !result.contains(member) {
257            result.push(member.clone());
258        }
259    }
260}
261
262/// Resolve named groups for an inline passthrough while preserving source order.
263pub(crate) fn resolve_passthrough_substitutions(
264    substitutions: &[Substitution],
265) -> Vec<Substitution> {
266    let mut resolved = Vec::with_capacity(substitutions.len());
267    for substitution in substitutions {
268        append_expanded_substitution(&mut resolved, substitution, GroupContext::Passthrough);
269    }
270    resolved
271}
272
273/// A substitution operation to apply to a default substitution list.
274///
275/// Used when the `subs` attribute contains modifier syntax (`+quotes`, `-callouts`, `quotes+`).
276///
277/// Only available when the `pre-spec-subs` feature is enabled — the draft
278/// `AsciiDoc` spec is dropping the substitution model in favour of an inline
279/// parsing grammar, so this type goes away with the feature.
280#[cfg(feature = "pre-spec-subs")]
281#[derive(Clone, Debug, Hash, Eq, PartialEq)]
282pub enum SubstitutionOp {
283    /// `+name` - append substitution to end of default list
284    Append(Substitution),
285    /// `name+` - prepend substitution to beginning of default list
286    Prepend(Substitution),
287    /// `-name` - remove substitution from default list
288    Remove(Substitution),
289}
290
291#[cfg(feature = "pre-spec-subs")]
292impl std::fmt::Display for SubstitutionOp {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        match self {
295            Self::Append(sub) => write!(f, "+{sub}"),
296            Self::Prepend(sub) => write!(f, "{sub}+"),
297            Self::Remove(sub) => write!(f, "-{sub}"),
298        }
299    }
300}
301
302/// Specification for substitutions to apply to a block.
303///
304/// Parsed documents use [`Self::Source`] to preserve the entries in the `subs`
305/// attribute, including groups, aliases, duplicates and modifiers. Only whitespace
306/// around each entry is trimmed. Attribute references in the list have already
307/// been expanded by metadata parsing.
308///
309/// Standalone `none` and empty values use [`Self::Explicit`] with an empty list.
310/// They disable substitutions, whereas absent metadata uses block defaults.
311/// Mixed lists retain `none` because its position can select the starting list.
312///
313/// [`Self::resolve`] derives the effective stages without changing the stored
314/// entries. A plain first entry starts an empty list; a modifier first entry
315/// starts with the supplied block defaults. Later entries operate on that list.
316/// [`Self::Explicit`] and [`Self::Modifiers`] also support typed construction.
317///
318/// ## Serialization
319///
320/// Serializes to a flat array. Source entries retain their spelling, for example
321/// `["verbatim", "-macros"]`; typed entries use canonical names such as
322/// `"special_chars"`.
323///
324/// Only available when the `pre-spec-subs` feature is enabled.
325#[cfg(feature = "pre-spec-subs")]
326#[derive(Clone, Debug, Hash, Eq, PartialEq)]
327pub enum SubstitutionSpec {
328    /// Authored entries, interpreted when resolving against block defaults.
329    Source(Vec<String>),
330    /// Explicit list of substitutions to apply (replaces all defaults)
331    Explicit(Vec<Substitution>),
332    /// Modifier operations to apply to block-type defaults
333    Modifiers(Vec<SubstitutionOp>),
334}
335
336#[cfg(feature = "pre-spec-subs")]
337impl Serialize for SubstitutionSpec {
338    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
339    where
340        S: serde::Serializer,
341    {
342        let strings: Vec<String> = match self {
343            Self::Source(entries) => return entries.serialize(serializer),
344            Self::Explicit(subs) => subs.iter().map(ToString::to_string).collect(),
345            Self::Modifiers(ops) => ops.iter().map(ToString::to_string).collect(),
346        };
347        strings.serialize(serializer)
348    }
349}
350
351#[cfg(feature = "pre-spec-subs")]
352impl SubstitutionSpec {
353    pub(crate) fn contains(&self, substitution: &Substitution, defaults: &[Substitution]) -> bool {
354        match self {
355            Self::Source(entries) => entries
356                .iter()
357                .rev()
358                .find_map(|entry| {
359                    let (name, modifier) = parse_subs_part(entry.trim());
360                    let affected = substitution_named(name)?;
361                    substitution_members(&affected, GroupContext::Block)
362                        .contains(substitution)
363                        .then_some(!matches!(modifier, Some(SubsModifier::Remove)))
364                })
365                .unwrap_or_else(|| {
366                    entries
367                        .first()
368                        .is_some_and(|entry| parse_subs_part(entry.trim()).1.is_some())
369                        && defaults.contains(substitution)
370                }),
371            Self::Explicit(substitutions) => substitutions.contains(substitution),
372            // The last operation affecting a substitution determines its membership.
373            Self::Modifiers(operations) => operations
374                .iter()
375                .rev()
376                .find_map(|operation| {
377                    let (affected, enabled) = match operation {
378                        SubstitutionOp::Append(affected) | SubstitutionOp::Prepend(affected) => {
379                            (affected, true)
380                        }
381                        SubstitutionOp::Remove(affected) => (affected, false),
382                    };
383                    substitution_members(affected, GroupContext::Block)
384                        .contains(substitution)
385                        .then_some(enabled)
386                })
387                .unwrap_or_else(|| defaults.contains(substitution)),
388        }
389    }
390
391    /// Apply modifier operations to a default substitution list.
392    ///
393    /// This is used by converters to resolve modifiers with the appropriate baseline.
394    #[must_use]
395    pub fn apply_modifiers(ops: &[SubstitutionOp], default: &[Substitution]) -> Vec<Substitution> {
396        let mut result = default.to_vec();
397        for op in ops {
398            match op {
399                SubstitutionOp::Append(sub) => append_substitution(&mut result, sub),
400                SubstitutionOp::Prepend(sub) => prepend_substitution(&mut result, sub),
401                SubstitutionOp::Remove(sub) => remove_substitution(&mut result, sub),
402            }
403        }
404        result
405    }
406
407    /// Resolve the substitution spec to a concrete list of substitutions.
408    ///
409    /// - For `Source`, applies its entries in order, expanding named groups
410    /// - For `Explicit`, returns the list directly
411    /// - For `Modifiers`, applies the operations to the provided default
412    #[must_use]
413    pub fn resolve(&self, default: &[Substitution]) -> Vec<Substitution> {
414        match self {
415            SubstitutionSpec::Source(entries) => resolve_source_substitutions(entries, default),
416            SubstitutionSpec::Explicit(subs) => subs.clone(),
417            SubstitutionSpec::Modifiers(ops) => Self::apply_modifiers(ops, default),
418        }
419    }
420}
421
422/// Modifier for a substitution in the `subs` attribute (internal parsing helper).
423#[cfg(feature = "pre-spec-subs")]
424#[derive(Clone, Copy, Debug, PartialEq, Eq)]
425enum SubsModifier {
426    /// `+name` - append to end of default list
427    Append,
428    /// `name+` - prepend to beginning of default list
429    Prepend,
430    /// `-name` - remove from default list
431    Remove,
432}
433
434/// Parse a single subs part into name and optional modifier.
435#[cfg(feature = "pre-spec-subs")]
436fn parse_subs_part(part: &str) -> (&str, Option<SubsModifier>) {
437    if let Some(name) = part.strip_prefix('+') {
438        (name, Some(SubsModifier::Append))
439    } else if let Some(name) = part.strip_suffix('+') {
440        (name, Some(SubsModifier::Prepend))
441    } else if let Some(name) = part.strip_prefix('-') {
442        (name, Some(SubsModifier::Remove))
443    } else {
444        (part, None)
445    }
446}
447
448/// Preserve `subs` entries and report unknown names once during parsing.
449#[cfg(feature = "pre-spec-subs")]
450#[must_use]
451pub(crate) fn parse_subs_attribute(value: &str) -> SubstitutionSpec {
452    let value = value.trim();
453    if value.is_empty() || value == "none" {
454        return SubstitutionSpec::Explicit(Vec::new());
455    }
456    let entries = value
457        .split(',')
458        .map(|entry| entry.trim().to_owned())
459        .collect::<Vec<_>>();
460    for entry in &entries {
461        let (name, _) = parse_subs_part(entry);
462        if !name.is_empty() && name != "none" {
463            let _ = parse_substitution(name);
464        }
465    }
466    SubstitutionSpec::Source(entries)
467}
468
469#[cfg(feature = "pre-spec-subs")]
470fn resolve_source_substitutions(entries: &[String], default: &[Substitution]) -> Vec<Substitution> {
471    let mut parts = entries
472        .iter()
473        .map(|entry| parse_subs_part(entry.trim()))
474        .peekable();
475
476    // The first entry selects the baseline, even if its name is empty or unknown.
477    let uses_defaults = parts.peek().is_some_and(|(_, modifier)| modifier.is_some());
478    let mut result = if uses_defaults {
479        default.to_vec()
480    } else {
481        Vec::new()
482    };
483    for (name, modifier) in parts {
484        // Invalid names remain in the AST; resolution must not repeat parse diagnostics.
485        let Some(substitution) = substitution_named(name) else {
486            continue;
487        };
488        match modifier {
489            Some(SubsModifier::Prepend) => prepend_substitution(&mut result, &substitution),
490            Some(SubsModifier::Remove) => remove_substitution(&mut result, &substitution),
491            Some(SubsModifier::Append) | None => append_substitution(&mut result, &substitution),
492        }
493    }
494    result
495}
496
497/// Append a substitution (or group) to the end of the list.
498#[cfg(feature = "pre-spec-subs")]
499pub(crate) fn append_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
500    append_expanded_substitution(result, sub, GroupContext::Block);
501}
502
503/// Move a substitution (or group) to the beginning, preserving group order.
504#[cfg(feature = "pre-spec-subs")]
505pub(crate) fn prepend_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
506    // The prepended occurrence wins, including members already enabled by the baseline.
507    remove_substitution(result, sub);
508    let members = substitution_members(sub, GroupContext::Block);
509    result.splice(0..0, members.iter().cloned());
510}
511
512/// Remove a substitution (or group) from the list.
513#[cfg(feature = "pre-spec-subs")]
514pub(crate) fn remove_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
515    for s in substitution_members(sub, GroupContext::Block) {
516        result.retain(|x| x != s);
517    }
518}
519
520/// Apply a sequence of substitutions to text.
521///
522/// Iterates through the substitution list and applies each in order:
523///
524/// - `Attributes` - Expands `{name}` references using document attributes
525/// - `Normal` / `Verbatim` - Recursively applies the corresponding substitution group
526/// - All others (`SpecialChars`, `Quotes`, `Replacements`, `Macros`,
527///   `PostReplacements`, `Callouts`) - No-op; handled by the converter
528///   (`SpecialChars`, `Quotes`, `Replacements`) or by the grammar
529///   (`Macros`, `PostReplacements`, `Callouts`).
530///
531/// # Example
532///
533/// ```
534/// use acdc_parser::{Options, Substitution, substitute};
535///
536/// let options = Options::with_attributes([("version", "1.0")])?;
537/// let attrs = options.document_attributes();
538///
539/// let result = substitute("Version {version}", &[Substitution::Attributes], attrs);
540/// assert_eq!(result, "Version 1.0");
541/// # Ok::<(), acdc_parser::Error>(())
542/// ```
543#[must_use]
544pub fn substitute<'a, 'b>(
545    text: &'b str,
546    substitutions: &[Substitution],
547    attributes: &DocumentAttributes<'a>,
548) -> Cow<'b, str>
549where
550    'a: 'b,
551{
552    let mut result = Cow::Borrowed(text);
553    for substitution in substitutions {
554        match substitution {
555            Substitution::Attributes => {
556                if let Cow::Owned(expanded) =
557                    substitute_attributes(&result, |name| attributes.get(name))
558                {
559                    result = Cow::Owned(expanded);
560                }
561            }
562            // These substitutions are handled elsewhere — the converter
563            // (`SpecialChars`, `Quotes`, `Replacements`) or the grammar
564            // (`Macros`, `PostReplacements`, `Callouts`). They are no-ops
565            // here in `substitute()`.
566            Substitution::SpecialChars
567            | Substitution::Quotes
568            | Substitution::Replacements
569            | Substitution::Macros
570            | Substitution::PostReplacements
571            | Substitution::Callouts => {}
572            // Group substitutions expand recursively
573            Substitution::Normal => {
574                let current = std::mem::take(&mut result);
575                result = match current {
576                    Cow::Borrowed(s) => substitute(s, NORMAL, attributes),
577                    Cow::Owned(s) => Cow::Owned(substitute(&s, NORMAL, attributes).into_owned()),
578                };
579            }
580            Substitution::Verbatim => {
581                let current = std::mem::take(&mut result);
582                result = match current {
583                    Cow::Borrowed(s) => substitute(s, VERBATIM, attributes),
584                    Cow::Owned(s) => Cow::Owned(substitute(&s, VERBATIM, attributes).into_owned()),
585                };
586            }
587        }
588    }
589    result
590}
591
592#[cfg(test)]
593mod group_tests {
594    use super::*;
595
596    #[test]
597    fn normal_group_uses_reference_order() {
598        assert_eq!(
599            NORMAL,
600            &[
601                Substitution::SpecialChars,
602                Substitution::Quotes,
603                Substitution::Attributes,
604                Substitution::Replacements,
605                Substitution::Macros,
606                Substitution::PostReplacements,
607            ]
608        );
609    }
610
611    #[test]
612    fn passthrough_groups_use_inline_policies() {
613        assert_eq!(
614            resolve_passthrough_substitutions(&[Substitution::Normal]),
615            NORMAL
616        );
617        assert_eq!(
618            resolve_passthrough_substitutions(&[Substitution::Verbatim]),
619            [Substitution::SpecialChars]
620        );
621    }
622
623    #[test]
624    fn passthrough_group_expansion_preserves_first_occurrence() {
625        assert_eq!(
626            resolve_passthrough_substitutions(&[
627                Substitution::Attributes,
628                Substitution::Normal,
629                Substitution::Quotes,
630            ]),
631            [
632                Substitution::Attributes,
633                Substitution::SpecialChars,
634                Substitution::Quotes,
635                Substitution::Replacements,
636                Substitution::Macros,
637                Substitution::PostReplacements,
638            ]
639        );
640    }
641}
642
643// Tests cover the `subs=` machinery (parse_subs_attribute, SubstitutionSpec,
644// SubstitutionOp), all of which are feature-gated. `substitute()` is
645// exercised indirectly through the parser's fixture suite.
646#[cfg(all(test, feature = "pre-spec-subs"))]
647mod tests {
648    use super::*;
649    use crate::AttributeValue;
650
651    proptest::proptest! {
652        #[test]
653        fn membership_matches_resolved_modifier_order(
654            operations in proptest::collection::vec((0_usize..9, 0_u8..3), 0..20),
655        ) {
656            let substitutions = [
657                Substitution::SpecialChars, Substitution::Attributes,
658                Substitution::Replacements, Substitution::Macros,
659                Substitution::PostReplacements, Substitution::Quotes,
660                Substitution::Callouts, Substitution::Normal, Substitution::Verbatim,
661            ];
662            let operations = operations.into_iter().filter_map(|(index, operation)| {
663                substitutions.get(index).map(|substitution| match operation {
664                    0 => SubstitutionOp::Append(substitution.clone()),
665                    1 => SubstitutionOp::Prepend(substitution.clone()),
666                    _ => SubstitutionOp::Remove(substitution.clone()),
667                })
668            }).collect();
669            let spec = SubstitutionSpec::Modifiers(operations);
670            for defaults in [NORMAL, VERBATIM, &[]] {
671                let resolved = spec.resolve(defaults);
672                for substitution in &substitutions {
673                    proptest::prop_assert_eq!(
674                        spec.contains(substitution, defaults),
675                        resolved.contains(substitution),
676                    );
677                }
678            }
679        }
680
681        #[test]
682        fn membership_matches_resolved_source_order(
683            operations in proptest::collection::vec((0_usize..15, 0_u8..4), 0..20),
684        ) {
685            let names = [
686                "specialchars", "attributes", "replacements", "macros",
687                "post_replacements", "quotes", "callouts", "normal", "verbatim",
688                "a", "q", "c", "none", "unknown", "",
689            ];
690            let entries = operations.into_iter().filter_map(|(index, operation)| {
691                names.get(index).map(|name| match operation {
692                    0 => format!(" {name} "),
693                    1 => format!(" +{name} "),
694                    2 => format!(" {name}+ "),
695                    _ => format!(" -{name} "),
696                })
697            }).collect();
698            let spec = SubstitutionSpec::Source(entries);
699            for defaults in [NORMAL, VERBATIM, &[]] {
700                let resolved = spec.resolve(defaults);
701                for substitution in [
702                    Substitution::SpecialChars, Substitution::Attributes,
703                    Substitution::Replacements, Substitution::Macros,
704                    Substitution::PostReplacements, Substitution::Quotes,
705                    Substitution::Callouts, Substitution::Normal, Substitution::Verbatim,
706                ] {
707                    proptest::prop_assert_eq!(
708                        spec.contains(&substitution, defaults),
709                        resolved.contains(&substitution),
710                    );
711                }
712            }
713        }
714    }
715
716    fn explicit(spec: &SubstitutionSpec) -> Vec<Substitution> {
717        spec.resolve(&[])
718    }
719
720    #[allow(clippy::panic)]
721    fn source_entries(spec: &SubstitutionSpec) -> &[String] {
722        match spec {
723            SubstitutionSpec::Source(entries) => entries,
724            SubstitutionSpec::Explicit(_) | SubstitutionSpec::Modifiers(_) => {
725                panic!("expected source entries")
726            }
727        }
728    }
729
730    #[test]
731    fn test_parse_subs_none() {
732        let result = parse_subs_attribute("none");
733        assert_eq!(result, SubstitutionSpec::Explicit(Vec::new()));
734    }
735
736    #[test]
737    fn test_parse_subs_empty_string() {
738        let result = parse_subs_attribute("");
739        assert_eq!(result, SubstitutionSpec::Explicit(Vec::new()));
740    }
741
742    #[test]
743    fn test_parse_subs_none_with_whitespace() {
744        let result = parse_subs_attribute("  none  ");
745        assert_eq!(result, SubstitutionSpec::Explicit(Vec::new()));
746    }
747
748    #[test]
749    fn test_parse_subs_specialchars() {
750        let result = parse_subs_attribute("specialchars");
751        assert_eq!(explicit(&result), vec![Substitution::SpecialChars]);
752    }
753
754    #[test]
755    fn test_parse_subs_specialchars_shorthand() {
756        let result = parse_subs_attribute("c");
757        assert_eq!(explicit(&result), vec![Substitution::SpecialChars]);
758    }
759
760    #[test]
761    fn test_parse_subs_specialcharacters_alias() {
762        let result = parse_subs_attribute("specialcharacters");
763        assert_eq!(explicit(&result), vec![Substitution::SpecialChars]);
764    }
765
766    #[test]
767    fn test_parse_subs_normal_expands() {
768        let result = parse_subs_attribute("normal");
769        assert_eq!(explicit(&result), NORMAL.to_vec());
770    }
771
772    #[test]
773    fn test_parse_subs_verbatim_expands() {
774        let result = parse_subs_attribute("verbatim");
775        assert_eq!(explicit(&result), VERBATIM.to_vec());
776    }
777
778    #[test]
779    fn test_parse_subs_append_modifier() {
780        let result = parse_subs_attribute("+quotes");
781        assert_eq!(source_entries(&result), ["+quotes"]);
782
783        // Verify resolved result with VERBATIM baseline
784        let resolved = result.resolve(VERBATIM);
785        assert!(resolved.contains(&Substitution::SpecialChars));
786        assert!(resolved.contains(&Substitution::Callouts));
787        assert!(resolved.contains(&Substitution::Quotes));
788        assert_eq!(resolved.last(), Some(&Substitution::Quotes));
789    }
790
791    #[test]
792    fn test_parse_subs_prepend_modifier() {
793        let result = parse_subs_attribute("quotes+");
794        assert_eq!(source_entries(&result), ["quotes+"]);
795
796        // Verify resolved result with VERBATIM baseline
797        let resolved = result.resolve(VERBATIM);
798        assert_eq!(resolved.first(), Some(&Substitution::Quotes));
799        assert!(resolved.contains(&Substitution::SpecialChars));
800        assert!(resolved.contains(&Substitution::Callouts));
801    }
802
803    #[test]
804    fn prepend_modifiers_move_existing_stages_and_preserve_group_order() {
805        for (spec, baseline, expected) in [
806            (
807                "attributes+",
808                NORMAL,
809                "attributes,special_chars,quotes,replacements,macros,post_replacements",
810            ),
811            (
812                "+attributes",
813                NORMAL,
814                "special_chars,quotes,attributes,replacements,macros,post_replacements",
815            ),
816            (
817                "attributes+,quotes+",
818                NORMAL,
819                "quotes,attributes,special_chars,replacements,macros,post_replacements",
820            ),
821            (
822                "quotes+,attributes+",
823                NORMAL,
824                "attributes,quotes,special_chars,replacements,macros,post_replacements",
825            ),
826            (
827                "attributes+,attributes+",
828                NORMAL,
829                "attributes,special_chars,quotes,replacements,macros,post_replacements",
830            ),
831            (
832                "attributes+,normal+",
833                NORMAL,
834                "special_chars,quotes,attributes,replacements,macros,post_replacements",
835            ),
836            (
837                "normal+,attributes+",
838                NORMAL,
839                "attributes,special_chars,quotes,replacements,macros,post_replacements",
840            ),
841            (
842                "verbatim+",
843                NORMAL,
844                "special_chars,callouts,quotes,attributes,replacements,macros,post_replacements",
845            ),
846            (
847                "normal+",
848                VERBATIM,
849                "special_chars,quotes,attributes,replacements,macros,post_replacements,callouts",
850            ),
851            (
852                "+normal",
853                VERBATIM,
854                "special_chars,callouts,quotes,attributes,replacements,macros,post_replacements",
855            ),
856            (
857                "normal+,verbatim+,normal+",
858                VERBATIM,
859                "special_chars,quotes,attributes,replacements,macros,post_replacements,callouts",
860            ),
861            ("-normal,attributes+", NORMAL, "attributes"),
862            (
863                "attributes+,-attributes",
864                NORMAL,
865                "special_chars,quotes,replacements,macros,post_replacements",
866            ),
867            (
868                "-attributes,+attributes",
869                NORMAL,
870                "special_chars,quotes,replacements,macros,post_replacements,attributes",
871            ),
872            (
873                "normal+,-quotes,quotes+",
874                VERBATIM,
875                "quotes,special_chars,attributes,replacements,macros,post_replacements,callouts",
876            ),
877            (
878                "verbatim+,-specialchars,+specialchars",
879                NORMAL,
880                "callouts,quotes,attributes,replacements,macros,post_replacements,special_chars",
881            ),
882            (
883                "+quotes,attributes+",
884                &[] as &[Substitution],
885                "attributes,quotes",
886            ),
887        ] {
888            let actual = parse_subs_attribute(spec).resolve(baseline);
889            assert_eq!(
890                actual
891                    .iter()
892                    .map(ToString::to_string)
893                    .collect::<Vec<_>>()
894                    .join(","),
895                expected,
896                "{spec} with {baseline:?}"
897            );
898        }
899    }
900
901    #[test]
902    fn test_parse_subs_remove_modifier() {
903        let result = parse_subs_attribute("-specialchars");
904        assert_eq!(source_entries(&result), ["-specialchars"]);
905
906        // Verify resolved result with VERBATIM baseline
907        let resolved = result.resolve(VERBATIM);
908        assert!(!resolved.contains(&Substitution::SpecialChars));
909        assert!(resolved.contains(&Substitution::Callouts));
910    }
911
912    #[test]
913    fn test_parse_subs_remove_all_verbatim() {
914        let result = parse_subs_attribute("-specialchars,-callouts");
915        let ops = source_entries(&result);
916        assert_eq!(ops.len(), 2);
917
918        // Verify resolved result with VERBATIM baseline
919        let resolved = result.resolve(VERBATIM);
920        assert_eq!(resolved, []);
921    }
922
923    #[test]
924    fn test_parse_subs_combined_modifiers() {
925        let result = parse_subs_attribute("+quotes,-callouts");
926        let ops = source_entries(&result);
927        assert_eq!(ops.len(), 2);
928
929        // Verify resolved result with VERBATIM baseline
930        let resolved = result.resolve(VERBATIM);
931        assert!(resolved.contains(&Substitution::SpecialChars)); // from default
932        assert!(resolved.contains(&Substitution::Quotes)); // added
933        assert!(!resolved.contains(&Substitution::Callouts)); // removed
934    }
935
936    #[test]
937    fn test_parse_subs_ordering_preserved() {
938        let result = parse_subs_attribute("quotes,attributes,specialchars");
939        assert_eq!(
940            explicit(&result),
941            vec![
942                Substitution::Quotes,
943                Substitution::Attributes,
944                Substitution::SpecialChars
945            ]
946        );
947    }
948
949    #[test]
950    fn test_parse_subs_shorthand_list() {
951        let result = parse_subs_attribute("q,a,c");
952        assert_eq!(
953            explicit(&result),
954            vec![
955                Substitution::Quotes,
956                Substitution::Attributes,
957                Substitution::SpecialChars
958            ]
959        );
960    }
961
962    #[test]
963    fn test_parse_subs_with_spaces() {
964        let result = parse_subs_attribute(" quotes , attributes ");
965        assert_eq!(
966            explicit(&result),
967            vec![Substitution::Quotes, Substitution::Attributes]
968        );
969    }
970
971    #[test]
972    fn test_parse_subs_duplicates_ignored() {
973        let result = parse_subs_attribute("quotes,quotes,quotes");
974        assert_eq!(explicit(&result), vec![Substitution::Quotes]);
975    }
976
977    #[test]
978    fn test_parse_subs_normal_in_list_expands() {
979        let result = parse_subs_attribute("normal");
980        let subs = explicit(&result);
981        // Should expand to all NORMAL substitutions
982        assert_eq!(subs.len(), NORMAL.len());
983        for sub in NORMAL {
984            assert!(subs.contains(sub));
985        }
986    }
987
988    #[test]
989    fn test_parse_subs_append_normal_group() {
990        let result = parse_subs_attribute("+normal");
991        // This is modifier syntax, resolve with a baseline that has Callouts
992        let resolved = result.resolve(&[Substitution::Callouts]);
993        // Should have Callouts + all of NORMAL
994        assert!(resolved.contains(&Substitution::Callouts));
995        for sub in NORMAL {
996            assert!(resolved.contains(sub));
997        }
998    }
999
1000    #[test]
1001    fn test_parse_subs_remove_normal_group() {
1002        let result = parse_subs_attribute("-normal");
1003        // This is modifier syntax, resolve with NORMAL baseline
1004        let resolved = result.resolve(NORMAL);
1005        // Removing normal group should leave empty
1006        assert_eq!(resolved, []);
1007    }
1008
1009    #[test]
1010    fn test_parse_subs_unknown_is_skipped() {
1011        // Unknown substitution types are logged and skipped
1012        let result = parse_subs_attribute("unknown");
1013        assert_eq!(explicit(&result), []);
1014    }
1015
1016    #[test]
1017    fn test_parse_subs_unknown_mixed_with_valid() {
1018        // Unknown substitution types are skipped, valid ones are kept
1019        let result = parse_subs_attribute("quotes,typo,attributes");
1020        assert_eq!(
1021            explicit(&result),
1022            vec![Substitution::Quotes, Substitution::Attributes]
1023        );
1024    }
1025
1026    #[test]
1027    fn test_parse_subs_all_individual_types() {
1028        // Test each substitution type can be parsed
1029        assert_eq!(
1030            explicit(&parse_subs_attribute("attributes")),
1031            vec![Substitution::Attributes]
1032        );
1033        assert_eq!(
1034            explicit(&parse_subs_attribute("replacements")),
1035            vec![Substitution::Replacements]
1036        );
1037        assert_eq!(
1038            explicit(&parse_subs_attribute("macros")),
1039            vec![Substitution::Macros]
1040        );
1041        assert_eq!(
1042            explicit(&parse_subs_attribute("post_replacements")),
1043            vec![Substitution::PostReplacements]
1044        );
1045        assert_eq!(
1046            explicit(&parse_subs_attribute("quotes")),
1047            vec![Substitution::Quotes]
1048        );
1049        assert_eq!(
1050            explicit(&parse_subs_attribute("callouts")),
1051            vec![Substitution::Callouts]
1052        );
1053    }
1054
1055    #[test]
1056    fn test_parse_subs_shorthand_types() {
1057        assert_eq!(
1058            explicit(&parse_subs_attribute("a")),
1059            vec![Substitution::Attributes]
1060        );
1061        assert_eq!(
1062            explicit(&parse_subs_attribute("r")),
1063            vec![Substitution::Replacements]
1064        );
1065        assert_eq!(
1066            explicit(&parse_subs_attribute("m")),
1067            vec![Substitution::Macros]
1068        );
1069        assert_eq!(
1070            explicit(&parse_subs_attribute("p")),
1071            vec![Substitution::PostReplacements]
1072        );
1073        assert_eq!(
1074            explicit(&parse_subs_attribute("q")),
1075            vec![Substitution::Quotes]
1076        );
1077        assert_eq!(
1078            explicit(&parse_subs_attribute("c")),
1079            vec![Substitution::SpecialChars]
1080        );
1081    }
1082
1083    #[test]
1084    fn test_parse_subs_mixed_modifier_list() {
1085        let result = parse_subs_attribute("specialchars,+quotes");
1086        assert_eq!(
1087            explicit(&result),
1088            &[Substitution::SpecialChars, Substitution::Quotes]
1089        );
1090        assert_eq!(result.resolve(VERBATIM), explicit(&result));
1091    }
1092
1093    #[test]
1094    fn test_parse_subs_modifier_in_middle() {
1095        let result = parse_subs_attribute("attributes,+quotes,-callouts");
1096        assert_eq!(
1097            explicit(&result),
1098            &[Substitution::Attributes, Substitution::Quotes]
1099        );
1100        assert_eq!(result.resolve(VERBATIM), explicit(&result));
1101    }
1102
1103    #[test]
1104    fn test_parse_subs_asciidoctor_example() {
1105        // From asciidoctor docs: subs="attributes+,+replacements,-callouts"
1106        let result = parse_subs_attribute("attributes+,+replacements,-callouts");
1107        let ops = source_entries(&result);
1108        assert_eq!(ops.len(), 3);
1109
1110        // Verify resolved result with VERBATIM baseline
1111        let resolved = result.resolve(VERBATIM);
1112        assert_eq!(resolved.first(), Some(&Substitution::Attributes)); // prepended
1113        assert!(resolved.contains(&Substitution::Replacements)); // appended
1114        assert!(!resolved.contains(&Substitution::Callouts)); // removed
1115    }
1116
1117    #[test]
1118    fn test_parse_subs_modifier_only_at_end() {
1119        let result = parse_subs_attribute("quotes,-specialchars");
1120        assert_eq!(explicit(&result), &[Substitution::Quotes]);
1121        assert_eq!(result.resolve(VERBATIM), explicit(&result));
1122    }
1123
1124    #[test]
1125    fn plain_first_substitution_lists_ignore_all_block_baselines() -> Result<(), serde_json::Error>
1126    {
1127        for (spec, expected) in [
1128            ("quotes,+attributes", "quotes,attributes"),
1129            ("quotes,attributes+", "attributes,quotes"),
1130            ("quotes,-quotes", ""),
1131            ("quotes,-quotes,+quotes", "quotes"),
1132            ("none,+quotes", "quotes"),
1133            ("none,-quotes,+attributes", "attributes"),
1134            ("quotes,+none", "quotes"),
1135            ("quotes,none", "quotes"),
1136            (
1137                "none,normal,-macros",
1138                "special_chars,quotes,attributes,replacements,post_replacements",
1139            ),
1140            (
1141                "normal,-macros",
1142                "special_chars,quotes,attributes,replacements,post_replacements",
1143            ),
1144            ("verbatim,-callouts,+quotes", "special_chars,quotes"),
1145            (
1146                "verbatim,normal+",
1147                "special_chars,quotes,attributes,replacements,macros,post_replacements,callouts",
1148            ),
1149            (
1150                "normal,verbatim+",
1151                "special_chars,callouts,quotes,attributes,replacements,macros,post_replacements",
1152            ),
1153            ("none,+normal,-normal", ""),
1154            ("unknown,+quotes", "quotes"),
1155            ("none,unknown,+quotes", "quotes"),
1156            (",+quotes", "quotes"),
1157            (" , +quotes", "quotes"),
1158            (",,attributes+", "attributes"),
1159            ("quotes,,+attributes,", "quotes,attributes"),
1160            ("quotes,+attributes,-quotes,quotes+", "quotes,attributes"),
1161            ("quotes,attributes+,+quotes", "attributes,quotes"),
1162            ("attributes,+quotes,-callouts", "attributes,quotes"),
1163            ("quotes,-normal,+macros", "macros"),
1164            ("normal,-normal,+attributes", "attributes"),
1165            (
1166                "normal,+replacements",
1167                "special_chars,quotes,attributes,replacements,macros,post_replacements",
1168            ),
1169        ] {
1170            let parsed = parse_subs_attribute(spec);
1171            assert_eq!(
1172                source_entries(&parsed),
1173                spec.split(',').map(str::trim).collect::<Vec<_>>(),
1174                "{spec}"
1175            );
1176            for baseline in [NORMAL, VERBATIM, &[]] {
1177                let actual = parsed.resolve(baseline);
1178                assert_eq!(
1179                    actual
1180                        .iter()
1181                        .map(ToString::to_string)
1182                        .collect::<Vec<_>>()
1183                        .join(","),
1184                    expected,
1185                    "{spec} with {baseline:?}"
1186                );
1187                assert_eq!(
1188                    serde_json::to_value(&parsed)?,
1189                    serde_json::to_value(spec.split(',').map(str::trim).collect::<Vec<_>>())?,
1190                    "{spec}"
1191                );
1192            }
1193        }
1194        Ok(())
1195    }
1196
1197    #[test]
1198    fn modifier_first_substitution_lists_keep_block_baselines() {
1199        for spec in [
1200            "+quotes,attributes",
1201            "+none,quotes,attributes",
1202            "-unknown,quotes,attributes",
1203            "+quotes,,attributes",
1204        ] {
1205            let parsed = parse_subs_attribute(spec);
1206            assert_eq!(
1207                source_entries(&parsed),
1208                spec.split(',').map(str::trim).collect::<Vec<_>>(),
1209                "{spec}"
1210            );
1211            assert_eq!(parsed.resolve(NORMAL), NORMAL, "{spec}");
1212            assert_eq!(
1213                parsed.resolve(VERBATIM),
1214                [
1215                    Substitution::SpecialChars,
1216                    Substitution::Callouts,
1217                    Substitution::Quotes,
1218                    Substitution::Attributes
1219                ],
1220                "{spec}"
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn test_resolve_modifiers_with_normal_baseline() {
1227        // This is the key test for the bug fix:
1228        // -quotes on a paragraph should remove quotes from NORMAL baseline
1229        let result = parse_subs_attribute("-quotes");
1230        let resolved = result.resolve(NORMAL);
1231
1232        // Should have all of NORMAL except Quotes
1233        assert!(resolved.contains(&Substitution::SpecialChars));
1234        assert!(resolved.contains(&Substitution::Attributes));
1235        assert!(!resolved.contains(&Substitution::Quotes)); // removed
1236        assert!(resolved.contains(&Substitution::Replacements));
1237        assert!(resolved.contains(&Substitution::Macros));
1238        assert!(resolved.contains(&Substitution::PostReplacements));
1239    }
1240
1241    #[test]
1242    fn test_resolve_modifiers_with_verbatim_baseline() {
1243        // -quotes on a listing block: Quotes wasn't in VERBATIM, so no effect
1244        let result = parse_subs_attribute("-quotes");
1245        let resolved = result.resolve(VERBATIM);
1246
1247        // Should still have all of VERBATIM (quotes wasn't there to remove)
1248        assert!(resolved.contains(&Substitution::SpecialChars));
1249        assert!(resolved.contains(&Substitution::Callouts));
1250        assert!(!resolved.contains(&Substitution::Quotes));
1251    }
1252
1253    #[test]
1254    fn test_resolve_explicit_ignores_baseline() {
1255        // Explicit lists should ignore the baseline
1256        let result = parse_subs_attribute("quotes,attributes");
1257        let resolved_normal = result.resolve(NORMAL);
1258        let resolved_verbatim = result.resolve(VERBATIM);
1259
1260        // Both should be the same
1261        assert_eq!(resolved_normal, resolved_verbatim);
1262        assert_eq!(
1263            resolved_normal,
1264            vec![Substitution::Quotes, Substitution::Attributes]
1265        );
1266    }
1267
1268    #[test]
1269    fn test_resolve_attribute_references() {
1270        // These two are attributes we add to the attributes map.
1271        let attribute_weight: AttributeValue = "weight".into();
1272        let attribute_mass: AttributeValue = "mass".into();
1273
1274        // This one is an attribute we do NOT add to the attributes map so it can never be
1275        // resolved.
1276        let attribute_volume_repeat = "value {attribute_volume}";
1277
1278        let mut attributes = DocumentAttributes::default();
1279        assert!(
1280            attributes
1281                .insert("weight".into(), attribute_weight.clone())
1282                .is_ok()
1283        );
1284        assert!(
1285            attributes
1286                .insert("mass".into(), attribute_mass.clone())
1287                .is_ok()
1288        );
1289
1290        // Resolve an attribute that is in the attributes map.
1291        let resolved = substitute("{weight}", HEADER, &attributes);
1292        assert_eq!(resolved, "weight");
1293
1294        // Resolve two attributes that are in the attributes map.
1295        let resolved = substitute("{weight} {mass}", HEADER, &attributes);
1296        assert_eq!(resolved, "weight mass");
1297
1298        // Resolve without attributes in the map
1299        let resolved = substitute("value {attribute_volume}", HEADER, &attributes);
1300        assert_eq!(resolved, attribute_volume_repeat);
1301    }
1302
1303    #[test]
1304    fn test_resolve_typed_default_and_original_text() {
1305        let mut attributes = DocumentAttributes::default();
1306        assert_eq!(
1307            substitute("depth={max-include-depth}", HEADER, &attributes),
1308            "depth=64"
1309        );
1310
1311        assert!(
1312            attributes
1313                .set("max-include-depth".into(), "064".into())
1314                .is_ok()
1315        );
1316        assert_eq!(
1317            substitute("depth={max-include-depth}", HEADER, &attributes),
1318            "depth=064"
1319        );
1320
1321        assert!(
1322            attributes
1323                .set("present".into(), AttributeValue::Bool(true))
1324                .is_ok()
1325        );
1326        assert_eq!(
1327            substitute("before{present}after", HEADER, &attributes),
1328            "beforeafter"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_substitute_single_pass_expansion() {
1334        // Test that the substitute() function does single-pass expansion.
1335        // When foo's value is "{bar}", substitute("{foo}") returns the literal
1336        // "{bar}" string - it does NOT recursively resolve {bar}.
1337        //
1338        // This is correct behavior because:
1339        // 1. Definition-time resolution is handled separately (in the grammar parser)
1340        // 2. The substitute function just replaces one level of references
1341        let mut attributes = DocumentAttributes::default();
1342        assert!(attributes.insert("foo".into(), "{bar}".into()).is_ok());
1343        assert!(
1344            attributes
1345                .insert("bar".into(), "should-not-appear".into())
1346                .is_ok()
1347        );
1348
1349        let resolved = substitute("{foo}", HEADER, &attributes);
1350        assert_eq!(resolved, "{bar}");
1351    }
1352
1353    #[test]
1354    fn test_utf8_boundary_handling() {
1355        // Regression test for fuzzer-found bug: UTF-8 multi-byte characters
1356        // should not cause panics during attribute substitution
1357        let attributes = DocumentAttributes::default();
1358
1359        let values = [
1360            // Input with UTF-8 multi-byte character (Ô = 0xc3 0x94)
1361            ":J::~\x01\x00\x00Ô",
1362            // Test with various UTF-8 characters and attribute-like patterns
1363            "{attr}Ô{missing}日本語",
1364            // Test with multi-byte chars inside attribute name
1365            "{attrÔ}test",
1366        ];
1367        for value in values {
1368            let resolved = substitute(value, HEADER, &attributes);
1369            assert_eq!(resolved, value);
1370        }
1371    }
1372
1373    // One row per `subs=` attribute × substitution to inspect. Covers the four
1374    // forms accepted by `parse_subs_attribute` (explicit list, single short
1375    // alias, modifier list, the special `none` keyword) against each known
1376    // substitution. `expected = true` means the substitution is disabled by
1377    // that spec.
1378    #[rstest::rstest]
1379    // Explicit list — `specialchars` disables everything else.
1380    #[case::explicit_specialchars_disables_macros("specialchars", Substitution::Macros, true)]
1381    #[case::explicit_specialchars_disables_attributes(
1382        "specialchars",
1383        Substitution::Attributes,
1384        true
1385    )]
1386    #[case::explicit_specialchars_disables_post_replacements(
1387        "specialchars",
1388        Substitution::PostReplacements,
1389        true
1390    )]
1391    #[case::explicit_specialchars_disables_quotes("specialchars", Substitution::Quotes, true)]
1392    #[case::explicit_specialchars_disables_callouts("specialchars", Substitution::Callouts, true)]
1393    // Explicit single-name list — that one substitution is enabled.
1394    #[case::explicit_macros("macros", Substitution::Macros, false)]
1395    #[case::explicit_attributes("attributes", Substitution::Attributes, false)]
1396    #[case::explicit_post_replacements("post_replacements", Substitution::PostReplacements, false)]
1397    #[case::explicit_quotes("quotes", Substitution::Quotes, false)]
1398    #[case::explicit_callouts("callouts", Substitution::Callouts, false)]
1399    // Short aliases resolve to the same enabled state.
1400    #[case::short_alias_p_enables_post_replacements("p", Substitution::PostReplacements, false)]
1401    #[case::short_alias_q_enables_quotes("q", Substitution::Quotes, false)]
1402    // Baseline groups (`normal` / `verbatim`) include their members.
1403    #[case::baseline_normal_includes_macros("normal", Substitution::Macros, false)]
1404    #[case::baseline_normal_includes_attributes("normal", Substitution::Attributes, false)]
1405    #[case::baseline_normal_includes_post_replacements(
1406        "normal",
1407        Substitution::PostReplacements,
1408        false
1409    )]
1410    #[case::baseline_normal_includes_quotes("normal", Substitution::Quotes, false)]
1411    #[case::baseline_verbatim_includes_callouts("verbatim", Substitution::Callouts, false)]
1412    // Modifier remove `-X` disables only X.
1413    #[case::modifier_remove_macros("-macros", Substitution::Macros, true)]
1414    #[case::modifier_remove_attributes("-attributes", Substitution::Attributes, true)]
1415    #[case::modifier_remove_post_replacements(
1416        "-post_replacements",
1417        Substitution::PostReplacements,
1418        true
1419    )]
1420    #[case::modifier_remove_quotes("-quotes", Substitution::Quotes, true)]
1421    #[case::modifier_remove_callouts("-callouts", Substitution::Callouts, true)]
1422    // Later operations can restore individual stages or whole groups.
1423    #[case::modifier_add_macros("+macros", Substitution::Macros, false)]
1424    #[case::modifier_add_attributes("+attributes", Substitution::Attributes, false)]
1425    #[case::modifier_add_post_replacements(
1426        "+post_replacements",
1427        Substitution::PostReplacements,
1428        false
1429    )]
1430    #[case::modifier_add_quotes("+quotes", Substitution::Quotes, false)]
1431    #[case::modifier_add_callouts("+callouts", Substitution::Callouts, false)]
1432    #[case::remove_normal_disables_macros("-normal", Substitution::Macros, true)]
1433    #[case::remove_normal_disables_quotes("-normal", Substitution::Quotes, true)]
1434    #[case::remove_normal_disables_attributes("-normal", Substitution::Attributes, true)]
1435    #[case::remove_normal_disables_hardbreaks("-normal", Substitution::PostReplacements, true)]
1436    #[case::remove_normal_keeps_callouts("-normal", Substitution::Callouts, false)]
1437    #[case::remove_verbatim_disables_callouts("-verbatim", Substitution::Callouts, true)]
1438    #[case::remove_verbatim_disables_specialchars("-verbatim", Substitution::SpecialChars, true)]
1439    #[case::remove_verbatim_keeps_quotes("-verbatim", Substitution::Quotes, false)]
1440    #[case::restore_normal_macros("-normal,+normal", Substitution::Macros, false)]
1441    #[case::restore_normal_attributes("-normal,+normal", Substitution::Attributes, false)]
1442    #[case::restore_verbatim_callouts("-verbatim,+verbatim", Substitution::Callouts, false)]
1443    #[case::restore_only_callouts("-verbatim,+callouts", Substitution::SpecialChars, true)]
1444    #[case::restore_only_specialchars("-verbatim,+specialchars", Substitution::Callouts, true)]
1445    #[case::remove_restored_group("-normal,+normal,-normal", Substitution::Quotes, true)]
1446    #[case::restore_only_macros("-normal,+macros", Substitution::Attributes, true)]
1447    #[case::restore_only_attributes("-normal,attributes+", Substitution::Macros, true)]
1448    // `none` is the explicit empty list and disables everything.
1449    #[case::none_disables_macros("none", Substitution::Macros, true)]
1450    #[case::none_disables_attributes("none", Substitution::Attributes, true)]
1451    #[case::none_disables_post_replacements("none", Substitution::PostReplacements, true)]
1452    #[case::none_disables_quotes("none", Substitution::Quotes, true)]
1453    #[case::none_disables_callouts("none", Substitution::Callouts, true)]
1454    fn block_plan_disables_requested_stages(
1455        #[case] subs_attr: &str,
1456        #[case] sub: Substitution,
1457        #[case] expected: bool,
1458    ) {
1459        let spec = parse_subs_attribute(subs_attr);
1460        assert_eq!(
1461            !SubstitutionPlan::for_block_spec(&spec).enabled(&sub),
1462            expected,
1463            "spec={subs_attr:?} sub={sub:?}"
1464        );
1465    }
1466
1467    #[test]
1468    fn removed_group_plans_keep_restored_stages_in_order() {
1469        for (spec, expected) in [
1470            ("-normal", "callouts"),
1471            ("-normal,attributes+", "attributes,callouts"),
1472            ("-normal,+attributes,+macros", "callouts,attributes,macros"),
1473            ("-normal,+macros,+attributes", "callouts,macros,attributes"),
1474            (
1475                "-normal,+normal",
1476                "callouts,specialchars,quotes,attributes,replacements,macros,post_replacements",
1477            ),
1478            (
1479                "-normal,normal+,attributes+",
1480                "attributes,specialchars,quotes,replacements,macros,post_replacements,callouts",
1481            ),
1482            (
1483                "-verbatim",
1484                "quotes,attributes,replacements,macros,post_replacements",
1485            ),
1486            (
1487                "-verbatim,+verbatim",
1488                "quotes,attributes,replacements,macros,post_replacements,specialchars,callouts",
1489            ),
1490            (
1491                "-verbatim,+callouts",
1492                "quotes,attributes,replacements,macros,post_replacements,callouts",
1493            ),
1494            ("-normal,-verbatim", "none"),
1495            ("-normal,+quotes,-normal", "callouts"),
1496        ] {
1497            assert_eq!(
1498                SubstitutionPlan::for_block_spec(&parse_subs_attribute(spec)),
1499                SubstitutionPlan::from_substitutions(&parse_subs_attribute(expected).resolve(&[])),
1500                "{spec}"
1501            );
1502        }
1503    }
1504}
1505
1506/// Expand known attribute references once, leaving unresolved references unchanged.
1507///
1508/// Names contain one or more ASCII letters, digits, underscores, or hyphens.
1509/// A backslash before a valid reference is removed and protects that reference
1510/// from lookup. Only valid, unescaped references call `lookup`; inserted values
1511/// are not scanned again. Other text and line endings are preserved.
1512///
1513/// The result borrows the input when no reference is replaced or escape removed.
1514#[must_use]
1515pub fn substitute_attributes<'text, 'value>(
1516    text: &'text str,
1517    lookup: impl FnMut(&str) -> Option<&'value super::DocumentAttributeValue<'value>>,
1518) -> Cow<'text, str> {
1519    substitute_attributes_with_ranges(text, lookup, |_, _| {})
1520}
1521
1522/// Resolve attribute source and return a diagnostic for unsupported profiles.
1523pub(crate) fn resolve_attribute_entry_text<'text>(
1524    text: &'text str,
1525    attributes: &DocumentAttributes<'_>,
1526) -> (super::DocumentAttributeValue<'text>, Option<String>) {
1527    if let Some(profile) = attribute_inline_profile(text, attributes) {
1528        return match profile {
1529            Ok((inner, mut substitutions)) => {
1530                let inner = if substitutions.contains(&Substitution::Attributes) {
1531                    expand_attribute_entry_references(inner, attributes, false).into()
1532                } else {
1533                    Cow::Borrowed(inner)
1534                };
1535                // References are frozen at assignment. Structural rules run at
1536                // the use site and must not expand those references again.
1537                substitutions.retain(|stage| *stage != Substitution::Attributes);
1538                let fragment = super::AttributeInlineFragment {
1539                    range: 0..inner.len(),
1540                    substitutions: substitutions.into_boxed_slice(),
1541                };
1542                (
1543                    super::DocumentAttributeValue::with_inline_fragments(
1544                        inner,
1545                        Vec::new(),
1546                        vec![fragment],
1547                    ),
1548                    None,
1549                )
1550            }
1551            Err(message) => (
1552                super::DocumentAttributeValue::with_inline_fragments(
1553                    Cow::Borrowed(text),
1554                    Vec::new(),
1555                    vec![super::AttributeInlineFragment {
1556                        range: 0..text.len(),
1557                        substitutions: vec![Substitution::SpecialChars].into_boxed_slice(),
1558                    }],
1559                ),
1560                Some(message),
1561            ),
1562        };
1563    }
1564    if let Some((inner, substitutions)) = attribute_text_substitutions(text) {
1565        if substitutions == [AttributeTextSubstitution::Attributes] {
1566            let value = resolve_attribute_references(inner, attributes, true);
1567            if !value.inline_fragments().is_empty() {
1568                return (value, None);
1569            }
1570        }
1571        let mut inner = Cow::Borrowed(inner);
1572        for substitution in substitutions {
1573            inner = match substitution {
1574                AttributeTextSubstitution::Attributes => {
1575                    expand_attribute_entry_references(&inner, attributes, true).into()
1576                }
1577                AttributeTextSubstitution::SpecialChars => {
1578                    escape_attribute_characters(&inner).into()
1579                }
1580            };
1581        }
1582        let ranges = if inner.is_empty() {
1583            Vec::new()
1584        } else {
1585            std::iter::once(0..inner.len()).collect()
1586        };
1587        return (
1588            super::DocumentAttributeValue::with_passthrough_ranges(inner, ranges),
1589            None,
1590        );
1591    }
1592    (resolve_attribute_references(text, attributes, false), None)
1593}
1594
1595fn resolve_attribute_references<'text>(
1596    text: &'text str,
1597    attributes: &DocumentAttributes<'_>,
1598    protect_gaps: bool,
1599) -> super::DocumentAttributeValue<'text> {
1600    let mut ranges = Vec::new();
1601    let mut fragments = Vec::new();
1602    let text = substitute_attributes_with_ranges(
1603        text,
1604        |name| attributes.get(name),
1605        |value, offset| {
1606            fragments.extend(
1607                value
1608                    .inline_fragments()
1609                    .iter()
1610                    .cloned()
1611                    .map(|mut fragment| {
1612                        fragment.range = fragment.range.start + offset..fragment.range.end + offset;
1613                        fragment
1614                    }),
1615            );
1616            ranges.extend(
1617                value
1618                    .passthrough_ranges()
1619                    .iter()
1620                    .map(|range| range.start + offset..range.end + offset),
1621            );
1622        },
1623    );
1624    if protect_gaps && !fragments.is_empty() {
1625        // An explicit `a` list imports referenced profiles but enables no
1626        // structural rules for the surrounding source.
1627        let mut gaps = Vec::new();
1628        let mut cursor = 0;
1629        for fragment in &fragments {
1630            if cursor < fragment.range.start {
1631                gaps.push(cursor..fragment.range.start);
1632            }
1633            cursor = fragment.range.end;
1634        }
1635        if cursor < text.len() {
1636            gaps.push(cursor..text.len());
1637        }
1638        fragments.extend(
1639            gaps.into_iter()
1640                .map(|range| super::AttributeInlineFragment {
1641                    range,
1642                    substitutions: Box::default(),
1643                }),
1644        );
1645    }
1646    super::DocumentAttributeValue::with_inline_fragments(text, ranges, fragments)
1647}
1648
1649/// Select retained source rules, rejecting lists that require converted markup.
1650fn attribute_inline_profile<'text>(
1651    text: &'text str,
1652    attributes: &DocumentAttributes<'_>,
1653) -> Option<Result<(&'text str, Vec<Substitution>), String>> {
1654    let (names, inner) = text.trim().strip_prefix("pass:")?.split_once('[')?;
1655    let inner = inner.strip_suffix(']')?;
1656    let mut substitutions = Vec::new();
1657    for name in names
1658        .split(',')
1659        .filter(|name| !name.is_empty() && *name != "none")
1660    {
1661        let Some(stage) = parse_substitution(name) else {
1662            return Some(Err(format!(
1663                "unknown attribute-value substitution: {name}; value retained literally"
1664            )));
1665        };
1666        substitutions.push(stage);
1667    }
1668    let substitutions = resolve_passthrough_substitutions(&substitutions);
1669    if !substitutions.iter().any(|stage| {
1670        matches!(
1671            stage,
1672            Substitution::Quotes
1673                | Substitution::Macros
1674                | Substitution::Replacements
1675                | Substitution::PostReplacements
1676        )
1677    }) {
1678        if substitutions.contains(&Substitution::Attributes)
1679            && substitutions.contains(&Substitution::SpecialChars)
1680            && !resolve_attribute_references(inner, attributes, false)
1681                .inline_fragments()
1682                .is_empty()
1683        {
1684            return Some(Err("attribute-value escaping combined with formatted references is unsupported; value retained literally".into()));
1685        }
1686        return None;
1687    }
1688    let mut structural = false;
1689    for stage in &substitutions {
1690        if *stage == Substitution::SpecialChars && structural {
1691            return Some(Err("attribute-value escaping after formatting or macros requires backend markup; value retained literally".into()));
1692        }
1693        structural |= matches!(stage, Substitution::Quotes | Substitution::Macros);
1694    }
1695    Some(Ok((inner, substitutions)))
1696}
1697
1698#[derive(PartialEq, Eq)]
1699pub(crate) enum AttributeTextSubstitution {
1700    Attributes,
1701    SpecialChars,
1702}
1703
1704fn attribute_text_substitutions(text: &str) -> Option<(&str, Vec<AttributeTextSubstitution>)> {
1705    let (names, inner) = text.trim().strip_prefix("pass:")?.split_once('[')?;
1706    let inner = inner.strip_suffix(']')?;
1707    Some((inner, attribute_text_substitution_names(names)?))
1708}
1709
1710pub(crate) fn attribute_text_substitution_names(
1711    names: &str,
1712) -> Option<Vec<AttributeTextSubstitution>> {
1713    let mut substitutions = Vec::new();
1714    if !names.is_empty() {
1715        for name in names.split(',') {
1716            let substitution = match name {
1717                "none" => continue,
1718                "a" | "attributes" => AttributeTextSubstitution::Attributes,
1719                "c" | "specialchars" | "specialcharacters" | "v" | "verbatim" => {
1720                    AttributeTextSubstitution::SpecialChars
1721                }
1722                _ => return None,
1723            };
1724            if !substitutions.contains(&substitution) {
1725                substitutions.push(substitution);
1726            }
1727        }
1728    }
1729    Some(substitutions)
1730}
1731
1732pub(crate) fn escape_attribute_characters(text: &str) -> String {
1733    text.replace('&', "&amp;")
1734        .replace('<', "&lt;")
1735        .replace('>', "&gt;")
1736}
1737
1738/// Find a reference-shaped token, including its existing `amp;` escaping.
1739pub(crate) fn character_reference_end(text: &str) -> Option<usize> {
1740    let tail = text.strip_prefix('&')?;
1741    let first = tail.find(';')?;
1742    if tail[..first].is_empty()
1743        || !tail[..first]
1744            .bytes()
1745            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'#')
1746    {
1747        return None;
1748    }
1749    let mut unescaped = tail;
1750    while let Some(rest) = unescaped.strip_prefix("amp;") {
1751        unescaped = rest;
1752    }
1753    let end = unescaped.find(';').filter(|end| {
1754        !unescaped[..*end].is_empty()
1755            && unescaped[..*end]
1756                .bytes()
1757                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'#')
1758    });
1759    Some(
1760        end.map_or((text.len() - unescaped.len()).max(first + 2), |end| {
1761            text.len() - unescaped.len() + end + 1
1762        }),
1763    )
1764}
1765
1766pub(crate) fn is_restorable_character_reference(body: &str) -> bool {
1767    // Asciidoctor restores only this subset during replacements. In particular,
1768    // a one-digit numeric reference and an uppercase hex prefix stay escaped.
1769    if let Some(digits) = body.strip_prefix("#x") {
1770        (2..=5).contains(&digits.len()) && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
1771    } else if let Some(digits) = body.strip_prefix('#') {
1772        (2..=6).contains(&digits.len()) && digits.bytes().all(|byte| byte.is_ascii_digit())
1773    } else {
1774        let letters = body.bytes().take_while(u8::is_ascii_alphabetic).count();
1775        letters >= 2
1776            && body.len() - letters <= 2
1777            && body[letters..].bytes().all(|byte| byte.is_ascii_digit())
1778    }
1779}
1780
1781fn expand_attribute_entry_references(
1782    text: &str,
1783    attributes: &DocumentAttributes<'_>,
1784    escape_document_values: bool,
1785) -> String {
1786    let mut output = String::with_capacity(text.len());
1787    let mut remaining = text;
1788    while let Some((prefix, candidate)) = remaining.split_once('{') {
1789        let Some((name, rest)) = candidate.split_once('}') else {
1790            break;
1791        };
1792        if prefix.ends_with('\\') || name.ends_with('\\') {
1793            output.push_str(prefix.strip_suffix('\\').unwrap_or(prefix));
1794            output.push('{');
1795            output.push_str(name.strip_suffix('\\').unwrap_or(name));
1796            output.push('}');
1797        } else if let Some(value) = attributes.get(name) {
1798            output.push_str(prefix);
1799            let mut value_text = String::new();
1800            let _ = value.write_text(&mut value_text);
1801            if escape_document_values && attributes.is_document_value(name) {
1802                // Ordinary entries already underwent header escaping. Raw ranges
1803                // contain prepared text and must not be escaped a second time.
1804                let mut cursor = 0;
1805                for range in value.passthrough_ranges() {
1806                    output.push_str(&escape_attribute_characters(
1807                        &value_text[cursor..range.start],
1808                    ));
1809                    output.push_str(&value_text[range.clone()]);
1810                    cursor = range.end;
1811                }
1812                output.push_str(&escape_attribute_characters(&value_text[cursor..]));
1813            } else {
1814                output.push_str(&value_text);
1815            }
1816        } else {
1817            output.push_str(prefix);
1818            output.push('{');
1819            output.push_str(name);
1820            output.push('}');
1821        }
1822        remaining = rest;
1823    }
1824    output.push_str(remaining);
1825    output
1826}
1827
1828fn substitute_attributes_with_ranges<'text, 'value>(
1829    text: &'text str,
1830    mut lookup: impl FnMut(&str) -> Option<&'value super::DocumentAttributeValue<'value>>,
1831    mut substituted: impl FnMut(&super::DocumentAttributeValue<'value>, usize),
1832) -> Cow<'text, str> {
1833    let mut remaining = text;
1834    let mut unwritten = text;
1835    let mut output: Option<String> = None;
1836    while let Some((prefix, candidate)) = remaining.split_once('{') {
1837        let name_len = candidate
1838            .bytes()
1839            .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'-'))
1840            .count();
1841        let (name, after_name) = candidate.split_at(name_len);
1842        let Some(rest) = after_name.strip_prefix('}') else {
1843            remaining = after_name;
1844            continue;
1845        };
1846        remaining = rest;
1847        if name.is_empty() {
1848            continue;
1849        }
1850        let prefix_len = unwritten.len() - candidate.len() - 1;
1851        if prefix.ends_with('\\') {
1852            let output = output.get_or_insert_with(|| String::with_capacity(text.len()));
1853            output.push_str(unwritten.split_at(prefix_len - 1).0);
1854            output.push('{');
1855            output.push_str(name);
1856            output.push('}');
1857            unwritten = rest;
1858        } else if let Some(value) = lookup(name) {
1859            let output = output.get_or_insert_with(|| String::with_capacity(text.len()));
1860            output.push_str(unwritten.split_at(prefix_len).0);
1861            substituted(value, output.len());
1862            let _ = value.write_text(output);
1863            unwritten = rest;
1864        }
1865    }
1866    match output {
1867        Some(mut output) => {
1868            output.push_str(unwritten);
1869            Cow::Owned(output)
1870        }
1871        None => Cow::Borrowed(text),
1872    }
1873}
1874
1875#[cfg(test)]
1876mod attribute_substitution_tests {
1877    use std::borrow::Cow;
1878
1879    use crate::{DocumentAttributeValue, Options};
1880
1881    use super::substitute_attributes;
1882
1883    #[rstest::rstest]
1884    #[case(r"\{shell}", "{shell}")]
1885    #[case(r"\\{shell}", r"\{shell}")]
1886    #[case(r"\\\{missing}", r"\\{missing}")]
1887    fn escaped_references_do_not_call_lookup(#[case] input: &str, #[case] expected: &str) {
1888        let value = DocumentAttributeValue::from("unexpected");
1889        let mut names = Vec::new();
1890        let output = substitute_attributes(input, |name| {
1891            names.push(name.to_owned());
1892            Some(&value)
1893        });
1894        assert_eq!(output, expected);
1895        assert_eq!(names, [] as [String; 0]);
1896    }
1897
1898    #[rstest::rstest]
1899    #[case("λ plain\r\ntext\n", &[])]
1900    #[case("{missing}", &["missing"])]
1901    #[case("{{missing}}", &["missing"])]
1902    #[case("{} {bad name} {name.part} {café} {counter:n}", &[])]
1903    #[case(r"\{bad name} {shell\} ${shell:-fallback}", &[])]
1904    #[case("{unfinished", &[])]
1905    fn unchanged_text_stays_borrowed(#[case] input: &str, #[case] expected_names: &[&str]) {
1906        let mut names = Vec::new();
1907        let output = substitute_attributes(input, |name| {
1908            names.push(name.to_owned());
1909            None
1910        });
1911        assert!(matches!(output, Cow::Borrowed(value) if std::ptr::eq(value, input)));
1912        assert_eq!(names, expected_names);
1913    }
1914
1915    #[test]
1916    fn adjacent_escaped_known_and_unknown_references_are_distinct() {
1917        let value = DocumentAttributeValue::from("zsh");
1918        let mut names = Vec::new();
1919        let output = substitute_attributes(r"{shell}\{shell}{missing}{shell}", |name| {
1920            names.push(name.to_owned());
1921            (name == "shell").then_some(&value)
1922        });
1923        assert_eq!(output, "zsh{shell}{missing}zsh");
1924        assert_eq!(names, ["shell", "missing", "shell"]);
1925    }
1926
1927    #[rstest::rstest]
1928    #[case("{{shell}}", "{zsh}", 1)]
1929    #[case("{outer{shell}}", "{outerzsh}", 1)]
1930    #[case(r"\{{shell}}", r"\{zsh}", 1)]
1931    #[case(r"{\{shell}}", "{{shell}}", 0)]
1932    #[case(r"\{outer{shell}}", r"\{outerzsh}", 1)]
1933    #[case("{é{shell}}", "{ézsh}", 1)]
1934    fn valid_inner_references_expand_once(
1935        #[case] input: &str,
1936        #[case] expected: &str,
1937        #[case] calls: usize,
1938    ) {
1939        let value = DocumentAttributeValue::from("zsh");
1940        let mut names = Vec::new();
1941        let output = substitute_attributes(input, |name| {
1942            names.push(name.to_owned());
1943            Some(&value)
1944        });
1945        assert_eq!(output, expected);
1946        assert_eq!(names, vec!["shell"; calls]);
1947    }
1948
1949    #[test]
1950    fn names_follow_the_parser_reference_grammar() {
1951        let value = DocumentAttributeValue::from("x");
1952        let mut names = Vec::new();
1953        let output = substitute_attributes("{_}{-}{0}{A-b_2}", |name| {
1954            names.push(name.to_owned());
1955            Some(&value)
1956        });
1957        assert_eq!(output, "xxxx");
1958        assert_eq!(names, ["_", "-", "0", "A-b_2"]);
1959    }
1960
1961    #[test]
1962    fn replacement_text_is_not_scanned_again() {
1963        let first = DocumentAttributeValue::from("{second}\\{third}\r\n");
1964        let second = DocumentAttributeValue::from("done");
1965        let mut names = Vec::new();
1966        let output = substitute_attributes("{first}/{second}", |name| {
1967            names.push(name.to_owned());
1968            match name {
1969                "first" => Some(&first),
1970                "second" => Some(&second),
1971                _ => None,
1972            }
1973        });
1974        assert_eq!(output, "{second}\\{third}\r\n/done");
1975        assert_eq!(names, ["first", "second"]);
1976    }
1977
1978    #[test]
1979    fn values_keep_lexical_spelling_and_empty_values_are_present() -> Result<(), crate::Error> {
1980        let options = Options::builder()
1981            .with_attribute("max-include-depth", "003")
1982            .with_attribute("quoted", "\"word\"")
1983            .with_attribute("empty", "")
1984            .with_attribute("present", true)
1985            .build()?;
1986        let attributes = options.document_attributes();
1987        let output = substitute_attributes(
1988            "\r\n{max-include-depth}/{quoted}/{empty}/{present}\n",
1989            |name| attributes.get(name),
1990        );
1991        assert_eq!(output, "\r\n003/\"word\"//\n");
1992        Ok(())
1993    }
1994}