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 path: String,
205 pub role: Option<String>,
210 pub key_field: Option<String>,
214}
215
216#[derive(Debug, Clone)]
220pub struct RelDecl {
221 pub source: String,
222 pub target: String,
224 pub cond: String,
226}
227
228#[derive(Debug, Clone)]
229pub struct EdgeDecl {
230 pub scope: String,
231 pub field_a: String,
232 pub field_b: String,
233}
234
235fn statements(text: &str) -> Vec<String> {
242 let b: Vec<char> = text.chars().collect();
243 let mut out = Vec::new();
244 let mut start = 0;
245 let mut i = 0;
246 let mut depth = 0usize;
247 while i < b.len() {
248 match b[i] {
249 q @ ('\'' | '"') => {
250 i += 1;
251 while i < b.len() && b[i] != q {
252 if b[i] == '\\' {
253 i += 1;
254 }
255 i += 1;
256 }
257 i += 1;
258 }
259 '(' | '[' | '{' => {
260 depth += 1;
261 i += 1;
262 }
263 ')' | ']' | '}' => {
264 depth = depth.saturating_sub(1);
265 i += 1;
266 }
267 ';' => {
268 let mut run = 0;
269 while i < b.len() && b[i] == ';' {
270 run += 1;
271 i += 1;
272 }
273 if run == 1 && depth == 0 {
274 out.push(b[start..i - 1].iter().collect());
275 start = i;
276 }
277 }
278 _ => i += 1,
279 }
280 }
281 if start < b.len() {
282 let tail: String = b[start..].iter().collect();
283 if !tail.trim().is_empty() {
284 out.push(tail);
285 }
286 }
287 out
288}
289
290fn strip_comments(text: &str) -> String {
295 let mut out = String::new();
296 for line in text.lines() {
297 let b: Vec<char> = line.chars().collect();
298 let mut i = 0;
299 while i < b.len() {
300 match b[i] {
301 q @ ('\'' | '"') => {
302 out.push(q);
303 i += 1;
304 while i < b.len() && b[i] != q {
305 if b[i] == '\\' && i + 1 < b.len() {
306 out.push(b[i]);
307 i += 1;
308 }
309 out.push(b[i]);
310 i += 1;
311 }
312 if i < b.len() {
313 out.push(b[i]);
314 i += 1;
315 }
316 }
317 '#' if i == 0 || b[i - 1].is_whitespace() => break,
318 c => {
319 out.push(c);
320 i += 1;
321 }
322 }
323 }
324 out.push('\n');
325 }
326 out
327}
328
329fn split_field(s: &str) -> Result<(String, String), String> {
332 let s = s.trim();
333 match s.rfind("::") {
334 Some(idx) => {
335 let path = s[..idx].trim().to_string();
336 let field = s[idx + 2..].trim().to_string();
337 if path.is_empty() {
338 return Err(format!(
339 "ref needs a path scope before '::{field}' \
340 (bare fields are refused): '{s}'"
341 ));
342 }
343 if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
344 return Err(format!("ref/edge field is not a bare property: '{s}'"));
345 }
346 Ok((path, field))
347 }
348 None => Err(format!(
349 "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
350 )),
351 }
352}
353
354pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
359 let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
360 s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
361 });
362 let p = std::path::Path::new(target);
363 if has_scheme || p.is_absolute() {
364 return target.to_string();
365 }
366 match base_dir {
367 Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
368 _ => target.to_string(),
369 }
370}
371
372fn split_trait(head: &str) -> (String, Option<String>) {
374 match head.split_once('<') {
375 Some((path, rest)) => (
376 path.trim().to_string(),
377 rest.strip_suffix('>').map(|t| t.trim().to_string()),
378 ),
379 None => (head.to_string(), None),
380 }
381}
382
383fn split_container_role(path: &str) -> Option<(String, String)> {
386 let p = path.trim().trim_start_matches('/');
387 let (container, role) = p.split_once('/')?;
388 let (container, role) = (container.trim(), role.trim());
389 if container.is_empty() || role.is_empty() || role.contains('/') {
390 return None;
391 }
392 Some((container.to_string(), role.to_string()))
393}
394
395fn split_trailing_group(s: &str) -> Option<(String, String)> {
399 let s = s.trim();
400 if !s.ends_with(']') {
401 return None;
402 }
403 let b: Vec<char> = s.chars().collect();
404 let mut depth = 0usize;
405 for i in (0..b.len()).rev() {
406 match b[i] {
407 ']' => depth += 1,
408 '[' => {
409 depth -= 1;
410 if depth == 0 {
411 let path = b[..i].iter().collect::<String>().trim().to_string();
412 let group = b[i..].iter().collect::<String>();
413 return (!path.is_empty()).then_some((path, group));
414 }
415 }
416 _ => {}
417 }
418 }
419 None
420}
421
422fn parse_ref_target(s: &str) -> Result<(String, String, Option<String>, Option<String>), String> {
425 let (path, key_field) = match split_trailing_group(s) {
426 Some((path, group)) => {
427 let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
428 let (lhs, rhs) = inner
429 .split_once('=')
430 .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
431 if rhs.trim() != "$" {
432 return Err(format!(
433 "ref resolves by value, so its condition is 'PROP = $': '{s}'"
434 ));
435 }
436 let key = lhs.trim().trim_start_matches("::").trim().to_string();
437 (path, (!key.is_empty()).then_some(key))
438 }
439 None => (s.trim().to_string(), None),
440 };
441 let mut segs = path.trim_start_matches('/').split('/').map(str::trim);
444 let container = segs.next().unwrap_or("").to_string();
445 let role = segs.next().filter(|r| !r.is_empty()).map(str::to_string);
446 if container.is_empty() {
447 return Err(format!("ref needs a target container: '{s}'"));
448 }
449 Ok((container, path, role, key_field))
450}
451
452fn split_arrow(s: &str) -> Option<(String, String)> {
454 let b: Vec<char> = s.chars().collect();
455 let mut i = 0;
456 while i + 1 < b.len() {
457 if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
458 return Some((
459 b[..i].iter().collect::<String>().trim().to_string(),
460 b[i + 2..].iter().collect::<String>().trim().to_string(),
461 ));
462 }
463 i += 1;
464 }
465 None
466}
467
468pub fn parse_model(text: &str) -> Result<Model, String> {
472 parse_model_in(text, None, &mut Vec::new(), &mut Vec::new())
473}
474
475pub fn parse_model_file(path: &std::path::Path) -> Result<Model, String> {
482 let text =
483 std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
484 let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
485 let mut visited = vec![path.canonicalize().unwrap_or_else(|_| path.to_path_buf())];
486 parse_model_in(&text, path.parent(), &mut visited, &mut Vec::new())
487}
488
489fn parse_model_in(
490 text: &str,
491 base_dir: Option<&std::path::Path>,
492 visited: &mut Vec<std::path::PathBuf>,
493 done: &mut Vec<std::path::PathBuf>,
494) -> Result<Model, String> {
495 let mut model = Model::default();
496 let mut defs = Vec::new();
497 for raw in statements(&strip_comments(text)) {
498 let stmt = raw.trim();
499 if stmt.is_empty() {
500 continue;
501 }
502 let (head, rest) = match stmt.split_once(char::is_whitespace) {
503 Some((h, r)) => (h, r.trim()),
504 None => (stmt, ""),
505 };
506 match head {
507 "node" => {
508 let (head, query) = rest
509 .split_once(':')
510 .ok_or_else(|| format!("node needs '/container/role: query': '{stmt}'"))?;
511 let (path, trait_name) = split_trait(head.trim());
512 let (name, role) = split_container_role(&path).ok_or_else(|| {
513 format!(
514 "node needs '/container/role', so a hop can be \
515 labelled by the role it lands on: '{stmt}'"
516 )
517 })?;
518 let trait_name = trait_name.unwrap_or_else(|| role.clone());
519 model.nodes.push(NodeDecl {
520 name,
521 role,
522 trait_name,
523 query: query.trim().to_string(),
524 });
525 }
526 "mount" => {
527 let (name, target) = rest
528 .split_once(':')
529 .ok_or_else(|| format!("mount needs 'NAME: target': '{stmt}'"))?;
530 let name = name.trim().to_string();
531 if model.mounts.iter().any(|m| m.name == name) {
532 return Err(format!("mount '{name}' is already declared: '{stmt}'"));
533 }
534 model.mounts.push(Mount {
535 name,
536 target: target.trim().to_string(),
537 });
538 }
539 "use" => {
540 let file = rest.trim().trim_matches(|c| c == '"' || c == '\'');
541 if file.is_empty() {
542 return Err(format!("use needs a model file: '{stmt}'"));
543 }
544 let Some(dir) = base_dir else {
545 return Err(format!(
546 "use needs a location to resolve '{file}' against: pass the model as a file (--model FILE)"
547 ));
548 };
549 let path = {
550 let p = std::path::Path::new(file);
551 if p.is_absolute() {
552 p.to_path_buf()
553 } else {
554 dir.join(p)
555 }
556 };
557 let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
558 if visited.contains(&canon) {
563 return Err(format!("use {file}: the model includes itself"));
564 }
565 if done.contains(&canon) {
566 continue;
567 }
568 visited.push(canon.clone());
569 let text = std::fs::read_to_string(&path)
570 .map_err(|e| format!("use {file}: reading {}: {e}", path.display()))?;
571 let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
572 let sub = parse_model_in(&text, path.parent(), visited, done)
573 .map_err(|e| format!("use {file}: {e}"))?;
574 visited.pop();
575 done.push(canon);
576 for m in sub.mounts {
577 if model.mounts.iter().any(|x| x.name == m.name) {
578 return Err(format!(
579 "use {file}: mount '{}' is already declared",
580 m.name
581 ));
582 }
583 model.mounts.push(Mount {
584 name: m.name,
585 target: resolve_mount_target(&m.target, path.parent()),
586 });
587 }
588 model.nodes.extend(sub.nodes);
589 model.refs.extend(sub.refs);
590 model.rels.extend(sub.rels);
591 model.edges.extend(sub.edges);
592 model.aliases.extend(sub.aliases);
593 if !sub.defs_text.trim().is_empty() {
594 defs.push(sub.defs_text);
595 }
596 }
597 "ref" => {
598 let (left, container) = rest
599 .split_once("-->")
600 .ok_or_else(|| format!("ref needs 'PATH::field --> container': '{stmt}'"))?;
601 let (scope, field) = split_field(left)?;
602 let (container, path, role, key_field) = parse_ref_target(container)?;
603 model.refs.push(RefDecl {
604 scope,
605 field,
606 container,
607 path,
608 role,
609 key_field,
610 });
611 }
612 "rel" => {
613 let (source, target) = split_arrow(rest)
614 .ok_or_else(|| format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'"))?;
615 let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
616 format!(
617 "rel needs a condition — two node sets joined by \
618 nothing are a cross product: '{stmt}'"
619 )
620 })?;
621 if source.is_empty() {
622 return Err(format!("rel needs a source path: '{stmt}'"));
623 }
624 model.rels.push(RelDecl {
625 source,
626 target,
627 cond,
628 });
629 }
630 "edge" => {
631 let (scope_head, pair) = rest
632 .split_once(':')
633 .ok_or_else(|| format!("edge needs 'PATH: ::a -- ::b': '{stmt}'"))?;
634 let (a, b) = pair
635 .split_once("--")
636 .ok_or_else(|| format!("edge needs two fields 'a -- b': '{stmt}'"))?;
637 let field_a = a.trim().trim_start_matches("::").trim().to_string();
638 let field_b = b.trim().trim_start_matches("::").trim().to_string();
639 model.edges.push(EdgeDecl {
640 scope: scope_head.trim().to_string(),
641 field_a,
642 field_b,
643 });
644 }
645 "def" | "macro" => {
646 defs.push(format!("{stmt};"));
647 }
648 "alias" => {
649 model.aliases.push(parse_alias(rest, stmt)?);
650 }
651 other => {
652 return Err(format!(
653 "unknown model statement '{other}' \
654 (expected node/ref/rel/edge/mount/alias/def/macro)"
655 ));
656 }
657 }
658 }
659 model.defs_text = defs.join("\n");
660 for edge in &model.edges {
665 for field in [&edge.field_a, &edge.field_b] {
666 if !model.refs.iter().any(|r| &r.field == field) {
667 return Err(format!(
668 "edge field '::{field}' has no ref declaration — \
669 an edge connects the containers its fields \
670 resolve into, so declare \
671 'ref {}::{field} --> ...;' first",
672 edge.scope
673 ));
674 }
675 }
676 }
677 Ok(model)
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn parses_the_forum_model() {
686 let m = parse_model(
687 r#"
688 # the forum's implied graph
689 mount forum: posts.csv;
690 node /ips/ip: /forum/posts/*::ip | unique;
691 node /cookies/cookie: /forum/posts/*::cookie | unique;
692 ref /forum/posts/*::ip --> ips;
693 ref /forum/posts/*::cookie --> cookies;
694 edge /forum/posts/*: ::cookie -- ::ip;
695 "#,
696 )
697 .unwrap();
698 assert_eq!(m.mounts.len(), 1);
699 assert_eq!(m.mounts[0].name, "forum");
700 assert_eq!(m.mounts[0].target, "posts.csv");
701 assert_eq!(m.nodes.len(), 2);
702 assert_eq!(m.nodes[0].name, "ips");
703 assert_eq!(m.nodes[0].role, "ip");
704 assert_eq!(m.nodes[0].trait_name, "ip");
705 assert_eq!(m.nodes[0].query, "/forum/posts/*::ip | unique");
706 assert_eq!(m.refs.len(), 2);
707 assert_eq!(m.refs[1].scope, "/forum/posts/*");
708 assert_eq!(m.refs[1].field, "cookie");
709 assert_eq!(m.refs[1].container, "cookies");
710 assert_eq!(m.edges.len(), 1);
711 assert_eq!(m.edges[0].scope, "/forum/posts/*");
712 assert_eq!(m.edges[0].field_a, "cookie");
713 assert_eq!(m.edges[0].field_b, "ip");
714 }
715
716 #[test]
717 fn trailing_comments_are_stripped_but_glued_hashes_survive() {
718 let m = parse_model(
719 "node /ips/ip: /row::ip | unique; # distinct ips\n\
720 node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
721 ref /row::ip --> ips; # resolves there\n",
722 )
723 .unwrap();
724 assert_eq!(m.nodes.len(), 2);
725 assert_eq!(m.nodes[0].query, "/row::ip | unique");
726 assert!(m.nodes[1].query.contains("s/#("));
728 assert_eq!(m.refs.len(), 1);
729 }
730
731 #[test]
732 fn collects_defs_and_comments() {
733 let m = parse_model("def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;")
734 .unwrap();
735 assert_eq!(m.nodes.len(), 1);
736 assert!(m.defs_text.contains("def &recent"));
737 }
738
739 #[test]
740 fn a_semicolon_projection_is_not_a_terminator() {
741 let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
743 assert_eq!(m.nodes.len(), 1);
744 assert_eq!(m.nodes[0].query, "/files/*::::size");
745 }
746
747 #[test]
748 fn a_node_head_needs_a_role() {
749 assert!(parse_model("node ips: /row::ip;").is_err());
752 }
753
754 #[test]
755 fn declares_an_explicit_trait() {
756 let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
757 assert_eq!(m.nodes[0].role, "ip");
758 assert_eq!(m.nodes[0].trait_name, "addr");
759 }
760
761 #[test]
762 fn bare_field_ref_is_refused() {
763 assert!(parse_model("ref ::ip --> ips;").is_err());
764 }
765
766 #[test]
767 fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
768 let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
769 assert_eq!(m.refs[0].container, "cookies");
770 assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
771 let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
774 assert_eq!(m.refs[0].container, "cookies");
775 assert_eq!(m.refs[0].key_field, None);
776 }
777
778 #[test]
779 fn parses_a_relation() {
780 let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
781 assert_eq!(m.rels.len(), 1);
782 assert_eq!(m.rels[0].source, "/errors/error");
783 assert_eq!(m.rels[0].target, "/deploys/deploy");
784 assert_eq!(m.rels[0].cond, "[::at > _::at]");
785 }
786
787 #[test]
788 fn an_edge_over_unrefd_fields_is_refused() {
789 let err = parse_model(
792 "node /ips/ip: /row::ip | unique;\n\
793 edge /row: ::cookie -- ::ip;",
794 )
795 .unwrap_err();
796 assert!(err.contains("no ref declaration"), "{err}");
797 }
798
799 #[test]
800 fn a_relation_without_a_condition_is_refused() {
801 assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
803 }
804}