Skip to main content

polydat_core/kernel/
interp.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `{name}`-style template interpolation against a Polydat Kernel.
5//!
6//! Surface #5 home (per `polydat/docs/design/comprehension_cutover_contact_surfaces.md`).
7//! Previously lived in `polydat::iteration::comprehension::eval`; relocated
8//! to the kernel module because the operation is general
9//! GK-kernel facility, not a comprehension concern. The
10//! comprehension runtime uses it; synthesisers use it; the
11//! executor uses it; but it doesn't depend on comprehension AST
12//! shape.
13//!
14//! ## Functions
15//!
16//! - [`interpolate_via_kernel`] — looks up `{name}` placeholders
17//!   against the kernel's chain-aware bindings via
18//!   [`PolydatKernel::lookup`].
19//! - [`interpolate_with_lookup`] — the generic engine; the
20//!   `lookup` closure decides where each leaf's value comes
21//!   from. Used by callers that compose their own lookup over
22//!   the kernel plus workload params plus synthesis-time
23//!   probes.
24//! - [`collect_string_interp_refs`] — extracts the placeholder
25//!   names from a text without doing substitution.
26//!
27//! ## Semantics
28//!
29//! Iterative leaf-placeholder substitution with escape handling
30//! and a round cap:
31//!
32//! - **Leaf**: `{name}` whose body contains no further `{`. The
33//!   dynamic form `{a_{b}_c}` is resolved by first substituting
34//!   `{b}`, then re-scanning for the resulting `{a_<b-value>_c}`
35//!   as a leaf.
36//! - **Escape**: `\{` and `\}` pass through as literal `{` /
37//!   `}` and are removed from the final string.
38//! - **Round cap**: if substitution doesn't stabilize in
39//!   `ROUND_HARD` iterations, returns Err (the input had
40//!   cyclic placeholders).
41//! - **Unresolved name**: any `{name}` that survives the
42//!   substitution rounds errors with a diagnostic naming the
43//!   missing binding.
44
45use std::collections::HashSet;
46
47use crate::ast::Value;
48use crate::kernel::PolydatKernel;
49
50/// Name resolution for comprehension sources and predicates: what a
51/// `{name}` placeholder or a bare identifier reads. The interpreter
52/// kernel is one; a [`Layered`] view puts a tuple's bindings in front of
53/// another, so opening a traversal needs no kernel of the engine that
54/// opens it (engine parity, step 8).
55pub trait Lookup {
56    /// The value `name` denotes here, if any.
57    fn lookup(&self, name: &str) -> Option<Value>;
58
59    /// The compile ledger of the program tree this scope belongs to:
60    /// what a source or predicate that has to compile is charged to.
61    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger>;
62}
63
64impl Lookup for PolydatKernel {
65    fn lookup(&self, name: &str) -> Option<Value> {
66        PolydatKernel::lookup(self, name)
67    }
68    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
69        self.program().ledger()
70    }
71}
72
73/// Bindings in front of another lookup: a tuple's elements over the
74/// scope they were drawn in.
75pub struct Layered<'a> {
76    /// The bindings consulted first, in order.
77    pub prefix: &'a [(String, Value)],
78    /// Where every other name resolves.
79    pub inner: &'a dyn Lookup,
80}
81
82impl Lookup for Layered<'_> {
83    fn lookup(&self, name: &str) -> Option<Value> {
84        if let Some((_, v)) = self.prefix.iter().find(|(n, _)| n == name) {
85            return Some(v.clone());
86        }
87        self.inner.lookup(name)
88    }
89    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
90        self.inner.ledger()
91    }
92}
93
94/// Round count at which we warn about possible cycles in the
95/// substitution stream.
96const ROUND_WARN: usize = 100;
97
98/// Hard round-count limit. Errors out if substitution doesn't
99/// stabilize in this many iterations.
100const ROUND_HARD: usize = 1000;
101
102/// Interpolate `{name}` placeholders against `kernel`.
103///
104/// `{name}` resolves to `kernel.lookup(name).map(|v| v.to_display_string())`.
105/// `Value::None` (an unset extern slot) doesn't match — falls
106/// through to the unresolved-name error path at the fixed
107/// point.
108///
109/// Returns a typed [`crate::dsl::compile::EmbeddingError`] per
110/// E7 of the spec; the underlying string-form
111/// [`interpolate_with_lookup`] is kept for callers that
112/// compose their own lookup and don't want the
113/// typed-error overhead.
114pub fn interpolate_via_kernel(
115    text: &str,
116    kernel: &dyn Lookup,
117) -> Result<String, crate::dsl::compile::EmbeddingError> {
118    interpolate_with_lookup(text, |name| {
119        kernel.lookup(name).map(|v| v.to_display_string())
120    })
121    .map_err(|msg| classify_interpolate_error(text, msg))
122}
123
124fn classify_interpolate_error(text: &str, msg: String) -> crate::dsl::compile::EmbeddingError {
125    // "interpolation: unresolved placeholder '{name}' in '...'"
126    if let Some(rest) = msg.strip_prefix("interpolation: unresolved placeholder '{")
127        && let Some(end) = rest.find('}')
128    {
129        let name = rest[..end].to_string();
130        return crate::dsl::compile::EmbeddingError::UnresolvedPlaceholder {
131            name,
132            source: text.to_string(),
133        };
134    }
135    // Cyclic placeholder fall-through: classify as Parse since
136    // the text didn't stabilise.
137    crate::dsl::compile::EmbeddingError::Parse {
138        source: text.to_string(),
139        message: msg,
140        position: None,
141    }
142}
143
144/// Iterative leaf-placeholder substitution with escape handling,
145/// round cap, and final unresolved-name check. The `lookup`
146/// closure decides where each leaf's value comes from.
147///
148/// Public so callers like the synthesis-time clause probe can
149/// compose their own lookup (parent kernel + workload params +
150/// clause probes) without reimplementing the iterative loop.
151pub fn interpolate_with_lookup<F>(text: &str, lookup: F) -> Result<String, String>
152where
153    F: Fn(&str) -> Option<String>,
154{
155    let mut s = text.to_string();
156    let mut warned = false;
157    for round in 1..=ROUND_HARD {
158        if round == ROUND_WARN && !warned {
159            eprintln!(
160                "interpolation: '{text}' has run {ROUND_WARN} substitution rounds — likely cyclic"
161            );
162            warned = true;
163        }
164        let progress = one_pass(&mut s, &lookup)?;
165        if !progress {
166            break;
167        }
168        if round == ROUND_HARD {
169            return Err(format!(
170                "interpolation: '{text}' did not stabilize in {ROUND_HARD} rounds — \
171                 cyclic placeholders?"
172            ));
173        }
174    }
175    if let Some(unresolved) = first_unresolved(&s) {
176        return Err(format!(
177            "interpolation: unresolved placeholder '{{{unresolved}}}' in '{text}' — \
178             not bound by any outer for_each var or workload param. \
179             Use \\{{ \\}} to write literal braces."
180        ));
181    }
182    Ok(unescape(&s))
183}
184
185/// Extract every leaf `{name}` placeholder mentioned inside
186/// string-literal contexts in `src` into `refs`.
187///
188/// Used by the synthesiser to discover names the body
189/// references via `{name}` interpolation that don't appear as
190/// bare identifiers in the Polydat source. The detection is
191/// quote-aware: leading non-identifier chars (`'`, `"`) skip
192/// the placeholder, matching the binding compiler's
193/// `string_lit_has_real_placeholder` disambiguation.
194pub fn collect_string_interp_refs(src: &str, refs: &mut HashSet<String>) {
195    let chars: Vec<char> = src.chars().collect();
196    let mut i = 0;
197    let mut in_str: Option<char> = None;
198    while i < chars.len() {
199        let c = chars[i];
200        match in_str {
201            Some(quote) if c == quote => {
202                in_str = None;
203                i += 1;
204            }
205            Some(_) if c == '\\' && i + 1 < chars.len() => {
206                i += 2;
207            }
208            Some(_) if c == '{' => {
209                let body_start = i + 1;
210                let mut body_end = body_start;
211                while body_end < chars.len() && chars[body_end] != '}' {
212                    body_end += 1;
213                }
214                let body: String = chars[body_start..body_end].iter().collect();
215                let trimmed = body.trim();
216                if !trimmed.is_empty()
217                    && !trimmed.starts_with('\'')
218                    && !trimmed.starts_with('"')
219                    && trimmed
220                        .bytes()
221                        .all(|b| b.is_ascii_alphanumeric() || b == b'_')
222                    && !trimmed.bytes().next().unwrap().is_ascii_digit()
223                {
224                    refs.insert(trimmed.to_string());
225                }
226                i = body_end + 1;
227            }
228            Some(_) => {
229                i += 1;
230            }
231            None if c == '"' || c == '\'' => {
232                in_str = Some(c);
233                i += 1;
234            }
235            None => {
236                i += 1;
237            }
238        }
239    }
240}
241
242/// One sweep over `s`: replaces every **leaf** placeholder
243/// (`{NAME}` whose body contains no `{` or `}`) with its
244/// resolved value via the supplied `lookup` closure. Returns
245/// `Ok(true)` if any replacement happened, `Ok(false)` if the
246/// pass was a no-op (fixed point reached).
247fn one_pass<F>(s: &mut String, lookup: &F) -> Result<bool, String>
248where
249    F: Fn(&str) -> Option<String>,
250{
251    let bytes = s.as_bytes();
252    let n = bytes.len();
253    let mut out = String::with_capacity(n);
254    let mut i = 0;
255    let mut replaced_any = false;
256
257    while i < n {
258        let c = bytes[i];
259        if c == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
260            out.push('\\');
261            out.push(bytes[i + 1] as char);
262            i += 2;
263            continue;
264        }
265        if c == b'{' {
266            let mut j = i + 1;
267            let mut has_inner_open = false;
268            let mut end: Option<usize> = None;
269            while j < n {
270                let cj = bytes[j];
271                if cj == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}') {
272                    j += 2;
273                    continue;
274                }
275                if cj == b'{' {
276                    has_inner_open = true;
277                    break;
278                }
279                if cj == b'}' {
280                    end = Some(j);
281                    break;
282                }
283                j += 1;
284            }
285            if has_inner_open {
286                out.push('{');
287                i += 1;
288                continue;
289            }
290            let Some(end_idx) = end else {
291                return Err(format!(
292                    "interpolation: unmatched '{{' in '{s}' starting at byte {i} — \
293                     write \\{{ for a literal opening brace"
294                ));
295            };
296            let name = std::str::from_utf8(&bytes[i + 1..end_idx])
297                .map_err(|e| format!("interpolation: non-utf8 placeholder in '{s}': {e}"))?
298                .to_string();
299            if name.is_empty() {
300                return Err(format!(
301                    "interpolation: empty placeholder '{{}}' in '{s}' — \
302                     write \\{{\\}} for literal braces"
303                ));
304            }
305            let value = lookup(&name);
306            let Some(value) = value else {
307                out.push_str(&s[i..=end_idx]);
308                i = end_idx + 1;
309                continue;
310            };
311            out.push_str(&value);
312            i = end_idx + 1;
313            replaced_any = true;
314            continue;
315        }
316        // Passthrough. ASCII bytes copy directly; a non-ASCII
317        // lead byte starts a multi-byte UTF-8 char that must be
318        // copied whole (`c as char` would split it into mojibake).
319        // `i` is always at a char boundary here — the scanner only
320        // advances past ASCII specials (`{` `}` `\`) or whole
321        // placeholders.
322        if c < 0x80 {
323            out.push(c as char);
324            i += 1;
325        } else {
326            let ch = s[i..].chars().next().expect("byte index at char boundary");
327            out.push(ch);
328            i += ch.len_utf8();
329        }
330    }
331    *s = out;
332    Ok(replaced_any)
333}
334
335/// Locate the first unresolved leaf placeholder name (after
336/// fixed-point iteration) for the diagnostic message. Returns
337/// `None` if every `{...}` is escaped or already resolved.
338fn first_unresolved(s: &str) -> Option<String> {
339    let bytes = s.as_bytes();
340    let n = bytes.len();
341    let mut i = 0;
342    while i < n {
343        if bytes[i] == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
344            i += 2;
345            continue;
346        }
347        if bytes[i] == b'{' {
348            let mut j = i + 1;
349            while j < n {
350                if bytes[j] == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}')
351                {
352                    j += 2;
353                    continue;
354                }
355                if bytes[j] == b'}' {
356                    return Some(s[i + 1..j].to_string());
357                }
358                if bytes[j] == b'{' {
359                    break;
360                }
361                j += 1;
362            }
363        }
364        i += 1;
365    }
366    None
367}
368
369/// Strip `\{` → `{` and `\}` → `}`. Other escapes pass through
370/// untouched so the substituted text doesn't gain newlines or
371/// other surprises the user didn't ask for.
372fn unescape(s: &str) -> String {
373    // Char-based, not byte-based: `bytes[i] as char` would split
374    // any multi-byte UTF-8 sequence (e.g. `…` U+2026) into
375    // mojibake. Only `\{` and `\}` are unescaped; every other
376    // character — ASCII or not — passes through intact.
377    let mut out = String::with_capacity(s.len());
378    let mut chars = s.chars().peekable();
379    while let Some(c) = chars.next() {
380        if c == '\\'
381            && let Some(&next) = chars.peek()
382            && (next == '{' || next == '}')
383        {
384            out.push(next);
385            chars.next();
386            continue;
387        }
388        out.push(c);
389    }
390    out
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::collections::HashMap;
397
398    fn h(pairs: &[(&str, &str)]) -> HashMap<String, String> {
399        pairs
400            .iter()
401            .map(|(k, v)| (k.to_string(), v.to_string()))
402            .collect()
403    }
404
405    #[test]
406    fn interpolate_with_lookup_resolves_leaves() {
407        let m = h(&[("name", "Alice"), ("count", "42")]);
408        let s = interpolate_with_lookup("hello {name}, you have {count} items", |n| {
409            m.get(n).cloned()
410        })
411        .unwrap();
412        assert_eq!(s, "hello Alice, you have 42 items");
413    }
414
415    #[test]
416    fn interpolate_with_lookup_handles_escapes() {
417        let m = h(&[("x", "1")]);
418        let s = interpolate_with_lookup("\\{literal\\} and {x}", |n| m.get(n).cloned()).unwrap();
419        assert_eq!(s, "{literal} and 1");
420    }
421
422    #[test]
423    fn interpolate_with_lookup_resolves_dynamic_via_iteration() {
424        // `{a_{b}_c}` resolves by first substituting {b} = "X",
425        // then re-scanning to find `{a_X_c}` as a leaf.
426        let m = h(&[("b", "X"), ("a_X_c", "RESULT")]);
427        let s = interpolate_with_lookup("got {a_{b}_c}", |n| m.get(n).cloned()).unwrap();
428        assert_eq!(s, "got RESULT");
429    }
430
431    #[test]
432    fn interpolate_with_lookup_errors_on_unresolved() {
433        let m = h(&[]);
434        let err = interpolate_with_lookup("missing: {nope}", |n| m.get(n).cloned()).unwrap_err();
435        assert!(err.contains("unresolved placeholder"));
436    }
437
438    #[test]
439    fn collect_string_interp_refs_picks_quoted_placeholders() {
440        let mut refs = HashSet::new();
441        collect_string_interp_refs(r#"do "x = {var}" and "{another}""#, &mut refs);
442        assert!(refs.contains("var"));
443        assert!(refs.contains("another"));
444    }
445
446    #[test]
447    fn collect_string_interp_refs_skips_outside_strings() {
448        let mut refs = HashSet::new();
449        collect_string_interp_refs("bare {not_picked} and \"yes {picked}\"", &mut refs);
450        assert!(refs.contains("picked"));
451        assert!(!refs.contains("not_picked"));
452    }
453}