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
60impl Lookup for PolydatKernel {
61 fn lookup(&self, name: &str) -> Option<Value> {
62 PolydatKernel::lookup(self, name)
63 }
64}
65
66pub struct Layered<'a> {
69 pub prefix: &'a [(String, Value)],
71 pub inner: &'a dyn Lookup,
73}
74
75impl Lookup for Layered<'_> {
76 fn lookup(&self, name: &str) -> Option<Value> {
77 if let Some((_, v)) = self.prefix.iter().find(|(n, _)| n == name) {
78 return Some(v.clone());
79 }
80 self.inner.lookup(name)
81 }
82}
83
84const ROUND_WARN: usize = 100;
87
88const ROUND_HARD: usize = 1000;
91
92pub fn interpolate_via_kernel(
105 text: &str,
106 kernel: &dyn Lookup,
107) -> Result<String, crate::dsl::compile::EmbeddingError> {
108 interpolate_with_lookup(text, |name| {
109 kernel.lookup(name).map(|v| v.to_display_string())
110 })
111 .map_err(|msg| classify_interpolate_error(text, msg))
112}
113
114fn classify_interpolate_error(text: &str, msg: String) -> crate::dsl::compile::EmbeddingError {
115 if let Some(rest) = msg.strip_prefix("interpolation: unresolved placeholder '{")
117 && let Some(end) = rest.find('}')
118 {
119 let name = rest[..end].to_string();
120 return crate::dsl::compile::EmbeddingError::UnresolvedPlaceholder {
121 name,
122 source: text.to_string(),
123 };
124 }
125 crate::dsl::compile::EmbeddingError::Parse {
128 source: text.to_string(),
129 message: msg,
130 position: None,
131 }
132}
133
134pub fn interpolate_with_lookup<F>(text: &str, lookup: F) -> Result<String, String>
142where
143 F: Fn(&str) -> Option<String>,
144{
145 let mut s = text.to_string();
146 let mut warned = false;
147 for round in 1..=ROUND_HARD {
148 if round == ROUND_WARN && !warned {
149 eprintln!(
150 "interpolation: '{text}' has run {ROUND_WARN} substitution rounds — likely cyclic"
151 );
152 warned = true;
153 }
154 let progress = one_pass(&mut s, &lookup)?;
155 if !progress {
156 break;
157 }
158 if round == ROUND_HARD {
159 return Err(format!(
160 "interpolation: '{text}' did not stabilize in {ROUND_HARD} rounds — \
161 cyclic placeholders?"
162 ));
163 }
164 }
165 if let Some(unresolved) = first_unresolved(&s) {
166 return Err(format!(
167 "interpolation: unresolved placeholder '{{{unresolved}}}' in '{text}' — \
168 not bound by any outer for_each var or workload param. \
169 Use \\{{ \\}} to write literal braces."
170 ));
171 }
172 Ok(unescape(&s))
173}
174
175pub fn collect_string_interp_refs(src: &str, refs: &mut HashSet<String>) {
185 let chars: Vec<char> = src.chars().collect();
186 let mut i = 0;
187 let mut in_str: Option<char> = None;
188 while i < chars.len() {
189 let c = chars[i];
190 match in_str {
191 Some(quote) if c == quote => {
192 in_str = None;
193 i += 1;
194 }
195 Some(_) if c == '\\' && i + 1 < chars.len() => {
196 i += 2;
197 }
198 Some(_) if c == '{' => {
199 let body_start = i + 1;
200 let mut body_end = body_start;
201 while body_end < chars.len() && chars[body_end] != '}' {
202 body_end += 1;
203 }
204 let body: String = chars[body_start..body_end].iter().collect();
205 let trimmed = body.trim();
206 if !trimmed.is_empty()
207 && !trimmed.starts_with('\'')
208 && !trimmed.starts_with('"')
209 && trimmed
210 .bytes()
211 .all(|b| b.is_ascii_alphanumeric() || b == b'_')
212 && !trimmed.bytes().next().unwrap().is_ascii_digit()
213 {
214 refs.insert(trimmed.to_string());
215 }
216 i = body_end + 1;
217 }
218 Some(_) => {
219 i += 1;
220 }
221 None if c == '"' || c == '\'' => {
222 in_str = Some(c);
223 i += 1;
224 }
225 None => {
226 i += 1;
227 }
228 }
229 }
230}
231
232fn one_pass<F>(s: &mut String, lookup: &F) -> Result<bool, String>
238where
239 F: Fn(&str) -> Option<String>,
240{
241 let bytes = s.as_bytes();
242 let n = bytes.len();
243 let mut out = String::with_capacity(n);
244 let mut i = 0;
245 let mut replaced_any = false;
246
247 while i < n {
248 let c = bytes[i];
249 if c == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
250 out.push('\\');
251 out.push(bytes[i + 1] as char);
252 i += 2;
253 continue;
254 }
255 if c == b'{' {
256 let mut j = i + 1;
257 let mut has_inner_open = false;
258 let mut end: Option<usize> = None;
259 while j < n {
260 let cj = bytes[j];
261 if cj == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}') {
262 j += 2;
263 continue;
264 }
265 if cj == b'{' {
266 has_inner_open = true;
267 break;
268 }
269 if cj == b'}' {
270 end = Some(j);
271 break;
272 }
273 j += 1;
274 }
275 if has_inner_open {
276 out.push('{');
277 i += 1;
278 continue;
279 }
280 let Some(end_idx) = end else {
281 return Err(format!(
282 "interpolation: unmatched '{{' in '{s}' starting at byte {i} — \
283 write \\{{ for a literal opening brace"
284 ));
285 };
286 let name = std::str::from_utf8(&bytes[i + 1..end_idx])
287 .map_err(|e| format!("interpolation: non-utf8 placeholder in '{s}': {e}"))?
288 .to_string();
289 if name.is_empty() {
290 return Err(format!(
291 "interpolation: empty placeholder '{{}}' in '{s}' — \
292 write \\{{\\}} for literal braces"
293 ));
294 }
295 let value = lookup(&name);
296 let Some(value) = value else {
297 out.push_str(&s[i..=end_idx]);
298 i = end_idx + 1;
299 continue;
300 };
301 out.push_str(&value);
302 i = end_idx + 1;
303 replaced_any = true;
304 continue;
305 }
306 if c < 0x80 {
313 out.push(c as char);
314 i += 1;
315 } else {
316 let ch = s[i..].chars().next().expect("byte index at char boundary");
317 out.push(ch);
318 i += ch.len_utf8();
319 }
320 }
321 *s = out;
322 Ok(replaced_any)
323}
324
325fn first_unresolved(s: &str) -> Option<String> {
329 let bytes = s.as_bytes();
330 let n = bytes.len();
331 let mut i = 0;
332 while i < n {
333 if bytes[i] == b'\\' && i + 1 < n && (bytes[i + 1] == b'{' || bytes[i + 1] == b'}') {
334 i += 2;
335 continue;
336 }
337 if bytes[i] == b'{' {
338 let mut j = i + 1;
339 while j < n {
340 if bytes[j] == b'\\' && j + 1 < n && (bytes[j + 1] == b'{' || bytes[j + 1] == b'}')
341 {
342 j += 2;
343 continue;
344 }
345 if bytes[j] == b'}' {
346 return Some(s[i + 1..j].to_string());
347 }
348 if bytes[j] == b'{' {
349 break;
350 }
351 j += 1;
352 }
353 }
354 i += 1;
355 }
356 None
357}
358
359fn unescape(s: &str) -> String {
363 let mut out = String::with_capacity(s.len());
368 let mut chars = s.chars().peekable();
369 while let Some(c) = chars.next() {
370 if c == '\\'
371 && let Some(&next) = chars.peek()
372 && (next == '{' || next == '}')
373 {
374 out.push(next);
375 chars.next();
376 continue;
377 }
378 out.push(c);
379 }
380 out
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::collections::HashMap;
387
388 fn h(pairs: &[(&str, &str)]) -> HashMap<String, String> {
389 pairs
390 .iter()
391 .map(|(k, v)| (k.to_string(), v.to_string()))
392 .collect()
393 }
394
395 #[test]
396 fn interpolate_with_lookup_resolves_leaves() {
397 let m = h(&[("name", "Alice"), ("count", "42")]);
398 let s = interpolate_with_lookup("hello {name}, you have {count} items", |n| {
399 m.get(n).cloned()
400 })
401 .unwrap();
402 assert_eq!(s, "hello Alice, you have 42 items");
403 }
404
405 #[test]
406 fn interpolate_with_lookup_handles_escapes() {
407 let m = h(&[("x", "1")]);
408 let s = interpolate_with_lookup("\\{literal\\} and {x}", |n| m.get(n).cloned()).unwrap();
409 assert_eq!(s, "{literal} and 1");
410 }
411
412 #[test]
413 fn interpolate_with_lookup_resolves_dynamic_via_iteration() {
414 let m = h(&[("b", "X"), ("a_X_c", "RESULT")]);
417 let s = interpolate_with_lookup("got {a_{b}_c}", |n| m.get(n).cloned()).unwrap();
418 assert_eq!(s, "got RESULT");
419 }
420
421 #[test]
422 fn interpolate_with_lookup_errors_on_unresolved() {
423 let m = h(&[]);
424 let err = interpolate_with_lookup("missing: {nope}", |n| m.get(n).cloned()).unwrap_err();
425 assert!(err.contains("unresolved placeholder"));
426 }
427
428 #[test]
429 fn collect_string_interp_refs_picks_quoted_placeholders() {
430 let mut refs = HashSet::new();
431 collect_string_interp_refs(r#"do "x = {var}" and "{another}""#, &mut refs);
432 assert!(refs.contains("var"));
433 assert!(refs.contains("another"));
434 }
435
436 #[test]
437 fn collect_string_interp_refs_skips_outside_strings() {
438 let mut refs = HashSet::new();
439 collect_string_interp_refs("bare {not_picked} and \"yes {picked}\"", &mut refs);
440 assert!(refs.contains("picked"));
441 assert!(!refs.contains("not_picked"));
442 }
443}