simpleinterpolation/
lib.rs

1#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
2//! # `SimpleInterpolation`
3//!
4//! A dead simple interpolation format.
5//!
6//! for example: `this is an {interpolated} string`
7//!
8//! Variable names may have `-`, `_`, `0-9`, `a-z`, and `A-Z`, any other characters will be raised as errors.
9//!
10use std::{borrow::Cow, collections::HashMap, fmt::Formatter};
11
12/// The main entrypoint for this crate.
13/// Created with [`Interpolation::new`], this represents
14/// a template that can be supplied variables to render.
15#[derive(Clone, Debug, Hash, Eq, PartialEq)]
16pub struct Interpolation {
17    /// The first value is the raw value that will be appended
18    /// to the final string. The second value will go AFTER this string,
19    /// but it is dynamic
20    parts: Vec<(String, String)>,
21    /// The value which is placed after the otherwise rendered interpolation
22    end: String,
23}
24
25impl Interpolation {
26    const REASONABLE_INTERPOLATION_PREALLOC_BYTES: usize = 128;
27
28    /// Create a new [`Interpolation`].
29    /// # Errors
30    /// This function usually errors when there is a syntax error
31    /// in the interpolation compiler, e.g. an unclosed identifier or an invalid escape.
32    pub fn new(input: impl AsRef<str>) -> Result<Self, ParseError> {
33        InterpolationCompiler::compile(input.as_ref())
34    }
35
36    /// Create a new string with capacity to be reasonably rendered into.
37    fn output_string(&self) -> String {
38        String::with_capacity(
39            self.parts
40                .iter()
41                .map(|v| v.0.len() + Self::REASONABLE_INTERPOLATION_PREALLOC_BYTES)
42                .sum(),
43        )
44    }
45
46    /// Renders this template, using the `args` hashmap to fetch
47    /// interpolation values from. Said values *must* be strings.
48    /// If an interpolation value is not found, it is replaced with an empty string.
49    #[must_use]
50    pub fn render(&self, args: &HashMap<Cow<str>, Cow<str>>) -> String {
51        let mut output = self.output_string();
52        for (raw, interpolation_key) in &self.parts {
53            output.push_str(raw);
54            let interpolation_value = args.get(interpolation_key.as_str());
55            output.push_str(interpolation_value.unwrap_or(&Cow::Borrowed("")));
56        }
57        output.push_str(&self.end);
58        output
59    }
60
61    /// Renders this template, using the `args` hashmap to fetch
62    /// interpolation values from. Said values *must* be strings.
63    /// # Errors
64    /// If an interpolation value is not found, it is added to the [`RenderError`].
65    pub fn try_render(&self, args: &HashMap<Cow<str>, Cow<str>>) -> Result<String, RenderError> {
66        let mut output = self.output_string();
67        for (raw, interpolation_key) in &self.parts {
68            output.push_str(raw);
69            let Some(interpolation_value) = args.get(interpolation_key.as_str()) else {
70                return Err(RenderError::UnknownVariables(
71                    self.listify_unknown_args(args),
72                ));
73            };
74            output.push_str(interpolation_value);
75        }
76        output.push_str(&self.end);
77        Ok(output)
78    }
79
80    // this is the cold path. intentionally inefficient.
81    fn listify_unknown_args<T>(&self, args: &HashMap<Cow<str>, T>) -> Vec<&str> {
82        let mut output = Vec::with_capacity(args.len());
83        for (_, key) in &self.parts {
84            if !args.contains_key(key.as_str()) {
85                output.push(key.as_str());
86            }
87        }
88        output
89    }
90
91    /// Returns an iterator over all variables used in this interpolation.
92    /// Useful if you have a non hashmap item you wish to get items from.
93    pub fn variables_used(&self) -> impl Iterator<Item = &str> {
94        UsedVariablesIterator {
95            inner: self.parts.as_slice(),
96            current: 0,
97        }
98    }
99
100    // Rebuilds the value you put into the interpolation.
101    #[must_use]
102    pub fn input_value(&self) -> String {
103        fn push_escape(s: &mut String, txt: &str) {
104            for next in txt.chars() {
105                if next == '{' || next == '\\' {
106                    s.push('\\');
107                }
108                s.push(next);
109            }
110        }
111
112        let mut output = self.output_string();
113        for (text, key) in &self.parts {
114            push_escape(&mut output, text);
115            output.push('{');
116            output.push_str(key);
117            output.push('}');
118        }
119        push_escape(&mut output, &self.end);
120        output
121    }
122}
123
124struct UsedVariablesIterator<'a> {
125    inner: &'a [(String, String)],
126    current: usize,
127}
128
129impl<'a> Iterator for UsedVariablesIterator<'a> {
130    type Item = &'a str;
131
132    fn next(&mut self) -> Option<Self::Item> {
133        let next = self.inner.get(self.current)?.1.as_str();
134        self.current += 1;
135        Some(next)
136    }
137}
138
139struct InterpolationCompiler {
140    chars: Vec<char>,
141    parts: Vec<(String, String)>,
142    index: usize,
143    next: String,
144    escaped: bool,
145}
146
147impl InterpolationCompiler {
148    fn compile(input: &str) -> Result<Interpolation, ParseError> {
149        let mut compiler = Self {
150            chars: input.chars().collect(),
151            parts: Vec::new(),
152            index: 0,
153            next: String::new(),
154            escaped: false,
155        };
156
157        // for each character, check if the character exists, then
158        // feed it into the compiler
159        while let Some(character) = compiler.chars.get(compiler.index).copied() {
160            compiler.handle_char(character)?;
161        }
162
163        compiler.shrink();
164
165        Ok(Interpolation {
166            parts: compiler.parts,
167            end: compiler.next,
168        })
169    }
170
171    fn handle_char(&mut self, ch: char) -> Result<(), ParseError> {
172        if self.escaped && ch != '{' && ch != '\\' {
173            return Err(ParseError::InvalidEscape(ch, self.index));
174        } else if self.escaped {
175            self.next.push(ch);
176            self.escaped = false;
177        } else if ch == '\\' {
178            self.escaped = true;
179        } else if ch == '{' {
180            self.index += 1;
181            let mut ident = self.make_identifier()?;
182            let mut to_push = std::mem::take(&mut self.next);
183            ident.shrink_to_fit();
184            to_push.shrink_to_fit();
185            self.parts.push((to_push, ident));
186        } else {
187            self.next.push(ch);
188        }
189        self.index += 1;
190        Ok(())
191    }
192
193    #[inline]
194    const fn valid_ident_char(ch: char) -> bool {
195        matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-')
196    }
197
198    fn make_identifier(&mut self) -> Result<String, ParseError> {
199        let mut identifier = String::new();
200        let start = self.index;
201        loop {
202            let identifier_part = self
203                .chars
204                .get(self.index)
205                .copied()
206                .ok_or(ParseError::UnclosedIdentifier(start))?;
207            if identifier_part == '}' {
208                break;
209            }
210            if !Self::valid_ident_char(identifier_part) {
211                return Err(ParseError::InvalidCharInIdentifier(
212                    identifier_part,
213                    self.index,
214                ));
215            }
216            identifier.push(identifier_part);
217            self.index += 1;
218        }
219        Ok(identifier)
220    }
221
222    fn shrink(&mut self) {
223        self.parts.shrink_to_fit();
224
225        for (a, b) in &mut self.parts {
226            a.shrink_to_fit();
227            b.shrink_to_fit();
228        }
229
230        self.next.shrink_to_fit();
231    }
232}
233
234/// Error returned in the parsing stage.
235#[derive(Debug, PartialEq, Eq)]
236pub enum ParseError {
237    /// Unclosed identifier found at a specific spot.
238    UnclosedIdentifier(usize),
239    /// Invalid char (.0) in identifier, located at .1
240    InvalidCharInIdentifier(char, usize),
241    /// Invalid value (.0) escaped at usize (.1)
242    InvalidEscape(char, usize),
243}
244
245impl std::fmt::Display for ParseError {
246    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
247        match self {
248            Self::UnclosedIdentifier(at) => {
249                write!(f, "Unclosed identifier (mismatched pair at {})", at + 1)
250            }
251            Self::InvalidCharInIdentifier(c, at) => {
252                write!(f, "Invalid character `{c:?}` in identifier at {}", at + 1)
253            }
254            Self::InvalidEscape(c, at) => {
255                write!(
256                    f,
257                    "`{c:?}` at position {} cannot be escaped, only `{{` and `\\` can",
258                    at + 1
259                )
260            }
261        }
262    }
263}
264
265impl std::error::Error for ParseError {}
266
267/// Errors returned by the [`Interpolation::try_render`] function.
268#[derive(Debug, PartialEq, Eq)]
269pub enum RenderError<'a> {
270    /// Unknown variables used in the interpolation. Contains a list of them.
271    UnknownVariables(Vec<&'a str>),
272}
273
274impl std::fmt::Display for RenderError<'_> {
275    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
276        match self {
277            Self::UnknownVariables(vars) => {
278                write!(f, "Unknown variables used: ")?;
279                for (idx, item) in vars.iter().enumerate() {
280                    if idx == vars.len() - 1 {
281                        write!(f, "{item}")?;
282                    } else {
283                        write!(f, "{item}, ")?;
284                    }
285                }
286                Ok(())
287            }
288        }
289    }
290}
291
292impl std::error::Error for RenderError<'_> {}
293
294#[cfg(test)]
295mod tests {
296    #![allow(clippy::literal_string_with_formatting_args)]
297    use std::collections::HashMap;
298
299    use super::*;
300
301    fn get_example_args() -> HashMap<Cow<'static, str>, Cow<'static, str>> {
302        let mut hm = HashMap::new();
303        hm.insert(
304            Cow::Borrowed("interpolation"),
305            Cow::Borrowed("Interpolation"),
306        );
307        hm.insert(Cow::Borrowed("unused"), Cow::Borrowed("ERROR"));
308        hm
309    }
310    #[test]
311    fn basic() {
312        let interpolation =
313            Interpolation::new("This is an example string for {interpolation}!").unwrap();
314        println!("{interpolation:?}");
315        let rendered = interpolation.render(&get_example_args());
316        assert_eq!("This is an example string for Interpolation!", rendered);
317    }
318    #[test]
319    fn escapes() {
320        let initial = "This is an example string for \\{interpolation} escapes!";
321        let target = "This is an example string for {interpolation} escapes!";
322        let interpolation = Interpolation::new(initial).unwrap();
323        println!("{interpolation:?}");
324        assert_eq!(target, interpolation.render(&HashMap::new()));
325    }
326    #[test]
327    fn recursive_escapes() {
328        let initial = "This is an example string for \\\\{interpolation} recursive escapes!";
329        let target = "This is an example string for \\Interpolation recursive escapes!";
330        let interpolation = Interpolation::new(initial).unwrap();
331        println!("{interpolation:?}");
332        assert_eq!(target, interpolation.render(&get_example_args()));
333    }
334    #[test]
335    fn variables_are_right() {
336        let interpolation =
337            Interpolation::new("This is an example string for {interpolation} variable listing!")
338                .unwrap();
339        println!("{interpolation:?}");
340        assert_eq!(
341            interpolation.variables_used().collect::<Vec<&str>>(),
342            vec!["interpolation"]
343        );
344    }
345    #[test]
346    fn basic_roundtrip() {
347        let roundtrip = "This is an example string for {interpolation}!";
348        let interpolation = Interpolation::new(roundtrip).unwrap();
349        println!("{interpolation:?}");
350        assert_eq!(roundtrip, interpolation.input_value());
351    }
352    #[test]
353    fn escapes_roundtrip() {
354        let roundtrip = "This is an example string for \\{interpolation} escapes!";
355        let interpolation = Interpolation::new(roundtrip).unwrap();
356        println!("{interpolation:?}");
357        assert_eq!(roundtrip, interpolation.input_value());
358    }
359    #[test]
360    fn recursive_escapes_roundtrip() {
361        let roundtrip = "This is an example string for \\\\{interpolation} recursive escapes!";
362        let interpolation = Interpolation::new(roundtrip).unwrap();
363        println!("{interpolation:?}");
364        assert_eq!(roundtrip, interpolation.input_value());
365    }
366    #[test]
367    fn no_interpolation() {
368        let unchanged = "This is an example string for a lack of interpolation!";
369        let interpolation = Interpolation::new(unchanged).unwrap();
370        println!("{interpolation:?}");
371        assert_eq!(unchanged, interpolation.render(&HashMap::new()));
372    }
373    #[test]
374    fn error_nonexistents_found() {
375        let one_interp = "{nonexistent}";
376        let interpolation = Interpolation::new(one_interp).unwrap();
377        println!("{interpolation:?}");
378        assert_eq!(
379            Err(RenderError::UnknownVariables(vec!["nonexistent"])),
380            interpolation.try_render(&HashMap::new())
381        );
382    }
383    #[test]
384    fn error_nonexistents_found_2() {
385        let one_interp = "{nonexistent} {nonexistent2}";
386        let interpolation = Interpolation::new(one_interp).unwrap();
387        println!("{interpolation:?}");
388        assert_eq!(
389            Err(RenderError::UnknownVariables(vec![
390                "nonexistent",
391                "nonexistent2"
392            ])),
393            interpolation.try_render(&HashMap::new())
394        );
395    }
396    #[test]
397    fn error_bad_ident() {
398        let bad_template = "{a)";
399        let interpolation = Interpolation::new(bad_template);
400        assert_eq!(
401            interpolation,
402            Err(ParseError::InvalidCharInIdentifier(')', 2))
403        );
404    }
405    #[test]
406    fn error_unclosed() {
407        let bad_template = "{a";
408        let interpolation = Interpolation::new(bad_template);
409        assert_eq!(interpolation, Err(ParseError::UnclosedIdentifier(1)));
410    }
411    #[test]
412    fn error_bad_escape() {
413        let bad_template = "\\a";
414        let interpolation = Interpolation::new(bad_template);
415        assert_eq!(interpolation, Err(ParseError::InvalidEscape('a', 1)));
416    }
417}