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
104                .strip_suffix('>')
105                .ok_or_else(|| format!("a trait alias closes its angle bracket: '{stmt}'"))?;
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| {
160        t.trim()
161            .trim_start_matches('<')
162            .trim_end_matches('>')
163            .to_string()
164    };
165    Ok(AliasDecl {
166        kind,
167        rule: AliasRule::Exact {
168            alias: strip(alias),
169            original: strip(original),
170        },
171    })
172}
173
174#[derive(Debug, Clone)]
175pub struct Mount {
176    pub name: String,
177    pub target: String,
178}
179
180#[derive(Debug, Clone)]
181pub struct NodeDecl {
182    /// The container's name: `ips` from `/ips/ip`.
183    pub name: String,
184    /// The role each child plays in it: `ip` from `/ips/ip`. A hop
185    /// into this container is labelled by this role — a label names
186    /// what it lands on, never the property it came from.
187    pub role: String,
188    /// An explicit trait, `<addr>` in `node /ips/ip<addr>: …`;
189    /// defaults to the role.
190    pub trait_name: String,
191    pub query: String,
192}
193
194#[derive(Debug, Clone)]
195pub struct RefDecl {
196    /// The node-selecting path (the `::field` suffix stripped).
197    pub scope: String,
198    /// The property resolved into the container.
199    pub field: String,
200    pub container: String,
201    /// The target path as written, condition stripped: when no
202    /// derived container answers to the first segment, the target
203    /// is this path over the base and derived view (`/кузнецова/*`).
204    pub path: String,
205    /// The role within the container the path form names
206    /// (`/tables/class`), which restricts resolution to that role's
207    /// members; `None` for the bare form (`tables`), which resolves
208    /// anywhere in the container.
209    pub role: Option<String>,
210    /// The target property the value is matched against, from the
211    /// explicit form `--> /cookies/cookie[::id = $]`. `None` is the
212    /// short form, matching the target's default projection.
213    pub key_field: Option<String>,
214}
215
216/// `rel SOURCE -> TARGET[cond];` — a relation between two node sets
217/// that no property carries: the condition is evaluated per pair,
218/// with `$$` standing for the source node.
219#[derive(Debug, Clone)]
220pub struct RelDecl {
221    pub source: String,
222    /// The target path, without its condition.
223    pub target: String,
224    /// The condition, brackets included: `[::at > $$::at]`.
225    pub cond: String,
226}
227
228#[derive(Debug, Clone)]
229pub struct EdgeDecl {
230    pub scope: String,
231    pub field_a: String,
232    pub field_b: String,
233}
234
235/// Split model-file text into statements at a lone `;` outside any
236/// bracket pair. A maximal run of two or more `;` is a projection
237/// sigil in a body (`;;;` and its `::;` cousin), never a terminator;
238/// a `;` inside `(…)`, `[…]` or `{…}` separates a call's arguments
239/// or a record's fields (`%(a = ::a; b = ::b)`), never statements;
240/// strings are opaque.
241fn statements(text: &str) -> Vec<String> {
242    let b: Vec<char> = text.chars().collect();
243    let mut out = Vec::new();
244    let mut start = 0;
245    let mut i = 0;
246    let mut depth = 0usize;
247    while i < b.len() {
248        match b[i] {
249            q @ ('\'' | '"') => {
250                i += 1;
251                while i < b.len() && b[i] != q {
252                    if b[i] == '\\' {
253                        i += 1;
254                    }
255                    i += 1;
256                }
257                i += 1;
258            }
259            '(' | '[' | '{' => {
260                depth += 1;
261                i += 1;
262            }
263            ')' | ']' | '}' => {
264                depth = depth.saturating_sub(1);
265                i += 1;
266            }
267            ';' => {
268                let mut run = 0;
269                while i < b.len() && b[i] == ';' {
270                    run += 1;
271                    i += 1;
272                }
273                if run == 1 && depth == 0 {
274                    out.push(b[start..i - 1].iter().collect());
275                    start = i;
276                }
277            }
278            _ => i += 1,
279        }
280    }
281    if start < b.len() {
282        let tail: String = b[start..].iter().collect();
283        if !tail.trim().is_empty() {
284            out.push(tail);
285        }
286    }
287    out
288}
289
290/// Strip `#` comments: from a `#` at start of line or preceded by
291/// whitespace, to end of line. Strings are opaque, and a `#` glued
292/// to text (`s/\.#\d+//`) is body text, not a comment — so trailing
293/// annotations parse while substitutions survive.
294fn strip_comments(text: &str) -> String {
295    let mut out = String::new();
296    for line in text.lines() {
297        let b: Vec<char> = line.chars().collect();
298        let mut i = 0;
299        while i < b.len() {
300            match b[i] {
301                q @ ('\'' | '"') => {
302                    out.push(q);
303                    i += 1;
304                    while i < b.len() && b[i] != q {
305                        if b[i] == '\\' && i + 1 < b.len() {
306                            out.push(b[i]);
307                            i += 1;
308                        }
309                        out.push(b[i]);
310                        i += 1;
311                    }
312                    if i < b.len() {
313                        out.push(b[i]);
314                        i += 1;
315                    }
316                }
317                '#' if i == 0 || b[i - 1].is_whitespace() => break,
318                c => {
319                    out.push(c);
320                    i += 1;
321                }
322            }
323        }
324        out.push('\n');
325    }
326    out
327}
328
329/// Parse the left side of a `ref`/`edge` field into `(scope_path,
330/// field)` — splitting at the last `::` (the property projection).
331fn split_field(s: &str) -> Result<(String, String), String> {
332    let s = s.trim();
333    match s.rfind("::") {
334        Some(idx) => {
335            let path = s[..idx].trim().to_string();
336            let field = s[idx + 2..].trim().to_string();
337            if path.is_empty() {
338                return Err(format!(
339                    "ref needs a path scope before '::{field}' \
340                     (bare fields are refused): '{s}'"
341                ));
342            }
343            if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
344                return Err(format!("ref/edge field is not a bare property: '{s}'"));
345            }
346            Ok((path, field))
347        }
348        None => Err(format!(
349            "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
350        )),
351    }
352}
353
354/// Resolve a `mount` target against the model file's directory: a
355/// scheme target (`neo4j://`, `git:…`) or an absolute path is left
356/// as-is; a relative filesystem path is joined to `base_dir`, so a
357/// shipped fixture directory is self-locating.
358pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
359    let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
360        s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
361    });
362    let p = std::path::Path::new(target);
363    if has_scheme || p.is_absolute() {
364        return target.to_string();
365    }
366    match base_dir {
367        Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
368        _ => target.to_string(),
369    }
370}
371
372/// Split a trailing `<trait>` off a node head.
373fn split_trait(head: &str) -> (String, Option<String>) {
374    match head.split_once('<') {
375        Some((path, rest)) => (
376            path.trim().to_string(),
377            rest.strip_suffix('>').map(|t| t.trim().to_string()),
378        ),
379        None => (head.to_string(), None),
380    }
381}
382
383/// `/ips/ip` -> (`ips`, `ip`). A bare name has no role and is
384/// refused: the role is what a hop into the container is called.
385fn split_container_role(path: &str) -> Option<(String, String)> {
386    let p = path.trim().trim_start_matches('/');
387    let (container, role) = p.split_once('/')?;
388    let (container, role) = (container.trim(), role.trim());
389    if container.is_empty() || role.is_empty() || role.contains('/') {
390        return None;
391    }
392    Some((container.to_string(), role.to_string()))
393}
394
395/// Split a trailing bracket group off a path: `/d/deploy [::at > $$]`
396/// -> (`/d/deploy`, `[::at > $$]`). Scans from the end so a bracket
397/// inside an earlier predicate stays with the path.
398fn split_trailing_group(s: &str) -> Option<(String, String)> {
399    let s = s.trim();
400    if !s.ends_with(']') {
401        return None;
402    }
403    let b: Vec<char> = s.chars().collect();
404    let mut depth = 0usize;
405    for i in (0..b.len()).rev() {
406        match b[i] {
407            ']' => depth += 1,
408            '[' => {
409                depth -= 1;
410                if depth == 0 {
411                    let path = b[..i].iter().collect::<String>().trim().to_string();
412                    let group = b[i..].iter().collect::<String>();
413                    return (!path.is_empty()).then_some((path, group));
414                }
415            }
416            _ => {}
417        }
418    }
419    None
420}
421
422/// Parse a `ref` target: `ips`, `/ips/ip`, or `/ips/ip[::id = $]` —
423/// the container, the role the path form names, and the key field.
424fn parse_ref_target(s: &str) -> Result<(String, String, Option<String>, Option<String>), String> {
425    let (path, key_field) = match split_trailing_group(s) {
426        Some((path, group)) => {
427            let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
428            let (lhs, rhs) = inner
429                .split_once('=')
430                .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
431            if rhs.trim() != "$" {
432                return Err(format!(
433                    "ref resolves by value, so its condition is 'PROP = $': '{s}'"
434                ));
435            }
436            let key = lhs.trim().trim_start_matches("::").trim().to_string();
437            (path, (!key.is_empty()).then_some(key))
438        }
439        None => (s.trim().to_string(), None),
440    };
441    // The container is the first segment of a path form, or the whole
442    // word of the bare form; the path form's second segment is the role.
443    let mut segs = path.trim_start_matches('/').split('/').map(str::trim);
444    let container = segs.next().unwrap_or("").to_string();
445    let role = segs.next().filter(|r| !r.is_empty()).map(str::to_string);
446    if container.is_empty() {
447        return Err(format!("ref needs a target container: '{s}'"));
448    }
449    Ok((container, path, role, key_field))
450}
451
452/// Split at the first `->` that is not part of a `-->`.
453fn split_arrow(s: &str) -> Option<(String, String)> {
454    let b: Vec<char> = s.chars().collect();
455    let mut i = 0;
456    while i + 1 < b.len() {
457        if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
458            return Some((
459                b[..i].iter().collect::<String>().trim().to_string(),
460                b[i + 2..].iter().collect::<String>().trim().to_string(),
461            ));
462        }
463        i += 1;
464    }
465    None
466}
467
468/// Parse model-file text. A `use FILE;` statement needs a location
469/// to resolve against, so it is refused here; [`parse_model_file`]
470/// is the entry point for a model on disk.
471pub fn parse_model(text: &str) -> Result<Model, String> {
472    parse_model_in(text, None, &mut Vec::new(), &mut Vec::new())
473}
474
475/// Parse a model file, resolving its `use FILE;` includes against
476/// its own directory (recursively; a cycle is refused). An included
477/// model's mounts are resolved against the included file's
478/// directory, so each model stays self-locating; its `node`, `ref`,
479/// `rel`, `edge`, `alias` and `def` statements join the includer's,
480/// ahead of them; a mount name declared twice is refused.
481pub fn parse_model_file(path: &std::path::Path) -> Result<Model, String> {
482    let text =
483        std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
484    let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
485    let mut visited = vec![path.canonicalize().unwrap_or_else(|_| path.to_path_buf())];
486    parse_model_in(&text, path.parent(), &mut visited, &mut Vec::new())
487}
488
489fn parse_model_in(
490    text: &str,
491    base_dir: Option<&std::path::Path>,
492    visited: &mut Vec<std::path::PathBuf>,
493    done: &mut Vec<std::path::PathBuf>,
494) -> Result<Model, String> {
495    let mut model = Model::default();
496    let mut defs = Vec::new();
497    for raw in statements(&strip_comments(text)) {
498        let stmt = raw.trim();
499        if stmt.is_empty() {
500            continue;
501        }
502        let (head, rest) = match stmt.split_once(char::is_whitespace) {
503            Some((h, r)) => (h, r.trim()),
504            None => (stmt, ""),
505        };
506        match head {
507            "node" => {
508                let (head, query) = rest
509                    .split_once(':')
510                    .ok_or_else(|| format!("node needs '/container/role: query': '{stmt}'"))?;
511                let (path, trait_name) = split_trait(head.trim());
512                let (name, role) = split_container_role(&path).ok_or_else(|| {
513                    format!(
514                        "node needs '/container/role', so a hop can be \
515                         labelled by the role it lands on: '{stmt}'"
516                    )
517                })?;
518                let trait_name = trait_name.unwrap_or_else(|| role.clone());
519                model.nodes.push(NodeDecl {
520                    name,
521                    role,
522                    trait_name,
523                    query: query.trim().to_string(),
524                });
525            }
526            "mount" => {
527                let (name, target) = rest
528                    .split_once(':')
529                    .ok_or_else(|| format!("mount needs 'NAME: target': '{stmt}'"))?;
530                let name = name.trim().to_string();
531                if model.mounts.iter().any(|m| m.name == name) {
532                    return Err(format!("mount '{name}' is already declared: '{stmt}'"));
533                }
534                model.mounts.push(Mount {
535                    name,
536                    target: target.trim().to_string(),
537                });
538            }
539            "use" => {
540                let file = rest.trim().trim_matches(|c| c == '"' || c == '\'');
541                if file.is_empty() {
542                    return Err(format!("use needs a model file: '{stmt}'"));
543                }
544                let Some(dir) = base_dir else {
545                    return Err(format!(
546                        "use needs a location to resolve '{file}' against: pass the model as a file (--model FILE)"
547                    ));
548                };
549                let path = {
550                    let p = std::path::Path::new(file);
551                    if p.is_absolute() {
552                        p.to_path_buf()
553                    } else {
554                        dir.join(p)
555                    }
556                };
557                let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
558                // `visited` is the chain of files being read: meeting
559                // one of them again is a cycle. A file already read
560                // on another branch (two models over one base, used
561                // by a third) is in `done`, and is not read twice.
562                if visited.contains(&canon) {
563                    return Err(format!("use {file}: the model includes itself"));
564                }
565                if done.contains(&canon) {
566                    continue;
567                }
568                visited.push(canon.clone());
569                let text = std::fs::read_to_string(&path)
570                    .map_err(|e| format!("use {file}: reading {}: {e}", path.display()))?;
571                let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
572                let sub = parse_model_in(&text, path.parent(), visited, done)
573                    .map_err(|e| format!("use {file}: {e}"))?;
574                visited.pop();
575                done.push(canon);
576                for m in sub.mounts {
577                    if model.mounts.iter().any(|x| x.name == m.name) {
578                        return Err(format!(
579                            "use {file}: mount '{}' is already declared",
580                            m.name
581                        ));
582                    }
583                    model.mounts.push(Mount {
584                        name: m.name,
585                        target: resolve_mount_target(&m.target, path.parent()),
586                    });
587                }
588                model.nodes.extend(sub.nodes);
589                model.refs.extend(sub.refs);
590                model.rels.extend(sub.rels);
591                model.edges.extend(sub.edges);
592                model.aliases.extend(sub.aliases);
593                if !sub.defs_text.trim().is_empty() {
594                    defs.push(sub.defs_text);
595                }
596            }
597            "ref" => {
598                let (left, container) = rest
599                    .split_once("-->")
600                    .ok_or_else(|| format!("ref needs 'PATH::field --> container': '{stmt}'"))?;
601                let (scope, field) = split_field(left)?;
602                let (container, path, role, key_field) = parse_ref_target(container)?;
603                model.refs.push(RefDecl {
604                    scope,
605                    field,
606                    container,
607                    path,
608                    role,
609                    key_field,
610                });
611            }
612            "rel" => {
613                let (source, target) = split_arrow(rest)
614                    .ok_or_else(|| format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'"))?;
615                let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
616                    format!(
617                        "rel needs a condition — two node sets joined by \
618                         nothing are a cross product: '{stmt}'"
619                    )
620                })?;
621                if source.is_empty() {
622                    return Err(format!("rel needs a source path: '{stmt}'"));
623                }
624                model.rels.push(RelDecl {
625                    source,
626                    target,
627                    cond,
628                });
629            }
630            "edge" => {
631                let (scope_head, pair) = rest
632                    .split_once(':')
633                    .ok_or_else(|| format!("edge needs 'PATH: ::a -- ::b': '{stmt}'"))?;
634                let (a, b) = pair
635                    .split_once("--")
636                    .ok_or_else(|| format!("edge needs two fields 'a -- b': '{stmt}'"))?;
637                let field_a = a.trim().trim_start_matches("::").trim().to_string();
638                let field_b = b.trim().trim_start_matches("::").trim().to_string();
639                model.edges.push(EdgeDecl {
640                    scope: scope_head.trim().to_string(),
641                    field_a,
642                    field_b,
643                });
644            }
645            "def" | "macro" => {
646                defs.push(format!("{stmt};"));
647            }
648            "alias" => {
649                model.aliases.push(parse_alias(rest, stmt)?);
650            }
651            other => {
652                return Err(format!(
653                    "unknown model statement '{other}' \
654                     (expected node/ref/rel/edge/mount/alias/def/macro)"
655                ));
656            }
657        }
658    }
659    model.defs_text = defs.join("\n");
660    // An edge's endpoints are the scoped nodes' *references*: each
661    // field must be ref-declared, or the edge would have no
662    // containers to connect. Refusing here keeps the failure loud —
663    // a skipped edge at walk time reads as an empty graph.
664    for edge in &model.edges {
665        for field in [&edge.field_a, &edge.field_b] {
666            if !model.refs.iter().any(|r| &r.field == field) {
667                return Err(format!(
668                    "edge field '::{field}' has no ref declaration — \
669                     an edge connects the containers its fields \
670                     resolve into, so declare \
671                     'ref {}::{field} --> ...;' first",
672                    edge.scope
673                ));
674            }
675        }
676    }
677    Ok(model)
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn parses_the_forum_model() {
686        let m = parse_model(
687            r#"
688            # the forum's implied graph
689            mount forum: posts.csv;
690            node /ips/ip:         /forum/posts/*::ip     | unique;
691            node /cookies/cookie: /forum/posts/*::cookie | unique;
692            ref /forum/posts/*::ip     --> ips;
693            ref /forum/posts/*::cookie --> cookies;
694            edge /forum/posts/*: ::cookie -- ::ip;
695            "#,
696        )
697        .unwrap();
698        assert_eq!(m.mounts.len(), 1);
699        assert_eq!(m.mounts[0].name, "forum");
700        assert_eq!(m.mounts[0].target, "posts.csv");
701        assert_eq!(m.nodes.len(), 2);
702        assert_eq!(m.nodes[0].name, "ips");
703        assert_eq!(m.nodes[0].role, "ip");
704        assert_eq!(m.nodes[0].trait_name, "ip");
705        assert_eq!(m.nodes[0].query, "/forum/posts/*::ip     | unique");
706        assert_eq!(m.refs.len(), 2);
707        assert_eq!(m.refs[1].scope, "/forum/posts/*");
708        assert_eq!(m.refs[1].field, "cookie");
709        assert_eq!(m.refs[1].container, "cookies");
710        assert_eq!(m.edges.len(), 1);
711        assert_eq!(m.edges[0].scope, "/forum/posts/*");
712        assert_eq!(m.edges[0].field_a, "cookie");
713        assert_eq!(m.edges[0].field_b, "ip");
714    }
715
716    #[test]
717    fn trailing_comments_are_stripped_but_glued_hashes_survive() {
718        let m = parse_model(
719            "node /ips/ip: /row::ip | unique;   # distinct ips\n\
720             node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
721             ref /row::ip --> ips;  # resolves there\n",
722        )
723        .unwrap();
724        assert_eq!(m.nodes.len(), 2);
725        assert_eq!(m.nodes[0].query, "/row::ip | unique");
726        // the glued '#' inside the substitution is body text
727        assert!(m.nodes[1].query.contains("s/#("));
728        assert_eq!(m.refs.len(), 1);
729    }
730
731    #[test]
732    fn collects_defs_and_comments() {
733        let m = parse_model("def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;")
734            .unwrap();
735        assert_eq!(m.nodes.len(), 1);
736        assert!(m.defs_text.contains("def &recent"));
737    }
738
739    #[test]
740    fn a_semicolon_projection_is_not_a_terminator() {
741        // legacy ;;; in a body survives the statement splitter
742        let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
743        assert_eq!(m.nodes.len(), 1);
744        assert_eq!(m.nodes[0].query, "/files/*::::size");
745    }
746
747    #[test]
748    fn a_node_head_needs_a_role() {
749        // `node ips: …` says nothing about what a hop landing in
750        // the container should be called.
751        assert!(parse_model("node ips: /row::ip;").is_err());
752    }
753
754    #[test]
755    fn declares_an_explicit_trait() {
756        let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
757        assert_eq!(m.nodes[0].role, "ip");
758        assert_eq!(m.nodes[0].trait_name, "addr");
759    }
760
761    #[test]
762    fn bare_field_ref_is_refused() {
763        assert!(parse_model("ref ::ip --> ips;").is_err());
764    }
765
766    #[test]
767    fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
768        let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
769        assert_eq!(m.refs[0].container, "cookies");
770        assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
771        // the short form is the same statement with the condition left
772        // to resolution
773        let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
774        assert_eq!(m.refs[0].container, "cookies");
775        assert_eq!(m.refs[0].key_field, None);
776    }
777
778    #[test]
779    fn parses_a_relation() {
780        let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
781        assert_eq!(m.rels.len(), 1);
782        assert_eq!(m.rels[0].source, "/errors/error");
783        assert_eq!(m.rels[0].target, "/deploys/deploy");
784        assert_eq!(m.rels[0].cond, "[::at > _::at]");
785    }
786
787    #[test]
788    fn an_edge_over_unrefd_fields_is_refused() {
789        // the edge's endpoints are references; without the refs
790        // there is nothing to connect
791        let err = parse_model(
792            "node /ips/ip: /row::ip | unique;\n\
793             edge /row: ::cookie -- ::ip;",
794        )
795        .unwrap_err();
796        assert!(err.contains("no ref declaration"), "{err}");
797    }
798
799    #[test]
800    fn a_relation_without_a_condition_is_refused() {
801        // two node sets joined by nothing are a cross product
802        assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
803    }
804}