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