Skip to main content

acdc_parser/model/
substitution.rs

1//! Substitution types and application for `AsciiDoc` content.
2//!
3//! # Architecture: Parser vs Converter Responsibilities
4//!
5//! Substitutions are split between the parser and converters by design:
6//!
7//! ## Parser handles (format-agnostic)
8//!
9//! - **Attributes** - Expands `{name}` references using document attributes.
10//!   This is document-wide and doesn't depend on output format.
11//!
12//! - **Group expansion** - `Normal` and `Verbatim` expand to their constituent
13//!   substitution lists recursively.
14//!
15//! ## Converters handle (format-specific)
16//!
17//! - **`SpecialChars`** - HTML converter escapes `<`, `>`, `&` to entities.
18//!   Other converters may handle differently (e.g., terminal needs no escaping).
19//!
20//! - **Quotes** - Parses inline formatting (`*bold*`, `_italic_`, etc.) via
21//!   [`crate::parse_text_for_quotes`]. The converter then renders the parsed
22//!   nodes appropriately for the output format.
23//!
24//! - **Replacements** - Typography transformations (em-dashes, arrows, ellipsis).
25//!   Output varies by format (HTML entities vs Unicode characters).
26//!
27//! - **Callouts** - Already parsed into [`crate::CalloutRef`] nodes by the grammar.
28//!   Converters render the callout markers.
29//!
30//! - **Macros** / **`PostReplacements`** - Not yet implemented.
31//!
32//! ## Why this split?
33//!
34//! The parser stays format-agnostic. It extracts the substitution list from
35//! `[subs=...]` attributes and stores it in the AST. Each converter then
36//! applies the relevant substitutions for its output format. This allows
37//! adding new converters (terminal, manpage, PDF) without modifying the parser.
38//!
39//! ## Usage flow
40//!
41//! 1. Parser extracts `subs=` attribute → stored in [`crate::BlockMetadata`]
42//! 2. Parser applies `Attributes` substitution during parsing
43//! 3. Converter reads the substitution list from AST
44//! 4. Converter applies remaining substitutions during rendering
45
46use serde::Serialize;
47
48use crate::{AttributeValue, DocumentAttributes};
49
50/// A `Substitution` represents a substitution in a passthrough macro.
51#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum Substitution {
55    SpecialChars,
56    Attributes,
57    Replacements,
58    Macros,
59    PostReplacements,
60    Normal,
61    Verbatim,
62    Quotes,
63    Callouts,
64}
65
66impl std::fmt::Display for Substitution {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        let name = match self {
69            Self::SpecialChars => "special_chars",
70            Self::Attributes => "attributes",
71            Self::Replacements => "replacements",
72            Self::Macros => "macros",
73            Self::PostReplacements => "post_replacements",
74            Self::Normal => "normal",
75            Self::Verbatim => "verbatim",
76            Self::Quotes => "quotes",
77            Self::Callouts => "callouts",
78        };
79        write!(f, "{name}")
80    }
81}
82
83/// Parse a substitution name into a `Substitution` enum variant.
84///
85/// Returns `None` for unknown substitution types, which are logged and skipped.
86pub(crate) fn parse_substitution(value: &str) -> Option<Substitution> {
87    match value {
88        "attributes" | "a" => Some(Substitution::Attributes),
89        "replacements" | "r" => Some(Substitution::Replacements),
90        "macros" | "m" => Some(Substitution::Macros),
91        "post_replacements" | "p" => Some(Substitution::PostReplacements),
92        "normal" | "n" => Some(Substitution::Normal),
93        "verbatim" | "v" => Some(Substitution::Verbatim),
94        "quotes" | "q" => Some(Substitution::Quotes),
95        "callouts" => Some(Substitution::Callouts),
96        "specialchars" | "c" => Some(Substitution::SpecialChars),
97        unknown => {
98            tracing::error!(
99                substitution = %unknown,
100                "unknown substitution type, ignoring - check for typos"
101            );
102            None
103        }
104    }
105}
106
107/// Default substitutions for header content.
108pub const HEADER: &[Substitution] = &[Substitution::SpecialChars, Substitution::Attributes];
109
110/// Default substitutions for normal content (paragraphs, etc).
111pub const NORMAL: &[Substitution] = &[
112    Substitution::SpecialChars,
113    Substitution::Attributes,
114    Substitution::Quotes,
115    Substitution::Replacements,
116    Substitution::Macros,
117    Substitution::PostReplacements,
118];
119
120/// Default substitutions for verbatim blocks (listing, literal).
121pub const VERBATIM: &[Substitution] = &[Substitution::SpecialChars, Substitution::Callouts];
122
123/// A substitution operation to apply to a default substitution list.
124///
125/// Used when the `subs` attribute contains modifier syntax (`+quotes`, `-callouts`, `quotes+`).
126#[derive(Clone, Debug, Hash, Eq, PartialEq)]
127pub enum SubstitutionOp {
128    /// `+name` - append substitution to end of default list
129    Append(Substitution),
130    /// `name+` - prepend substitution to beginning of default list
131    Prepend(Substitution),
132    /// `-name` - remove substitution from default list
133    Remove(Substitution),
134}
135
136impl std::fmt::Display for SubstitutionOp {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            Self::Append(sub) => write!(f, "+{sub}"),
140            Self::Prepend(sub) => write!(f, "{sub}+"),
141            Self::Remove(sub) => write!(f, "-{sub}"),
142        }
143    }
144}
145
146/// Specification for substitutions to apply to a block.
147///
148/// This type represents how substitutions are specified in a `subs` attribute:
149///
150/// - **Explicit**: A direct list of substitutions (e.g., `subs=specialchars,quotes`)
151/// - **Modifiers**: Operations to apply to the block-type default substitutions
152///   (e.g., `subs=+quotes,-callouts`)
153///
154/// The parser cannot know the block type when parsing attributes (metadata comes before
155/// the block delimiter), so modifier operations are stored and the converter applies
156/// them with the appropriate baseline (VERBATIM for listing/literal, NORMAL for paragraphs).
157///
158/// ## Serialization
159///
160/// Serializes to a flat array of strings matching document syntax:
161/// - Explicit: `["special_chars", "quotes"]`
162/// - Modifiers: `["+quotes", "-callouts", "macros+"]`
163#[derive(Clone, Debug, Hash, Eq, PartialEq)]
164pub enum SubstitutionSpec {
165    /// Explicit list of substitutions to apply (replaces all defaults)
166    Explicit(Vec<Substitution>),
167    /// Modifier operations to apply to block-type defaults
168    Modifiers(Vec<SubstitutionOp>),
169}
170
171impl Serialize for SubstitutionSpec {
172    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
173    where
174        S: serde::Serializer,
175    {
176        let strings: Vec<String> = match self {
177            Self::Explicit(subs) => subs.iter().map(ToString::to_string).collect(),
178            Self::Modifiers(ops) => ops.iter().map(ToString::to_string).collect(),
179        };
180        strings.serialize(serializer)
181    }
182}
183
184impl SubstitutionSpec {
185    /// Apply modifier operations to a default substitution list.
186    ///
187    /// This is used by converters to resolve modifiers with the appropriate baseline.
188    #[must_use]
189    pub fn apply_modifiers(ops: &[SubstitutionOp], default: &[Substitution]) -> Vec<Substitution> {
190        let mut result = default.to_vec();
191        for op in ops {
192            match op {
193                SubstitutionOp::Append(sub) => append_substitution(&mut result, sub),
194                SubstitutionOp::Prepend(sub) => prepend_substitution(&mut result, sub),
195                SubstitutionOp::Remove(sub) => remove_substitution(&mut result, sub),
196            }
197        }
198        result
199    }
200
201    /// Resolve the substitution spec to a concrete list of substitutions.
202    ///
203    /// - For `Explicit`, returns the list directly
204    /// - For `Modifiers`, applies the operations to the provided default
205    #[must_use]
206    pub fn resolve(&self, default: &[Substitution]) -> Vec<Substitution> {
207        match self {
208            SubstitutionSpec::Explicit(subs) => subs.clone(),
209            SubstitutionSpec::Modifiers(ops) => Self::apply_modifiers(ops, default),
210        }
211    }
212}
213
214/// Modifier for a substitution in the `subs` attribute (internal parsing helper).
215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216enum SubsModifier {
217    /// `+name` - append to end of default list
218    Append,
219    /// `name+` - prepend to beginning of default list
220    Prepend,
221    /// `-name` - remove from default list
222    Remove,
223}
224
225/// Parse a single subs part into name and optional modifier.
226fn parse_subs_part(part: &str) -> (&str, Option<SubsModifier>) {
227    if let Some(name) = part.strip_prefix('+') {
228        (name, Some(SubsModifier::Append))
229    } else if let Some(name) = part.strip_suffix('+') {
230        (name, Some(SubsModifier::Prepend))
231    } else if let Some(name) = part.strip_prefix('-') {
232        (name, Some(SubsModifier::Remove))
233    } else {
234        (part, None)
235    }
236}
237
238/// Parse a `subs` attribute value into a substitution specification.
239///
240/// Returns either:
241/// - `SubstitutionSpec::Explicit` for explicit lists (e.g., `subs=specialchars,quotes`)
242/// - `SubstitutionSpec::Modifiers` for modifier syntax (e.g., `subs=+quotes,-callouts`)
243///
244/// Supports:
245/// - `none` → Explicit empty list (no substitutions)
246/// - `normal` → Explicit NORMAL list
247/// - `verbatim` → Explicit VERBATIM list
248/// - `a,q,c` → Explicit specific substitutions (comma-separated)
249/// - `+quotes` → Modifiers: append to end of default list
250/// - `quotes+` → Modifiers: prepend to beginning of default list
251/// - `-specialchars` → Modifiers: remove from default list
252/// - `specialchars,+quotes` → Modifiers: mixed modifier mode
253///
254/// Order matters: substitutions/modifiers are applied in sequence.
255#[must_use]
256pub(crate) fn parse_subs_attribute(value: &str) -> SubstitutionSpec {
257    let value = value.trim();
258
259    // Handle special cases
260    if value.is_empty() || value == "none" {
261        return SubstitutionSpec::Explicit(Vec::new());
262    }
263
264    // Parse all parts in one pass: O(n)
265    let parts: Vec<_> = value
266        .split(',')
267        .map(str::trim)
268        .filter(|p| !p.is_empty())
269        .map(parse_subs_part)
270        .collect();
271
272    // Determine mode: if ANY part has a modifier, use modifier mode
273    let has_modifiers = parts.iter().any(|(_, m)| m.is_some());
274
275    if has_modifiers {
276        // Modifier mode: collect operations for converter to apply
277        let mut ops = Vec::new();
278
279        for (name, modifier) in parts {
280            // Parse the substitution name; skip if invalid
281            let Some(sub) = parse_substitution(name) else {
282                continue;
283            };
284
285            match modifier {
286                Some(SubsModifier::Append) => {
287                    ops.push(SubstitutionOp::Append(sub));
288                }
289                Some(SubsModifier::Prepend) => {
290                    ops.push(SubstitutionOp::Prepend(sub));
291                }
292                Some(SubsModifier::Remove) => {
293                    ops.push(SubstitutionOp::Remove(sub));
294                }
295                None => {
296                    // Plain substitution name in modifier context - warn and treat as append
297                    tracing::warn!(
298                        substitution = %name,
299                        "plain substitution in modifier context; consider +{name} for clarity"
300                    );
301                    ops.push(SubstitutionOp::Append(sub));
302                }
303            }
304        }
305        SubstitutionSpec::Modifiers(ops)
306    } else {
307        // No modifiers - parse as an explicit list of substitution names (in order)
308        let mut result = Vec::new();
309        for (name, _) in parts {
310            if let Some(ref sub) = parse_substitution(name) {
311                append_substitution(&mut result, sub);
312            }
313        }
314        SubstitutionSpec::Explicit(result)
315    }
316}
317
318/// Expand a substitution to its constituent list.
319///
320/// Groups (`Normal`, `Verbatim`) expand to their members; individual subs return themselves.
321fn expand_substitution(sub: &Substitution) -> &[Substitution] {
322    match sub {
323        Substitution::Normal => NORMAL,
324        Substitution::Verbatim => VERBATIM,
325        Substitution::SpecialChars
326        | Substitution::Attributes
327        | Substitution::Replacements
328        | Substitution::Macros
329        | Substitution::PostReplacements
330        | Substitution::Quotes
331        | Substitution::Callouts => std::slice::from_ref(sub),
332    }
333}
334
335/// Append a substitution (or group) to the end of the list.
336pub(crate) fn append_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
337    for s in expand_substitution(sub) {
338        if !result.contains(s) {
339            result.push(s.clone());
340        }
341    }
342}
343
344/// Prepend a substitution (or group) to the beginning of the list.
345pub(crate) fn prepend_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
346    // Insert in reverse order at position 0 to maintain group order
347    for s in expand_substitution(sub).iter().rev() {
348        if !result.contains(s) {
349            result.insert(0, s.clone());
350        }
351    }
352}
353
354/// Remove a substitution (or group) from the list.
355pub(crate) fn remove_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
356    for s in expand_substitution(sub) {
357        result.retain(|x| x != s);
358    }
359}
360
361/// Apply a sequence of substitutions to text.
362///
363/// Iterates through the substitution list and applies each in order:
364///
365/// - `Attributes` - Expands `{name}` references using document attributes
366/// - `Normal` / `Verbatim` - Recursively applies the corresponding substitution group
367/// - All others (`SpecialChars`, `Quotes`, `Replacements`, `Macros`,
368///   `PostReplacements`, `Callouts`) - No-op; handled by converters
369///
370/// # Example
371///
372/// ```
373/// use acdc_parser::{DocumentAttributes, AttributeValue, Substitution, substitute};
374///
375/// let mut attrs = DocumentAttributes::default();
376/// attrs.set("version".to_string(), AttributeValue::String("1.0".to_string()));
377///
378/// let result = substitute("Version {version}", &[Substitution::Attributes], &attrs);
379/// assert_eq!(result, "Version 1.0");
380/// ```
381#[must_use]
382pub fn substitute(
383    text: &str,
384    substitutions: &[Substitution],
385    attributes: &DocumentAttributes,
386) -> String {
387    let mut result = text.to_string();
388    for substitution in substitutions {
389        match substitution {
390            Substitution::Attributes => {
391                // Expand {name} patterns with values from document attributes
392                let mut expanded = String::with_capacity(result.len());
393                let mut chars = result.chars().peekable();
394
395                while let Some(ch) = chars.next() {
396                    if ch == '{' {
397                        let mut attr_name = String::new();
398                        let mut found_closing_brace = false;
399
400                        while let Some(&next_ch) = chars.peek() {
401                            if next_ch == '}' {
402                                chars.next();
403                                found_closing_brace = true;
404                                break;
405                            }
406                            attr_name.push(next_ch);
407                            chars.next();
408                        }
409
410                        if found_closing_brace {
411                            match attributes.get(&attr_name) {
412                                Some(AttributeValue::Bool(true)) => {
413                                    // Boolean true attributes expand to empty string
414                                }
415                                Some(AttributeValue::String(attr_value)) => {
416                                    expanded.push_str(attr_value);
417                                }
418                                _ => {
419                                    // Unknown attribute - keep reference as-is
420                                    expanded.push('{');
421                                    expanded.push_str(&attr_name);
422                                    expanded.push('}');
423                                }
424                            }
425                        } else {
426                            // No closing brace - keep opening brace and collected chars
427                            expanded.push('{');
428                            expanded.push_str(&attr_name);
429                        }
430                    } else {
431                        expanded.push(ch);
432                    }
433                }
434                result = expanded;
435            }
436            // These substitutions are handled elsewhere (converter) or not yet implemented
437            Substitution::SpecialChars
438            | Substitution::Quotes
439            | Substitution::Replacements
440            | Substitution::Macros
441            | Substitution::PostReplacements
442            | Substitution::Callouts => {}
443            // Group substitutions expand recursively
444            Substitution::Normal => {
445                result = substitute(&result, NORMAL, attributes);
446            }
447            Substitution::Verbatim => {
448                result = substitute(&result, VERBATIM, attributes);
449            }
450        }
451    }
452    result
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    // Helper to extract explicit list from SubstitutionSpec
460    #[allow(clippy::panic)]
461    fn explicit(spec: &SubstitutionSpec) -> &Vec<Substitution> {
462        match spec {
463            SubstitutionSpec::Explicit(subs) => subs,
464            SubstitutionSpec::Modifiers(_) => panic!("Expected Explicit, got Modifiers"),
465        }
466    }
467
468    // Helper to extract modifiers from SubstitutionSpec
469    #[allow(clippy::panic)]
470    fn modifiers(spec: &SubstitutionSpec) -> &Vec<SubstitutionOp> {
471        match spec {
472            SubstitutionSpec::Modifiers(ops) => ops,
473            SubstitutionSpec::Explicit(_) => panic!("Expected Modifiers, got Explicit"),
474        }
475    }
476
477    #[test]
478    fn test_parse_subs_none() {
479        let result = parse_subs_attribute("none");
480        assert!(explicit(&result).is_empty());
481    }
482
483    #[test]
484    fn test_parse_subs_empty_string() {
485        let result = parse_subs_attribute("");
486        assert!(explicit(&result).is_empty());
487    }
488
489    #[test]
490    fn test_parse_subs_none_with_whitespace() {
491        let result = parse_subs_attribute("  none  ");
492        assert!(explicit(&result).is_empty());
493    }
494
495    #[test]
496    fn test_parse_subs_specialchars() {
497        let result = parse_subs_attribute("specialchars");
498        assert_eq!(explicit(&result), &vec![Substitution::SpecialChars]);
499    }
500
501    #[test]
502    fn test_parse_subs_specialchars_shorthand() {
503        let result = parse_subs_attribute("c");
504        assert_eq!(explicit(&result), &vec![Substitution::SpecialChars]);
505    }
506
507    #[test]
508    fn test_parse_subs_normal_expands() {
509        let result = parse_subs_attribute("normal");
510        assert_eq!(explicit(&result), &NORMAL.to_vec());
511    }
512
513    #[test]
514    fn test_parse_subs_verbatim_expands() {
515        let result = parse_subs_attribute("verbatim");
516        assert_eq!(explicit(&result), &VERBATIM.to_vec());
517    }
518
519    #[test]
520    fn test_parse_subs_append_modifier() {
521        let result = parse_subs_attribute("+quotes");
522        let ops = modifiers(&result);
523        assert_eq!(ops, &vec![SubstitutionOp::Append(Substitution::Quotes)]);
524
525        // Verify resolved result with VERBATIM baseline
526        let resolved = result.resolve(VERBATIM);
527        assert!(resolved.contains(&Substitution::SpecialChars));
528        assert!(resolved.contains(&Substitution::Callouts));
529        assert!(resolved.contains(&Substitution::Quotes));
530        assert_eq!(resolved.last(), Some(&Substitution::Quotes));
531    }
532
533    #[test]
534    fn test_parse_subs_prepend_modifier() {
535        let result = parse_subs_attribute("quotes+");
536        let ops = modifiers(&result);
537        assert_eq!(ops, &vec![SubstitutionOp::Prepend(Substitution::Quotes)]);
538
539        // Verify resolved result with VERBATIM baseline
540        let resolved = result.resolve(VERBATIM);
541        assert_eq!(resolved.first(), Some(&Substitution::Quotes));
542        assert!(resolved.contains(&Substitution::SpecialChars));
543        assert!(resolved.contains(&Substitution::Callouts));
544    }
545
546    #[test]
547    fn test_parse_subs_remove_modifier() {
548        let result = parse_subs_attribute("-specialchars");
549        let ops = modifiers(&result);
550        assert_eq!(
551            ops,
552            &vec![SubstitutionOp::Remove(Substitution::SpecialChars)]
553        );
554
555        // Verify resolved result with VERBATIM baseline
556        let resolved = result.resolve(VERBATIM);
557        assert!(!resolved.contains(&Substitution::SpecialChars));
558        assert!(resolved.contains(&Substitution::Callouts));
559    }
560
561    #[test]
562    fn test_parse_subs_remove_all_verbatim() {
563        let result = parse_subs_attribute("-specialchars,-callouts");
564        let ops = modifiers(&result);
565        assert_eq!(ops.len(), 2);
566
567        // Verify resolved result with VERBATIM baseline
568        let resolved = result.resolve(VERBATIM);
569        assert!(resolved.is_empty());
570    }
571
572    #[test]
573    fn test_parse_subs_combined_modifiers() {
574        let result = parse_subs_attribute("+quotes,-callouts");
575        let ops = modifiers(&result);
576        assert_eq!(ops.len(), 2);
577
578        // Verify resolved result with VERBATIM baseline
579        let resolved = result.resolve(VERBATIM);
580        assert!(resolved.contains(&Substitution::SpecialChars)); // from default
581        assert!(resolved.contains(&Substitution::Quotes)); // added
582        assert!(!resolved.contains(&Substitution::Callouts)); // removed
583    }
584
585    #[test]
586    fn test_parse_subs_ordering_preserved() {
587        let result = parse_subs_attribute("quotes,attributes,specialchars");
588        assert_eq!(
589            explicit(&result),
590            &vec![
591                Substitution::Quotes,
592                Substitution::Attributes,
593                Substitution::SpecialChars
594            ]
595        );
596    }
597
598    #[test]
599    fn test_parse_subs_shorthand_list() {
600        let result = parse_subs_attribute("q,a,c");
601        assert_eq!(
602            explicit(&result),
603            &vec![
604                Substitution::Quotes,
605                Substitution::Attributes,
606                Substitution::SpecialChars
607            ]
608        );
609    }
610
611    #[test]
612    fn test_parse_subs_with_spaces() {
613        let result = parse_subs_attribute(" quotes , attributes ");
614        assert_eq!(
615            explicit(&result),
616            &vec![Substitution::Quotes, Substitution::Attributes]
617        );
618    }
619
620    #[test]
621    fn test_parse_subs_duplicates_ignored() {
622        let result = parse_subs_attribute("quotes,quotes,quotes");
623        assert_eq!(explicit(&result), &vec![Substitution::Quotes]);
624    }
625
626    #[test]
627    fn test_parse_subs_normal_in_list_expands() {
628        let result = parse_subs_attribute("normal");
629        let subs = explicit(&result);
630        // Should expand to all NORMAL substitutions
631        assert_eq!(subs.len(), NORMAL.len());
632        for sub in NORMAL {
633            assert!(subs.contains(sub));
634        }
635    }
636
637    #[test]
638    fn test_parse_subs_append_normal_group() {
639        let result = parse_subs_attribute("+normal");
640        // This is modifier syntax, resolve with a baseline that has Callouts
641        let resolved = result.resolve(&[Substitution::Callouts]);
642        // Should have Callouts + all of NORMAL
643        assert!(resolved.contains(&Substitution::Callouts));
644        for sub in NORMAL {
645            assert!(resolved.contains(sub));
646        }
647    }
648
649    #[test]
650    fn test_parse_subs_remove_normal_group() {
651        let result = parse_subs_attribute("-normal");
652        // This is modifier syntax, resolve with NORMAL baseline
653        let resolved = result.resolve(NORMAL);
654        // Removing normal group should leave empty
655        assert!(resolved.is_empty());
656    }
657
658    #[test]
659    fn test_parse_subs_unknown_is_skipped() {
660        // Unknown substitution types are logged and skipped
661        let result = parse_subs_attribute("unknown");
662        assert!(explicit(&result).is_empty());
663    }
664
665    #[test]
666    fn test_parse_subs_unknown_mixed_with_valid() {
667        // Unknown substitution types are skipped, valid ones are kept
668        let result = parse_subs_attribute("quotes,typo,attributes");
669        assert_eq!(
670            explicit(&result),
671            &vec![Substitution::Quotes, Substitution::Attributes]
672        );
673    }
674
675    #[test]
676    fn test_parse_subs_all_individual_types() {
677        // Test each substitution type can be parsed
678        assert_eq!(
679            explicit(&parse_subs_attribute("attributes")),
680            &vec![Substitution::Attributes]
681        );
682        assert_eq!(
683            explicit(&parse_subs_attribute("replacements")),
684            &vec![Substitution::Replacements]
685        );
686        assert_eq!(
687            explicit(&parse_subs_attribute("macros")),
688            &vec![Substitution::Macros]
689        );
690        assert_eq!(
691            explicit(&parse_subs_attribute("post_replacements")),
692            &vec![Substitution::PostReplacements]
693        );
694        assert_eq!(
695            explicit(&parse_subs_attribute("quotes")),
696            &vec![Substitution::Quotes]
697        );
698        assert_eq!(
699            explicit(&parse_subs_attribute("callouts")),
700            &vec![Substitution::Callouts]
701        );
702    }
703
704    #[test]
705    fn test_parse_subs_shorthand_types() {
706        assert_eq!(
707            explicit(&parse_subs_attribute("a")),
708            &vec![Substitution::Attributes]
709        );
710        assert_eq!(
711            explicit(&parse_subs_attribute("r")),
712            &vec![Substitution::Replacements]
713        );
714        assert_eq!(
715            explicit(&parse_subs_attribute("m")),
716            &vec![Substitution::Macros]
717        );
718        assert_eq!(
719            explicit(&parse_subs_attribute("p")),
720            &vec![Substitution::PostReplacements]
721        );
722        assert_eq!(
723            explicit(&parse_subs_attribute("q")),
724            &vec![Substitution::Quotes]
725        );
726        assert_eq!(
727            explicit(&parse_subs_attribute("c")),
728            &vec![Substitution::SpecialChars]
729        );
730    }
731
732    #[test]
733    fn test_parse_subs_mixed_modifier_list() {
734        // Bug case: subs=specialchars,+quotes - modifier not at start of string
735        let result = parse_subs_attribute("specialchars,+quotes");
736        // Should be in modifier mode
737        let ops = modifiers(&result);
738        assert_eq!(ops.len(), 2); // specialchars (as append) and +quotes
739
740        // Verify resolved result with VERBATIM baseline
741        let resolved = result.resolve(VERBATIM);
742        assert!(resolved.contains(&Substitution::SpecialChars));
743        assert!(resolved.contains(&Substitution::Callouts)); // from VERBATIM default
744        assert!(resolved.contains(&Substitution::Quotes)); // appended
745    }
746
747    #[test]
748    fn test_parse_subs_modifier_in_middle() {
749        // subs=attributes,+quotes,-callouts
750        let result = parse_subs_attribute("attributes,+quotes,-callouts");
751        let ops = modifiers(&result);
752        assert_eq!(ops.len(), 3);
753
754        // Verify resolved result with VERBATIM baseline
755        let resolved = result.resolve(VERBATIM);
756        assert!(resolved.contains(&Substitution::Attributes)); // plain name in modifier context
757        assert!(resolved.contains(&Substitution::Quotes)); // appended
758        assert!(!resolved.contains(&Substitution::Callouts)); // removed
759    }
760
761    #[test]
762    fn test_parse_subs_asciidoctor_example() {
763        // From asciidoctor docs: subs="attributes+,+replacements,-callouts"
764        let result = parse_subs_attribute("attributes+,+replacements,-callouts");
765        let ops = modifiers(&result);
766        assert_eq!(ops.len(), 3);
767
768        // Verify resolved result with VERBATIM baseline
769        let resolved = result.resolve(VERBATIM);
770        assert_eq!(resolved.first(), Some(&Substitution::Attributes)); // prepended
771        assert!(resolved.contains(&Substitution::Replacements)); // appended
772        assert!(!resolved.contains(&Substitution::Callouts)); // removed
773    }
774
775    #[test]
776    fn test_parse_subs_modifier_only_at_end() {
777        // Modifier at end of comma-separated list
778        let result = parse_subs_attribute("quotes,-specialchars");
779        // Should detect modifier mode from -specialchars
780        let ops = modifiers(&result);
781        assert_eq!(ops.len(), 2);
782
783        // Verify resolved result with VERBATIM baseline
784        let resolved = result.resolve(VERBATIM);
785        assert!(resolved.contains(&Substitution::Quotes)); // plain name appended
786        assert!(!resolved.contains(&Substitution::SpecialChars)); // removed
787        assert!(resolved.contains(&Substitution::Callouts)); // from default
788    }
789
790    #[test]
791    fn test_resolve_modifiers_with_normal_baseline() {
792        // This is the key test for the bug fix:
793        // -quotes on a paragraph should remove quotes from NORMAL baseline
794        let result = parse_subs_attribute("-quotes");
795        let resolved = result.resolve(NORMAL);
796
797        // Should have all of NORMAL except Quotes
798        assert!(resolved.contains(&Substitution::SpecialChars));
799        assert!(resolved.contains(&Substitution::Attributes));
800        assert!(!resolved.contains(&Substitution::Quotes)); // removed
801        assert!(resolved.contains(&Substitution::Replacements));
802        assert!(resolved.contains(&Substitution::Macros));
803        assert!(resolved.contains(&Substitution::PostReplacements));
804    }
805
806    #[test]
807    fn test_resolve_modifiers_with_verbatim_baseline() {
808        // -quotes on a listing block: Quotes wasn't in VERBATIM, so no effect
809        let result = parse_subs_attribute("-quotes");
810        let resolved = result.resolve(VERBATIM);
811
812        // Should still have all of VERBATIM (quotes wasn't there to remove)
813        assert!(resolved.contains(&Substitution::SpecialChars));
814        assert!(resolved.contains(&Substitution::Callouts));
815        assert!(!resolved.contains(&Substitution::Quotes));
816    }
817
818    #[test]
819    fn test_resolve_explicit_ignores_baseline() {
820        // Explicit lists should ignore the baseline
821        let result = parse_subs_attribute("quotes,attributes");
822        let resolved_normal = result.resolve(NORMAL);
823        let resolved_verbatim = result.resolve(VERBATIM);
824
825        // Both should be the same
826        assert_eq!(resolved_normal, resolved_verbatim);
827        assert_eq!(
828            resolved_normal,
829            vec![Substitution::Quotes, Substitution::Attributes]
830        );
831    }
832
833    #[test]
834    fn test_resolve_attribute_references() {
835        // These two are attributes we add to the attributes map.
836        let attribute_weight = AttributeValue::String(String::from("weight"));
837        let attribute_mass = AttributeValue::String(String::from("mass"));
838
839        // This one is an attribute we do NOT add to the attributes map so it can never be
840        // resolved.
841        let attribute_volume_repeat = String::from("value {attribute_volume}");
842
843        let mut attributes = DocumentAttributes::default();
844        attributes.insert("weight".into(), attribute_weight.clone());
845        attributes.insert("mass".into(), attribute_mass.clone());
846
847        // Resolve an attribute that is in the attributes map.
848        let resolved = substitute("{weight}", HEADER, &attributes);
849        assert_eq!(resolved, "weight".to_string());
850
851        // Resolve two attributes that are in the attributes map.
852        let resolved = substitute("{weight} {mass}", HEADER, &attributes);
853        assert_eq!(resolved, "weight mass".to_string());
854
855        // Resolve without attributes in the map
856        let resolved = substitute("value {attribute_volume}", HEADER, &attributes);
857        assert_eq!(resolved, attribute_volume_repeat);
858    }
859
860    #[test]
861    fn test_substitute_single_pass_expansion() {
862        // Test that the substitute() function does single-pass expansion.
863        // When foo's value is "{bar}", substitute("{foo}") returns the literal
864        // "{bar}" string - it does NOT recursively resolve {bar}.
865        //
866        // This is correct behavior because:
867        // 1. Definition-time resolution is handled separately (in the grammar parser)
868        // 2. The substitute function just replaces one level of references
869        let mut attributes = DocumentAttributes::default();
870        attributes.insert("foo".into(), AttributeValue::String("{bar}".to_string()));
871        attributes.insert(
872            "bar".into(),
873            AttributeValue::String("should-not-appear".to_string()),
874        );
875
876        let resolved = substitute("{foo}", HEADER, &attributes);
877        assert_eq!(resolved, "{bar}");
878    }
879
880    #[test]
881    fn test_utf8_boundary_handling() {
882        // Regression test for fuzzer-found bug: UTF-8 multi-byte characters
883        // should not cause panics during attribute substitution
884        let attributes = DocumentAttributes::default();
885
886        let values = [
887            // Input with UTF-8 multi-byte character (Ô = 0xc3 0x94)
888            ":J::~\x01\x00\x00Ô",
889            // Test with various UTF-8 characters and attribute-like patterns
890            "{attr}Ô{missing}日本語",
891            // Test with multi-byte chars inside attribute name
892            "{attrÔ}test",
893        ];
894        for value in values {
895            let resolved = substitute(value, HEADER, &attributes);
896            assert_eq!(resolved, value);
897        }
898    }
899}