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