1use std::collections::BTreeSet;
30
31use serde::{Deserialize, Serialize};
32
33#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35pub struct ChangeUnit {
36 pub seq: usize,
39 pub cut_id: String,
40 pub change_id: String,
41 pub branch_id: String,
42 pub path: String,
43 pub before: Option<String>,
44 pub after: Option<String>,
45 pub origin: Option<String>,
46 pub actor: Option<String>,
48 pub intent: Option<String>,
51 pub recorded_at: String,
52 #[serde(default)]
57 pub decls: Vec<DeclUnit>,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
62pub struct DeclUnit {
63 pub identity: String,
65 pub before_canon: Option<String>,
67 pub after_canon: Option<String>,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub enum SelExpr {
73 Union(Box<SelExpr>, Box<SelExpr>),
74 Intersect(Box<SelExpr>, Box<SelExpr>),
75 Difference(Box<SelExpr>, Box<SelExpr>),
76 Atom(SelAtom),
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub enum SelAtom {
81 Path(String),
82 ByEffect(String),
83 ByOrigin(String),
84 ByActor(String),
90 ByIntent(String),
94 Decl(String),
98 InBranch(String),
99 Change(String),
100 Cut(String),
101 Since(String),
102 Until(String),
103 DependentsOf(Box<SelExpr>),
104}
105
106pub fn parse(input: &str) -> Result<SelExpr, String> {
109 let mut parser = Parser {
110 rest: input.trim(),
111 depth: 0,
112 };
113 let expr = parser.expr()?;
114 if !parser.rest.is_empty() {
115 return Err(format!("unexpected trailing input: `{}`", parser.rest));
116 }
117 Ok(expr)
118}
119
120const MAX_SELECTION_DEPTH: usize = 256;
126
127struct Parser<'a> {
128 rest: &'a str,
129 depth: usize,
130}
131
132impl<'a> Parser<'a> {
133 fn skip_ws(&mut self) {
134 self.rest = self.rest.trim_start();
135 }
136
137 fn eat(&mut self, token: char) -> bool {
138 self.skip_ws();
139 if let Some(stripped) = self.rest.strip_prefix(token) {
140 self.rest = stripped;
141 true
142 } else {
143 false
144 }
145 }
146
147 fn expr(&mut self) -> Result<SelExpr, String> {
148 let mut left = self.diff()?;
149 while self.eat('|') {
150 let right = self.diff()?;
151 left = SelExpr::Union(Box::new(left), Box::new(right));
152 }
153 Ok(left)
154 }
155
156 fn diff(&mut self) -> Result<SelExpr, String> {
157 let mut left = self.inter()?;
158 while self.eat('~') {
159 let right = self.inter()?;
160 left = SelExpr::Difference(Box::new(left), Box::new(right));
161 }
162 Ok(left)
163 }
164
165 fn inter(&mut self) -> Result<SelExpr, String> {
166 let mut left = self.prim()?;
167 while self.eat('&') {
168 let right = self.prim()?;
169 left = SelExpr::Intersect(Box::new(left), Box::new(right));
170 }
171 Ok(left)
172 }
173
174 fn prim(&mut self) -> Result<SelExpr, String> {
175 self.depth += 1;
176 if self.depth > MAX_SELECTION_DEPTH {
177 self.depth -= 1;
178 return Err(format!(
179 "selection expression is nested too deeply (limit {MAX_SELECTION_DEPTH})"
180 ));
181 }
182 let result = self.prim_inner();
183 self.depth -= 1;
184 result
185 }
186
187 fn prim_inner(&mut self) -> Result<SelExpr, String> {
188 self.skip_ws();
189 if self.eat('(') {
190 let inner = self.expr()?;
191 if !self.eat(')') {
192 return Err(format!("expected `)` at `{}`", self.rest));
193 }
194 return Ok(inner);
195 }
196 let name_len = self
197 .rest
198 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
199 .unwrap_or(self.rest.len());
200 let name = &self.rest[..name_len];
201 if name.is_empty() {
202 return Err(format!("expected a selection atom at `{}`", self.rest));
203 }
204 self.rest = &self.rest[name_len..];
205 if !self.eat('(') {
206 return Err(format!("expected `(` after `{name}`"));
207 }
208 if name == "dependents-of" {
209 let inner = self.expr()?;
210 if !self.eat(')') {
211 return Err(format!("expected `)` at `{}`", self.rest));
212 }
213 return Ok(SelExpr::Atom(SelAtom::DependentsOf(Box::new(inner))));
214 }
215 let close = self
217 .rest
218 .find(')')
219 .ok_or_else(|| format!("unterminated `{name}(`"))?;
220 let arg = self.rest[..close].trim().to_owned();
221 self.rest = &self.rest[close + 1..];
222 let atom = match name {
223 "path" => SelAtom::Path(arg),
224 "by-effect" => SelAtom::ByEffect(arg),
225 "by-origin" => SelAtom::ByOrigin(arg),
226 "by" => SelAtom::ByActor(arg),
227 "intent" => SelAtom::ByIntent(arg),
228 "decl" => SelAtom::Decl(arg),
229 "in-branch" => SelAtom::InBranch(arg),
230 "change" => SelAtom::Change(arg),
231 "cut" => SelAtom::Cut(arg),
232 "since" => SelAtom::Since(arg),
233 "until" => SelAtom::Until(arg),
234 other => return Err(format!("unknown selection atom `{other}`")),
235 };
236 Ok(SelExpr::Atom(atom))
237 }
238}
239
240pub fn glob_matches(pattern: &str, value: &str) -> bool {
243 let pattern = pattern.as_bytes();
249 let value = value.as_bytes();
250 let (mut p, mut v) = (0usize, 0usize);
251 let (mut star, mut resume) = (None, 0usize);
253 while v < value.len() {
254 if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) {
255 p += 1;
256 v += 1;
257 } else if p < pattern.len() && pattern[p] == b'*' {
258 star = Some(p);
260 resume = v;
261 p += 1;
262 } else if let Some(star_p) = star {
263 p = star_p + 1;
265 resume += 1;
266 v = resume;
267 } else {
268 return false;
269 }
270 }
271 while p < pattern.len() && pattern[p] == b'*' {
273 p += 1;
274 }
275 p == pattern.len()
276}
277
278pub fn eval(expr: &SelExpr, universe: &[ChangeUnit]) -> BTreeSet<usize> {
281 match expr {
282 SelExpr::Union(a, b) => eval(a, universe)
283 .union(&eval(b, universe))
284 .copied()
285 .collect(),
286 SelExpr::Intersect(a, b) => eval(a, universe)
287 .intersection(&eval(b, universe))
288 .copied()
289 .collect(),
290 SelExpr::Difference(a, b) => eval(a, universe)
291 .difference(&eval(b, universe))
292 .copied()
293 .collect(),
294 SelExpr::Atom(atom) => eval_atom(atom, universe),
295 }
296}
297
298fn eval_atom(atom: &SelAtom, universe: &[ChangeUnit]) -> BTreeSet<usize> {
299 let pick = |predicate: &dyn Fn(&ChangeUnit) -> bool| -> BTreeSet<usize> {
300 universe
301 .iter()
302 .enumerate()
303 .filter(|(_, unit)| predicate(unit))
304 .map(|(index, _)| index)
305 .collect()
306 };
307 match atom {
308 SelAtom::Path(glob) => pick(&|unit| glob_matches(glob, &unit.path)),
309 SelAtom::ByEffect(prefix) => pick(&|unit| unit.cut_id.starts_with(prefix.as_str())),
310 SelAtom::ByOrigin(prefix) => pick(&|unit| {
311 unit.origin
312 .as_deref()
313 .is_some_and(|origin| origin.starts_with(prefix.as_str()))
314 }),
315 SelAtom::ByActor(prefix) => pick(&|unit| {
316 unit.actor
317 .as_deref()
318 .is_some_and(|actor| actor.starts_with(prefix.as_str()))
319 }),
320 SelAtom::ByIntent(prefix) => pick(&|unit| {
321 unit.intent
322 .as_deref()
323 .is_some_and(|intent| intent.starts_with(prefix.as_str()))
324 }),
325 SelAtom::Decl(glob) => pick(&|unit| {
326 unit.decls
327 .iter()
328 .any(|decl| glob_matches(glob, &decl.identity))
329 }),
330 SelAtom::InBranch(branch) => pick(&|unit| unit.branch_id == *branch),
331 SelAtom::Change(change) => pick(&|unit| unit.change_id == *change),
332 SelAtom::Cut(cut) => pick(&|unit| unit.cut_id == *cut),
333 SelAtom::Since(stamp) => pick(&|unit| unit.recorded_at.as_str() >= stamp.as_str()),
334 SelAtom::Until(stamp) => pick(&|unit| unit.recorded_at.as_str() <= stamp.as_str()),
335 SelAtom::DependentsOf(inner) => {
336 let seeds = eval(inner, universe);
340 let mut selected = seeds.clone();
341 for &seed in &seeds {
342 let seed_unit = &universe[seed];
343 for (index, unit) in universe.iter().enumerate() {
344 if unit.path == seed_unit.path && unit.seq > seed_unit.seq {
345 selected.insert(index);
346 }
347 }
348 }
349 selected
350 }
351 }
352}
353
354pub fn stranded_by_undo(selected: &BTreeSet<usize>, universe: &[ChangeUnit]) -> BTreeSet<usize> {
360 let mut stranded = BTreeSet::new();
361 for &chosen in selected {
362 let undone = &universe[chosen];
363 for (index, unit) in universe.iter().enumerate() {
364 if unit.path == undone.path && unit.seq > undone.seq && !selected.contains(&index) {
365 stranded.insert(index);
366 }
367 }
368 }
369 stranded
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn unit(seq: usize, cut: &str, path: &str, at: &str) -> ChangeUnit {
377 ChangeUnit {
378 seq,
379 cut_id: cut.to_owned(),
380 change_id: cut.to_owned(),
381 branch_id: "b1".to_owned(),
382 path: path.to_owned(),
383 before: None,
384 after: Some(format!("h{seq}")),
385 origin: Some(format!("write:{path}")),
386 actor: None,
387 intent: None,
388 recorded_at: at.to_owned(),
389 decls: Vec::new(),
390 }
391 }
392
393 fn unit_by(seq: usize, cut: &str, path: &str, actor: &str, at: &str) -> ChangeUnit {
394 ChangeUnit {
395 actor: Some(actor.to_owned()),
396 ..unit(seq, cut, path, at)
397 }
398 }
399
400 #[test]
405 fn actor_and_intent_atoms_select_and_compose() {
406 let universe = vec![
407 unit_by(0, "c1", "src/a.rs", "s:sess-7", "t1"),
408 unit_by(1, "c2", "src/b.rs", "s:sess-9", "t2"),
409 unit_by(2, "c3", "docs/c.md", "git:alice@example.com", "t3"),
410 unit(3, "c4", "src/a.rs", "t4"), ];
412 let by_session = eval(&parse("by(s:sess-7)").expect("parse"), &universe);
413 assert_eq!(by_session, BTreeSet::from([0]));
414 let all_sessions = eval(&parse("by(s:)").expect("parse"), &universe);
415 assert_eq!(all_sessions, BTreeSet::from([0, 1]));
416 let imported = eval(&parse("by(git:)").expect("parse"), &universe);
417 assert_eq!(imported, BTreeSet::from([2]));
418 let downstream = eval(
420 &parse("dependents-of(by(s:sess-7)) ~ by(s:sess-7)").expect("parse"),
421 &universe,
422 );
423 assert_eq!(downstream, BTreeSet::from([3]));
424 assert!(eval(&parse("intent(inc-)").expect("parse"), &universe).is_empty());
427 let mut with_intent = universe.clone();
428 with_intent[1].intent = Some("inc-42".to_owned());
429 assert_eq!(
430 eval(&parse("intent(inc-)").expect("parse"), &with_intent),
431 BTreeSet::from([1])
432 );
433 }
434
435 #[test]
439 fn decl_atom_selects_by_declaration_identity() {
440 let mut universe = vec![
441 unit(0, "c1", "flows/main.whip", "t1"),
442 unit(1, "c2", "flows/main.whip", "t2"),
443 unit(2, "c3", "notes/readme.md", "t3"),
444 ];
445 universe[0].decls = vec![DeclUnit {
446 identity: "rule triage".to_owned(),
447 before_canon: None,
448 after_canon: Some("k1".to_owned()),
449 }];
450 universe[1].decls = vec![
451 DeclUnit {
452 identity: "rule close".to_owned(),
453 before_canon: Some("k2".to_owned()),
454 after_canon: Some("k3".to_owned()),
455 },
456 DeclUnit {
457 identity: "class Report".to_owned(),
458 before_canon: Some("k4".to_owned()),
459 after_canon: None,
460 },
461 ];
462 assert_eq!(
463 eval(&parse("decl(rule close)").expect("parse"), &universe),
464 BTreeSet::from([1])
465 );
466 assert_eq!(
467 eval(&parse("decl(rule *)").expect("parse"), &universe),
468 BTreeSet::from([0, 1])
469 );
470 assert_eq!(
471 eval(
472 &parse("decl(rule *) ~ decl(class *)").expect("parse"),
473 &universe
474 ),
475 BTreeSet::from([0])
476 );
477 assert!(eval(&parse("decl(gauge *)").expect("parse"), &universe).is_empty());
478 }
479
480 #[test]
481 fn grammar_parses_composition_with_precedence() {
482 let expr = parse("path(src/*.md) & since(t3) | by-effect(eff_) ~ cut(c9)").expect("parse");
483 let SelExpr::Union(left, right) = expr else {
485 panic!("expected a union at the top");
486 };
487 assert!(matches!(*left, SelExpr::Intersect(..)));
488 assert!(matches!(*right, SelExpr::Difference(..)));
489 assert!(parse("path(unclosed").is_err());
490 assert!(parse("nonsense(x)").is_err());
491 assert!(parse("path(a) extra").is_err());
492 }
493
494 #[test]
495 fn deeply_nested_selection_errors_instead_of_overflowing_the_stack() {
496 std::thread::Builder::new()
499 .stack_size(8 * 1024 * 1024)
500 .spawn(|| {
501 let deep = format!("{}path(a){}", "(".repeat(4000), ")".repeat(4000));
502 let result = parse(&deep);
503 assert!(
504 result
505 .as_ref()
506 .err()
507 .is_some_and(|message| message.contains("nested too deeply")),
508 "expected a depth-limit diagnostic, got {result:?}"
509 );
510 let ok = format!("{}path(a){}", "(".repeat(64), ")".repeat(64));
511 assert!(parse(&ok).is_ok(), "64-deep nesting must parse");
512 })
513 .expect("spawn")
514 .join()
515 .expect("nested-selection parse must not crash");
516 }
517
518 #[test]
519 fn atoms_select_by_provenance_dimensions() {
520 let universe = vec![
521 unit(0, "eff_1-f0", "src/a.md", "t1"),
522 unit(1, "eff_2-f0", "src/b.md", "t2"),
523 unit(2, "cut_x", "notes/c.txt", "t3"),
524 ];
525 let by_path = eval(&parse("path(src/*.md)").expect("parse"), &universe);
526 assert_eq!(by_path, BTreeSet::from([0, 1]));
527 let by_effect = eval(&parse("by-effect(eff_2)").expect("parse"), &universe);
528 assert_eq!(by_effect, BTreeSet::from([1]));
529 let since = eval(&parse("since(t2)").expect("parse"), &universe);
530 assert_eq!(since, BTreeSet::from([1, 2]));
531 let composed = eval(
532 &parse("path(src/*.md) ~ by-effect(eff_2)").expect("parse"),
533 &universe,
534 );
535 assert_eq!(composed, BTreeSet::from([0]));
536 let grouped = eval(
537 &parse("(path(src/*.md) | path(notes/*)) & until(t2)").expect("parse"),
538 &universe,
539 );
540 assert_eq!(grouped, BTreeSet::from([0, 1]));
541 }
542
543 #[test]
549 fn stranding_and_dependents_mirror_the_model() {
550 let universe = vec![
551 unit(0, "e1", "p1", "t1"),
552 unit(1, "e2", "p2", "t2"),
553 unit(2, "e3", "p2", "t3"),
554 ];
555 let sel = eval(&parse("cut(e2)").expect("parse"), &universe);
557 assert_eq!(stranded_by_undo(&sel, &universe), BTreeSet::from([2]));
558 let sel = eval(&parse("path(p2)").expect("parse"), &universe);
560 assert!(stranded_by_undo(&sel, &universe).is_empty());
561 let sel = eval(&parse("dependents-of(cut(e2))").expect("parse"), &universe);
563 assert_eq!(sel, BTreeSet::from([1, 2]));
564 assert!(stranded_by_undo(&sel, &universe).is_empty());
565 let sel = eval(&parse("cut(e1)").expect("parse"), &universe);
567 assert!(stranded_by_undo(&sel, &universe).is_empty());
568 }
569
570 #[test]
571 fn glob_semantics() {
572 assert!(glob_matches("src/*.md", "src/deep/a.md"));
573 assert!(glob_matches("*", "anything"));
574 assert!(glob_matches("a?c", "abc"));
575 assert!(!glob_matches("a?c", "ac"));
576 assert!(!glob_matches("src/*.md", "src/a.txt"));
577 assert!(glob_matches("a*b*c", "axxbyyc"));
579 assert!(glob_matches("**", "anything"));
580 assert!(glob_matches("*.md", ".md"));
581 assert!(glob_matches("", ""));
582 assert!(!glob_matches("", "x"));
583 assert!(!glob_matches("a*b", "axxbx")); assert!(glob_matches("a*b", "ab"));
585 }
586
587 #[test]
588 fn glob_worst_case_is_linear_not_exponential() {
589 let pattern = "a*".repeat(30) + "Z";
593 let value = "a".repeat(60);
594 assert!(!glob_matches(&pattern, &value));
595 }
596}