Skip to main content

quarb_model/
parse.rs

1//! The model-file grammar: a definitions file
2//! (Section~\ref{sec:fragments}) extended with `node`, `ref`,
3//! `rel`, `edge`, and `mount` statements. Statements terminate with `;`;
4//! `#` line comments and `def`/`macro` statements are collected as
5//! defs text (prepended to every constructor query so a model's own
6//! fragments are visible to its `node` queries).
7
8/// One parsed model file.
9#[derive(Debug, Default, Clone)]
10pub struct Model {
11    /// `mount NAME: target;` — sources the model opens itself.
12    pub mounts: Vec<Mount>,
13    /// `node /container/role: query;` — derived containers.
14    pub nodes: Vec<NodeDecl>,
15    /// `ref PATH::field --> container;` — scoped references.
16    pub refs: Vec<RefDecl>,
17    /// `rel SOURCE -> TARGET[cond];` — node-to-node relations.
18    pub rels: Vec<RelDecl>,
19    /// `edge PATH: ::a -- ::b;` — pair edges between containers.
20    pub edges: Vec<EdgeDecl>,
21    /// `def`/`macro` statements, verbatim, joined with `;` — seeded
22    /// into every constructor query's fragment table.
23    pub defs_text: String,
24    /// `alias` statements: other spellings a node's name or trait
25    /// answers to (ruling #30 — an alias is a way in, never a way
26    /// out; locators and reflection keep the canonical spelling).
27    pub aliases: Vec<AliasDecl>,
28}
29
30/// What an alias applies to.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AliasKind {
33    /// A node's name: `alias page document;`, `alias s/\.html$//;`.
34    Name,
35    /// A node's traits: `alias <chunk> <block>;`,
36    /// `alias <s/^tag://i>;`.
37    Trait,
38}
39
40/// How a written spelling is matched against the canonical one.
41#[derive(Debug, Clone)]
42pub enum AliasRule {
43    /// The written `alias` stands for the canonical `original`.
44    Exact { alias: String, original: String },
45    /// A Perl-style substitution `s/pattern/replacement/flags` applied
46    /// to each canonical spelling: the node also answers to what it
47    /// produces. `i` compares case-insensitively; `g` replaces every
48    /// match rather than the first.
49    Subst {
50        pattern: regex::Regex,
51        replacement: String,
52        ignore_case: bool,
53        global: bool,
54    },
55}
56
57#[derive(Debug, Clone)]
58pub struct AliasDecl {
59    pub kind: AliasKind,
60    pub rule: AliasRule,
61}
62
63impl AliasDecl {
64    /// Whether `written` reaches a node whose canonical spelling is
65    /// `canonical` under this alias.
66    pub fn admits(&self, written: &str, canonical: &str) -> bool {
67        match &self.rule {
68            AliasRule::Exact { alias, original } => written == alias && canonical == original,
69            AliasRule::Subst {
70                pattern,
71                replacement,
72                ignore_case,
73                global,
74            } => {
75                // Perl's rule: a substitution that matched nothing
76                // substituted nothing, and admits nothing.
77                if !pattern.is_match(canonical) {
78                    return false;
79                }
80                let produced = if *global {
81                    pattern.replace_all(canonical, replacement.as_str())
82                } else {
83                    pattern.replace(canonical, replacement.as_str())
84                };
85                if *ignore_case {
86                    produced.eq_ignore_ascii_case(written)
87                        || produced.to_lowercase() == written.to_lowercase()
88                } else {
89                    produced == written
90                }
91            }
92        }
93    }
94}
95
96/// Parse one alias body: `NEW ORIGINAL`, `s/pat/rep/flags`, or the
97/// same two forms inside angle brackets for traits.
98fn parse_alias(body: &str, stmt: &str) -> Result<AliasDecl, String> {
99    let body = body.trim();
100    let (kind, body) = match body.strip_prefix('<') {
101        Some(inner) => {
102            let inner = inner.trim_end();
103            let inner = inner.strip_suffix('>').ok_or_else(|| {
104                format!("a trait alias closes its angle bracket: '{stmt}'")
105            })?;
106            // `<chunk> <block>` — two bracketed spellings.
107            let inner = inner.trim();
108            let inner = match inner.split_once("> <") {
109                Some((a, b)) => format!("{} {}", a.trim(), b.trim()),
110                None => inner.to_string(),
111            };
112            (AliasKind::Trait, inner)
113        }
114        None => (AliasKind::Name, body.to_string()),
115    };
116    let body = body.trim();
117    if let Some(rest) = body.strip_prefix("s/") {
118        // s/pattern/replacement/flags, `\/` escaping a slash.
119        let mut parts: Vec<String> = Vec::new();
120        let mut cur = String::new();
121        let mut chars = rest.chars().peekable();
122        while let Some(c) = chars.next() {
123            if c == '\\' && chars.peek() == Some(&'/') {
124                cur.push('/');
125                chars.next();
126            } else if c == '/' {
127                parts.push(std::mem::take(&mut cur));
128            } else {
129                cur.push(c);
130            }
131        }
132        parts.push(cur);
133        if parts.len() != 3 {
134            return Err(format!(
135                "a substitution alias is 's/pattern/replacement/flags': '{stmt}'"
136            ));
137        }
138        let flags = parts[2].trim();
139        for f in flags.chars() {
140            if !matches!(f, 'i' | 'g') {
141                return Err(format!("unknown substitution flag '{f}' (i, g): '{stmt}'"));
142            }
143        }
144        let pattern = regex::Regex::new(&parts[0])
145            .map_err(|e| format!("alias pattern does not parse: {e}: '{stmt}'"))?;
146        return Ok(AliasDecl {
147            kind,
148            rule: AliasRule::Subst {
149                pattern,
150                replacement: parts[1].clone(),
151                ignore_case: flags.contains('i'),
152                global: flags.contains('g'),
153            },
154        });
155    }
156    let (alias, original) = body.split_once(char::is_whitespace).ok_or_else(|| {
157        format!("alias needs 'NEW ORIGINAL' or 's/pattern/replacement/': '{stmt}'")
158    })?;
159    let strip = |t: &str| t.trim().trim_start_matches('<').trim_end_matches('>').to_string();
160    Ok(AliasDecl {
161        kind,
162        rule: AliasRule::Exact {
163            alias: strip(alias),
164            original: strip(original),
165        },
166    })
167}
168
169#[derive(Debug, Clone)]
170pub struct Mount {
171    pub name: String,
172    pub target: String,
173}
174
175#[derive(Debug, Clone)]
176pub struct NodeDecl {
177    /// The container's name: `ips` from `/ips/ip`.
178    pub name: String,
179    /// The role each child plays in it: `ip` from `/ips/ip`. A hop
180    /// into this container is labelled by this role — a label names
181    /// what it lands on, never the property it came from.
182    pub role: String,
183    /// An explicit trait, `<addr>` in `node /ips/ip<addr>: …`;
184    /// defaults to the role.
185    pub trait_name: String,
186    pub query: String,
187}
188
189#[derive(Debug, Clone)]
190pub struct RefDecl {
191    /// The node-selecting path (the `::field` suffix stripped).
192    pub scope: String,
193    /// The property resolved into the container.
194    pub field: String,
195    pub container: String,
196    /// The target property the value is matched against, from the
197    /// explicit form `--> /cookies/cookie[::id = $]`. `None` is the
198    /// short form, matching the target's default projection.
199    pub key_field: Option<String>,
200}
201
202/// `rel SOURCE -> TARGET[cond];` — a relation between two node sets
203/// that no property carries: the condition is evaluated per pair,
204/// with `$$` standing for the source node.
205#[derive(Debug, Clone)]
206pub struct RelDecl {
207    pub source: String,
208    /// The target path, without its condition.
209    pub target: String,
210    /// The condition, brackets included: `[::at > $$::at]`.
211    pub cond: String,
212}
213
214#[derive(Debug, Clone)]
215pub struct EdgeDecl {
216    pub scope: String,
217    pub field_a: String,
218    pub field_b: String,
219}
220
221/// Split model-file text into statements at a lone `;`. A maximal
222/// run of two or more `;` is a projection sigil in a body (`;;;` and
223/// its `::;` cousin), never a terminator; strings are opaque.
224fn statements(text: &str) -> Vec<String> {
225    let b: Vec<char> = text.chars().collect();
226    let mut out = Vec::new();
227    let mut start = 0;
228    let mut i = 0;
229    while i < b.len() {
230        match b[i] {
231            q @ ('\'' | '"') => {
232                i += 1;
233                while i < b.len() && b[i] != q {
234                    if b[i] == '\\' {
235                        i += 1;
236                    }
237                    i += 1;
238                }
239                i += 1;
240            }
241            ';' => {
242                let mut run = 0;
243                while i < b.len() && b[i] == ';' {
244                    run += 1;
245                    i += 1;
246                }
247                if run == 1 {
248                    out.push(b[start..i - 1].iter().collect());
249                    start = i;
250                }
251            }
252            _ => i += 1,
253        }
254    }
255    if start < b.len() {
256        let tail: String = b[start..].iter().collect();
257        if !tail.trim().is_empty() {
258            out.push(tail);
259        }
260    }
261    out
262}
263
264/// Strip `#` comments: from a `#` at start of line or preceded by
265/// whitespace, to end of line. Strings are opaque, and a `#` glued
266/// to text (`s/\.#\d+//`) is body text, not a comment — so trailing
267/// annotations parse while substitutions survive.
268fn strip_comments(text: &str) -> String {
269    let mut out = String::new();
270    for line in text.lines() {
271        let b: Vec<char> = line.chars().collect();
272        let mut i = 0;
273        while i < b.len() {
274            match b[i] {
275                q @ ('\'' | '"') => {
276                    out.push(q);
277                    i += 1;
278                    while i < b.len() && b[i] != q {
279                        if b[i] == '\\' && i + 1 < b.len() {
280                            out.push(b[i]);
281                            i += 1;
282                        }
283                        out.push(b[i]);
284                        i += 1;
285                    }
286                    if i < b.len() {
287                        out.push(b[i]);
288                        i += 1;
289                    }
290                }
291                '#' if i == 0 || b[i - 1].is_whitespace() => break,
292                c => {
293                    out.push(c);
294                    i += 1;
295                }
296            }
297        }
298        out.push('\n');
299    }
300    out
301}
302
303/// Parse the left side of a `ref`/`edge` field into `(scope_path,
304/// field)` — splitting at the last `::` (the property projection).
305fn split_field(s: &str) -> Result<(String, String), String> {
306    let s = s.trim();
307    match s.rfind("::") {
308        Some(idx) => {
309            let path = s[..idx].trim().to_string();
310            let field = s[idx + 2..].trim().to_string();
311            if path.is_empty() {
312                return Err(format!(
313                    "ref needs a path scope before '::{field}' \
314                     (bare fields are refused): '{s}'"
315                ));
316            }
317            if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
318                return Err(format!("ref/edge field is not a bare property: '{s}'"));
319            }
320            Ok((path, field))
321        }
322        None => Err(format!(
323            "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
324        )),
325    }
326}
327
328/// Resolve a `mount` target against the model file's directory: a
329/// scheme target (`neo4j://`, `git:…`) or an absolute path is left
330/// as-is; a relative filesystem path is joined to `base_dir`, so a
331/// shipped fixture directory is self-locating.
332pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
333    let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
334        s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
335    });
336    let p = std::path::Path::new(target);
337    if has_scheme || p.is_absolute() {
338        return target.to_string();
339    }
340    match base_dir {
341        Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
342        _ => target.to_string(),
343    }
344}
345
346/// Split a trailing `<trait>` off a node head.
347fn split_trait(head: &str) -> (String, Option<String>) {
348    match head.split_once('<') {
349        Some((path, rest)) => (
350            path.trim().to_string(),
351            rest.strip_suffix('>').map(|t| t.trim().to_string()),
352        ),
353        None => (head.to_string(), None),
354    }
355}
356
357/// `/ips/ip` -> (`ips`, `ip`). A bare name has no role and is
358/// refused: the role is what a hop into the container is called.
359fn split_container_role(path: &str) -> Option<(String, String)> {
360    let p = path.trim().trim_start_matches('/');
361    let (container, role) = p.split_once('/')?;
362    let (container, role) = (container.trim(), role.trim());
363    if container.is_empty() || role.is_empty() || role.contains('/') {
364        return None;
365    }
366    Some((container.to_string(), role.to_string()))
367}
368
369/// Split a trailing bracket group off a path: `/d/deploy [::at > $$]`
370/// -> (`/d/deploy`, `[::at > $$]`). Scans from the end so a bracket
371/// inside an earlier predicate stays with the path.
372fn split_trailing_group(s: &str) -> Option<(String, String)> {
373    let s = s.trim();
374    if !s.ends_with(']') {
375        return None;
376    }
377    let b: Vec<char> = s.chars().collect();
378    let mut depth = 0usize;
379    for i in (0..b.len()).rev() {
380        match b[i] {
381            ']' => depth += 1,
382            '[' => {
383                depth -= 1;
384                if depth == 0 {
385                    let path = b[..i].iter().collect::<String>().trim().to_string();
386                    let group = b[i..].iter().collect::<String>();
387                    return (!path.is_empty()).then_some((path, group));
388                }
389            }
390            _ => {}
391        }
392    }
393    None
394}
395
396/// Parse a `ref` target: `ips`, `/ips/ip`, or `/ips/ip[::id = $]`.
397fn parse_ref_target(s: &str) -> Result<(String, Option<String>), String> {
398    let (path, key_field) = match split_trailing_group(s) {
399        Some((path, group)) => {
400            let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
401            let (lhs, rhs) = inner
402                .split_once('=')
403                .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
404            if rhs.trim() != "$" {
405                return Err(format!(
406                    "ref resolves by value, so its condition is 'PROP = $': '{s}'"
407                ));
408            }
409            let key = lhs.trim().trim_start_matches("::").trim().to_string();
410            (path, (!key.is_empty()).then_some(key))
411        }
412        None => (s.trim().to_string(), None),
413    };
414    // The container is the first segment of a path form, or the whole
415    // word of the bare form.
416    let container = path
417        .trim_start_matches('/')
418        .split('/')
419        .next()
420        .unwrap_or("")
421        .trim()
422        .to_string();
423    if container.is_empty() {
424        return Err(format!("ref needs a target container: '{s}'"));
425    }
426    Ok((container, key_field))
427}
428
429/// Split at the first `->` that is not part of a `-->`.
430fn split_arrow(s: &str) -> Option<(String, String)> {
431    let b: Vec<char> = s.chars().collect();
432    let mut i = 0;
433    while i + 1 < b.len() {
434        if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
435            return Some((
436                b[..i].iter().collect::<String>().trim().to_string(),
437                b[i + 2..].iter().collect::<String>().trim().to_string(),
438            ));
439        }
440        i += 1;
441    }
442    None
443}
444
445/// Parse model-file text.
446pub fn parse_model(text: &str) -> Result<Model, String> {
447    let mut model = Model::default();
448    let mut defs = Vec::new();
449    for raw in statements(&strip_comments(text)) {
450        let stmt = raw.trim();
451        if stmt.is_empty() {
452            continue;
453        }
454        let (head, rest) = match stmt.split_once(char::is_whitespace) {
455            Some((h, r)) => (h, r.trim()),
456            None => (stmt, ""),
457        };
458        match head {
459            "node" => {
460                let (head, query) = rest.split_once(':').ok_or_else(|| {
461                    format!("node needs '/container/role: query': '{stmt}'")
462                })?;
463                let (path, trait_name) = split_trait(head.trim());
464                let (name, role) = split_container_role(&path)
465                    .ok_or_else(|| format!(
466                        "node needs '/container/role', so a hop can be \
467                         labelled by the role it lands on: '{stmt}'"
468                    ))?;
469                let trait_name = trait_name.unwrap_or_else(|| role.clone());
470                model.nodes.push(NodeDecl {
471                    name,
472                    role,
473                    trait_name,
474                    query: query.trim().to_string(),
475                });
476            }
477            "mount" => {
478                let (name, target) = rest.split_once(':').ok_or_else(|| {
479                    format!("mount needs 'NAME: target': '{stmt}'")
480                })?;
481                model.mounts.push(Mount {
482                    name: name.trim().to_string(),
483                    target: target.trim().to_string(),
484                });
485            }
486            "ref" => {
487                let (left, container) = rest.split_once("-->").ok_or_else(|| {
488                    format!("ref needs 'PATH::field --> container': '{stmt}'")
489                })?;
490                let (scope, field) = split_field(left)?;
491                let (container, key_field) = parse_ref_target(container)?;
492                model.refs.push(RefDecl {
493                    scope,
494                    field,
495                    container,
496                    key_field,
497                });
498            }
499            "rel" => {
500                let (source, target) = split_arrow(rest).ok_or_else(|| {
501                    format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'")
502                })?;
503                let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
504                    format!(
505                        "rel needs a condition — two node sets joined by \
506                         nothing are a cross product: '{stmt}'"
507                    )
508                })?;
509                if source.is_empty() {
510                    return Err(format!("rel needs a source path: '{stmt}'"));
511                }
512                model.rels.push(RelDecl {
513                    source,
514                    target,
515                    cond,
516                });
517            }
518            "edge" => {
519                let (scope_head, pair) = rest.split_once(':').ok_or_else(|| {
520                    format!("edge needs 'PATH: ::a -- ::b': '{stmt}'")
521                })?;
522                let (a, b) = pair.split_once("--").ok_or_else(|| {
523                    format!("edge needs two fields 'a -- b': '{stmt}'")
524                })?;
525                let field_a = a.trim().trim_start_matches("::").trim().to_string();
526                let field_b = b.trim().trim_start_matches("::").trim().to_string();
527                model.edges.push(EdgeDecl {
528                    scope: scope_head.trim().to_string(),
529                    field_a,
530                    field_b,
531                });
532            }
533            "def" | "macro" => {
534                defs.push(format!("{stmt};"));
535            }
536            "alias" => {
537                model.aliases.push(parse_alias(rest, stmt)?);
538            }
539            other => {
540                return Err(format!(
541                    "unknown model statement '{other}' \
542                     (expected node/ref/rel/edge/mount/alias/def/macro)"
543                ));
544            }
545        }
546    }
547    model.defs_text = defs.join("\n");
548    // An edge's endpoints are the scoped nodes' *references*: each
549    // field must be ref-declared, or the edge would have no
550    // containers to connect. Refusing here keeps the failure loud —
551    // a skipped edge at walk time reads as an empty graph.
552    for edge in &model.edges {
553        for field in [&edge.field_a, &edge.field_b] {
554            if !model.refs.iter().any(|r| &r.field == field) {
555                return Err(format!(
556                    "edge field '::{field}' has no ref declaration — \
557                     an edge connects the containers its fields \
558                     resolve into, so declare \
559                     'ref {}::{field} --> ...;' first",
560                    edge.scope
561                ));
562            }
563        }
564    }
565    Ok(model)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn parses_the_forum_model() {
574        let m = parse_model(
575            r#"
576            # the forum's implied graph
577            mount forum: posts.csv;
578            node /ips/ip:         /forum/posts/*::ip     | unique;
579            node /cookies/cookie: /forum/posts/*::cookie | unique;
580            ref /forum/posts/*::ip     --> ips;
581            ref /forum/posts/*::cookie --> cookies;
582            edge /forum/posts/*: ::cookie -- ::ip;
583            "#,
584        )
585        .unwrap();
586        assert_eq!(m.mounts.len(), 1);
587        assert_eq!(m.mounts[0].name, "forum");
588        assert_eq!(m.mounts[0].target, "posts.csv");
589        assert_eq!(m.nodes.len(), 2);
590        assert_eq!(m.nodes[0].name, "ips");
591        assert_eq!(m.nodes[0].role, "ip");
592        assert_eq!(m.nodes[0].trait_name, "ip");
593        assert_eq!(m.nodes[0].query, "/forum/posts/*::ip     | unique");
594        assert_eq!(m.refs.len(), 2);
595        assert_eq!(m.refs[1].scope, "/forum/posts/*");
596        assert_eq!(m.refs[1].field, "cookie");
597        assert_eq!(m.refs[1].container, "cookies");
598        assert_eq!(m.edges.len(), 1);
599        assert_eq!(m.edges[0].scope, "/forum/posts/*");
600        assert_eq!(m.edges[0].field_a, "cookie");
601        assert_eq!(m.edges[0].field_b, "ip");
602    }
603
604    #[test]
605    fn trailing_comments_are_stripped_but_glued_hashes_survive() {
606        let m = parse_model(
607            "node /ips/ip: /row::ip | unique;   # distinct ips\n\
608             node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
609             ref /row::ip --> ips;  # resolves there\n",
610        )
611        .unwrap();
612        assert_eq!(m.nodes.len(), 2);
613        assert_eq!(m.nodes[0].query, "/row::ip | unique");
614        // the glued '#' inside the substitution is body text
615        assert!(m.nodes[1].query.contains("s/#("));
616        assert_eq!(m.refs.len(), 1);
617    }
618
619    #[test]
620    fn collects_defs_and_comments() {
621        let m = parse_model(
622            "def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;",
623        )
624        .unwrap();
625        assert_eq!(m.nodes.len(), 1);
626        assert!(m.defs_text.contains("def &recent"));
627    }
628
629    #[test]
630    fn a_semicolon_projection_is_not_a_terminator() {
631        // legacy ;;; in a body survives the statement splitter
632        let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
633        assert_eq!(m.nodes.len(), 1);
634        assert_eq!(m.nodes[0].query, "/files/*::::size");
635    }
636
637    #[test]
638    fn a_node_head_needs_a_role() {
639        // `node ips: …` says nothing about what a hop landing in
640        // the container should be called.
641        assert!(parse_model("node ips: /row::ip;").is_err());
642    }
643
644    #[test]
645    fn declares_an_explicit_trait() {
646        let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
647        assert_eq!(m.nodes[0].role, "ip");
648        assert_eq!(m.nodes[0].trait_name, "addr");
649    }
650
651    #[test]
652    fn bare_field_ref_is_refused() {
653        assert!(parse_model("ref ::ip --> ips;").is_err());
654    }
655
656    #[test]
657    fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
658        let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
659        assert_eq!(m.refs[0].container, "cookies");
660        assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
661        // the short form is the same statement with the condition left
662        // to resolution
663        let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
664        assert_eq!(m.refs[0].container, "cookies");
665        assert_eq!(m.refs[0].key_field, None);
666    }
667
668    #[test]
669    fn parses_a_relation() {
670        let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
671        assert_eq!(m.rels.len(), 1);
672        assert_eq!(m.rels[0].source, "/errors/error");
673        assert_eq!(m.rels[0].target, "/deploys/deploy");
674        assert_eq!(m.rels[0].cond, "[::at > _::at]");
675    }
676
677    #[test]
678    fn an_edge_over_unrefd_fields_is_refused() {
679        // the edge's endpoints are references; without the refs
680        // there is nothing to connect
681        let err = parse_model(
682            "node /ips/ip: /row::ip | unique;\n\
683             edge /row: ::cookie -- ::ip;",
684        )
685        .unwrap_err();
686        assert!(err.contains("no ref declaration"), "{err}");
687    }
688
689    #[test]
690    fn a_relation_without_a_condition_is_refused() {
691        // two node sets joined by nothing are a cross product
692        assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
693    }
694}