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//! `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 NAME: query;` — derived value containers.
14    pub nodes: Vec<NodeDecl>,
15    /// `ref PATH::field --> container;` — scoped references.
16    pub refs: Vec<RefDecl>,
17    /// `edge PATH: ::a -- ::b;` — pair edges between containers.
18    pub edges: Vec<EdgeDecl>,
19    /// `def`/`macro` statements, verbatim, joined with `;` — seeded
20    /// into every constructor query's fragment table.
21    pub defs_text: String,
22}
23
24#[derive(Debug, Clone)]
25pub struct Mount {
26    pub name: String,
27    pub target: String,
28}
29
30#[derive(Debug, Clone)]
31pub struct NodeDecl {
32    pub name: String,
33    pub query: String,
34}
35
36#[derive(Debug, Clone)]
37pub struct RefDecl {
38    /// The node-selecting path (the `::field` suffix stripped).
39    pub scope: String,
40    /// The property resolved into the container.
41    pub field: String,
42    pub container: String,
43}
44
45#[derive(Debug, Clone)]
46pub struct EdgeDecl {
47    pub scope: String,
48    pub field_a: String,
49    pub field_b: String,
50}
51
52/// Split model-file text into statements at a lone `;`. A maximal
53/// run of two or more `;` is a projection sigil in a body (`;;;` and
54/// its `::;` cousin), never a terminator; strings are opaque.
55fn statements(text: &str) -> Vec<String> {
56    let b: Vec<char> = text.chars().collect();
57    let mut out = Vec::new();
58    let mut start = 0;
59    let mut i = 0;
60    while i < b.len() {
61        match b[i] {
62            q @ ('\'' | '"') => {
63                i += 1;
64                while i < b.len() && b[i] != q {
65                    if b[i] == '\\' {
66                        i += 1;
67                    }
68                    i += 1;
69                }
70                i += 1;
71            }
72            ';' => {
73                let mut run = 0;
74                while i < b.len() && b[i] == ';' {
75                    run += 1;
76                    i += 1;
77                }
78                if run == 1 {
79                    out.push(b[start..i - 1].iter().collect());
80                    start = i;
81                }
82            }
83            _ => i += 1,
84        }
85    }
86    if start < b.len() {
87        let tail: String = b[start..].iter().collect();
88        if !tail.trim().is_empty() {
89            out.push(tail);
90        }
91    }
92    out
93}
94
95/// Strip `#` line comments (a `#` starting a line, after optional
96/// whitespace) — the defs-file convention.
97fn strip_comments(text: &str) -> String {
98    text.lines()
99        .filter(|l| !l.trim_start().starts_with('#'))
100        .collect::<Vec<_>>()
101        .join("\n")
102}
103
104/// Parse the left side of a `ref`/`edge` field into `(scope_path,
105/// field)` — splitting at the last `::` (the property projection).
106fn split_field(s: &str) -> Result<(String, String), String> {
107    let s = s.trim();
108    match s.rfind("::") {
109        Some(idx) => {
110            let path = s[..idx].trim().to_string();
111            let field = s[idx + 2..].trim().to_string();
112            if path.is_empty() {
113                return Err(format!(
114                    "ref needs a path scope before '::{field}' \
115                     (bare fields are refused): '{s}'"
116                ));
117            }
118            if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
119                return Err(format!("ref/edge field is not a bare property: '{s}'"));
120            }
121            Ok((path, field))
122        }
123        None => Err(format!(
124            "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
125        )),
126    }
127}
128
129/// Resolve a `mount` target against the model file's directory: a
130/// scheme target (`neo4j://`, `git:…`) or an absolute path is left
131/// as-is; a relative filesystem path is joined to `base_dir`, so a
132/// shipped fixture directory is self-locating.
133pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
134    let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
135        s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
136    });
137    let p = std::path::Path::new(target);
138    if has_scheme || p.is_absolute() {
139        return target.to_string();
140    }
141    match base_dir {
142        Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
143        _ => target.to_string(),
144    }
145}
146
147/// Parse model-file text.
148pub fn parse_model(text: &str) -> Result<Model, String> {
149    let mut model = Model::default();
150    let mut defs = Vec::new();
151    for raw in statements(&strip_comments(text)) {
152        let stmt = raw.trim();
153        if stmt.is_empty() {
154            continue;
155        }
156        let (head, rest) = match stmt.split_once(char::is_whitespace) {
157            Some((h, r)) => (h, r.trim()),
158            None => (stmt, ""),
159        };
160        match head {
161            "node" => {
162                let (name, query) = rest.split_once(':').ok_or_else(|| {
163                    format!("node needs 'NAME: query': '{stmt}'")
164                })?;
165                model.nodes.push(NodeDecl {
166                    name: name.trim().to_string(),
167                    query: query.trim().to_string(),
168                });
169            }
170            "mount" => {
171                let (name, target) = rest.split_once(':').ok_or_else(|| {
172                    format!("mount needs 'NAME: target': '{stmt}'")
173                })?;
174                model.mounts.push(Mount {
175                    name: name.trim().to_string(),
176                    target: target.trim().to_string(),
177                });
178            }
179            "ref" => {
180                let (left, container) = rest.split_once("-->").ok_or_else(|| {
181                    format!("ref needs 'PATH::field --> container': '{stmt}'")
182                })?;
183                let (scope, field) = split_field(left)?;
184                model.refs.push(RefDecl {
185                    scope,
186                    field,
187                    container: container.trim().to_string(),
188                });
189            }
190            "edge" => {
191                let (scope_head, pair) = rest.split_once(':').ok_or_else(|| {
192                    format!("edge needs 'PATH: ::a -- ::b': '{stmt}'")
193                })?;
194                let (a, b) = pair.split_once("--").ok_or_else(|| {
195                    format!("edge needs two fields 'a -- b': '{stmt}'")
196                })?;
197                let field_a = a.trim().trim_start_matches("::").trim().to_string();
198                let field_b = b.trim().trim_start_matches("::").trim().to_string();
199                model.edges.push(EdgeDecl {
200                    scope: scope_head.trim().to_string(),
201                    field_a,
202                    field_b,
203                });
204            }
205            "def" | "macro" => {
206                defs.push(format!("{stmt};"));
207            }
208            other => {
209                return Err(format!(
210                    "unknown model statement '{other}' (expected node/ref/edge/mount/def/macro)"
211                ));
212            }
213        }
214    }
215    model.defs_text = defs.join("\n");
216    Ok(model)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parses_the_forum_model() {
225        let m = parse_model(
226            r#"
227            # the forum's implied graph
228            mount forum: posts.csv;
229            node ips:     /forum/posts/*::ip;
230            node cookies: /forum/posts/*::cookie;
231            ref /forum/posts/*::ip     --> ips;
232            ref /forum/posts/*::cookie --> cookies;
233            edge /forum/posts/*: ::cookie -- ::ip;
234            "#,
235        )
236        .unwrap();
237        assert_eq!(m.mounts.len(), 1);
238        assert_eq!(m.mounts[0].name, "forum");
239        assert_eq!(m.mounts[0].target, "posts.csv");
240        assert_eq!(m.nodes.len(), 2);
241        assert_eq!(m.nodes[0].name, "ips");
242        assert_eq!(m.nodes[0].query, "/forum/posts/*::ip");
243        assert_eq!(m.refs.len(), 2);
244        assert_eq!(m.refs[1].scope, "/forum/posts/*");
245        assert_eq!(m.refs[1].field, "cookie");
246        assert_eq!(m.refs[1].container, "cookies");
247        assert_eq!(m.edges.len(), 1);
248        assert_eq!(m.edges[0].scope, "/forum/posts/*");
249        assert_eq!(m.edges[0].field_a, "cookie");
250        assert_eq!(m.edges[0].field_b, "ip");
251    }
252
253    #[test]
254    fn collects_defs_and_comments() {
255        let m = parse_model(
256            "def &recent: [::ts > '2026-01-01'];\nnode who: /log/*::user;",
257        )
258        .unwrap();
259        assert_eq!(m.nodes.len(), 1);
260        assert!(m.defs_text.contains("def &recent"));
261    }
262
263    #[test]
264    fn a_semicolon_projection_is_not_a_terminator() {
265        // legacy ;;; in a body survives the statement splitter
266        let m = parse_model("node sizes: /files/*;;;size;").unwrap();
267        assert_eq!(m.nodes.len(), 1);
268        assert_eq!(m.nodes[0].query, "/files/*;;;size");
269    }
270
271    #[test]
272    fn bare_field_ref_is_refused() {
273        assert!(parse_model("ref ::ip --> ips;").is_err());
274    }
275}