1use serde::Serialize;
47
48use crate::{AttributeValue, DocumentAttributes};
49
50#[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
83pub(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
107pub const HEADER: &[Substitution] = &[Substitution::SpecialChars, Substitution::Attributes];
109
110pub const NORMAL: &[Substitution] = &[
112 Substitution::SpecialChars,
113 Substitution::Attributes,
114 Substitution::Quotes,
115 Substitution::Replacements,
116 Substitution::Macros,
117 Substitution::PostReplacements,
118];
119
120pub const VERBATIM: &[Substitution] = &[Substitution::SpecialChars, Substitution::Callouts];
122
123#[derive(Clone, Debug, Hash, Eq, PartialEq)]
127pub enum SubstitutionOp {
128 Append(Substitution),
130 Prepend(Substitution),
132 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#[derive(Clone, Debug, Hash, Eq, PartialEq)]
164pub enum SubstitutionSpec {
165 Explicit(Vec<Substitution>),
167 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 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216enum SubsModifier {
217 Append,
219 Prepend,
221 Remove,
223}
224
225fn 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#[must_use]
256pub(crate) fn parse_subs_attribute(value: &str) -> SubstitutionSpec {
257 let value = value.trim();
258
259 if value.is_empty() || value == "none" {
261 return SubstitutionSpec::Explicit(Vec::new());
262 }
263
264 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 let has_modifiers = parts.iter().any(|(_, m)| m.is_some());
274
275 if has_modifiers {
276 let mut ops = Vec::new();
278
279 for (name, modifier) in parts {
280 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 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 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
318fn 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
335pub(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
344pub(crate) fn prepend_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
346 for s in expand_substitution(sub).iter().rev() {
348 if !result.contains(s) {
349 result.insert(0, s.clone());
350 }
351 }
352}
353
354pub(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#[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 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 }
415 Some(AttributeValue::String(attr_value)) => {
416 expanded.push_str(attr_value);
417 }
418 _ => {
419 expanded.push('{');
421 expanded.push_str(&attr_name);
422 expanded.push('}');
423 }
424 }
425 } else {
426 expanded.push('{');
428 expanded.push_str(&attr_name);
429 }
430 } else {
431 expanded.push(ch);
432 }
433 }
434 result = expanded;
435 }
436 Substitution::SpecialChars
438 | Substitution::Quotes
439 | Substitution::Replacements
440 | Substitution::Macros
441 | Substitution::PostReplacements
442 | Substitution::Callouts => {}
443 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 #[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 #[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 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 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 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 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 let resolved = result.resolve(VERBATIM);
580 assert!(resolved.contains(&Substitution::SpecialChars)); assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::Callouts)); }
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 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 let resolved = result.resolve(&[Substitution::Callouts]);
642 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 let resolved = result.resolve(NORMAL);
654 assert!(resolved.is_empty());
656 }
657
658 #[test]
659 fn test_parse_subs_unknown_is_skipped() {
660 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 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 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 let result = parse_subs_attribute("specialchars,+quotes");
736 let ops = modifiers(&result);
738 assert_eq!(ops.len(), 2); let resolved = result.resolve(VERBATIM);
742 assert!(resolved.contains(&Substitution::SpecialChars));
743 assert!(resolved.contains(&Substitution::Callouts)); assert!(resolved.contains(&Substitution::Quotes)); }
746
747 #[test]
748 fn test_parse_subs_modifier_in_middle() {
749 let result = parse_subs_attribute("attributes,+quotes,-callouts");
751 let ops = modifiers(&result);
752 assert_eq!(ops.len(), 3);
753
754 let resolved = result.resolve(VERBATIM);
756 assert!(resolved.contains(&Substitution::Attributes)); assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::Callouts)); }
760
761 #[test]
762 fn test_parse_subs_asciidoctor_example() {
763 let result = parse_subs_attribute("attributes+,+replacements,-callouts");
765 let ops = modifiers(&result);
766 assert_eq!(ops.len(), 3);
767
768 let resolved = result.resolve(VERBATIM);
770 assert_eq!(resolved.first(), Some(&Substitution::Attributes)); assert!(resolved.contains(&Substitution::Replacements)); assert!(!resolved.contains(&Substitution::Callouts)); }
774
775 #[test]
776 fn test_parse_subs_modifier_only_at_end() {
777 let result = parse_subs_attribute("quotes,-specialchars");
779 let ops = modifiers(&result);
781 assert_eq!(ops.len(), 2);
782
783 let resolved = result.resolve(VERBATIM);
785 assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::SpecialChars)); assert!(resolved.contains(&Substitution::Callouts)); }
789
790 #[test]
791 fn test_resolve_modifiers_with_normal_baseline() {
792 let result = parse_subs_attribute("-quotes");
795 let resolved = result.resolve(NORMAL);
796
797 assert!(resolved.contains(&Substitution::SpecialChars));
799 assert!(resolved.contains(&Substitution::Attributes));
800 assert!(!resolved.contains(&Substitution::Quotes)); 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 let result = parse_subs_attribute("-quotes");
810 let resolved = result.resolve(VERBATIM);
811
812 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 let result = parse_subs_attribute("quotes,attributes");
822 let resolved_normal = result.resolve(NORMAL);
823 let resolved_verbatim = result.resolve(VERBATIM);
824
825 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 let attribute_weight = AttributeValue::String(String::from("weight"));
837 let attribute_mass = AttributeValue::String(String::from("mass"));
838
839 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 let resolved = substitute("{weight}", HEADER, &attributes);
849 assert_eq!(resolved, "weight".to_string());
850
851 let resolved = substitute("{weight} {mass}", HEADER, &attributes);
853 assert_eq!(resolved, "weight mass".to_string());
854
855 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 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 let attributes = DocumentAttributes::default();
885
886 let values = [
887 ":J::~\x01\x00\x00Ô",
889 "{attr}Ô{missing}日本語",
891 "{attrÔ}test",
893 ];
894 for value in values {
895 let resolved = substitute(value, HEADER, &attributes);
896 assert_eq!(resolved, value);
897 }
898 }
899}