1use std::borrow::Cow;
26
27use serde::Serialize;
28
29use crate::DocumentAttributes;
30
31const SUBSTITUTION_STAGE_COUNT: usize = 7;
32const DISABLED_SUBSTITUTION: u8 = u8::MAX;
33const DEFAULT_PARSER_SUBSTITUTIONS: &[Substitution] = &[
34 Substitution::SpecialChars,
35 Substitution::Quotes,
36 Substitution::Attributes,
37 Substitution::Replacements,
38 Substitution::Macros,
39 Substitution::PostReplacements,
40 Substitution::Callouts,
41];
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) struct SubstitutionPlan {
49 ranks: [u8; SUBSTITUTION_STAGE_COUNT],
50}
51
52impl SubstitutionPlan {
53 pub(crate) fn from_substitutions(substitutions: &[Substitution]) -> Self {
54 let mut ranks = [DISABLED_SUBSTITUTION; SUBSTITUTION_STAGE_COUNT];
55 for (rank, substitution) in substitutions.iter().enumerate() {
56 if let Some(slot) =
57 substitution_stage_index(substitution).and_then(|index| ranks.get_mut(index))
58 {
59 *slot = u8::try_from(rank).unwrap_or(u8::MAX - 1);
60 }
61 }
62 Self { ranks }
63 }
64
65 pub(crate) fn only(substitution: &Substitution) -> Self {
66 Self::from_substitutions(std::slice::from_ref(substitution))
67 }
68
69 #[cfg(feature = "pre-spec-subs")]
70 pub(crate) fn for_block_spec(spec: &SubstitutionSpec) -> Self {
71 Self::from_substitutions(&spec.resolve(DEFAULT_PARSER_SUBSTITUTIONS))
74 }
75
76 pub(crate) fn enabled(self, substitution: &Substitution) -> bool {
77 substitution_stage_index(substitution)
78 .and_then(|index| self.ranks.get(index))
79 .is_some_and(|rank| *rank != DISABLED_SUBSTITUTION)
80 }
81
82 pub(crate) fn precedes(self, first: &Substitution, second: &Substitution) -> bool {
83 let (Some(first), Some(second)) = (
84 substitution_stage_index(first),
85 substitution_stage_index(second),
86 ) else {
87 return false;
88 };
89 let (Some(first), Some(second)) = (self.ranks.get(first), self.ranks.get(second)) else {
90 return false;
91 };
92 *first != DISABLED_SUBSTITUTION && *second != DISABLED_SUBSTITUTION && first < second
93 }
94
95 pub(crate) fn through(mut self, substitution: &Substitution) -> Self {
96 if let Some(last) = substitution_stage_index(substitution)
97 .and_then(|index| self.ranks.get(index))
98 .copied()
99 {
100 for rank in &mut self.ranks {
101 if *rank > last {
102 *rank = DISABLED_SUBSTITUTION;
103 }
104 }
105 }
106 self
107 }
108
109 pub(crate) fn after(self, substitution: &Substitution) -> Vec<Substitution> {
110 let mut stages: Vec<_> = DEFAULT_PARSER_SUBSTITUTIONS
111 .iter()
112 .filter(|stage| self.precedes(substitution, stage))
113 .cloned()
114 .collect();
115 stages.sort_by_key(|stage| {
116 substitution_stage_index(stage)
117 .and_then(|index| self.ranks.get(index))
118 .copied()
119 });
120 stages
121 }
122}
123
124impl Default for SubstitutionPlan {
125 fn default() -> Self {
126 Self::from_substitutions(DEFAULT_PARSER_SUBSTITUTIONS)
127 }
128}
129
130const fn substitution_stage_index(substitution: &Substitution) -> Option<usize> {
131 match substitution {
132 Substitution::SpecialChars => Some(0),
133 Substitution::Attributes => Some(1),
134 Substitution::Replacements => Some(2),
135 Substitution::Macros => Some(3),
136 Substitution::PostReplacements => Some(4),
137 Substitution::Quotes => Some(5),
138 Substitution::Callouts => Some(6),
139 Substitution::Normal | Substitution::Verbatim => None,
140 }
141}
142
143#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize)]
145#[serde(rename_all = "snake_case")]
146#[non_exhaustive]
147pub enum Substitution {
148 SpecialChars,
149 Attributes,
150 Replacements,
151 Macros,
152 PostReplacements,
153 Normal,
154 Verbatim,
155 Quotes,
156 Callouts,
157}
158
159impl std::fmt::Display for Substitution {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 let name = match self {
162 Self::SpecialChars => "special_chars",
163 Self::Attributes => "attributes",
164 Self::Replacements => "replacements",
165 Self::Macros => "macros",
166 Self::PostReplacements => "post_replacements",
167 Self::Normal => "normal",
168 Self::Verbatim => "verbatim",
169 Self::Quotes => "quotes",
170 Self::Callouts => "callouts",
171 };
172 write!(f, "{name}")
173 }
174}
175
176pub(crate) fn parse_substitution(value: &str) -> Option<Substitution> {
180 let substitution = substitution_named(value);
181 if substitution.is_none() {
182 tracing::error!("unknown substitution type, ignoring - check for typos");
183 }
184 substitution
185}
186
187fn substitution_named(value: &str) -> Option<Substitution> {
188 match value {
189 "attributes" | "a" => Some(Substitution::Attributes),
190 "replacements" | "r" => Some(Substitution::Replacements),
191 "macros" | "m" => Some(Substitution::Macros),
192 "post_replacements" | "p" => Some(Substitution::PostReplacements),
193 "normal" | "n" => Some(Substitution::Normal),
194 "verbatim" | "v" => Some(Substitution::Verbatim),
195 "quotes" | "q" => Some(Substitution::Quotes),
196 "callouts" => Some(Substitution::Callouts),
197 "specialchars" | "specialcharacters" | "c" => Some(Substitution::SpecialChars),
198 _ => None,
199 }
200}
201
202pub const HEADER: &[Substitution] = &[Substitution::SpecialChars, Substitution::Attributes];
204
205pub const NORMAL: &[Substitution] = &[
207 Substitution::SpecialChars,
208 Substitution::Quotes,
209 Substitution::Attributes,
210 Substitution::Replacements,
211 Substitution::Macros,
212 Substitution::PostReplacements,
213];
214
215pub const VERBATIM: &[Substitution] = &[Substitution::SpecialChars, Substitution::Callouts];
217
218const PASSTHROUGH_VERBATIM: &[Substitution] = &[Substitution::SpecialChars];
220
221#[derive(Clone, Copy)]
222enum GroupContext {
223 #[cfg(feature = "pre-spec-subs")]
224 Block,
225 Passthrough,
226}
227
228fn substitution_members(substitution: &Substitution, context: GroupContext) -> &[Substitution] {
229 match (substitution, context) {
230 (Substitution::Normal, _) => NORMAL,
231 #[cfg(feature = "pre-spec-subs")]
232 (Substitution::Verbatim, GroupContext::Block) => VERBATIM,
233 (Substitution::Verbatim, GroupContext::Passthrough) => PASSTHROUGH_VERBATIM,
234 (
235 Substitution::SpecialChars
236 | Substitution::Attributes
237 | Substitution::Replacements
238 | Substitution::Macros
239 | Substitution::PostReplacements
240 | Substitution::Quotes
241 | Substitution::Callouts,
242 _,
243 ) => std::slice::from_ref(substitution),
244 }
245}
246
247fn append_expanded_substitution(
248 result: &mut Vec<Substitution>,
249 substitution: &Substitution,
250 context: GroupContext,
251) {
252 for member in substitution_members(substitution, context) {
253 if !result.contains(member) {
254 result.push(member.clone());
255 }
256 }
257}
258
259pub(crate) fn resolve_passthrough_substitutions(
261 substitutions: &[Substitution],
262) -> Vec<Substitution> {
263 let mut resolved = Vec::with_capacity(substitutions.len());
264 for substitution in substitutions {
265 append_expanded_substitution(&mut resolved, substitution, GroupContext::Passthrough);
266 }
267 resolved
268}
269
270#[cfg(feature = "pre-spec-subs")]
278#[derive(Clone, Debug, Hash, Eq, PartialEq)]
279pub enum SubstitutionOp {
280 Append(Substitution),
282 Prepend(Substitution),
284 Remove(Substitution),
286}
287
288#[cfg(feature = "pre-spec-subs")]
289impl std::fmt::Display for SubstitutionOp {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 match self {
292 Self::Append(sub) => write!(f, "+{sub}"),
293 Self::Prepend(sub) => write!(f, "{sub}+"),
294 Self::Remove(sub) => write!(f, "-{sub}"),
295 }
296 }
297}
298
299#[cfg(feature = "pre-spec-subs")]
323#[derive(Clone, Debug, Hash, Eq, PartialEq)]
324pub enum SubstitutionSpec {
325 Source(Vec<String>),
327 Explicit(Vec<Substitution>),
329 Modifiers(Vec<SubstitutionOp>),
331}
332
333#[cfg(feature = "pre-spec-subs")]
334impl Serialize for SubstitutionSpec {
335 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
336 where
337 S: serde::Serializer,
338 {
339 let strings: Vec<String> = match self {
340 Self::Source(entries) => return entries.serialize(serializer),
341 Self::Explicit(subs) => subs.iter().map(ToString::to_string).collect(),
342 Self::Modifiers(ops) => ops.iter().map(ToString::to_string).collect(),
343 };
344 strings.serialize(serializer)
345 }
346}
347
348#[cfg(feature = "pre-spec-subs")]
349impl SubstitutionSpec {
350 pub(crate) fn contains(&self, substitution: &Substitution, defaults: &[Substitution]) -> bool {
351 match self {
352 Self::Source(entries) => entries
353 .iter()
354 .rev()
355 .find_map(|entry| {
356 let (name, modifier) = parse_subs_part(entry.trim());
357 let affected = substitution_named(name)?;
358 substitution_members(&affected, GroupContext::Block)
359 .contains(substitution)
360 .then_some(!matches!(modifier, Some(SubsModifier::Remove)))
361 })
362 .unwrap_or_else(|| {
363 entries
364 .first()
365 .is_some_and(|entry| parse_subs_part(entry.trim()).1.is_some())
366 && defaults.contains(substitution)
367 }),
368 Self::Explicit(substitutions) => substitutions.contains(substitution),
369 Self::Modifiers(operations) => operations
371 .iter()
372 .rev()
373 .find_map(|operation| {
374 let (affected, enabled) = match operation {
375 SubstitutionOp::Append(affected) | SubstitutionOp::Prepend(affected) => {
376 (affected, true)
377 }
378 SubstitutionOp::Remove(affected) => (affected, false),
379 };
380 substitution_members(affected, GroupContext::Block)
381 .contains(substitution)
382 .then_some(enabled)
383 })
384 .unwrap_or_else(|| defaults.contains(substitution)),
385 }
386 }
387
388 #[must_use]
392 pub fn apply_modifiers(ops: &[SubstitutionOp], default: &[Substitution]) -> Vec<Substitution> {
393 let mut result = default.to_vec();
394 for op in ops {
395 match op {
396 SubstitutionOp::Append(sub) => append_substitution(&mut result, sub),
397 SubstitutionOp::Prepend(sub) => prepend_substitution(&mut result, sub),
398 SubstitutionOp::Remove(sub) => remove_substitution(&mut result, sub),
399 }
400 }
401 result
402 }
403
404 #[must_use]
410 pub fn resolve(&self, default: &[Substitution]) -> Vec<Substitution> {
411 match self {
412 SubstitutionSpec::Source(entries) => resolve_source_substitutions(entries, default),
413 SubstitutionSpec::Explicit(subs) => subs.clone(),
414 SubstitutionSpec::Modifiers(ops) => Self::apply_modifiers(ops, default),
415 }
416 }
417}
418
419#[cfg(feature = "pre-spec-subs")]
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422enum SubsModifier {
423 Append,
425 Prepend,
427 Remove,
429}
430
431#[cfg(feature = "pre-spec-subs")]
433fn parse_subs_part(part: &str) -> (&str, Option<SubsModifier>) {
434 if let Some(name) = part.strip_prefix('+') {
435 (name, Some(SubsModifier::Append))
436 } else if let Some(name) = part.strip_suffix('+') {
437 (name, Some(SubsModifier::Prepend))
438 } else if let Some(name) = part.strip_prefix('-') {
439 (name, Some(SubsModifier::Remove))
440 } else {
441 (part, None)
442 }
443}
444
445#[cfg(feature = "pre-spec-subs")]
447#[must_use]
448pub(crate) fn parse_subs_attribute(value: &str) -> SubstitutionSpec {
449 let value = value.trim();
450 if value.is_empty() || value == "none" {
451 return SubstitutionSpec::Explicit(Vec::new());
452 }
453 let entries = value
454 .split(',')
455 .map(|entry| entry.trim().to_owned())
456 .collect::<Vec<_>>();
457 for entry in &entries {
458 let (name, _) = parse_subs_part(entry);
459 if !name.is_empty() && name != "none" {
460 let _ = parse_substitution(name);
461 }
462 }
463 SubstitutionSpec::Source(entries)
464}
465
466#[cfg(feature = "pre-spec-subs")]
467fn resolve_source_substitutions(entries: &[String], default: &[Substitution]) -> Vec<Substitution> {
468 let mut parts = entries
469 .iter()
470 .map(|entry| parse_subs_part(entry.trim()))
471 .peekable();
472
473 let uses_defaults = parts.peek().is_some_and(|(_, modifier)| modifier.is_some());
475 let mut result = if uses_defaults {
476 default.to_vec()
477 } else {
478 Vec::new()
479 };
480 for (name, modifier) in parts {
481 let Some(substitution) = substitution_named(name) else {
483 continue;
484 };
485 match modifier {
486 Some(SubsModifier::Prepend) => prepend_substitution(&mut result, &substitution),
487 Some(SubsModifier::Remove) => remove_substitution(&mut result, &substitution),
488 Some(SubsModifier::Append) | None => append_substitution(&mut result, &substitution),
489 }
490 }
491 result
492}
493
494#[cfg(feature = "pre-spec-subs")]
496pub(crate) fn append_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
497 append_expanded_substitution(result, sub, GroupContext::Block);
498}
499
500#[cfg(feature = "pre-spec-subs")]
502pub(crate) fn prepend_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
503 remove_substitution(result, sub);
505 let members = substitution_members(sub, GroupContext::Block);
506 result.splice(0..0, members.iter().cloned());
507}
508
509#[cfg(feature = "pre-spec-subs")]
511pub(crate) fn remove_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
512 for s in substitution_members(sub, GroupContext::Block) {
513 result.retain(|x| x != s);
514 }
515}
516
517#[must_use]
541pub fn substitute<'a, 'b>(
542 text: &'b str,
543 substitutions: &[Substitution],
544 attributes: &DocumentAttributes<'a>,
545) -> Cow<'b, str>
546where
547 'a: 'b,
548{
549 let mut result = Cow::Borrowed(text);
550 for substitution in substitutions {
551 match substitution {
552 Substitution::Attributes => {
553 if let Cow::Owned(expanded) =
554 substitute_attributes(&result, |name| attributes.get(name))
555 {
556 result = Cow::Owned(expanded);
557 }
558 }
559 Substitution::SpecialChars
564 | Substitution::Quotes
565 | Substitution::Replacements
566 | Substitution::Macros
567 | Substitution::PostReplacements
568 | Substitution::Callouts => {}
569 Substitution::Normal => {
571 let current = std::mem::take(&mut result);
572 result = match current {
573 Cow::Borrowed(s) => substitute(s, NORMAL, attributes),
574 Cow::Owned(s) => Cow::Owned(substitute(&s, NORMAL, attributes).into_owned()),
575 };
576 }
577 Substitution::Verbatim => {
578 let current = std::mem::take(&mut result);
579 result = match current {
580 Cow::Borrowed(s) => substitute(s, VERBATIM, attributes),
581 Cow::Owned(s) => Cow::Owned(substitute(&s, VERBATIM, attributes).into_owned()),
582 };
583 }
584 }
585 }
586 result
587}
588
589#[cfg(test)]
590mod group_tests {
591 use super::*;
592
593 #[test]
594 fn normal_group_uses_reference_order() {
595 assert_eq!(
596 NORMAL,
597 &[
598 Substitution::SpecialChars,
599 Substitution::Quotes,
600 Substitution::Attributes,
601 Substitution::Replacements,
602 Substitution::Macros,
603 Substitution::PostReplacements,
604 ]
605 );
606 }
607
608 #[test]
609 fn passthrough_groups_use_inline_policies() {
610 assert_eq!(
611 resolve_passthrough_substitutions(&[Substitution::Normal]),
612 NORMAL
613 );
614 assert_eq!(
615 resolve_passthrough_substitutions(&[Substitution::Verbatim]),
616 [Substitution::SpecialChars]
617 );
618 }
619
620 #[test]
621 fn passthrough_group_expansion_preserves_first_occurrence() {
622 assert_eq!(
623 resolve_passthrough_substitutions(&[
624 Substitution::Attributes,
625 Substitution::Normal,
626 Substitution::Quotes,
627 ]),
628 [
629 Substitution::Attributes,
630 Substitution::SpecialChars,
631 Substitution::Quotes,
632 Substitution::Replacements,
633 Substitution::Macros,
634 Substitution::PostReplacements,
635 ]
636 );
637 }
638}
639
640#[cfg(all(test, feature = "pre-spec-subs"))]
644mod tests {
645 use super::*;
646 use crate::AttributeValue;
647
648 proptest::proptest! {
649 #[test]
650 fn membership_matches_resolved_modifier_order(
651 operations in proptest::collection::vec((0_usize..9, 0_u8..3), 0..20),
652 ) {
653 let substitutions = [
654 Substitution::SpecialChars, Substitution::Attributes,
655 Substitution::Replacements, Substitution::Macros,
656 Substitution::PostReplacements, Substitution::Quotes,
657 Substitution::Callouts, Substitution::Normal, Substitution::Verbatim,
658 ];
659 let operations = operations.into_iter().filter_map(|(index, operation)| {
660 substitutions.get(index).map(|substitution| match operation {
661 0 => SubstitutionOp::Append(substitution.clone()),
662 1 => SubstitutionOp::Prepend(substitution.clone()),
663 _ => SubstitutionOp::Remove(substitution.clone()),
664 })
665 }).collect();
666 let spec = SubstitutionSpec::Modifiers(operations);
667 for defaults in [NORMAL, VERBATIM, &[]] {
668 let resolved = spec.resolve(defaults);
669 for substitution in &substitutions {
670 proptest::prop_assert_eq!(
671 spec.contains(substitution, defaults),
672 resolved.contains(substitution),
673 );
674 }
675 }
676 }
677
678 #[test]
679 fn membership_matches_resolved_source_order(
680 operations in proptest::collection::vec((0_usize..15, 0_u8..4), 0..20),
681 ) {
682 let names = [
683 "specialchars", "attributes", "replacements", "macros",
684 "post_replacements", "quotes", "callouts", "normal", "verbatim",
685 "a", "q", "c", "none", "unknown", "",
686 ];
687 let entries = operations.into_iter().filter_map(|(index, operation)| {
688 names.get(index).map(|name| match operation {
689 0 => format!(" {name} "),
690 1 => format!(" +{name} "),
691 2 => format!(" {name}+ "),
692 _ => format!(" -{name} "),
693 })
694 }).collect();
695 let spec = SubstitutionSpec::Source(entries);
696 for defaults in [NORMAL, VERBATIM, &[]] {
697 let resolved = spec.resolve(defaults);
698 for substitution in [
699 Substitution::SpecialChars, Substitution::Attributes,
700 Substitution::Replacements, Substitution::Macros,
701 Substitution::PostReplacements, Substitution::Quotes,
702 Substitution::Callouts, Substitution::Normal, Substitution::Verbatim,
703 ] {
704 proptest::prop_assert_eq!(
705 spec.contains(&substitution, defaults),
706 resolved.contains(&substitution),
707 );
708 }
709 }
710 }
711 }
712
713 fn explicit(spec: &SubstitutionSpec) -> Vec<Substitution> {
714 spec.resolve(&[])
715 }
716
717 #[allow(clippy::panic)]
718 fn source_entries(spec: &SubstitutionSpec) -> &[String] {
719 match spec {
720 SubstitutionSpec::Source(entries) => entries,
721 SubstitutionSpec::Explicit(_) | SubstitutionSpec::Modifiers(_) => {
722 panic!("expected source entries")
723 }
724 }
725 }
726
727 #[test]
728 fn test_parse_subs_none() {
729 let result = parse_subs_attribute("none");
730 assert_eq!(result, SubstitutionSpec::Explicit(Vec::new()));
731 }
732
733 #[test]
734 fn test_parse_subs_empty_string() {
735 let result = parse_subs_attribute("");
736 assert_eq!(result, SubstitutionSpec::Explicit(Vec::new()));
737 }
738
739 #[test]
740 fn test_parse_subs_none_with_whitespace() {
741 let result = parse_subs_attribute(" none ");
742 assert_eq!(result, SubstitutionSpec::Explicit(Vec::new()));
743 }
744
745 #[test]
746 fn test_parse_subs_specialchars() {
747 let result = parse_subs_attribute("specialchars");
748 assert_eq!(explicit(&result), vec![Substitution::SpecialChars]);
749 }
750
751 #[test]
752 fn test_parse_subs_specialchars_shorthand() {
753 let result = parse_subs_attribute("c");
754 assert_eq!(explicit(&result), vec![Substitution::SpecialChars]);
755 }
756
757 #[test]
758 fn test_parse_subs_specialcharacters_alias() {
759 let result = parse_subs_attribute("specialcharacters");
760 assert_eq!(explicit(&result), vec![Substitution::SpecialChars]);
761 }
762
763 #[test]
764 fn test_parse_subs_normal_expands() {
765 let result = parse_subs_attribute("normal");
766 assert_eq!(explicit(&result), NORMAL.to_vec());
767 }
768
769 #[test]
770 fn test_parse_subs_verbatim_expands() {
771 let result = parse_subs_attribute("verbatim");
772 assert_eq!(explicit(&result), VERBATIM.to_vec());
773 }
774
775 #[test]
776 fn test_parse_subs_append_modifier() {
777 let result = parse_subs_attribute("+quotes");
778 assert_eq!(source_entries(&result), ["+quotes"]);
779
780 let resolved = result.resolve(VERBATIM);
782 assert!(resolved.contains(&Substitution::SpecialChars));
783 assert!(resolved.contains(&Substitution::Callouts));
784 assert!(resolved.contains(&Substitution::Quotes));
785 assert_eq!(resolved.last(), Some(&Substitution::Quotes));
786 }
787
788 #[test]
789 fn test_parse_subs_prepend_modifier() {
790 let result = parse_subs_attribute("quotes+");
791 assert_eq!(source_entries(&result), ["quotes+"]);
792
793 let resolved = result.resolve(VERBATIM);
795 assert_eq!(resolved.first(), Some(&Substitution::Quotes));
796 assert!(resolved.contains(&Substitution::SpecialChars));
797 assert!(resolved.contains(&Substitution::Callouts));
798 }
799
800 #[test]
801 fn prepend_modifiers_move_existing_stages_and_preserve_group_order() {
802 for (spec, baseline, expected) in [
803 (
804 "attributes+",
805 NORMAL,
806 "attributes,special_chars,quotes,replacements,macros,post_replacements",
807 ),
808 (
809 "+attributes",
810 NORMAL,
811 "special_chars,quotes,attributes,replacements,macros,post_replacements",
812 ),
813 (
814 "attributes+,quotes+",
815 NORMAL,
816 "quotes,attributes,special_chars,replacements,macros,post_replacements",
817 ),
818 (
819 "quotes+,attributes+",
820 NORMAL,
821 "attributes,quotes,special_chars,replacements,macros,post_replacements",
822 ),
823 (
824 "attributes+,attributes+",
825 NORMAL,
826 "attributes,special_chars,quotes,replacements,macros,post_replacements",
827 ),
828 (
829 "attributes+,normal+",
830 NORMAL,
831 "special_chars,quotes,attributes,replacements,macros,post_replacements",
832 ),
833 (
834 "normal+,attributes+",
835 NORMAL,
836 "attributes,special_chars,quotes,replacements,macros,post_replacements",
837 ),
838 (
839 "verbatim+",
840 NORMAL,
841 "special_chars,callouts,quotes,attributes,replacements,macros,post_replacements",
842 ),
843 (
844 "normal+",
845 VERBATIM,
846 "special_chars,quotes,attributes,replacements,macros,post_replacements,callouts",
847 ),
848 (
849 "+normal",
850 VERBATIM,
851 "special_chars,callouts,quotes,attributes,replacements,macros,post_replacements",
852 ),
853 (
854 "normal+,verbatim+,normal+",
855 VERBATIM,
856 "special_chars,quotes,attributes,replacements,macros,post_replacements,callouts",
857 ),
858 ("-normal,attributes+", NORMAL, "attributes"),
859 (
860 "attributes+,-attributes",
861 NORMAL,
862 "special_chars,quotes,replacements,macros,post_replacements",
863 ),
864 (
865 "-attributes,+attributes",
866 NORMAL,
867 "special_chars,quotes,replacements,macros,post_replacements,attributes",
868 ),
869 (
870 "normal+,-quotes,quotes+",
871 VERBATIM,
872 "quotes,special_chars,attributes,replacements,macros,post_replacements,callouts",
873 ),
874 (
875 "verbatim+,-specialchars,+specialchars",
876 NORMAL,
877 "callouts,quotes,attributes,replacements,macros,post_replacements,special_chars",
878 ),
879 (
880 "+quotes,attributes+",
881 &[] as &[Substitution],
882 "attributes,quotes",
883 ),
884 ] {
885 let actual = parse_subs_attribute(spec).resolve(baseline);
886 assert_eq!(
887 actual
888 .iter()
889 .map(ToString::to_string)
890 .collect::<Vec<_>>()
891 .join(","),
892 expected,
893 "{spec} with {baseline:?}"
894 );
895 }
896 }
897
898 #[test]
899 fn test_parse_subs_remove_modifier() {
900 let result = parse_subs_attribute("-specialchars");
901 assert_eq!(source_entries(&result), ["-specialchars"]);
902
903 let resolved = result.resolve(VERBATIM);
905 assert!(!resolved.contains(&Substitution::SpecialChars));
906 assert!(resolved.contains(&Substitution::Callouts));
907 }
908
909 #[test]
910 fn test_parse_subs_remove_all_verbatim() {
911 let result = parse_subs_attribute("-specialchars,-callouts");
912 let ops = source_entries(&result);
913 assert_eq!(ops.len(), 2);
914
915 let resolved = result.resolve(VERBATIM);
917 assert_eq!(resolved, []);
918 }
919
920 #[test]
921 fn test_parse_subs_combined_modifiers() {
922 let result = parse_subs_attribute("+quotes,-callouts");
923 let ops = source_entries(&result);
924 assert_eq!(ops.len(), 2);
925
926 let resolved = result.resolve(VERBATIM);
928 assert!(resolved.contains(&Substitution::SpecialChars)); assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::Callouts)); }
932
933 #[test]
934 fn test_parse_subs_ordering_preserved() {
935 let result = parse_subs_attribute("quotes,attributes,specialchars");
936 assert_eq!(
937 explicit(&result),
938 vec![
939 Substitution::Quotes,
940 Substitution::Attributes,
941 Substitution::SpecialChars
942 ]
943 );
944 }
945
946 #[test]
947 fn test_parse_subs_shorthand_list() {
948 let result = parse_subs_attribute("q,a,c");
949 assert_eq!(
950 explicit(&result),
951 vec![
952 Substitution::Quotes,
953 Substitution::Attributes,
954 Substitution::SpecialChars
955 ]
956 );
957 }
958
959 #[test]
960 fn test_parse_subs_with_spaces() {
961 let result = parse_subs_attribute(" quotes , attributes ");
962 assert_eq!(
963 explicit(&result),
964 vec![Substitution::Quotes, Substitution::Attributes]
965 );
966 }
967
968 #[test]
969 fn test_parse_subs_duplicates_ignored() {
970 let result = parse_subs_attribute("quotes,quotes,quotes");
971 assert_eq!(explicit(&result), vec![Substitution::Quotes]);
972 }
973
974 #[test]
975 fn test_parse_subs_normal_in_list_expands() {
976 let result = parse_subs_attribute("normal");
977 let subs = explicit(&result);
978 assert_eq!(subs.len(), NORMAL.len());
980 for sub in NORMAL {
981 assert!(subs.contains(sub));
982 }
983 }
984
985 #[test]
986 fn test_parse_subs_append_normal_group() {
987 let result = parse_subs_attribute("+normal");
988 let resolved = result.resolve(&[Substitution::Callouts]);
990 assert!(resolved.contains(&Substitution::Callouts));
992 for sub in NORMAL {
993 assert!(resolved.contains(sub));
994 }
995 }
996
997 #[test]
998 fn test_parse_subs_remove_normal_group() {
999 let result = parse_subs_attribute("-normal");
1000 let resolved = result.resolve(NORMAL);
1002 assert_eq!(resolved, []);
1004 }
1005
1006 #[test]
1007 fn test_parse_subs_unknown_is_skipped() {
1008 let result = parse_subs_attribute("unknown");
1010 assert_eq!(explicit(&result), []);
1011 }
1012
1013 #[test]
1014 fn test_parse_subs_unknown_mixed_with_valid() {
1015 let result = parse_subs_attribute("quotes,typo,attributes");
1017 assert_eq!(
1018 explicit(&result),
1019 vec![Substitution::Quotes, Substitution::Attributes]
1020 );
1021 }
1022
1023 #[test]
1024 fn test_parse_subs_all_individual_types() {
1025 assert_eq!(
1027 explicit(&parse_subs_attribute("attributes")),
1028 vec![Substitution::Attributes]
1029 );
1030 assert_eq!(
1031 explicit(&parse_subs_attribute("replacements")),
1032 vec![Substitution::Replacements]
1033 );
1034 assert_eq!(
1035 explicit(&parse_subs_attribute("macros")),
1036 vec![Substitution::Macros]
1037 );
1038 assert_eq!(
1039 explicit(&parse_subs_attribute("post_replacements")),
1040 vec![Substitution::PostReplacements]
1041 );
1042 assert_eq!(
1043 explicit(&parse_subs_attribute("quotes")),
1044 vec![Substitution::Quotes]
1045 );
1046 assert_eq!(
1047 explicit(&parse_subs_attribute("callouts")),
1048 vec![Substitution::Callouts]
1049 );
1050 }
1051
1052 #[test]
1053 fn test_parse_subs_shorthand_types() {
1054 assert_eq!(
1055 explicit(&parse_subs_attribute("a")),
1056 vec![Substitution::Attributes]
1057 );
1058 assert_eq!(
1059 explicit(&parse_subs_attribute("r")),
1060 vec![Substitution::Replacements]
1061 );
1062 assert_eq!(
1063 explicit(&parse_subs_attribute("m")),
1064 vec![Substitution::Macros]
1065 );
1066 assert_eq!(
1067 explicit(&parse_subs_attribute("p")),
1068 vec![Substitution::PostReplacements]
1069 );
1070 assert_eq!(
1071 explicit(&parse_subs_attribute("q")),
1072 vec![Substitution::Quotes]
1073 );
1074 assert_eq!(
1075 explicit(&parse_subs_attribute("c")),
1076 vec![Substitution::SpecialChars]
1077 );
1078 }
1079
1080 #[test]
1081 fn test_parse_subs_mixed_modifier_list() {
1082 let result = parse_subs_attribute("specialchars,+quotes");
1083 assert_eq!(
1084 explicit(&result),
1085 &[Substitution::SpecialChars, Substitution::Quotes]
1086 );
1087 assert_eq!(result.resolve(VERBATIM), explicit(&result));
1088 }
1089
1090 #[test]
1091 fn test_parse_subs_modifier_in_middle() {
1092 let result = parse_subs_attribute("attributes,+quotes,-callouts");
1093 assert_eq!(
1094 explicit(&result),
1095 &[Substitution::Attributes, Substitution::Quotes]
1096 );
1097 assert_eq!(result.resolve(VERBATIM), explicit(&result));
1098 }
1099
1100 #[test]
1101 fn test_parse_subs_asciidoctor_example() {
1102 let result = parse_subs_attribute("attributes+,+replacements,-callouts");
1104 let ops = source_entries(&result);
1105 assert_eq!(ops.len(), 3);
1106
1107 let resolved = result.resolve(VERBATIM);
1109 assert_eq!(resolved.first(), Some(&Substitution::Attributes)); assert!(resolved.contains(&Substitution::Replacements)); assert!(!resolved.contains(&Substitution::Callouts)); }
1113
1114 #[test]
1115 fn test_parse_subs_modifier_only_at_end() {
1116 let result = parse_subs_attribute("quotes,-specialchars");
1117 assert_eq!(explicit(&result), &[Substitution::Quotes]);
1118 assert_eq!(result.resolve(VERBATIM), explicit(&result));
1119 }
1120
1121 #[test]
1122 fn plain_first_substitution_lists_ignore_all_block_baselines() -> Result<(), serde_json::Error>
1123 {
1124 for (spec, expected) in [
1125 ("quotes,+attributes", "quotes,attributes"),
1126 ("quotes,attributes+", "attributes,quotes"),
1127 ("quotes,-quotes", ""),
1128 ("quotes,-quotes,+quotes", "quotes"),
1129 ("none,+quotes", "quotes"),
1130 ("none,-quotes,+attributes", "attributes"),
1131 ("quotes,+none", "quotes"),
1132 ("quotes,none", "quotes"),
1133 (
1134 "none,normal,-macros",
1135 "special_chars,quotes,attributes,replacements,post_replacements",
1136 ),
1137 (
1138 "normal,-macros",
1139 "special_chars,quotes,attributes,replacements,post_replacements",
1140 ),
1141 ("verbatim,-callouts,+quotes", "special_chars,quotes"),
1142 (
1143 "verbatim,normal+",
1144 "special_chars,quotes,attributes,replacements,macros,post_replacements,callouts",
1145 ),
1146 (
1147 "normal,verbatim+",
1148 "special_chars,callouts,quotes,attributes,replacements,macros,post_replacements",
1149 ),
1150 ("none,+normal,-normal", ""),
1151 ("unknown,+quotes", "quotes"),
1152 ("none,unknown,+quotes", "quotes"),
1153 (",+quotes", "quotes"),
1154 (" , +quotes", "quotes"),
1155 (",,attributes+", "attributes"),
1156 ("quotes,,+attributes,", "quotes,attributes"),
1157 ("quotes,+attributes,-quotes,quotes+", "quotes,attributes"),
1158 ("quotes,attributes+,+quotes", "attributes,quotes"),
1159 ("attributes,+quotes,-callouts", "attributes,quotes"),
1160 ("quotes,-normal,+macros", "macros"),
1161 ("normal,-normal,+attributes", "attributes"),
1162 (
1163 "normal,+replacements",
1164 "special_chars,quotes,attributes,replacements,macros,post_replacements",
1165 ),
1166 ] {
1167 let parsed = parse_subs_attribute(spec);
1168 assert_eq!(
1169 source_entries(&parsed),
1170 spec.split(',').map(str::trim).collect::<Vec<_>>(),
1171 "{spec}"
1172 );
1173 for baseline in [NORMAL, VERBATIM, &[]] {
1174 let actual = parsed.resolve(baseline);
1175 assert_eq!(
1176 actual
1177 .iter()
1178 .map(ToString::to_string)
1179 .collect::<Vec<_>>()
1180 .join(","),
1181 expected,
1182 "{spec} with {baseline:?}"
1183 );
1184 assert_eq!(
1185 serde_json::to_value(&parsed)?,
1186 serde_json::to_value(spec.split(',').map(str::trim).collect::<Vec<_>>())?,
1187 "{spec}"
1188 );
1189 }
1190 }
1191 Ok(())
1192 }
1193
1194 #[test]
1195 fn modifier_first_substitution_lists_keep_block_baselines() {
1196 for spec in [
1197 "+quotes,attributes",
1198 "+none,quotes,attributes",
1199 "-unknown,quotes,attributes",
1200 "+quotes,,attributes",
1201 ] {
1202 let parsed = parse_subs_attribute(spec);
1203 assert_eq!(
1204 source_entries(&parsed),
1205 spec.split(',').map(str::trim).collect::<Vec<_>>(),
1206 "{spec}"
1207 );
1208 assert_eq!(parsed.resolve(NORMAL), NORMAL, "{spec}");
1209 assert_eq!(
1210 parsed.resolve(VERBATIM),
1211 [
1212 Substitution::SpecialChars,
1213 Substitution::Callouts,
1214 Substitution::Quotes,
1215 Substitution::Attributes
1216 ],
1217 "{spec}"
1218 );
1219 }
1220 }
1221
1222 #[test]
1223 fn test_resolve_modifiers_with_normal_baseline() {
1224 let result = parse_subs_attribute("-quotes");
1227 let resolved = result.resolve(NORMAL);
1228
1229 assert!(resolved.contains(&Substitution::SpecialChars));
1231 assert!(resolved.contains(&Substitution::Attributes));
1232 assert!(!resolved.contains(&Substitution::Quotes)); assert!(resolved.contains(&Substitution::Replacements));
1234 assert!(resolved.contains(&Substitution::Macros));
1235 assert!(resolved.contains(&Substitution::PostReplacements));
1236 }
1237
1238 #[test]
1239 fn test_resolve_modifiers_with_verbatim_baseline() {
1240 let result = parse_subs_attribute("-quotes");
1242 let resolved = result.resolve(VERBATIM);
1243
1244 assert!(resolved.contains(&Substitution::SpecialChars));
1246 assert!(resolved.contains(&Substitution::Callouts));
1247 assert!(!resolved.contains(&Substitution::Quotes));
1248 }
1249
1250 #[test]
1251 fn test_resolve_explicit_ignores_baseline() {
1252 let result = parse_subs_attribute("quotes,attributes");
1254 let resolved_normal = result.resolve(NORMAL);
1255 let resolved_verbatim = result.resolve(VERBATIM);
1256
1257 assert_eq!(resolved_normal, resolved_verbatim);
1259 assert_eq!(
1260 resolved_normal,
1261 vec![Substitution::Quotes, Substitution::Attributes]
1262 );
1263 }
1264
1265 #[test]
1266 fn test_resolve_attribute_references() {
1267 let attribute_weight: AttributeValue = "weight".into();
1269 let attribute_mass: AttributeValue = "mass".into();
1270
1271 let attribute_volume_repeat = "value {attribute_volume}";
1274
1275 let mut attributes = DocumentAttributes::default();
1276 assert!(
1277 attributes
1278 .insert("weight".into(), attribute_weight.clone())
1279 .is_ok()
1280 );
1281 assert!(
1282 attributes
1283 .insert("mass".into(), attribute_mass.clone())
1284 .is_ok()
1285 );
1286
1287 let resolved = substitute("{weight}", HEADER, &attributes);
1289 assert_eq!(resolved, "weight");
1290
1291 let resolved = substitute("{weight} {mass}", HEADER, &attributes);
1293 assert_eq!(resolved, "weight mass");
1294
1295 let resolved = substitute("value {attribute_volume}", HEADER, &attributes);
1297 assert_eq!(resolved, attribute_volume_repeat);
1298 }
1299
1300 #[test]
1301 fn test_resolve_typed_default_and_original_text() {
1302 let mut attributes = DocumentAttributes::default();
1303 assert_eq!(
1304 substitute("depth={max-include-depth}", HEADER, &attributes),
1305 "depth=64"
1306 );
1307
1308 assert!(
1309 attributes
1310 .set("max-include-depth".into(), "064".into())
1311 .is_ok()
1312 );
1313 assert_eq!(
1314 substitute("depth={max-include-depth}", HEADER, &attributes),
1315 "depth=064"
1316 );
1317
1318 assert!(
1319 attributes
1320 .set("present".into(), AttributeValue::Bool(true))
1321 .is_ok()
1322 );
1323 assert_eq!(
1324 substitute("before{present}after", HEADER, &attributes),
1325 "beforeafter"
1326 );
1327 }
1328
1329 #[test]
1330 fn test_substitute_single_pass_expansion() {
1331 let mut attributes = DocumentAttributes::default();
1339 assert!(attributes.insert("foo".into(), "{bar}".into()).is_ok());
1340 assert!(
1341 attributes
1342 .insert("bar".into(), "should-not-appear".into())
1343 .is_ok()
1344 );
1345
1346 let resolved = substitute("{foo}", HEADER, &attributes);
1347 assert_eq!(resolved, "{bar}");
1348 }
1349
1350 #[test]
1351 fn test_utf8_boundary_handling() {
1352 let attributes = DocumentAttributes::default();
1355
1356 let values = [
1357 ":J::~\x01\x00\x00Ô",
1359 "{attr}Ô{missing}日本語",
1361 "{attrÔ}test",
1363 ];
1364 for value in values {
1365 let resolved = substitute(value, HEADER, &attributes);
1366 assert_eq!(resolved, value);
1367 }
1368 }
1369
1370 #[rstest::rstest]
1376 #[case::explicit_specialchars_disables_macros("specialchars", Substitution::Macros, true)]
1378 #[case::explicit_specialchars_disables_attributes(
1379 "specialchars",
1380 Substitution::Attributes,
1381 true
1382 )]
1383 #[case::explicit_specialchars_disables_post_replacements(
1384 "specialchars",
1385 Substitution::PostReplacements,
1386 true
1387 )]
1388 #[case::explicit_specialchars_disables_quotes("specialchars", Substitution::Quotes, true)]
1389 #[case::explicit_specialchars_disables_callouts("specialchars", Substitution::Callouts, true)]
1390 #[case::explicit_macros("macros", Substitution::Macros, false)]
1392 #[case::explicit_attributes("attributes", Substitution::Attributes, false)]
1393 #[case::explicit_post_replacements("post_replacements", Substitution::PostReplacements, false)]
1394 #[case::explicit_quotes("quotes", Substitution::Quotes, false)]
1395 #[case::explicit_callouts("callouts", Substitution::Callouts, false)]
1396 #[case::short_alias_p_enables_post_replacements("p", Substitution::PostReplacements, false)]
1398 #[case::short_alias_q_enables_quotes("q", Substitution::Quotes, false)]
1399 #[case::baseline_normal_includes_macros("normal", Substitution::Macros, false)]
1401 #[case::baseline_normal_includes_attributes("normal", Substitution::Attributes, false)]
1402 #[case::baseline_normal_includes_post_replacements(
1403 "normal",
1404 Substitution::PostReplacements,
1405 false
1406 )]
1407 #[case::baseline_normal_includes_quotes("normal", Substitution::Quotes, false)]
1408 #[case::baseline_verbatim_includes_callouts("verbatim", Substitution::Callouts, false)]
1409 #[case::modifier_remove_macros("-macros", Substitution::Macros, true)]
1411 #[case::modifier_remove_attributes("-attributes", Substitution::Attributes, true)]
1412 #[case::modifier_remove_post_replacements(
1413 "-post_replacements",
1414 Substitution::PostReplacements,
1415 true
1416 )]
1417 #[case::modifier_remove_quotes("-quotes", Substitution::Quotes, true)]
1418 #[case::modifier_remove_callouts("-callouts", Substitution::Callouts, true)]
1419 #[case::modifier_add_macros("+macros", Substitution::Macros, false)]
1421 #[case::modifier_add_attributes("+attributes", Substitution::Attributes, false)]
1422 #[case::modifier_add_post_replacements(
1423 "+post_replacements",
1424 Substitution::PostReplacements,
1425 false
1426 )]
1427 #[case::modifier_add_quotes("+quotes", Substitution::Quotes, false)]
1428 #[case::modifier_add_callouts("+callouts", Substitution::Callouts, false)]
1429 #[case::remove_normal_disables_macros("-normal", Substitution::Macros, true)]
1430 #[case::remove_normal_disables_quotes("-normal", Substitution::Quotes, true)]
1431 #[case::remove_normal_disables_attributes("-normal", Substitution::Attributes, true)]
1432 #[case::remove_normal_disables_hardbreaks("-normal", Substitution::PostReplacements, true)]
1433 #[case::remove_normal_keeps_callouts("-normal", Substitution::Callouts, false)]
1434 #[case::remove_verbatim_disables_callouts("-verbatim", Substitution::Callouts, true)]
1435 #[case::remove_verbatim_disables_specialchars("-verbatim", Substitution::SpecialChars, true)]
1436 #[case::remove_verbatim_keeps_quotes("-verbatim", Substitution::Quotes, false)]
1437 #[case::restore_normal_macros("-normal,+normal", Substitution::Macros, false)]
1438 #[case::restore_normal_attributes("-normal,+normal", Substitution::Attributes, false)]
1439 #[case::restore_verbatim_callouts("-verbatim,+verbatim", Substitution::Callouts, false)]
1440 #[case::restore_only_callouts("-verbatim,+callouts", Substitution::SpecialChars, true)]
1441 #[case::restore_only_specialchars("-verbatim,+specialchars", Substitution::Callouts, true)]
1442 #[case::remove_restored_group("-normal,+normal,-normal", Substitution::Quotes, true)]
1443 #[case::restore_only_macros("-normal,+macros", Substitution::Attributes, true)]
1444 #[case::restore_only_attributes("-normal,attributes+", Substitution::Macros, true)]
1445 #[case::none_disables_macros("none", Substitution::Macros, true)]
1447 #[case::none_disables_attributes("none", Substitution::Attributes, true)]
1448 #[case::none_disables_post_replacements("none", Substitution::PostReplacements, true)]
1449 #[case::none_disables_quotes("none", Substitution::Quotes, true)]
1450 #[case::none_disables_callouts("none", Substitution::Callouts, true)]
1451 fn block_plan_disables_requested_stages(
1452 #[case] subs_attr: &str,
1453 #[case] sub: Substitution,
1454 #[case] expected: bool,
1455 ) {
1456 let spec = parse_subs_attribute(subs_attr);
1457 assert_eq!(
1458 !SubstitutionPlan::for_block_spec(&spec).enabled(&sub),
1459 expected,
1460 "spec={subs_attr:?} sub={sub:?}"
1461 );
1462 }
1463
1464 #[test]
1465 fn removed_group_plans_keep_restored_stages_in_order() {
1466 for (spec, expected) in [
1467 ("-normal", "callouts"),
1468 ("-normal,attributes+", "attributes,callouts"),
1469 ("-normal,+attributes,+macros", "callouts,attributes,macros"),
1470 ("-normal,+macros,+attributes", "callouts,macros,attributes"),
1471 (
1472 "-normal,+normal",
1473 "callouts,specialchars,quotes,attributes,replacements,macros,post_replacements",
1474 ),
1475 (
1476 "-normal,normal+,attributes+",
1477 "attributes,specialchars,quotes,replacements,macros,post_replacements,callouts",
1478 ),
1479 (
1480 "-verbatim",
1481 "quotes,attributes,replacements,macros,post_replacements",
1482 ),
1483 (
1484 "-verbatim,+verbatim",
1485 "quotes,attributes,replacements,macros,post_replacements,specialchars,callouts",
1486 ),
1487 (
1488 "-verbatim,+callouts",
1489 "quotes,attributes,replacements,macros,post_replacements,callouts",
1490 ),
1491 ("-normal,-verbatim", "none"),
1492 ("-normal,+quotes,-normal", "callouts"),
1493 ] {
1494 assert_eq!(
1495 SubstitutionPlan::for_block_spec(&parse_subs_attribute(spec)),
1496 SubstitutionPlan::from_substitutions(&parse_subs_attribute(expected).resolve(&[])),
1497 "{spec}"
1498 );
1499 }
1500 }
1501}
1502
1503#[must_use]
1512pub fn substitute_attributes<'text, 'value>(
1513 text: &'text str,
1514 lookup: impl FnMut(&str) -> Option<&'value super::DocumentAttributeValue<'value>>,
1515) -> Cow<'text, str> {
1516 substitute_attributes_with_ranges(text, lookup, |_, _| {})
1517}
1518
1519pub(crate) fn resolve_attribute_entry_text<'text>(
1521 text: &'text str,
1522 attributes: &DocumentAttributes<'_>,
1523) -> (super::DocumentAttributeValue<'text>, Option<String>) {
1524 if let Some(profile) = attribute_inline_profile(text, attributes) {
1525 return match profile {
1526 Ok((inner, mut substitutions)) => {
1527 let inner = if substitutions.contains(&Substitution::Attributes) {
1528 expand_attribute_entry_references(inner, attributes, false).into()
1529 } else {
1530 Cow::Borrowed(inner)
1531 };
1532 substitutions.retain(|stage| *stage != Substitution::Attributes);
1535 let fragment = super::AttributeInlineFragment {
1536 range: 0..inner.len(),
1537 substitutions: substitutions.into_boxed_slice(),
1538 };
1539 (
1540 super::DocumentAttributeValue::with_inline_fragments(
1541 inner,
1542 Vec::new(),
1543 vec![fragment],
1544 ),
1545 None,
1546 )
1547 }
1548 Err(message) => (
1549 super::DocumentAttributeValue::with_inline_fragments(
1550 Cow::Borrowed(text),
1551 Vec::new(),
1552 vec![super::AttributeInlineFragment {
1553 range: 0..text.len(),
1554 substitutions: vec![Substitution::SpecialChars].into_boxed_slice(),
1555 }],
1556 ),
1557 Some(message),
1558 ),
1559 };
1560 }
1561 if let Some((inner, substitutions)) = attribute_text_substitutions(text) {
1562 if substitutions == [AttributeTextSubstitution::Attributes] {
1563 let value = resolve_attribute_references(inner, attributes, true);
1564 if !value.inline_fragments().is_empty() {
1565 return (value, None);
1566 }
1567 }
1568 let mut inner = Cow::Borrowed(inner);
1569 for substitution in substitutions {
1570 inner = match substitution {
1571 AttributeTextSubstitution::Attributes => {
1572 expand_attribute_entry_references(&inner, attributes, true).into()
1573 }
1574 AttributeTextSubstitution::SpecialChars => {
1575 escape_attribute_characters(&inner).into()
1576 }
1577 };
1578 }
1579 let ranges = if inner.is_empty() {
1580 Vec::new()
1581 } else {
1582 std::iter::once(0..inner.len()).collect()
1583 };
1584 return (
1585 super::DocumentAttributeValue::with_passthrough_ranges(inner, ranges),
1586 None,
1587 );
1588 }
1589 (resolve_attribute_references(text, attributes, false), None)
1590}
1591
1592fn resolve_attribute_references<'text>(
1593 text: &'text str,
1594 attributes: &DocumentAttributes<'_>,
1595 protect_gaps: bool,
1596) -> super::DocumentAttributeValue<'text> {
1597 let mut ranges = Vec::new();
1598 let mut fragments = Vec::new();
1599 let text = substitute_attributes_with_ranges(
1600 text,
1601 |name| attributes.get(name),
1602 |value, offset| {
1603 fragments.extend(
1604 value
1605 .inline_fragments()
1606 .iter()
1607 .cloned()
1608 .map(|mut fragment| {
1609 fragment.range = fragment.range.start + offset..fragment.range.end + offset;
1610 fragment
1611 }),
1612 );
1613 ranges.extend(
1614 value
1615 .passthrough_ranges()
1616 .iter()
1617 .map(|range| range.start + offset..range.end + offset),
1618 );
1619 },
1620 );
1621 if protect_gaps && !fragments.is_empty() {
1622 let mut gaps = Vec::new();
1625 let mut cursor = 0;
1626 for fragment in &fragments {
1627 if cursor < fragment.range.start {
1628 gaps.push(cursor..fragment.range.start);
1629 }
1630 cursor = fragment.range.end;
1631 }
1632 if cursor < text.len() {
1633 gaps.push(cursor..text.len());
1634 }
1635 fragments.extend(
1636 gaps.into_iter()
1637 .map(|range| super::AttributeInlineFragment {
1638 range,
1639 substitutions: Box::default(),
1640 }),
1641 );
1642 }
1643 super::DocumentAttributeValue::with_inline_fragments(text, ranges, fragments)
1644}
1645
1646fn attribute_inline_profile<'text>(
1648 text: &'text str,
1649 attributes: &DocumentAttributes<'_>,
1650) -> Option<Result<(&'text str, Vec<Substitution>), String>> {
1651 let (names, inner) = text.trim().strip_prefix("pass:")?.split_once('[')?;
1652 let inner = inner.strip_suffix(']')?;
1653 let mut substitutions = Vec::new();
1654 for name in names
1655 .split(',')
1656 .filter(|name| !name.is_empty() && *name != "none")
1657 {
1658 let Some(stage) = parse_substitution(name) else {
1659 return Some(Err(format!(
1660 "unknown attribute-value substitution: {name}; value retained literally"
1661 )));
1662 };
1663 substitutions.push(stage);
1664 }
1665 let substitutions = resolve_passthrough_substitutions(&substitutions);
1666 if !substitutions.iter().any(|stage| {
1667 matches!(
1668 stage,
1669 Substitution::Quotes
1670 | Substitution::Macros
1671 | Substitution::Replacements
1672 | Substitution::PostReplacements
1673 )
1674 }) {
1675 if substitutions.contains(&Substitution::Attributes)
1676 && substitutions.contains(&Substitution::SpecialChars)
1677 && !resolve_attribute_references(inner, attributes, false)
1678 .inline_fragments()
1679 .is_empty()
1680 {
1681 return Some(Err("attribute-value escaping combined with formatted references is unsupported; value retained literally".into()));
1682 }
1683 return None;
1684 }
1685 let mut structural = false;
1686 for stage in &substitutions {
1687 if *stage == Substitution::SpecialChars && structural {
1688 return Some(Err("attribute-value escaping after formatting or macros requires backend markup; value retained literally".into()));
1689 }
1690 structural |= matches!(stage, Substitution::Quotes | Substitution::Macros);
1691 }
1692 Some(Ok((inner, substitutions)))
1693}
1694
1695#[derive(PartialEq, Eq)]
1696pub(crate) enum AttributeTextSubstitution {
1697 Attributes,
1698 SpecialChars,
1699}
1700
1701fn attribute_text_substitutions(text: &str) -> Option<(&str, Vec<AttributeTextSubstitution>)> {
1702 let (names, inner) = text.trim().strip_prefix("pass:")?.split_once('[')?;
1703 let inner = inner.strip_suffix(']')?;
1704 Some((inner, attribute_text_substitution_names(names)?))
1705}
1706
1707pub(crate) fn attribute_text_substitution_names(
1708 names: &str,
1709) -> Option<Vec<AttributeTextSubstitution>> {
1710 let mut substitutions = Vec::new();
1711 if !names.is_empty() {
1712 for name in names.split(',') {
1713 let substitution = match name {
1714 "none" => continue,
1715 "a" | "attributes" => AttributeTextSubstitution::Attributes,
1716 "c" | "specialchars" | "specialcharacters" | "v" | "verbatim" => {
1717 AttributeTextSubstitution::SpecialChars
1718 }
1719 _ => return None,
1720 };
1721 if !substitutions.contains(&substitution) {
1722 substitutions.push(substitution);
1723 }
1724 }
1725 }
1726 Some(substitutions)
1727}
1728
1729pub(crate) fn escape_attribute_characters(text: &str) -> String {
1730 text.replace('&', "&")
1731 .replace('<', "<")
1732 .replace('>', ">")
1733}
1734
1735pub(crate) fn character_reference_end(text: &str) -> Option<usize> {
1737 let tail = text.strip_prefix('&')?;
1738 let first = tail.find(';')?;
1739 if tail[..first].is_empty()
1740 || !tail[..first]
1741 .bytes()
1742 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'#')
1743 {
1744 return None;
1745 }
1746 let mut unescaped = tail;
1747 while let Some(rest) = unescaped.strip_prefix("amp;") {
1748 unescaped = rest;
1749 }
1750 let end = unescaped.find(';').filter(|end| {
1751 !unescaped[..*end].is_empty()
1752 && unescaped[..*end]
1753 .bytes()
1754 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'#')
1755 });
1756 Some(
1757 end.map_or((text.len() - unescaped.len()).max(first + 2), |end| {
1758 text.len() - unescaped.len() + end + 1
1759 }),
1760 )
1761}
1762
1763pub(crate) fn is_restorable_character_reference(body: &str) -> bool {
1764 if let Some(digits) = body.strip_prefix("#x") {
1767 (2..=5).contains(&digits.len()) && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
1768 } else if let Some(digits) = body.strip_prefix('#') {
1769 (2..=6).contains(&digits.len()) && digits.bytes().all(|byte| byte.is_ascii_digit())
1770 } else {
1771 let letters = body.bytes().take_while(u8::is_ascii_alphabetic).count();
1772 letters >= 2
1773 && body.len() - letters <= 2
1774 && body[letters..].bytes().all(|byte| byte.is_ascii_digit())
1775 }
1776}
1777
1778fn expand_attribute_entry_references(
1779 text: &str,
1780 attributes: &DocumentAttributes<'_>,
1781 escape_document_values: bool,
1782) -> String {
1783 let mut output = String::with_capacity(text.len());
1784 let mut remaining = text;
1785 while let Some((prefix, candidate)) = remaining.split_once('{') {
1786 let Some((name, rest)) = candidate.split_once('}') else {
1787 break;
1788 };
1789 if prefix.ends_with('\\') || name.ends_with('\\') {
1790 output.push_str(prefix.strip_suffix('\\').unwrap_or(prefix));
1791 output.push('{');
1792 output.push_str(name.strip_suffix('\\').unwrap_or(name));
1793 output.push('}');
1794 } else if let Some(value) = attributes.get(name) {
1795 output.push_str(prefix);
1796 let mut value_text = String::new();
1797 let _ = value.write_text(&mut value_text);
1798 if escape_document_values && attributes.is_document_value(name) {
1799 let mut cursor = 0;
1802 for range in value.passthrough_ranges() {
1803 output.push_str(&escape_attribute_characters(
1804 &value_text[cursor..range.start],
1805 ));
1806 output.push_str(&value_text[range.clone()]);
1807 cursor = range.end;
1808 }
1809 output.push_str(&escape_attribute_characters(&value_text[cursor..]));
1810 } else {
1811 output.push_str(&value_text);
1812 }
1813 } else {
1814 output.push_str(prefix);
1815 output.push('{');
1816 output.push_str(name);
1817 output.push('}');
1818 }
1819 remaining = rest;
1820 }
1821 output.push_str(remaining);
1822 output
1823}
1824
1825fn substitute_attributes_with_ranges<'text, 'value>(
1826 text: &'text str,
1827 mut lookup: impl FnMut(&str) -> Option<&'value super::DocumentAttributeValue<'value>>,
1828 mut substituted: impl FnMut(&super::DocumentAttributeValue<'value>, usize),
1829) -> Cow<'text, str> {
1830 let mut remaining = text;
1831 let mut unwritten = text;
1832 let mut output: Option<String> = None;
1833 while let Some((prefix, candidate)) = remaining.split_once('{') {
1834 let name_len = candidate
1835 .bytes()
1836 .take_while(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'-'))
1837 .count();
1838 let (name, after_name) = candidate.split_at(name_len);
1839 let Some(rest) = after_name.strip_prefix('}') else {
1840 remaining = after_name;
1841 continue;
1842 };
1843 remaining = rest;
1844 if name.is_empty() {
1845 continue;
1846 }
1847 let prefix_len = unwritten.len() - candidate.len() - 1;
1848 if prefix.ends_with('\\') {
1849 let output = output.get_or_insert_with(|| String::with_capacity(text.len()));
1850 output.push_str(unwritten.split_at(prefix_len - 1).0);
1851 output.push('{');
1852 output.push_str(name);
1853 output.push('}');
1854 unwritten = rest;
1855 } else if let Some(value) = lookup(name) {
1856 let output = output.get_or_insert_with(|| String::with_capacity(text.len()));
1857 output.push_str(unwritten.split_at(prefix_len).0);
1858 substituted(value, output.len());
1859 let _ = value.write_text(output);
1860 unwritten = rest;
1861 }
1862 }
1863 match output {
1864 Some(mut output) => {
1865 output.push_str(unwritten);
1866 Cow::Owned(output)
1867 }
1868 None => Cow::Borrowed(text),
1869 }
1870}
1871
1872#[cfg(test)]
1873mod attribute_substitution_tests {
1874 use std::borrow::Cow;
1875
1876 use crate::{DocumentAttributeValue, Options};
1877
1878 use super::substitute_attributes;
1879
1880 #[rstest::rstest]
1881 #[case(r"\{shell}", "{shell}")]
1882 #[case(r"\\{shell}", r"\{shell}")]
1883 #[case(r"\\\{missing}", r"\\{missing}")]
1884 fn escaped_references_do_not_call_lookup(#[case] input: &str, #[case] expected: &str) {
1885 let value = DocumentAttributeValue::from("unexpected");
1886 let mut names = Vec::new();
1887 let output = substitute_attributes(input, |name| {
1888 names.push(name.to_owned());
1889 Some(&value)
1890 });
1891 assert_eq!(output, expected);
1892 assert_eq!(names, [] as [String; 0]);
1893 }
1894
1895 #[rstest::rstest]
1896 #[case("λ plain\r\ntext\n", &[])]
1897 #[case("{missing}", &["missing"])]
1898 #[case("{{missing}}", &["missing"])]
1899 #[case("{} {bad name} {name.part} {café} {counter:n}", &[])]
1900 #[case(r"\{bad name} {shell\} ${shell:-fallback}", &[])]
1901 #[case("{unfinished", &[])]
1902 fn unchanged_text_stays_borrowed(#[case] input: &str, #[case] expected_names: &[&str]) {
1903 let mut names = Vec::new();
1904 let output = substitute_attributes(input, |name| {
1905 names.push(name.to_owned());
1906 None
1907 });
1908 assert!(matches!(output, Cow::Borrowed(value) if std::ptr::eq(value, input)));
1909 assert_eq!(names, expected_names);
1910 }
1911
1912 #[test]
1913 fn adjacent_escaped_known_and_unknown_references_are_distinct() {
1914 let value = DocumentAttributeValue::from("zsh");
1915 let mut names = Vec::new();
1916 let output = substitute_attributes(r"{shell}\{shell}{missing}{shell}", |name| {
1917 names.push(name.to_owned());
1918 (name == "shell").then_some(&value)
1919 });
1920 assert_eq!(output, "zsh{shell}{missing}zsh");
1921 assert_eq!(names, ["shell", "missing", "shell"]);
1922 }
1923
1924 #[rstest::rstest]
1925 #[case("{{shell}}", "{zsh}", 1)]
1926 #[case("{outer{shell}}", "{outerzsh}", 1)]
1927 #[case(r"\{{shell}}", r"\{zsh}", 1)]
1928 #[case(r"{\{shell}}", "{{shell}}", 0)]
1929 #[case(r"\{outer{shell}}", r"\{outerzsh}", 1)]
1930 #[case("{é{shell}}", "{ézsh}", 1)]
1931 fn valid_inner_references_expand_once(
1932 #[case] input: &str,
1933 #[case] expected: &str,
1934 #[case] calls: usize,
1935 ) {
1936 let value = DocumentAttributeValue::from("zsh");
1937 let mut names = Vec::new();
1938 let output = substitute_attributes(input, |name| {
1939 names.push(name.to_owned());
1940 Some(&value)
1941 });
1942 assert_eq!(output, expected);
1943 assert_eq!(names, vec!["shell"; calls]);
1944 }
1945
1946 #[test]
1947 fn names_follow_the_parser_reference_grammar() {
1948 let value = DocumentAttributeValue::from("x");
1949 let mut names = Vec::new();
1950 let output = substitute_attributes("{_}{-}{0}{A-b_2}", |name| {
1951 names.push(name.to_owned());
1952 Some(&value)
1953 });
1954 assert_eq!(output, "xxxx");
1955 assert_eq!(names, ["_", "-", "0", "A-b_2"]);
1956 }
1957
1958 #[test]
1959 fn replacement_text_is_not_scanned_again() {
1960 let first = DocumentAttributeValue::from("{second}\\{third}\r\n");
1961 let second = DocumentAttributeValue::from("done");
1962 let mut names = Vec::new();
1963 let output = substitute_attributes("{first}/{second}", |name| {
1964 names.push(name.to_owned());
1965 match name {
1966 "first" => Some(&first),
1967 "second" => Some(&second),
1968 _ => None,
1969 }
1970 });
1971 assert_eq!(output, "{second}\\{third}\r\n/done");
1972 assert_eq!(names, ["first", "second"]);
1973 }
1974
1975 #[test]
1976 fn values_keep_lexical_spelling_and_empty_values_are_present() -> Result<(), crate::Error> {
1977 let options = Options::builder()
1978 .with_attribute("max-include-depth", "003")
1979 .with_attribute("quoted", "\"word\"")
1980 .with_attribute("empty", "")
1981 .with_attribute("present", true)
1982 .build()?;
1983 let attributes = options.document_attributes();
1984 let output = substitute_attributes(
1985 "\r\n{max-include-depth}/{quoted}/{empty}/{present}\n",
1986 |name| attributes.get(name),
1987 );
1988 assert_eq!(output, "\r\n003/\"word\"//\n");
1989 Ok(())
1990 }
1991}