Skip to main content

cuttlefish_core/
graph.rs

1//! The graph AST: `nodes = {...}` and `branches = {...}`.
2//!
3//! A node's `in` is an expression over other nodes' outputs, not just a bare
4//! name — `Record`/`List` are what make fan-in possible. See
5//! `docs/superpowers/specs/2026-08-03-dag-core-design.md` for the full
6//! rationale; this module is purely the parsed shape, with no typechecking
7//! or execution logic (those live in `cuttlefish-host`).
8
9use crate::lex::{Tok, Token};
10use crate::spec::SpecError;
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14/// What feeds a node's input.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum InputExpr {
17    /// `other_node.out` — this node's whole output.
18    FromNode(String),
19    /// `{ field = expr; ... }` — build a record from several nodes.
20    Record(BTreeMap<String, InputExpr>),
21    /// `[ expr, expr, ... ]` — build a list from several nodes, order significant.
22    List(Vec<InputExpr>),
23}
24
25/// One node in the graph.
26#[derive(Debug, Clone, PartialEq)]
27pub struct Node {
28    /// The block/bundle this node runs — same string shape as today's
29    /// `pipeline` entries (a path, or a bare `name@version`).
30    pub block: PathBuf,
31    /// What feeds this node. `None` only for a node with no inbound edge —
32    /// the graph's entry point(s).
33    pub input: Option<InputExpr>,
34    /// Bounded-loop marker: the `Ty::Text` output field compared against
35    /// `"done"`. Requires `max_iterations`.
36    pub repeat_until: Option<String>,
37    /// Mandatory alongside `repeat_until` — enforced at parse time, not left
38    /// as a typecheck-time gap, since a missing bound is a spec-authoring
39    /// mistake regardless of what the graph shape turns out to be.
40    pub max_iterations: Option<u32>,
41}
42
43/// `nodes = { name = { ... }; ... }`
44#[derive(Debug, Clone, PartialEq, Default)]
45pub struct NodeGraph {
46    /// Insertion order preserved (`BTreeMap` would reorder alphabetically,
47    /// which is fine for lookup but wrong for any diagnostic that lists
48    /// nodes "in the order the author wrote them").
49    pub nodes: Vec<(String, Node)>,
50}
51
52impl NodeGraph {
53    /// The one-node graph `block = "...";` desugars to.
54    pub fn single(block: PathBuf) -> Self {
55        Self {
56            nodes: vec![(
57                "block".to_string(),
58                Node {
59                    block,
60                    input: None,
61                    repeat_until: None,
62                    max_iterations: None,
63                },
64            )],
65        }
66    }
67
68    /// Look up a node by name.
69    pub fn get(&self, name: &str) -> Option<&Node> {
70        self.nodes
71            .iter()
72            .find(|(n, _)| n == name)
73            .map(|(_, node)| node)
74    }
75}
76
77/// Whether a graph is a strict linear chain — a single strand where node
78/// `i`'s sole input (if any) is `FromNode` of node `i-1`, nothing more:
79///
80/// - `branches` must be empty (no conditional dispatch to encode).
81/// - No node declares `repeat_until` (no loop to encode).
82/// - No node's `input` is `Record`/`List` fan-in.
83/// - **Beyond per-node checks:** the *whole graph* must be one strand, not
84///   just individually-simple nodes that still fan out or fan in as a
85///   group. Concretely: the first declared node has no input; every
86///   subsequent declared node's input must be exactly `FromNode` of the
87///   node declared immediately before it; and no node may be referenced by
88///   more than one other node's `FromNode` (that would be fan-out — two
89///   nodes both reading node `k`'s output — which individually satisfies
90///   every per-node check above while still not being a chain).
91///
92/// `cuttlefish build`'s bundle format only knows how to encode this exact
93/// shape, walked in `spec.nodes`' declaration order.
94pub fn is_simple_chain(graph: &NodeGraph, branches: &Branches) -> bool {
95    if !branches.decisions.is_empty() {
96        return false;
97    }
98    for (i, (_, node)) in graph.nodes.iter().enumerate() {
99        if node.repeat_until.is_some() {
100            return false;
101        }
102        match (i, &node.input) {
103            (0, None) => {}
104            (0, Some(_)) => return false, // the entry node must have no input
105            (_, Some(InputExpr::FromNode(referenced))) => {
106                let (previous_name, _) = &graph.nodes[i - 1];
107                if referenced != previous_name {
108                    return false; // not chained to the immediately-preceding node
109                }
110            }
111            _ => return false, // missing input, or Record/List fan-in
112        }
113    }
114    // Fan-out check: provably unreachable given the position check above
115    // already passed (if every node's input is exactly its immediate
116    // predecessor, no target can have two referrers) — kept as an explicit,
117    // cheap assertion rather than an implicit invariant, so a future change
118    // to the loop above that weakens it trips this instead of silently
119    // regressing.
120    let mut referenced_counts = std::collections::HashMap::new();
121    for (_, node) in &graph.nodes {
122        if let Some(InputExpr::FromNode(referenced)) = &node.input {
123            *referenced_counts.entry(referenced.clone()).or_insert(0) += 1;
124        }
125    }
126    referenced_counts.values().all(|&count| count <= 1)
127}
128
129/// `branches = { node_name = { "label" -> target; ... }; ... }`
130#[derive(Debug, Clone, PartialEq, Default)]
131pub struct Branches {
132    /// (branching node name) -> (label -> target node name), insertion order.
133    pub decisions: Vec<(String, Vec<(String, String)>)>,
134}
135
136/// A self-contained recursive-descent parser for the `nodes = {...}` and
137/// `branches = {...}` bodies, operating directly on a token slice.
138pub struct GraphParser<'a> {
139    /// The full token stream being parsed.
140    pub tokens: &'a [Token],
141    /// Current cursor position into `tokens`.
142    pub at: usize,
143}
144
145impl<'a> GraphParser<'a> {
146    fn peek(&self) -> Option<&'a Tok> {
147        self.tokens.get(self.at).map(|t| &t.tok)
148    }
149    fn here(&self) -> String {
150        match self.tokens.get(self.at) {
151            Some(t) => format!("{} at {}", t.tok.describe(), t.span),
152            None => "end of input".into(),
153        }
154    }
155    fn expect(&mut self, want: &Tok) -> Result<(), SpecError> {
156        match self.peek() {
157            Some(got) if got == want => {
158                self.at += 1;
159                Ok(())
160            }
161            _ => Err(SpecError::Malformed(format!(
162                "expected {}, found {}",
163                want.describe(),
164                self.here()
165            ))),
166        }
167    }
168    fn ident(&mut self) -> Result<String, SpecError> {
169        match self.tokens.get(self.at).map(|t| &t.tok) {
170            Some(Tok::Ident(name)) => {
171                self.at += 1;
172                Ok(name.clone())
173            }
174            _ => Err(SpecError::Malformed(format!(
175                "expected a name, found {}",
176                self.here()
177            ))),
178        }
179    }
180    fn string(&mut self) -> Result<String, SpecError> {
181        match self.tokens.get(self.at).map(|t| &t.tok) {
182            Some(Tok::Str(s)) => {
183                self.at += 1;
184                Ok(s.clone())
185            }
186            _ => Err(SpecError::Malformed(format!(
187                "expected a quoted string, found {}",
188                self.here()
189            ))),
190        }
191    }
192    fn skip_semi(&mut self) {
193        if self.peek() == Some(&Tok::Semicolon) {
194            self.at += 1;
195        }
196    }
197
198    /// `{ name = { field* }; ... }` — the whole `nodes = {...}` body.
199    ///
200    /// Returns the parsed graph together with the token position just past
201    /// its closing `}` — `spec.rs`'s `Parser` (a *separate* struct walking
202    /// the same token slice, Task 3) needs that position to resume parsing
203    /// the rest of the `spec {...}` body afterward, since it can't see
204    /// `GraphParser`'s internal cursor otherwise.
205    pub fn node_graph(&mut self) -> Result<(NodeGraph, usize), SpecError> {
206        self.expect(&Tok::OpenBrace)?;
207        let mut nodes = Vec::new();
208        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
209            let name = self.ident()?;
210            self.expect(&Tok::Equals)?;
211            let node = self.node_body()?;
212            nodes.push((name, node));
213            self.skip_semi();
214        }
215        self.expect(&Tok::CloseBrace)?;
216        if nodes.is_empty() {
217            return Err(SpecError::Malformed("nodes needs at least one node".into()));
218        }
219        Ok((NodeGraph { nodes }, self.at))
220    }
221
222    /// `{ block = "..."; in = expr; repeat_until = "..."; max_iterations = N; }`
223    fn node_body(&mut self) -> Result<Node, SpecError> {
224        self.expect(&Tok::OpenBrace)?;
225        let (mut block, mut input, mut repeat_until, mut max_iterations) = (None, None, None, None);
226        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
227            let key = self.ident()?;
228            self.expect(&Tok::Equals)?;
229            match key.as_str() {
230                "block" => block = Some(PathBuf::from(self.string()?)),
231                "in" => input = Some(self.input_expr()?),
232                "repeat_until" => repeat_until = Some(self.string_or_field()?),
233                "max_iterations" => max_iterations = Some(self.number()?),
234                other => return Err(SpecError::UnknownField(other.to_string())),
235            }
236            self.skip_semi();
237        }
238        self.expect(&Tok::CloseBrace)?;
239        if let (Some(_), None) = (&repeat_until, &max_iterations) {
240            return Err(SpecError::Malformed(
241                "repeat_until requires max_iterations".into(),
242            ));
243        }
244        Ok(Node {
245            block: block.ok_or(SpecError::MissingField("block"))?,
246            input,
247            repeat_until,
248            max_iterations,
249        })
250    }
251
252    /// `repeat_until = "done"` — a bare field-name string, not a node
253    /// reference, so this reuses `string()` (kept as its own method name at
254    /// the call site above for readability, not because parsing differs).
255    fn string_or_field(&mut self) -> Result<String, SpecError> {
256        self.string()
257    }
258
259    fn number(&mut self) -> Result<u32, SpecError> {
260        // Numbers aren't tokenized separately today (see lex.rs) — an
261        // integer like `5` lexes as `Ident("5")` since digits satisfy
262        // `is_alphanumeric()`. Parsing it here, rather than adding a
263        // dedicated numeric token, keeps this the only place that cares.
264        let s = self.ident()?;
265        s.parse::<u32>()
266            .map_err(|_| SpecError::Malformed(format!("`{s}` is not a valid max_iterations")))
267    }
268
269    /// `node.out` | `{ field = expr; ... }` | `[ expr, ... ]`
270    fn input_expr(&mut self) -> Result<InputExpr, SpecError> {
271        match self.peek() {
272            Some(Tok::OpenBrace) => {
273                self.at += 1;
274                let mut fields = BTreeMap::new();
275                while self.peek() != Some(&Tok::CloseBrace) {
276                    let field = self.ident()?;
277                    self.expect(&Tok::Equals)?;
278                    fields.insert(field, self.input_expr()?);
279                    self.skip_semi();
280                }
281                self.expect(&Tok::CloseBrace)?;
282                Ok(InputExpr::Record(fields))
283            }
284            Some(Tok::OpenBracket) => {
285                self.at += 1;
286                let mut items = Vec::new();
287                while self.peek() != Some(&Tok::CloseBracket) {
288                    items.push(self.input_expr()?);
289                    if self.peek() == Some(&Tok::Comma) {
290                        self.at += 1;
291                    } else {
292                        break;
293                    }
294                }
295                self.expect(&Tok::CloseBracket)?;
296                Ok(InputExpr::List(items))
297            }
298            Some(Tok::Ident(reference)) => {
299                let reference = reference.clone();
300                self.at += 1;
301                reference
302                    .strip_suffix(".out")
303                    .map(|node| InputExpr::FromNode(node.to_string()))
304                    .ok_or_else(|| {
305                        SpecError::Malformed(format!(
306                            "`{reference}` is not a node reference — expected `<node>.out`"
307                        ))
308                    })
309            }
310            _ => Err(SpecError::Malformed(format!(
311                "expected a node reference, `{{...}}`, or `[...]`, found {}",
312                self.here()
313            ))),
314        }
315    }
316
317    /// `{ node_name = { "label" -> target; ... }; ... }` — the whole
318    /// `branches = {...}` body. Returns `(Branches, new_at)`, same handoff
319    /// convention as [`Self::node_graph`].
320    pub fn branches(&mut self) -> Result<(Branches, usize), SpecError> {
321        self.expect(&Tok::OpenBrace)?;
322        let mut decisions = Vec::new();
323        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
324            let node_name = self.ident()?;
325            self.expect(&Tok::Equals)?;
326            self.expect(&Tok::OpenBrace)?;
327            let mut labels = Vec::new();
328            while self.peek() != Some(&Tok::CloseBrace) {
329                let label = self.string()?;
330                self.expect(&Tok::Arrow)?;
331                let target = self.ident()?;
332                labels.push((label, target));
333                self.skip_semi();
334            }
335            self.expect(&Tok::CloseBrace)?;
336            decisions.push((node_name, labels));
337            self.skip_semi();
338        }
339        self.expect(&Tok::CloseBrace)?;
340        Ok((Branches { decisions }, self.at))
341    }
342}
343
344#[cfg(test)]
345mod is_simple_chain_tests {
346    use super::*;
347
348    fn node(block: &str, input: Option<InputExpr>) -> Node {
349        Node {
350            block: PathBuf::from(block),
351            input,
352            repeat_until: None,
353            max_iterations: None,
354        }
355    }
356
357    fn from_node(name: &str) -> InputExpr {
358        InputExpr::FromNode(name.to_string())
359    }
360
361    #[test]
362    fn a_genuine_three_node_chain_is_simple() {
363        let graph = NodeGraph {
364            nodes: vec![
365                ("a".into(), node("blocks/a", None)),
366                ("b".into(), node("blocks/b", Some(from_node("a")))),
367                ("c".into(), node("blocks/c", Some(from_node("b")))),
368            ],
369        };
370        assert!(is_simple_chain(&graph, &Branches::default()));
371    }
372
373    #[test]
374    fn record_fan_in_is_not_simple() {
375        let mut fields = BTreeMap::new();
376        fields.insert("x".to_string(), from_node("a"));
377        fields.insert("y".to_string(), from_node("b"));
378        let graph = NodeGraph {
379            nodes: vec![
380                ("a".into(), node("blocks/a", None)),
381                ("b".into(), node("blocks/b", None)),
382                (
383                    "c".into(),
384                    node("blocks/c", Some(InputExpr::Record(fields))),
385                ),
386            ],
387        };
388        assert!(!is_simple_chain(&graph, &Branches::default()));
389    }
390
391    #[test]
392    fn list_fan_in_is_not_simple() {
393        let graph = NodeGraph {
394            nodes: vec![
395                ("a".into(), node("blocks/a", None)),
396                ("b".into(), node("blocks/b", None)),
397                (
398                    "c".into(),
399                    node(
400                        "blocks/c",
401                        Some(InputExpr::List(vec![from_node("a"), from_node("b")])),
402                    ),
403                ),
404            ],
405        };
406        assert!(!is_simple_chain(&graph, &Branches::default()));
407    }
408
409    #[test]
410    fn a_repeat_until_node_is_not_simple() {
411        let mut looped = node("blocks/b", Some(from_node("a")));
412        looped.repeat_until = Some("done".to_string());
413        looped.max_iterations = Some(5);
414        let graph = NodeGraph {
415            nodes: vec![("a".into(), node("blocks/a", None)), ("b".into(), looped)],
416        };
417        assert!(!is_simple_chain(&graph, &Branches::default()));
418    }
419
420    #[test]
421    fn a_branches_decision_is_not_simple() {
422        let graph = NodeGraph {
423            nodes: vec![
424                ("a".into(), node("blocks/a", None)),
425                ("b".into(), node("blocks/b", Some(from_node("a")))),
426            ],
427        };
428        let branches = Branches {
429            decisions: vec![("a".to_string(), vec![("done".to_string(), "b".to_string())])],
430        };
431        assert!(!is_simple_chain(&graph, &branches));
432    }
433
434    /// Two independent, individually-simple nodes that both declare
435    /// `in = a.out` — fan-out from `a`. Neither `b` nor `c` individually has
436    /// fan-in (each has exactly one `FromNode` input); what makes this not a
437    /// chain is a whole-graph property (two referrers of `a`), not anything
438    /// visible from a single node in isolation. In this implementation the
439    /// position check (node `i`'s input must be exactly node `i-1`) already
440    /// rejects this case before the dedicated fan-out tally ever runs — `c`'s
441    /// immediate predecessor is `b`, not `a` — which is exactly why that
442    /// tally is documented as unreachable-but-kept-explicit. This test
443    /// pins the required *behavior* (fan-out is rejected), independent of
444    /// which internal check catches it.
445    #[test]
446    fn fan_out_from_a_shared_predecessor_is_not_simple() {
447        let graph = NodeGraph {
448            nodes: vec![
449                ("a".into(), node("blocks/a", None)),
450                ("b".into(), node("blocks/b", Some(from_node("a")))),
451                ("c".into(), node("blocks/c", Some(from_node("a")))),
452            ],
453        };
454        assert!(!is_simple_chain(&graph, &Branches::default()));
455    }
456}