use serde::Serialize;
use crate::{AttributeValue, DocumentAttributes};
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Substitution {
SpecialChars,
Attributes,
Replacements,
Macros,
PostReplacements,
Normal,
Verbatim,
Quotes,
Callouts,
}
impl std::fmt::Display for Substitution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::SpecialChars => "special_chars",
Self::Attributes => "attributes",
Self::Replacements => "replacements",
Self::Macros => "macros",
Self::PostReplacements => "post_replacements",
Self::Normal => "normal",
Self::Verbatim => "verbatim",
Self::Quotes => "quotes",
Self::Callouts => "callouts",
};
write!(f, "{name}")
}
}
pub(crate) fn parse_substitution(value: &str) -> Option<Substitution> {
match value {
"attributes" | "a" => Some(Substitution::Attributes),
"replacements" | "r" => Some(Substitution::Replacements),
"macros" | "m" => Some(Substitution::Macros),
"post_replacements" | "p" => Some(Substitution::PostReplacements),
"normal" | "n" => Some(Substitution::Normal),
"verbatim" | "v" => Some(Substitution::Verbatim),
"quotes" | "q" => Some(Substitution::Quotes),
"callouts" => Some(Substitution::Callouts),
"specialchars" | "specialcharacters" | "c" => Some(Substitution::SpecialChars),
unknown => {
tracing::error!(
substitution = %unknown,
"unknown substitution type, ignoring - check for typos"
);
None
}
}
}
pub const HEADER: &[Substitution] = &[Substitution::SpecialChars, Substitution::Attributes];
pub const NORMAL: &[Substitution] = &[
Substitution::SpecialChars,
Substitution::Attributes,
Substitution::Quotes,
Substitution::Replacements,
Substitution::Macros,
Substitution::PostReplacements,
];
pub const VERBATIM: &[Substitution] = &[Substitution::SpecialChars, Substitution::Callouts];
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub enum SubstitutionOp {
Append(Substitution),
Prepend(Substitution),
Remove(Substitution),
}
impl std::fmt::Display for SubstitutionOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Append(sub) => write!(f, "+{sub}"),
Self::Prepend(sub) => write!(f, "{sub}+"),
Self::Remove(sub) => write!(f, "-{sub}"),
}
}
}
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub enum SubstitutionSpec {
Explicit(Vec<Substitution>),
Modifiers(Vec<SubstitutionOp>),
}
impl Serialize for SubstitutionSpec {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let strings: Vec<String> = match self {
Self::Explicit(subs) => subs.iter().map(ToString::to_string).collect(),
Self::Modifiers(ops) => ops.iter().map(ToString::to_string).collect(),
};
strings.serialize(serializer)
}
}
impl SubstitutionSpec {
#[must_use]
pub fn apply_modifiers(ops: &[SubstitutionOp], default: &[Substitution]) -> Vec<Substitution> {
let mut result = default.to_vec();
for op in ops {
match op {
SubstitutionOp::Append(sub) => append_substitution(&mut result, sub),
SubstitutionOp::Prepend(sub) => prepend_substitution(&mut result, sub),
SubstitutionOp::Remove(sub) => remove_substitution(&mut result, sub),
}
}
result
}
#[must_use]
pub fn macros_disabled(&self) -> bool {
match self {
Self::Explicit(subs) => !subs.contains(&Substitution::Macros),
Self::Modifiers(ops) => ops
.iter()
.any(|op| matches!(op, SubstitutionOp::Remove(Substitution::Macros))),
}
}
#[must_use]
pub fn attributes_disabled(&self) -> bool {
match self {
Self::Explicit(subs) => !subs.contains(&Substitution::Attributes),
Self::Modifiers(ops) => ops
.iter()
.any(|op| matches!(op, SubstitutionOp::Remove(Substitution::Attributes))),
}
}
#[must_use]
pub fn resolve(&self, default: &[Substitution]) -> Vec<Substitution> {
match self {
SubstitutionSpec::Explicit(subs) => subs.clone(),
SubstitutionSpec::Modifiers(ops) => Self::apply_modifiers(ops, default),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SubsModifier {
Append,
Prepend,
Remove,
}
fn parse_subs_part(part: &str) -> (&str, Option<SubsModifier>) {
if let Some(name) = part.strip_prefix('+') {
(name, Some(SubsModifier::Append))
} else if let Some(name) = part.strip_suffix('+') {
(name, Some(SubsModifier::Prepend))
} else if let Some(name) = part.strip_prefix('-') {
(name, Some(SubsModifier::Remove))
} else {
(part, None)
}
}
#[must_use]
pub(crate) fn parse_subs_attribute(value: &str) -> SubstitutionSpec {
let value = value.trim();
if value.is_empty() || value == "none" {
return SubstitutionSpec::Explicit(Vec::new());
}
let parts: Vec<_> = value
.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(parse_subs_part)
.collect();
let has_modifiers = parts.iter().any(|(_, m)| m.is_some());
if has_modifiers {
let mut ops = Vec::new();
for (name, modifier) in parts {
let Some(sub) = parse_substitution(name) else {
continue;
};
match modifier {
Some(SubsModifier::Append) => {
ops.push(SubstitutionOp::Append(sub));
}
Some(SubsModifier::Prepend) => {
ops.push(SubstitutionOp::Prepend(sub));
}
Some(SubsModifier::Remove) => {
ops.push(SubstitutionOp::Remove(sub));
}
None => {
tracing::warn!(
substitution = %name,
"plain substitution in modifier context; consider +{name} for clarity"
);
ops.push(SubstitutionOp::Append(sub));
}
}
}
SubstitutionSpec::Modifiers(ops)
} else {
let mut result = Vec::new();
for (name, _) in parts {
if let Some(ref sub) = parse_substitution(name) {
append_substitution(&mut result, sub);
}
}
SubstitutionSpec::Explicit(result)
}
}
fn expand_substitution(sub: &Substitution) -> &[Substitution] {
match sub {
Substitution::Normal => NORMAL,
Substitution::Verbatim => VERBATIM,
Substitution::SpecialChars
| Substitution::Attributes
| Substitution::Replacements
| Substitution::Macros
| Substitution::PostReplacements
| Substitution::Quotes
| Substitution::Callouts => std::slice::from_ref(sub),
}
}
pub(crate) fn append_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
for s in expand_substitution(sub) {
if !result.contains(s) {
result.push(s.clone());
}
}
}
pub(crate) fn prepend_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
for s in expand_substitution(sub).iter().rev() {
if !result.contains(s) {
result.insert(0, s.clone());
}
}
}
pub(crate) fn remove_substitution(result: &mut Vec<Substitution>, sub: &Substitution) {
for s in expand_substitution(sub) {
result.retain(|x| x != s);
}
}
#[must_use]
pub fn substitute(
text: &str,
substitutions: &[Substitution],
attributes: &DocumentAttributes,
) -> String {
let mut result = text.to_string();
for substitution in substitutions {
match substitution {
Substitution::Attributes => {
let mut expanded = String::with_capacity(result.len());
let mut chars = result.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' {
let mut attr_name = String::new();
let mut found_closing_brace = false;
while let Some(&next_ch) = chars.peek() {
if next_ch == '}' {
chars.next();
found_closing_brace = true;
break;
}
attr_name.push(next_ch);
chars.next();
}
if found_closing_brace {
match attributes.get(&attr_name) {
Some(AttributeValue::Bool(true)) => {
}
Some(AttributeValue::String(attr_value)) => {
expanded.push_str(attr_value);
}
_ => {
expanded.push('{');
expanded.push_str(&attr_name);
expanded.push('}');
}
}
} else {
expanded.push('{');
expanded.push_str(&attr_name);
}
} else {
expanded.push(ch);
}
}
result = expanded;
}
Substitution::SpecialChars
| Substitution::Quotes
| Substitution::Replacements
| Substitution::Macros
| Substitution::PostReplacements
| Substitution::Callouts => {}
Substitution::Normal => {
result = substitute(&result, NORMAL, attributes);
}
Substitution::Verbatim => {
result = substitute(&result, VERBATIM, attributes);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::panic)]
fn explicit(spec: &SubstitutionSpec) -> &Vec<Substitution> {
match spec {
SubstitutionSpec::Explicit(subs) => subs,
SubstitutionSpec::Modifiers(_) => panic!("Expected Explicit, got Modifiers"),
}
}
#[allow(clippy::panic)]
fn modifiers(spec: &SubstitutionSpec) -> &Vec<SubstitutionOp> {
match spec {
SubstitutionSpec::Modifiers(ops) => ops,
SubstitutionSpec::Explicit(_) => panic!("Expected Modifiers, got Explicit"),
}
}
#[test]
fn test_parse_subs_none() {
let result = parse_subs_attribute("none");
assert!(explicit(&result).is_empty());
}
#[test]
fn test_parse_subs_empty_string() {
let result = parse_subs_attribute("");
assert!(explicit(&result).is_empty());
}
#[test]
fn test_parse_subs_none_with_whitespace() {
let result = parse_subs_attribute(" none ");
assert!(explicit(&result).is_empty());
}
#[test]
fn test_parse_subs_specialchars() {
let result = parse_subs_attribute("specialchars");
assert_eq!(explicit(&result), &vec![Substitution::SpecialChars]);
}
#[test]
fn test_parse_subs_specialchars_shorthand() {
let result = parse_subs_attribute("c");
assert_eq!(explicit(&result), &vec![Substitution::SpecialChars]);
}
#[test]
fn test_parse_subs_specialcharacters_alias() {
let result = parse_subs_attribute("specialcharacters");
assert_eq!(explicit(&result), &vec![Substitution::SpecialChars]);
}
#[test]
fn test_parse_subs_normal_expands() {
let result = parse_subs_attribute("normal");
assert_eq!(explicit(&result), &NORMAL.to_vec());
}
#[test]
fn test_parse_subs_verbatim_expands() {
let result = parse_subs_attribute("verbatim");
assert_eq!(explicit(&result), &VERBATIM.to_vec());
}
#[test]
fn test_parse_subs_append_modifier() {
let result = parse_subs_attribute("+quotes");
let ops = modifiers(&result);
assert_eq!(ops, &vec![SubstitutionOp::Append(Substitution::Quotes)]);
let resolved = result.resolve(VERBATIM);
assert!(resolved.contains(&Substitution::SpecialChars));
assert!(resolved.contains(&Substitution::Callouts));
assert!(resolved.contains(&Substitution::Quotes));
assert_eq!(resolved.last(), Some(&Substitution::Quotes));
}
#[test]
fn test_parse_subs_prepend_modifier() {
let result = parse_subs_attribute("quotes+");
let ops = modifiers(&result);
assert_eq!(ops, &vec![SubstitutionOp::Prepend(Substitution::Quotes)]);
let resolved = result.resolve(VERBATIM);
assert_eq!(resolved.first(), Some(&Substitution::Quotes));
assert!(resolved.contains(&Substitution::SpecialChars));
assert!(resolved.contains(&Substitution::Callouts));
}
#[test]
fn test_parse_subs_remove_modifier() {
let result = parse_subs_attribute("-specialchars");
let ops = modifiers(&result);
assert_eq!(
ops,
&vec![SubstitutionOp::Remove(Substitution::SpecialChars)]
);
let resolved = result.resolve(VERBATIM);
assert!(!resolved.contains(&Substitution::SpecialChars));
assert!(resolved.contains(&Substitution::Callouts));
}
#[test]
fn test_parse_subs_remove_all_verbatim() {
let result = parse_subs_attribute("-specialchars,-callouts");
let ops = modifiers(&result);
assert_eq!(ops.len(), 2);
let resolved = result.resolve(VERBATIM);
assert!(resolved.is_empty());
}
#[test]
fn test_parse_subs_combined_modifiers() {
let result = parse_subs_attribute("+quotes,-callouts");
let ops = modifiers(&result);
assert_eq!(ops.len(), 2);
let resolved = result.resolve(VERBATIM);
assert!(resolved.contains(&Substitution::SpecialChars)); assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::Callouts)); }
#[test]
fn test_parse_subs_ordering_preserved() {
let result = parse_subs_attribute("quotes,attributes,specialchars");
assert_eq!(
explicit(&result),
&vec![
Substitution::Quotes,
Substitution::Attributes,
Substitution::SpecialChars
]
);
}
#[test]
fn test_parse_subs_shorthand_list() {
let result = parse_subs_attribute("q,a,c");
assert_eq!(
explicit(&result),
&vec![
Substitution::Quotes,
Substitution::Attributes,
Substitution::SpecialChars
]
);
}
#[test]
fn test_parse_subs_with_spaces() {
let result = parse_subs_attribute(" quotes , attributes ");
assert_eq!(
explicit(&result),
&vec![Substitution::Quotes, Substitution::Attributes]
);
}
#[test]
fn test_parse_subs_duplicates_ignored() {
let result = parse_subs_attribute("quotes,quotes,quotes");
assert_eq!(explicit(&result), &vec![Substitution::Quotes]);
}
#[test]
fn test_parse_subs_normal_in_list_expands() {
let result = parse_subs_attribute("normal");
let subs = explicit(&result);
assert_eq!(subs.len(), NORMAL.len());
for sub in NORMAL {
assert!(subs.contains(sub));
}
}
#[test]
fn test_parse_subs_append_normal_group() {
let result = parse_subs_attribute("+normal");
let resolved = result.resolve(&[Substitution::Callouts]);
assert!(resolved.contains(&Substitution::Callouts));
for sub in NORMAL {
assert!(resolved.contains(sub));
}
}
#[test]
fn test_parse_subs_remove_normal_group() {
let result = parse_subs_attribute("-normal");
let resolved = result.resolve(NORMAL);
assert!(resolved.is_empty());
}
#[test]
fn test_parse_subs_unknown_is_skipped() {
let result = parse_subs_attribute("unknown");
assert!(explicit(&result).is_empty());
}
#[test]
fn test_parse_subs_unknown_mixed_with_valid() {
let result = parse_subs_attribute("quotes,typo,attributes");
assert_eq!(
explicit(&result),
&vec![Substitution::Quotes, Substitution::Attributes]
);
}
#[test]
fn test_parse_subs_all_individual_types() {
assert_eq!(
explicit(&parse_subs_attribute("attributes")),
&vec![Substitution::Attributes]
);
assert_eq!(
explicit(&parse_subs_attribute("replacements")),
&vec![Substitution::Replacements]
);
assert_eq!(
explicit(&parse_subs_attribute("macros")),
&vec![Substitution::Macros]
);
assert_eq!(
explicit(&parse_subs_attribute("post_replacements")),
&vec![Substitution::PostReplacements]
);
assert_eq!(
explicit(&parse_subs_attribute("quotes")),
&vec![Substitution::Quotes]
);
assert_eq!(
explicit(&parse_subs_attribute("callouts")),
&vec![Substitution::Callouts]
);
}
#[test]
fn test_parse_subs_shorthand_types() {
assert_eq!(
explicit(&parse_subs_attribute("a")),
&vec![Substitution::Attributes]
);
assert_eq!(
explicit(&parse_subs_attribute("r")),
&vec![Substitution::Replacements]
);
assert_eq!(
explicit(&parse_subs_attribute("m")),
&vec![Substitution::Macros]
);
assert_eq!(
explicit(&parse_subs_attribute("p")),
&vec![Substitution::PostReplacements]
);
assert_eq!(
explicit(&parse_subs_attribute("q")),
&vec![Substitution::Quotes]
);
assert_eq!(
explicit(&parse_subs_attribute("c")),
&vec![Substitution::SpecialChars]
);
}
#[test]
fn test_parse_subs_mixed_modifier_list() {
let result = parse_subs_attribute("specialchars,+quotes");
let ops = modifiers(&result);
assert_eq!(ops.len(), 2);
let resolved = result.resolve(VERBATIM);
assert!(resolved.contains(&Substitution::SpecialChars));
assert!(resolved.contains(&Substitution::Callouts)); assert!(resolved.contains(&Substitution::Quotes)); }
#[test]
fn test_parse_subs_modifier_in_middle() {
let result = parse_subs_attribute("attributes,+quotes,-callouts");
let ops = modifiers(&result);
assert_eq!(ops.len(), 3);
let resolved = result.resolve(VERBATIM);
assert!(resolved.contains(&Substitution::Attributes)); assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::Callouts)); }
#[test]
fn test_parse_subs_asciidoctor_example() {
let result = parse_subs_attribute("attributes+,+replacements,-callouts");
let ops = modifiers(&result);
assert_eq!(ops.len(), 3);
let resolved = result.resolve(VERBATIM);
assert_eq!(resolved.first(), Some(&Substitution::Attributes)); assert!(resolved.contains(&Substitution::Replacements)); assert!(!resolved.contains(&Substitution::Callouts)); }
#[test]
fn test_parse_subs_modifier_only_at_end() {
let result = parse_subs_attribute("quotes,-specialchars");
let ops = modifiers(&result);
assert_eq!(ops.len(), 2);
let resolved = result.resolve(VERBATIM);
assert!(resolved.contains(&Substitution::Quotes)); assert!(!resolved.contains(&Substitution::SpecialChars)); assert!(resolved.contains(&Substitution::Callouts)); }
#[test]
fn test_resolve_modifiers_with_normal_baseline() {
let result = parse_subs_attribute("-quotes");
let resolved = result.resolve(NORMAL);
assert!(resolved.contains(&Substitution::SpecialChars));
assert!(resolved.contains(&Substitution::Attributes));
assert!(!resolved.contains(&Substitution::Quotes)); assert!(resolved.contains(&Substitution::Replacements));
assert!(resolved.contains(&Substitution::Macros));
assert!(resolved.contains(&Substitution::PostReplacements));
}
#[test]
fn test_resolve_modifiers_with_verbatim_baseline() {
let result = parse_subs_attribute("-quotes");
let resolved = result.resolve(VERBATIM);
assert!(resolved.contains(&Substitution::SpecialChars));
assert!(resolved.contains(&Substitution::Callouts));
assert!(!resolved.contains(&Substitution::Quotes));
}
#[test]
fn test_resolve_explicit_ignores_baseline() {
let result = parse_subs_attribute("quotes,attributes");
let resolved_normal = result.resolve(NORMAL);
let resolved_verbatim = result.resolve(VERBATIM);
assert_eq!(resolved_normal, resolved_verbatim);
assert_eq!(
resolved_normal,
vec![Substitution::Quotes, Substitution::Attributes]
);
}
#[test]
fn test_resolve_attribute_references() {
let attribute_weight = AttributeValue::String(String::from("weight"));
let attribute_mass = AttributeValue::String(String::from("mass"));
let attribute_volume_repeat = String::from("value {attribute_volume}");
let mut attributes = DocumentAttributes::default();
attributes.insert("weight".into(), attribute_weight.clone());
attributes.insert("mass".into(), attribute_mass.clone());
let resolved = substitute("{weight}", HEADER, &attributes);
assert_eq!(resolved, "weight".to_string());
let resolved = substitute("{weight} {mass}", HEADER, &attributes);
assert_eq!(resolved, "weight mass".to_string());
let resolved = substitute("value {attribute_volume}", HEADER, &attributes);
assert_eq!(resolved, attribute_volume_repeat);
}
#[test]
fn test_substitute_single_pass_expansion() {
let mut attributes = DocumentAttributes::default();
attributes.insert("foo".into(), AttributeValue::String("{bar}".to_string()));
attributes.insert(
"bar".into(),
AttributeValue::String("should-not-appear".to_string()),
);
let resolved = substitute("{foo}", HEADER, &attributes);
assert_eq!(resolved, "{bar}");
}
#[test]
fn test_utf8_boundary_handling() {
let attributes = DocumentAttributes::default();
let values = [
":J::~\x01\x00\x00Ô",
"{attr}Ô{missing}日本語",
"{attrÔ}test",
];
for value in values {
let resolved = substitute(value, HEADER, &attributes);
assert_eq!(resolved, value);
}
}
#[test]
fn test_macros_disabled_explicit_without_macros() {
let spec = parse_subs_attribute("specialchars");
assert!(spec.macros_disabled());
}
#[test]
fn test_macros_disabled_explicit_with_macros() {
let spec = parse_subs_attribute("macros");
assert!(!spec.macros_disabled());
}
#[test]
fn test_macros_disabled_explicit_normal_includes_macros() {
let spec = parse_subs_attribute("normal");
assert!(!spec.macros_disabled());
}
#[test]
fn test_macros_disabled_modifier_remove() {
let spec = parse_subs_attribute("-macros");
assert!(spec.macros_disabled());
}
#[test]
fn test_macros_disabled_modifier_add() {
let spec = parse_subs_attribute("+macros");
assert!(!spec.macros_disabled());
}
#[test]
fn test_macros_disabled_explicit_none() {
let spec = parse_subs_attribute("none");
assert!(spec.macros_disabled());
}
#[test]
fn test_attributes_disabled_explicit_without_attributes() {
let spec = parse_subs_attribute("specialchars");
assert!(spec.attributes_disabled());
}
#[test]
fn test_attributes_disabled_explicit_with_attributes() {
let spec = parse_subs_attribute("attributes");
assert!(!spec.attributes_disabled());
}
#[test]
fn test_attributes_disabled_explicit_normal_includes_attributes() {
let spec = parse_subs_attribute("normal");
assert!(!spec.attributes_disabled());
}
#[test]
fn test_attributes_disabled_modifier_remove() {
let spec = parse_subs_attribute("-attributes");
assert!(spec.attributes_disabled());
}
#[test]
fn test_attributes_disabled_modifier_add() {
let spec = parse_subs_attribute("+attributes");
assert!(!spec.attributes_disabled());
}
#[test]
fn test_attributes_disabled_explicit_none() {
let spec = parse_subs_attribute("none");
assert!(spec.attributes_disabled());
}
}