acdc_parser/model/
substitution.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{AttributeValue, DocumentAttributes};
4
5/// A `Substitution` represents a substitution in a passthrough macro.
6#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8#[non_exhaustive]
9pub enum Substitution {
10    SpecialChars,
11    Attributes,
12    Replacements,
13    Macros,
14    PostReplacements,
15    Normal,
16    Verbatim,
17    Quotes,
18    Callouts,
19}
20
21impl From<&str> for Substitution {
22    fn from(value: &str) -> Self {
23        match value {
24            "attributes" | "a" => Substitution::Attributes,
25            "replacements" | "r" => Substitution::Replacements,
26            "macros" | "m" => Substitution::Macros,
27            "post_replacements" | "p" => Substitution::PostReplacements,
28            "normal" | "n" => Substitution::Normal,
29            "verbatim" | "v" => Substitution::Verbatim,
30            "quotes" | "q" => Substitution::Quotes,
31            "callouts" => Substitution::Callouts,
32            "specialchars" | "c" | "" => Substitution::SpecialChars, // Empty substitution list defaults to special chars
33            unknown => {
34                tracing::warn!(substitution = %unknown, "unknown substitution type, using SpecialChars as default");
35                Substitution::SpecialChars
36            }
37        }
38    }
39}
40
41#[allow(dead_code)]
42pub const BASIC: &[Substitution] = &[Substitution::SpecialChars];
43pub const HEADER: &[Substitution] = &[Substitution::SpecialChars, Substitution::Attributes];
44pub const NORMAL: &[Substitution] = &[
45    Substitution::SpecialChars,
46    Substitution::Attributes,
47    Substitution::Quotes,
48    Substitution::Replacements,
49    Substitution::Macros,
50    Substitution::PostReplacements,
51];
52#[allow(dead_code)]
53pub const REFTEXT: &[Substitution] = &[
54    Substitution::SpecialChars,
55    Substitution::Quotes,
56    Substitution::Replacements,
57];
58pub const VERBATIM: &[Substitution] = &[Substitution::SpecialChars, Substitution::Callouts];
59
60impl Substitute for &str {}
61impl Substitute for String {}
62
63pub(crate) trait Substitute: ToString {
64    fn substitute(
65        &self,
66        substitutions: &[Substitution],
67        attributes: &DocumentAttributes,
68    ) -> String {
69        let mut text = self.to_string();
70        for substitution in substitutions {
71            match substitution {
72                Substitution::SpecialChars => {
73                    text = Self::substitute_special_chars(&text);
74                }
75                Substitution::Attributes => {
76                    text = Self::substitute_attributes(&text, attributes);
77                }
78                Substitution::Quotes => {
79                    text = Self::substitute_quotes(&text);
80                }
81                Substitution::Replacements => {
82                    text = Self::substitute_replacements(&text);
83                }
84                Substitution::Macros => {
85                    text = Self::substitute_macros(&text);
86                }
87                Substitution::PostReplacements => {
88                    text = Self::substitute_post_replacements(&text);
89                }
90                Substitution::Callouts => {
91                    text = Self::substitute_callouts(&text);
92                }
93                // For the two below, should this be how I do it? 🤔 Not sure.
94                Substitution::Normal => {
95                    self.substitute(NORMAL, attributes);
96                }
97                Substitution::Verbatim => {
98                    self.substitute(VERBATIM, attributes);
99                }
100            }
101        }
102        text
103    }
104
105    #[must_use]
106    fn substitute_special_chars(text: &str) -> String {
107        text.to_string()
108    }
109
110    /**
111    Given a text and a set of attributes, resolve the attribute references in the text.
112
113    The attribute references are in the form of {name}.
114     */
115    #[must_use]
116    fn substitute_attributes(text: &str, attributes: &DocumentAttributes) -> String {
117        let mut result = String::with_capacity(text.len());
118        let mut chars = text.chars().peekable();
119
120        while let Some(ch) = chars.next() {
121            if ch == '{' {
122                // Collect characters until we find '}'
123                let mut attr_name = String::new();
124                let mut found_closing_brace = false;
125
126                while let Some(&next_ch) = chars.peek() {
127                    if next_ch == '}' {
128                        chars.next(); // consume the '}'
129                        found_closing_brace = true;
130                        break;
131                    }
132                    attr_name.push(next_ch);
133                    chars.next();
134                }
135
136                if found_closing_brace {
137                    match attributes.get(&attr_name) {
138                        Some(AttributeValue::Bool(true)) => {
139                            // Don't add anything for true boolean attributes
140                        }
141                        Some(AttributeValue::String(attr_value)) => {
142                            result.push_str(attr_value);
143                        }
144                        _ => {
145                            // If the attribute is not found, we return the attribute reference as is.
146                            result.push('{');
147                            result.push_str(&attr_name);
148                            result.push('}');
149                        }
150                    }
151                } else {
152                    // No closing brace found, push the opening brace and the collected chars
153                    result.push('{');
154                    result.push_str(&attr_name);
155                }
156            } else {
157                result.push(ch);
158            }
159        }
160
161        result
162    }
163
164    #[must_use]
165    fn substitute_quotes(text: &str) -> String {
166        text.to_string()
167    }
168
169    #[must_use]
170    fn substitute_replacements(text: &str) -> String {
171        text.to_string()
172    }
173
174    #[must_use]
175    fn substitute_macros(text: &str) -> String {
176        text.to_string()
177    }
178
179    #[must_use]
180    fn substitute_post_replacements(text: &str) -> String {
181        text.to_string()
182    }
183
184    #[must_use]
185    fn substitute_callouts(text: &str) -> String {
186        text.to_string()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_resolve_attribute_references() {
196        // These two are attributes we add to the attributes map.
197        let attribute_weight = AttributeValue::String(String::from("weight"));
198        let attribute_mass = AttributeValue::String(String::from("mass"));
199
200        // This one is an attribute we do NOT add to the attributes map so it can never be
201        // resolved.
202        let attribute_volume_repeat = String::from("value {attribute_volume}");
203
204        let mut attributes = DocumentAttributes::default();
205        attributes.insert("weight".into(), attribute_weight.clone());
206        attributes.insert("mass".into(), attribute_mass.clone());
207
208        // Resolve an attribute that is in the attributes map.
209        let value = "{weight}";
210        let resolved = value.substitute(HEADER, &attributes);
211        assert_eq!(resolved, "weight".to_string());
212
213        // Resolve two attributes that are in the attributes map.
214        let value = "{weight} {mass}";
215        let resolved = value.substitute(HEADER, &attributes);
216        assert_eq!(resolved, "weight mass".to_string());
217
218        // Resolve without attributes in the map
219        let value = "value {attribute_volume}";
220        let resolved = value.substitute(HEADER, &attributes);
221        assert_eq!(resolved, attribute_volume_repeat);
222    }
223
224    #[test]
225    fn test_utf8_boundary_handling() {
226        // Regression test for fuzzer-found bug: UTF-8 multi-byte characters
227        // should not cause panics during attribute substitution
228        let attributes = DocumentAttributes::default();
229
230        // Input with UTF-8 multi-byte character (Ô = 0xc3 0x94)
231        let value = ":J::~\x01\x00\x00Ô";
232        let resolved = value.substitute(HEADER, &attributes);
233        // Should not panic and preserve the input
234        assert_eq!(resolved, value);
235
236        // Test with various UTF-8 characters and attribute-like patterns
237        let value = "{attr}Ô{missing}日本語";
238        let resolved = value.substitute(HEADER, &attributes);
239        assert_eq!(resolved, "{attr}Ô{missing}日本語");
240
241        // Test with multi-byte chars inside attribute name
242        let value = "{attrÔ}test";
243        let resolved = value.substitute(HEADER, &attributes);
244        assert_eq!(resolved, "{attrÔ}test");
245    }
246}