1use std::collections::{BTreeSet, HashMap, HashSet};
28
29use bynk_syntax::ast::*;
30use bynk_syntax::span::Span;
31
32use crate::agent_state::{PATH_BUDGET, combine_guards};
33use bynk_check::ast_text::{branch_outcome, pattern_summary, peel_paren};
34use bynk_check::store_write::{store_kinds, store_write};
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct HandlerEffects {
39 pub handler: String,
40 pub rows: Vec<EffectRow>,
41 pub truncated: bool,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct EffectRow {
48 pub guards: Vec<String>,
50 pub writes: Vec<EffectWrite>,
52 pub reads: Vec<String>,
54 pub calls: Vec<String>,
56 pub outcomes: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct EffectWrite {
62 pub field: String,
63 pub op: String,
65 pub key: Option<String>,
68 pub span: Span,
69}
70
71pub fn agent_effects(agent: &AgentDecl, agents: &HashSet<String>) -> Vec<HandlerEffects> {
75 let store = store_kinds(agent);
76 agent
77 .handlers
78 .iter()
79 .map(|h| {
80 let mut w = Walker {
81 store: &store,
82 agents,
83 bound: h.params.iter().map(|p| p.name.name.clone()).collect(),
84 truncated: false,
85 };
86 let paths = w.walk_block(&h.body, vec![Path::default()], true);
87 let handler = h
88 .method_name
89 .as_ref()
90 .map_or_else(|| "call".to_string(), |m| m.name.clone());
91 HandlerEffects {
92 handler,
93 truncated: w.truncated,
94 rows: if w.truncated {
95 Vec::new()
96 } else {
97 paths.into_iter().map(Path::into_row).collect()
98 },
99 }
100 })
101 .collect()
102}
103
104#[derive(Debug, Clone, Default)]
105struct Path {
106 guards: BTreeSet<String>,
107 writes: Vec<EffectWrite>,
108 reads: BTreeSet<String>,
109 calls: BTreeSet<String>,
110 outcomes: BTreeSet<String>,
111}
112
113impl Path {
114 fn written(&self, field: &str) -> bool {
115 self.writes.iter().any(|w| w.field == field)
116 }
117
118 fn same_key(&self, other: &Path) -> bool {
121 let ops = |p: &Path| -> Vec<(String, String, Option<String>)> {
122 p.writes
123 .iter()
124 .map(|w| (w.field.clone(), w.op.clone(), w.key.clone()))
125 .collect()
126 };
127 ops(self) == ops(other) && self.reads == other.reads && self.calls == other.calls
128 }
129
130 fn into_row(self) -> EffectRow {
131 EffectRow {
132 guards: self.guards.into_iter().collect(),
133 writes: self.writes,
134 reads: self.reads.into_iter().collect(),
135 calls: self.calls.into_iter().collect(),
136 outcomes: self.outcomes.into_iter().collect(),
137 }
138 }
139}
140
141struct Walker<'a> {
142 store: &'a HashMap<String, String>,
143 agents: &'a HashSet<String>,
144 bound: Vec<String>,
145 truncated: bool,
146}
147
148impl Walker<'_> {
149 fn walk_block(&mut self, block: &Block, mut paths: Vec<Path>, tail: bool) -> Vec<Path> {
150 let scope = self.bound.len();
151 for stmt in &block.statements {
152 if self.truncated {
153 return Vec::new();
154 }
155 paths = match stmt {
156 Statement::Let(l) | Statement::EffectLet(l) => {
157 let paths = self.walk_expr(&l.value, paths, false);
158 self.bound.push(l.name.name.clone());
159 paths
160 }
161 Statement::Do(d) => self.walk_expr(&d.value, paths, false),
162 Statement::Expect(e) => self.walk_expr(&e.value, paths, false),
163 Statement::Send(s) => self.walk_expr(&s.value, paths, false),
164 Statement::Advance(a) => self.walk_expr(&a.amount, paths, false),
165 Statement::Assign(a) => {
166 let mut paths = self.walk_expr(&a.value, paths, false);
167 for p in &mut paths {
168 p.writes.push(EffectWrite {
169 field: a.target.name.clone(),
170 op: ":=".to_string(),
171 key: None,
172 span: a.span,
173 });
174 }
175 paths
176 }
177 };
178 }
179 let paths = self.walk_expr(&block.tail, paths, tail);
180 self.bound.truncate(scope);
181 paths
182 }
183
184 fn walk_expr(&mut self, expr: &Expr, mut paths: Vec<Path>, tail: bool) -> Vec<Path> {
185 if self.truncated || paths.is_empty() {
186 return paths;
187 }
188 let inner = peel_paren(expr);
189 match &inner.kind {
190 ExprKind::If {
191 cond,
192 then_block,
193 else_block,
194 } => {
195 self.scan(cond, &mut paths);
196 let text = bynk_fmt::expr_to_string(cond);
197 let then_paths = guarded(paths.clone(), text.clone());
198 let else_paths = guarded(paths, format!("!({text})"));
199 let mut out = self.walk_block(then_block, then_paths, tail);
200 out.extend(self.walk_block(else_block, else_paths, tail));
201 self.join(out)
202 }
203 ExprKind::Match { discriminant, arms } => {
204 self.scan(discriminant, &mut paths);
205 let subject = bynk_fmt::expr_to_string(discriminant);
206 let mut out = Vec::new();
207 for arm in arms {
208 let mut arm_paths = guarded(
209 paths.clone(),
210 format!("{subject} is {}", pattern_summary(&arm.pattern)),
211 );
212 let scope = self.bound.len();
213 self.bound.extend(
214 arm.pattern
215 .bound_names()
216 .into_iter()
217 .map(|n| n.name.clone()),
218 );
219 if let Some(g) = &arm.guard {
220 self.scan(g, &mut arm_paths);
221 arm_paths = guarded(arm_paths, bynk_fmt::expr_to_string(g));
222 }
223 out.extend(match &arm.body {
224 MatchBody::Expr(e) => self.walk_expr(e, arm_paths, tail),
225 MatchBody::Block(b) => self.walk_block(b, arm_paths, tail),
226 });
227 self.bound.truncate(scope);
228 }
229 self.join(out)
230 }
231 ExprKind::Block(b) => self.walk_block(b, paths, tail),
232 _ => {
233 if let Some(w) = store_write(self.store, &self.bound, inner) {
234 for a in w.args {
235 self.scan(a, &mut paths);
236 }
237 let key = w.args.first().and_then(simple_text);
238 for p in &mut paths {
239 p.writes.push(EffectWrite {
240 field: w.field.name.clone(),
241 op: w.method.name.clone(),
242 key: key.clone(),
243 span: inner.span,
244 });
245 }
246 } else {
248 self.scan(inner, &mut paths);
249 if tail && let Some(o) = branch_outcome(inner) {
250 for p in &mut paths {
251 p.outcomes.insert(o.clone());
252 }
253 }
254 }
255 paths
256 }
257 }
258 }
259
260 fn scan(&self, expr: &Expr, paths: &mut [Path]) {
263 let mut reads = BTreeSet::new();
264 let mut calls = BTreeSet::new();
265 let mut shadow = self.bound.clone();
266 self.collect(expr, &mut shadow, &mut reads, &mut calls);
267 for p in paths {
268 let fresh: Vec<String> = reads.iter().filter(|f| !p.written(f)).cloned().collect();
269 p.reads.extend(fresh);
270 p.calls.extend(calls.iter().cloned());
271 }
272 }
273
274 fn collect(
277 &self,
278 expr: &Expr,
279 shadow: &mut Vec<String>,
280 reads: &mut BTreeSet<String>,
281 calls: &mut BTreeSet<String>,
282 ) {
283 if let ExprKind::Lambda(l) = &expr.kind {
284 let scope = shadow.len();
285 shadow.extend(l.params.iter().map(|p| p.name.name.clone()));
286 self.collect(&l.body, shadow, reads, calls);
287 shadow.truncate(scope);
288 return;
289 }
290 if let Some(w) = store_write(self.store, shadow, expr) {
293 for a in w.args {
294 self.collect(a, shadow, reads, calls);
295 }
296 return;
297 }
298 match &expr.kind {
299 ExprKind::Ident(id)
300 if self.store.contains_key(&id.name) && !shadow.contains(&id.name) =>
301 {
302 reads.insert(id.name.clone());
303 }
304 ExprKind::MethodCall {
305 receiver, method, ..
306 } => {
307 if let ExprKind::Call { name, .. } = &receiver.kind
308 && self.agents.contains(&name.name)
309 {
310 calls.insert(format!("{}.{}", name.name, method.name));
311 }
312 }
313 _ => {}
314 }
315 for c in expr_children(expr) {
316 self.collect(c, shadow, reads, calls);
317 }
318 }
319
320 fn join(&mut self, paths: Vec<Path>) -> Vec<Path> {
321 let mut out: Vec<Path> = Vec::new();
322 for p in paths {
323 match out.iter_mut().find(|q| q.same_key(&p)) {
324 Some(q) => {
325 q.guards = combine_guards(&q.guards, &p.guards);
326 q.outcomes.extend(p.outcomes);
327 }
328 None => out.push(p),
329 }
330 }
331 if out.len() > PATH_BUDGET {
332 self.truncated = true;
333 return Vec::new();
334 }
335 out
336 }
337}
338
339fn guarded(mut paths: Vec<Path>, label: String) -> Vec<Path> {
340 for p in &mut paths {
341 p.guards.insert(label.clone());
342 }
343 paths
344}
345
346fn simple_text(arg: &Expr) -> Option<String> {
348 matches!(
349 peel_paren(arg).kind,
350 ExprKind::Ident(_) | ExprKind::IntLit { .. } | ExprKind::StrLit(_) | ExprKind::BoolLit(_)
351 )
352 .then(|| bynk_fmt::expr_to_string(arg))
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn effects(src: &str, agent: &str, agents: &[&str]) -> Vec<HandlerEffects> {
360 let tokens = bynk_syntax::lexer::tokenize(src).expect("tokenize");
361 let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, src);
362 assert!(errs.is_empty(), "parse errors: {errs:?}");
363 let Some(SourceUnit::Context(ctx)) = unit else {
364 panic!("expected a context")
365 };
366 let a = ctx
367 .items
368 .iter()
369 .find_map(|i| match i {
370 CommonsItem::Agent(a) if a.name.name == agent => Some(a),
371 _ => None,
372 })
373 .expect("agent");
374 let names = agents.iter().map(|s| s.to_string()).collect();
375 agent_effects(a, &names)
376 }
377
378 fn handler<'a>(all: &'a [HandlerEffects], name: &str) -> &'a HandlerEffects {
379 all.iter().find(|h| h.handler == name).expect("handler")
380 }
381
382 type Summary = (
384 Vec<String>,
385 Vec<String>,
386 Vec<String>,
387 Vec<String>,
388 Vec<String>,
389 );
390
391 fn summary(h: &HandlerEffects) -> Vec<Summary> {
392 h.rows
393 .iter()
394 .map(|r| {
395 let writes = r
396 .writes
397 .iter()
398 .map(|w| match (&w.key, w.op.as_str()) {
399 (_, ":=") => format!("{} :=", w.field),
400 (Some(k), op) => format!("{}.{op}({k})", w.field),
401 (None, op) => format!("{}.{op}", w.field),
402 })
403 .collect();
404 (
405 r.guards.clone(),
406 writes,
407 r.reads.clone(),
408 r.calls.clone(),
409 r.outcomes.clone(),
410 )
411 })
412 .collect()
413 }
414
415 fn v(x: &[&str]) -> Vec<String> {
416 x.iter().map(|s| s.to_string()).collect()
417 }
418
419 const TODOS: &str = r#"context todo
421
422type UserId = String where NonEmpty
423type Title = String where NonEmpty
424type Stored = { seq: Int, title: Title, done: Bool }
425type TodoItem = { id: String, title: Title, done: Bool }
426type TodoError = enum { NotFound }
427
428fn view(id: String, s: Stored) -> TodoItem {
429 TodoItem { id: id, title: s.title, done: s.done }
430}
431
432agent Todos {
433 key owner: UserId
434
435 store items: Map[String, Stored]
436 store lastSeq: Cell[Int]
437
438 on call add(title: Title) -> Effect[TodoItem] {
439 let next = lastSeq + 1
440 let id = "\(next)"
441 let item = Stored { seq: next, title: title, done: false }
442 let _ <- items.put(id, item)
443 lastSeq := next
444 view(id, item)
445 }
446
447 on call pendingCount() -> Effect[Int] {
448 items.values.filter((it) => it.done == false).count()
449 }
450
451 on call complete(id: String) -> Effect[Result[(), TodoError]] {
452 let found <- items.get(id)
453 match found {
454 Some(it) => {
455 let _ <- items.put(id, Stored { ...it, done: true })
456 Ok(())
457 }
458 None => Err(NotFound)
459 }
460 }
461}
462"#;
463
464 #[test]
465 fn todos_add_writes_both_fields_and_reads_the_counter_first() {
466 let all = effects(TODOS, "Todos", &[]);
467 assert_eq!(
468 summary(handler(&all, "add")),
469 [(
470 v(&[]),
471 v(&["items.put(id)", "lastSeq :="]),
472 v(&["lastSeq"]),
473 v(&[]),
474 v(&["view(id, item)"])
475 )]
476 );
477 }
478
479 #[test]
480 fn todos_complete_writes_only_when_the_item_is_found() {
481 let all = effects(TODOS, "Todos", &[]);
482 assert_eq!(
483 summary(handler(&all, "complete")),
484 [
485 (
486 v(&["found is Some"]),
487 v(&["items.put(id)"]),
488 v(&["items"]),
489 v(&[]),
490 v(&["Ok(())"])
491 ),
492 (
493 v(&["found is None"]),
494 v(&[]),
495 v(&["items"]),
496 v(&[]),
497 v(&["Err(NotFound)"])
498 ),
499 ]
500 );
501 }
502
503 #[test]
504 fn a_query_accessor_is_a_read() {
505 let all = effects(TODOS, "Todos", &[]);
506 let rows = summary(handler(&all, "pendingCount"));
507 assert_eq!(rows.len(), 1);
508 assert_eq!(rows[0].1, v(&[]));
509 assert_eq!(rows[0].2, v(&["items"]));
510 }
511
512 #[test]
513 fn the_rate_limiter_reads_then_writes_both_cells() {
514 let src = r#"context ratelimit
515type RateView = { allowed: Bool }
516agent Limiter {
517 key client: String
518 store windowStart: Cell[Int]
519 store count: Cell[Int]
520 on call hit(now: Int) -> Effect[RateView] {
521 let d = decide(windowStart, count, now)
522 windowStart := d.windowStart
523 count := d.count
524 RateView { allowed: d.allowed }
525 }
526}
527"#;
528 let all = effects(src, "Limiter", &[]);
529 let rows = summary(handler(&all, "hit"));
530 assert_eq!(rows.len(), 1);
531 assert_eq!(rows[0].1, v(&["windowStart :=", "count :="]));
532 assert_eq!(rows[0].2, v(&["count", "windowStart"]));
533 }
534
535 #[test]
536 fn a_log_append_is_a_write_and_its_queries_are_reads() {
537 let src = r#"context events
538type Event = { kind: String }
539agent Activity {
540 key stream: String
541 store history: Log[Event]
542 on call add(event: Event) -> Effect[()] {
543 let _ <- history.append(event)
544 Effect.pure(())
545 }
546 on call countSince(t: Int) -> Effect[Int] {
547 history.since(t).count()
548 }
549}
550"#;
551 let all = effects(src, "Activity", &[]);
552 let add = summary(handler(&all, "add"));
553 assert_eq!(add[0].1, v(&["history.append(event)"]));
554 assert_eq!(add[0].2, v(&[]), "the write's receiver is not also a read");
555 let count = summary(handler(&all, "countSince"));
556 assert_eq!(count[0].1, v(&[]));
557 assert_eq!(count[0].2, v(&["history"]));
558 }
559
560 #[test]
561 fn agent_calls_are_listed_and_paths_that_agree_merge() {
562 let src = r#"context shop
563agent Counter {
564 key id: String
565 store n: Cell[Int]
566 on call bump() -> Effect[Int] { n := n + 1
567 n }
568}
569agent Basket {
570 key id: String
571 store total: Cell[Int]
572 on call add(k: String, big: Bool) -> Effect[Int] {
573 let c <- Counter(k).bump()
574 let _ = if big { total := total + 10
575 () } else { total := total + 1
576 () }
577 c
578 }
579}
580"#;
581 let all = effects(src, "Basket", &["Counter", "Basket"]);
582 let rows = summary(handler(&all, "add"));
583 assert_eq!(rows.len(), 1);
586 assert_eq!(rows[0].0, v(&[]));
587 assert_eq!(rows[0].1, v(&["total :="]));
588 assert_eq!(rows[0].2, v(&["total"]));
589 assert_eq!(rows[0].3, v(&["Counter.bump"]));
590 assert_eq!(rows[0].4, v(&["c"]));
591 }
592
593 #[test]
594 fn a_shadowed_field_is_neither_read_nor_written() {
595 let src = r#"context demo
596agent A {
597 key id: String
598 store items: Map[String, Int]
599 on call go(items: List[Int]) -> Effect[Int] {
600 items.size()
601 }
602}
603"#;
604 let all = effects(src, "A", &[]);
605 let rows = summary(handler(&all, "go"));
606 assert_eq!(rows[0].1, v(&[]));
607 assert_eq!(rows[0].2, v(&[]));
608 }
609
610 #[test]
611 fn writes_with_different_keys_stay_separate_rows() {
612 let src = r#"context demo
613agent A {
614 key id: String
615 store items: Map[String, Int]
616 on call go(c: Bool, a: String, b: String, x: Int) -> Effect[()] {
617 let _ = if c { let _ <- items.put(a, x)
618 () } else { let _ <- items.put(b, x)
619 () }
620 Effect.pure(())
621 }
622}
623"#;
624 let all = effects(src, "A", &[]);
625 let h = handler(&all, "go");
626 let rows = summary(h);
627 assert_eq!(rows.len(), 2);
629 assert_eq!(rows[0].1, v(&["items.put(a)"]));
630 assert_eq!(rows[1].1, v(&["items.put(b)"]));
631 assert_ne!(h.rows[0].writes[0].span, h.rows[1].writes[0].span);
632 }
633
634 #[test]
635 fn a_lambda_parameter_shadows_a_field_and_a_nested_write_is_not_a_read() {
636 let src = r#"context demo
637agent A {
638 key id: String
639 store count: Cell[Int]
640 store items: Map[String, Int]
641 on call go(xs: List[Int], k: String) -> Effect[Int] {
642 let ys = xs.map((count) => count + 1)
643 let _ = ignore(items.put(k, 1))
644 ys.size()
645 }
646}
647"#;
648 let all = effects(src, "A", &[]);
649 let rows = summary(handler(&all, "go"));
650 assert_eq!(rows.len(), 1);
651 assert_eq!(rows[0].1, v(&[]), "a nested write is out of scope");
652 assert_eq!(rows[0].2, v(&[]), "neither `count` nor `items` is read");
653 }
654
655 #[test]
656 fn a_handler_past_the_budget_is_truncated() {
657 let choices: String = (0..9)
660 .map(|i| {
661 format!(
662 " let _ = if c{i} {{ a := {i}\n () }} else {{ b := {i}\n () }}\n"
663 )
664 })
665 .collect();
666 let params: Vec<String> = (0..9).map(|i| format!("c{i}: Bool")).collect();
667 let src = format!(
668 "context demo\nagent A {{\n key id: String\n store a: Cell[Int]\n store b: Cell[Int]\n on call go({}) -> Effect[()] {{\n{choices} ()\n }}\n}}\n",
669 params.join(", ")
670 );
671 let all = effects(&src, "A", &[]);
672 let go = handler(&all, "go");
673 assert!(go.truncated);
674 assert!(go.rows.is_empty());
675 }
676}