1use std::collections::HashMap;
30
31use bynk_check::analysis::ContextSequenceInfo;
32use bynk_check::ast_text::{branch_outcome, pattern_summary, peel_paren};
33use bynk_syntax::ast::*;
34use bynk_syntax::span::Span;
35
36#[derive(Debug, Clone, Copy)]
38pub enum HandlerOwner<'a> {
39 Service(&'a str),
40 Agent(&'a str),
41}
42
43const MAX_BLOCK_DEPTH: u32 = 2;
47
48#[derive(Debug, Clone, Default, PartialEq)]
49pub struct SequenceModel {
50 pub participants: Vec<Participant>,
51 pub messages: Vec<Message>,
52 pub blocks: Vec<AltBlock>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct Participant {
57 pub id: u32,
58 pub kind: ParticipantKind,
59 pub name: String,
60 pub span: Option<Span>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ParticipantKind {
67 Entry,
68 Capability,
69 Context,
70 Agent,
71 Actor,
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct Message {
81 pub from: u32,
82 pub to: u32,
83 pub kind: MessageKind,
84 pub label: String,
85 pub span: Span,
86 pub block: Option<u32>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MessageKind {
91 Call,
92 Return,
93 Send,
95 StateWrite,
101}
102
103#[derive(Debug, Clone, PartialEq)]
104pub struct AltBlock {
105 pub id: u32,
106 pub kind: AltKind,
107 pub branches: Vec<Branch>,
109 pub span: Span,
110 pub parent: Option<u32>,
111 pub parent_branch: Option<u32>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum AltKind {
122 If,
123 Match,
124 Collapsed,
125}
126
127#[derive(Debug, Clone, PartialEq)]
128pub struct Branch {
129 pub label: String,
130 pub message_ids: Vec<usize>,
131 pub reply: Option<String>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum Arrow {
152 Awaited,
154 FireAndForget,
156}
157
158const ENTRY_ID: u32 = 0;
159
160#[derive(Debug, Clone, Copy)]
164struct BlockCtx {
165 id: u32,
166 branch: u32,
167}
168
169pub fn sequence_model(
183 handler: &Handler,
184 owner: HandlerOwner<'_>,
185 default_given: &[CapRef],
186 default_by: Option<&ByClause>,
187 info: Option<&ContextSequenceInfo>,
188) -> SequenceModel {
189 let given = if handler.given.is_empty() {
190 default_given
191 } else {
192 &handler.given
193 };
194 let by = handler.by_clause.as_ref().or(default_by);
195 let mut b = Builder::new(entry_label(handler, owner, by.is_some()), given, info);
196 if let HandlerOwner::Agent(name) = owner
197 && let Some(agent) = info.and_then(|i| i.agents.get(name))
198 {
199 b.store = bynk_check::store_write::store_kinds(agent);
200 b.bound = handler.params.iter().map(|p| p.name.name.clone()).collect();
202 }
203 if let Some(by) = by {
204 b.add_actor(actor_name(by), by.span);
209 b.emit_request(discriminator(handler), handler.span);
210 }
211 b.walk_block(&handler.body, None, 0, true);
214 b.finish()
215}
216
217fn discriminator(handler: &Handler) -> String {
221 match &handler.kind {
222 HandlerKind::Call => match handler.method_name.as_ref() {
223 Some(m) => m.name.clone(),
224 None => "call".to_string(),
225 },
226 HandlerKind::Http { method, path } => format!("{} {}", method.as_str(), path),
227 HandlerKind::Cron { expr } => format!("cron \"{expr}\""),
228 HandlerKind::Message => "message".to_string(),
229 HandlerKind::Open => "open".to_string(),
230 HandlerKind::Close => "close".to_string(),
231 HandlerKind::Event => "event".to_string(),
232 HandlerKind::Rejected => "rejected".to_string(),
233 }
234}
235
236fn actor_name(by: &ByClause) -> String {
239 by.actors
240 .iter()
241 .map(|a| a.name.as_str())
242 .collect::<Vec<_>>()
243 .join(" | ")
244}
245
246fn entry_label(handler: &Handler, owner: HandlerOwner<'_>, has_actor: bool) -> String {
251 match owner {
252 HandlerOwner::Service(name) if has_actor => name.to_string(),
253 HandlerOwner::Agent(name) if has_actor => name.to_string(),
254 HandlerOwner::Service(name) => format!("{name} {}", discriminator(handler)),
255 HandlerOwner::Agent(name) => format!("{name}.{}", discriminator(handler)),
256 }
257}
258
259struct Builder<'a> {
260 given: &'a [CapRef],
261 info: Option<&'a ContextSequenceInfo>,
262 participants: Vec<Participant>,
263 messages: Vec<Message>,
264 blocks: Vec<AltBlock>,
265 actor: Option<u32>,
270 store: HashMap<String, String>,
273 bound: Vec<String>,
276}
277
278impl<'a> Builder<'a> {
279 fn new(
280 entry_label: String,
281 given: &'a [CapRef],
282 info: Option<&'a ContextSequenceInfo>,
283 ) -> Self {
284 Builder {
285 given,
286 info,
287 participants: vec![Participant {
288 id: ENTRY_ID,
289 kind: ParticipantKind::Entry,
290 name: entry_label,
291 span: None,
292 }],
293 messages: Vec::new(),
294 blocks: Vec::new(),
295 actor: None,
296 store: HashMap::new(),
297 bound: Vec::new(),
298 }
299 }
300
301 fn add_actor(&mut self, name: String, span: Span) {
306 let id = self.participants.len() as u32;
307 self.participants.insert(
308 0,
309 Participant {
310 id,
311 kind: ParticipantKind::Actor,
312 name,
313 span: Some(span),
314 },
315 );
316 self.actor = Some(id);
317 }
318
319 fn emit_request(&mut self, label: String, handler_span: Span) {
324 let Some(actor) = self.actor else { return };
325 self.messages.push(Message {
326 from: actor,
327 to: ENTRY_ID,
328 kind: MessageKind::Call,
329 label,
330 span: handler_span,
331 block: None,
332 });
333 }
334
335 fn emit_reply(&mut self, outcome: String, span: Span, current_block: Option<BlockCtx>) {
339 let Some(actor) = self.actor else { return };
340 self.messages.push(Message {
341 from: ENTRY_ID,
342 to: actor,
343 kind: MessageKind::Return,
344 label: outcome,
345 span,
346 block: current_block.map(|c| c.id),
347 });
348 }
349
350 fn finish(self) -> SequenceModel {
351 SequenceModel {
352 participants: self.participants,
353 messages: self.messages,
354 blocks: self.blocks,
355 }
356 }
357
358 fn participant_id(&mut self, kind: ParticipantKind, name: &str, span: Span) -> u32 {
359 if let Some(p) = self
360 .participants
361 .iter()
362 .find(|p| p.kind == kind && p.name == name)
363 {
364 return p.id;
365 }
366 let id = self.participants.len() as u32;
367 self.participants.push(Participant {
368 id,
369 kind,
370 name: name.to_string(),
371 span: Some(span),
372 });
373 id
374 }
375
376 fn walk_block(
383 &mut self,
384 block: &Block,
385 current_block: Option<BlockCtx>,
386 depth: u32,
387 ret: bool,
388 ) {
389 let scope = self.bound.len();
391 for stmt in &block.statements {
392 match stmt {
393 Statement::EffectLet(l) => {
394 self.walk_value(&l.value, current_block, depth, Some(Arrow::Awaited), false);
395 self.bound.push(l.name.name.clone());
396 }
397 Statement::Do(d) => {
398 self.walk_value(&d.value, current_block, depth, Some(Arrow::Awaited), false)
399 }
400 Statement::Send(s) => self.walk_value(
401 &s.value,
402 current_block,
403 depth,
404 Some(Arrow::FireAndForget),
405 false,
406 ),
407 Statement::Let(l) => {
408 self.walk_value(&l.value, current_block, depth, None, false);
409 self.bound.push(l.name.name.clone());
410 }
411 Statement::Expect(e) => {
412 self.walk_value(&e.value, current_block, depth, None, false)
413 }
414 Statement::Assign(a) => {
415 self.walk_value(&a.value, current_block, depth, None, false);
416 self.emit_assign(a, current_block);
417 }
418 Statement::Advance(a) => {
419 self.walk_value(&a.amount, current_block, depth, None, false)
420 }
421 }
422 }
423 self.walk_value(&block.tail, current_block, depth, None, ret);
424 self.bound.truncate(scope);
425 }
426
427 fn walk_value(
436 &mut self,
437 expr: &Expr,
438 current_block: Option<BlockCtx>,
439 depth: u32,
440 arrow: Option<Arrow>,
441 ret: bool,
442 ) {
443 let inner = peel_paren(expr);
444 match &inner.kind {
445 ExprKind::If { .. } => self.walk_if(inner, current_block, depth, ret),
448 ExprKind::Match { arms, .. } => {
449 self.walk_match(inner.span, arms, current_block, depth, ret)
450 }
451 ExprKind::Block(b) => self.walk_block(b, current_block, depth, ret),
452 ExprKind::Call { .. } | ExprKind::MethodCall { .. } => {
453 if self.emit_store_op(inner, current_block) {
458 return;
459 }
460 if let Some(arrow) = arrow {
461 self.classify_call(inner, arrow, current_block);
462 }
463 self.maybe_reply(inner, current_block, ret);
464 }
465 _ => self.maybe_reply(inner, current_block, ret),
466 }
467 }
468
469 fn maybe_reply(&mut self, expr: &Expr, current_block: Option<BlockCtx>, ret: bool) {
473 if !ret {
474 return;
475 }
476 if let Some(outcome) = branch_outcome(expr) {
477 self.emit_reply(outcome, expr.span, current_block);
478 }
479 }
480
481 fn walk_if(&mut self, if_expr: &Expr, current_block: Option<BlockCtx>, depth: u32, ret: bool) {
484 let ExprKind::If {
485 cond,
486 then_block,
487 else_block,
488 } = &if_expr.kind
489 else {
490 return;
491 };
492 let span = if_expr.span;
493 if depth >= MAX_BLOCK_DEPTH {
494 self.push_collapsed(span, current_block);
495 return;
496 }
497 let id = self.blocks.len() as u32;
498 self.blocks.push(AltBlock {
499 id,
500 kind: AltKind::If,
501 branches: Vec::new(),
502 span,
503 parent: current_block.map(|c| c.id),
504 parent_branch: current_block.map(|c| c.branch),
505 });
506 let then_start = self.messages.len();
507 self.walk_block(then_block, Some(BlockCtx { id, branch: 0 }), depth + 1, ret);
508 let then_ids = (then_start..self.messages.len()).collect();
509 let mut branches = vec![Branch {
512 label: bynk_fmt::expr_to_string(cond),
513 message_ids: then_ids,
514 reply: self.branch_reply(&then_block.tail),
515 }];
516 if !else_block.is_synth_unit() {
522 let else_start = self.messages.len();
523 self.walk_block(else_block, Some(BlockCtx { id, branch: 1 }), depth + 1, ret);
524 branches.push(Branch {
525 label: "otherwise".to_string(),
526 message_ids: (else_start..self.messages.len()).collect(),
527 reply: self.branch_reply(&else_block.tail),
528 });
529 }
530 self.blocks[id as usize].branches = branches;
531 }
532
533 fn branch_reply(&self, tail: &Expr) -> Option<String> {
538 if self.actor.is_some() || self.store_op_label(peel_paren(tail)).is_some() {
539 None
541 } else {
542 branch_outcome(tail)
543 }
544 }
545
546 fn walk_match(
547 &mut self,
548 span: Span,
549 arms: &[MatchArm],
550 current_block: Option<BlockCtx>,
551 depth: u32,
552 ret: bool,
553 ) {
554 if depth >= MAX_BLOCK_DEPTH {
555 self.push_collapsed(span, current_block);
556 return;
557 }
558 let id = self.blocks.len() as u32;
559 self.blocks.push(AltBlock {
560 id,
561 kind: AltKind::Match,
562 branches: Vec::new(),
563 span,
564 parent: current_block.map(|c| c.id),
565 parent_branch: current_block.map(|c| c.branch),
566 });
567 let mut branches = Vec::with_capacity(arms.len());
568 for (arm_index, arm) in arms.iter().enumerate() {
569 let start = self.messages.len();
570 let branch_ctx = Some(BlockCtx {
571 id,
572 branch: arm_index as u32,
573 });
574 let scope = self.bound.len();
578 self.bound.extend(
579 arm.pattern
580 .bound_names()
581 .into_iter()
582 .map(|n| n.name.clone()),
583 );
584 let reply = match &arm.body {
585 MatchBody::Expr(e) => {
586 self.walk_value(e, branch_ctx, depth + 1, None, ret);
587 self.branch_reply(e)
588 }
589 MatchBody::Block(b) => {
590 self.walk_block(b, branch_ctx, depth + 1, ret);
591 self.branch_reply(&b.tail)
592 }
593 };
594 self.bound.truncate(scope);
595 branches.push(Branch {
596 label: pattern_summary(&arm.pattern),
597 message_ids: (start..self.messages.len()).collect(),
598 reply,
599 });
600 }
601 self.blocks[id as usize].branches = branches;
602 }
603
604 fn emit_assign(&mut self, a: &AssignStmt, current_block: Option<BlockCtx>) {
608 if !self.store.contains_key(&a.target.name) {
611 return;
612 }
613 let label = format!(
614 "{} := {}",
615 a.target.name,
616 truncate(&bynk_fmt::expr_to_string(&a.value))
617 );
618 self.push_state_write(label, a.span, current_block);
619 }
620
621 fn emit_store_op(&mut self, expr: &Expr, current_block: Option<BlockCtx>) -> bool {
626 let Some(label) = self.store_op_label(expr) else {
627 return false;
628 };
629 self.push_state_write(label, expr.span, current_block);
630 true
631 }
632
633 fn store_op_label(&self, expr: &Expr) -> Option<String> {
636 let w = bynk_check::store_write::store_write(&self.store, &self.bound, expr)?;
637 Some(format!(
638 "{}.{}",
639 w.field.name,
640 op_call_label(&w.method.name, w.args)
641 ))
642 }
643
644 fn push_state_write(&mut self, label: String, span: Span, current_block: Option<BlockCtx>) {
645 self.messages.push(Message {
646 from: ENTRY_ID,
647 to: ENTRY_ID,
648 kind: MessageKind::StateWrite,
649 label,
650 span,
651 block: current_block.map(|c| c.id),
652 });
653 }
654
655 fn push_collapsed(&mut self, span: Span, current_block: Option<BlockCtx>) {
656 let id = self.blocks.len() as u32;
657 self.blocks.push(AltBlock {
658 id,
659 kind: AltKind::Collapsed,
660 branches: Vec::new(),
661 span,
662 parent: current_block.map(|c| c.id),
663 parent_branch: current_block.map(|c| c.branch),
664 });
665 }
666
667 fn classify_call(&mut self, expr: &Expr, arrow: Arrow, current_block: Option<BlockCtx>) {
668 let Some((target, label)) = self.classify_target(expr) else {
669 return;
672 };
673 let block = current_block.map(|c| c.id);
674 match arrow {
675 Arrow::FireAndForget => {
676 self.messages.push(Message {
677 from: ENTRY_ID,
678 to: target,
679 kind: MessageKind::Send,
680 label,
681 span: expr.span,
682 block,
683 });
684 }
685 Arrow::Awaited => {
686 self.messages.push(Message {
687 from: ENTRY_ID,
688 to: target,
689 kind: MessageKind::Call,
690 label,
691 span: expr.span,
692 block,
693 });
694 self.messages.push(Message {
695 from: target,
696 to: ENTRY_ID,
697 kind: MessageKind::Return,
698 label: String::new(),
699 span: expr.span,
700 block,
701 });
702 }
703 }
704 }
705
706 fn classify_target(&mut self, expr: &Expr) -> Option<(u32, String)> {
722 match &expr.kind {
723 ExprKind::MethodCall {
724 receiver,
725 method,
726 args,
727 ..
728 } => match &receiver.kind {
729 ExprKind::Call { name, .. }
730 if self.info.is_some_and(|i| i.agents.contains_key(&name.name)) =>
731 {
732 let id = self.participant_id(ParticipantKind::Agent, &name.name, name.span);
733 Some((id, call_label(&method.name, args)))
734 }
735 ExprKind::Ident(id) => self.classify_static(&id.name, &method.name, args, id.span),
736 _ => None,
737 },
738 _ => None,
739 }
740 }
741
742 fn classify_static(
748 &mut self,
749 name: &str,
750 method: &str,
751 args: &[Expr],
752 span: Span,
753 ) -> Option<(u32, String)> {
754 if self.given.iter().any(|c| c.key() == name) {
755 let id = self.participant_id(ParticipantKind::Capability, name, span);
756 return Some((id, call_label(method, args)));
757 }
758 self.classify_cross_context(name, method, args, span)
759 }
760
761 fn classify_cross_context(
762 &mut self,
763 prefix: &str,
764 method: &str,
765 args: &[Expr],
766 span: Span,
767 ) -> Option<(u32, String)> {
768 let ctx_name = self.info?.cross_context.resolve_prefix(prefix)?;
769 let id = self.participant_id(ParticipantKind::Context, &ctx_name, span);
770 Some((id, call_label(method, args)))
771 }
772}
773
774const WRITE_LABEL_MAX: usize = 32;
776
777fn truncate(text: &str) -> String {
779 if text.chars().count() <= WRITE_LABEL_MAX {
780 text.to_string()
781 } else {
782 let kept: String = text.chars().take(WRITE_LABEL_MAX - 1).collect();
783 format!("{kept}…")
784 }
785}
786
787fn op_call_label(method: &str, args: &[Expr]) -> String {
792 let mut rendered = Vec::with_capacity(args.len());
793 for (i, arg) in args.iter().enumerate() {
794 let simple = matches!(
795 peel_paren(arg).kind,
796 ExprKind::Ident(_)
797 | ExprKind::IntLit { .. }
798 | ExprKind::StrLit(_)
799 | ExprKind::BoolLit(_)
800 );
801 if i == 0 && simple {
802 rendered.push(truncate(&bynk_fmt::expr_to_string(arg)));
803 } else {
804 rendered.push("…".to_string());
805 break;
806 }
807 }
808 format!("{method}({})", rendered.join(", "))
809}
810
811fn call_label(method: &str, args: &[Expr]) -> String {
812 let rendered: Vec<String> = args.iter().map(bynk_fmt::expr_to_string).collect();
813 format!("{method}({})", rendered.join(", "))
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use std::fs;
820 use std::path::PathBuf;
821
822 fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
828 let root = std::env::temp_dir().join(format!(
829 "bynk-ide-sequence-test-{test_name}-{}",
830 std::process::id()
831 ));
832 let _ = fs::remove_dir_all(&root);
833 fs::create_dir_all(&root).expect("create test root");
834 for (rel, contents) in files {
835 let p = root.join(rel);
836 if let Some(parent) = p.parent() {
837 fs::create_dir_all(parent).expect("create parent");
838 }
839 fs::write(&p, contents).expect("write file");
840 }
841 root
842 }
843
844 fn parse_context(text: &str) -> Context {
845 let tokens = bynk_syntax::lexer::tokenize(text).expect("tokenize");
846 let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
847 assert!(errs.is_empty(), "parse errors: {errs:?}");
848 match unit.expect("parsed unit") {
849 SourceUnit::Context(c) => c,
850 _ => panic!("expected a context"),
851 }
852 }
853
854 fn find_service<'a>(ctx: &'a Context, name: &str) -> &'a ServiceDecl {
855 ctx.items
856 .iter()
857 .find_map(|i| match i {
858 CommonsItem::Service(s) if s.name.name == name => Some(s),
859 _ => None,
860 })
861 .unwrap_or_else(|| panic!("service {name} not found"))
862 }
863
864 const RATELIMIT_SRC: &str = r#"context ratelimit
871
872consumes bynk { Clock }
873
874type ClientId = String where NonEmpty
875
876type RateView = {
877 allowed: Bool,
878 remaining: Int,
879 resetAt: Int,
880}
881
882agent Limiter {
883 key client: ClientId
884
885 store count: Cell[Int]
886
887 on call hit(now: Int) -> Effect[RateView] {
888 let _ <- count.update((c) => c + 1)
889 RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
890 }
891}
892
893service api from http {
894 on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
895 let now <- Clock.now()
896 let view <- Limiter(client).hit(now.toEpochMillis())
897 if view.allowed {
898 Ok(view)
899 } else {
900 TooManyRequests("rate limit exceeded")
901 }
902 }
903}
904"#;
905
906 #[test]
907 fn rate_limiter_get_check_client_classifies_capability_and_agent_and_gates_the_return() {
908 let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
909 let diag = crate::testkit::diagnose_project(&root);
910 let info = diag
911 .sequence_info
912 .get("ratelimit")
913 .expect("sequence_info entry for ratelimit");
914
915 let ctx = parse_context(RATELIMIT_SRC);
916 let svc = find_service(&ctx, "api");
917 let handler = &svc.handlers[0];
918
919 let model = sequence_model(
920 handler,
921 HandlerOwner::Service("api"),
922 &svc.default_given,
923 svc.default_by.as_ref(),
924 Some(info),
925 );
926
927 let kinds: Vec<(ParticipantKind, &str)> = model
931 .participants
932 .iter()
933 .map(|p| (p.kind, p.name.as_str()))
934 .collect();
935 assert_eq!(
936 kinds,
937 vec![
938 (ParticipantKind::Actor, "Visitor"),
939 (ParticipantKind::Entry, "api"),
940 (ParticipantKind::Capability, "Clock"),
941 (ParticipantKind::Agent, "Limiter"),
942 ]
943 );
944 let actor_id = model.participants[0].id;
945
946 let req = &model.messages[0];
948 assert_eq!(req.kind, MessageKind::Call);
949 assert_eq!((req.from, req.to), (actor_id, ENTRY_ID));
950 assert_eq!(req.label, "GET /check/:client");
951
952 assert_eq!(
954 model.messages.len(),
955 7,
956 "request + Clock Call/Return + Limiter Call/Return + a reply-to-actor per branch"
957 );
958
959 assert_eq!(model.blocks.len(), 1);
960 assert_eq!(model.blocks[0].kind, AltKind::If);
961 assert_eq!(model.blocks[0].branches.len(), 2);
962 let labels: Vec<&str> = model.blocks[0]
966 .branches
967 .iter()
968 .map(|b| b.label.as_str())
969 .collect();
970 assert_eq!(labels, vec!["view.allowed", "otherwise"]);
971 assert!(
972 model.blocks[0].branches.iter().all(|b| b.reply.is_none()),
973 "with an actor the outcome is a message, not a note"
974 );
975 let branch_replies: Vec<(MessageKind, u32, u32, &str)> = model.blocks[0]
976 .branches
977 .iter()
978 .flat_map(|b| &b.message_ids)
979 .map(|&i| {
980 let m = &model.messages[i];
981 (m.kind, m.from, m.to, m.label.as_str())
982 })
983 .collect();
984 assert_eq!(
985 branch_replies,
986 vec![
987 (MessageKind::Return, ENTRY_ID, actor_id, "Ok(view)"),
988 (
989 MessageKind::Return,
990 ENTRY_ID,
991 actor_id,
992 "TooManyRequests(\"rate limit exceeded\")",
993 ),
994 ],
995 "each branch replies its outcome to the actor"
996 );
997 }
998
999 const PLATFORM_SRC: &str = r#"context platform
1001
1002service Pinger {
1003 on call(n: Int) -> Effect[Int] {
1004 n
1005 }
1006}
1007"#;
1008 const CONSUMER_SRC: &str = r#"context consumer
1009
1010consumes platform
1011
1012service api {
1013 on call(n: Int) -> Effect[Int] {
1014 let v <- platform.Pinger(n)
1015 v
1016 }
1017}
1018"#;
1019
1020 #[test]
1021 fn cross_context_call_is_boundary_stop() {
1022 let root = setup_project(
1023 "crossctx",
1024 &[
1025 ("platform.bynk", PLATFORM_SRC),
1026 ("consumer.bynk", CONSUMER_SRC),
1027 ],
1028 );
1029 let diag = crate::testkit::diagnose_project(&root);
1030 let info = diag
1031 .sequence_info
1032 .get("consumer")
1033 .expect("sequence_info entry for consumer");
1034
1035 let ctx = parse_context(CONSUMER_SRC);
1036 let svc = find_service(&ctx, "api");
1037 let handler = &svc.handlers[0];
1038 let model = sequence_model(
1039 handler,
1040 HandlerOwner::Service("api"),
1041 &svc.default_given,
1042 svc.default_by.as_ref(),
1043 Some(info),
1044 );
1045
1046 assert_eq!(model.participants.len(), 2, "Entry + the consumed context");
1047 assert_eq!(model.participants[1].kind, ParticipantKind::Context);
1048 assert_eq!(model.participants[1].name, "platform");
1049 assert_eq!(
1050 model.messages.len(),
1051 2,
1052 "one Call + one Return — the consumed service's own body is never walked"
1053 );
1054 assert_eq!(model.messages[0].kind, MessageKind::Call);
1055 assert_eq!(model.messages[1].kind, MessageKind::Return);
1056 }
1057
1058 const MISC_SRC: &str = r#"context misc
1061
1062consumes bynk { Clock, Logger }
1063
1064fn double(x: Int) -> Int {
1065 x * 2
1066}
1067
1068service fireService {
1069 on call(n: Int) -> Effect[()] given Logger {
1070 ~> Logger.info("hi")
1071 Effect.pure(())
1072 }
1073}
1074
1075service localService {
1076 on call(n: Int) -> Effect[Int] {
1077 double(n)
1078 }
1079}
1080
1081service nestedService {
1082 on call(n: Int) -> Effect[Int] given Clock {
1083 let now <- Clock.now()
1084 if n > 0 {
1085 if n > 10 {
1086 if n > 100 {
1087 now.toEpochMillis()
1088 } else {
1089 1
1090 }
1091 } else {
1092 2
1093 }
1094 } else {
1095 3
1096 }
1097 }
1098}
1099"#;
1100
1101 fn misc_info(diag: &crate::ProjectDiagnostics) -> bynk_check::analysis::ContextSequenceInfo {
1102 diag.sequence_info
1103 .get("misc")
1104 .cloned()
1105 .expect("sequence_info entry for misc")
1106 }
1107
1108 #[test]
1109 fn fire_and_forget_send_has_no_paired_return() {
1110 let root = setup_project("misc-send", &[("misc.bynk", MISC_SRC)]);
1111 let diag = crate::testkit::diagnose_project(&root);
1112 let info = misc_info(&diag);
1113
1114 let ctx = parse_context(MISC_SRC);
1115 let svc = find_service(&ctx, "fireService");
1116 let handler = &svc.handlers[0];
1117 let model = sequence_model(
1118 handler,
1119 HandlerOwner::Service("fireService"),
1120 &svc.default_given,
1121 svc.default_by.as_ref(),
1122 Some(&info),
1123 );
1124
1125 assert_eq!(model.participants.len(), 2);
1126 assert_eq!(model.participants[1].kind, ParticipantKind::Capability);
1127 assert_eq!(model.participants[1].name, "Logger");
1128 assert_eq!(model.messages.len(), 1, "a Send has no paired Return");
1129 assert_eq!(model.messages[0].kind, MessageKind::Send);
1130 }
1131
1132 #[test]
1133 fn degenerate_handler_with_only_local_calls_has_no_lifelines() {
1134 let root = setup_project("misc-local", &[("misc.bynk", MISC_SRC)]);
1135 let diag = crate::testkit::diagnose_project(&root);
1136 let info = misc_info(&diag);
1137
1138 let ctx = parse_context(MISC_SRC);
1139 let svc = find_service(&ctx, "localService");
1140 let handler = &svc.handlers[0];
1141 let model = sequence_model(
1142 handler,
1143 HandlerOwner::Service("localService"),
1144 &svc.default_given,
1145 svc.default_by.as_ref(),
1146 Some(&info),
1147 );
1148
1149 assert_eq!(model.participants.len(), 1);
1150 assert_eq!(model.participants[0].kind, ParticipantKind::Entry);
1151 assert!(model.messages.is_empty());
1152 assert!(model.blocks.is_empty());
1153 }
1154
1155 #[test]
1156 fn nested_if_collapses_past_the_depth_budget() {
1157 let root = setup_project("misc-nested", &[("misc.bynk", MISC_SRC)]);
1158 let diag = crate::testkit::diagnose_project(&root);
1159 let info = misc_info(&diag);
1160
1161 let ctx = parse_context(MISC_SRC);
1162 let svc = find_service(&ctx, "nestedService");
1163 let handler = &svc.handlers[0];
1164 let model = sequence_model(
1165 handler,
1166 HandlerOwner::Service("nestedService"),
1167 &svc.default_given,
1168 svc.default_by.as_ref(),
1169 Some(&info),
1170 );
1171
1172 let kinds: Vec<AltKind> = model.blocks.iter().map(|b| b.kind).collect();
1178 assert_eq!(kinds, vec![AltKind::If, AltKind::If, AltKind::Collapsed]);
1179 assert!(
1180 model.blocks[2].branches.is_empty(),
1181 "a Collapsed block carries no branches"
1182 );
1183 assert_eq!(model.blocks[0].parent, None);
1189 assert_eq!(model.blocks[0].parent_branch, None);
1190 assert_eq!(model.blocks[1].parent, Some(0));
1191 assert_eq!(model.blocks[1].parent_branch, Some(0));
1192 assert_eq!(model.blocks[2].parent, Some(1));
1193 assert_eq!(model.blocks[2].parent_branch, Some(0));
1194 }
1195
1196 const SERVICE_GIVEN_SRC: &str = r#"context svcgiven
1202
1203consumes bynk { Clock }
1204
1205service api from http by Visitor given Clock {
1206 on GET("/now") () -> Effect[HttpResult[Int]] {
1207 let now <- Clock.now()
1208 Ok(now.toEpochMillis())
1209 }
1210}
1211"#;
1212
1213 #[test]
1214 fn service_level_given_default_is_inherited_by_a_handler_without_its_own() {
1215 let root = setup_project("svcgiven", &[("svcgiven.bynk", SERVICE_GIVEN_SRC)]);
1216 let diag = crate::testkit::diagnose_project(&root);
1217 let info = diag
1218 .sequence_info
1219 .get("svcgiven")
1220 .expect("sequence_info entry for svcgiven");
1221
1222 let ctx = parse_context(SERVICE_GIVEN_SRC);
1223 let svc = find_service(&ctx, "api");
1224 let handler = &svc.handlers[0];
1225 assert!(
1228 handler.given.is_empty(),
1229 "fixture handler must omit its own `given`"
1230 );
1231 assert_eq!(svc.default_given.len(), 1, "service-level `given Clock`");
1232 assert!(
1233 handler.by_clause.is_none() && svc.default_by.is_some(),
1234 "fixture handler must inherit the service-level `by Visitor`"
1235 );
1236
1237 let model = sequence_model(
1238 handler,
1239 HandlerOwner::Service("api"),
1240 &svc.default_given,
1241 svc.default_by.as_ref(),
1242 Some(info),
1243 );
1244
1245 let kinds: Vec<(ParticipantKind, &str)> = model
1246 .participants
1247 .iter()
1248 .map(|p| (p.kind, p.name.as_str()))
1249 .collect();
1250 assert_eq!(
1251 kinds,
1252 vec![
1253 (ParticipantKind::Actor, "Visitor"),
1256 (ParticipantKind::Entry, "api"),
1257 (ParticipantKind::Capability, "Clock"),
1258 ],
1259 "Clock must classify via the inherited `given`, and Visitor via the \
1260 inherited `by` — with only `handler.given`/`handler.by_clause` both \
1261 would be dropped"
1262 );
1263
1264 let actor_id = model.participants[0].id;
1267 let last = model.messages.last().expect("at least one message");
1268 assert_eq!(last.kind, MessageKind::Return);
1269 assert_eq!((last.from, last.to), (ENTRY_ID, actor_id));
1270 assert_eq!(last.label, "Ok(now.toEpochMillis())");
1271 }
1272
1273 const OUTCOME_SRC: &str = r#"context outcome
1278
1279consumes bynk { Logger }
1280
1281service guardSvc {
1282 on call(flag: Bool) -> Effect[()] given Logger {
1283 if flag {
1284 ~> Logger.info("hi")
1285 }
1286 }
1287}
1288
1289service routeSvc {
1290 on call(x: Int) -> Effect[Int] {
1291 match x {
1292 0 => 100
1293 _ => x
1294 }
1295 }
1296}
1297"#;
1298
1299 #[test]
1300 fn else_less_if_is_a_single_branch_opt_and_match_arms_capture_replies() {
1301 let ctx = parse_context(OUTCOME_SRC);
1306 let guard_svc = find_service(&ctx, "guardSvc");
1307 let route_svc = find_service(&ctx, "routeSvc");
1308
1309 let guard = &guard_svc.handlers[0];
1313 let gm = sequence_model(
1314 guard,
1315 HandlerOwner::Service("guardSvc"),
1316 &guard_svc.default_given,
1317 guard_svc.default_by.as_ref(),
1318 None,
1319 );
1320 assert_eq!(gm.blocks.len(), 1);
1321 assert_eq!(gm.blocks[0].kind, AltKind::If);
1322 assert_eq!(
1323 gm.blocks[0].branches.len(),
1324 1,
1325 "an else-less `if` renders as an `opt`, not an `alt` with an empty second branch"
1326 );
1327 assert_eq!(gm.blocks[0].branches[0].label, "flag");
1328 assert_eq!(
1329 gm.blocks[0].branches[0].reply, None,
1330 "a unit tail has no reply"
1331 );
1332 assert_eq!(
1333 gm.blocks[0].branches[0].message_ids.len(),
1334 1,
1335 "the `~>` Send"
1336 );
1337
1338 let route = &route_svc.handlers[0];
1341 let rm = sequence_model(
1342 route,
1343 HandlerOwner::Service("routeSvc"),
1344 &route_svc.default_given,
1345 route_svc.default_by.as_ref(),
1346 None,
1347 );
1348 assert_eq!(rm.blocks.len(), 1);
1349 assert_eq!(rm.blocks[0].kind, AltKind::Match);
1350 let replies: Vec<Option<&str>> = rm.blocks[0]
1351 .branches
1352 .iter()
1353 .map(|b| b.reply.as_deref())
1354 .collect();
1355 assert_eq!(replies, vec![Some("100"), Some("x")]);
1356 }
1357
1358 const ORDER_SRC: &str = r#"context orders
1360
1361type OrderStatus = enum { Pending, Placed, Cancelled }
1362type OrderError = enum { AlreadyPlaced, AlreadyCancelled }
1363
1364agent Counter {
1365 key id: String
1366
1367 store total: Cell[Int]
1368
1369 on call bump() -> Effect[Int] {
1370 total := total + 1
1371 total
1372 }
1373}
1374
1375agent Order {
1376 key id: String
1377
1378 store status: Cell[OrderStatus] = Pending
1379 store items: Map[String, Int]
1380 store tally: Cell[Int]
1381
1382 on call place() -> Effect[Result[(), OrderError]] {
1383 match status {
1384 Pending => {
1385 status := Placed
1386 Ok(())
1387 }
1388 Placed => Err(AlreadyPlaced)
1389 Cancelled => Err(AlreadyCancelled)
1390 }
1391 }
1392
1393 on call add(sku: String, qty: Int) -> Effect[Bool] {
1394 let n <- Counter(sku).bump()
1395 let _ <- items.put(sku, qty + n)
1396 let _ <- tally.update((t) => t + qty)
1397 let known <- items.contains(sku)
1398 known
1399 }
1400
1401 on call bumpTally(n: Int) -> Effect[()] {
1402 tally.update((t) => t + n)
1403 }
1404
1405 on call putIfSome(k: String, found: Option[Int]) -> Effect[()] {
1406 match found {
1407 Some(v) => items.put(k, v),
1408 None => Effect.pure(()),
1409 }
1410 }
1411}
1412"#;
1413
1414 fn find_agent<'a>(ctx: &'a Context, name: &str) -> &'a AgentDecl {
1415 ctx.items
1416 .iter()
1417 .find_map(|i| match i {
1418 CommonsItem::Agent(a) if a.name.name == name => Some(a),
1419 _ => None,
1420 })
1421 .unwrap_or_else(|| panic!("agent {name} not found"))
1422 }
1423
1424 fn agent_model(test_name: &str, handler_src: &str, agent: &str, method: &str) -> SequenceModel {
1425 let root = setup_project(test_name, &[("orders.bynk", ORDER_SRC)]);
1426 let diag = crate::testkit::diagnose_project(&root);
1427 let info = diag
1428 .sequence_info
1429 .get("orders")
1430 .expect("sequence_info for orders");
1431 let ctx = parse_context(handler_src);
1432 let a = find_agent(&ctx, agent);
1433 let h = a
1434 .handlers
1435 .iter()
1436 .find(|h| h.method_name.as_ref().is_some_and(|m| m.name == method))
1437 .expect("handler");
1438 sequence_model(h, HandlerOwner::Agent(agent), &[], None, Some(info))
1439 }
1440
1441 fn writes(model: &SequenceModel) -> Vec<(&str, Option<u32>)> {
1442 model
1443 .messages
1444 .iter()
1445 .filter(|m| m.kind == MessageKind::StateWrite)
1446 .map(|m| (m.label.as_str(), m.block))
1447 .collect()
1448 }
1449
1450 #[test]
1451 fn an_assignment_in_an_agent_handler_is_a_state_write_inside_its_branch() {
1452 let model = agent_model("assign", ORDER_SRC, "Order", "place");
1453 assert_eq!(writes(&model), [("status := Placed", Some(0))]);
1454 let w = &model.messages[0];
1455 assert_eq!((w.from, w.to), (ENTRY_ID, ENTRY_ID));
1456 let pending = &model.blocks[0].branches[0];
1458 assert_eq!(pending.label, "Pending");
1459 assert_eq!(pending.message_ids, [0]);
1460 assert_eq!(pending.reply.as_deref(), Some("Ok(())"));
1461 assert!(model.blocks[0].branches[1].message_ids.is_empty());
1463 }
1464
1465 #[test]
1466 fn mutating_store_ops_are_state_writes_in_order_and_reads_are_not() {
1467 let model = agent_model("storeops", ORDER_SRC, "Order", "add");
1468 let kinds: Vec<(MessageKind, &str)> = model
1469 .messages
1470 .iter()
1471 .map(|m| (m.kind, m.label.as_str()))
1472 .collect();
1473 assert_eq!(
1474 kinds,
1475 [
1476 (MessageKind::Call, "bump()"),
1477 (MessageKind::Return, ""),
1478 (MessageKind::StateWrite, "items.put(sku, …)"),
1479 (MessageKind::StateWrite, "tally.update(…)"),
1480 ],
1481 "the agent call keeps its lifeline; `contains` is a read and shows nothing"
1482 );
1483 }
1484
1485 #[test]
1486 fn an_assignment_in_another_agent_is_still_a_write_of_its_own() {
1487 let model = agent_model("counter", ORDER_SRC, "Counter", "bump");
1488 assert_eq!(writes(&model), [("total := total + 1", None)]);
1489 }
1490
1491 #[test]
1492 fn a_store_field_shadowed_by_a_parameter_or_let_is_not_a_store_op() {
1493 let shadowing = r#"context orders
1495agent Order {
1496 key id: String
1497 store items: Map[String, Int]
1498 on call byParam(items: List[Int]) -> Effect[()] {
1499 let _ <- items.put("a", 1)
1500 ()
1501 }
1502 on call byLet() -> Effect[()] {
1503 let tally = Thing()
1504 let _ <- tally.update((t) => t)
1505 let _ <- items.remove("a")
1506 ()
1507 }
1508}
1509"#;
1510 assert!(writes(&agent_model("shadow-param", shadowing, "Order", "byParam")).is_empty());
1511 assert_eq!(
1512 writes(&agent_model("shadow-let", shadowing, "Order", "byLet")),
1513 [("items.remove(\"a\")", None)],
1514 "only the unshadowed field's write is a note"
1515 );
1516 }
1517
1518 #[test]
1519 fn a_long_right_hand_side_is_truncated() {
1520 assert_eq!(truncate("short"), "short");
1521 let long = "a".repeat(40);
1522 let t = truncate(&long);
1523 assert_eq!(t.chars().count(), WRITE_LABEL_MAX);
1524 assert!(t.ends_with('…'));
1525 }
1526
1527 #[test]
1528 fn a_service_handler_emits_no_state_writes() {
1529 let root = setup_project("svc-nowrites", &[("ratelimit.bynk", RATELIMIT_SRC)]);
1530 let diag = crate::testkit::diagnose_project(&root);
1531 let info = diag.sequence_info.get("ratelimit").expect("info");
1532 let ctx = parse_context(RATELIMIT_SRC);
1533 let svc = find_service(&ctx, "api");
1534 let model = sequence_model(
1535 &svc.handlers[0],
1536 HandlerOwner::Service("api"),
1537 &svc.default_given,
1538 svc.default_by.as_ref(),
1539 Some(info),
1540 );
1541 assert!(writes(&model).is_empty());
1542 }
1543
1544 #[test]
1545 fn the_rate_limiters_own_hit_handler_shows_its_cell_update() {
1546 let root = setup_project("limiter-hit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
1547 let diag = crate::testkit::diagnose_project(&root);
1548 let info = diag.sequence_info.get("ratelimit").expect("info");
1549 let ctx = parse_context(RATELIMIT_SRC);
1550 let a = find_agent(&ctx, "Limiter");
1551 let model = sequence_model(
1552 &a.handlers[0],
1553 HandlerOwner::Agent("Limiter"),
1554 &[],
1555 None,
1556 Some(info),
1557 );
1558 assert_eq!(writes(&model), [("count.update(…)", None)]);
1559 }
1560
1561 #[test]
1562 fn a_store_write_in_tail_position_is_a_note_and_not_also_a_reply() {
1563 let model = agent_model("tail-write", ORDER_SRC, "Order", "bumpTally");
1565 assert_eq!(writes(&model), [("tally.update(…)", None)]);
1566 assert_eq!(model.messages.len(), 1, "no reply duplicating the write");
1567 }
1568
1569 #[test]
1570 fn a_store_write_as_an_expression_match_arm_is_a_note_in_that_arm() {
1571 let model = agent_model("arm-write", ORDER_SRC, "Order", "putIfSome");
1573 assert_eq!(writes(&model), [("items.put(k, …)", Some(0))]);
1574 let arms = &model.blocks[0].branches;
1575 assert_eq!(arms[0].message_ids, [0]);
1576 assert_eq!(
1577 arms[0].reply, None,
1578 "the write is the arm's note, not its reply"
1579 );
1580 assert!(arms[1].message_ids.is_empty());
1581 assert_eq!(arms[1].reply.as_deref(), Some("Effect.pure(())"));
1582 }
1583
1584 #[test]
1585 fn an_assignment_to_a_name_that_is_not_a_store_field_is_not_labelled() {
1586 let src = r#"context orders
1588agent Order {
1589 key id: String
1590 store items: Map[String, Int]
1591 on call bad() -> Effect[()] {
1592 nope := 1
1593 ()
1594 }
1595}
1596"#;
1597 assert!(writes(&agent_model("bad-assign", src, "Order", "bad")).is_empty());
1598 }
1599}