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