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