Skip to main content

zenops_expand/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![warn(missing_docs)]
3
4//! String templates with `${name}` placeholders that have to be resolved
5//! before use.
6//!
7//! [`ExpandStr`] wraps a template string and refuses to be implicitly
8//! coerced to a `&str`: it does not implement [`Display`], `AsRef<str>`,
9//! or `Deref<Target = str>`. Callers go through
10//! [`expand_to_string`](ExpandStr::expand_to_string) or
11//! [`write_expanded`](ExpandStr::write_expanded) with an [`ExpandLookup`],
12//! and the missing trait impls mean the compiler complains the moment a
13//! raw template leaks into a `println!`, a path, or a shell command.
14//!
15//! Placeholders are exactly `${name}` — no other syntax, no escape
16//! sequence. An unresolved placeholder is an error; so is an unterminated
17//! `${`. The lookup side is pluggable: implement [`ExpandLookup`] yourself,
18//! reach for the [`HashMap`] / [`BTreeMap`] / `IndexMap` impls that ship
19//! with the crate, or chain several with `[&dyn ExpandLookup; N]` for an
20//! ordered fallback search.
21//!
22//! # Example
23//!
24//! ```
25//! use std::collections::HashMap;
26//! use zenops_expand::ExpandStr;
27//!
28//! let t = ExpandStr::new_static("hello, ${name}!");
29//!
30//! let mut lookup = HashMap::new();
31//! lookup.insert("name", "world");
32//!
33//! assert_eq!(t.expand_to_string(&lookup).unwrap(), "hello, world!");
34//! ```
35//!
36//! # Features
37//!
38//! - `indexmap` — [`ExpandLookup`] impl for [`indexmap::IndexMap`].
39//!
40//! [`Display`]: std::fmt::Display
41//! [`HashMap`]: std::collections::HashMap
42//! [`BTreeMap`]: std::collections::BTreeMap
43
44mod expand_lookup;
45
46use std::fmt;
47
48use serde::Deserialize;
49use smol_str::SmolStr;
50
51pub use expand_lookup::{ExpandLookup, ExpandLookupError};
52
53/// Error returned from expanding an [`ExpandStr`].
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55pub enum ExpandError {
56    /// A `${name}` placeholder was not resolved by the lookup.
57    #[error("unresolved placeholder `${{{0}}}`")]
58    Unresolved(SmolStr),
59    /// The [`fmt::Write`] sink returned an error.
60    #[error(transparent)]
61    WriteFmt(#[from] fmt::Error),
62    /// A `${` sequence was never closed by `}`.
63    #[error("unterminated `${{` in template")]
64    Unterminated,
65}
66
67/// A template string with `${name}` placeholders, awaiting expansion.
68///
69/// Construct with [`new`](Self::new) (or [`new_static`](Self::new_static)
70/// for a `'static` literal), then resolve against an [`ExpandLookup`]
71/// with [`expand_to_string`](Self::expand_to_string) or
72/// [`write_expanded`](Self::write_expanded). Deserialises transparently
73/// from a string, so an `ExpandStr` field in a serde config is just a
74/// plain string in TOML / JSON / YAML.
75///
76/// Construction performs no validation: a template with an unterminated
77/// `${` or an unresolved placeholder is held verbatim until expansion
78/// time and only then errors. The trade-off is that `new` / `new_static`
79/// are infallible — and [`new_static`](Self::new_static) is `const`, so an
80/// `ExpandStr` can live in a `const` or `static` binding.
81///
82/// # Example
83///
84/// ```
85/// use std::collections::HashMap;
86/// use zenops_expand::ExpandStr;
87///
88/// let path = ExpandStr::new_static("${home}/.config");
89///
90/// let mut env = HashMap::new();
91/// env.insert("home", "/home/ada");
92///
93/// assert_eq!(path.expand_to_string(&env).unwrap(), "/home/ada/.config");
94/// ```
95#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
96#[serde(transparent)]
97pub struct ExpandStr(SmolStr);
98
99#[cfg(feature = "schemars")]
100impl schemars::JsonSchema for ExpandStr {
101    fn schema_name() -> std::borrow::Cow<'static, str> {
102        "ExpandStr".into()
103    }
104
105    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
106        schemars::json_schema!({
107            "type": "string",
108            "description": "Template string containing `${name}` placeholders, expanded at apply time.",
109        })
110    }
111}
112
113impl ExpandStr {
114    /// Wrap a template string.
115    pub fn new(raw: SmolStr) -> Self {
116        Self(raw)
117    }
118
119    /// Wrap a `'static` template string without allocating.
120    ///
121    /// `const`, so suitable for `const` and `static` bindings.
122    pub const fn new_static(raw: &'static str) -> Self {
123        Self(SmolStr::new_static(raw))
124    }
125
126    /// Expand the template into a new [`String`].
127    ///
128    /// Each `${name}` is replaced with the value `lookup` writes for that
129    /// name. Literal characters pass through unchanged.
130    ///
131    /// # Example
132    ///
133    /// ```
134    /// use std::collections::HashMap;
135    /// use zenops_expand::ExpandStr;
136    ///
137    /// let t = ExpandStr::new_static("${greeting}, ${name}!");
138    ///
139    /// let mut lookup: HashMap<&str, &str> = HashMap::new();
140    /// lookup.insert("greeting", "hi");
141    /// lookup.insert("name", "Ada");
142    ///
143    /// assert_eq!(t.expand_to_string(&lookup).unwrap(), "hi, Ada!");
144    /// ```
145    pub fn expand_to_string(
146        &self,
147        lookup: &(impl ExpandLookup + ?Sized),
148    ) -> Result<String, ExpandError> {
149        let mut out = String::with_capacity(self.0.len() * 2);
150        self.write_expanded(lookup, &mut out)?;
151        Ok(out)
152    }
153
154    /// Expand the template into an existing [`fmt::Write`] sink.
155    ///
156    /// Equivalent to [`expand_to_string`] but writes into a caller-supplied
157    /// buffer, so multiple templates can be concatenated without
158    /// intermediate allocations. On error the sink may have been written
159    /// to partially.
160    ///
161    /// # Example
162    ///
163    /// ```
164    /// use std::collections::HashMap;
165    /// use std::fmt::Write;
166    /// use zenops_expand::ExpandStr;
167    ///
168    /// let mut lookup: HashMap<&str, &str> = HashMap::new();
169    /// lookup.insert("user", "ada");
170    ///
171    /// let mut out = String::from("path=");
172    /// let t = ExpandStr::new_static("/home/${user}");
173    /// t.write_expanded(&lookup, &mut out).unwrap();
174    /// write!(out, ";").unwrap();
175    ///
176    /// assert_eq!(out, "path=/home/ada;");
177    /// ```
178    ///
179    /// [`expand_to_string`]: ExpandStr::expand_to_string
180    pub fn write_expanded(
181        &self,
182        lookup: &(impl ExpandLookup + ?Sized),
183        f: &mut impl fmt::Write,
184    ) -> Result<(), ExpandError> {
185        let mut rest = self.0.as_str();
186        while let Some(start) = rest.find("${") {
187            f.write_str(&rest[..start])?;
188            let after_open = &rest[start + 2..];
189            let end = after_open.find('}').ok_or(ExpandError::Unterminated)?;
190            let name = &after_open[..end];
191            lookup.write_value(name, f)?;
192            rest = &after_open[end + 1..];
193        }
194        f.write_str(rest)?;
195        Ok(())
196    }
197
198    /// Get the raw template string.
199    pub fn as_template(&self) -> &str {
200        &self.0
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::collections::HashMap;
208
209    fn make_lookup(pairs: &[(&str, &str)]) -> HashMap<String, SmolStr> {
210        pairs
211            .iter()
212            .map(|(k, v)| ((*k).to_string(), SmolStr::new(*v)))
213            .collect()
214    }
215
216    #[test]
217    fn literal_passthrough() {
218        let s = ExpandStr::new_static("plain text");
219        assert_eq!(s.expand_to_string(&make_lookup(&[])).unwrap(), "plain text");
220    }
221
222    #[test]
223    fn resolves_single() {
224        let s = ExpandStr::new_static("hello ${name}!");
225        let m = make_lookup(&[("name", "world")]);
226        assert_eq!(s.expand_to_string(&m).unwrap(), "hello world!");
227    }
228
229    #[test]
230    fn resolves_adjacent_and_repeated() {
231        let s = ExpandStr::new_static("${a}${b}-${a}");
232        let m = make_lookup(&[("a", "X"), ("b", "Y")]);
233        assert_eq!(s.expand_to_string(&m).unwrap(), "XY-X");
234    }
235
236    #[test]
237    fn resolves_at_boundaries() {
238        let s = ExpandStr::new_static("${a}");
239        let m = make_lookup(&[("a", "A")]);
240        assert_eq!(s.expand_to_string(&m).unwrap(), "A");
241    }
242
243    #[test]
244    fn unresolved_key_errors() {
245        let s = ExpandStr::new_static("a ${missing} b");
246        let m = make_lookup(&[]);
247        assert_eq!(
248            s.expand_to_string(&m),
249            Err(ExpandError::Unresolved(SmolStr::new_static("missing"))),
250        );
251    }
252
253    #[test]
254    fn unterminated_errors() {
255        let s = ExpandStr::new_static("a ${oops");
256        let m = make_lookup(&[]);
257        assert_eq!(s.expand_to_string(&m), Err(ExpandError::Unterminated));
258    }
259
260    #[test]
261    fn deserializes_from_toml_string() {
262        #[derive(Deserialize)]
263        struct Holder {
264            v: ExpandStr,
265        }
266        let h: Holder = toml::from_str(r#"v = "x-${y}-z""#).unwrap();
267        assert_eq!(h.v.as_template(), "x-${y}-z");
268    }
269
270    #[test]
271    fn dyn_compatible() {
272        let a = make_lookup(&[("a", "A")]);
273        let b = make_lookup(&[("b", "B")]);
274
275        // &dyn ExpandLookup accepted directly.
276        let dyn_lookup: &dyn ExpandLookup = &a;
277        let s = ExpandStr::new_static("${a}");
278        assert_eq!(s.expand_to_string(dyn_lookup).unwrap(), "A");
279
280        // Heterogeneous chain via [&dyn ExpandLookup; N].
281        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
282        let s = ExpandStr::new_static("${a}/${b}");
283        assert_eq!(s.expand_to_string(&chain).unwrap(), "A/B");
284    }
285
286    #[test]
287    fn array_lookup_falls_through_and_propagates_unresolved() {
288        let primary = make_lookup(&[("a", "from-primary")]);
289        let fallback = make_lookup(&[("b", "from-fallback")]);
290        let chain = [&primary, &fallback];
291
292        let s = ExpandStr::new_static("${a}/${b}");
293        assert_eq!(
294            s.expand_to_string(&chain).unwrap(),
295            "from-primary/from-fallback"
296        );
297
298        // If nothing in the chain resolves, the final result must be
299        // Unresolved — not a silent empty expansion.
300        let s = ExpandStr::new_static("${missing}");
301        assert_eq!(
302            s.expand_to_string(&chain),
303            Err(ExpandError::Unresolved(SmolStr::new_static("missing"))),
304        );
305    }
306}