Skip to main content

ezu_style/
expand.rs

1//! Function expansion: rewrite a [`Document`] with `functions` into a
2//! flat node graph before building.
3//!
4//! Functions behave like hygienic macros over the node graph:
5//!
6//! - A call node `{ "op": "func", "fn": "f", ...args }` is replaced by
7//!   a copy of `f`'s body. Body node ids are namespaced as
8//!   `<call-id>/<body-id>`; the body's output node takes the call id
9//!   itself, so `@<call-id>` references keep working unchanged.
10//! - Arguments substitute structurally: every `@<input-name>` in the
11//!   body (recursively, including inside arrays/objects) is replaced
12//!   by the caller's argument *value* — a literal stays a literal, a
13//!   `$param` stays a runtime-resolved param, a `@node` becomes a port
14//!   connection to the caller's graph.
15//! - Bodies are closed over their inputs: a body reference must name a
16//!   function input, another body node, or a document-scoped source.
17//!   Anything else is an error (catches typos, keeps functions
18//!   reusable).
19//! - Functions may call functions; the call graph must be acyclic
20//!   (checked up front, reported with the cycle path). A node-count
21//!   cap guards against exponential nesting.
22//!
23//! Because the evaluator's cache keys are content-addressed (no node
24//! identity), two calls with identical arguments share cache entries
25//! after expansion — inlining does not duplicate work.
26
27use indexmap::IndexMap;
28use serde_json::Value;
29
30use crate::spec::{Document, FuncDecl, FuncKind, NodeSpec, SourceDecl};
31
32/// Hard ceiling on the number of nodes an expanded document may
33/// contain. Functions calling functions multiply node counts; this
34/// converts a runaway composition into a clear error.
35pub const MAX_EXPANDED_NODES: usize = 10_000;
36
37#[derive(Debug, thiserror::Error)]
38pub enum ExpandError {
39    #[error("call `{call}`: unknown function `{func}`")]
40    UnknownFunction { call: String, func: String },
41
42    #[error("call `{call}` of `{func}`: missing required input `{input}`")]
43    MissingInput {
44        call: String,
45        func: String,
46        input: String,
47    },
48
49    #[error("call `{call}` of `{func}`: unknown input `{input}` (declared inputs: {declared})")]
50    UnknownInput {
51        call: String,
52        func: String,
53        input: String,
54        declared: String,
55    },
56
57    #[error(
58        "call `{call}` of `{func}`: input `{input}` is `{kind}`, so the argument must be a `@node` reference"
59    )]
60    NodeArgExpected {
61        call: String,
62        func: String,
63        input: String,
64        kind: &'static str,
65    },
66
67    #[error("function `{func}`: input `{input}` is `{kind}` — `default` is only allowed for scalar inputs")]
68    NonScalarDefault {
69        func: String,
70        input: String,
71        kind: &'static str,
72    },
73
74    #[error("function `{func}`: output node `{output}` is not in its body")]
75    UnknownOutputNode { func: String, output: String },
76
77    #[error("function `{func}`: `{name}` is both an input and a body node")]
78    InputBodyCollision { func: String, name: String },
79
80    #[error("function `{func}`, node `{node}`: unknown reference `@{reference}` (not an input, body node, or source)")]
81    UnknownRef {
82        func: String,
83        node: String,
84        reference: String,
85    },
86
87    #[error("recursive function call: {path}")]
88    RecursiveCall { path: String },
89
90    #[error("node id `{id}` contains `/`, which is reserved for expanded function bodies")]
91    ReservedIdSeparator { id: String },
92
93    #[error("function call `{call}`: missing `fn` field naming the function")]
94    MissingFnName { call: String },
95
96    #[error(
97        "expansion produced more than {MAX_EXPANDED_NODES} nodes — check for heavily nested function calls"
98    )]
99    TooManyNodes,
100}
101
102/// A declared-kind check the host should verify once the graph is
103/// built and port kinds are resolved. Produced per call site so kind
104/// errors can name the call instead of an expanded internal port.
105#[derive(Debug, Clone)]
106pub struct KindCheck {
107    /// Node id (in the expanded document) whose resolved output kind
108    /// must match `declared`.
109    pub node: String,
110    pub declared: FuncKind,
111    /// Call node id, for error messages.
112    pub call: String,
113    /// Function name, for error messages.
114    pub func: String,
115    /// Input name when this checks an argument; `None` for the
116    /// function's own output kind.
117    pub input: Option<String>,
118}
119
120/// Result of expanding a document's functions.
121#[derive(Debug)]
122pub struct Expanded {
123    pub doc: Document,
124    pub kind_checks: Vec<KindCheck>,
125}
126
127/// Expand every `op: "func"` call in `doc` into inline body copies.
128/// Returns `None` when the document declares no functions (no work to
129/// do — callers keep using the original document).
130pub fn expand_functions(doc: &Document) -> Result<Option<Expanded>, ExpandError> {
131    if doc.functions.is_empty() {
132        return Ok(None);
133    }
134
135    // Reserved separator: user ids must not collide with mangled ones.
136    for id in doc.nodes.keys() {
137        if id.contains('/') {
138            return Err(ExpandError::ReservedIdSeparator { id: id.clone() });
139        }
140    }
141    for (fname, f) in &doc.functions {
142        for id in f.nodes.keys() {
143            if id.contains('/') {
144                return Err(ExpandError::ReservedIdSeparator { id: id.clone() });
145            }
146            if f.inputs.contains_key(id) {
147                return Err(ExpandError::InputBodyCollision {
148                    func: fname.clone(),
149                    name: id.clone(),
150                });
151            }
152        }
153        if !f.nodes.contains_key(f.output.as_str()) {
154            return Err(ExpandError::UnknownOutputNode {
155                func: fname.clone(),
156                output: f.output.as_str().to_string(),
157            });
158        }
159        for (iname, input) in &f.inputs {
160            if input.default.is_some() && input.kind != FuncKind::Scalar {
161                return Err(ExpandError::NonScalarDefault {
162                    func: fname.clone(),
163                    input: iname.clone(),
164                    kind: input.kind.as_str(),
165                });
166            }
167        }
168    }
169
170    check_call_cycles(&doc.functions)?;
171
172    let mut cx = Expander {
173        functions: &doc.functions,
174        sources: &doc.sources,
175        out: IndexMap::new(),
176        kind_checks: Vec::new(),
177    };
178
179    for (id, spec) in &doc.nodes {
180        if spec.op == "func" {
181            cx.expand_call(id, &spec.fields)?;
182        } else {
183            cx.push(id.clone(), spec.clone())?;
184        }
185    }
186
187    Ok(Some(Expanded {
188        doc: Document {
189            name: doc.name.clone(),
190            version: doc.version.clone(),
191            tile_size: doc.tile_size,
192            pad: doc.pad,
193            params: doc.params.clone(),
194            attribution: doc.attribution.clone(),
195            functions: IndexMap::new(),
196            legend: doc.legend.clone(),
197            sources: doc.sources.clone(),
198            nodes: cx.out,
199            output: doc.output.clone(),
200        },
201        kind_checks: cx.kind_checks,
202    }))
203}
204
205/// Reject cyclic function-to-function calls up front, with the path.
206fn check_call_cycles(functions: &IndexMap<String, FuncDecl>) -> Result<(), ExpandError> {
207    fn visit(
208        name: &str,
209        functions: &IndexMap<String, FuncDecl>,
210        stack: &mut Vec<String>,
211        done: &mut Vec<String>,
212    ) -> Result<(), ExpandError> {
213        if done.iter().any(|d| d == name) {
214            return Ok(());
215        }
216        if let Some(pos) = stack.iter().position(|s| s == name) {
217            let mut path: Vec<&str> = stack[pos..].iter().map(String::as_str).collect();
218            path.push(name);
219            return Err(ExpandError::RecursiveCall {
220                path: path.join(" → "),
221            });
222        }
223        let Some(f) = functions.get(name) else {
224            // Unknown callee — reported with call context during
225            // expansion, where the call node id is known.
226            return Ok(());
227        };
228        stack.push(name.to_string());
229        for spec in f.nodes.values() {
230            if spec.op == "func" {
231                if let Some(callee) = spec.fields.get("fn").and_then(Value::as_str) {
232                    visit(callee, functions, stack, done)?;
233                }
234            }
235        }
236        stack.pop();
237        done.push(name.to_string());
238        Ok(())
239    }
240
241    let mut done = Vec::new();
242    for name in functions.keys() {
243        visit(name, functions, &mut Vec::new(), &mut done)?;
244    }
245    Ok(())
246}
247
248struct Expander<'a> {
249    functions: &'a IndexMap<String, FuncDecl>,
250    sources: &'a IndexMap<String, SourceDecl>,
251    out: IndexMap<String, NodeSpec>,
252    kind_checks: Vec<KindCheck>,
253}
254
255impl Expander<'_> {
256    fn push(&mut self, id: String, spec: NodeSpec) -> Result<(), ExpandError> {
257        if self.out.len() >= MAX_EXPANDED_NODES {
258            return Err(ExpandError::TooManyNodes);
259        }
260        self.out.insert(id, spec);
261        Ok(())
262    }
263
264    /// Expand one `op: "func"` call: validate the arguments, copy the
265    /// body with mangled ids and substituted inputs, recurse into
266    /// nested calls. The body's output node is inserted under the call
267    /// id itself so outer `@call` references resolve unchanged.
268    fn expand_call(
269        &mut self,
270        call_id: &str,
271        fields: &serde_json::Map<String, Value>,
272    ) -> Result<(), ExpandError> {
273        let func_name =
274            fields
275                .get("fn")
276                .and_then(Value::as_str)
277                .ok_or_else(|| ExpandError::MissingFnName {
278                    call: call_id.to_string(),
279                })?;
280        let func = self
281            .functions
282            .get(func_name)
283            .ok_or_else(|| ExpandError::UnknownFunction {
284                call: call_id.to_string(),
285                func: func_name.to_string(),
286            })?;
287
288        // Validate the argument set against the declared inputs.
289        for key in fields.keys() {
290            if key == "op" || key == "fn" {
291                continue;
292            }
293            if !func.inputs.contains_key(key) {
294                return Err(ExpandError::UnknownInput {
295                    call: call_id.to_string(),
296                    func: func_name.to_string(),
297                    input: key.clone(),
298                    declared: func
299                        .inputs
300                        .keys()
301                        .map(String::as_str)
302                        .collect::<Vec<_>>()
303                        .join(", "),
304                });
305            }
306        }
307
308        // Build the substitution map: input name -> argument value.
309        let mut subst: IndexMap<String, Value> = IndexMap::new();
310        for (iname, input) in &func.inputs {
311            let arg = match fields.get(iname) {
312                Some(v) => v.clone(),
313                None => match &input.default {
314                    Some(d) => d.clone(),
315                    None => {
316                        return Err(ExpandError::MissingInput {
317                            call: call_id.to_string(),
318                            func: func_name.to_string(),
319                            input: iname.clone(),
320                        });
321                    }
322                },
323            };
324            let arg_node = arg
325                .as_str()
326                .and_then(|s| s.strip_prefix('@'))
327                .map(str::to_string);
328            if input.kind != FuncKind::Scalar && arg_node.is_none() {
329                return Err(ExpandError::NodeArgExpected {
330                    call: call_id.to_string(),
331                    func: func_name.to_string(),
332                    input: iname.clone(),
333                    kind: input.kind.as_str(),
334                });
335            }
336            // Node-fed arguments get their resolved kind verified once
337            // the graph is built (scalar literals/params need no check
338            // — the In<T> readers enforce value types).
339            if let Some(src) = arg_node {
340                self.kind_checks.push(KindCheck {
341                    node: src,
342                    declared: input.kind,
343                    call: call_id.to_string(),
344                    func: func_name.to_string(),
345                    input: Some(iname.clone()),
346                });
347            }
348            subst.insert(iname.clone(), arg);
349        }
350
351        // Mangle body ids; the output node takes the call id.
352        let output_id = func.output.as_str();
353        let mangled = |body_id: &str| -> String {
354            if body_id == output_id {
355                call_id.to_string()
356            } else {
357                format!("{call_id}/{body_id}")
358            }
359        };
360
361        self.kind_checks.push(KindCheck {
362            node: call_id.to_string(),
363            declared: func.output_kind,
364            call: call_id.to_string(),
365            func: func_name.to_string(),
366            input: None,
367        });
368
369        for (body_id, spec) in &func.nodes {
370            let new_id = mangled(body_id);
371            let mut new_fields = serde_json::Map::with_capacity(spec.fields.len());
372            for (k, v) in &spec.fields {
373                // A field that IS an input reference whose argument is
374                // `null` disappears from the node — the way to leave
375                // optional op fields (stroke curves, seeds, …) unset
376                // from a call site.
377                if let Some(name) = v.as_str().and_then(|s| s.strip_prefix('@')) {
378                    if subst.get(name) == Some(&Value::Null) {
379                        continue;
380                    }
381                }
382                new_fields.insert(
383                    k.clone(),
384                    self.rewrite(v, &subst, func, func_name, body_id, &mangled)?,
385                );
386            }
387            if spec.op == "func" {
388                self.expand_call(&new_id, &new_fields)?;
389            } else {
390                self.push(
391                    new_id,
392                    NodeSpec {
393                        op: spec.op.clone(),
394                        fields: new_fields,
395                    },
396                )?;
397            }
398        }
399        Ok(())
400    }
401
402    /// Rewrite one body field value: substitute `@input` references
403    /// with argument values, remap `@body-node` references to mangled
404    /// ids, let `@source` references pass through, and reject anything
405    /// else. Recurses into arrays and objects so references inside
406    /// e.g. gradient stops are covered.
407    fn rewrite(
408        &self,
409        v: &Value,
410        subst: &IndexMap<String, Value>,
411        func: &FuncDecl,
412        func_name: &str,
413        body_id: &str,
414        mangled: &dyn Fn(&str) -> String,
415    ) -> Result<Value, ExpandError> {
416        match v {
417            Value::String(s) => {
418                let Some(name) = s.strip_prefix('@') else {
419                    return Ok(v.clone());
420                };
421                if let Some(arg) = subst.get(name) {
422                    return Ok(arg.clone());
423                }
424                if func.nodes.contains_key(name) {
425                    return Ok(Value::String(format!("@{}", mangled(name))));
426                }
427                if self.sources.contains_key(name) {
428                    return Ok(v.clone());
429                }
430                Err(ExpandError::UnknownRef {
431                    func: func_name.to_string(),
432                    node: body_id.to_string(),
433                    reference: name.to_string(),
434                })
435            }
436            Value::Array(items) => Ok(Value::Array(
437                items
438                    .iter()
439                    .map(|item| self.rewrite(item, subst, func, func_name, body_id, mangled))
440                    .collect::<Result<_, _>>()?,
441            )),
442            Value::Object(map) => {
443                let mut out = serde_json::Map::with_capacity(map.len());
444                for (k, item) in map {
445                    out.insert(
446                        k.clone(),
447                        self.rewrite(item, subst, func, func_name, body_id, mangled)?,
448                    );
449                }
450                Ok(Value::Object(out))
451            }
452            _ => Ok(v.clone()),
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    fn expand(json: &str) -> Result<Option<Expanded>, ExpandError> {
462        let doc = Document::from_json(json).unwrap();
463        expand_functions(&doc)
464    }
465
466    fn expanded(json: &str) -> Document {
467        expand(json).unwrap().expect("functions present").doc
468    }
469
470    const BASIC: &str = r##"{
471      "name": "demo",
472      "functions": {
473        "tinted": {
474          "inputs": {
475            "base":  { "kind": "raster" },
476            "color": { "kind": "scalar", "default": "#ff0000" }
477          },
478          "output": "@mix",
479          "output-kind": "raster",
480          "nodes": {
481            "tint": { "op": "solid", "color": "@color" },
482            "mix":  { "op": "blend", "base": "@base", "over": "@tint" }
483          }
484        }
485      },
486      "nodes": {
487        "bg": { "op": "solid", "color": "#ffffff" },
488        "out": { "op": "func", "fn": "tinted", "base": "@bg", "color": "#00ff00" }
489      },
490      "output": "@out"
491    }"##;
492
493    #[test]
494    fn expands_with_mangling_and_output_alias() {
495        let doc = expanded(BASIC);
496        assert!(doc.functions.is_empty());
497        let ids: Vec<&str> = doc.nodes.keys().map(String::as_str).collect();
498        assert_eq!(ids, ["bg", "out/tint", "out"]);
499        // Output body node took the call id; its internal `@tint` ref
500        // was mangled; `@base` was substituted with the caller arg.
501        assert_eq!(doc.nodes["out"].fields["base"], "@bg");
502        assert_eq!(doc.nodes["out"].fields["over"], "@out/tint");
503        // The scalar arg substituted as a literal.
504        assert_eq!(doc.nodes["out/tint"].fields["color"], "#00ff00");
505    }
506
507    #[test]
508    fn default_fills_missing_scalar_arg() {
509        let json = BASIC.replace(r##", "color": "#00ff00""##, "");
510        let doc = expanded(&json);
511        assert_eq!(doc.nodes["out/tint"].fields["color"], "#ff0000");
512    }
513
514    #[test]
515    fn param_args_stay_params() {
516        let json = BASIC.replace(r##""color": "#00ff00""##, r##""color": "$ink""##);
517        let doc = expanded(&json);
518        assert_eq!(doc.nodes["out/tint"].fields["color"], "$ink");
519    }
520
521    #[test]
522    fn missing_required_input_errors() {
523        let json = BASIC.replace(r#", "base": "@bg""#, "");
524        let err = expand(&json).unwrap_err();
525        assert!(matches!(err, ExpandError::MissingInput { input, .. } if input == "base"));
526    }
527
528    #[test]
529    fn unknown_input_errors() {
530        let json = BASIC.replace(r##""color": "#00ff00""##, r##""colour": "#00ff00""##);
531        let err = expand(&json).unwrap_err();
532        assert!(matches!(err, ExpandError::UnknownInput { input, .. } if input == "colour"));
533    }
534
535    #[test]
536    fn non_scalar_input_requires_node_arg() {
537        let json = BASIC.replace(r#""base": "@bg""#, r#""base": 3"#);
538        let err = expand(&json).unwrap_err();
539        assert!(matches!(err, ExpandError::NodeArgExpected { input, .. } if input == "base"));
540    }
541
542    #[test]
543    fn unknown_body_ref_errors() {
544        let json = BASIC.replace(r#""base": "@base""#, r#""base": "@nope""#);
545        let err = expand(&json).unwrap_err();
546        assert!(matches!(err, ExpandError::UnknownRef { reference, .. } if reference == "nope"));
547    }
548
549    #[test]
550    fn nested_function_calls_expand() {
551        let json = r##"{
552          "name": "demo",
553          "functions": {
554            "white": {
555              "inputs": {},
556              "output": "@w",
557              "output-kind": "raster",
558              "nodes": { "w": { "op": "solid", "color": "#ffffff" } }
559            },
560            "framed": {
561              "inputs": {},
562              "output": "@mix",
563              "output-kind": "raster",
564              "nodes": {
565                "fill": { "op": "func", "fn": "white" },
566                "mix":  { "op": "blend", "base": "@fill", "over": "@fill" }
567              }
568            }
569          },
570          "nodes": { "out": { "op": "func", "fn": "framed" } },
571          "output": "@out"
572        }"##;
573        let doc = expanded(json);
574        let ids: Vec<&str> = doc.nodes.keys().map(String::as_str).collect();
575        // `fill` is itself a call: its body's output node takes the
576        // (mangled) call id `out/fill`.
577        assert_eq!(ids, ["out/fill", "out"]);
578        assert_eq!(doc.nodes["out"].fields["base"], "@out/fill");
579    }
580
581    #[test]
582    fn recursive_calls_error_with_path() {
583        let json = r##"{
584          "name": "demo",
585          "functions": {
586            "a": { "inputs": {}, "output": "@n", "output-kind": "raster",
587                   "nodes": { "n": { "op": "func", "fn": "b" } } },
588            "b": { "inputs": {}, "output": "@n", "output-kind": "raster",
589                   "nodes": { "n": { "op": "func", "fn": "a" } } }
590          },
591          "nodes": { "out": { "op": "func", "fn": "a" } },
592          "output": "@out"
593        }"##;
594        let err = expand(json).unwrap_err();
595        let msg = err.to_string();
596        assert!(
597            msg.contains("a → b → a") || msg.contains("b → a → b"),
598            "{msg}"
599        );
600    }
601
602    #[test]
603    fn no_functions_is_a_noop() {
604        let json = r##"{
605          "name": "demo",
606          "nodes": { "out": { "op": "solid", "color": "#ffffff" } },
607          "output": "@out"
608        }"##;
609        assert!(expand(json).unwrap().is_none());
610    }
611
612    #[test]
613    fn substitution_recurses_into_arrays() {
614        let json = r##"{
615          "name": "demo",
616          "functions": {
617            "ramp": {
618              "inputs": { "lo": { "kind": "scalar", "default": "#000000" } },
619              "output": "@g",
620              "output-kind": "raster",
621              "nodes": {
622                "g": { "op": "gradient-linear",
623                       "stops": [[0, "@lo"], [1, "#ffffff"]] }
624              }
625            }
626          },
627          "nodes": { "out": { "op": "func", "fn": "ramp", "lo": "#101010" } },
628          "output": "@out"
629        }"##;
630        let doc = expanded(json);
631        assert_eq!(doc.nodes["out"].fields["stops"][0][1], "#101010");
632    }
633
634    #[test]
635    fn null_arg_drops_the_substituted_field() {
636        let json = r##"{
637          "name": "demo",
638          "functions": {
639            "stroke": {
640              "inputs": {
641                "curve": { "kind": "scalar", "default": null }
642              },
643              "output": "@n",
644              "output-kind": "raster",
645              "nodes": {
646                "n": { "op": "solid", "color": "#ffffff",
647                       "radius-stroke-curve": "@curve" }
648              }
649            }
650          },
651          "nodes": {
652            "a": { "op": "func", "fn": "stroke" },
653            "b": { "op": "func", "fn": "stroke", "curve": [[0, -1.0], [1, 0.0]] }
654          },
655          "output": "@a"
656        }"##;
657        let doc = expanded(json);
658        // Omitted arg -> null default -> field dropped entirely.
659        assert!(!doc.nodes["a"].fields.contains_key("radius-stroke-curve"));
660        // Array arg substitutes verbatim.
661        assert_eq!(doc.nodes["b"].fields["radius-stroke-curve"][0][1], -1.0);
662    }
663
664    #[test]
665    fn kind_checks_record_call_sites() {
666        let e = expand(BASIC).unwrap().unwrap();
667        // One check for the node-fed `base` arg, one for the output.
668        assert_eq!(e.kind_checks.len(), 2);
669        let arg = &e.kind_checks[0];
670        assert_eq!(arg.node, "bg");
671        assert_eq!(arg.declared, FuncKind::Raster);
672        assert_eq!(arg.input.as_deref(), Some("base"));
673        let out = &e.kind_checks[1];
674        assert_eq!(out.node, "out");
675        assert!(out.input.is_none());
676    }
677}