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 edges: Vec<EdgeDecl>,
19 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 pub scope: String,
40 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
52fn 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
95fn strip_comments(text: &str) -> String {
98 text.lines()
99 .filter(|l| !l.trim_start().starts_with('#'))
100 .collect::<Vec<_>>()
101 .join("\n")
102}
103
104fn 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
129pub 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
147pub 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 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}