Skip to main content

bynk_ide/
sequence.rs

1//! #846: the sequence-diagram query.
2//!
3//! Classifies a handler body's calls into runtime-participant lifelines —
4//! consumed capabilities, calls into consumed contexts, and agents (including
5//! same-context agents) — for the "Show Sequence Diagram" VS Code feature.
6//! Everything else (commons fns, context-local fns, methods, constructors)
7//! folds into the entry participant's own activation: no message is emitted
8//! for the call, and — because this repo's resolver does not inline call
9//! bodies either — a lifeline call written inside a commons `fn`'s own body is
10//! invisible to this walk. That is a stated Tier-1 limitation, not a bug: see
11//! `design/pending/sequence-diagram-846.md`.
12//!
13//! A pure, read-only IDE query: it never touches the checker's hot path
14//! (`bynk_check::checker::check_handler_body`) and is built on the exhaustive
15//! `expr_children`/`statement_exprs` walkers only insofar as this module's own
16//! statement dispatch mirrors their coverage of [`Statement`] — a new
17//! `Statement` variant is a compile error here, in `Builder::walk_block`'s
18//! `match`, not a silent gap.
19//!
20//! Cross-context/agent calls are boundary-stop (Decision C): one `Call` +
21//! one `Return` message: the callee's own body is never walked, even where
22//! reachable (an agent's handlers are visible via [`ContextSequenceInfo::agents`]).
23
24use bynk_check::analysis::ContextSequenceInfo;
25use bynk_syntax::ast::*;
26use bynk_syntax::span::Span;
27
28/// Which declaration owns the handler being diagrammed.
29#[derive(Debug, Clone, Copy)]
30pub enum HandlerOwner<'a> {
31    Service(&'a str),
32    Agent(&'a str),
33}
34
35/// Nesting budget for rendered `if`/`match` blocks (issue #846: "~2 levels").
36/// Beyond this depth the walk stops classifying calls and emits a single
37/// [`AltKind::Collapsed`] marker instead of recursing further.
38const MAX_BLOCK_DEPTH: u32 = 2;
39
40#[derive(Debug, Clone, Default, PartialEq)]
41pub struct SequenceModel {
42    pub participants: Vec<Participant>,
43    pub messages: Vec<Message>,
44    pub blocks: Vec<AltBlock>,
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct Participant {
49    pub id: u32,
50    pub kind: ParticipantKind,
51    pub name: String,
52    /// `None` for `Entry` — it has no single declaration site to jump to
53    /// (it *is* the handler).
54    pub span: Option<Span>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ParticipantKind {
59    Entry,
60    Capability,
61    Context,
62    Agent,
63    /// The handler's **principal** — the `by <Actor>` role (v0.45) that
64    /// originates the request. Rendered leftmost, as the sender of the initial
65    /// inbound message into the entry, and the recipient of the handler's
66    /// replies. Present only for a handler that declares (or inherits) a `by`
67    /// clause — HTTP/WebSocket service handlers; agents have no principal.
68    Actor,
69}
70
71#[derive(Debug, Clone, PartialEq)]
72pub struct Message {
73    pub from: u32,
74    pub to: u32,
75    pub kind: MessageKind,
76    pub label: String,
77    pub span: Span,
78    pub block: Option<u32>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum MessageKind {
83    Call,
84    Return,
85    /// `~>` fire-and-forget — no paired `Return`.
86    Send,
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub struct AltBlock {
91    pub id: u32,
92    pub kind: AltKind,
93    /// Empty for `Collapsed`.
94    pub branches: Vec<Branch>,
95    pub span: Span,
96    pub parent: Option<u32>,
97    /// Which of `parent`'s branches this block is nested under — `None` iff
98    /// `parent` is `None`. Needed to render nesting correctly: a parent
99    /// branch can be entirely empty of messages (the rate-limiter's `if`/
100    /// `else` gating only a return, for one), so a renderer walking
101    /// `Message.block` alone would have no way to place a nested block whose
102    /// own branches are also message-free.
103    pub parent_branch: Option<u32>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum AltKind {
108    If,
109    Match,
110    Collapsed,
111}
112
113#[derive(Debug, Clone, PartialEq)]
114pub struct Branch {
115    pub label: String,
116    pub message_ids: Vec<usize>,
117    /// The value the handler yields on this branch — its rendered tail
118    /// expression (`Ok(view)`, `TooManyRequests(...)`). This is the "reply to
119    /// its own caller" ADR 0260 names as always present, but which the
120    /// original model never actually emitted: a return-gating `if`/`match`
121    /// whose branches call no lifeline produced two empty branches, and an
122    /// empty `alt` renders as a mangled zero-width box (issue: rate-limiter's
123    /// `GET /check/:client`). Carrying the outcome here gives the block real
124    /// content to render as a note over the entry lifeline.
125    ///
126    /// `None` when the tail carries no distinguishable signal: a unit `()`
127    /// tail (an else-less `if`'s synthesised branch), or control flow
128    /// (`if`/`match`/block) that is itself already rendered as nested
129    /// structure — a note duplicating it would be noise.
130    pub reply: Option<String>,
131}
132
133/// Whether an expression sits directly under an effect operator
134/// (`<-`/`~>`/`do`) — only then can it be a lifeline call, since a bare
135/// (pure) `let` cannot bind an `Effect[_]` value in this language.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137enum Arrow {
138    /// `let x <- expr` / `do expr` — awaited; a Call+Return pair.
139    Awaited,
140    /// `~> expr` — fire-and-forget; a Send only.
141    FireAndForget,
142}
143
144const ENTRY_ID: u32 = 0;
145
146/// The block/branch a walk is currently nested inside — `None` at the
147/// handler-body top level. Threaded through every walk method so a nested
148/// block can record `parent_branch` and a message can record its `block`.
149#[derive(Debug, Clone, Copy)]
150struct BlockCtx {
151    id: u32,
152    branch: u32,
153}
154
155/// `default_given` is the owning service's service-level `given` default
156/// (v0.155, `ServiceDecl.default_given`) — a handler that declares no `given`
157/// of its own inherits it. This classifier walks a freshly-parsed AST that
158/// has *not* been through `bynk-emit`'s `inject_service_defaults`
159/// normalization pass (which is what mutates `handler.given` in the compile
160/// pipeline), so the fallback has to be applied here or a handler relying on
161/// the service default would drop every capability lifeline. Pass `&[]` when
162/// there is no default (always the case for `HandlerOwner::Agent` — agents
163/// have no service-level `given`).
164/// `default_by` is the owning service's service-level `by` default (v0.155,
165/// `ServiceDecl.default_by`) — a handler that declares no `by` of its own
166/// inherits it, exactly as it does the `given` default. Pass `None` for an
167/// agent (agents have no principal).
168pub fn sequence_model(
169    handler: &Handler,
170    owner: HandlerOwner<'_>,
171    default_given: &[CapRef],
172    default_by: Option<&ByClause>,
173    info: Option<&ContextSequenceInfo>,
174) -> SequenceModel {
175    let given = if handler.given.is_empty() {
176        default_given
177    } else {
178        &handler.given
179    };
180    let by = handler.by_clause.as_ref().or(default_by);
181    let mut b = Builder::new(entry_label(handler, owner, by.is_some()), given, info);
182    if let Some(by) = by {
183        // The principal originates the request: a leftmost actor lifeline that
184        // sends the initial inbound message (the route/method) into the entry.
185        // The entry label is bare (`api`) in this case — the request descriptor
186        // rides on the message rather than doubling into the entry box.
187        b.add_actor(actor_name(by), by.span);
188        b.emit_request(discriminator(handler), handler.span);
189    }
190    // The whole body is in the handler's return position: its tail (and each
191    // return-gating branch's tail) is what the handler replies to the actor.
192    b.walk_block(&handler.body, None, 0, true);
193    b.finish()
194}
195
196/// The handler's request descriptor — its method + path (`GET /check/:client`),
197/// method name, or lifecycle kind. Labels the initial actor→entry message, and
198/// (when there is no actor) the entry box's own discriminator suffix.
199fn discriminator(handler: &Handler) -> String {
200    match &handler.kind {
201        HandlerKind::Call => match handler.method_name.as_ref() {
202            Some(m) => m.name.clone(),
203            None => "call".to_string(),
204        },
205        HandlerKind::Http { method, path } => format!("{} {}", method.as_str(), path),
206        HandlerKind::Cron { expr } => format!("cron \"{expr}\""),
207        HandlerKind::Message => "message".to_string(),
208        HandlerKind::Open => "open".to_string(),
209        HandlerKind::Close => "close".to_string(),
210        HandlerKind::Event => "event".to_string(),
211    }
212}
213
214/// The actor participant's name — the referenced actor contract(s). More than
215/// one (`by who: A | B`, an ordered sum of peer actors) joins with `|`.
216fn actor_name(by: &ByClause) -> String {
217    by.actors
218        .iter()
219        .map(|a| a.name.as_str())
220        .collect::<Vec<_>>()
221        .join(" | ")
222}
223
224/// The entry lifeline's label. With an actor present the request descriptor
225/// moves onto the initial message, so the entry is just its owner name
226/// (`api`); without one it keeps the self-describing combined form
227/// (`api GET /check/:client`, `Limiter.hit`).
228fn entry_label(handler: &Handler, owner: HandlerOwner<'_>, has_actor: bool) -> String {
229    match owner {
230        HandlerOwner::Service(name) if has_actor => name.to_string(),
231        HandlerOwner::Agent(name) if has_actor => name.to_string(),
232        HandlerOwner::Service(name) => format!("{name} {}", discriminator(handler)),
233        HandlerOwner::Agent(name) => format!("{name}.{}", discriminator(handler)),
234    }
235}
236
237struct Builder<'a> {
238    given: &'a [CapRef],
239    info: Option<&'a ContextSequenceInfo>,
240    participants: Vec<Participant>,
241    messages: Vec<Message>,
242    blocks: Vec<AltBlock>,
243    /// The principal's participant id, once [`Builder::add_actor`] has run.
244    /// Its presence is also the switch for reply routing: with an actor, a
245    /// return-position outcome is emitted as a `Return` message *to the actor*;
246    /// without one it stays a note on the branch (`Branch::reply`).
247    actor: Option<u32>,
248}
249
250impl<'a> Builder<'a> {
251    fn new(
252        entry_label: String,
253        given: &'a [CapRef],
254        info: Option<&'a ContextSequenceInfo>,
255    ) -> Self {
256        Builder {
257            given,
258            info,
259            participants: vec![Participant {
260                id: ENTRY_ID,
261                kind: ParticipantKind::Entry,
262                name: entry_label,
263                span: None,
264            }],
265            messages: Vec::new(),
266            blocks: Vec::new(),
267            actor: None,
268        }
269    }
270
271    /// Add the principal as the leftmost participant. Inserted at index 0 so it
272    /// renders left of the entry, while the entry keeps [`ENTRY_ID`] (`0`) —
273    /// participant ids are stable labels, independent of array position, so
274    /// every `from: ENTRY_ID` reference stays correct.
275    fn add_actor(&mut self, name: String, span: Span) {
276        let id = self.participants.len() as u32;
277        self.participants.insert(
278            0,
279            Participant {
280                id,
281                kind: ParticipantKind::Actor,
282                name,
283                span: Some(span),
284            },
285        );
286        self.actor = Some(id);
287    }
288
289    /// The initial inbound request: a lone `Call` from the actor to the entry
290    /// (no paired `Return` — the handler's replies are the branch outcomes).
291    /// Spanned at the handler declaration so it sorts ahead of every body
292    /// message and click-navigates to the handler.
293    fn emit_request(&mut self, label: String, handler_span: Span) {
294        let Some(actor) = self.actor else { return };
295        self.messages.push(Message {
296            from: actor,
297            to: ENTRY_ID,
298            kind: MessageKind::Call,
299            label,
300            span: handler_span,
301            block: None,
302        });
303    }
304
305    /// A return-position outcome replied to the principal — a `Return` message
306    /// from the entry to the actor. A no-op when there is no actor (the outcome
307    /// stays a branch note instead).
308    fn emit_reply(&mut self, outcome: String, span: Span, current_block: Option<BlockCtx>) {
309        let Some(actor) = self.actor else { return };
310        self.messages.push(Message {
311            from: ENTRY_ID,
312            to: actor,
313            kind: MessageKind::Return,
314            label: outcome,
315            span,
316            block: current_block.map(|c| c.id),
317        });
318    }
319
320    fn finish(self) -> SequenceModel {
321        SequenceModel {
322            participants: self.participants,
323            messages: self.messages,
324            blocks: self.blocks,
325        }
326    }
327
328    fn participant_id(&mut self, kind: ParticipantKind, name: &str, span: Span) -> u32 {
329        if let Some(p) = self
330            .participants
331            .iter()
332            .find(|p| p.kind == kind && p.name == name)
333        {
334            return p.id;
335        }
336        let id = self.participants.len() as u32;
337        self.participants.push(Participant {
338            id,
339            kind,
340            name: name.to_string(),
341            span: Some(span),
342        });
343        id
344    }
345
346    /// Walk a block's statements, then its tail — the traversal spine every
347    /// call site (top-level body, `if`/`match` branch) shares. `ret` is whether
348    /// this block sits in the handler's **return position**: only then is its
349    /// tail a value the handler replies to the caller. Statements are never in
350    /// return position (their values are bound or discarded); only the tail
351    /// inherits the block's `ret`.
352    fn walk_block(
353        &mut self,
354        block: &Block,
355        current_block: Option<BlockCtx>,
356        depth: u32,
357        ret: bool,
358    ) {
359        for stmt in &block.statements {
360            match stmt {
361                Statement::EffectLet(l) => {
362                    self.walk_value(&l.value, current_block, depth, Some(Arrow::Awaited), false)
363                }
364                Statement::Do(d) => {
365                    self.walk_value(&d.value, current_block, depth, Some(Arrow::Awaited), false)
366                }
367                Statement::Send(s) => self.walk_value(
368                    &s.value,
369                    current_block,
370                    depth,
371                    Some(Arrow::FireAndForget),
372                    false,
373                ),
374                Statement::Let(l) => self.walk_value(&l.value, current_block, depth, None, false),
375                Statement::Expect(e) => {
376                    self.walk_value(&e.value, current_block, depth, None, false)
377                }
378                Statement::Assign(a) => {
379                    self.walk_value(&a.value, current_block, depth, None, false)
380                }
381            }
382        }
383        self.walk_value(&block.tail, current_block, depth, None, ret);
384    }
385
386    /// Classify one expression reached directly under a statement (or a
387    /// block's tail): a lifeline call (only possible when `arrow.is_some()`,
388    /// since a pure `let`/tail cannot bind an `Effect[_]`), or `if`/`match`
389    /// control flow to recurse into. Anything else — plain computation,
390    /// constructors, nested calls buried in argument expressions — folds into
391    /// the current activation with no further descent (Tier-1 scope: only
392    /// handler-body-level control flow is diagrammed, not arbitrary
393    /// expression-level branching).
394    fn walk_value(
395        &mut self,
396        expr: &Expr,
397        current_block: Option<BlockCtx>,
398        depth: u32,
399        arrow: Option<Arrow>,
400        ret: bool,
401    ) {
402        let inner = peel_paren(expr);
403        match &inner.kind {
404            // Control flow in return position keeps the `ret` flag: each
405            // branch's own tail is what the handler ultimately returns.
406            ExprKind::If { .. } => self.walk_if(inner, current_block, depth, ret),
407            ExprKind::Match { arms, .. } => {
408                self.walk_match(inner.span, arms, current_block, depth, ret)
409            }
410            ExprKind::Block(b) => self.walk_block(b, current_block, depth, ret),
411            ExprKind::Call { .. }
412            | ExprKind::ConstructorCall { .. }
413            | ExprKind::MethodCall { .. } => {
414                if let Some(arrow) = arrow {
415                    self.classify_call(inner, arrow, current_block);
416                }
417                self.maybe_reply(inner, current_block, ret);
418            }
419            _ => self.maybe_reply(inner, current_block, ret),
420        }
421    }
422
423    /// A value tail in return position is the handler's reply to the actor.
424    /// A no-op off the return path, on a signal-free outcome (unit), or with
425    /// no actor (`emit_reply` itself then does nothing).
426    fn maybe_reply(&mut self, expr: &Expr, current_block: Option<BlockCtx>, ret: bool) {
427        if !ret {
428            return;
429        }
430        if let Some(outcome) = branch_outcome(expr) {
431            self.emit_reply(outcome, expr.span, current_block);
432        }
433    }
434
435    /// `if_expr` is the (paren-peeled) `ExprKind::If` — destructured here rather
436    /// than in the caller to keep the argument list small.
437    fn walk_if(&mut self, if_expr: &Expr, current_block: Option<BlockCtx>, depth: u32, ret: bool) {
438        let ExprKind::If {
439            cond,
440            then_block,
441            else_block,
442        } = &if_expr.kind
443        else {
444            return;
445        };
446        let span = if_expr.span;
447        if depth >= MAX_BLOCK_DEPTH {
448            self.push_collapsed(span, current_block);
449            return;
450        }
451        let id = self.blocks.len() as u32;
452        self.blocks.push(AltBlock {
453            id,
454            kind: AltKind::If,
455            branches: Vec::new(),
456            span,
457            parent: current_block.map(|c| c.id),
458            parent_branch: current_block.map(|c| c.branch),
459        });
460        let then_start = self.messages.len();
461        self.walk_block(then_block, Some(BlockCtx { id, branch: 0 }), depth + 1, ret);
462        let then_ids = (then_start..self.messages.len()).collect();
463        // The condition itself labels the `then` branch (`alt view.allowed`)
464        // rather than a bare "then" — the branch predicate is the signal.
465        let mut branches = vec![Branch {
466            label: bynk_fmt::expr_to_string(cond),
467            message_ids: then_ids,
468            reply: self.branch_reply(&then_block.tail),
469        }];
470        // An else-less `if` has a synthesised `()` else block: no messages, no
471        // nested blocks, no outcome. Rendering it as a second (empty) branch
472        // gains nothing — a single-branch block renders as an `opt`, which is
473        // exactly the right shape for a guard with no alternative. A written
474        // `else` is always walked and always contributes its branch.
475        if !else_block.is_synth_unit() {
476            let else_start = self.messages.len();
477            self.walk_block(else_block, Some(BlockCtx { id, branch: 1 }), depth + 1, ret);
478            branches.push(Branch {
479                label: "otherwise".to_string(),
480                message_ids: (else_start..self.messages.len()).collect(),
481                reply: self.branch_reply(&else_block.tail),
482            });
483        }
484        self.blocks[id as usize].branches = branches;
485    }
486
487    /// A branch's `reply` note — its outcome, but only when there is no actor.
488    /// With an actor the outcome is instead emitted as a `Return` message to it
489    /// (by the return-position walk of the branch tail), so a note would double
490    /// it.
491    fn branch_reply(&self, tail: &Expr) -> Option<String> {
492        if self.actor.is_some() {
493            None
494        } else {
495            branch_outcome(tail)
496        }
497    }
498
499    fn walk_match(
500        &mut self,
501        span: Span,
502        arms: &[MatchArm],
503        current_block: Option<BlockCtx>,
504        depth: u32,
505        ret: bool,
506    ) {
507        if depth >= MAX_BLOCK_DEPTH {
508            self.push_collapsed(span, current_block);
509            return;
510        }
511        let id = self.blocks.len() as u32;
512        self.blocks.push(AltBlock {
513            id,
514            kind: AltKind::Match,
515            branches: Vec::new(),
516            span,
517            parent: current_block.map(|c| c.id),
518            parent_branch: current_block.map(|c| c.branch),
519        });
520        let mut branches = Vec::with_capacity(arms.len());
521        for (arm_index, arm) in arms.iter().enumerate() {
522            let start = self.messages.len();
523            let branch_ctx = Some(BlockCtx {
524                id,
525                branch: arm_index as u32,
526            });
527            // The arm's tail — the value it yields — is its outcome, whether
528            // the body is a bare expression or a block. It is in the match's
529            // own return position, so a return-position arm replies to the actor.
530            let reply = match &arm.body {
531                MatchBody::Expr(e) => {
532                    self.walk_value(e, branch_ctx, depth + 1, None, ret);
533                    self.branch_reply(e)
534                }
535                MatchBody::Block(b) => {
536                    self.walk_block(b, branch_ctx, depth + 1, ret);
537                    self.branch_reply(&b.tail)
538                }
539            };
540            branches.push(Branch {
541                label: pattern_summary(&arm.pattern),
542                message_ids: (start..self.messages.len()).collect(),
543                reply,
544            });
545        }
546        self.blocks[id as usize].branches = branches;
547    }
548
549    fn push_collapsed(&mut self, span: Span, current_block: Option<BlockCtx>) {
550        let id = self.blocks.len() as u32;
551        self.blocks.push(AltBlock {
552            id,
553            kind: AltKind::Collapsed,
554            branches: Vec::new(),
555            span,
556            parent: current_block.map(|c| c.id),
557            parent_branch: current_block.map(|c| c.branch),
558        });
559    }
560
561    fn classify_call(&mut self, expr: &Expr, arrow: Arrow, current_block: Option<BlockCtx>) {
562        let Some((target, label)) = self.classify_target(expr) else {
563            // Local computation (commons/context-local fn, plain method,
564            // constructor) — folds into the entry activation, no message.
565            return;
566        };
567        let block = current_block.map(|c| c.id);
568        match arrow {
569            Arrow::FireAndForget => {
570                self.messages.push(Message {
571                    from: ENTRY_ID,
572                    to: target,
573                    kind: MessageKind::Send,
574                    label,
575                    span: expr.span,
576                    block,
577                });
578            }
579            Arrow::Awaited => {
580                self.messages.push(Message {
581                    from: ENTRY_ID,
582                    to: target,
583                    kind: MessageKind::Call,
584                    label,
585                    span: expr.span,
586                    block,
587                });
588                self.messages.push(Message {
589                    from: target,
590                    to: ENTRY_ID,
591                    kind: MessageKind::Return,
592                    label: String::new(),
593                    span: expr.span,
594                    block,
595                });
596            }
597        }
598    }
599
600    /// Resolve a call expression's target lifeline, per Decision A:
601    /// consumed Capability > Agent > consumed Context. Returns the
602    /// participant id and a rendered call label, or `None` when the call is
603    /// local computation.
604    ///
605    /// `TypeName.method(args)` (`Clock.now()`, a local capability op) and
606    /// `Consumed.service(args)` (a cross-context call) are syntactically
607    /// identical to an ordinary instance method call — the parser has no
608    /// static-vs-instance distinction at the receiver, so *every* qualified
609    /// call parses uniformly as `ExprKind::MethodCall` with an `Ident`
610    /// receiver (`ExprKind::ConstructorCall` is unreachable from the parser
611    /// today; the resolver/checker make the static-vs-instance call from
612    /// context, which this classifier reimplements against `given`/`agents`/
613    /// `cross_context` instead). `Agent(key).method(args)` is the one
614    /// receiver shape that differs structurally: the receiver is itself an
615    /// `ExprKind::Call` (the agent construction), not a bare `Ident`.
616    fn classify_target(&mut self, expr: &Expr) -> Option<(u32, String)> {
617        match &expr.kind {
618            ExprKind::MethodCall {
619                receiver,
620                method,
621                args,
622                ..
623            } => match &receiver.kind {
624                ExprKind::Call { name, .. }
625                    if self.info.is_some_and(|i| i.agents.contains_key(&name.name)) =>
626                {
627                    let id = self.participant_id(ParticipantKind::Agent, &name.name, name.span);
628                    Some((id, call_label(&method.name, args)))
629                }
630                ExprKind::Ident(id) => self.classify_static(&id.name, &method.name, args, id.span),
631                _ => None,
632            },
633            // Kept for exhaustiveness against a possible future parser
634            // change; unreachable today (see the doc comment above).
635            ExprKind::ConstructorCall {
636                type_name,
637                method,
638                args,
639            } => self.classify_static(&type_name.name, &method.name, args, type_name.span),
640            _ => None,
641        }
642    }
643
644    /// Classify a bare qualified call `Name.method(args)`: a local
645    /// capability op when `Name` is in the handler's effective `given`,
646    /// otherwise a cross-context call when `Name` resolves as a consumed
647    /// context (or alias). Anything else (a static method on an ordinary
648    /// type, a sum-type variant constructor) is local — `None`.
649    fn classify_static(
650        &mut self,
651        name: &str,
652        method: &str,
653        args: &[Expr],
654        span: Span,
655    ) -> Option<(u32, String)> {
656        if self.given.iter().any(|c| c.key() == name) {
657            let id = self.participant_id(ParticipantKind::Capability, name, span);
658            return Some((id, call_label(method, args)));
659        }
660        self.classify_cross_context(name, method, args, span)
661    }
662
663    fn classify_cross_context(
664        &mut self,
665        prefix: &str,
666        method: &str,
667        args: &[Expr],
668        span: Span,
669    ) -> Option<(u32, String)> {
670        let ctx_name = self.info?.cross_context.resolve_prefix(prefix)?;
671        let id = self.participant_id(ParticipantKind::Context, &ctx_name, span);
672        Some((id, call_label(method, args)))
673    }
674}
675
676fn call_label(method: &str, args: &[Expr]) -> String {
677    let rendered: Vec<String> = args.iter().map(bynk_fmt::expr_to_string).collect();
678    format!("{method}({})", rendered.join(", "))
679}
680
681/// A branch's rendered outcome — the tail value the handler yields on that
682/// path (`Ok(view)`), which a renderer shows as a note over the entry
683/// lifeline. `None` when the tail carries no distinguishable reply: a unit
684/// `()` (an else-less `if`'s synthesised branch, or an explicit `()` tail), or
685/// control flow (`if`/`match`/block) that is already rendered as its own
686/// nested structure — repeating it as a note would only add noise.
687fn branch_outcome(tail: &Expr) -> Option<String> {
688    let inner = peel_paren(tail);
689    match &inner.kind {
690        ExprKind::UnitLit | ExprKind::If { .. } | ExprKind::Match { .. } | ExprKind::Block(_) => {
691            None
692        }
693        _ => Some(bynk_fmt::expr_to_string(inner)),
694    }
695}
696
697fn peel_paren(expr: &Expr) -> &Expr {
698    match &expr.kind {
699        ExprKind::Paren(inner) => peel_paren(inner),
700        _ => expr,
701    }
702}
703
704/// A match arm's branch label — a short rendering of its pattern, since
705/// there is no dedicated pattern-to-source printer to reuse.
706fn pattern_summary(pattern: &Pattern) -> String {
707    match pattern {
708        Pattern::Wildcard(_) => "_".to_string(),
709        Pattern::Binding(b) => b.name.clone(),
710        Pattern::Literal { value, .. } => value.describe(),
711        Pattern::Variant {
712            type_name, variant, ..
713        } => match type_name {
714            Some(t) => format!("{}.{}", t.name, variant.name),
715            None => variant.name.clone(),
716        },
717        Pattern::Refined { inner, .. } => pattern_summary(inner),
718        Pattern::Or(patterns, _) => patterns
719            .iter()
720            .map(pattern_summary)
721            .collect::<Vec<_>>()
722            .join(" | "),
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use std::fs;
730    use std::path::PathBuf;
731
732    /// Same convention as `symbols.rs`'s `setup_project`: a temp dir unique to
733    /// the test name, populated with `(relative_path, contents)` files.
734    /// Self-contained fixtures only (never `examples/`) — `bynk-ide` is
735    /// published standalone, and a test reaching outside the crate would fail
736    /// a `cargo test` on the released tarball.
737    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
738        let root = std::env::temp_dir().join(format!(
739            "bynk-ide-sequence-test-{test_name}-{}",
740            std::process::id()
741        ));
742        let _ = fs::remove_dir_all(&root);
743        fs::create_dir_all(&root).expect("create test root");
744        for (rel, contents) in files {
745            let p = root.join(rel);
746            if let Some(parent) = p.parent() {
747                fs::create_dir_all(parent).expect("create parent");
748            }
749            fs::write(&p, contents).expect("write file");
750        }
751        root
752    }
753
754    fn parse_context(text: &str) -> Context {
755        let tokens = bynk_syntax::lexer::tokenize(text).expect("tokenize");
756        let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
757        assert!(errs.is_empty(), "parse errors: {errs:?}");
758        match unit.expect("parsed unit") {
759            SourceUnit::Context(c) => c,
760            _ => panic!("expected a context"),
761        }
762    }
763
764    fn find_service<'a>(ctx: &'a Context, name: &str) -> &'a ServiceDecl {
765        ctx.items
766            .iter()
767            .find_map(|i| match i {
768                CommonsItem::Service(s) if s.name.name == name => Some(s),
769                _ => None,
770            })
771            .unwrap_or_else(|| panic!("service {name} not found"))
772    }
773
774    // -- Fixture 1: examples/rate-limiter's `GET /check/:client`, reproduced
775    // -- self-contained (not read from `examples/` — see `setup_project`).
776    // -- Capability (Clock) + Agent (Limiter) + a return-gating `if` whose
777    // -- branches call nothing lifeline-worthy — the regression fixture for
778    // -- the corrected AltBlock rule (see the plan's "Corrected extractor
779    // -- rule" note): the issue's own worked example renders this block.
780    const RATELIMIT_SRC: &str = r#"context ratelimit
781
782consumes bynk { Clock }
783
784type ClientId = String where NonEmpty
785
786type RateView = {
787  allowed:   Bool,
788  remaining: Int,
789  resetAt:   Int,
790}
791
792agent Limiter {
793  key client: ClientId
794
795  store count: Cell[Int]
796
797  on call hit(now: Int) -> Effect[RateView] {
798    let _ <- count.update((c) => c + 1)
799    RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
800  }
801}
802
803service api from http {
804  on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
805    let now  <- Clock.now()
806    let view <- Limiter(client).hit(now.toEpochMillis())
807    if view.allowed {
808      Ok(view)
809    } else {
810      TooManyRequests("rate limit exceeded")
811    }
812  }
813}
814"#;
815
816    #[test]
817    fn rate_limiter_get_check_client_classifies_capability_and_agent_and_gates_the_return() {
818        let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
819        let diag = crate::testkit::diagnose_project(&root);
820        let info = diag
821            .sequence_info
822            .get("ratelimit")
823            .expect("sequence_info entry for ratelimit");
824
825        let ctx = parse_context(RATELIMIT_SRC);
826        let svc = find_service(&ctx, "api");
827        let handler = &svc.handlers[0];
828
829        let model = sequence_model(
830            handler,
831            HandlerOwner::Service("api"),
832            &svc.default_given,
833            svc.default_by.as_ref(),
834            Some(info),
835        );
836
837        // The handler's `by Visitor` principal is the leftmost participant and
838        // originates the request; the entry box drops to the bare owner name
839        // (`api`) since the route now rides on the inbound message.
840        let kinds: Vec<(ParticipantKind, &str)> = model
841            .participants
842            .iter()
843            .map(|p| (p.kind, p.name.as_str()))
844            .collect();
845        assert_eq!(
846            kinds,
847            vec![
848                (ParticipantKind::Actor, "Visitor"),
849                (ParticipantKind::Entry, "api"),
850                (ParticipantKind::Capability, "Clock"),
851                (ParticipantKind::Agent, "Limiter"),
852            ]
853        );
854        let actor_id = model.participants[0].id;
855
856        // The initial inbound request: Visitor -> api, labelled by the route.
857        let req = &model.messages[0];
858        assert_eq!(req.kind, MessageKind::Call);
859        assert_eq!((req.from, req.to), (actor_id, ENTRY_ID));
860        assert_eq!(req.label, "GET /check/:client");
861
862        // now() + reply, hit(...) + reply, then a reply-to-actor per branch.
863        assert_eq!(
864            model.messages.len(),
865            7,
866            "request + Clock Call/Return + Limiter Call/Return + a reply-to-actor per branch"
867        );
868
869        assert_eq!(model.blocks.len(), 1);
870        assert_eq!(model.blocks[0].kind, AltKind::If);
871        assert_eq!(model.blocks[0].branches.len(), 2);
872        // With an actor, each branch carries no *note* (`reply` is None) — its
873        // outcome is instead a `Return` message back to the actor, which the
874        // `then`/`else` branches own via their `message_ids`.
875        let labels: Vec<&str> = model.blocks[0]
876            .branches
877            .iter()
878            .map(|b| b.label.as_str())
879            .collect();
880        assert_eq!(labels, vec!["view.allowed", "otherwise"]);
881        assert!(
882            model.blocks[0].branches.iter().all(|b| b.reply.is_none()),
883            "with an actor the outcome is a message, not a note"
884        );
885        let branch_replies: Vec<(MessageKind, u32, u32, &str)> = model.blocks[0]
886            .branches
887            .iter()
888            .flat_map(|b| &b.message_ids)
889            .map(|&i| {
890                let m = &model.messages[i];
891                (m.kind, m.from, m.to, m.label.as_str())
892            })
893            .collect();
894        assert_eq!(
895            branch_replies,
896            vec![
897                (MessageKind::Return, ENTRY_ID, actor_id, "Ok(view)"),
898                (
899                    MessageKind::Return,
900                    ENTRY_ID,
901                    actor_id,
902                    "TooManyRequests(\"rate limit exceeded\")",
903                ),
904            ],
905            "each branch replies its outcome to the actor"
906        );
907    }
908
909    // -- Fixture 2: a consumed-context call — boundary-stop (Decision C).
910    const PLATFORM_SRC: &str = r#"context platform
911
912service Pinger {
913  on call(n: Int) -> Effect[Int] {
914    n
915  }
916}
917"#;
918    const CONSUMER_SRC: &str = r#"context consumer
919
920consumes platform
921
922service api {
923  on call(n: Int) -> Effect[Int] {
924    let v <- platform.Pinger(n)
925    v
926  }
927}
928"#;
929
930    #[test]
931    fn cross_context_call_is_boundary_stop() {
932        let root = setup_project(
933            "crossctx",
934            &[
935                ("platform.bynk", PLATFORM_SRC),
936                ("consumer.bynk", CONSUMER_SRC),
937            ],
938        );
939        let diag = crate::testkit::diagnose_project(&root);
940        let info = diag
941            .sequence_info
942            .get("consumer")
943            .expect("sequence_info entry for consumer");
944
945        let ctx = parse_context(CONSUMER_SRC);
946        let svc = find_service(&ctx, "api");
947        let handler = &svc.handlers[0];
948        let model = sequence_model(
949            handler,
950            HandlerOwner::Service("api"),
951            &svc.default_given,
952            svc.default_by.as_ref(),
953            Some(info),
954        );
955
956        assert_eq!(model.participants.len(), 2, "Entry + the consumed context");
957        assert_eq!(model.participants[1].kind, ParticipantKind::Context);
958        assert_eq!(model.participants[1].name, "platform");
959        assert_eq!(
960            model.messages.len(),
961            2,
962            "one Call + one Return — the consumed service's own body is never walked"
963        );
964        assert_eq!(model.messages[0].kind, MessageKind::Call);
965        assert_eq!(model.messages[1].kind, MessageKind::Return);
966    }
967
968    // -- Fixtures 3-5: fire-and-forget send, a degenerate (local-only)
969    // -- handler, and 3-level-nested `if` past the depth budget.
970    const MISC_SRC: &str = r#"context misc
971
972consumes bynk { Clock, Logger }
973
974fn double(x: Int) -> Int {
975  x * 2
976}
977
978service fireService {
979  on call(n: Int) -> Effect[()] given Logger {
980    ~> Logger.info("hi")
981    Effect.pure(())
982  }
983}
984
985service localService {
986  on call(n: Int) -> Effect[Int] {
987    double(n)
988  }
989}
990
991service nestedService {
992  on call(n: Int) -> Effect[Int] given Clock {
993    let now <- Clock.now()
994    if n > 0 {
995      if n > 10 {
996        if n > 100 {
997          now.toEpochMillis()
998        } else {
999          1
1000        }
1001      } else {
1002        2
1003      }
1004    } else {
1005      3
1006    }
1007  }
1008}
1009"#;
1010
1011    fn misc_info(diag: &crate::ProjectDiagnostics) -> bynk_check::analysis::ContextSequenceInfo {
1012        diag.sequence_info
1013            .get("misc")
1014            .cloned()
1015            .expect("sequence_info entry for misc")
1016    }
1017
1018    #[test]
1019    fn fire_and_forget_send_has_no_paired_return() {
1020        let root = setup_project("misc-send", &[("misc.bynk", MISC_SRC)]);
1021        let diag = crate::testkit::diagnose_project(&root);
1022        let info = misc_info(&diag);
1023
1024        let ctx = parse_context(MISC_SRC);
1025        let svc = find_service(&ctx, "fireService");
1026        let handler = &svc.handlers[0];
1027        let model = sequence_model(
1028            handler,
1029            HandlerOwner::Service("fireService"),
1030            &svc.default_given,
1031            svc.default_by.as_ref(),
1032            Some(&info),
1033        );
1034
1035        assert_eq!(model.participants.len(), 2);
1036        assert_eq!(model.participants[1].kind, ParticipantKind::Capability);
1037        assert_eq!(model.participants[1].name, "Logger");
1038        assert_eq!(model.messages.len(), 1, "a Send has no paired Return");
1039        assert_eq!(model.messages[0].kind, MessageKind::Send);
1040    }
1041
1042    #[test]
1043    fn degenerate_handler_with_only_local_calls_has_no_lifelines() {
1044        let root = setup_project("misc-local", &[("misc.bynk", MISC_SRC)]);
1045        let diag = crate::testkit::diagnose_project(&root);
1046        let info = misc_info(&diag);
1047
1048        let ctx = parse_context(MISC_SRC);
1049        let svc = find_service(&ctx, "localService");
1050        let handler = &svc.handlers[0];
1051        let model = sequence_model(
1052            handler,
1053            HandlerOwner::Service("localService"),
1054            &svc.default_given,
1055            svc.default_by.as_ref(),
1056            Some(&info),
1057        );
1058
1059        assert_eq!(model.participants.len(), 1);
1060        assert_eq!(model.participants[0].kind, ParticipantKind::Entry);
1061        assert!(model.messages.is_empty());
1062        assert!(model.blocks.is_empty());
1063    }
1064
1065    #[test]
1066    fn nested_if_collapses_past_the_depth_budget() {
1067        let root = setup_project("misc-nested", &[("misc.bynk", MISC_SRC)]);
1068        let diag = crate::testkit::diagnose_project(&root);
1069        let info = misc_info(&diag);
1070
1071        let ctx = parse_context(MISC_SRC);
1072        let svc = find_service(&ctx, "nestedService");
1073        let handler = &svc.handlers[0];
1074        let model = sequence_model(
1075            handler,
1076            HandlerOwner::Service("nestedService"),
1077            &svc.default_given,
1078            svc.default_by.as_ref(),
1079            Some(&info),
1080        );
1081
1082        // Depth 0 (`n > 0`) and depth 1 (`n > 10`) render as real blocks;
1083        // depth 2 (`n > 100`) is past `MAX_BLOCK_DEPTH` and collapses instead
1084        // of expanding further — click-to-code still works (the collapsed
1085        // marker's span points at the whole collapsed `if`), the diagram
1086        // just doesn't recurse into it.
1087        let kinds: Vec<AltKind> = model.blocks.iter().map(|b| b.kind).collect();
1088        assert_eq!(kinds, vec![AltKind::If, AltKind::If, AltKind::Collapsed]);
1089        assert!(
1090            model.blocks[2].branches.is_empty(),
1091            "a Collapsed block carries no branches"
1092        );
1093        // Regression: each nested block must record which branch of its
1094        // parent it sits under, not just the parent id — both the middle
1095        // and innermost `if` are nested in their parent's *first* ("then")
1096        // branch, and a renderer needs that even when the branch itself
1097        // carries no messages of its own (only the nested block does).
1098        assert_eq!(model.blocks[0].parent, None);
1099        assert_eq!(model.blocks[0].parent_branch, None);
1100        assert_eq!(model.blocks[1].parent, Some(0));
1101        assert_eq!(model.blocks[1].parent_branch, Some(0));
1102        assert_eq!(model.blocks[2].parent, Some(1));
1103        assert_eq!(model.blocks[2].parent_branch, Some(0));
1104    }
1105
1106    // -- Fixture 6 (#861 review): a service declares a service-level `given`
1107    // -- default (v0.155) and the handler omits its own `given`, inheriting it.
1108    // -- The classifier walks a freshly-parsed AST that never ran
1109    // -- `inject_service_defaults`, so it must apply the fallback itself — or
1110    // -- the Clock capability lifeline is silently dropped.
1111    const SERVICE_GIVEN_SRC: &str = r#"context svcgiven
1112
1113consumes bynk { Clock }
1114
1115service api from http by Visitor given Clock {
1116  on GET("/now") () -> Effect[HttpResult[Int]] {
1117    let now <- Clock.now()
1118    Ok(now.toEpochMillis())
1119  }
1120}
1121"#;
1122
1123    #[test]
1124    fn service_level_given_default_is_inherited_by_a_handler_without_its_own() {
1125        let root = setup_project("svcgiven", &[("svcgiven.bynk", SERVICE_GIVEN_SRC)]);
1126        let diag = crate::testkit::diagnose_project(&root);
1127        let info = diag
1128            .sequence_info
1129            .get("svcgiven")
1130            .expect("sequence_info entry for svcgiven");
1131
1132        let ctx = parse_context(SERVICE_GIVEN_SRC);
1133        let svc = find_service(&ctx, "api");
1134        let handler = &svc.handlers[0];
1135        // Precondition: the freshly-parsed handler carries no `given` of its
1136        // own — it relies entirely on the service-level default.
1137        assert!(
1138            handler.given.is_empty(),
1139            "fixture handler must omit its own `given`"
1140        );
1141        assert_eq!(svc.default_given.len(), 1, "service-level `given Clock`");
1142        assert!(
1143            handler.by_clause.is_none() && svc.default_by.is_some(),
1144            "fixture handler must inherit the service-level `by Visitor`"
1145        );
1146
1147        let model = sequence_model(
1148            handler,
1149            HandlerOwner::Service("api"),
1150            &svc.default_given,
1151            svc.default_by.as_ref(),
1152            Some(info),
1153        );
1154
1155        let kinds: Vec<(ParticipantKind, &str)> = model
1156            .participants
1157            .iter()
1158            .map(|p| (p.kind, p.name.as_str()))
1159            .collect();
1160        assert_eq!(
1161            kinds,
1162            vec![
1163                // Both service-level defaults are inherited: `by Visitor` (the
1164                // actor) and `given Clock` (the capability lifeline).
1165                (ParticipantKind::Actor, "Visitor"),
1166                (ParticipantKind::Entry, "api"),
1167                (ParticipantKind::Capability, "Clock"),
1168            ],
1169            "Clock must classify via the inherited `given`, and Visitor via the \
1170             inherited `by` — with only `handler.given`/`handler.by_clause` both \
1171             would be dropped"
1172        );
1173
1174        // A non-branching handler tail still replies to the actor: the last
1175        // message is the `Ok(...)` return, entry -> Visitor.
1176        let actor_id = model.participants[0].id;
1177        let last = model.messages.last().expect("at least one message");
1178        assert_eq!(last.kind, MessageKind::Return);
1179        assert_eq!((last.from, last.to), (ENTRY_ID, actor_id));
1180        assert_eq!(last.label, "Ok(now.toEpochMillis())");
1181    }
1182
1183    // -- Fixture 7 (issue): branch outcomes + else-less-if shape. An else-less
1184    // -- `if` renders as an `opt` (one branch, not an empty second branch), and
1185    // -- a `match` captures each arm's yielded value as its reply — the content
1186    // -- that keeps a return-gating block from rendering as an empty box.
1187    const OUTCOME_SRC: &str = r#"context outcome
1188
1189consumes bynk { Logger }
1190
1191service guardSvc {
1192  on call(flag: Bool) -> Effect[()] given Logger {
1193    if flag {
1194      ~> Logger.info("hi")
1195    }
1196  }
1197}
1198
1199service routeSvc {
1200  on call(x: Int) -> Effect[Int] {
1201    match x {
1202      0 => 100
1203      _ => x
1204    }
1205  }
1206}
1207"#;
1208
1209    #[test]
1210    fn else_less_if_is_a_single_branch_opt_and_match_arms_capture_replies() {
1211        // `info` is `None` here: this fixture references only a capability
1212        // (`given Logger`) and control flow — no agent or cross-context call —
1213        // so classification needs no project table, and this stays a pure
1214        // parse-only test (the full project wouldn't type-check anyway).
1215        let ctx = parse_context(OUTCOME_SRC);
1216        let guard_svc = find_service(&ctx, "guardSvc");
1217        let route_svc = find_service(&ctx, "routeSvc");
1218
1219        // `guard`: an else-less `if` — one branch (an `opt`), labelled by its
1220        // condition. Its `then` carries the Send but its tail is unit, so it
1221        // has no reply of its own.
1222        let guard = &guard_svc.handlers[0];
1223        let gm = sequence_model(
1224            guard,
1225            HandlerOwner::Service("guardSvc"),
1226            &guard_svc.default_given,
1227            guard_svc.default_by.as_ref(),
1228            None,
1229        );
1230        assert_eq!(gm.blocks.len(), 1);
1231        assert_eq!(gm.blocks[0].kind, AltKind::If);
1232        assert_eq!(
1233            gm.blocks[0].branches.len(),
1234            1,
1235            "an else-less `if` renders as an `opt`, not an `alt` with an empty second branch"
1236        );
1237        assert_eq!(gm.blocks[0].branches[0].label, "flag");
1238        assert_eq!(
1239            gm.blocks[0].branches[0].reply, None,
1240            "a unit tail has no reply"
1241        );
1242        assert_eq!(
1243            gm.blocks[0].branches[0].message_ids.len(),
1244            1,
1245            "the `~>` Send"
1246        );
1247
1248        // `route`: a `match` whose arms yield distinct values — each captured
1249        // as that branch's reply.
1250        let route = &route_svc.handlers[0];
1251        let rm = sequence_model(
1252            route,
1253            HandlerOwner::Service("routeSvc"),
1254            &route_svc.default_given,
1255            route_svc.default_by.as_ref(),
1256            None,
1257        );
1258        assert_eq!(rm.blocks.len(), 1);
1259        assert_eq!(rm.blocks[0].kind, AltKind::Match);
1260        let replies: Vec<Option<&str>> = rm.blocks[0]
1261            .branches
1262            .iter()
1263            .map(|b| b.reply.as_deref())
1264            .collect();
1265        assert_eq!(replies, vec![Some("100"), Some("x")]);
1266    }
1267}