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