Skip to main content

knf/interp/
mod.rs

1//! `${key.path}` and `${env:VAR}` resolution over the merged [`Value`].
2//!
3//! One pass over a merged document, replacing references in string values. Keys
4//! are never interpolated; values only.
5//!
6//! Two positions, and the distinction is the whole design:
7//!
8//! - **whole string** — `port = "${p}"` takes the referent's value *and type*,
9//!   so the output is a number. A reference to a container is allowed here, and
10//!   aliases the (fully resolved) subtree.
11//! - **embedded** — `url = "http://${host}:${p}/"` stringifies. A container has
12//!   no format-independent spelling there, so it is an error in v1.
13//!
14//! `$$` is a literal `$`. A `$` followed by anything but `$` or `{` is ordinary
15//! text.
16//!
17//! **No `std::env` here.** The environment is injected through [`Env`], so this
18//! module is deterministic and testable without touching process state. It is
19//! also what keeps the JSON-or-string typing rule out of resolution: the caller
20//! parses and hands over a [`Value`]. [`ProcessEnv`](crate::ProcessEnv) is the
21//! one implementation that reads the real environment.
22
23mod error;
24mod render;
25mod scan;
26
27use std::collections::HashMap;
28
29use crate::path::lookup;
30use crate::{Map, PathError, RefPath, Seg, Value};
31
32pub use error::{Cycle, InterpError, Problem};
33pub use scan::Syntax;
34
35use render::stringify;
36use scan::{Piece, Spelled, scan};
37
38/// The one namespace. Matched as a literal **prefix**, not by splitting on the
39/// first `:`, so `${a:b}` is the ordinary key `a:b` and `${db.host:port}` does
40/// not produce a baffling "unknown namespace `db.host`". The only unaddressable
41/// keys are those literally beginning `env:` — the same class of limitation the
42/// dotted-path flags already carry.
43const ENV: &str = "env:";
44
45/// An environment variable in both forms interpolation needs.
46///
47/// Two fields rather than one because the two positions want different things.
48/// Embedded, the variable is spliced as **raw text**: parsing it and rendering
49/// it back could only ever corrupt it. Whole-string, it is typed, by whatever
50/// rule the caller uses for its own inline values — which is the entire
51/// consistency argument for typing environment values at all.
52#[derive(Debug, Clone, PartialEq)]
53pub struct EnvValue {
54    /// Spliced verbatim into surrounding text.
55    pub raw: String,
56    /// Substituted whole, with its type, when the reference is the whole string.
57    pub typed: Value,
58}
59
60/// Where `${env:NAME}` reads from.
61///
62/// A trait rather than a direct `std::env::var` call so that resolution never
63/// touches process state; [`ProcessEnv`](crate::ProcessEnv) is the one
64/// implementation that does.
65pub trait Env {
66    /// The variable, or `None` if it is unset.
67    fn lookup(&self, name: &str) -> Option<EnvValue>;
68}
69
70/// Resolves every reference in `doc`.
71///
72/// By value because resolution builds a new tree rather than editing in place —
73/// a referent must be read in its pre-substitution form no matter which order
74/// the document is walked in.
75///
76/// Call it once, on the merged document, never per layer: a reference reads the
77/// document the caller is actually going to get. Overlays therefore interpolate
78/// like any other layer, and strict mode has already run — it compares the
79/// types values had when they were *written*, so a `"${port}"` was a string when
80/// it looked.
81///
82/// Every unresolved reference and every malformed one is collected, so a run
83/// reports all of them. A cycle is the exception and returns alone: there is
84/// nothing meaningful to continue past.
85pub fn interpolate(doc: Value, env: &dyn Env) -> Result<Value, InterpError> {
86    let mut resolver = Resolver {
87        doc: &doc,
88        env,
89        memo: HashMap::new(),
90        visiting: Vec::new(),
91        problems: Vec::new(),
92    };
93    // Resolving the root path resolves the document: the recursion is the same
94    // one references use, so transitivity and cycle detection come for free.
95    let resolved = resolver.resolve(&[]).map_err(InterpError::Cycle)?;
96    if resolver.problems.is_empty() {
97        Ok(resolved)
98    } else {
99        Err(InterpError::Problems(resolver.problems))
100    }
101}
102
103/// Memoized depth-first resolution, keyed on path.
104///
105/// One table buys three things at once: transitivity (a referent is resolved
106/// before it is spliced), order-independence (which key is reached first decides
107/// who does the work, never what the answer is), and — since `resolve_value`
108/// runs at most once per path — reporting each problem exactly once however many
109/// references point at it.
110struct Resolver<'a> {
111    doc: &'a Value,
112    env: &'a dyn Env,
113    memo: HashMap<Vec<Seg>, Value>,
114    /// The paths currently being resolved, innermost last. Doubles as the cycle
115    /// chain: the slice from a repeated path to the top *is* the loop.
116    visiting: Vec<Vec<Seg>>,
117    problems: Vec<Problem>,
118}
119
120impl<'a> Resolver<'a> {
121    /// Resolves the node at `path`, which the caller has established exists.
122    fn resolve(&mut self, path: &[Seg]) -> Result<Value, Cycle> {
123        if let Some(done) = self.memo.get(path) {
124            return Ok(done.clone());
125        }
126        if let Some(start) = self
127            .visiting
128            .iter()
129            .position(|seen| seen.as_slice() == path)
130        {
131            let mut chain = self.visiting[start..].to_vec();
132            chain.push(path.to_vec());
133            return Err(Cycle::new(chain));
134        }
135
136        // Copied out of `self` so the raw tree stays readable while `self` is
137        // borrowed mutably below.
138        let raw = lookup(self.doc, path).expect("resolve is only called on paths that exist");
139
140        self.visiting.push(path.to_vec());
141        let resolved = self.resolve_value(raw, path)?;
142        self.visiting.pop();
143
144        // The root is skipped: no reference can name it (a `RefPath` is never
145        // empty) and it is resolved exactly once, so caching it would only
146        // clone the whole document for nobody.
147        if !path.is_empty() {
148            self.memo.insert(path.to_vec(), resolved.clone());
149        }
150        Ok(resolved)
151    }
152
153    fn resolve_value(&mut self, raw: &'a Value, path: &[Seg]) -> Result<Value, Cycle> {
154        match raw {
155            Value::String(text) => self.resolve_string(text, path),
156            // Children resolve under their own paths and memoize there, which
157            // is what makes a whole-string container reference return a fully
158            // resolved subtree — the cost of allowing aliasing at all.
159            Value::Array(items) => {
160                let mut out = Vec::with_capacity(items.len());
161                for index in 0..items.len() {
162                    out.push(self.resolve(&child(path, Seg::Index(index)))?);
163                }
164                Ok(Value::Array(out))
165            }
166            Value::Object(map) => {
167                let mut out = Map::with_capacity(map.len());
168                for key in map.keys() {
169                    let value = self.resolve(&child(path, Seg::Key(key.clone())))?;
170                    out.insert(key.clone(), value);
171                }
172                Ok(Value::Object(out))
173            }
174            scalar => Ok(scalar.clone()),
175        }
176    }
177
178    fn resolve_string(&mut self, text: &str, path: &[Seg]) -> Result<Value, Cycle> {
179        let pieces = scan(text);
180
181        // No `$` anywhere — the common case, and the reason `scan` reports it
182        // as emptiness rather than a list of one literal.
183        if pieces.is_empty() {
184            return Ok(Value::String(text.to_string()));
185        }
186        if let [Piece::Ref(body)] = pieces.as_slice() {
187            return self.substitute(body, path);
188        }
189
190        let mut out = String::new();
191        for piece in pieces {
192            match piece {
193                Piece::Literal(literal) => out.push_str(literal),
194                Piece::Ref(body) => out.push_str(&self.splice(body, path)?),
195                Piece::Malformed { spelling, error } => {
196                    self.problems.push(Problem::Syntax {
197                        path: path.to_vec(),
198                        error,
199                    });
200                    out.push_str(spelling);
201                }
202            }
203        }
204        Ok(Value::String(out))
205    }
206
207    /// Whole-string position: the reference *is* the value, so it takes the
208    /// referent's type. Containers are allowed here.
209    fn substitute(&mut self, body: &str, path: &[Seg]) -> Result<Value, Cycle> {
210        if let Some(name) = body.strip_prefix(ENV) {
211            return Ok(match self.env_value(name, body, path) {
212                // Environment values are terminal: never re-scanned, so a
213                // variable holding `${x}` cannot reach back into the document.
214                Some(found) => found.typed,
215                None => Value::String(Spelled(body).to_string()),
216            });
217        }
218        match self.target(body, path) {
219            Some(target) => self.resolve(&target),
220            None => Ok(Value::String(Spelled(body).to_string())),
221        }
222    }
223
224    /// Embedded position: the reference joins surrounding text, so it renders.
225    fn splice(&mut self, body: &str, path: &[Seg]) -> Result<String, Cycle> {
226        if let Some(name) = body.strip_prefix(ENV) {
227            return Ok(match self.env_value(name, body, path) {
228                // Raw, not re-rendered: a variable is text already, and parsing
229                // it only to print it again could only lose something.
230                Some(found) => found.raw,
231                None => Spelled(body).to_string(),
232            });
233        }
234        let Some(target) = self.target(body, path) else {
235            return Ok(Spelled(body).to_string());
236        };
237        let value = self.resolve(&target)?;
238        Ok(match stringify(&value) {
239            Some(text) => text,
240            None => {
241                self.problems.push(Problem::NotStringifiable {
242                    path: path.to_vec(),
243                    reference: body.to_string(),
244                    kind: value.kind(),
245                });
246                Spelled(body).to_string()
247            }
248        })
249    }
250
251    /// The variable, recording a problem and returning `None` if the name is
252    /// empty or the variable is unset.
253    fn env_value(&mut self, name: &str, body: &str, path: &[Seg]) -> Option<EnvValue> {
254        if name.is_empty() {
255            self.problems.push(Problem::Syntax {
256                path: path.to_vec(),
257                error: Syntax::EmptyEnvName,
258            });
259            return None;
260        }
261        let found = self.env.lookup(name);
262        if found.is_none() {
263            self.problems.push(Problem::Unresolved {
264                path: path.to_vec(),
265                reference: body.to_string(),
266            });
267        }
268        found
269    }
270
271    /// The document path a reference names, recording a problem and returning
272    /// `None` if it is malformed or names nothing.
273    ///
274    /// A reference may *read* an array element — `${servers[0]}` parses through
275    /// the one `RefPath` spelling, where write-side callers run
276    /// `try_into_keys` to reject indices instead — and memoization,
277    /// cycle detection and the whole-string/embedded split all run on `Vec<Seg>`
278    /// already, so nothing downstream of this parse changes.
279    fn target(&mut self, body: &str, path: &[Seg]) -> Option<Vec<Seg>> {
280        let target: Vec<Seg> = match body.parse::<RefPath>() {
281            Ok(parsed) => parsed.into_segs(),
282            Err(PathError::BadIndex { .. }) => {
283                self.problems.push(Problem::Syntax {
284                    path: path.to_vec(),
285                    error: Syntax::BadIndex {
286                        body: body.to_string(),
287                    },
288                });
289                return None;
290            }
291            // `EmptyPath` is unreachable: the scanner rejects `${}` first.
292            Err(PathError::EmptySegment { .. } | PathError::EmptyPath) => {
293                self.problems.push(Problem::Syntax {
294                    path: path.to_vec(),
295                    error: Syntax::EmptySegment {
296                        body: body.to_string(),
297                    },
298                });
299                return None;
300            }
301            // Neither can come out of `FromStr`: a reference body has no `=`
302            // to miss, and index rejection lives in `try_into_keys`, which
303            // only write-side callers run.
304            Err(PathError::MissingEquals | PathError::IndexInKeyPath { .. }) => {
305                unreachable!("parsing a reference body never reports these")
306            }
307        };
308        if lookup(self.doc, &target).is_none() {
309            self.problems.push(Problem::Unresolved {
310                path: path.to_vec(),
311                reference: body.to_string(),
312            });
313            return None;
314        }
315        Some(target)
316    }
317}
318
319fn child(path: &[Seg], seg: Seg) -> Vec<Seg> {
320    let mut out = Vec::with_capacity(path.len() + 1);
321    out.extend_from_slice(path);
322    out.push(seg);
323    out
324}
325
326#[cfg(test)]
327mod tests;