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}