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