simpleinterpolation/
lib.rs

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