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 pub traits: Vec<TraitDecl>,
33 pub indexes: Vec<IndexDecl>,
38 pub trees: Vec<TreeDecl>,
42 pub units: Vec<UnitsDecl>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct UnitsDecl {
52 pub library: String,
54 pub path: std::path::PathBuf,
56}
57
58#[derive(Debug, Clone)]
60pub struct TreeDecl {
61 pub identity: String,
62 pub path: String,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct IndexDecl {
67 pub scope: String,
69 pub property: String,
70}
71
72#[derive(Debug, Clone, PartialEq)]
73pub struct TraitDecl {
74 pub name: String,
75 pub step: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum AliasKind {
84 Name,
86 Trait,
89}
90
91#[derive(Debug, Clone)]
93pub enum AliasRule {
94 Exact { alias: String, original: String },
96 Subst {
101 pattern: regex::Regex,
102 replacement: String,
103 ignore_case: bool,
104 global: bool,
105 },
106}
107
108#[derive(Debug, Clone)]
109pub struct AliasDecl {
110 pub kind: AliasKind,
111 pub rule: AliasRule,
112}
113
114impl AliasDecl {
115 pub fn admits(&self, written: &str, canonical: &str) -> bool {
118 match &self.rule {
119 AliasRule::Exact { alias, original } => written == alias && canonical == original,
120 AliasRule::Subst {
121 pattern,
122 replacement,
123 ignore_case,
124 global,
125 } => {
126 if !pattern.is_match(canonical) {
129 return false;
130 }
131 let produced = if *global {
132 pattern.replace_all(canonical, replacement.as_str())
133 } else {
134 pattern.replace(canonical, replacement.as_str())
135 };
136 if *ignore_case {
137 produced.eq_ignore_ascii_case(written)
138 || produced.to_lowercase() == written.to_lowercase()
139 } else {
140 produced == written
141 }
142 }
143 }
144 }
145}
146
147fn parse_trait(body: &str, stmt: &str) -> Result<TraitDecl, String> {
153 let body = body.trim();
154 let Some(rest) = body.strip_prefix('<') else {
155 return Err(format!(
156 "trait needs '<name>: STEP' (the name in angle brackets): '{stmt}'"
157 ));
158 };
159 let Some((name, step)) = rest.split_once('>') else {
160 return Err(format!("trait <name> is not closed: '{stmt}'"));
161 };
162 let name = name.trim();
163 if let Some(bare) = name.strip_prefix('.') {
164 return Err(format!(
165 "a declared trait has a plain name — write 'trait <{bare}>', not '<.{bare}>': '{stmt}'"
166 ));
167 }
168 let plain = name
169 .chars()
170 .next()
171 .is_some_and(|c| c.is_alphabetic() || c == '_')
172 && name
173 .chars()
174 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-'));
175 if !plain {
176 return Err(format!(
177 "trait name '<{name}>' is not a plain name: '{stmt}'"
178 ));
179 }
180 let Some(step) = step.trim().strip_prefix(':') else {
181 return Err(format!(
182 "trait <{name}> needs ': STEP' after the name: '{stmt}'"
183 ));
184 };
185 let step = step.trim();
186 if step.is_empty() {
187 return Err(format!("trait <{name}> needs a step body: '{stmt}'"));
188 }
189 quarb::validate_step_body(step).map_err(|e| format!("trait <{name}>: {e}"))?;
190 Ok(TraitDecl {
191 name: name.to_string(),
192 step: step.to_string(),
193 })
194}
195
196fn parse_alias(body: &str, stmt: &str) -> Result<AliasDecl, String> {
197 let body = body.trim();
198 let (kind, body) = match body.strip_prefix('<') {
199 Some(inner) => {
200 let inner = inner.trim_end();
201 let inner = inner
202 .strip_suffix('>')
203 .ok_or_else(|| format!("a trait alias closes its angle bracket: '{stmt}'"))?;
204 let inner = inner.trim();
206 let inner = match inner.split_once("> <") {
207 Some((a, b)) => format!("{} {}", a.trim(), b.trim()),
208 None => inner.to_string(),
209 };
210 (AliasKind::Trait, inner)
211 }
212 None => (AliasKind::Name, body.to_string()),
213 };
214 let body = body.trim();
215 if let Some(rest) = body.strip_prefix("s/") {
216 let mut parts: Vec<String> = Vec::new();
218 let mut cur = String::new();
219 let mut chars = rest.chars().peekable();
220 while let Some(c) = chars.next() {
221 if c == '\\' && chars.peek() == Some(&'/') {
222 cur.push('/');
223 chars.next();
224 } else if c == '/' {
225 parts.push(std::mem::take(&mut cur));
226 } else {
227 cur.push(c);
228 }
229 }
230 parts.push(cur);
231 if parts.len() != 3 {
232 return Err(format!(
233 "a substitution alias is 's/pattern/replacement/flags': '{stmt}'"
234 ));
235 }
236 let flags = parts[2].trim();
237 for f in flags.chars() {
238 if !matches!(f, 'i' | 'g') {
239 return Err(format!("unknown substitution flag '{f}' (i, g): '{stmt}'"));
240 }
241 }
242 let pattern = regex::Regex::new(&parts[0])
243 .map_err(|e| format!("alias pattern does not parse: {e}: '{stmt}'"))?;
244 return Ok(AliasDecl {
245 kind,
246 rule: AliasRule::Subst {
247 pattern,
248 replacement: parts[1].clone(),
249 ignore_case: flags.contains('i'),
250 global: flags.contains('g'),
251 },
252 });
253 }
254 let (alias, original) = body.split_once(char::is_whitespace).ok_or_else(|| {
255 format!("alias needs 'NEW ORIGINAL' or 's/pattern/replacement/': '{stmt}'")
256 })?;
257 let strip = |t: &str| {
258 t.trim()
259 .trim_start_matches('<')
260 .trim_end_matches('>')
261 .to_string()
262 };
263 Ok(AliasDecl {
264 kind,
265 rule: AliasRule::Exact {
266 alias: strip(alias),
267 original: strip(original),
268 },
269 })
270}
271
272#[derive(Debug, Clone)]
273pub struct Mount {
274 pub name: String,
275 pub target: String,
276}
277
278#[derive(Debug, Clone)]
279pub struct NodeDecl {
280 pub name: String,
282 pub role: String,
286 pub trait_name: String,
289 pub query: String,
290}
291
292#[derive(Debug, Clone)]
293pub struct RefDecl {
294 pub scope: String,
296 pub field: String,
298 pub container: String,
299 pub path: String,
303 pub role: Option<String>,
308 pub key_field: Option<String>,
312}
313
314#[derive(Debug, Clone)]
318pub struct RelDecl {
319 pub source: String,
320 pub target: String,
322 pub cond: String,
324}
325
326#[derive(Debug, Clone)]
327pub struct EdgeDecl {
328 pub scope: String,
329 pub field_a: String,
330 pub field_b: String,
331}
332
333fn statements(text: &str) -> Vec<String> {
340 let b: Vec<char> = text.chars().collect();
341 let mut out = Vec::new();
342 let mut start = 0;
343 let mut i = 0;
344 let mut depth = 0usize;
345 while i < b.len() {
346 match b[i] {
347 q @ ('\'' | '"') => {
348 i += 1;
349 while i < b.len() && b[i] != q {
350 if b[i] == '\\' {
351 i += 1;
352 }
353 i += 1;
354 }
355 i += 1;
356 }
357 '(' | '[' | '{' => {
358 depth += 1;
359 i += 1;
360 }
361 ')' | ']' | '}' => {
362 depth = depth.saturating_sub(1);
363 i += 1;
364 }
365 ';' => {
366 let mut run = 0;
367 while i < b.len() && b[i] == ';' {
368 run += 1;
369 i += 1;
370 }
371 if run == 1 && depth == 0 {
372 out.push(b[start..i - 1].iter().collect());
373 start = i;
374 }
375 }
376 _ => i += 1,
377 }
378 }
379 if start < b.len() {
380 let tail: String = b[start..].iter().collect();
381 if !tail.trim().is_empty() {
382 out.push(tail);
383 }
384 }
385 out
386}
387
388fn strip_comments(text: &str) -> String {
393 let mut out = String::new();
394 for line in text.lines() {
395 let b: Vec<char> = line.chars().collect();
396 let mut i = 0;
397 while i < b.len() {
398 match b[i] {
399 q @ ('\'' | '"') => {
400 out.push(q);
401 i += 1;
402 while i < b.len() && b[i] != q {
403 if b[i] == '\\' && i + 1 < b.len() {
404 out.push(b[i]);
405 i += 1;
406 }
407 out.push(b[i]);
408 i += 1;
409 }
410 if i < b.len() {
411 out.push(b[i]);
412 i += 1;
413 }
414 }
415 '#' if i == 0 || b[i - 1].is_whitespace() => break,
416 c => {
417 out.push(c);
418 i += 1;
419 }
420 }
421 }
422 out.push('\n');
423 }
424 out
425}
426
427fn split_field(s: &str) -> Result<(String, String), String> {
430 let s = s.trim();
431 match s.rfind("::") {
432 Some(idx) => {
433 let path = s[..idx].trim().to_string();
434 let field = s[idx + 2..].trim().to_string();
435 if path.is_empty() {
436 return Err(format!(
437 "ref needs a path scope before '::{field}' \
438 (bare fields are refused): '{s}'"
439 ));
440 }
441 if field.is_empty() || field.contains(|c: char| c.is_whitespace()) {
442 return Err(format!("ref/edge field is not a bare property: '{s}'"));
443 }
444 Ok((path, field))
445 }
446 None => Err(format!(
447 "ref/edge needs a scoped '::field' (bare fields are refused): '{s}'"
448 )),
449 }
450}
451
452pub fn resolve_mount_target(target: &str, base_dir: Option<&std::path::Path>) -> String {
457 let has_scheme = target.split_once(':').is_some_and(|(s, _)| {
458 s.len() > 1 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
459 });
460 let p = std::path::Path::new(target);
461 if has_scheme || p.is_absolute() {
462 return target.to_string();
463 }
464 match base_dir {
465 Some(dir) if !dir.as_os_str().is_empty() => dir.join(target).display().to_string(),
466 _ => target.to_string(),
467 }
468}
469
470fn split_trait(head: &str) -> (String, Option<String>) {
472 match head.split_once('<') {
473 Some((path, rest)) => (
474 path.trim().to_string(),
475 rest.strip_suffix('>').map(|t| t.trim().to_string()),
476 ),
477 None => (head.to_string(), None),
478 }
479}
480
481fn split_container_role(path: &str) -> Option<(String, String)> {
484 let p = path.trim().trim_start_matches('/');
485 let (container, role) = p.split_once('/')?;
486 let (container, role) = (container.trim(), role.trim());
487 if container.is_empty() || role.is_empty() || role.contains('/') {
488 return None;
489 }
490 Some((container.to_string(), role.to_string()))
491}
492
493fn split_trailing_group(s: &str) -> Option<(String, String)> {
497 let s = s.trim();
498 if !s.ends_with(']') {
499 return None;
500 }
501 let b: Vec<char> = s.chars().collect();
502 let mut depth = 0usize;
503 for i in (0..b.len()).rev() {
504 match b[i] {
505 ']' => depth += 1,
506 '[' => {
507 depth -= 1;
508 if depth == 0 {
509 let path = b[..i].iter().collect::<String>().trim().to_string();
510 let group = b[i..].iter().collect::<String>();
511 return (!path.is_empty()).then_some((path, group));
512 }
513 }
514 _ => {}
515 }
516 }
517 None
518}
519
520fn parse_ref_target(s: &str) -> Result<(String, String, Option<String>, Option<String>), String> {
523 let (path, key_field) = match split_trailing_group(s) {
524 Some((path, group)) => {
525 let inner = group.trim_start_matches('[').trim_end_matches(']').trim();
526 let (lhs, rhs) = inner
527 .split_once('=')
528 .ok_or_else(|| format!("ref target condition must be 'PROP = $': '{s}'"))?;
529 if rhs.trim() != "$" {
530 return Err(format!(
531 "ref resolves by value, so its condition is 'PROP = $': '{s}'"
532 ));
533 }
534 let key = lhs.trim().trim_start_matches("::").trim().to_string();
535 (path, (!key.is_empty()).then_some(key))
536 }
537 None => (s.trim().to_string(), None),
538 };
539 let mut segs = path.trim_start_matches('/').split('/').map(str::trim);
542 let container = segs.next().unwrap_or("").to_string();
543 let role = segs.next().filter(|r| !r.is_empty()).map(str::to_string);
544 if container.is_empty() {
545 return Err(format!("ref needs a target container: '{s}'"));
546 }
547 Ok((container, path, role, key_field))
548}
549
550fn split_arrow(s: &str) -> Option<(String, String)> {
552 let b: Vec<char> = s.chars().collect();
553 let mut i = 0;
554 while i + 1 < b.len() {
555 if b[i] == '-' && b[i + 1] == '>' && !(i > 0 && b[i - 1] == '-') {
556 return Some((
557 b[..i].iter().collect::<String>().trim().to_string(),
558 b[i + 2..].iter().collect::<String>().trim().to_string(),
559 ));
560 }
561 i += 1;
562 }
563 None
564}
565
566pub fn parse_model(text: &str) -> Result<Model, String> {
570 parse_model_in(text, None, &mut Vec::new(), &mut Vec::new())
571}
572
573pub fn parse_model_file(path: &std::path::Path) -> Result<Model, String> {
580 let text =
581 std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
582 let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
583 let mut visited = vec![path.canonicalize().unwrap_or_else(|_| path.to_path_buf())];
584 parse_model_in(&text, path.parent(), &mut visited, &mut Vec::new())
585}
586
587fn parse_model_in(
588 text: &str,
589 base_dir: Option<&std::path::Path>,
590 visited: &mut Vec<std::path::PathBuf>,
591 done: &mut Vec<std::path::PathBuf>,
592) -> Result<Model, String> {
593 let mut model = Model::default();
594 let mut defs = Vec::new();
595 for raw in statements(&strip_comments(text)) {
596 let stmt = raw.trim();
597 if stmt.is_empty() {
598 continue;
599 }
600 let (head, rest) = match stmt.split_once(char::is_whitespace) {
601 Some((h, r)) => (h, r.trim()),
602 None => (stmt, ""),
603 };
604 match head {
605 "node" => {
606 let (head, query) = rest
607 .split_once(':')
608 .ok_or_else(|| format!("node needs '/container/role: query': '{stmt}'"))?;
609 let (path, trait_name) = split_trait(head.trim());
610 let (name, role) = split_container_role(&path).ok_or_else(|| {
611 format!(
612 "node needs '/container/role', so a hop can be \
613 labelled by the role it lands on: '{stmt}'"
614 )
615 })?;
616 let trait_name = trait_name.unwrap_or_else(|| role.clone());
617 model.nodes.push(NodeDecl {
618 name,
619 role,
620 trait_name,
621 query: query.trim().to_string(),
622 });
623 }
624 "mount" => {
625 let (name, target) = rest
626 .split_once(':')
627 .ok_or_else(|| format!("mount needs 'NAME: target': '{stmt}'"))?;
628 let name = name.trim().to_string();
629 if model.mounts.iter().any(|m| m.name == name) {
630 return Err(format!("mount '{name}' is already declared: '{stmt}'"));
631 }
632 model.mounts.push(Mount {
633 name,
634 target: target.trim().to_string(),
635 });
636 }
637 "use" => {
638 let file = rest.trim().trim_matches(|c| c == '"' || c == '\'');
639 if file.is_empty() {
640 return Err(format!("use needs a model file: '{stmt}'"));
641 }
642 let Some(dir) = base_dir else {
643 return Err(format!(
644 "use needs a location to resolve '{file}' against: pass the model as a file (--model FILE)"
645 ));
646 };
647 let path = {
648 let p = std::path::Path::new(file);
649 if p.is_absolute() {
650 p.to_path_buf()
651 } else {
652 dir.join(p)
653 }
654 };
655 let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
656 if visited.contains(&canon) {
661 return Err(format!("use {file}: the model includes itself"));
662 }
663 if done.contains(&canon) {
664 continue;
665 }
666 visited.push(canon.clone());
667 let text = std::fs::read_to_string(&path)
668 .map_err(|e| format!("use {file}: reading {}: {e}", path.display()))?;
669 let text = text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned();
670 let sub = parse_model_in(&text, path.parent(), visited, done)
671 .map_err(|e| format!("use {file}: {e}"))?;
672 visited.pop();
673 done.push(canon);
674 for m in sub.mounts {
675 if model.mounts.iter().any(|x| x.name == m.name) {
676 return Err(format!(
677 "use {file}: mount '{}' is already declared",
678 m.name
679 ));
680 }
681 model.mounts.push(Mount {
689 name: m.name,
690 target: resolve_mount_target(
691 &m.target,
692 std::path::Path::new(file).parent(),
693 ),
694 });
695 }
696 model.nodes.extend(sub.nodes);
697 model.refs.extend(sub.refs);
698 model.rels.extend(sub.rels);
699 model.edges.extend(sub.edges);
700 model.aliases.extend(sub.aliases);
701 model.indexes.extend(sub.indexes);
702 for u in sub.units {
703 if !model.units.iter().any(|x| x.path == u.path) {
704 model.units.push(u);
705 }
706 }
707 for t in sub.trees {
708 if model.trees.iter().any(|x| x.identity == t.identity) {
709 return Err(format!(
710 "use {file}: tree '{}' is already declared",
711 t.identity
712 ));
713 }
714 model.trees.push(t);
715 }
716 for t in sub.traits {
717 if model.traits.iter().any(|x| x.name == t.name) {
718 return Err(format!(
719 "use {file}: trait <{}> is already declared",
720 t.name
721 ));
722 }
723 model.traits.push(t);
724 }
725 if !sub.defs_text.trim().is_empty() {
726 defs.push(sub.defs_text);
727 }
728 }
729 "ref" => {
730 let (left, container) = rest
731 .split_once("-->")
732 .ok_or_else(|| format!("ref needs 'PATH::field --> container': '{stmt}'"))?;
733 let (scope, field) = split_field(left)?;
734 let (container, path, role, key_field) = parse_ref_target(container)?;
735 model.refs.push(RefDecl {
736 scope,
737 field,
738 container,
739 path,
740 role,
741 key_field,
742 });
743 }
744 "rel" => {
745 let (source, target) = split_arrow(rest)
746 .ok_or_else(|| format!("rel needs 'SOURCE -> TARGET[cond]': '{stmt}'"))?;
747 let (target, cond) = split_trailing_group(&target).ok_or_else(|| {
748 format!(
749 "rel needs a condition — two node sets joined by \
750 nothing are a cross product: '{stmt}'"
751 )
752 })?;
753 if source.is_empty() {
754 return Err(format!("rel needs a source path: '{stmt}'"));
755 }
756 model.rels.push(RelDecl {
757 source,
758 target,
759 cond,
760 });
761 }
762 "edge" => {
763 let (scope_head, pair) = rest
764 .split_once(':')
765 .ok_or_else(|| format!("edge needs 'PATH: ::a -- ::b': '{stmt}'"))?;
766 let (a, b) = pair
767 .split_once("--")
768 .ok_or_else(|| format!("edge needs two fields 'a -- b': '{stmt}'"))?;
769 let field_a = a.trim().trim_start_matches("::").trim().to_string();
770 let field_b = b.trim().trim_start_matches("::").trim().to_string();
771 model.edges.push(EdgeDecl {
772 scope: scope_head.trim().to_string(),
773 field_a,
774 field_b,
775 });
776 }
777 "def" | "macro" => {
778 defs.push(format!("{stmt};"));
779 }
780 "alias" => {
781 model.aliases.push(parse_alias(rest, stmt)?);
782 }
783 "tree" => {
784 let (identity, path) = rest
788 .rfind(": ")
789 .or_else(|| rest.rfind(":\t"))
790 .map(|i| (rest[..i].trim(), rest[i + 1..].trim()))
791 .ok_or_else(|| format!("tree needs 'IDENTITY: PATH': '{stmt}'"))?;
792 if identity.is_empty() || path.is_empty() {
793 return Err(format!("tree needs 'IDENTITY: PATH': '{stmt}'"));
794 }
795 if !path.starts_with('/') {
796 return Err(format!(
797 "a tree stands at a path from the root ('/mount' or '/mount/…'): '{stmt}'"
798 ));
799 }
800 if model.trees.iter().any(|t| t.identity == identity) {
801 return Err(format!("tree '{identity}' is declared twice: '{stmt}'"));
802 }
803 model.trees.push(TreeDecl {
804 identity: identity.to_string(),
805 path: path.to_string(),
806 });
807 }
808 "units" => {
809 let library = rest.trim().trim_matches(|c| c == '"' || c == '\'');
810 if library.is_empty() {
811 return Err(format!(
812 "units needs a library: 'units starye-mery.faiv;' ('{stmt}')"
813 ));
814 }
815 let path = std::path::Path::new(library);
816 let path = match (path.is_absolute(), base_dir) {
817 (false, Some(dir)) => dir.join(path),
818 _ => path.to_path_buf(),
819 };
820 if model.units.iter().any(|u| u.path == path) {
821 return Err(format!("units '{library}' is imported twice: '{stmt}'"));
822 }
823 model.units.push(UnitsDecl {
824 library: library.to_string(),
825 path,
826 });
827 }
828 "index" => {
829 let (scope, property) = split_field(rest)?;
830 if model
831 .indexes
832 .iter()
833 .any(|d| d.scope == scope && d.property == property)
834 {
835 return Err(format!("index is declared twice: '{stmt}'"));
836 }
837 model.indexes.push(IndexDecl { scope, property });
838 }
839 "trait" => {
840 let decl = parse_trait(rest, stmt)?;
841 if model.traits.iter().any(|t| t.name == decl.name) {
842 return Err(format!("trait <{}> is declared twice: '{stmt}'", decl.name));
843 }
844 model.traits.push(decl);
845 }
846 other => {
847 return Err(format!(
848 "unknown model statement '{other}' \
849 (expected node/ref/rel/edge/mount/alias/trait/index/tree/def/macro)"
850 ));
851 }
852 }
853 }
854 model.defs_text = defs.join("\n");
855 for edge in &model.edges {
860 for field in [&edge.field_a, &edge.field_b] {
861 if !model.refs.iter().any(|r| &r.field == field) {
862 return Err(format!(
863 "edge field '::{field}' has no ref declaration — \
864 an edge connects the containers its fields \
865 resolve into, so declare \
866 'ref {}::{field} --> ...;' first",
867 edge.scope
868 ));
869 }
870 }
871 }
872 Ok(model)
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 #[test]
880 fn parses_the_forum_model() {
881 let m = parse_model(
882 r#"
883 # the forum's implied graph
884 mount forum: posts.csv;
885 node /ips/ip: /forum/posts/*::ip | unique;
886 node /cookies/cookie: /forum/posts/*::cookie | unique;
887 ref /forum/posts/*::ip --> ips;
888 ref /forum/posts/*::cookie --> cookies;
889 edge /forum/posts/*: ::cookie -- ::ip;
890 "#,
891 )
892 .unwrap();
893 assert_eq!(m.mounts.len(), 1);
894 assert_eq!(m.mounts[0].name, "forum");
895 assert_eq!(m.mounts[0].target, "posts.csv");
896 assert_eq!(m.nodes.len(), 2);
897 assert_eq!(m.nodes[0].name, "ips");
898 assert_eq!(m.nodes[0].role, "ip");
899 assert_eq!(m.nodes[0].trait_name, "ip");
900 assert_eq!(m.nodes[0].query, "/forum/posts/*::ip | unique");
901 assert_eq!(m.refs.len(), 2);
902 assert_eq!(m.refs[1].scope, "/forum/posts/*");
903 assert_eq!(m.refs[1].field, "cookie");
904 assert_eq!(m.refs[1].container, "cookies");
905 assert_eq!(m.edges.len(), 1);
906 assert_eq!(m.edges[0].scope, "/forum/posts/*");
907 assert_eq!(m.edges[0].field_a, "cookie");
908 assert_eq!(m.edges[0].field_b, "ip");
909 }
910
911 #[test]
912 fn trailing_comments_are_stripped_but_glued_hashes_survive() {
913 let m = parse_model(
914 "node /ips/ip: /row::ip | unique; # distinct ips\n\
915 node /tags/tag: /row::tag | s/#(\\d+)/$1/ | unique;\n\
916 ref /row::ip --> ips; # resolves there\n",
917 )
918 .unwrap();
919 assert_eq!(m.nodes.len(), 2);
920 assert_eq!(m.nodes[0].query, "/row::ip | unique");
921 assert!(m.nodes[1].query.contains("s/#("));
923 assert_eq!(m.refs.len(), 1);
924 }
925
926 #[test]
927 fn collects_defs_and_comments() {
928 let m = parse_model("def &recent: [::ts > '2026-01-01'];\nnode /who/user: /log/*::user;")
929 .unwrap();
930 assert_eq!(m.nodes.len(), 1);
931 assert!(m.defs_text.contains("def &recent"));
932 }
933
934 #[test]
935 fn a_semicolon_projection_is_not_a_terminator() {
936 let m = parse_model("node /sizes/size: /files/*::::size;").unwrap();
938 assert_eq!(m.nodes.len(), 1);
939 assert_eq!(m.nodes[0].query, "/files/*::::size");
940 }
941
942 #[test]
943 fn a_node_head_needs_a_role() {
944 assert!(parse_model("node ips: /row::ip;").is_err());
947 }
948
949 #[test]
950 fn declares_an_explicit_trait() {
951 let m = parse_model("node /ips/ip<addr>: /row::ip | unique;").unwrap();
952 assert_eq!(m.nodes[0].role, "ip");
953 assert_eq!(m.nodes[0].trait_name, "addr");
954 }
955
956 #[test]
957 fn bare_field_ref_is_refused() {
958 assert!(parse_model("ref ::ip --> ips;").is_err());
959 }
960
961 #[test]
962 fn a_ref_target_may_be_a_path_with_its_condition_said_out_loud() {
963 let m = parse_model("ref /row::cookie --> /cookies/cookie[::id = $];").unwrap();
964 assert_eq!(m.refs[0].container, "cookies");
965 assert_eq!(m.refs[0].key_field.as_deref(), Some("id"));
966 let m = parse_model("ref /row::cookie --> /cookies/cookie;").unwrap();
969 assert_eq!(m.refs[0].container, "cookies");
970 assert_eq!(m.refs[0].key_field, None);
971 }
972
973 #[test]
974 fn parses_a_relation() {
975 let m = parse_model("rel /errors/error -> /deploys/deploy [::at > _::at];").unwrap();
976 assert_eq!(m.rels.len(), 1);
977 assert_eq!(m.rels[0].source, "/errors/error");
978 assert_eq!(m.rels[0].target, "/deploys/deploy");
979 assert_eq!(m.rels[0].cond, "[::at > _::at]");
980 }
981
982 #[test]
983 fn an_edge_over_unrefd_fields_is_refused() {
984 let err = parse_model(
987 "node /ips/ip: /row::ip | unique;\n\
988 edge /row: ::cookie -- ::ip;",
989 )
990 .unwrap_err();
991 assert!(err.contains("no ref declaration"), "{err}");
992 }
993
994 #[test]
995 fn a_relation_without_a_condition_is_refused() {
996 assert!(parse_model("rel /errors/error -> /deploys/deploy;").is_err());
998 }
999}
1000
1001fn first_segment(path: &str) -> Option<String> {
1005 let t = path.trim();
1006 if t.starts_with("//") {
1007 return None;
1008 }
1009 let t = t.trim_start_matches('/');
1010 let end = t
1011 .find(|c: char| matches!(c, '/' | '[' | ':' | '(' | '<' | '>' | '*' | ' ' | '-'))
1012 .unwrap_or(t.len());
1013 let seg = &t[..end];
1014 (!seg.is_empty()).then(|| seg.to_string())
1015}
1016
1017impl Model {
1018 pub fn reachable_roots(&self, named: &[&str]) -> Vec<String> {
1027 let mut out: Vec<String> = named.iter().map(|s| s.to_string()).collect();
1028 let mut edges: Vec<(Option<String>, Option<String>)> = Vec::new();
1029 for r in &self.refs {
1030 let to = first_segment(&r.path).or_else(|| first_segment(&r.container));
1031 edges.push((first_segment(&r.scope), to));
1032 }
1033 for r in &self.rels {
1034 edges.push((first_segment(&r.source), first_segment(&r.target)));
1035 }
1036 let mut changed = true;
1037 while changed {
1038 changed = false;
1039 for (from, to) in &edges {
1040 let reached = match from {
1041 Some(f) => out.contains(f),
1042 None => true,
1043 };
1044 if reached
1045 && let Some(t) = to
1046 && !out.contains(t)
1047 {
1048 out.push(t.clone());
1049 changed = true;
1050 }
1051 }
1052 }
1053 for t in &self.trees {
1054 if let Some(m) = first_segment(&t.path)
1055 && !out.contains(&m)
1056 {
1057 out.push(m);
1058 }
1059 }
1060 out
1061 }
1062}