1#[derive(Debug, Default, Clone)]
10pub struct Model {
11 pub mounts: Vec<Mount>,
13 pub nodes: Vec<NodeDecl>,
15 pub refs: Vec<RefDecl>,
17 pub rels: Vec<RelDecl>,
19 pub edges: Vec<EdgeDecl>,
21 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 pub name: String,
36 pub role: String,
40 pub trait_name: String,
43 pub query: String,
44}
45
46#[derive(Debug, Clone)]
47pub struct RefDecl {
48 pub scope: String,
50 pub field: String,
52 pub container: String,
53 pub key_field: Option<String>,
57}
58
59#[derive(Debug, Clone)]
63pub struct RelDecl {
64 pub source: String,
65 pub target: String,
67 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
78fn 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
121fn 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
160fn 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
185pub 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
203fn 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
214fn 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
226fn 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
253fn 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 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
286fn 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
302pub 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 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 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 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 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 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 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 assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
547 }
548}