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 pub aliases: Vec<AliasDecl>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum AliasKind {
33 Name,
35 Trait,
38}
39
40#[derive(Debug, Clone)]
42pub enum AliasRule {
43 Exact { alias: String, original: String },
45 Subst {
50 pattern: regex::Regex,
51 replacement: String,
52 ignore_case: bool,
53 global: bool,
54 },
55}
56
57#[derive(Debug, Clone)]
58pub struct AliasDecl {
59 pub kind: AliasKind,
60 pub rule: AliasRule,
61}
62
63impl AliasDecl {
64 pub fn admits(&self, written: &str, canonical: &str) -> bool {
67 match &self.rule {
68 AliasRule::Exact { alias, original } => written == alias && canonical == original,
69 AliasRule::Subst {
70 pattern,
71 replacement,
72 ignore_case,
73 global,
74 } => {
75 if !pattern.is_match(canonical) {
78 return false;
79 }
80 let produced = if *global {
81 pattern.replace_all(canonical, replacement.as_str())
82 } else {
83 pattern.replace(canonical, replacement.as_str())
84 };
85 if *ignore_case {
86 produced.eq_ignore_ascii_case(written)
87 || produced.to_lowercase() == written.to_lowercase()
88 } else {
89 produced == written
90 }
91 }
92 }
93 }
94}
95
96fn parse_alias(body: &str, stmt: &str) -> Result<AliasDecl, String> {
99 let body = body.trim();
100 let (kind, body) = match body.strip_prefix('<') {
101 Some(inner) => {
102 let inner = inner.trim_end();
103 let inner = inner
104 .strip_suffix('>')
105 .ok_or_else(|| format!("a trait alias closes its angle bracket: '{stmt}'"))?;
106 let inner = inner.trim();
108 let inner = match inner.split_once("> <") {
109 Some((a, b)) => format!("{} {}", a.trim(), b.trim()),
110 None => inner.to_string(),
111 };
112 (AliasKind::Trait, inner)
113 }
114 None => (AliasKind::Name, body.to_string()),
115 };
116 let body = body.trim();
117 if let Some(rest) = body.strip_prefix("s/") {
118 let mut parts: Vec<String> = Vec::new();
120 let mut cur = String::new();
121 let mut chars = rest.chars().peekable();
122 while let Some(c) = chars.next() {
123 if c == '\\' && chars.peek() == Some(&'/') {
124 cur.push('/');
125 chars.next();
126 } else if c == '/' {
127 parts.push(std::mem::take(&mut cur));
128 } else {
129 cur.push(c);
130 }
131 }
132 parts.push(cur);
133 if parts.len() != 3 {
134 return Err(format!(
135 "a substitution alias is 's/pattern/replacement/flags': '{stmt}'"
136 ));
137 }
138 let flags = parts[2].trim();
139 for f in flags.chars() {
140 if !matches!(f, 'i' | 'g') {
141 return Err(format!("unknown substitution flag '{f}' (i, g): '{stmt}'"));
142 }
143 }
144 let pattern = regex::Regex::new(&parts[0])
145 .map_err(|e| format!("alias pattern does not parse: {e}: '{stmt}'"))?;
146 return Ok(AliasDecl {
147 kind,
148 rule: AliasRule::Subst {
149 pattern,
150 replacement: parts[1].clone(),
151 ignore_case: flags.contains('i'),
152 global: flags.contains('g'),
153 },
154 });
155 }
156 let (alias, original) = body.split_once(char::is_whitespace).ok_or_else(|| {
157 format!("alias needs 'NEW ORIGINAL' or 's/pattern/replacement/': '{stmt}'")
158 })?;
159 let strip = |t: &str| {
160 t.trim()
161 .trim_start_matches('<')
162 .trim_end_matches('>')
163 .to_string()
164 };
165 Ok(AliasDecl {
166 kind,
167 rule: AliasRule::Exact {
168 alias: strip(alias),
169 original: strip(original),
170 },
171 })
172}
173
174#[derive(Debug, Clone)]
175pub struct Mount {
176 pub name: String,
177 pub target: String,
178}
179
180#[derive(Debug, Clone)]
181pub struct NodeDecl {
182 pub name: String,
184 pub role: String,
188 pub trait_name: String,
191 pub query: String,
192}
193
194#[derive(Debug, Clone)]
195pub struct RefDecl {
196 pub scope: String,
198 pub field: String,
200 pub container: String,
201 pub key_field: Option<String>,
205}
206
207#[derive(Debug, Clone)]
211pub struct RelDecl {
212 pub source: String,
213 pub target: String,
215 pub cond: String,
217}
218
219#[derive(Debug, Clone)]
220pub struct EdgeDecl {
221 pub scope: String,
222 pub field_a: String,
223 pub field_b: String,
224}
225
226fn statements(text: &str) -> Vec<String> {
230 let b: Vec<char> = text.chars().collect();
231 let mut out = Vec::new();
232 let mut start = 0;
233 let mut i = 0;
234 while i < b.len() {
235 match b[i] {
236 q @ ('\'' | '"') => {
237 i += 1;
238 while i < b.len() && b[i] != q {
239 if b[i] == '\\' {
240 i += 1;
241 }
242 i += 1;
243 }
244 i += 1;
245 }
246 ';' => {
247 let mut run = 0;
248 while i < b.len() && b[i] == ';' {
249 run += 1;
250 i += 1;
251 }
252 if run == 1 {
253 out.push(b[start..i - 1].iter().collect());
254 start = i;
255 }
256 }
257 _ => i += 1,
258 }
259 }
260 if start < b.len() {
261 let tail: String = b[start..].iter().collect();
262 if !tail.trim().is_empty() {
263 out.push(tail);
264 }
265 }
266 out
267}
268
269fn strip_comments(text: &str) -> String {
274 let mut out = String::new();
275 for line in text.lines() {
276 let b: Vec<char> = line.chars().collect();
277 let mut i = 0;
278 while i < b.len() {
279 match b[i] {
280 q @ ('\'' | '"') => {
281 out.push(q);
282 i += 1;
283 while i < b.len() && b[i] != q {
284 if b[i] == '\\' && i + 1 < b.len() {
285 out.push(b[i]);
286 i += 1;
287 }
288 out.push(b[i]);
289 i += 1;
290 }
291 if i < b.len() {
292 out.push(b[i]);
293 i += 1;
294 }
295 }
296 '#' if i == 0 || b[i - 1].is_whitespace() => break,
297 c => {
298 out.push(c);
299 i += 1;
300 }
301 }
302 }
303 out.push('\n');
304 }
305 out
306}
307
308fn split_field(s: &str) -> Result<(String, String), String> {
311 let s = s.trim();
312 match s.rfind("::") {
313 Some(idx) => {
314 let path = s[..idx].trim().to_string();
315 let field = s[idx + 2..].trim().to_string();
316 if path.is_empty() {
317 return Err(format!(
318 "ref needs a path scope before '::{field}' \
319 (bare fields are refused): '{s}'"
320 ));
321 }
322 if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
323 return Err(format!("ref/edge field is not a bare property: '{s}'"));
324 }
325 Ok((path, field))
326 }
327 None => Err(format!(
328 "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
329 )),
330 }
331}
332
333pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
338 let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
339 s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
340 });
341 let p = std::path::Path::new(target);
342 if has_scheme || p.is_absolute() {
343 return target.to_string();
344 }
345 match base_dir {
346 Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
347 _ => target.to_string(),
348 }
349}
350
351fn split_trait(head: &str) -> (String, Option<String>) {
353 match head.split_once('<') {
354 Some((path, rest)) => (
355 path.trim().to_string(),
356 rest.strip_suffix('>').map(|t| t.trim().to_string()),
357 ),
358 None => (head.to_string(), None),
359 }
360}
361
362fn split_container_role(path: &str) -> Option<(String, String)> {
365 let p = path.trim().trim_start_matches('/');
366 let (container, role) = p.split_once('/')?;
367 let (container, role) = (container.trim(), role.trim());
368 if container.is_empty() || role.is_empty() || role.contains('/') {
369 return None;
370 }
371 Some((container.to_string(), role.to_string()))
372}
373
374fn split_trailing_group(s: &str) -> Option<(String, String)> {
378 let s = s.trim();
379 if !s.ends_with(']') {
380 return None;
381 }
382 let b: Vec<char> = s.chars().collect();
383 let mut depth = 0usize;
384 for i in (0..b.len()).rev() {
385 match b[i] {
386 ']' => depth += 1,
387 '[' => {
388 depth -= 1;
389 if depth == 0 {
390 let path = b[..i].iter().collect::<String>().trim().to_string();
391 let group = b[i..].iter().collect::<String>();
392 return (!path.is_empty()).then_some((path, group));
393 }
394 }
395 _ => {}
396 }
397 }
398 None
399}
400
401fn parse_ref_target(s: &str) -> Result<(String, Option<String>), String> {
403 let (path, key_field) = match split_trailing_group(s) {
404 Some((path, group)) => {
405 let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
406 let (lhs, rhs) = inner
407 .split_once('=')
408 .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
409 if rhs.trim() != "$" {
410 return Err(format!(
411 "ref resolves by value, so its condition is 'PROP = $': '{s}'"
412 ));
413 }
414 let key = lhs.trim().trim_start_matches("::").trim().to_string();
415 (path, (!key.is_empty()).then_some(key))
416 }
417 None => (s.trim().to_string(), None),
418 };
419 let container = path
422 .trim_start_matches('/')
423 .split('/')
424 .next()
425 .unwrap_or("")
426 .trim()
427 .to_string();
428 if container.is_empty() {
429 return Err(format!("ref needs a target container: '{s}'"));
430 }
431 Ok((container, key_field))
432}
433
434fn split_arrow(s: &str) -> Option<(String, String)> {
436 let b: Vec<char> = s.chars().collect();
437 let mut i = 0;
438 while i + 1 < b.len() {
439 if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
440 return Some((
441 b[..i].iter().collect::<String>().trim().to_string(),
442 b[i + 2..].iter().collect::<String>().trim().to_string(),
443 ));
444 }
445 i += 1;
446 }
447 None
448}
449
450pub fn parse_model(text: &str) -> Result<Model, String> {
452 let mut model = Model::default();
453 let mut defs = Vec::new();
454 for raw in statements(&strip_comments(text)) {
455 let stmt = raw.trim();
456 if stmt.is_empty() {
457 continue;
458 }
459 let (head, rest) = match stmt.split_once(char::is_whitespace) {
460 Some((h, r)) => (h, r.trim()),
461 None => (stmt, ""),
462 };
463 match head {
464 "node" => {
465 let (head, query) = rest
466 .split_once(':')
467 .ok_or_else(|| format!("node needs '/container/role: query': '{stmt}'"))?;
468 let (path, trait_name) = split_trait(head.trim());
469 let (name, role) = split_container_role(&path).ok_or_else(|| {
470 format!(
471 "node needs '/container/role', so a hop can be \
472 labelled by the role it lands on: '{stmt}'"
473 )
474 })?;
475 let trait_name = trait_name.unwrap_or_else(|| role.clone());
476 model.nodes.push(NodeDecl {
477 name,
478 role,
479 trait_name,
480 query: query.trim().to_string(),
481 });
482 }
483 "mount" => {
484 let (name, target) = rest
485 .split_once(':')
486 .ok_or_else(|| format!("mount needs 'NAME: target': '{stmt}'"))?;
487 model.mounts.push(Mount {
488 name: name.trim().to_string(),
489 target: target.trim().to_string(),
490 });
491 }
492 "ref" => {
493 let (left, container) = rest
494 .split_once("-->")
495 .ok_or_else(|| format!("ref needs 'PATH::field --> container': '{stmt}'"))?;
496 let (scope, field) = split_field(left)?;
497 let (container, key_field) = parse_ref_target(container)?;
498 model.refs.push(RefDecl {
499 scope,
500 field,
501 container,
502 key_field,
503 });
504 }
505 "rel" => {
506 let (source, target) = split_arrow(rest)
507 .ok_or_else(|| format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'"))?;
508 let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
509 format!(
510 "rel needs a condition — two node sets joined by \
511 nothing are a cross product: '{stmt}'"
512 )
513 })?;
514 if source.is_empty() {
515 return Err(format!("rel needs a source path: '{stmt}'"));
516 }
517 model.rels.push(RelDecl {
518 source,
519 target,
520 cond,
521 });
522 }
523 "edge" => {
524 let (scope_head, pair) = rest
525 .split_once(':')
526 .ok_or_else(|| format!("edge needs 'PATH: ::a -- ::b': '{stmt}'"))?;
527 let (a, b) = pair
528 .split_once("--")
529 .ok_or_else(|| format!("edge needs two fields 'a -- b': '{stmt}'"))?;
530 let field_a = a.trim().trim_start_matches("::").trim().to_string();
531 let field_b = b.trim().trim_start_matches("::").trim().to_string();
532 model.edges.push(EdgeDecl {
533 scope: scope_head.trim().to_string(),
534 field_a,
535 field_b,
536 });
537 }
538 "def" | "macro" => {
539 defs.push(format!("{stmt};"));
540 }
541 "alias" => {
542 model.aliases.push(parse_alias(rest, stmt)?);
543 }
544 other => {
545 return Err(format!(
546 "unknown model statement '{other}' \
547 (expected node/ref/rel/edge/mount/alias/def/macro)"
548 ));
549 }
550 }
551 }
552 model.defs_text = defs.join("\n");
553 for edge in &model.edges {
558 for field in [&edge.field_a, &edge.field_b] {
559 if !model.refs.iter().any(|r| &r.field == field) {
560 return Err(format!(
561 "edge field '::{field}' has no ref declaration — \
562 an edge connects the containers its fields \
563 resolve into, so declare \
564 'ref {}::{field} --> ...;' first",
565 edge.scope
566 ));
567 }
568 }
569 }
570 Ok(model)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn parses_the_forum_model() {
579 let m = parse_model(
580 r#"
581 # the forum's implied graph
582 mount forum: posts.csv;
583 node /ips/ip: /forum/posts/*::ip | unique;
584 node /cookies/cookie: /forum/posts/*::cookie | unique;
585 ref /forum/posts/*::ip --> ips;
586 ref /forum/posts/*::cookie --> cookies;
587 edge /forum/posts/*: ::cookie -- ::ip;
588 "#,
589 )
590 .unwrap();
591 assert_eq!(m.mounts.len(), 1);
592 assert_eq!(m.mounts[0].name, "forum");
593 assert_eq!(m.mounts[0].target, "posts.csv");
594 assert_eq!(m.nodes.len(), 2);
595 assert_eq!(m.nodes[0].name, "ips");
596 assert_eq!(m.nodes[0].role, "ip");
597 assert_eq!(m.nodes[0].trait_name, "ip");
598 assert_eq!(m.nodes[0].query, "/forum/posts/*::ip | unique");
599 assert_eq!(m.refs.len(), 2);
600 assert_eq!(m.refs[1].scope, "/forum/posts/*");
601 assert_eq!(m.refs[1].field, "cookie");
602 assert_eq!(m.refs[1].container, "cookies");
603 assert_eq!(m.edges.len(), 1);
604 assert_eq!(m.edges[0].scope, "/forum/posts/*");
605 assert_eq!(m.edges[0].field_a, "cookie");
606 assert_eq!(m.edges[0].field_b, "ip");
607 }
608
609 #[test]
610 fn trailing_comments_are_stripped_but_glued_hashes_survive() {
611 let m = parse_model(
612 "node /ips/ip: /row::ip | unique; # distinct ips\n\
613 node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
614 ref /row::ip --> ips; # resolves there\n",
615 )
616 .unwrap();
617 assert_eq!(m.nodes.len(), 2);
618 assert_eq!(m.nodes[0].query, "/row::ip | unique");
619 assert!(m.nodes[1].query.contains("s/#("));
621 assert_eq!(m.refs.len(), 1);
622 }
623
624 #[test]
625 fn collects_defs_and_comments() {
626 let m = parse_model("def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;")
627 .unwrap();
628 assert_eq!(m.nodes.len(), 1);
629 assert!(m.defs_text.contains("def &recent"));
630 }
631
632 #[test]
633 fn a_semicolon_projection_is_not_a_terminator() {
634 let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
636 assert_eq!(m.nodes.len(), 1);
637 assert_eq!(m.nodes[0].query, "/files/*::::size");
638 }
639
640 #[test]
641 fn a_node_head_needs_a_role() {
642 assert!(parse_model("node ips: /row::ip;").is_err());
645 }
646
647 #[test]
648 fn declares_an_explicit_trait() {
649 let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
650 assert_eq!(m.nodes[0].role, "ip");
651 assert_eq!(m.nodes[0].trait_name, "addr");
652 }
653
654 #[test]
655 fn bare_field_ref_is_refused() {
656 assert!(parse_model("ref ::ip --> ips;").is_err());
657 }
658
659 #[test]
660 fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
661 let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
662 assert_eq!(m.refs[0].container, "cookies");
663 assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
664 let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
667 assert_eq!(m.refs[0].container, "cookies");
668 assert_eq!(m.refs[0].key_field, None);
669 }
670
671 #[test]
672 fn parses_a_relation() {
673 let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
674 assert_eq!(m.rels.len(), 1);
675 assert_eq!(m.rels[0].source, "/errors/error");
676 assert_eq!(m.rels[0].target, "/deploys/deploy");
677 assert_eq!(m.rels[0].cond, "[::at > _::at]");
678 }
679
680 #[test]
681 fn an_edge_over_unrefd_fields_is_refused() {
682 let err = parse_model(
685 "node /ips/ip: /row::ip | unique;\n\
686 edge /row: ::cookie -- ::ip;",
687 )
688 .unwrap_err();
689 assert!(err.contains("no ref declaration"), "{err}");
690 }
691
692 #[test]
693 fn a_relation_without_a_condition_is_refused() {
694 assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
696 }
697}