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