Skip to main content

dataflow_rs/program/
compile.rs

1//! The compiler: one walk over a definition that compiles every expression
2//! and config, reports every issue at its authored path, fills the side
3//! tables the ops index, and lowers each step into ops as it reaches it.
4
5use super::checks::{Below, Checks, Hook, Site, Stamp};
6use super::fingerprint::Fingerprint;
7use super::lower::Lower;
8use super::ops::{CallSite, Callee, CondKind, CondSite, FanSite, LoopSite, Op, Policy, TaskMeta};
9use crate::base::any_rejected;
10use crate::base::typo::near_miss;
11use crate::base::{AuthoredPath, Issue, IssueCode, PathSeg, StepKind};
12use crate::def::{
13    self, ForEachDef, GroupDef, Logic, LoopDef, ParseOptions, Rollout, Status, StepDef, TaskDef,
14    WorkflowDef, WorkflowFailurePolicy,
15};
16use crate::expr::path::MAX_PATH_SEGMENTS;
17use crate::expr::{CompileCx, Expr, ExprEngine, Path};
18use crate::function::{Entry, FunctionSpec, Prepare, Registry};
19use datavalue::OwnedDataValue;
20use serde_json::Value;
21use std::fmt;
22use std::str::FromStr;
23use std::sync::Arc;
24
25/// Answers whether a definition that a config names exists, such as a
26/// connector a deployment declares, and why not when it does not.
27///
28/// A [`Compiler`] given one through [`CompileOptions::resources`] checks each
29/// [`Reference`](crate::function::Reference) a host function's spec declares.
30/// A field holding a literal string the oracle does not resolve is an
31/// `UNRESOLVED_REFERENCE` Defect, whose message leads with the oracle's
32/// reason when it gives one. A computed or absent value is not checked.
33///
34/// ```
35/// use dataflow_rs::program::ResourceOracle;
36///
37/// /// The deployment's connectors: `orders` a database, `sessions` a cache.
38/// struct Connectors;
39///
40/// impl ResourceOracle for Connectors {
41///     fn resolve(&self, _kind: &str, name: &str) -> Result<(), String> {
42///         match name {
43///             "orders" => Ok(()),
44///             "sessions" => Err(format!("connector `{name}` is a cache, not a database")),
45///             _ => Err(String::new()),
46///         }
47///     }
48/// }
49/// ```
50pub trait ResourceOracle: Send + Sync + 'static {
51    /// `Ok` when a `kind` named `name` exists: the connector `db1`, say.
52    /// `Err(why)` when it does not. A `why` that is not empty is the issue's
53    /// message, after what did not resolve: `` `sessions` does not resolve
54    /// as a connector: connector `sessions` is a cache, not a database ``.
55    /// An empty one gives the plain message: `` no connector named `db2`
56    /// resolves ``.
57    ///
58    /// # Errors
59    ///
60    /// Why `name` does not resolve, or nothing more to say: an empty string.
61    fn resolve(&self, kind: &str, name: &str) -> std::result::Result<(), String>;
62}
63
64/// How a [`Compiler`] compiles.
65#[derive(Clone, Default)]
66#[non_exhaustive]
67pub struct CompileOptions {
68    /// The oracle the `Reference` fields are checked against. `None`, the
69    /// default, checks nothing.
70    pub resources: Option<Arc<dyn ResourceOracle>>,
71}
72
73impl fmt::Debug for CompileOptions {
74    /// Whether an oracle is set; not the oracle.
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.debug_struct("CompileOptions")
77            .field("resources", &self.resources.is_some())
78            .finish_non_exhaustive()
79    }
80}
81
82impl CompileOptions {
83    /// Checks the `Reference` fields against `oracle`.
84    #[must_use]
85    pub fn resources(mut self, oracle: Arc<dyn ResourceOracle>) -> Self {
86        self.resources = Some(oracle);
87        self
88    }
89}
90
91impl Registry {
92    /// A compiler for this registry's functions, with `options`.
93    pub fn compiler(&self, options: CompileOptions) -> Compiler {
94        Compiler {
95            registry: self.clone(),
96            options,
97        }
98    }
99}
100
101/// Compiles definitions against one [`Registry`], so everything it compiles
102/// shares one set of functions and one vocabulary.
103///
104/// Each workflow compiles on its own: an unregistered function, or any other
105/// Rejected issue, rejects only its own workflow.
106///
107/// ```
108/// use dataflow_rs::Registry;
109/// use dataflow_rs::def::{ParseOptions, parse};
110/// use dataflow_rs::program::{CompileOptions, IssueCode};
111/// use serde_json::json;
112///
113/// let compiler = Registry::default().compiler(CompileOptions::default());
114/// let json = json!({"id": "w", "tasks": [
115///     {"id": "t", "function": {"name": "fetch"}}
116/// ]});
117/// let def = parse(&json, ParseOptions::default()).def.unwrap();
118/// let issues = compiler.compile_one(&def).unwrap_err();
119/// assert_eq!(issues[0].code, IssueCode::UnknownFunction);
120/// assert_eq!(issues[0].path.to_string(), "tasks[0].function.name");
121/// assert_eq!(compiler.check(&def), issues);
122/// ```
123#[derive(Clone, Debug)]
124pub struct Compiler {
125    pub(super) registry: Registry,
126    pub(super) options: CompileOptions,
127}
128
129impl Compiler {
130    /// The registry the compiler resolves functions in.
131    pub fn registry(&self) -> &Registry {
132        &self.registry
133    }
134
135    /// Every issue [`compile_one`](Self::compile_one) finds in `def`, whether
136    /// or not it rejects the workflow.
137    pub fn check(&self, def: &WorkflowDef) -> Vec<Issue> {
138        match self.compile_one(def) {
139            Ok(compiled) => compiled.issues,
140            Err(issues) => issues,
141        }
142    }
143
144    /// Every issue in a fragment of steps that would sit at `root` in a
145    /// workflow, such as `tasks[2].tasks` or `loop.setup`: its parse issues,
146    /// then its compile issues, every path under `root`.
147    ///
148    /// The fragment is parsed as [`parse`](crate::def::parse) reads a
149    /// workflow's steps, with the compat reader on. A root under `loop.setup`
150    /// makes setup steps, and each `tasks` key in `root` past the first adds
151    /// one group of depth. The steps compile only when nothing in the parse is
152    /// Rejected, and a fragment has no workflow condition, loop or workflow
153    /// lint. A host function's `prepare` sees an empty workflow id.
154    ///
155    /// A `root` that is not an authored path is `PARSE_FAILED` at the empty
156    /// path. A root that holds no steps, one neither ending in a `tasks` key
157    /// nor `loop.setup`, is `PARSE_FAILED` at the root, and so is a fragment
158    /// that is not an array.
159    pub fn check_steps(&self, steps: &Value, root: &str) -> Vec<Issue> {
160        let root = match AuthoredPath::from_str(root) {
161            Ok(root) => root,
162            Err(error) => {
163                return vec![Issue::new(
164                    IssueCode::ParseFailed,
165                    AuthoredPath::root(),
166                    error.to_string(),
167                )];
168            }
169        };
170        let Some((setup, _)) = def::fragment_place(&root) else {
171            let message = format!(
172                "`{root}` holds no steps: a fragment sits at a `tasks` list, such as `tasks` or \
173                 `tasks[2].tasks`, or at `loop.setup`"
174            );
175            return vec![Issue::new(IssueCode::ParseFailed, root, message)];
176        };
177        let (parsed, mut issues) = def::parse_steps(steps, &root, ParseOptions::default());
178        if let Some(steps) = parsed {
179            let mut checks = Checks::new(self.registry.exprs());
180            let mut walk = Walk::new(self, "", &mut checks);
181            walk.steps(&steps, &root, setup);
182            issues.append(&mut walk.issues);
183        }
184        issues
185    }
186
187    /// Compiles one workflow.
188    ///
189    /// `Err` holds every issue, in walk order, when any is Rejected. `Ok`
190    /// holds the workflow and its Defects and Advisories, so seeing them
191    /// takes no second compile.
192    pub fn compile_one(&self, def: &WorkflowDef) -> std::result::Result<Compiled, Vec<Issue>> {
193        let mut checks = Checks::new(self.registry.exprs());
194        let (workflow, issues) = self.compile_with(def, &mut checks);
195        if any_rejected(&issues) {
196            return Err(issues);
197        }
198        // O(ops), once per workflow: the interpreter trusts every one.
199        if let Err(broken) = workflow.check_invariants() {
200            panic!(
201                "the lowering of `{}` broke an invariant: {broken}\n{}",
202                workflow.id,
203                workflow.listing(),
204            );
205        }
206        Ok(Compiled { workflow, issues })
207    }
208
209    /// The compile walk, calling `hook` at every expression site.
210    fn compile_with(
211        &self,
212        def: &WorkflowDef,
213        hook: &mut dyn Hook,
214    ) -> (CompiledWorkflow, Vec<Issue>) {
215        let mut walk = Walk::new(self, &def.id, hook);
216        let root = AuthoredPath::root();
217        // 1. The workflow condition: `None` when it is absent or literally
218        //    true, so it lowers to no `Cond`.
219        let cond = def
220            .condition
221            .as_ref()
222            .and_then(|raw| walk.cond(raw, root.join("condition"), None));
223        // 2. The loop: `over`, then its slots. 3. Each step: setup first, then
224        //    the body, pre-order, each lowered as the walk reaches it.
225        let body = root.join("tasks");
226        match &def.r#loop {
227            None => {
228                walk.lower.workflow_cond(cond);
229                walk.steps(&def.tasks, &body, false);
230            }
231            Some(config) => {
232                let lp = walk.r#loop(config);
233                // The pre-phase, before the first sweep: only a loop with
234                // setup or `over` has one.
235                if !config.setup.is_empty() || config.over.is_some() {
236                    walk.lower.workflow_cond(cond);
237                }
238                walk.steps(&config.setup, &root.join("loop").join("setup"), true);
239                let sweep = walk.lower.loop_init(lp);
240                walk.lower.workflow_cond(cond);
241                walk.steps(&def.tasks, &body, false);
242                walk.lower.loop_next(lp, sweep);
243            }
244        }
245        // 4. The workflow lint, `UNGUARDED_VALIDATION`.
246        walk.hook.workflow(def, &mut walk.issues);
247        let workflow = CompiledWorkflow {
248            registry: self.registry.clone(),
249            id: def.id.clone(),
250            channel: def.channel.clone(),
251            priority: def.priority,
252            status: def.status,
253            version: def.version,
254            rollout: def.rollout,
255            on_failure: def.on_failure,
256            ops: walk.lower.finish(),
257            conds: walk.conds,
258            calls: walk.calls,
259            fans: walk.fans,
260            loops: walk.loops,
261            tasks: walk.tasks,
262            fingerprint: walk.hook.fingerprint(),
263        };
264        (workflow, walk.issues)
265    }
266}
267
268/// A workflow [`Compiler::compile_one`] accepted, and what it found.
269#[derive(Debug)]
270#[non_exhaustive]
271pub struct Compiled {
272    /// The compiled workflow.
273    pub workflow: CompiledWorkflow,
274    /// Its Defects and Advisories, in walk order. None is Rejected.
275    pub issues: Vec<Issue>,
276}
277
278/// One workflow, compiled and immutable. `Send + Sync`.
279pub struct CompiledWorkflow {
280    /// The registry it was compiled under: [`Compiler::link`] links it under
281    /// that one only. Holding it keeps its identity from being reused.
282    pub(crate) registry: Registry,
283    pub(crate) id: String,
284    pub(crate) channel: String,
285    pub(crate) priority: u32,
286    pub(crate) status: Status,
287    pub(crate) version: u32,
288    pub(crate) rollout: Option<Rollout>,
289    pub(crate) on_failure: WorkflowFailurePolicy,
290    pub(crate) ops: Vec<Op>,
291    pub(crate) conds: Vec<CondSite>,
292    pub(crate) calls: Vec<CallSite>,
293    pub(crate) fans: Vec<FanSite>,
294    pub(crate) loops: Vec<LoopSite>,
295    pub(crate) tasks: Vec<TaskMeta>,
296    pub(crate) fingerprint: Fingerprint,
297}
298
299impl CompiledWorkflow {
300    /// The workflow's id.
301    pub fn id(&self) -> &str {
302        &self.id
303    }
304
305    /// The workflow's fingerprint: the operators and data keys its
306    /// expressions use.
307    pub fn fingerprint(&self) -> Fingerprint {
308        self.fingerprint
309    }
310}
311
312impl fmt::Debug for CompiledWorkflow {
313    /// The id, the op count and the size of each side table.
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.debug_struct("CompiledWorkflow")
316            .field("id", &self.id)
317            .field("ops", &self.ops.len())
318            .field("conds", &self.conds.len())
319            .field("calls", &self.calls.len())
320            .field("fans", &self.fans.len())
321            .field("loops", &self.loops.len())
322            .field("tasks", &self.tasks.len())
323            .finish_non_exhaustive()
324    }
325}
326
327/// Where a task's function is compiled: what [`compile_callee`] needs
328/// beside the function and its input.
329pub(crate) struct CalleeAt<'a> {
330    /// The function's registered name.
331    pub(crate) name: &'a str,
332    /// The workflow's id, which `prepare` is told; empty for a fragment.
333    pub(crate) workflow_id: &'a str,
334    /// The task's step, stamped on every issue.
335    pub(crate) step: (StepKind, &'a str),
336    /// Where the config sits: the task's `function.input`.
337    pub(crate) input_at: &'a AuthoredPath,
338}
339
340/// `entry`'s config from `input`, compiled as a task's at `at`: a host
341/// function's with its spec's runtime fields and a [`Prepare`] naming the
342/// task; a config that refuses its input without recording why reported, as
343/// [`refused`]; then, when it compiled, its expressions walked with `hook`.
344/// The compiler compiles every task's function through it, and
345/// `function::testing` the one function a unit test calls, so a check
346/// added here reaches both.
347pub(crate) fn compile_callee(
348    exprs: &ExprEngine,
349    entry: Entry<'_>,
350    input: &Value,
351    at: &CalleeAt<'_>,
352    hook: &mut dyn Hook,
353    issues: &mut Vec<Issue>,
354) -> Option<Callee> {
355    let callee = {
356        let mut cx = CompileCx::new(exprs, issues)
357            .at_path(at.input_at.clone())
358            .for_step(at.step.0, at.step.1);
359        let before = cx.rejected();
360        let callee = match entry {
361            Entry::Builtin(kind) => kind.compile(input, &mut cx).map(Callee::Builtin),
362            Entry::Host(f) => {
363                if let Some(fields) = &f.spec().runtime_fields {
364                    cx = cx.with_runtime_fields(fields);
365                }
366                let prepare = Prepare::new(at.name, at.workflow_id, at.step.1, f.spec());
367                f.compile(input, &mut cx, &prepare).map(Callee::Host)
368            }
369        };
370        // A hand-written config can refuse without recording why. Its task
371        // would then have no call, so the refusal is reported.
372        if callee.is_none() && cx.rejected() == before {
373            refused(&mut cx, at.name);
374        }
375        callee
376    }?;
377    callee.visit(&mut Below {
378        hook,
379        issues,
380        root: at.input_at,
381        step: Some(at.step),
382    });
383    Some(callee)
384}
385
386/// A config of `name` that refused its input without recording why,
387/// reported, so a rejection always carries an issue.
388pub(crate) fn refused(cx: &mut CompileCx<'_>, name: &str) {
389    cx.issue(
390        IssueCode::InputParse,
391        format!("the config of `{name}` refused its input without reporting why"),
392    );
393}
394
395/// A table's next index.
396pub(super) fn next(len: usize) -> u32 {
397    u32::try_from(len).expect("a workflow holds fewer than 2^32 steps")
398}
399
400/// The value at `path` below `value`, if there is one.
401fn lookup<'v>(value: &'v Value, path: &AuthoredPath) -> Option<&'v Value> {
402    path.segs().iter().try_fold(value, |value, seg| match seg {
403        PathSeg::Key(key) => value.get(&**key),
404        PathSeg::Index(index) => value.get(*index),
405    })
406}
407
408/// One compile's state: the issues so far, in walk order, the side tables,
409/// in walk order, and the ops so far.
410struct Walk<'w> {
411    registry: &'w Registry,
412    resources: Option<&'w dyn ResourceOracle>,
413    /// What `prepare` is told; empty for a fragment.
414    workflow_id: &'w str,
415    hook: &'w mut dyn Hook,
416    issues: Vec<Issue>,
417    conds: Vec<CondSite>,
418    calls: Vec<CallSite>,
419    fans: Vec<FanSite>,
420    loops: Vec<LoopSite>,
421    tasks: Vec<TaskMeta>,
422    lower: Lower,
423}
424
425impl<'w> Walk<'w> {
426    fn new(compiler: &'w Compiler, workflow_id: &'w str, hook: &'w mut dyn Hook) -> Self {
427        Self {
428            registry: &compiler.registry,
429            resources: compiler.options.resources.as_deref(),
430            workflow_id,
431            hook,
432            issues: Vec::new(),
433            conds: Vec::new(),
434            calls: Vec::new(),
435            fans: Vec::new(),
436            loops: Vec::new(),
437            tasks: Vec::new(),
438            lower: Lower::default(),
439        }
440    }
441
442    /// Records `issue`, stamped with `step`.
443    fn report(&mut self, issue: Issue, step: Stamp<'_>) {
444        self.issues.push(match step {
445            Some((kind, id)) => issue.with_step(kind, Some(id)),
446            None => issue,
447        });
448    }
449
450    /// Compiles the expression at `at` and, when it compiled, hands it to the
451    /// hook. A failure is `TEMPLATE_COMPILE` at `at`, and is not visited.
452    fn expr(
453        &mut self,
454        raw: &Logic,
455        at: &AuthoredPath,
456        records: bool,
457        step: Stamp<'_>,
458    ) -> Option<Expr> {
459        match self.registry.exprs().compile::<OwnedDataValue>(&raw.0) {
460            Ok(expr) => {
461                let site = Site {
462                    at,
463                    records,
464                    step,
465                    field: None,
466                };
467                self.hook
468                    .expr(site, expr.raw(), Some(&expr), &mut self.issues);
469                Some(expr)
470            }
471            Err(error) => {
472                let issue = Issue::new(IssueCode::TemplateCompile, at.clone(), error.message());
473                self.report(issue, step);
474                None
475            }
476        }
477    }
478
479    /// A condition at `at`: compiled and visited as not recorded. Its row in
480    /// `conds`, or `None` when it did not compile or folds to the literal
481    /// `true`, which keeps no row and lowers to no `Cond`, so a trivially
482    /// true condition costs nothing at run time.
483    fn cond(&mut self, raw: &Logic, at: AuthoredPath, step: Stamp<'_>) -> Option<u32> {
484        let expr = self.expr(raw, &at, false, step)?;
485        if expr.is_trivially_true() {
486            return None;
487        }
488        let row = next(self.conds.len());
489        self.conds.push(CondSite {
490            expr,
491            step: step.map(|(_, id)| id.to_owned()),
492            path: at,
493        });
494        Some(row)
495    }
496
497    /// `dotted` as a context path, else `INVALID_PATH` at `at`. Parse has
498    /// already checked every binding, and a definition cannot change after
499    /// it; this reports, rather than panics, if the two rules ever drift.
500    fn path(&mut self, dotted: &str, at: AuthoredPath, step: Stamp<'_>) -> Option<Path> {
501        match Path::from_str(dotted) {
502            Ok(path) => Some(path),
503            Err(error) => {
504                let issue = Issue::new(IssueCode::InvalidPath, at, error.message());
505                self.report(issue, step);
506                None
507            }
508        }
509    }
510
511    /// The loop's `over`, recorded, then its slots, each
512    /// `temp_data.<slot>`. Returns its row in `loops`.
513    ///
514    /// The bounds are `parse`'s: a definition cannot be edited after it, so
515    /// the interpreter's termination (`increment >= 1`) holds by
516    /// construction.
517    fn r#loop(&mut self, config: &LoopDef) -> u32 {
518        let at = AuthoredPath::root().join("loop");
519        let over = config
520            .over
521            .as_ref()
522            .and_then(|raw| self.expr(raw, &at.join("over"), true, None));
523        let mut slot = |slot: &Option<String>, key: &str| {
524            slot.as_ref()
525                .and_then(|slot| self.path(&format!("temp_data.{slot}"), at.join(key), None))
526        };
527        let counter = slot(&config.counter, "counter");
528        let item = slot(&config.r#as, "as");
529        let scratch = slot(&config.scratch, "scratch");
530        let row = next(self.loops.len());
531        self.loops.push(LoopSite {
532            init: config.init,
533            increment: config.increment,
534            max: config.max,
535            counter,
536            over,
537            r#as: item,
538            scratch,
539        });
540        row
541    }
542
543    /// The steps of the list at `at`, pre-order: each group before its
544    /// members, every one a setup step when `setup`. This is
545    /// [`WorkflowDef::walk`]'s order.
546    fn steps(&mut self, steps: &[StepDef], at: &AuthoredPath, setup: bool) {
547        for (i, step) in steps.iter().enumerate() {
548            let at = at.join(i);
549            let kind = step.kind(setup);
550            match step {
551                StepDef::Group(group) => self.group(&at, kind, group, setup),
552                StepDef::Task(task) => self.task(&at, kind, task),
553            }
554        }
555    }
556
557    /// A group: its condition, its members, then a `Halt` when it is
558    /// terminal. A false condition jumps past the `Halt`.
559    fn group(&mut self, at: &AuthoredPath, kind: StepKind, group: &GroupDef, setup: bool) {
560        let cond = group
561            .condition
562            .as_ref()
563            .and_then(|raw| self.cond(raw, at.join("condition"), Some((kind, &group.id))));
564        let open = self.lower.open(cond, CondKind::Group);
565        self.steps(&group.tasks, &at.join("tasks"), setup);
566        if group.terminal {
567            self.lower.halt();
568        }
569        self.lower.close(open);
570    }
571
572    /// A task: its condition, its fan-out, then its call, lowered to its
573    /// `Cond` and its `Call` or `FanOut`.
574    fn task(&mut self, at: &AuthoredPath, kind: StepKind, task: &TaskDef) {
575        let step = (kind, task.id.as_str());
576        let cond = task
577            .condition
578            .as_ref()
579            .and_then(|raw| self.cond(raw, at.join("condition"), Some(step)));
580        let fan = task.for_each.as_ref().and_then(|config| {
581            let fan = self.fan(config, &at.join("for_each"), step)?;
582            let row = next(self.fans.len());
583            self.fans.push(fan);
584            Some(row)
585        });
586        let row = next(self.tasks.len());
587        self.tasks.push(TaskMeta {
588            id: task.id.clone(),
589            function: task.function.name.clone(),
590            kind,
591            path: at.clone(),
592        });
593        let site = self.callee(task, at, step).map(|callee| {
594            let site = next(self.calls.len());
595            self.calls.push(CallSite {
596                task: row,
597                callee,
598                policy: Policy {
599                    on_failure: task.on_failure,
600                    on_rejection: task.on_rejection,
601                    terminal: task.terminal,
602                },
603            });
604            site
605        });
606        let open = self.lower.open(cond, CondKind::Task);
607        match (site, task.for_each.is_some(), fan) {
608            (Some(site), false, _) => self.lower.call(site),
609            (Some(site), true, Some(fan)) => self.lower.fan_out(site, fan),
610            // A Rejected issue is recorded, so the workflow is never lowered
611            // for use: the task emits nothing.
612            _ => {}
613        }
614        self.lower.close(open);
615    }
616
617    /// A fan-out at `at`: its `over`, recorded, then its bindings as
618    /// paths, `into` with room for the index each element's result lands
619    /// at.
620    ///
621    /// The fan-out's rules are `parse`'s: a definition cannot be edited
622    /// after it, so no call starts under `max_concurrency: 0` and no `into`
623    /// is a root.
624    fn fan(
625        &mut self,
626        config: &ForEachDef,
627        at: &AuthoredPath,
628        step: (StepKind, &str),
629    ) -> Option<FanSite> {
630        let over = self.expr(&config.over, &at.join("over"), true, Some(step));
631        let item = format!("temp_data.{}", config.r#as);
632        let bound = self
633            .path(&item, at.join("as"), Some(step))
634            .and_then(|item_path| {
635                let index = self.path(&format!("{item}_index"), at.join("as"), Some(step))?;
636                Some((item_path, index))
637            });
638        let mut context = |path: &Option<String>, key: &str| {
639            path.as_ref()
640                .and_then(|path| self.path(path, at.join(key), Some(step)))
641        };
642        let collect = context(&config.collect, "collect");
643        let into = context(&config.into, "into").and_then(|into| {
644            if into.segs().len() < MAX_PATH_SEGMENTS {
645                return Some(into);
646            }
647            let message = format!(
648                "path `{into}` has {MAX_PATH_SEGMENTS} segments, and each element's result \
649                 lands one segment below it; a path holds at most {MAX_PATH_SEGMENTS}"
650            );
651            let issue = Issue::new(IssueCode::InvalidPath, at.join("into"), message);
652            self.report(issue, Some(step));
653            None
654        });
655        let (over, (item, index)) = over.zip(bound)?;
656        Some(FanSite {
657            over,
658            r#as: item,
659            index,
660            max_concurrency: config.max_concurrency,
661            collect,
662            into,
663        })
664    }
665
666    /// The task's function: resolved, its config compiled at
667    /// `<at>.function.input` and visited ([`compile_callee`]), and its
668    /// references checked. `None` when the name is unknown, which is
669    /// `UNKNOWN_FUNCTION` naming the nearest catalogue name if one is close,
670    /// or the config was Rejected.
671    fn callee(
672        &mut self,
673        task: &TaskDef,
674        at: &AuthoredPath,
675        step: (StepKind, &str),
676    ) -> Option<Callee> {
677        let registry = self.registry;
678        let name = &task.function.name;
679        let Some(entry) = registry.entry(name) else {
680            let names: Vec<&str> = registry.catalogue().map(|f| f.name).collect();
681            let message = match near_miss(name, &names) {
682                Some(near) => format!("no function `{name}` is registered; did you mean `{near}`?"),
683                None => format!("no function `{name}` is registered"),
684            };
685            let issue = Issue::new(
686                IssueCode::UnknownFunction,
687                at.join("function").join("name"),
688                message,
689            );
690            self.report(issue, Some(step));
691            return None;
692        };
693        let input_at = at.join("function").join("input");
694        let input = &task.function.input;
695        let site = CalleeAt {
696            name,
697            workflow_id: self.workflow_id,
698            step,
699            input_at: &input_at,
700        };
701        let callee = compile_callee(
702            registry.exprs(),
703            entry,
704            input,
705            &site,
706            &mut *self.hook,
707            &mut self.issues,
708        )?;
709        if let Entry::Host(f) = entry {
710            self.references(f.spec(), input, &input_at, step);
711        }
712        Some(callee)
713    }
714
715    /// The oracle, over each `Reference` in `spec`: a literal string at the
716    /// field below `input` that it does not resolve is
717    /// `UNRESOLVED_REFERENCE` at the field. A field that is not an authored
718    /// path names nothing, and is not checked.
719    fn references(
720        &mut self,
721        spec: &FunctionSpec,
722        input: &Value,
723        input_at: &AuthoredPath,
724        step: (StepKind, &str),
725    ) {
726        let Some(oracle) = self.resources else {
727            return;
728        };
729        let mut cx = CompileCx::new(self.registry.exprs(), &mut self.issues)
730            .at_path(input_at.clone())
731            .for_step(step.0, step.1);
732        for reference in &spec.references {
733            let Ok(field) = AuthoredPath::from_str(&reference.field) else {
734                continue;
735            };
736            if let Some(Value::String(name)) = lookup(input, &field)
737                && let Err(why) = oracle.resolve(&reference.kind, name)
738            {
739                let kind = &reference.kind;
740                let message = if why.is_empty() {
741                    format!("no {kind} named `{name}` resolves")
742                } else {
743                    format!("`{name}` does not resolve as a {kind}: {why}")
744                };
745                cx.adopt(Issue::new(IssueCode::UnresolvedReference, field, message));
746            }
747        }
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use crate::base::Result;
755    use crate::def::{FailurePolicy, RejectionPolicy, parse};
756    use crate::function::builtins::{Builtin, BuiltinKind};
757    use crate::function::{Ctx, Function, FunctionConfig, Outcome};
758    use serde_json::json;
759
760    #[derive(FunctionConfig)]
761    struct EchoConfig {
762        #[config(records)]
763        text: Expr<String>,
764        label: Option<Expr<String>>,
765    }
766
767    struct Echo;
768
769    impl Function for Echo {
770        type Config = EchoConfig;
771        async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &EchoConfig) -> Result<Outcome> {
772            Ok(Outcome::Ok)
773        }
774    }
775
776    /// A step stamp, owned.
777    type Step = Option<(StepKind, String)>;
778
779    /// Every site the hook was called at: its path, `records`, and stamp.
780    #[derive(Default)]
781    struct Recorder(Vec<(String, bool, Step)>);
782
783    impl Hook for Recorder {
784        fn expr(
785            &mut self,
786            site: Site<'_>,
787            _raw: &Value,
788            _compiled: Option<&dyn crate::expr::ResolveExpr>,
789            _issues: &mut Vec<Issue>,
790        ) {
791            let step = site.step.map(|(kind, id)| (kind, id.to_owned()));
792            self.0.push((site.at.to_string(), site.records, step));
793        }
794    }
795
796    fn compiler() -> Compiler {
797        let mut registry = Registry::default();
798        registry.register("echo", Echo);
799        registry.compiler(CompileOptions::default())
800    }
801
802    /// A workflow with an expression at every kind of site, and every
803    /// workflow fact set.
804    fn every_site() -> WorkflowDef {
805        let json = json!({
806            "id": "w", "channel": "orders", "priority": 4, "status": "paused", "version": 3,
807            "on_failure": "continue", "rollout": {"bucket_start": 10, "bucket_end": 60},
808            "condition": {"var": "metadata.go"},
809            "loop": {
810                "counter": "i", "init": 1, "increment": 2, "max": 9,
811                "over": {"var": "data.batches"}, "as": "batch", "scratch": "acc",
812                "setup": [{"id": "s", "condition": {"var": "data.s"},
813                           "function": {"name": "log", "input": {"message": "start"}}}],
814            },
815            "tasks": [
816                {"id": "g", "condition": {"var": "data.g"}, "tasks": [
817                    {"id": "f", "condition": {"var": "data.f"},
818                     "for_each": {"over": {"var": "data.items"}, "as": "item",
819                                  "max_concurrency": 2, "collect": "temp_data.price",
820                                  "into": "temp_data.acc.prices"},
821                     "on_failure": "continue", "on_rejection": "halt",
822                     "function": {"name": "echo", "input": {
823                         "text": {"var": "temp_data.item"}, "label": "x"}}},
824                ]},
825                {"id": "m", "terminal": true, "function": {"name": "map", "input": {
826                    "mappings": [{"path": "data.x", "logic": {"var": "data.y"}}]}}},
827            ],
828        });
829        let parsed = parse(&json, ParseOptions::default());
830        parsed
831            .def
832            .unwrap_or_else(|| panic!("the definition parses: {:?}", parsed.issues))
833    }
834
835    fn task(id: &str) -> Step {
836        Some((StepKind::Task, id.to_owned()))
837    }
838
839    #[test]
840    fn the_hook_sees_every_site_at_its_absolute_path() {
841        let mut recorder = Recorder::default();
842        let (_, issues) = compiler().compile_with(&every_site(), &mut recorder);
843        assert!(issues.is_empty(), "{issues:?}");
844        let site = |at: &str, records: bool, step: Step| (at.to_owned(), records, step);
845        let setup = Some((StepKind::Setup, "s".to_owned()));
846        let group = Some((StepKind::Group, "g".to_owned()));
847        assert_eq!(
848            recorder.0,
849            [
850                site("condition", false, None),
851                site("loop.over", true, None),
852                site("loop.setup[0].condition", false, setup.clone()),
853                site("loop.setup[0].function.input.message", true, setup),
854                site("tasks[0].condition", false, group),
855                site("tasks[0].tasks[0].condition", false, task("f")),
856                site("tasks[0].tasks[0].for_each.over", true, task("f")),
857                site("tasks[0].tasks[0].function.input.text", true, task("f")),
858                site("tasks[0].tasks[0].function.input.label", false, task("f")),
859                site("tasks[1].function.input.mappings[0].path", true, task("m")),
860                site("tasks[1].function.input.mappings[0].logic", true, task("m")),
861            ]
862        );
863    }
864
865    #[test]
866    fn the_compile_walk_visits_the_steps_in_def_walk_order() {
867        // `UNGUARDED_VALIDATION` reads its chain from `WorkflowDef::walk`, and
868        // everything else from the compile walk: the two orders are one.
869        // Every step has a condition, so the hook sees each step once.
870        let c = |id: &str| json!({"var": format!("data.{id}")});
871        let t = |id: &str| {
872            json!({"id": id, "condition": c(id),
873                   "function": {"name": "log", "input": {"message": "m"}}})
874        };
875        let json = json!({"id": "w", "loop": {"max": 2, "setup": [
876                t("s1"),
877                {"id": "sg", "condition": c("sg"), "tasks": [t("s2"), t("s3")]},
878            ]},
879            "tasks": [
880                {"id": "a", "condition": c("a"), "tasks": [
881                    {"id": "b", "condition": c("b"), "tasks": [t("t1")]},
882                    t("t2"),
883                    {"id": "c", "condition": c("c"), "tasks": [
884                        {"id": "d", "condition": c("d"), "tasks": [t("t3")]},
885                    ]},
886                ]},
887                t("t4"),
888            ],
889        });
890        let def = parse(&json, ParseOptions::default()).def.unwrap();
891        let mut recorder = Recorder::default();
892        let (_, issues) = compiler().compile_with(&def, &mut recorder);
893        assert!(issues.is_empty(), "{issues:?}");
894        let walked: Vec<(String, Step)> = recorder
895            .0
896            .into_iter()
897            .filter_map(|(at, _, step)| Some((at.strip_suffix(".condition")?.to_owned(), step)))
898            .collect();
899        let expected: Vec<(String, Step)> = def
900            .walk()
901            .map(|(path, kind, step)| (path.to_string(), Some((kind, step.id().to_owned()))))
902            .collect();
903        assert_eq!(walked.len(), 12);
904        assert_eq!(walked, expected);
905    }
906
907    #[test]
908    fn check_steps_reaches_the_same_sites_under_its_root() {
909        // `check_steps` compiles through the same per-step walk.
910        let steps = json!([{"id": "f", "condition": {"var": "data.f"},
911            "function": {"name": "echo", "input": {"text": "a", "label": {"var": "data.l"}}}}]);
912        let root: AuthoredPath = "tasks[2].tasks".parse().unwrap();
913        let (parsed, issues) = def::parse_steps(&steps, &root, ParseOptions::default());
914        assert!(issues.is_empty(), "{issues:?}");
915        let parsed = parsed.unwrap();
916        let compiler = compiler();
917        let mut recorder = Recorder::default();
918        let mut walk = Walk::new(&compiler, "", &mut recorder);
919        walk.steps(&parsed, &root, false);
920        assert!(walk.issues.is_empty(), "{:?}", walk.issues);
921        let paths: Vec<_> = recorder.0.iter().map(|(at, ..)| at.as_str()).collect();
922        assert_eq!(
923            paths,
924            [
925                "tasks[2].tasks[0].condition",
926                "tasks[2].tasks[0].function.input.text",
927                "tasks[2].tasks[0].function.input.label",
928            ]
929        );
930    }
931
932    #[test]
933    fn the_side_tables_are_filled_in_walk_order() {
934        let compiled = compiler().compile_one(&every_site()).unwrap();
935        assert!(compiled.issues.is_empty(), "{:?}", compiled.issues);
936        let w = &compiled.workflow;
937
938        // The workflow's facts.
939        assert_eq!(w.id(), "w");
940        assert_eq!(w.channel, "orders");
941        assert_eq!((w.priority, w.version), (4, 3));
942        assert_eq!(w.status, Status::Paused);
943        assert_eq!(w.rollout, Some(Rollout::new(10, 60)));
944        assert_eq!(w.on_failure, WorkflowFailurePolicy::Continue);
945
946        // The lowering: a setup step, a loop over a group holding a
947        // conditional fan-out, and a terminal task.
948        let listing = [
949            " 0 Cond workflow else->end",
950            " 1 Cond task s else->3",
951            " 2 Call s log builtin fail->end",
952            " 3 LoopInit exit->end",
953            " 4 Cond workflow else->end",
954            " 5 Cond group g else->8",
955            " 6 Cond task f else->8",
956            " 7 FanOut f echo host as=item fail->9",
957            " 8 Call m map builtin fail->9 terminal",
958            " 9 LoopNext body->4 exit->end",
959        ];
960        let expected: String = listing.iter().map(|line| format!("{line}\n")).collect();
961        assert_eq!(w.listing(), expected, "\n{}", w.listing());
962        assert_eq!(w.check_invariants(), Ok(()));
963
964        // One row per task, never per group: setup first.
965        let rows: Vec<_> = w
966            .tasks
967            .iter()
968            .map(|t| (&*t.id, &*t.function, t.kind, t.path.to_string()))
969            .collect();
970        assert_eq!(
971            rows,
972            [
973                ("s", "log", StepKind::Setup, "loop.setup[0]".to_owned()),
974                ("f", "echo", StepKind::Task, "tasks[0].tasks[0]".to_owned()),
975                ("m", "map", StepKind::Task, "tasks[1]".to_owned()),
976            ]
977        );
978
979        // Each call site keeps its task row and the task's policies.
980        let calls: Vec<_> = w
981            .calls
982            .iter()
983            .map(|c| {
984                let callee = match &c.callee {
985                    Callee::Builtin(b) => Some(b.kind()),
986                    Callee::Host(_) => None,
987                };
988                (
989                    c.task,
990                    callee,
991                    c.policy.on_failure,
992                    c.policy.on_rejection,
993                    c.policy.terminal,
994                )
995            })
996            .collect();
997        assert_eq!(
998            calls,
999            [
1000                (
1001                    0,
1002                    Some(BuiltinKind::Log),
1003                    FailurePolicy::Fail,
1004                    RejectionPolicy::Continue,
1005                    false
1006                ),
1007                (
1008                    1,
1009                    None,
1010                    FailurePolicy::Continue,
1011                    RejectionPolicy::Halt,
1012                    false
1013                ),
1014                (
1015                    2,
1016                    Some(BuiltinKind::Map),
1017                    FailurePolicy::Fail,
1018                    RejectionPolicy::Continue,
1019                    true
1020                ),
1021            ]
1022        );
1023        assert!(matches!(
1024            &w.calls[2].callee,
1025            Callee::Builtin(Builtin::Map(_))
1026        ));
1027
1028        // Every condition, with its step and authored expression.
1029        let conds: Vec<_> = w
1030            .conds
1031            .iter()
1032            .map(|c| (c.step.as_deref(), c.expr.raw().clone()))
1033            .collect();
1034        assert_eq!(
1035            conds,
1036            [
1037                (None, json!({"var": "metadata.go"})),
1038                (Some("s"), json!({"var": "data.s"})),
1039                (Some("g"), json!({"var": "data.g"})),
1040                (Some("f"), json!({"var": "data.f"})),
1041            ]
1042        );
1043
1044        // The fan-out's bindings are `temp_data` paths; collect and into are
1045        // context paths.
1046        let [fan] = &w.fans[..] else {
1047            panic!("one fan-out");
1048        };
1049        assert_eq!(fan.over.raw(), &json!({"var": "data.items"}));
1050        assert_eq!(fan.r#as.to_string(), "temp_data.item");
1051        assert_eq!(fan.index.to_string(), "temp_data.item_index");
1052        assert_eq!(fan.max_concurrency, 2);
1053        assert_eq!(fan.collect.as_ref().unwrap().to_string(), "temp_data.price");
1054        assert_eq!(
1055            fan.into.as_ref().unwrap().to_string(),
1056            "temp_data.acc.prices"
1057        );
1058
1059        // The loop's slots are `temp_data` paths.
1060        let [lp] = &w.loops[..] else {
1061            panic!("one loop");
1062        };
1063        assert_eq!((lp.init, lp.increment, lp.max), (1, 2, 9));
1064        assert_eq!(lp.counter.as_ref().unwrap().to_string(), "temp_data.i");
1065        assert_eq!(
1066            lp.over.as_ref().unwrap().raw(),
1067            &json!({"var": "data.batches"})
1068        );
1069        assert_eq!(lp.r#as.as_ref().unwrap().to_string(), "temp_data.batch");
1070        assert_eq!(lp.scratch.as_ref().unwrap().to_string(), "temp_data.acc");
1071    }
1072
1073    #[test]
1074    fn a_workflow_without_a_loop_or_slots_has_none() {
1075        let json = json!({"id": "w", "loop": {"max": 2},
1076            "tasks": [{"id": "t", "function": {"name": "echo", "input": {"text": "a"}}}]});
1077        let def = parse(&json, ParseOptions::default()).def.unwrap();
1078        let compiled = compiler().compile_one(&def).unwrap();
1079        let [lp] = &compiled.workflow.loops[..] else {
1080            panic!("one loop");
1081        };
1082        assert!(lp.counter.is_none() && lp.over.is_none());
1083        assert!(lp.r#as.is_none() && lp.scratch.is_none());
1084        assert!(compiled.workflow.conds.is_empty());
1085        assert!(compiled.workflow.fans.is_empty());
1086    }
1087
1088    /// An `echo` task.
1089    fn echo_task() -> Value {
1090        json!({"id": "t", "function": {"name": "echo", "input": {"text": "a"}}})
1091    }
1092
1093    #[test]
1094    fn compile_refuses_an_into_with_no_room_for_an_index() {
1095        // Each element's result lands at `into.<index>`, one segment below
1096        // `into`, and a path holds at most 64.
1097        for (depth, refused) in [(64, true), (63, false)] {
1098            let into = format!("data{}", ".k".repeat(depth - 1));
1099            let mut task = echo_task();
1100            task["for_each"] = json!({"over": {"var": "data.items"}, "as": "item",
1101                "collect": "temp_data.r", "into": into});
1102            let def = parse(
1103                &json!({"id": "w", "tasks": [task]}),
1104                ParseOptions::default(),
1105            )
1106            .def
1107            .unwrap();
1108            let issues = compiler().compile_one(&def).err().unwrap_or_default();
1109            let issues: Vec<_> = issues
1110                .into_iter()
1111                .map(|i| (i.code, i.path.to_string(), i.message))
1112                .collect();
1113            if refused {
1114                assert_eq!(
1115                    issues,
1116                    [(
1117                        IssueCode::InvalidPath,
1118                        "tasks[0].for_each.into".to_owned(),
1119                        format!(
1120                            "path `{into}` has 64 segments, and each element's result lands \
1121                             one segment below it; a path holds at most 64"
1122                        ),
1123                    )]
1124                );
1125            } else {
1126                assert!(issues.is_empty(), "{issues:?}");
1127            }
1128        }
1129    }
1130
1131    #[test]
1132    fn debug_shows_the_id_the_op_count_and_the_table_sizes() {
1133        let compiled = compiler().compile_one(&every_site()).unwrap();
1134        assert_eq!(
1135            format!("{:?}", compiled.workflow),
1136            "CompiledWorkflow { id: \"w\", ops: 10, conds: 4, calls: 3, fans: 1, loops: 1, \
1137             tasks: 3, .. }"
1138        );
1139    }
1140
1141    #[test]
1142    fn debug_shows_the_registry_and_whether_an_oracle_is_set() {
1143        assert_eq!(
1144            format!("{:?}", CompileOptions::default()),
1145            "CompileOptions { resources: false, .. }"
1146        );
1147        let compiler = format!("{:?}", compiler());
1148        assert!(
1149            compiler.starts_with("Compiler { registry: Registry { functions: [\"map\""),
1150            "{compiler}"
1151        );
1152        assert!(compiler.contains("\"echo\"]"), "{compiler}");
1153    }
1154
1155    #[test]
1156    fn a_compiled_workflow_crosses_threads() {
1157        fn send_sync<T: Send + Sync>() {}
1158        send_sync::<CompiledWorkflow>();
1159        send_sync::<Compiler>();
1160    }
1161}