git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/expr.rs (25.5K)

  1 //! Parameter expressions — Houdini's channel references, with arithmetic.
  2 //!
  3 //! A parameter whose `expr` flag is set holds an EXPRESSION rather than a
  4 //! value, and is evaluated every time the node is: `ch("../sphere1/Radius")
  5 //! * 2 + 1`, `$F / 24`, `chs("../text1/Font")`. The flag is the model, not a
  6 //! guess about the text: a kernel's Code contains `chf(`, a node name is an
  7 //! identifier and `0.5` is an expression too, so anything that decided by
  8 //! looking at the string would be wrong somewhere. Houdini makes the same
  9 //! choice — a parm has an expression or it has a value.
 10 //!
 11 //! The language is deliberately small. Numbers and strings; `+ - * / % ^`,
 12 //! comparisons, `&& || !`; a fixed set of functions; the `$F` / `$FF` frame
 13 //! variables; and the channel functions, which are the whole point:
 14 //! `ch(path)` reads a parameter as a number (a toggle 1 or 0, a choice its
 15 //! option index, a float3 component through `.x` / `.y` / `.z`), `chs` as a
 16 //! string, `chf` / `chi` / `chb` are `ch` with the kernel vocabulary's
 17 //! conversions. Paths are Houdini's: relative to the node that holds the
 18 //! expression, `..` its parent, a leading `/` the root, and a bare name the
 19 //! node's OWN parameter. No ternary, because `:` separates a float3's
 20 //! components; `if(cond, a, b)` is the function instead.
 21 //!
 22 //! This module knows nothing about nodes: [`Scope`] is how an evaluation
 23 //! reaches a channel, and `geometry.rs` implements it over the tree.
 24 
 25 use std::fmt::Write as _;
 26 
 27 /// A parameter's evaluated value.
 28 #[derive(Debug, Clone, PartialEq)]
 29 pub enum Value {
 30     Num(f64),
 31     Str(String),
 32 }
 33 
 34 impl Value {
 35     pub fn as_num(&self) -> f64 {
 36         match self {
 37             Value::Num(n) => *n,
 38             Value::Str(s) => {
 39                 let t = s.trim();
 40                 if t.eq_ignore_ascii_case("true") {
 41                     1.0
 42                 } else if t.eq_ignore_ascii_case("false") {
 43                     0.0
 44                 } else {
 45                     t.parse::<f64>().unwrap_or(0.0)
 46                 }
 47             }
 48         }
 49     }
 50 
 51     pub fn as_str(&self) -> String {
 52         match self {
 53             Value::Num(n) => fmt_num(*n),
 54             Value::Str(s) => s.clone(),
 55         }
 56     }
 57 
 58     pub fn truthy(&self) -> bool {
 59         match self {
 60             Value::Num(n) => *n != 0.0,
 61             Value::Str(s) => !s.is_empty() && !s.eq_ignore_ascii_case("false") && s != "0",
 62         }
 63     }
 64 }
 65 
 66 /// A number as a parameter string: an integer when it is one, else the f32
 67 /// it will be read back as — so `0.1 + 0.2` shows as `0.3`, not the f64
 68 /// noise, and `2` is `2` rather than `2.0`.
 69 pub fn fmt_num(v: f64) -> String {
 70     if !v.is_finite() {
 71         return "0".to_string();
 72     }
 73     if v.fract() == 0.0 && v.abs() < 1e15 {
 74         format!("{}", v as i64)
 75     } else {
 76         format!("{}", v as f32)
 77     }
 78 }
 79 
 80 /// How a channel function wants the parameter it names.
 81 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 82 pub enum ChKind {
 83     /// `ch` / `chf`: a number.
 84     Float,
 85     /// `chi`: a number, truncated.
 86     Int,
 87     /// `chb`: 1 or 0.
 88     Bool,
 89     /// `chs`: the string as it is (a choice's option text, a toggle's
 90     /// `true` / `false`).
 91     Str,
 92 }
 93 
 94 /// What an evaluation asks of its surroundings.
 95 pub trait Scope {
 96     /// The parameter at `path`, relative to the node holding the expression.
 97     fn channel(&mut self, path: &str, kind: ChKind) -> Result<Value, String>;
 98     /// A `$NAME` variable, or None when there is no such variable.
 99     fn var(&self, name: &str) -> Option<Value>;
100 }
101 
102 #[derive(Debug, Clone, PartialEq)]
103 enum Node {
104     Num(f64),
105     Str(String),
106     Var(String),
107     Neg(Box<Node>),
108     Not(Box<Node>),
109     Bin(Op, Box<Node>, Box<Node>),
110     Call(String, Vec<Node>),
111     Ch(ChKind, String),
112 }
113 
114 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
115 enum Op {
116     Add,
117     Sub,
118     Mul,
119     Div,
120     Rem,
121     Pow,
122     Lt,
123     Le,
124     Gt,
125     Ge,
126     Eq,
127     Ne,
128     And,
129     Or,
130 }
131 
132 /// A parsed expression.
133 #[derive(Debug, Clone, PartialEq)]
134 pub struct Expr(Node);
135 
136 #[derive(Debug, Clone, PartialEq)]
137 enum Tok {
138     Num(f64),
139     Str(String),
140     Ident(String),
141     Var(String),
142     Sym(&'static str),
143 }
144 
145 fn tokenize(src: &str) -> Result<Vec<Tok>, String> {
146     let chars: Vec<char> = src.chars().collect();
147     let mut i = 0;
148     let mut out = Vec::new();
149     while i < chars.len() {
150         let c = chars[i];
151         if c.is_whitespace() {
152             i += 1;
153             continue;
154         }
155         if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
156             let start = i;
157             while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
158                 i += 1;
159             }
160             if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
161                 let mut j = i + 1;
162                 if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
163                     j += 1;
164                 }
165                 if j < chars.len() && chars[j].is_ascii_digit() {
166                     i = j;
167                     while i < chars.len() && chars[i].is_ascii_digit() {
168                         i += 1;
169                     }
170                 }
171             }
172             let text: String = chars[start..i].iter().collect();
173             let n = text.parse::<f64>().map_err(|_| format!("bad number `{text}`"))?;
174             out.push(Tok::Num(n));
175             continue;
176         }
177         if c == '"' || c == '\'' {
178             let quote = c;
179             i += 1;
180             let mut s = String::new();
181             loop {
182                 let Some(&d) = chars.get(i) else { return Err("unterminated string".to_string()) };
183                 i += 1;
184                 if d == quote {
185                     break;
186                 }
187                 if d == '\\' {
188                     if let Some(&e) = chars.get(i) {
189                         s.push(e);
190                         i += 1;
191                     }
192                     continue;
193                 }
194                 s.push(d);
195             }
196             out.push(Tok::Str(s));
197             continue;
198         }
199         if c == '$' {
200             let start = i + 1;
201             i += 1;
202             while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
203                 i += 1;
204             }
205             if i == start {
206                 return Err("`$` with no variable name".to_string());
207             }
208             out.push(Tok::Var(chars[start..i].iter().collect()));
209             continue;
210         }
211         if c.is_ascii_alphabetic() || c == '_' {
212             let start = i;
213             while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
214                 i += 1;
215             }
216             out.push(Tok::Ident(chars[start..i].iter().collect()));
217             continue;
218         }
219         let two: String = chars[i..(i + 2).min(chars.len())].iter().collect();
220         let sym = match two.as_str() {
221             "<=" => Some("<="),
222             ">=" => Some(">="),
223             "==" => Some("=="),
224             "!=" => Some("!="),
225             "&&" => Some("&&"),
226             "||" => Some("||"),
227             _ => None,
228         };
229         if let Some(s) = sym {
230             out.push(Tok::Sym(s));
231             i += 2;
232             continue;
233         }
234         let one = match c {
235             '+' => "+",
236             '-' => "-",
237             '*' => "*",
238             '/' => "/",
239             '%' => "%",
240             '^' => "^",
241             '(' => "(",
242             ')' => ")",
243             ',' => ",",
244             '<' => "<",
245             '>' => ">",
246             '!' => "!",
247             _ => return Err(format!("unexpected `{c}`")),
248         };
249         out.push(Tok::Sym(one));
250         i += 1;
251     }
252     Ok(out)
253 }
254 
255 struct Parser {
256     toks: Vec<Tok>,
257     pos: usize,
258 }
259 
260 impl Parser {
261     fn peek(&self) -> Option<&Tok> {
262         self.toks.get(self.pos)
263     }
264     fn eat_sym(&mut self, s: &str) -> bool {
265         if matches!(self.peek(), Some(Tok::Sym(t)) if *t == s) {
266             self.pos += 1;
267             true
268         } else {
269             false
270         }
271     }
272     fn expect_sym(&mut self, s: &str) -> Result<(), String> {
273         if self.eat_sym(s) { Ok(()) } else { Err(format!("expected `{s}`")) }
274     }
275 
276     fn or(&mut self) -> Result<Node, String> {
277         let mut l = self.and()?;
278         while self.eat_sym("||") {
279             let r = self.and()?;
280             l = Node::Bin(Op::Or, Box::new(l), Box::new(r));
281         }
282         Ok(l)
283     }
284     fn and(&mut self) -> Result<Node, String> {
285         let mut l = self.eq()?;
286         while self.eat_sym("&&") {
287             let r = self.eq()?;
288             l = Node::Bin(Op::And, Box::new(l), Box::new(r));
289         }
290         Ok(l)
291     }
292     fn eq(&mut self) -> Result<Node, String> {
293         let mut l = self.cmp()?;
294         loop {
295             let op = if self.eat_sym("==") { Op::Eq } else if self.eat_sym("!=") { Op::Ne } else { break };
296             let r = self.cmp()?;
297             l = Node::Bin(op, Box::new(l), Box::new(r));
298         }
299         Ok(l)
300     }
301     fn cmp(&mut self) -> Result<Node, String> {
302         let mut l = self.add()?;
303         loop {
304             let op = if self.eat_sym("<=") {
305                 Op::Le
306             } else if self.eat_sym(">=") {
307                 Op::Ge
308             } else if self.eat_sym("<") {
309                 Op::Lt
310             } else if self.eat_sym(">") {
311                 Op::Gt
312             } else {
313                 break;
314             };
315             let r = self.add()?;
316             l = Node::Bin(op, Box::new(l), Box::new(r));
317         }
318         Ok(l)
319     }
320     fn add(&mut self) -> Result<Node, String> {
321         let mut l = self.mul()?;
322         loop {
323             let op = if self.eat_sym("+") { Op::Add } else if self.eat_sym("-") { Op::Sub } else { break };
324             let r = self.mul()?;
325             l = Node::Bin(op, Box::new(l), Box::new(r));
326         }
327         Ok(l)
328     }
329     fn mul(&mut self) -> Result<Node, String> {
330         let mut l = self.unary()?;
331         loop {
332             let op = if self.eat_sym("*") {
333                 Op::Mul
334             } else if self.eat_sym("/") {
335                 Op::Div
336             } else if self.eat_sym("%") {
337                 Op::Rem
338             } else {
339                 break;
340             };
341             let r = self.unary()?;
342             l = Node::Bin(op, Box::new(l), Box::new(r));
343         }
344         Ok(l)
345     }
346     fn unary(&mut self) -> Result<Node, String> {
347         if self.eat_sym("-") {
348             return Ok(Node::Neg(Box::new(self.unary()?)));
349         }
350         if self.eat_sym("!") {
351             return Ok(Node::Not(Box::new(self.unary()?)));
352         }
353         if self.eat_sym("+") {
354             return self.unary();
355         }
356         self.pow()
357     }
358     fn pow(&mut self) -> Result<Node, String> {
359         let base = self.atom()?;
360         if self.eat_sym("^") {
361             // Right-associative: 2^3^2 is 2^9.
362             let exp = self.unary()?;
363             return Ok(Node::Bin(Op::Pow, Box::new(base), Box::new(exp)));
364         }
365         Ok(base)
366     }
367     fn atom(&mut self) -> Result<Node, String> {
368         let tok = self.peek().cloned().ok_or_else(|| "unexpected end of expression".to_string())?;
369         self.pos += 1;
370         match tok {
371             Tok::Num(n) => Ok(Node::Num(n)),
372             Tok::Str(s) => Ok(Node::Str(s)),
373             Tok::Var(v) => Ok(Node::Var(v)),
374             Tok::Sym("(") => {
375                 let inner = self.or()?;
376                 self.expect_sym(")")?;
377                 Ok(inner)
378             }
379             Tok::Ident(name) => {
380                 if self.eat_sym("(") {
381                     let mut args = Vec::new();
382                     if !self.eat_sym(")") {
383                         loop {
384                             args.push(self.or()?);
385                             if self.eat_sym(",") {
386                                 continue;
387                             }
388                             self.expect_sym(")")?;
389                             break;
390                         }
391                     }
392                     let kind = match name.as_str() {
393                         "ch" | "chf" => Some(ChKind::Float),
394                         "chi" => Some(ChKind::Int),
395                         "chb" => Some(ChKind::Bool),
396                         "chs" => Some(ChKind::Str),
397                         _ => None,
398                     };
399                     if let Some(kind) = kind {
400                         return match args.as_slice() {
401                             [Node::Str(path)] if !path.trim().is_empty() => Ok(Node::Ch(kind, path.trim().to_string())),
402                             _ => Err(format!("{name}() takes one quoted path")),
403                         };
404                     }
405                     Ok(Node::Call(name, args))
406                 } else {
407                     match name.as_str() {
408                         "PI" => Ok(Node::Num(std::f64::consts::PI)),
409                         "E" => Ok(Node::Num(std::f64::consts::E)),
410                         _ => Err(format!("unknown name `{name}`")),
411                     }
412                 }
413             }
414             Tok::Sym(s) => Err(format!("unexpected `{s}`")),
415         }
416     }
417 }
418 
419 /// Parse an expression. Errors name what went wrong, since they land on a
420 /// node's error slot for the user to read.
421 pub fn parse(src: &str) -> Result<Expr, String> {
422     let toks = tokenize(src)?;
423     if toks.is_empty() {
424         return Err("empty expression".to_string());
425     }
426     let mut p = Parser { toks, pos: 0 };
427     let node = p.or()?;
428     if p.pos != p.toks.len() {
429         return Err("trailing input after the expression".to_string());
430     }
431     Ok(Expr(node))
432 }
433 
434 /// Whether `s` reads as an expression that REFERS to something — a channel
435 /// or a variable. This is the inference applied to a value typed or scripted
436 /// into a parameter that has no expression yet: `ch("../a/Radius")` becomes
437 /// one, while `1+2`, a node name and a kernel do not — arithmetic on a
438 /// literal is asked for through the row's Edit Expression, not guessed.
439 pub fn looks_like_expression(s: &str) -> bool {
440     let t = s.trim();
441     if t.len() > 512 || !(t.contains("ch") || t.contains('$')) {
442         return false;
443     }
444     if let Ok(e) = parse(t) {
445         return e.refers();
446     }
447     // A float3: three components, each a number or an expression, at least
448     // one of which refers — `chf("../a/Size.x"):0:0`.
449     let parts: Vec<&str> = t.split(':').collect();
450     parts.len() == 3 && {
451         let parsed: Vec<Option<Expr>> = parts.iter().map(|p| parse(p.trim()).ok()).collect();
452         parsed.iter().all(Option::is_some) && parsed.iter().flatten().any(Expr::refers)
453     }
454 }
455 
456 impl Expr {
457     /// Whether the expression reads a channel or a variable.
458     pub fn refers(&self) -> bool {
459         fn walk(n: &Node) -> bool {
460             match n {
461                 Node::Ch(..) | Node::Var(_) => true,
462                 Node::Num(_) | Node::Str(_) => false,
463                 Node::Neg(a) | Node::Not(a) => walk(a),
464                 Node::Bin(_, a, b) => walk(a) || walk(b),
465                 Node::Call(_, args) => args.iter().any(walk),
466             }
467         }
468         walk(&self.0)
469     }
470 
471     /// Every channel path the expression names, in source order.
472     pub fn paths(&self) -> Vec<&str> {
473         fn walk<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
474             match n {
475                 Node::Ch(_, p) => out.push(p),
476                 Node::Num(_) | Node::Str(_) | Node::Var(_) => {}
477                 Node::Neg(a) | Node::Not(a) => walk(a, out),
478                 Node::Bin(_, a, b) => {
479                     walk(a, out);
480                     walk(b, out);
481                 }
482                 Node::Call(_, args) => args.iter().for_each(|a| walk(a, out)),
483             }
484         }
485         let mut out = Vec::new();
486         walk(&self.0, &mut out);
487         out
488     }
489 
490     pub fn eval(&self, scope: &mut dyn Scope) -> Result<Value, String> {
491         eval_node(&self.0, scope)
492     }
493 }
494 
495 fn eval_node(n: &Node, scope: &mut dyn Scope) -> Result<Value, String> {
496     use Value::*;
497     Ok(match n {
498         Node::Num(v) => Num(*v),
499         Node::Str(s) => Str(s.clone()),
500         Node::Var(name) => scope.var(name).ok_or_else(|| format!("unknown variable ${name}"))?,
501         Node::Neg(a) => Num(-eval_node(a, scope)?.as_num()),
502         Node::Not(a) => Num(if eval_node(a, scope)?.truthy() { 0.0 } else { 1.0 }),
503         Node::Ch(kind, path) => scope.channel(path, *kind)?,
504         Node::Bin(op, a, b) => {
505             // Short-circuit before evaluating the right side.
506             match op {
507                 Op::And => {
508                     let l = eval_node(a, scope)?;
509                     return Ok(Num(if !l.truthy() { 0.0 } else if eval_node(b, scope)?.truthy() { 1.0 } else { 0.0 }));
510                 }
511                 Op::Or => {
512                     let l = eval_node(a, scope)?;
513                     return Ok(Num(if l.truthy() { 1.0 } else if eval_node(b, scope)?.truthy() { 1.0 } else { 0.0 }));
514                 }
515                 _ => {}
516             }
517             let l = eval_node(a, scope)?;
518             let r = eval_node(b, scope)?;
519             let both_str = matches!((&l, &r), (Str(_), _) | (_, Str(_)));
520             match op {
521                 Op::Add if both_str => Str(format!("{}{}", l.as_str(), r.as_str())),
522                 Op::Eq if both_str => Num((l.as_str() == r.as_str()) as i32 as f64),
523                 Op::Ne if both_str => Num((l.as_str() != r.as_str()) as i32 as f64),
524                 _ => {
525                     let (x, y) = (l.as_num(), r.as_num());
526                     Num(match op {
527                         Op::Add => x + y,
528                         Op::Sub => x - y,
529                         Op::Mul => x * y,
530                         Op::Div => {
531                             if y == 0.0 {
532                                 return Err("division by zero".to_string());
533                             }
534                             x / y
535                         }
536                         Op::Rem => {
537                             if y == 0.0 {
538                                 return Err("modulo by zero".to_string());
539                             }
540                             x % y
541                         }
542                         Op::Pow => x.powf(y),
543                         Op::Lt => (x < y) as i32 as f64,
544                         Op::Le => (x <= y) as i32 as f64,
545                         Op::Gt => (x > y) as i32 as f64,
546                         Op::Ge => (x >= y) as i32 as f64,
547                         Op::Eq => (x == y) as i32 as f64,
548                         Op::Ne => (x != y) as i32 as f64,
549                         Op::And | Op::Or => unreachable!(),
550                     })
551                 }
552             }
553         }
554         Node::Call(name, args) => call(name, args, scope)?,
555     })
556 }
557 
558 fn call(name: &str, args: &[Node], scope: &mut dyn Scope) -> Result<Value, String> {
559     use Value::*;
560     // `if` evaluates one branch only, so a guarded division stays guarded.
561     if name == "if" {
562         if args.len() != 3 {
563             return Err("if() takes (condition, then, else)".to_string());
564         }
565         let c = eval_node(&args[0], scope)?;
566         return eval_node(if c.truthy() { &args[1] } else { &args[2] }, scope);
567     }
568     let vals: Vec<Value> = args.iter().map(|a| eval_node(a, scope)).collect::<Result<_, _>>()?;
569     let nums: Vec<f64> = vals.iter().map(Value::as_num).collect();
570     let arity = |n: usize| -> Result<(), String> {
571         if nums.len() == n { Ok(()) } else { Err(format!("{name}() takes {n} argument(s)")) }
572     };
573     let f1 = |f: fn(f64) -> f64| -> Result<Value, String> {
574         arity(1)?;
575         Ok(Num(f(nums[0])))
576     };
577     Ok(match name {
578         "abs" => f1(f64::abs)?,
579         "floor" => f1(f64::floor)?,
580         "ceil" => f1(f64::ceil)?,
581         "round" => f1(f64::round)?,
582         "int" | "trunc" => f1(f64::trunc)?,
583         "frac" => f1(f64::fract)?,
584         "sqrt" => f1(f64::sqrt)?,
585         "exp" => f1(f64::exp)?,
586         "log" => f1(f64::ln)?,
587         "sin" => f1(f64::sin)?,
588         "cos" => f1(f64::cos)?,
589         "tan" => f1(f64::tan)?,
590         "asin" => f1(f64::asin)?,
591         "acos" => f1(f64::acos)?,
592         "atan" => f1(f64::atan)?,
593         "sign" => f1(f64::signum)?,
594         "atan2" => {
595             arity(2)?;
596             Num(nums[0].atan2(nums[1]))
597         }
598         "pow" => {
599             arity(2)?;
600             Num(nums[0].powf(nums[1]))
601         }
602         "min" => {
603             if nums.is_empty() {
604                 return Err("min() takes at least one argument".to_string());
605             }
606             Num(nums.iter().cloned().fold(f64::INFINITY, f64::min))
607         }
608         "max" => {
609             if nums.is_empty() {
610                 return Err("max() takes at least one argument".to_string());
611             }
612             Num(nums.iter().cloned().fold(f64::NEG_INFINITY, f64::max))
613         }
614         "clamp" => {
615             arity(3)?;
616             Num(nums[0].max(nums[1]).min(nums[2]))
617         }
618         "lerp" => {
619             arity(3)?;
620             Num(nums[0] + (nums[1] - nums[0]) * nums[2])
621         }
622         // Houdini's fit: v in [omin, omax] mapped to [nmin, nmax], clamped.
623         "fit" => {
624             arity(5)?;
625             let (v, omin, omax, nmin, nmax) = (nums[0], nums[1], nums[2], nums[3], nums[4]);
626             let t = if omax == omin { 0.0 } else { ((v - omin) / (omax - omin)).clamp(0.0, 1.0) };
627             Num(nmin + (nmax - nmin) * t)
628         }
629         // Deterministic in its seed, as Houdini's is: the same seed is the
630         // same number on every evaluation and every machine.
631         "rand" => {
632             arity(1)?;
633             let bits = nums[0].to_bits();
634             let mut h = bits ^ 0x9E37_79B9_7F4A_7C15;
635             h = (h ^ (h >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
636             h = (h ^ (h >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
637             h ^= h >> 31;
638             Num((h >> 11) as f64 / (1u64 << 53) as f64)
639         }
640         "strlen" => {
641             arity(1)?;
642             Num(vals[0].as_str().chars().count() as f64)
643         }
644         _ => return Err(format!("unknown function {name}()")),
645     })
646 }
647 
648 /// Rewrite every channel path in `src` through `f` — the textual counterpart
649 /// of [`Expr::paths`], for a rename: `f` gets each quoted path inside a
650 /// `ch*(...)` call and answers with its replacement, or None to leave it.
651 /// Textual rather than a re-print of the AST so the user's spacing and
652 /// spelling survive a rename of a node three levels away.
653 pub fn rewrite_paths(src: &str, mut f: impl FnMut(&str) -> Option<String>) -> String {
654     let mut out = String::with_capacity(src.len());
655     let bytes = src.as_bytes();
656     let mut i = 0;
657     while i < bytes.len() {
658         // A channel call: an identifier ch / chf / chi / chb / chs not
659         // preceded by an identifier character, then `(`, then a string.
660         let at_ident_start = i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_');
661         let mut matched = None;
662         if at_ident_start && bytes[i] == b'c' {
663             for name in ["chf", "chi", "chb", "chs", "ch"] {
664                 if src[i..].starts_with(name) {
665                     let rest = &src[i + name.len()..];
666                     let trimmed = rest.trim_start();
667                     if let Some(after_paren) = trimmed.strip_prefix('(') {
668                         let inner = after_paren.trim_start();
669                         if let Some(q) = inner.chars().next().filter(|c| *c == '"' || *c == '\'') {
670                             let body = &inner[1..];
671                             if let Some(end) = body.find(q) {
672                                 let path = &body[..end];
673                                 let consumed = name.len() + (rest.len() - trimmed.len()) + 1 + (after_paren.len() - inner.len()) + 1 + end + 1;
674                                 let prefix_len = consumed - end - 1;
675                                 matched = Some((prefix_len, path.to_string(), consumed, q));
676                                 break;
677                             }
678                         }
679                     }
680                 }
681             }
682         }
683         if let Some((prefix_len, path, consumed, q)) = matched {
684             out.push_str(&src[i..i + prefix_len]);
685             match f(&path) {
686                 Some(new) => out.push_str(&new),
687                 None => out.push_str(&path),
688             }
689             out.push(q);
690             i += consumed;
691             continue;
692         }
693         let ch = src[i..].chars().next().unwrap();
694         out.push(ch);
695         i += ch.len_utf8();
696     }
697     out
698 }
699 
700 /// The pre-2026-09-24 reference: the WHOLE value one of `ch("Name")`,
701 /// `chf(...)`, `chi(...)`, `chb(...)`, with `../` per level — where a bare
702 /// name meant the PARENT's parameter. Kept for the load-time migration,
703 /// which turns it into an expression with Houdini's semantics (`../Name`).
704 pub fn parse_legacy_ref(value: &str) -> Option<(&'static str, String)> {
705     let v = value.trim();
706     let (kind, rest) = if let Some(r) = v.strip_prefix("chf(") {
707         ("chf", r)
708     } else if let Some(r) = v.strip_prefix("chi(") {
709         ("chi", r)
710     } else if let Some(r) = v.strip_prefix("chb(") {
711         ("chb", r)
712     } else if let Some(r) = v.strip_prefix("ch(") {
713         ("ch", r)
714     } else {
715         return None;
716     };
717     let inner = rest.strip_suffix(')')?.trim();
718     let quote = inner.chars().next()?;
719     if quote != '"' && quote != '\'' {
720         return None;
721     }
722     let path = inner.strip_prefix(quote)?.strip_suffix(quote)?;
723     let mut name = path;
724     while let Some(r) = name.strip_prefix("../") {
725         name = r;
726     }
727     if name.trim().is_empty() || name.contains('/') {
728         return None;
729     }
730     Some((kind, path.to_string()))
731 }
732 
733 /// The legacy reference rewritten to Houdini's semantics: a bare name gains
734 /// `../` (it meant the parent), an explicit `../` path is already right.
735 pub fn migrate_legacy_ref(value: &str) -> Option<String> {
736     let (kind, path) = parse_legacy_ref(value)?;
737     let path = if path.starts_with("../") { path } else { format!("../{path}") };
738     let mut s = String::new();
739     let _ = write!(s, "{kind}(\"{path}\")");
740     Some(s)
741 }