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    /// `trait <name>: STEP;` — declared traits: a node carries
29    /// `name` exactly when it would match STEP as a step of a path,
30    /// decided at the node. In declaration order; a declaration may
31    /// use the traits declared before it.
32    pub traits: Vec<TraitDecl>,
33}
34
35/// A declared trait: `trait <heading>: (h1|h2|h3);`.
36#[derive(Debug, Clone)]
37pub struct TraitDecl {
38    pub name: String,
39    /// The step body, as written — a name, a pattern, an
40    /// alternation, a trait expression, predicates, in any
41    /// combination, without the navigation operator.
42    pub step: String,
43}
44
45/// What an alias applies to.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum AliasKind {
48    /// A node's name: `alias page document;`, `alias s/\.html$//;`.
49    Name,
50    /// A node's traits: `alias <chunk> <block>;`,
51    /// `alias <s/^tag://i>;`.
52    Trait,
53}
54
55/// How a written spelling is matched against the canonical one.
56#[derive(Debug, Clone)]
57pub enum AliasRule {
58    /// The written `alias` stands for the canonical `original`.
59    Exact { alias: String, original: String },
60    /// A Perl-style substitution `s/pattern/replacement/flags` applied
61    /// to each canonical spelling: the node also answers to what it
62    /// produces. `i` compares case-insensitively; `g` replaces every
63    /// match rather than the first.
64    Subst {
65        pattern: regex::Regex,
66        replacement: String,
67        ignore_case: bool,
68        global: bool,
69    },
70}
71
72#[derive(Debug, Clone)]
73pub struct AliasDecl {
74    pub kind: AliasKind,
75    pub rule: AliasRule,
76}
77
78impl AliasDecl {
79    /// Whether `written` reaches a node whose canonical spelling is
80    /// `canonical` under this alias.
81    pub fn admits(&self, written: &str, canonical: &str) -> bool {
82        match &self.rule {
83            AliasRule::Exact { alias, original } => written == alias && canonical == original,
84            AliasRule::Subst {
85                pattern,
86                replacement,
87                ignore_case,
88                global,
89            } => {
90                // Perl's rule: a substitution that matched nothing
91                // substituted nothing, and admits nothing.
92                if !pattern.is_match(canonical) {
93                    return false;
94                }
95                let produced = if *global {
96                    pattern.replace_all(canonical, replacement.as_str())
97                } else {
98                    pattern.replace(canonical, replacement.as_str())
99                };
100                if *ignore_case {
101                    produced.eq_ignore_ascii_case(written)
102                        || produced.to_lowercase() == written.to_lowercase()
103                } else {
104                    produced == written
105                }
106            }
107        }
108    }
109}
110
111/// Parse one alias body: `NEW ORIGINAL`, `s/pat/rep/flags`, or the
112/// same two forms inside angle brackets for traits.
113/// `trait <NAME>: STEP` — a plain name in angle brackets (no
114/// period prefix: a declared trait is arbor structure, named like
115/// the adapter's own), then the body of one path step.
116fn parse_trait(body: &str, stmt: &str) -> Result<TraitDecl, String> {
117    let body = body.trim();
118    let Some(rest) = body.strip_prefix('<') else {
119        return Err(format!(
120            "trait needs '<name>: STEP' (the name in angle brackets): '{stmt}'"
121        ));
122    };
123    let Some((name, step)) = rest.split_once('>') else {
124        return Err(format!("trait <name> is not closed: '{stmt}'"));
125    };
126    let name = name.trim();
127    if let Some(bare) = name.strip_prefix('.') {
128        return Err(format!(
129            "a declared trait has a plain name — write 'trait <{bare}>', not '<.{bare}>': '{stmt}'"
130        ));
131    }
132    let plain = name
133        .chars()
134        .next()
135        .is_some_and(|c| c.is_alphabetic() || c == '_')
136        && name
137            .chars()
138            .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-'));
139    if !plain {
140        return Err(format!(
141            "trait name '<{name}>' is not a plain name: '{stmt}'"
142        ));
143    }
144    let Some(step) = step.trim().strip_prefix(':') else {
145        return Err(format!(
146            "trait <{name}> needs ': STEP' after the name: '{stmt}'"
147        ));
148    };
149    let step = step.trim();
150    if step.is_empty() {
151        return Err(format!("trait <{name}> needs a step body: '{stmt}'"));
152    }
153    quarb::validate_step_body(step).map_err(|e| format!("trait <{name}>: {e}"))?;
154    Ok(TraitDecl {
155        name: name.to_string(),
156        step: step.to_string(),
157    })
158}
159
160fn parse_alias(body: &str, stmt: &str) -> Result<AliasDecl, String> {
161    let body = body.trim();
162    let (kind, body) = match body.strip_prefix('<') {
163        Some(inner) => {
164            let inner = inner.trim_end();
165            let inner = inner
166                .strip_suffix('>')
167                .ok_or_else(|| format!("a trait alias closes its angle bracket: '{stmt}'"))?;
168            // `<chunk> <block>` — two bracketed spellings.
169            let inner = inner.trim();
170            let inner = match inner.split_once("> <") {
171                Some((a, b)) => format!("{} {}", a.trim(), b.trim()),
172                None => inner.to_string(),
173            };
174            (AliasKind::Trait, inner)
175        }
176        None => (AliasKind::Name, body.to_string()),
177    };
178    let body = body.trim();
179    if let Some(rest) = body.strip_prefix("s/") {
180        // s/pattern/replacement/flags, `\/` escaping a slash.
181        let mut parts: Vec<String> = Vec::new();
182        let mut cur = String::new();
183        let mut chars = rest.chars().peekable();
184        while let Some(c) = chars.next() {
185            if c == '\\' && chars.peek() == Some(&'/') {
186                cur.push('/');
187                chars.next();
188            } else if c == '/' {
189                parts.push(std::mem::take(&mut cur));
190            } else {
191                cur.push(c);
192            }
193        }
194        parts.push(cur);
195        if parts.len() != 3 {
196            return Err(format!(
197                "a substitution alias is 's/pattern/replacement/flags': '{stmt}'"
198            ));
199        }
200        let flags = parts[2].trim();
201        for f in flags.chars() {
202            if !matches!(f, 'i' | 'g') {
203                return Err(format!("unknown substitution flag '{f}' (i, g): '{stmt}'"));
204            }
205        }
206        let pattern = regex::Regex::new(&parts[0])
207            .map_err(|e| format!("alias pattern does not parse: {e}: '{stmt}'"))?;
208        return Ok(AliasDecl {
209            kind,
210            rule: AliasRule::Subst {
211                pattern,
212                replacement: parts[1].clone(),
213                ignore_case: flags.contains('i'),
214                global: flags.contains('g'),
215            },
216        });
217    }
218    let (alias, original) = body.split_once(char::is_whitespace).ok_or_else(|| {
219        format!("alias needs 'NEW ORIGINAL' or 's/pattern/replacement/': '{stmt}'")
220    })?;
221    let strip = |t: &str| {
222        t.trim()
223            .trim_start_matches('<')
224            .trim_end_matches('>')
225            .to_string()
226    };
227    Ok(AliasDecl {
228        kind,
229        rule: AliasRule::Exact {
230            alias: strip(alias),
231            original: strip(original),
232        },
233    })
234}
235
236#[derive(Debug, Clone)]
237pub struct Mount {
238    pub name: String,
239    pub target: String,
240}
241
242#[derive(Debug, Clone)]
243pub struct NodeDecl {
244    /// The container's name: `ips` from `/ips/ip`.
245    pub name: String,
246    /// The role each child plays in it: `ip` from `/ips/ip`. A hop
247    /// into this container is labelled by this role — a label names
248    /// what it lands on, never the property it came from.
249    pub role: String,
250    /// An explicit trait, `<addr>` in `node /ips/ip<addr>: …`;
251    /// defaults to the role.
252    pub trait_name: String,
253    pub query: String,
254}
255
256#[derive(Debug, Clone)]
257pub struct RefDecl {
258    /// The node-selecting path (the `::field` suffix stripped).
259    pub scope: String,
260    /// The property resolved into the container.
261    pub field: String,
262    pub container: String,
263    /// The target path as written, condition stripped: when no
264    /// derived container answers to the first segment, the target
265    /// is this path over the base and derived view (`/кузнецова/*`).
266    pub path: String,
267    /// The role within the container the path form names
268    /// (`/tables/class`), which restricts resolution to that role's
269    /// members; `None` for the bare form (`tables`), which resolves
270    /// anywhere in the container.
271    pub role: Option<String>,
272    /// The target property the value is matched against, from the
273    /// explicit form `--> /cookies/cookie[::id = $]`. `None` is the
274    /// short form, matching the target's default projection.
275    pub key_field: Option<String>,
276}
277
278/// `rel SOURCE -> TARGET[cond];` — a relation between two node sets
279/// that no property carries: the condition is evaluated per pair,
280/// with `$$` standing for the source node.
281#[derive(Debug, Clone)]
282pub struct RelDecl {
283    pub source: String,
284    /// The target path, without its condition.
285    pub target: String,
286    /// The condition, brackets included: `[::at > $$::at]`.
287    pub cond: String,
288}
289
290#[derive(Debug, Clone)]
291pub struct EdgeDecl {
292    pub scope: String,
293    pub field_a: String,
294    pub field_b: String,
295}
296
297/// Split model-file text into statements at a lone `;` outside any
298/// bracket pair. A maximal run of two or more `;` is a projection
299/// sigil in a body (`;;;` and its `::;` cousin), never a terminator;
300/// a `;` inside `(…)`, `[…]` or `{…}` separates a call's arguments
301/// or a record's fields (`%(a = ::a; b = ::b)`), never statements;
302/// strings are opaque.
303fn statements(text: &str) -> Vec<String> {
304    let b: Vec<char> = text.chars().collect();
305    let mut out = Vec::new();
306    let mut start = 0;
307    let mut i = 0;
308    let mut depth = 0usize;
309    while i < b.len() {
310        match b[i] {
311            q @ ('\'' | '"') => {
312                i += 1;
313                while i < b.len() && b[i] != q {
314                    if b[i] == '\\' {
315                        i += 1;
316                    }
317                    i += 1;
318                }
319                i += 1;
320            }
321            '(' | '[' | '{' => {
322                depth += 1;
323                i += 1;
324            }
325            ')' | ']' | '}' => {
326                depth = depth.saturating_sub(1);
327                i += 1;
328            }
329            ';' => {
330                let mut run = 0;
331                while i < b.len() && b[i] == ';' {
332                    run += 1;
333                    i += 1;
334                }
335                if run == 1 && depth == 0 {
336                    out.push(b[start..i - 1].iter().collect());
337                    start = i;
338                }
339            }
340            _ => i += 1,
341        }
342    }
343    if start < b.len() {
344        let tail: String = b[start..].iter().collect();
345        if !tail.trim().is_empty() {
346            out.push(tail);
347        }
348    }
349    out
350}
351
352/// Strip `#` comments: from a `#` at start of line or preceded by
353/// whitespace, to end of line. Strings are opaque, and a `#` glued
354/// to text (`s/\.#\d+//`) is body text, not a comment — so trailing
355/// annotations parse while substitutions survive.
356fn strip_comments(text: &str) -> String {
357    let mut out = String::new();
358    for line in text.lines() {
359        let b: Vec<char> = line.chars().collect();
360        let mut i = 0;
361        while i < b.len() {
362            match b[i] {
363                q @ ('\'' | '"') => {
364                    out.push(q);
365                    i += 1;
366                    while i < b.len() && b[i] != q {
367                        if b[i] == '\\' && i + 1 < b.len() {
368                            out.push(b[i]);
369                            i += 1;
370                        }
371                        out.push(b[i]);
372                        i += 1;
373                    }
374                    if i < b.len() {
375                        out.push(b[i]);
376                        i += 1;
377                    }
378                }
379                '#' if i == 0 || b[i - 1].is_whitespace() => break,
380                c => {
381                    out.push(c);
382                    i += 1;
383                }
384            }
385        }
386        out.push('\n');
387    }
388    out
389}
390
391/// Parse the left side of a `ref`/`edge` field into `(scope_path,
392/// field)` — splitting at the last `::` (the property projection).
393fn split_field(s: &str) -> Result<(String, String), String> {
394    let s = s.trim();
395    match s.rfind("::") {
396        Some(idx) => {
397            let path = s[..idx].trim().to_string();
398            let field = s[idx + 2..].trim().to_string();
399            if path.is_empty() {
400                return Err(format!(
401                    "ref needs a path scope before '::{field}' \
402                     (bare fields are refused): '{s}'"
403                ));
404            }
405            if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
406                return Err(format!("ref/edge field is not a bare property: '{s}'"));
407            }
408            Ok((path, field))
409        }
410        None => Err(format!(
411            "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
412        )),
413    }
414}
415
416/// Resolve a `mount` target against the model file's directory: a
417/// scheme target (`neo4j://`, `git:…`) or an absolute path is left
418/// as-is; a relative filesystem path is joined to `base_dir`, so a
419/// shipped fixture directory is self-locating.
420pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
421    let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
422        s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
423    });
424    let p = std::path::Path::new(target);
425    if has_scheme || p.is_absolute() {
426        return target.to_string();
427    }
428    match base_dir {
429        Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
430        _ => target.to_string(),
431    }
432}
433
434/// Split a trailing `<trait>` off a node head.
435fn split_trait(head: &str) -> (String, Option<String>) {
436    match head.split_once('<') {
437        Some((path, rest)) => (
438            path.trim().to_string(),
439            rest.strip_suffix('>').map(|t| t.trim().to_string()),
440        ),
441        None => (head.to_string(), None),
442    }
443}
444
445/// `/ips/ip` -> (`ips`, `ip`). A bare name has no role and is
446/// refused: the role is what a hop into the container is called.
447fn split_container_role(path: &str) -> Option<(String, String)> {
448    let p = path.trim().trim_start_matches('/');
449    let (container, role) = p.split_once('/')?;
450    let (container, role) = (container.trim(), role.trim());
451    if container.is_empty() || role.is_empty() || role.contains('/') {
452        return None;
453    }
454    Some((container.to_string(), role.to_string()))
455}
456
457/// Split a trailing bracket group off a path: `/d/deploy [::at > $$]`
458/// -> (`/d/deploy`, `[::at > $$]`). Scans from the end so a bracket
459/// inside an earlier predicate stays with the path.
460fn split_trailing_group(s: &str) -> Option<(String, String)> {
461    let s = s.trim();
462    if !s.ends_with(']') {
463        return None;
464    }
465    let b: Vec<char> = s.chars().collect();
466    let mut depth = 0usize;
467    for i in (0..b.len()).rev() {
468        match b[i] {
469            ']' => depth += 1,
470            '[' => {
471                depth -= 1;
472                if depth == 0 {
473                    let path = b[..i].iter().collect::<String>().trim().to_string();
474                    let group = b[i..].iter().collect::<String>();
475                    return (!path.is_empty()).then_some((path, group));
476                }
477            }
478            _ => {}
479        }
480    }
481    None
482}
483
484/// Parse a `ref` target: `ips`, `/ips/ip`, or `/ips/ip[::id = $]` —
485/// the container, the role the path form names, and the key field.
486fn parse_ref_target(s: &str) -> Result<(String, String, Option<String>, Option<String>), String> {
487    let (path, key_field) = match split_trailing_group(s) {
488        Some((path, group)) => {
489            let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
490            let (lhs, rhs) = inner
491                .split_once('=')
492                .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
493            if rhs.trim() != "$" {
494                return Err(format!(
495                    "ref resolves by value, so its condition is 'PROP = $': '{s}'"
496                ));
497            }
498            let key = lhs.trim().trim_start_matches("::").trim().to_string();
499            (path, (!key.is_empty()).then_some(key))
500        }
501        None => (s.trim().to_string(), None),
502    };
503    // The container is the first segment of a path form, or the whole
504    // word of the bare form; the path form's second segment is the role.
505    let mut segs = path.trim_start_matches('/').split('/').map(str::trim);
506    let container = segs.next().unwrap_or("").to_string();
507    let role = segs.next().filter(|r| !r.is_empty()).map(str::to_string);
508    if container.is_empty() {
509        return Err(format!("ref needs a target container: '{s}'"));
510    }
511    Ok((container, path, role, key_field))
512}
513
514/// Split at the first `->` that is not part of a `-->`.
515fn split_arrow(s: &str) -> Option<(String, String)> {
516    let b: Vec<char> = s.chars().collect();
517    let mut i = 0;
518    while i + 1 < b.len() {
519        if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
520            return Some((
521                b[..i].iter().collect::<String>().trim().to_string(),
522                b[i + 2..].iter().collect::<String>().trim().to_string(),
523            ));
524        }
525        i += 1;
526    }
527    None
528}
529
530/// Parse model-file text. A `use FILE;` statement needs a location
531/// to resolve against, so it is refused here; [`parse_model_file`]
532/// is the entry point for a model on disk.
533pub fn parse_model(text: &str) -> Result<Model, String> {
534    parse_model_in(text, None, &mut Vec::new(), &mut Vec::new())
535}
536
537/// Parse a model file, resolving its `use FILE;` includes against
538/// its own directory (recursively; a cycle is refused). An included
539/// model's mounts are resolved against the included file's
540/// directory, so each model stays self-locating; its `node`, `ref`,
541/// `rel`, `edge`, `alias` and `def` statements join the includer's,
542/// ahead of them; a mount name declared twice is refused.
543pub fn parse_model_file(path: &std::path::Path) -> Result<Model, String> {
544    let text =
545        std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
546    let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
547    let mut visited = vec![path.canonicalize().unwrap_or_else(|_| path.to_path_buf())];
548    parse_model_in(&text, path.parent(), &mut visited, &mut Vec::new())
549}
550
551fn parse_model_in(
552    text: &str,
553    base_dir: Option<&std::path::Path>,
554    visited: &mut Vec<std::path::PathBuf>,
555    done: &mut Vec<std::path::PathBuf>,
556) -> Result<Model, String> {
557    let mut model = Model::default();
558    let mut defs = Vec::new();
559    for raw in statements(&strip_comments(text)) {
560        let stmt = raw.trim();
561        if stmt.is_empty() {
562            continue;
563        }
564        let (head, rest) = match stmt.split_once(char::is_whitespace) {
565            Some((h, r)) => (h, r.trim()),
566            None => (stmt, ""),
567        };
568        match head {
569            "node" => {
570                let (head, query) = rest
571                    .split_once(':')
572                    .ok_or_else(|| format!("node needs '/container/role: query': '{stmt}'"))?;
573                let (path, trait_name) = split_trait(head.trim());
574                let (name, role) = split_container_role(&path).ok_or_else(|| {
575                    format!(
576                        "node needs '/container/role', so a hop can be \
577                         labelled by the role it lands on: '{stmt}'"
578                    )
579                })?;
580                let trait_name = trait_name.unwrap_or_else(|| role.clone());
581                model.nodes.push(NodeDecl {
582                    name,
583                    role,
584                    trait_name,
585                    query: query.trim().to_string(),
586                });
587            }
588            "mount" => {
589                let (name, target) = rest
590                    .split_once(':')
591                    .ok_or_else(|| format!("mount needs 'NAME: target': '{stmt}'"))?;
592                let name = name.trim().to_string();
593                if model.mounts.iter().any(|m| m.name == name) {
594                    return Err(format!("mount '{name}' is already declared: '{stmt}'"));
595                }
596                model.mounts.push(Mount {
597                    name,
598                    target: target.trim().to_string(),
599                });
600            }
601            "use" => {
602                let file = rest.trim().trim_matches(|c| c == '"' || c == '\'');
603                if file.is_empty() {
604                    return Err(format!("use needs a model file: '{stmt}'"));
605                }
606                let Some(dir) = base_dir else {
607                    return Err(format!(
608                        "use needs a location to resolve '{file}' against: pass the model as a file (--model FILE)"
609                    ));
610                };
611                let path = {
612                    let p = std::path::Path::new(file);
613                    if p.is_absolute() {
614                        p.to_path_buf()
615                    } else {
616                        dir.join(p)
617                    }
618                };
619                let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
620                // `visited` is the chain of files being read: meeting
621                // one of them again is a cycle. A file already read
622                // on another branch (two models over one base, used
623                // by a third) is in `done`, and is not read twice.
624                if visited.contains(&canon) {
625                    return Err(format!("use {file}: the model includes itself"));
626                }
627                if done.contains(&canon) {
628                    continue;
629                }
630                visited.push(canon.clone());
631                let text = std::fs::read_to_string(&path)
632                    .map_err(|e| format!("use {file}: reading {}: {e}", path.display()))?;
633                let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
634                let sub = parse_model_in(&text, path.parent(), visited, done)
635                    .map_err(|e| format!("use {file}: {e}"))?;
636                visited.pop();
637                done.push(canon);
638                for m in sub.mounts {
639                    if model.mounts.iter().any(|x| x.name == m.name) {
640                        return Err(format!(
641                            "use {file}: mount '{}' is already declared",
642                            m.name
643                        ));
644                    }
645                    model.mounts.push(Mount {
646                        name: m.name,
647                        target: resolve_mount_target(&m.target, path.parent()),
648                    });
649                }
650                model.nodes.extend(sub.nodes);
651                model.refs.extend(sub.refs);
652                model.rels.extend(sub.rels);
653                model.edges.extend(sub.edges);
654                model.aliases.extend(sub.aliases);
655                for t in sub.traits {
656                    if model.traits.iter().any(|x| x.name == t.name) {
657                        return Err(format!(
658                            "use {file}: trait <{}> is already declared",
659                            t.name
660                        ));
661                    }
662                    model.traits.push(t);
663                }
664                if !sub.defs_text.trim().is_empty() {
665                    defs.push(sub.defs_text);
666                }
667            }
668            "ref" => {
669                let (left, container) = rest
670                    .split_once("-->")
671                    .ok_or_else(|| format!("ref needs 'PATH::field --> container': '{stmt}'"))?;
672                let (scope, field) = split_field(left)?;
673                let (container, path, role, key_field) = parse_ref_target(container)?;
674                model.refs.push(RefDecl {
675                    scope,
676                    field,
677                    container,
678                    path,
679                    role,
680                    key_field,
681                });
682            }
683            "rel" => {
684                let (source, target) = split_arrow(rest)
685                    .ok_or_else(|| format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'"))?;
686                let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
687                    format!(
688                        "rel needs a condition — two node sets joined by \
689                         nothing are a cross product: '{stmt}'"
690                    )
691                })?;
692                if source.is_empty() {
693                    return Err(format!("rel needs a source path: '{stmt}'"));
694                }
695                model.rels.push(RelDecl {
696                    source,
697                    target,
698                    cond,
699                });
700            }
701            "edge" => {
702                let (scope_head, pair) = rest
703                    .split_once(':')
704                    .ok_or_else(|| format!("edge needs 'PATH: ::a -- ::b': '{stmt}'"))?;
705                let (a, b) = pair
706                    .split_once("--")
707                    .ok_or_else(|| format!("edge needs two fields 'a -- b': '{stmt}'"))?;
708                let field_a = a.trim().trim_start_matches("::").trim().to_string();
709                let field_b = b.trim().trim_start_matches("::").trim().to_string();
710                model.edges.push(EdgeDecl {
711                    scope: scope_head.trim().to_string(),
712                    field_a,
713                    field_b,
714                });
715            }
716            "def" | "macro" => {
717                defs.push(format!("{stmt};"));
718            }
719            "alias" => {
720                model.aliases.push(parse_alias(rest, stmt)?);
721            }
722            "trait" => {
723                let decl = parse_trait(rest, stmt)?;
724                if model.traits.iter().any(|t| t.name == decl.name) {
725                    return Err(format!("trait <{}> is declared twice: '{stmt}'", decl.name));
726                }
727                model.traits.push(decl);
728            }
729            other => {
730                return Err(format!(
731                    "unknown model statement '{other}' \
732                     (expected node/ref/rel/edge/mount/alias/trait/def/macro)"
733                ));
734            }
735        }
736    }
737    model.defs_text = defs.join("\n");
738    // An edge's endpoints are the scoped nodes' *references*: each
739    // field must be ref-declared, or the edge would have no
740    // containers to connect. Refusing here keeps the failure loud —
741    // a skipped edge at walk time reads as an empty graph.
742    for edge in &model.edges {
743        for field in [&edge.field_a, &edge.field_b] {
744            if !model.refs.iter().any(|r| &r.field == field) {
745                return Err(format!(
746                    "edge field '::{field}' has no ref declaration — \
747                     an edge connects the containers its fields \
748                     resolve into, so declare \
749                     'ref {}::{field} --> ...;' first",
750                    edge.scope
751                ));
752            }
753        }
754    }
755    Ok(model)
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn parses_the_forum_model() {
764        let m = parse_model(
765            r#"
766            # the forum's implied graph
767            mount forum: posts.csv;
768            node /ips/ip:         /forum/posts/*::ip     | unique;
769            node /cookies/cookie: /forum/posts/*::cookie | unique;
770            ref /forum/posts/*::ip     --> ips;
771            ref /forum/posts/*::cookie --> cookies;
772            edge /forum/posts/*: ::cookie -- ::ip;
773            "#,
774        )
775        .unwrap();
776        assert_eq!(m.mounts.len(), 1);
777        assert_eq!(m.mounts[0].name, "forum");
778        assert_eq!(m.mounts[0].target, "posts.csv");
779        assert_eq!(m.nodes.len(), 2);
780        assert_eq!(m.nodes[0].name, "ips");
781        assert_eq!(m.nodes[0].role, "ip");
782        assert_eq!(m.nodes[0].trait_name, "ip");
783        assert_eq!(m.nodes[0].query, "/forum/posts/*::ip     | unique");
784        assert_eq!(m.refs.len(), 2);
785        assert_eq!(m.refs[1].scope, "/forum/posts/*");
786        assert_eq!(m.refs[1].field, "cookie");
787        assert_eq!(m.refs[1].container, "cookies");
788        assert_eq!(m.edges.len(), 1);
789        assert_eq!(m.edges[0].scope, "/forum/posts/*");
790        assert_eq!(m.edges[0].field_a, "cookie");
791        assert_eq!(m.edges[0].field_b, "ip");
792    }
793
794    #[test]
795    fn trailing_comments_are_stripped_but_glued_hashes_survive() {
796        let m = parse_model(
797            "node /ips/ip: /row::ip | unique;   # distinct ips\n\
798             node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
799             ref /row::ip --> ips;  # resolves there\n",
800        )
801        .unwrap();
802        assert_eq!(m.nodes.len(), 2);
803        assert_eq!(m.nodes[0].query, "/row::ip | unique");
804        // the glued '#' inside the substitution is body text
805        assert!(m.nodes[1].query.contains("s/#("));
806        assert_eq!(m.refs.len(), 1);
807    }
808
809    #[test]
810    fn collects_defs_and_comments() {
811        let m = parse_model("def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;")
812            .unwrap();
813        assert_eq!(m.nodes.len(), 1);
814        assert!(m.defs_text.contains("def &recent"));
815    }
816
817    #[test]
818    fn a_semicolon_projection_is_not_a_terminator() {
819        // legacy ;;; in a body survives the statement splitter
820        let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
821        assert_eq!(m.nodes.len(), 1);
822        assert_eq!(m.nodes[0].query, "/files/*::::size");
823    }
824
825    #[test]
826    fn a_node_head_needs_a_role() {
827        // `node ips: …` says nothing about what a hop landing in
828        // the container should be called.
829        assert!(parse_model("node ips: /row::ip;").is_err());
830    }
831
832    #[test]
833    fn declares_an_explicit_trait() {
834        let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
835        assert_eq!(m.nodes[0].role, "ip");
836        assert_eq!(m.nodes[0].trait_name, "addr");
837    }
838
839    #[test]
840    fn bare_field_ref_is_refused() {
841        assert!(parse_model("ref ::ip --> ips;").is_err());
842    }
843
844    #[test]
845    fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
846        let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
847        assert_eq!(m.refs[0].container, "cookies");
848        assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
849        // the short form is the same statement with the condition left
850        // to resolution
851        let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
852        assert_eq!(m.refs[0].container, "cookies");
853        assert_eq!(m.refs[0].key_field, None);
854    }
855
856    #[test]
857    fn parses_a_relation() {
858        let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
859        assert_eq!(m.rels.len(), 1);
860        assert_eq!(m.rels[0].source, "/errors/error");
861        assert_eq!(m.rels[0].target, "/deploys/deploy");
862        assert_eq!(m.rels[0].cond, "[::at > _::at]");
863    }
864
865    #[test]
866    fn an_edge_over_unrefd_fields_is_refused() {
867        // the edge's endpoints are references; without the refs
868        // there is nothing to connect
869        let err = parse_model(
870            "node /ips/ip: /row::ip | unique;\n\
871             edge /row: ::cookie -- ::ip;",
872        )
873        .unwrap_err();
874        assert!(err.contains("no ref declaration"), "{err}");
875    }
876
877    #[test]
878    fn a_relation_without_a_condition_is_refused() {
879        // two node sets joined by nothing are a cross product
880        assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
881    }
882}