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//! Polydat's one name-resolution surface (expression_engine.md §3.2).
7//! It lives in the kernel module because the operation is a general
8//! kernel facility, not a comprehension concern: the comprehension
9//! runtime uses it, synthesisers use it, and the executor uses it,
10//! and none of them depends on the comprehension AST's shape.
11//!
12//! ## Functions
13//!
14//! - [`interpolate_via_kernel`] — looks up `{name}` placeholders
15//!   against the kernel's chain-aware bindings via
16//!   [`PolydatKernel::lookup`].
17//! - [`interpolate_with_lookup`] — the generic engine; the
18//!   `lookup` closure decides where each leaf's value comes
19//!   from. Used by callers that compose their own lookup over
20//!   the kernel plus workload params plus synthesis-time
21//!   probes.
22//! - [`collect_string_interp_refs`] — extracts the placeholder
23//!   names from a text without doing substitution.
24//!
25//! ## Semantics
26//!
27//! Iterative leaf-placeholder substitution with escape handling
28//! and a round cap:
29//!
30//! - **Leaf**: `{name}` whose body contains no further `{`. The
31//!   dynamic form `{a_{b}_c}` is resolved by first substituting
32//!   `{b}`, then re-scanning for the resulting `{a_<b-value>_c}`
33//!   as a leaf.
34//! - **Escape**: `\{` and `\}` pass through as literal `{` /
35//!   `}` and are removed from the final string.
36//! - **Round cap**: if substitution doesn't stabilize in
37//!   `ROUND_HARD` iterations, returns Err (the input had
38//!   cyclic placeholders).
39//! - **Unresolved name**: any `{name}` that survives the
40//!   substitution rounds errors with a diagnostic naming the
41//!   missing binding.
42
43use std::collections::HashSet;
44
45use crate::ast::Value;
46use crate::kernel::PolydatKernel;
47
48/// Name resolution for comprehension sources and predicates: what a
49/// `{name}` placeholder or a bare identifier reads. The interpreter
50/// kernel is one; a [`Layered`] view puts a tuple's bindings in front of
51/// another, so opening a traversal needs no kernel of the engine that
52/// opens it (engine parity, step 8).
53pub trait Lookup {
54    /// The value `name` denotes here, if any.
55    fn lookup(&self, name: &str) -> Option<Value>;
56
57    /// The compile ledger of the program tree this scope belongs to:
58    /// what a source or predicate that has to compile is charged to.
59    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger>;
60}
61
62impl Lookup for PolydatKernel {
63    fn lookup(&self, name: &str) -> Option<Value> {
64        PolydatKernel::lookup(self, name)
65    }
66    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
67        self.program().ledger()
68    }
69}
70
71/// A kernel of any engine, as a scope names resolve in.
72///
73/// `Lookup` had one kernel implementor and the typed embedding
74/// surfaces took `&PolydatKernel`, so a host holding a
75/// `Box<dyn Kernel>` could not interpolate `{k} > 5` against the
76/// kernel it had: the only route was to compile the program a second
77/// time on the interpreter. Wrapping is what makes this work rather
78/// than an `impl Lookup for dyn Kernel` — one trait object cannot
79/// become another.
80///
81/// Distinct from
82/// [`comprehension::surfaces::KernelScope`](crate::iteration::comprehension::surfaces::KernelScope),
83/// the algebra layer's trait for the parent a comprehension scopes
84/// under; this is a `Lookup` over a kernel that already exists.
85pub struct KernelLookup<'a>(&'a dyn crate::kernel::Kernel);
86
87impl<'a> KernelLookup<'a> {
88    /// The kernel as a scope.
89    pub fn new(kernel: &'a dyn crate::kernel::Kernel) -> Self {
90        KernelLookup(kernel)
91    }
92}
93
94impl Lookup for KernelLookup<'_> {
95    /// A name resolves to what the kernel holds for it now — an input
96    /// the host wrote, a coordinate it was positioned at — and
97    /// otherwise to what the build folded for it. The live answer
98    /// comes first because it is the later one: a coordinate has a
99    /// folded value on some engines, and it is the value the program
100    /// was built with, not the value the kernel is at.
101    ///
102    /// A `const` binding is the exception. Its value is the scope's for
103    /// the name, and an input slot of the same name, which a const that
104    /// reads a parameter it shadows is given (SRD-74 P2), holds only the
105    /// value from the scope above. So the const's own value comes
106    /// first, and the slot answers only while that value is `None`: the
107    /// two-tier read of a conditional shadow.
108    fn lookup(&self, name: &str) -> Option<Value> {
109        if self.0.output_modifier(name) == crate::dsl::ast::BindingModifier::CONST
110            && let Some(v) = self.0.folded_value(name)
111            && !matches!(v, Value::None)
112        {
113            return Some(v);
114        }
115        if let Some(v) = self.0.input_value(name)
116            && !matches!(v, Value::None)
117        {
118            return Some(v);
119        }
120        if let Some(v) = self.0.folded_value(name)
121            && !matches!(v, Value::None)
122        {
123            return Some(v);
124        }
125        // `a.b` lowers to the wire `a__b`, so a text reference like
126        // `{q.cursor.idx}` resolves through the same flattening the
127        // compiler applies.
128        if name.contains('.') {
129            return self.lookup(&name.replace('.', "__"));
130        }
131        None
132    }
133    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
134        crate::kernel::Kernel::ledger(self.0)
135    }
136}
137
138/// The empty scope: no name resolves in it, and what has to compile
139/// under it is charged to the ledger it holds. A context-free source,
140/// one whose expression references no name, evaluates in this scope
141/// (comprehension_forms.md §10.7.0), at compile time or wherever no
142/// kernel is at hand.
143pub struct NoScope {
144    ledger: std::sync::Arc<crate::kernel::CompileLedger>,
145}
146
147impl NoScope {
148    /// An empty scope charging to a fresh ledger of its own.
149    pub fn new() -> Self {
150        Self::charged_to(crate::kernel::CompileLedger::new())
151    }
152
153    /// An empty scope charging to `ledger`: the program tree's, when
154    /// the evaluation is part of that tree's compile.
155    pub fn charged_to(ledger: std::sync::Arc<crate::kernel::CompileLedger>) -> Self {
156        Self { ledger }
157    }
158}
159
160impl Default for NoScope {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166impl Lookup for NoScope {
167    fn lookup(&self, _name: &str) -> Option<Value> {
168        None
169    }
170    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
171        &self.ledger
172    }
173}
174
175/// Bindings in front of another lookup: a tuple's elements over the
176/// scope they were drawn in.
177pub struct Layered<'a> {
178    /// The bindings consulted first, in order.
179    pub prefix: &'a [(String, Value)],
180    /// Where every other name resolves.
181    pub inner: &'a dyn Lookup,
182}
183
184impl Lookup for Layered<'_> {
185    fn lookup(&self, name: &str) -> Option<Value> {
186        if let Some((_, v)) = self.prefix.iter().find(|(n, _)| n == name) {
187            return Some(v.clone());
188        }
189        self.inner.lookup(name)
190    }
191    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
192        self.inner.ledger()
193    }
194}
195
196/// Round count at which we warn about possible cycles in the
197/// substitution stream.
198const ROUND_WARN: usize = 100;
199
200/// Hard round-count limit. Errors out if substitution doesn't
201/// stabilize in this many iterations.
202const ROUND_HARD: usize = 1000;
203
204/// Interpolate `{name}` placeholders against `kernel`.
205///
206/// `{name}` resolves to `kernel.lookup(name).map(|v| v.to_display_string())`.
207/// `Value::None` (an unset extern slot) doesn't match — falls
208/// through to the unresolved-name error path at the fixed
209/// point.
210///
211/// Returns a typed [`crate::dsl::compile::EmbeddingError`] per
212/// E7 of the spec; the underlying string-form
213/// [`interpolate_with_lookup`] is kept for callers that
214/// compose their own lookup and don't want the
215/// typed-error overhead.
216pub fn interpolate_via_kernel(
217    text: &str,
218    kernel: &dyn Lookup,
219) -> Result<String, crate::dsl::compile::EmbeddingError> {
220    interpolate_with_lookup(text, |name| {
221        kernel.lookup(name).map(|v| v.to_display_string())
222    })
223    .map_err(|msg| classify_interpolate_error(text, msg))
224}
225
226fn classify_interpolate_error(text: &str, msg: String) -> crate::dsl::compile::EmbeddingError {
227    // "interpolation: unresolved placeholder '{name}' in '...'"
228    if let Some(rest) = msg.strip_prefix("interpolation: unresolved placeholder '{")
229        && let Some(end) = rest.find('}')
230    {
231        let name = rest[..end].to_string();
232        return crate::dsl::compile::EmbeddingError::UnresolvedPlaceholder {
233            name,
234            source: text.to_string(),
235        };
236    }
237    // Cyclic placeholder fall-through: classify as Parse since
238    // the text didn't stabilise.
239    crate::dsl::compile::EmbeddingError::Parse {
240        source: text.to_string(),
241        message: msg,
242        position: None,
243    }
244}
245
246/// Iterative leaf-placeholder substitution with escape handling,
247/// round cap, and final unresolved-name check. The `lookup`
248/// closure decides where each leaf's value comes from.
249///
250/// Public so callers like the synthesis-time clause probe can
251/// compose their own lookup (parent kernel + workload params +
252/// clause probes) without reimplementing the iterative loop.
253pub fn interpolate_with_lookup<F>(text: &str, lookup: F) -> Result<String, String>
254where
255    F: Fn(&str) -> Option<String>,
256{
257    let mut s = text.to_string();
258    let mut warned = false;
259    for round in 1..=ROUND_HARD {
260        if round == ROUND_WARN && !warned {
261            crate::library::support::audit::warn(&format!(
262                "interpolation: '{text}' has run {ROUND_WARN} substitution rounds — likely cyclic"
263            ));
264            warned = true;
265        }
266        let progress = one_pass(&mut s, &lookup)?;
267        if !progress {
268            break;
269        }
270        if round == ROUND_HARD {
271            return Err(format!(
272                "interpolation: '{text}' did not stabilize in {ROUND_HARD} rounds — \
273                 cyclic placeholders?"
274            ));
275        }
276    }
277    if let Some(unresolved) = first_unresolved(&s) {
278        return Err(format!(
279            "interpolation: unresolved placeholder '{{{unresolved}}}' in '{text}' — \
280             not bound by any outer for_each var or workload param. \
281             Use \\{{ \\}} to write literal braces."
282        ));
283    }
284    Ok(unescape(&s))
285}
286
287/// Extract every leaf `{name}` placeholder mentioned inside
288/// string-literal contexts in `src` into `refs`.
289///
290/// Used by the synthesiser to discover names the body
291/// references via `{name}` interpolation that don't appear as
292/// bare identifiers in the Polydat source. The detection is
293/// quote-aware: leading non-identifier chars (`'`, `"`) skip
294/// the placeholder, matching the binding compiler's
295/// `string_lit_has_real_placeholder` disambiguation.
296pub fn collect_string_interp_refs(src: &str, refs: &mut HashSet<String>) {
297    let chars: Vec<char> = src.chars().collect();
298    let mut i = 0;
299    let mut in_str: Option<char> = None;
300    while i < chars.len() {
301        let c = chars[i];
302        match in_str {
303            Some(quote) if c == quote => {
304                in_str = None;
305                i += 1;
306            }
307            Some(_) if c == '\\' && i + 1 < chars.len() => {
308                i += 2;
309            }
310            Some(_) if c == '{' => {
311                let body_start = i + 1;
312                let mut body_end = body_start;
313                while body_end < chars.len() && chars[body_end] != '}' {
314                    body_end += 1;
315                }
316                let body: String = chars[body_start..body_end].iter().collect();
317                let trimmed = body.trim();
318                if !trimmed.is_empty()
319                    && !trimmed.starts_with('\'')
320                    && !trimmed.starts_with('"')
321                    && trimmed
322                        .bytes()
323                        .all(|b| b.is_ascii_alphanumeric() || b == b'_')
324                    && !trimmed.bytes().next().unwrap().is_ascii_digit()
325                {
326                    refs.insert(trimmed.to_string());
327                }
328                i = body_end + 1;
329            }
330            Some(_) => {
331                i += 1;
332            }
333            None if c == '"' || c == '\'' => {
334                in_str = Some(c);
335                i += 1;
336            }
337            None => {
338                i += 1;
339            }
340        }
341    }
342}
343
344/// One sweep over `s`: replaces every **leaf** placeholder
345/// (`{NAME}` whose body contains no `{` or `}`) with its
346/// resolved value via the supplied `lookup` closure. Returns
347/// `Ok(true)` if any replacement happened, `Ok(false)` if the
348/// pass was a no-op (fixed point reached).
349fn one_pass<F>(s: &mut String, lookup: &F) -> Result<bool, String>
350where
351    F: Fn(&str) -> Option<String>,
352{
353    let bytes = s.as_bytes();
354    let n = bytes.len();
355    let mut out = String::with_capacity(n);
356    let mut i = 0;
357    let mut replaced_any = false;
358
359    while i < n {
360        let c = bytes[i];
361        if c == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
362            out.push('\\');
363            out.push(bytes[i + 1] as char);
364            i += 2;
365            continue;
366        }
367        if c == b'{' {
368            let mut j = i + 1;
369            let mut has_inner_open = false;
370            let mut end: Option<usize> = None;
371            while j < n {
372                let cj = bytes[j];
373                if cj == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}') {
374                    j += 2;
375                    continue;
376                }
377                if cj == b'{' {
378                    has_inner_open = true;
379                    break;
380                }
381                if cj == b'}' {
382                    end = Some(j);
383                    break;
384                }
385                j += 1;
386            }
387            if has_inner_open {
388                out.push('{');
389                i += 1;
390                continue;
391            }
392            let Some(end_idx) = end else {
393                return Err(format!(
394                    "interpolation: unmatched '{{' in '{s}' starting at byte {i} — \
395                     write \\{{ for a literal opening brace"
396                ));
397            };
398            let name = std::str::from_utf8(&bytes[i + 1..end_idx])
399                .map_err(|e| format!("interpolation: non-utf8 placeholder in '{s}': {e}"))?
400                .to_string();
401            if name.is_empty() {
402                return Err(format!(
403                    "interpolation: empty placeholder '{{}}' in '{s}' — \
404                     write \\{{\\}} for literal braces"
405                ));
406            }
407            let value = lookup(&name);
408            let Some(value) = value else {
409                out.push_str(&s[i..=end_idx]);
410                i = end_idx + 1;
411                continue;
412            };
413            out.push_str(&value);
414            i = end_idx + 1;
415            replaced_any = true;
416            continue;
417        }
418        // Passthrough. ASCII bytes copy directly; a non-ASCII
419        // lead byte starts a multi-byte UTF-8 char that must be
420        // copied whole (`c as char` would split it into mojibake).
421        // `i` is always at a char boundary here — the scanner only
422        // advances past ASCII specials (`{` `}` `\`) or whole
423        // placeholders.
424        if c < 0x80 {
425            out.push(c as char);
426            i += 1;
427        } else {
428            let ch = s[i..].chars().next().expect("byte index at char boundary");
429            out.push(ch);
430            i += ch.len_utf8();
431        }
432    }
433    *s = out;
434    Ok(replaced_any)
435}
436
437/// Locate the first unresolved leaf placeholder name (after
438/// fixed-point iteration) for the diagnostic message. Returns
439/// `None` if every `{...}` is escaped or already resolved.
440fn first_unresolved(s: &str) -> Option<String> {
441    let bytes = s.as_bytes();
442    let n = bytes.len();
443    let mut i = 0;
444    while i < n {
445        if bytes[i] == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
446            i += 2;
447            continue;
448        }
449        if bytes[i] == b'{' {
450            let mut j = i + 1;
451            while j < n {
452                if bytes[j] == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}')
453                {
454                    j += 2;
455                    continue;
456                }
457                if bytes[j] == b'}' {
458                    return Some(s[i + 1..j].to_string());
459                }
460                if bytes[j] == b'{' {
461                    break;
462                }
463                j += 1;
464            }
465        }
466        i += 1;
467    }
468    None
469}
470
471/// Strip `\{` → `{` and `\}` → `}`. Other escapes pass through
472/// untouched so the substituted text doesn't gain newlines or
473/// other surprises the user didn't ask for.
474fn unescape(s: &str) -> String {
475    // Char-based, not byte-based: `bytes[i] as char` would split
476    // any multi-byte UTF-8 sequence (e.g. `…` U+2026) into
477    // mojibake. Only `\{` and `\}` are unescaped; every other
478    // character — ASCII or not — passes through intact.
479    let mut out = String::with_capacity(s.len());
480    let mut chars = s.chars().peekable();
481    while let Some(c) = chars.next() {
482        if c == '\\'
483            && let Some(&next) = chars.peek()
484            && (next == '{' || next == '}')
485        {
486            out.push(next);
487            chars.next();
488            continue;
489        }
490        out.push(c);
491    }
492    out
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use std::collections::HashMap;
499
500    fn h(pairs: &[(&str, &str)]) -> HashMap<String, String> {
501        pairs
502            .iter()
503            .map(|(k, v)| (k.to_string(), v.to_string()))
504            .collect()
505    }
506
507    #[test]
508    fn interpolate_with_lookup_resolves_leaves() {
509        let m = h(&[("name", "Alice"), ("count", "42")]);
510        let s = interpolate_with_lookup("hello {name}, you have {count} items", |n| {
511            m.get(n).cloned()
512        })
513        .unwrap();
514        assert_eq!(s, "hello Alice, you have 42 items");
515    }
516
517    #[test]
518    fn interpolate_with_lookup_handles_escapes() {
519        let m = h(&[("x", "1")]);
520        let s = interpolate_with_lookup("\\{literal\\} and {x}", |n| m.get(n).cloned()).unwrap();
521        assert_eq!(s, "{literal} and 1");
522    }
523
524    #[test]
525    fn interpolate_with_lookup_resolves_dynamic_via_iteration() {
526        // `{a_{b}_c}` resolves by first substituting {b} = "X",
527        // then re-scanning to find `{a_X_c}` as a leaf.
528        let m = h(&[("b", "X"), ("a_X_c", "RESULT")]);
529        let s = interpolate_with_lookup("got {a_{b}_c}", |n| m.get(n).cloned()).unwrap();
530        assert_eq!(s, "got RESULT");
531    }
532
533    #[test]
534    fn interpolate_with_lookup_errors_on_unresolved() {
535        let m = h(&[]);
536        let err = interpolate_with_lookup("missing: {nope}", |n| m.get(n).cloned()).unwrap_err();
537        assert!(err.contains("unresolved placeholder"));
538    }
539
540    #[test]
541    fn collect_string_interp_refs_picks_quoted_placeholders() {
542        let mut refs = HashSet::new();
543        collect_string_interp_refs(r#"do "x = {var}" and "{another}""#, &mut refs);
544        assert!(refs.contains("var"));
545        assert!(refs.contains("another"));
546    }
547
548    #[test]
549    fn collect_string_interp_refs_skips_outside_strings() {
550        let mut refs = HashSet::new();
551        collect_string_interp_refs("bare {not_picked} and \"yes {picked}\"", &mut refs);
552        assert!(refs.contains("picked"));
553        assert!(!refs.contains("not_picked"));
554    }
555}