polydat_core/kernel/
interp.rs1use std::collections::HashSet;
46
47use crate::ast::Value;
48use crate::kernel::PolydatKernel;
49
50pub trait Lookup {
56 fn lookup(&self, name: &str) -> Option<Value>;
58
59 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
73pub struct Layered<'a> {
76 pub prefix: &'a [(String, Value)],
78 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
94const ROUND_WARN: usize = 100;
97
98const ROUND_HARD: usize = 1000;
101
102pub 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 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 crate::dsl::compile::EmbeddingError::Parse {
138 source: text.to_string(),
139 message: msg,
140 position: None,
141 }
142}
143
144pub 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
185pub 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
242fn 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 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
335fn 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
369fn unescape(s: &str) -> String {
373 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 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}