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;