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}
25
26#[derive(Debug, Clone)]
27pub struct Mount {
28    pub name: String,
29    pub target: String,
30}
31
32#[derive(Debug, Clone)]
33pub struct NodeDecl {
34    /// The container's name: `ips` from `/ips/ip`.
35    pub name: String,
36    /// The role each child plays in it: `ip` from `/ips/ip`. A hop
37    /// into this container is labelled by this role — a label names
38    /// what it lands on, never the property it came from.
39    pub role: String,
40    /// An explicit trait, `<addr>` in `node /ips/ip<addr>: …`;
41    /// defaults to the role.
42    pub trait_name: String,
43    pub query: String,
44}
45
46#[derive(Debug, Clone)]
47pub struct RefDecl {
48    /// The node-selecting path (the `::field` suffix stripped).
49    pub scope: String,
50    /// The property resolved into the container.
51    pub field: String,
52    pub container: String,
53    /// The target property the value is matched against, from the
54    /// explicit form `--> /cookies/cookie[::id = $]`. `None` is the
55    /// short form, matching the target's default projection.
56    pub key_field: Option<String>,
57}
58
59/// `rel SOURCE -> TARGET[cond];` — a relation between two node sets
60/// that no property carries: the condition is evaluated per pair,
61/// with `$$` standing for the source node.
62#[derive(Debug, Clone)]
63pub struct RelDecl {
64    pub source: String,
65    /// The target path, without its condition.
66    pub target: String,
67    /// The condition, brackets included: `[::at > $$::at]`.
68    pub cond: String,
69}
70
71#[derive(Debug, Clone)]
72pub struct EdgeDecl {
73    pub scope: String,
74    pub field_a: String,
75    pub field_b: String,
76}
77
78/// Split model-file text into statements at a lone `;`. A maximal
79/// run of two or more `;` is a projection sigil in a body (`;;;` and
80/// its `::;` cousin), never a terminator; strings are opaque.
81fn statements(text: &str) -> Vec<String> {
82    let b: Vec<char> = text.chars().collect();
83    let mut out = Vec::new();
84    let mut start = 0;
85    let mut i = 0;
86    while i < b.len() {
87        match b[i] {
88            q @ ('\'' | '"') => {
89                i += 1;
90                while i < b.len() && b[i] != q {
91                    if b[i] == '\\' {
92                        i += 1;
93                    }
94                    i += 1;
95                }
96                i += 1;
97            }
98            ';' => {
99                let mut run = 0;
100                while i < b.len() && b[i] == ';' {
101                    run += 1;
102                    i += 1;
103                }
104                if run == 1 {
105                    out.push(b[start..i - 1].iter().collect());
106                    start = i;
107                }
108            }
109            _ => i += 1,
110        }
111    }
112    if start < b.len() {
113        let tail: String = b[start..].iter().collect();
114        if !tail.trim().is_empty() {
115            out.push(tail);
116        }
117    }
118    out
119}
120
121/// Strip `#` comments: from a `#` at start of line or preceded by
122/// whitespace, to end of line. Strings are opaque, and a `#` glued
123/// to text (`s/\.#\d+//`) is body text, not a comment — so trailing
124/// annotations parse while substitutions survive.
125fn strip_comments(text: &str) -> String {
126    let mut out = String::new();
127    for line in text.lines() {
128        let b: Vec<char> = line.chars().collect();
129        let mut i = 0;
130        while i < b.len() {
131            match b[i] {
132                q @ ('\'' | '"') => {
133                    out.push(q);
134                    i += 1;
135                    while i < b.len() && b[i] != q {
136                        if b[i] == '\\' && i + 1 < b.len() {
137                            out.push(b[i]);
138                            i += 1;
139                        }
140                        out.push(b[i]);
141                        i += 1;
142                    }
143                    if i < b.len() {
144                        out.push(b[i]);
145                        i += 1;
146                    }
147                }
148                '#' if i == 0 || b[i - 1].is_whitespace() => break,
149                c => {
150                    out.push(c);
151                    i += 1;
152                }
153            }
154        }
155        out.push('\n');
156    }
157    out
158}
159
160/// Parse the left side of a `ref`/`edge` field into `(scope_path,
161/// field)` — splitting at the last `::` (the property projection).
162fn split_field(s: &str) -> Result<(String, String), String> {
163    let s = s.trim();
164    match s.rfind("::") {
165        Some(idx) => {
166            let path = s[..idx].trim().to_string();
167            let field = s[idx + 2..].trim().to_string();
168            if path.is_empty() {
169                return Err(format!(
170                    "ref needs a path scope before '::{field}' \
171                     (bare fields are refused): '{s}'"
172                ));
173            }
174            if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
175                return Err(format!("ref/edge field is not a bare property: '{s}'"));
176            }
177            Ok((path, field))
178        }
179        None => Err(format!(
180            "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
181        )),
182    }
183}
184
185/// Resolve a `mount` target against the model file's directory: a
186/// scheme target (`neo4j://`, `git:…`) or an absolute path is left
187/// as-is; a relative filesystem path is joined to `base_dir`, so a
188/// shipped fixture directory is self-locating.
189pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
190    let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
191        s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
192    });
193    let p = std::path::Path::new(target);
194    if has_scheme || p.is_absolute() {
195        return target.to_string();
196    }
197    match base_dir {
198        Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
199        _ => target.to_string(),
200    }
201}
202
203/// Split a trailing `<trait>` off a node head.
204fn split_trait(head: &str) -> (String, Option<String>) {
205    match head.split_once('<') {
206        Some((path, rest)) => (
207            path.trim().to_string(),
208            rest.strip_suffix('>').map(|t| t.trim().to_string()),
209        ),
210        None => (head.to_string(), None),
211    }
212}
213
214/// `/ips/ip` -> (`ips`, `ip`). A bare name has no role and is
215/// refused: the role is what a hop into the container is called.
216fn split_container_role(path: &str) -> Option<(String, String)> {
217    let p = path.trim().trim_start_matches('/');
218    let (container, role) = p.split_once('/')?;
219    let (container, role) = (container.trim(), role.trim());
220    if container.is_empty() || role.is_empty() || role.contains('/') {
221        return None;
222    }
223    Some((container.to_string(), role.to_string()))
224}
225
226/// Split a trailing bracket group off a path: `/d/deploy [::at > $$]`
227/// -> (`/d/deploy`, `[::at > $$]`). Scans from the end so a bracket
228/// inside an earlier predicate stays with the path.
229fn split_trailing_group(s: &str) -> Option<(String, String)> {
230    let s = s.trim();
231    if !s.ends_with(']') {
232        return None;
233    }
234    let b: Vec<char> = s.chars().collect();
235    let mut depth = 0usize;
236    for i in (0..b.len()).rev() {
237        match b[i] {
238            ']' => depth += 1,
239            '[' => {
240                depth -= 1;
241                if depth == 0 {
242                    let path = b[..i].iter().collect::<String>().trim().to_string();
243                    let group = b[i..].iter().collect::<String>();
244                    return (!path.is_empty()).then_some((path, group));
245                }
246            }
247            _ => {}
248        }
249    }
250    None
251}
252
253/// Parse a `ref` target: `ips`, `/ips/ip`, or `/ips/ip[::id = $]`.
254fn parse_ref_target(s: &str) -> Result<(String, Option<String>), String> {
255    let (path, key_field) = match split_trailing_group(s) {
256        Some((path, group)) => {
257            let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
258            let (lhs, rhs) = inner
259                .split_once('=')
260                .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
261            if rhs.trim() != "$" {
262                return Err(format!(
263                    "ref resolves by value, so its condition is 'PROP = $': '{s}'"
264                ));
265            }
266            let key = lhs.trim().trim_start_matches("::").trim().to_string();
267            (path, (!key.is_empty()).then_some(key))
268        }
269        None => (s.trim().to_string(), None),
270    };
271    // The container is the first segment of a path form, or the whole
272    // word of the bare form.
273    let container = path
274        .trim_start_matches('/')
275        .split('/')
276        .next()
277        .unwrap_or("")
278        .trim()
279        .to_string();
280    if container.is_empty() {
281        return Err(format!("ref needs a target container: '{s}'"));
282    }
283    Ok((container, key_field))
284}
285
286/// Split at the first `->` that is not part of a `-->`.
287fn split_arrow(s: &str) -> Option<(String, String)> {
288    let b: Vec<char> = s.chars().collect();
289    let mut i = 0;
290    while i + 1 < b.len() {
291        if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
292            return Some((
293                b[..i].iter().collect::<String>().trim().to_string(),
294                b[i + 2..].iter().collect::<String>().trim().to_string(),
295            ));
296        }
297        i += 1;
298    }
299    None
300}
301
302/// Parse model-file text.
303pub fn parse_model(text: &str) -> Result<Model, String> {
304    let mut model = Model::default();
305    let mut defs = Vec::new();
306    for raw in statements(&strip_comments(text)) {
307        let stmt = raw.trim();
308        if stmt.is_empty() {
309            continue;
310        }
311        let (head, rest) = match stmt.split_once(char::is_whitespace) {
312            Some((h, r)) => (h, r.trim()),
313            None => (stmt, ""),
314        };
315        match head {
316            "node" => {
317                let (head, query) = rest.split_once(':').ok_or_else(|| {
318                    format!("node needs '/container/role: query': '{stmt}'")
319                })?;
320                let (path, trait_name) = split_trait(head.trim());
321                let (name, role) = split_container_role(&path)
322                    .ok_or_else(|| format!(
323                        "node needs '/container/role', so a hop can be \
324                         labelled by the role it lands on: '{stmt}'"
325                    ))?;
326                let trait_name = trait_name.unwrap_or_else(|| role.clone());
327                model.nodes.push(NodeDecl {
328                    name,
329                    role,
330                    trait_name,
331                    query: query.trim().to_string(),
332                });
333            }
334            "mount" => {
335                let (name, target) = rest.split_once(':').ok_or_else(|| {
336                    format!("mount needs 'NAME: target': '{stmt}'")
337                })?;
338                model.mounts.push(Mount {
339                    name: name.trim().to_string(),
340                    target: target.trim().to_string(),
341                });
342            }
343            "ref" => {
344                let (left, container) = rest.split_once("-->").ok_or_else(|| {
345                    format!("ref needs 'PATH::field --> container': '{stmt}'")
346                })?;
347                let (scope, field) = split_field(left)?;
348                let (container, key_field) = parse_ref_target(container)?;
349                model.refs.push(RefDecl {
350                    scope,
351                    field,
352                    container,
353                    key_field,
354                });
355            }
356            "rel" => {
357                let (source, target) = split_arrow(rest).ok_or_else(|| {
358                    format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'")
359                })?;
360                let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
361                    format!(
362                        "rel needs a condition — two node sets joined by \
363                         nothing are a cross product: '{stmt}'"
364                    )
365                })?;
366                if source.is_empty() {
367                    return Err(format!("rel needs a source path: '{stmt}'"));
368                }
369                model.rels.push(RelDecl {
370                    source,
371                    target,
372                    cond,
373                });
374            }
375            "edge" => {
376                let (scope_head, pair) = rest.split_once(':').ok_or_else(|| {
377                    format!("edge needs 'PATH: ::a -- ::b': '{stmt}'")
378                })?;
379                let (a, b) = pair.split_once("--").ok_or_else(|| {
380                    format!("edge needs two fields 'a -- b': '{stmt}'")
381                })?;
382                let field_a = a.trim().trim_start_matches("::").trim().to_string();
383                let field_b = b.trim().trim_start_matches("::").trim().to_string();
384                model.edges.push(EdgeDecl {
385                    scope: scope_head.trim().to_string(),
386                    field_a,
387                    field_b,
388                });
389            }
390            "def" | "macro" => {
391                defs.push(format!("{stmt};"));
392            }
393            other => {
394                return Err(format!(
395                    "unknown model statement '{other}' \
396                     (expected node/ref/rel/edge/mount/def/macro)"
397                ));
398            }
399        }
400    }
401    model.defs_text = defs.join("\n");
402    // An edge's endpoints are the scoped nodes' *references*: each
403    // field must be ref-declared, or the edge would have no
404    // containers to connect. Refusing here keeps the failure loud —
405    // a skipped edge at walk time reads as an empty graph.
406    for edge in &model.edges {
407        for field in [&edge.field_a, &edge.field_b] {
408            if !model.refs.iter().any(|r| &r.field == field) {
409                return Err(format!(
410                    "edge field '::{field}' has no ref declaration — \
411                     an edge connects the containers its fields \
412                     resolve into, so declare \
413                     'ref {}::{field} --> ...;' first",
414                    edge.scope
415                ));
416            }
417        }
418    }
419    Ok(model)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn parses_the_forum_model() {
428        let m = parse_model(
429            r#"
430            # the forum's implied graph
431            mount forum: posts.csv;
432            node /ips/ip:         /forum/posts/*::ip     | unique;
433            node /cookies/cookie: /forum/posts/*::cookie | unique;
434            ref /forum/posts/*::ip     --> ips;
435            ref /forum/posts/*::cookie --> cookies;
436            edge /forum/posts/*: ::cookie -- ::ip;
437            "#,
438        )
439        .unwrap();
440        assert_eq!(m.mounts.len(), 1);
441        assert_eq!(m.mounts[0].name, "forum");
442        assert_eq!(m.mounts[0].target, "posts.csv");
443        assert_eq!(m.nodes.len(), 2);
444        assert_eq!(m.nodes[0].name, "ips");
445        assert_eq!(m.nodes[0].role, "ip");
446        assert_eq!(m.nodes[0].trait_name, "ip");
447        assert_eq!(m.nodes[0].query, "/forum/posts/*::ip     | unique");
448        assert_eq!(m.refs.len(), 2);
449        assert_eq!(m.refs[1].scope, "/forum/posts/*");
450        assert_eq!(m.refs[1].field, "cookie");
451        assert_eq!(m.refs[1].container, "cookies");
452        assert_eq!(m.edges.len(), 1);
453        assert_eq!(m.edges[0].scope, "/forum/posts/*");
454        assert_eq!(m.edges[0].field_a, "cookie");
455        assert_eq!(m.edges[0].field_b, "ip");
456    }
457
458    #[test]
459    fn trailing_comments_are_stripped_but_glued_hashes_survive() {
460        let m = parse_model(
461            "node /ips/ip: /row::ip | unique;   # distinct ips\n\
462             node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
463             ref /row::ip --> ips;  # resolves there\n",
464        )
465        .unwrap();
466        assert_eq!(m.nodes.len(), 2);
467        assert_eq!(m.nodes[0].query, "/row::ip | unique");
468        // the glued '#' inside the substitution is body text
469        assert!(m.nodes[1].query.contains("s/#("));
470        assert_eq!(m.refs.len(), 1);
471    }
472
473    #[test]
474    fn collects_defs_and_comments() {
475        let m = parse_model(
476            "def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;",
477        )
478        .unwrap();
479        assert_eq!(m.nodes.len(), 1);
480        assert!(m.defs_text.contains("def &recent"));
481    }
482
483    #[test]
484    fn a_semicolon_projection_is_not_a_terminator() {
485        // legacy ;;; in a body survives the statement splitter
486        let m = parse_model("node /sizes/size: /files/*;;;size;").unwrap();
487        assert_eq!(m.nodes.len(), 1);
488        assert_eq!(m.nodes[0].query, "/files/*;;;size");
489    }
490
491    #[test]
492    fn a_node_head_needs_a_role() {
493        // `node ips: …` says nothing about what a hop landing in
494        // the container should be called.
495        assert!(parse_model("node ips: /row::ip;").is_err());
496    }
497
498    #[test]
499    fn declares_an_explicit_trait() {
500        let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
501        assert_eq!(m.nodes[0].role, "ip");
502        assert_eq!(m.nodes[0].trait_name, "addr");
503    }
504
505    #[test]
506    fn bare_field_ref_is_refused() {
507        assert!(parse_model("ref ::ip --> ips;").is_err());
508    }
509
510    #[test]
511    fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
512        let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
513        assert_eq!(m.refs[0].container, "cookies");
514        assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
515        // the short form is the same statement with the condition left
516        // to resolution
517        let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
518        assert_eq!(m.refs[0].container, "cookies");
519        assert_eq!(m.refs[0].key_field, None);
520    }
521
522    #[test]
523    fn parses_a_relation() {
524        let m = parse_model("rel /errors/error -> /deploys/deploy [::at > $$::at];").unwrap();
525        assert_eq!(m.rels.len(), 1);
526        assert_eq!(m.rels[0].source, "/errors/error");
527        assert_eq!(m.rels[0].target, "/deploys/deploy");
528        assert_eq!(m.rels[0].cond, "[::at > $$::at]");
529    }
530
531    #[test]
532    fn an_edge_over_unrefd_fields_is_refused() {
533        // the edge's endpoints are references; without the refs
534        // there is nothing to connect
535        let err = parse_model(
536            "node /ips/ip: /row::ip | unique;\n\
537             edge /row: ::cookie -- ::ip;",
538        )
539        .unwrap_err();
540        assert!(err.contains("no ref declaration"), "{err}");
541    }
542
543    #[test]
544    fn a_relation_without_a_condition_is_refused() {
545        // two node sets joined by nothing are a cross product
546        assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
547    }
548}