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 }