Skip to main content

zenops_expand/
expand_lookup.rs

1use std::fmt;
2
3/// Resolves `${name}` placeholders to their values.
4///
5/// The lookup side of [`ExpandStr`](crate::ExpandStr). An implementation
6/// is handed a name plus a sink and either writes the value or signals
7/// the name isn't known. The common implementations are pre-shipped (see
8/// the foreign-type impls below); writing a custom one is a single
9/// method.
10///
11/// # Implementing the contract
12///
13/// `write_value` has a strict contract: on a miss, return
14/// [`ExpandLookupError::Unresolved`] and write **nothing** to `f`. Empty
15/// writes matter because lookups can be chained — the `[&dyn ExpandLookup; N]`
16/// impl walks entries in order, and bytes written by a missing entry
17/// would leak into the final expansion before the next entry got a
18/// chance to resolve the name.
19pub trait ExpandLookup {
20    /// Write the value of `name` into `f`, or return
21    /// [`ExpandLookupError::Unresolved`] without writing anything.
22    ///
23    /// See [the trait-level contract](ExpandLookup#implementing-the-contract):
24    /// on a miss, no bytes may reach `f`.
25    fn write_value<'a>(
26        &self,
27        name: &'a str,
28        f: &mut dyn fmt::Write,
29    ) -> Result<(), ExpandLookupError<'a>>;
30}
31
32/// Try each lookup in order; return the first hit.
33///
34/// The fallback pattern: combine a user-supplied lookup with a default
35/// lookup (or a chain of them) without copying values around. Search
36/// stops at the first entry that resolves the name.
37///
38/// # Example
39///
40/// ```
41/// use std::collections::HashMap;
42/// use zenops_expand::{ExpandLookup, ExpandStr};
43///
44/// let mut overrides = HashMap::new();
45/// overrides.insert("name", "Ada");
46///
47/// let mut defaults = HashMap::new();
48/// defaults.insert("name", "anon");
49/// defaults.insert("greeting", "hello");
50///
51/// let chain: [&dyn ExpandLookup; 2] = [&overrides, &defaults];
52/// let t = ExpandStr::new_static("${greeting}, ${name}!");
53///
54/// assert_eq!(t.expand_to_string(&chain).unwrap(), "hello, Ada!");
55/// ```
56impl<const SIZE: usize, T: ExpandLookup + ?Sized> ExpandLookup for [&T; SIZE] {
57    fn write_value<'a>(
58        &self,
59        name: &'a str,
60        f: &mut dyn fmt::Write,
61    ) -> Result<(), ExpandLookupError<'a>> {
62        for expander in self {
63            match expander.write_value(name, f) {
64                Ok(()) => return Ok(()),
65                Err(ExpandLookupError::Unresolved(_)) => continue,
66                Err(ExpandLookupError::WriteFmt(e)) => return Err(ExpandLookupError::WriteFmt(e)),
67            }
68        }
69        Err(ExpandLookupError::Unresolved(name))
70    }
71}
72
73/// Looks `name` up as a string-borrowable key. Available with the
74/// `indexmap` feature.
75#[cfg(feature = "indexmap")]
76impl<K, V> ExpandLookup for indexmap::IndexMap<K, V>
77where
78    K: std::borrow::Borrow<str>,
79    V: AsRef<str>,
80{
81    fn write_value<'a>(
82        &self,
83        name: &'a str,
84        f: &mut dyn fmt::Write,
85    ) -> Result<(), ExpandLookupError<'a>> {
86        if let Some(value) = self.get(name) {
87            f.write_str(value.as_ref())?;
88            Ok(())
89        } else {
90            Err(ExpandLookupError::Unresolved(name))
91        }
92    }
93}
94
95/// Looks `name` up as a string-borrowable key.
96impl<K, V> ExpandLookup for std::collections::BTreeMap<K, V>
97where
98    K: Ord + std::borrow::Borrow<str>,
99    V: AsRef<str>,
100{
101    fn write_value<'a>(
102        &self,
103        name: &'a str,
104        f: &mut dyn fmt::Write,
105    ) -> Result<(), ExpandLookupError<'a>> {
106        if let Some(value) = self.get(name) {
107            f.write_str(value.as_ref())?;
108            Ok(())
109        } else {
110            Err(ExpandLookupError::Unresolved(name))
111        }
112    }
113}
114
115/// Looks `name` up as a string-borrowable key.
116impl<K, V> ExpandLookup for std::collections::HashMap<K, V>
117where
118    K: Eq + std::hash::Hash + std::borrow::Borrow<str>,
119    V: AsRef<str>,
120{
121    fn write_value<'a>(
122        &self,
123        name: &'a str,
124        f: &mut dyn fmt::Write,
125    ) -> Result<(), ExpandLookupError<'a>> {
126        if let Some(value) = self.get(name) {
127            f.write_str(value.as_ref())?;
128            Ok(())
129        } else {
130            Err(ExpandLookupError::Unresolved(name))
131        }
132    }
133}
134
135/// Error returned from [`ExpandLookup::write_value`].
136///
137/// The `'a` lifetime is borrowed from the unresolved-name slice passed
138/// into the call. Unresolved misses are common inside chained lookups,
139/// so the error avoids allocating; the converting `From` impl into
140/// [`ExpandError`](crate::ExpandError) is where the name finally gets
141/// copied if the miss escapes.
142#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
143pub enum ExpandLookupError<'a> {
144    /// `name` is not known to this lookup.
145    #[error("Failed to resolve `${{{0}}}`")]
146    Unresolved(&'a str),
147    /// The [`fmt::Write`] sink returned an error.
148    #[error(transparent)]
149    WriteFmt(#[from] fmt::Error),
150}
151
152impl<'a> From<ExpandLookupError<'a>> for crate::ExpandError {
153    fn from(value: ExpandLookupError<'a>) -> Self {
154        match value {
155            ExpandLookupError::Unresolved(name) => crate::ExpandError::Unresolved(name.into()),
156            ExpandLookupError::WriteFmt(e) => crate::ExpandError::WriteFmt(e),
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn write_to_string<L: ExpandLookup>(lookup: &L, key: &str) -> Result<String, String> {
166        let mut buf = String::new();
167        lookup
168            .write_value(key, &mut buf)
169            .map(|()| buf)
170            .map_err(|e| e.to_string())
171    }
172
173    #[test]
174    fn btreemap_writes_value_for_known_key() {
175        let mut m = std::collections::BTreeMap::new();
176        m.insert("name", "alice");
177        assert_eq!(write_to_string(&m, "name").unwrap(), "alice");
178    }
179
180    #[test]
181    fn btreemap_returns_unresolved_for_missing_key() {
182        let m: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
183        let mut buf = String::new();
184        let err = m.write_value("missing", &mut buf).unwrap_err();
185        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
186        assert!(buf.is_empty(), "lookup must not write on miss");
187    }
188
189    #[test]
190    fn hashmap_writes_value_for_known_key() {
191        let mut m = std::collections::HashMap::new();
192        m.insert("name", "alice");
193        assert_eq!(write_to_string(&m, "name").unwrap(), "alice");
194    }
195
196    #[test]
197    fn hashmap_returns_unresolved_for_missing_key() {
198        let m: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
199        let mut buf = String::new();
200        let err = m.write_value("missing", &mut buf).unwrap_err();
201        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
202        assert!(buf.is_empty());
203    }
204
205    #[cfg(feature = "indexmap")]
206    #[test]
207    fn indexmap_writes_value_for_known_key() {
208        let mut m = indexmap::IndexMap::new();
209        m.insert("name", "alice");
210        assert_eq!(write_to_string(&m, "name").unwrap(), "alice");
211    }
212
213    #[cfg(feature = "indexmap")]
214    #[test]
215    fn indexmap_returns_unresolved_for_missing_key() {
216        let m: indexmap::IndexMap<&str, &str> = indexmap::IndexMap::new();
217        let mut buf = String::new();
218        let err = m.write_value("missing", &mut buf).unwrap_err();
219        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
220        assert!(buf.is_empty());
221    }
222
223    #[test]
224    fn array_chain_returns_first_hit() {
225        let mut a = std::collections::BTreeMap::new();
226        a.insert("k", "from_a");
227        let mut b = std::collections::BTreeMap::new();
228        b.insert("k", "from_b");
229
230        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
231        let mut buf = String::new();
232        chain.write_value("k", &mut buf).unwrap();
233        assert_eq!(buf, "from_a");
234    }
235
236    #[test]
237    fn array_chain_falls_through_to_second_lookup() {
238        let a: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
239        let mut b = std::collections::BTreeMap::new();
240        b.insert("k", "from_b");
241
242        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
243        let mut buf = String::new();
244        chain.write_value("k", &mut buf).unwrap();
245        assert_eq!(buf, "from_b");
246    }
247
248    #[test]
249    fn array_chain_returns_unresolved_when_no_lookup_has_key() {
250        let a: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
251        let b: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
252
253        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
254        let mut buf = String::new();
255        let err = chain.write_value("missing", &mut buf).unwrap_err();
256        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
257    }
258}