Skip to main content

bynk_ide/
architecture.rs

1//! #851: the architecture-model query — a whole-project map of contexts and
2//! adapters, their `consumes` edges, and the capabilities/providers/services/
3//! agents each one binds, for the "Show Architecture Map" VS Code webview.
4//! The macro counterpart to #846's per-handler sequence diagram: where that
5//! query is file-scoped and cursor-anchored, this one is **project-scoped** —
6//! every span here is paired with the project-relative file it belongs to
7//! (a unit's declarations may span several files, per `unit_sources`), so a
8//! wire layer can open the right file on click-to-code rather than assuming
9//! the request's own document.
10//!
11//! Citation correction (found during implementation of #851; recorded in
12//! `design/pending/architecture-map-851.md`): the issue proposed this as "a
13//! pure, read-only query over the binding index and call graph"
14//! (`bynk-check/src/index.rs`). That index has no `Context`/`Adapter` symbol
15//! kind and its call graph does not carry `consumes` edges — its own comment
16//! notes cross-context dispatch is "the one uncovered relation". The actual
17//! source, used here exactly as `sequence::Builder::classify_cross_context`
18//! already does, is [`ContextSequenceInfo::cross_context`] for resolved
19//! consumed-context names (aliasing applied) plus a re-parse of each unit's
20//! own snapshot text for its local declarations and raw `consumes` clauses —
21//! there is no retained AST after a round, the same constraint
22//! `sequence_request`/`documentation_request` re-parse against.
23//!
24//! Capability binding (Decision C: show binding, defer residency): a braced
25//! `consumes U { Cap, … }` selection flattens `Cap` into the consumer's own
26//! namespace, so each selected capability is recorded directly on the
27//! consuming node (not only as an edge label) — this is what makes the
28//! built-in `consumes bynk { Clock }` binding visible on a single-context
29//! project even though the synthetic `bynk` unit has no project file and so
30//! never becomes a node itself (an edge to it would dangle); see
31//! [`architecture_model`]'s node-name filter. A whole-unit consumes (no
32//! braces) grants qualified access to every exported capability but flattens
33//! none of them, so it contributes an edge only, unlabelled — matching the
34//! issue's "labelled with selected capabilities where braced".
35
36use std::collections::{HashMap, HashSet};
37use std::path::PathBuf;
38
39use bynk_check::analysis::ContextSequenceInfo;
40use bynk_syntax::ast::*;
41use bynk_syntax::span::Span;
42
43/// A declaration site: which project-relative file it lives in, and its span
44/// within that file's text. Project-scoped models (unlike the file-scoped
45/// `sequence`/`documentation` ones) always need both — a node's members can
46/// come from different files of the same multi-file unit.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Located {
49    pub file: PathBuf,
50    pub span: Span,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum NodeKind {
55    Context,
56    Adapter,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CapabilityOrigin {
61    /// A `capability { … }` declared directly in this unit.
62    Local,
63    /// Flattened in via a braced `consumes <from> { <name>, … }` selection.
64    Consumed { from: String },
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ArchCapability {
69    pub name: String,
70    pub origin: CapabilityOrigin,
71    pub loc: Located,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct ArchProvider {
76    pub capability: String,
77    pub provider_name: String,
78    /// `provides Cap = Name` with no brace block — supplied by an adapter's
79    /// external binding rather than a Bynk body (`ProviderDecl::external`).
80    pub external: bool,
81    pub loc: Located,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ArchService {
86    pub name: String,
87    pub handler_count: usize,
88    pub loc: Located,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ArchAgent {
93    pub name: String,
94    pub handler_count: usize,
95    pub loc: Located,
96}
97
98/// One context/adapter node — the "services in, agents below" grouping the
99/// design notes describe, rendered as a single box with click-to-expand
100/// members.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ArchNode {
103    /// The unit's qualified name (`demo.app`, `tokens`).
104    pub name: String,
105    pub kind: NodeKind,
106    /// The unit-name declaration site — the node box's own click target.
107    pub loc: Located,
108    pub capabilities: Vec<ArchCapability>,
109    pub providers: Vec<ArchProvider>,
110    pub services: Vec<ArchService>,
111    pub agents: Vec<ArchAgent>,
112}
113
114/// A directed `consumes` edge between two nodes.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ArchEdge {
117    pub from: String,
118    pub to: String,
119    /// Selected capability labels (braced form, in source order); empty for
120    /// a whole-unit consumes.
121    pub capabilities: Vec<String>,
122    /// The `consumes` clause's own span, in the consuming (`from`) unit's file.
123    pub loc: Located,
124}
125
126#[derive(Debug, Clone, Default, PartialEq, Eq)]
127pub struct ArchModel {
128    pub nodes: Vec<ArchNode>,
129    pub edges: Vec<ArchEdge>,
130}
131
132/// Build the whole-project architecture model.
133///
134/// `unit_sources` and `snapshots` are `Analysis`/`ProjectDiagnostics`'s own
135/// fields (project-relative file lists per unit, and each file's committed
136/// text); `sequence_info` is the same table `bynk/sequenceModel` classifies
137/// against. A unit with no `sequence_info` entry (a `commons`, or the
138/// toolchain's synthetic `bynk` capability surface — excluded from
139/// `unit_sources`, per its own doc) contributes no node; a `consumes` edge
140/// that resolves to such a unit is dropped (nothing to draw an arrow to) but
141/// its selected capabilities are still recorded on the consuming node — see
142/// the module doc.
143pub fn architecture_model(
144    unit_sources: &HashMap<String, Vec<PathBuf>>,
145    snapshots: &HashMap<PathBuf, String>,
146    sequence_info: &HashMap<String, ContextSequenceInfo>,
147) -> ArchModel {
148    // Deterministic iteration order (ADR risk: "layout churn" — a `HashMap`
149    // walk shuffles between rounds otherwise).
150    let mut names: Vec<&String> = sequence_info
151        .keys()
152        .filter(|n| unit_sources.contains_key(n.as_str()))
153        .collect();
154    names.sort();
155    let node_names: HashSet<&str> = names.iter().map(|s| s.as_str()).collect();
156
157    let mut nodes = Vec::with_capacity(names.len());
158    let mut edges = Vec::new();
159
160    for name in names {
161        let mut files = unit_sources[name].clone();
162        files.sort();
163
164        let mut kind = None;
165        let mut loc = None;
166        let mut capabilities = Vec::new();
167        let mut providers = Vec::new();
168        let mut services = Vec::new();
169        let mut agents = Vec::new();
170
171        let info = sequence_info.get(name);
172
173        for file in &files {
174            let Some(text) = snapshots.get(file) else {
175                continue;
176            };
177            let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
178                continue;
179            };
180            let (unit_opt, _errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
181            let Some(unit) = unit_opt else { continue };
182            let (this_kind, name_span, items, consumes): (
183                NodeKind,
184                Span,
185                &[CommonsItem],
186                &[ConsumesDecl],
187            ) = match &unit {
188                SourceUnit::Context(c) => (NodeKind::Context, c.name.span, &c.items, &c.consumes),
189                SourceUnit::Adapter(a) => (NodeKind::Adapter, a.name.span, &a.items, &a.consumes),
190                SourceUnit::Commons(_) | SourceUnit::Suite(_) => continue,
191            };
192            kind.get_or_insert(this_kind);
193            loc.get_or_insert(Located {
194                file: file.clone(),
195                span: name_span,
196            });
197
198            for item in items {
199                match item {
200                    CommonsItem::Capability(c) => capabilities.push(ArchCapability {
201                        name: c.name.name.clone(),
202                        origin: CapabilityOrigin::Local,
203                        loc: Located {
204                            file: file.clone(),
205                            span: c.name.span,
206                        },
207                    }),
208                    CommonsItem::Provider(p) => providers.push(ArchProvider {
209                        capability: p.capability.name.clone(),
210                        provider_name: p.provider_name.name.clone(),
211                        external: p.external,
212                        loc: Located {
213                            file: file.clone(),
214                            span: p.provider_name.span,
215                        },
216                    }),
217                    CommonsItem::Service(s) => services.push(ArchService {
218                        name: s.name.name.clone(),
219                        handler_count: s.handlers.len(),
220                        loc: Located {
221                            file: file.clone(),
222                            span: s.name.span,
223                        },
224                    }),
225                    CommonsItem::Agent(a) => agents.push(ArchAgent {
226                        name: a.name.name.clone(),
227                        handler_count: a.handlers.len(),
228                        loc: Located {
229                            file: file.clone(),
230                            span: a.name.span,
231                        },
232                    }),
233                    _ => {}
234                }
235            }
236
237            for decl in consumes {
238                let target = info
239                    .and_then(|i| i.cross_context.resolve_prefix(&decl.target.joined()))
240                    .unwrap_or_else(|| decl.target.joined());
241                let cap_labels: Vec<String> = decl
242                    .selected
243                    .as_ref()
244                    .map(|sel| sel.iter().map(|id| id.name.clone()).collect())
245                    .unwrap_or_default();
246
247                // A selected capability flattens into the consumer's own
248                // namespace regardless of whether `target` itself becomes a
249                // node (the built-in `bynk` surface never does) — record the
250                // binding on the node either way.
251                if let Some(selected) = &decl.selected {
252                    for id in selected {
253                        capabilities.push(ArchCapability {
254                            name: id.name.clone(),
255                            origin: CapabilityOrigin::Consumed {
256                                from: target.clone(),
257                            },
258                            loc: Located {
259                                file: file.clone(),
260                                span: id.span,
261                            },
262                        });
263                    }
264                }
265
266                // The edge itself is only meaningful between two real nodes —
267                // nothing to draw an arrow to for a synthetic/absent target.
268                if node_names.contains(target.as_str()) {
269                    edges.push(ArchEdge {
270                        from: name.clone(),
271                        to: target,
272                        capabilities: cap_labels,
273                        loc: Located {
274                            file: file.clone(),
275                            span: decl.span,
276                        },
277                    });
278                }
279            }
280        }
281
282        let (Some(kind), Some(loc)) = (kind, loc) else {
283            continue;
284        };
285        // A multi-file unit can declare the same thing more than once across
286        // its files in principle (a redundant `consumes X { Cap }` repeated
287        // in two files of the same context, say) — dedupe by identity, not
288        // by the whole struct (which would keep both copies apart on `loc`
289        // alone), keeping the first (file-sorted, so deterministic) copy.
290        capabilities.sort_by(|a, b| a.name.cmp(&b.name));
291        capabilities.dedup_by(|a, b| a.name == b.name && a.origin == b.origin);
292        providers.sort_by(|a, b| {
293            (&a.capability, &a.provider_name).cmp(&(&b.capability, &b.provider_name))
294        });
295        providers
296            .dedup_by(|a, b| a.capability == b.capability && a.provider_name == b.provider_name);
297        services.sort_by(|a, b| a.name.cmp(&b.name));
298        services.dedup_by(|a, b| a.name == b.name);
299        agents.sort_by(|a, b| a.name.cmp(&b.name));
300        agents.dedup_by(|a, b| a.name == b.name);
301
302        nodes.push(ArchNode {
303            name: name.clone(),
304            kind,
305            loc,
306            capabilities,
307            providers,
308            services,
309            agents,
310        });
311    }
312
313    // A multi-file unit can carry more than one `consumes` clause naming the
314    // same target across its files (e.g. `consumes X { A }` in one file and
315    // `consumes X { B }` in another) — merge same-`(from, to)` edges into one
316    // rather than drawing two arrows between the same pair of nodes, keeping
317    // the first (file-sorted) edge's own location and the union of every
318    // merged edge's capability labels.
319    edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
320    let mut merged_edges: Vec<ArchEdge> = Vec::with_capacity(edges.len());
321    for edge in edges {
322        match merged_edges
323            .last_mut()
324            .filter(|last: &&mut ArchEdge| last.from == edge.from && last.to == edge.to)
325        {
326            Some(last) => {
327                for cap in edge.capabilities {
328                    if !last.capabilities.contains(&cap) {
329                        last.capabilities.push(cap);
330                    }
331                }
332            }
333            None => merged_edges.push(edge),
334        }
335    }
336    for edge in &mut merged_edges {
337        edge.capabilities.sort();
338    }
339
340    ArchModel {
341        nodes,
342        edges: merged_edges,
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use std::fs;
350
351    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
352        let root = std::env::temp_dir().join(format!(
353            "bynk-ide-architecture-test-{test_name}-{}",
354            std::process::id()
355        ));
356        let _ = fs::remove_dir_all(&root);
357        fs::create_dir_all(&root).expect("create test root");
358        for (rel, contents) in files {
359            let p = root.join(rel);
360            if let Some(parent) = p.parent() {
361                fs::create_dir_all(parent).expect("create parent");
362            }
363            fs::write(&p, contents).expect("write file");
364        }
365        root
366    }
367
368    fn build_model(diag: &crate::ProjectDiagnostics) -> ArchModel {
369        let snapshots: HashMap<PathBuf, String> = diag
370            .files
371            .iter()
372            .map(|f| (f.source_path.clone(), f.text.clone()))
373            .collect();
374        architecture_model(&diag.unit_sources, &snapshots, &diag.sequence_info)
375    }
376
377    // -- Fixture: examples/rate-limiter's single context, self-contained (not
378    // -- read from `examples/` — same convention as `sequence.rs`'s tests).
379    // -- Its only consumes clause pulls a built-in capability (`Clock`) from
380    // -- the synthetic `bynk` surface — the case with no edge, only a bound
381    // -- capability.
382    const RATELIMIT_SRC: &str = r#"context ratelimit
383
384consumes bynk { Clock }
385
386type ClientId = String where NonEmpty
387
388type RateView = {
389  allowed:   Bool,
390  remaining: Int,
391  resetAt:   Int,
392}
393
394agent Limiter {
395  key client: ClientId
396
397  store count: Cell[Int]
398
399  on call hit(now: Int) -> Effect[RateView] {
400    let _ <- count.update((c) => c + 1)
401    RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
402  }
403}
404
405service api from http {
406  on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
407    let now  <- Clock.now()
408    let view <- Limiter(client).hit(now.toEpochMillis())
409    if view.allowed {
410      Ok(view)
411    } else {
412      TooManyRequests("rate limit exceeded")
413    }
414  }
415}
416"#;
417
418    #[test]
419    fn single_context_binds_a_builtin_capability_with_no_dangling_edge() {
420        let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
421        let diag = crate::testkit::diagnose_project(&root);
422        let model = build_model(&diag);
423
424        assert_eq!(
425            model.nodes.len(),
426            1,
427            "the built-in bynk surface is not a node"
428        );
429        let node = &model.nodes[0];
430        assert_eq!(node.name, "ratelimit");
431        assert_eq!(node.kind, NodeKind::Context);
432        assert_eq!(node.loc.file, PathBuf::from("ratelimit.bynk"));
433
434        assert_eq!(node.agents.len(), 1);
435        assert_eq!(node.agents[0].name, "Limiter");
436        assert_eq!(node.agents[0].handler_count, 1);
437
438        assert_eq!(node.services.len(), 1);
439        assert_eq!(node.services[0].name, "api");
440        assert_eq!(node.services[0].handler_count, 1);
441
442        assert_eq!(
443            node.capabilities,
444            vec![ArchCapability {
445                name: "Clock".to_string(),
446                origin: CapabilityOrigin::Consumed {
447                    from: "bynk".to_string()
448                },
449                loc: node.capabilities[0].loc.clone(),
450            }],
451            "Clock is bound via the built-in consumes, not locally declared"
452        );
453
454        assert!(
455            model.edges.is_empty(),
456            "no node exists for the synthetic bynk surface, so no edge is drawn to it"
457        );
458    }
459
460    // -- Fixture: two real contexts, a braced selected-capability consumes —
461    // -- the shape the issue's Done-when line calls for explicitly.
462    const PROVIDER_SRC: &str = r#"context platformtime
463
464exports capability { Clock }
465
466capability Clock {
467  fn now() -> Effect[Int]
468}
469
470provides Clock = SystemClock {
471  fn now() -> Effect[Int] {
472    0
473  }
474}
475"#;
476    const CONSUMER_SRC: &str = r#"context ops.jobs
477
478consumes platformtime { Clock }
479
480service run {
481  on call() -> Effect[Int] given Clock {
482    let now <- Clock.now()
483    now
484  }
485}
486"#;
487
488    #[test]
489    fn two_context_project_gets_a_labelled_consumes_edge() {
490        let root = setup_project(
491            "twoctx",
492            &[
493                ("platformtime.bynk", PROVIDER_SRC),
494                ("jobs.bynk", CONSUMER_SRC),
495            ],
496        );
497        let diag = crate::testkit::diagnose_project(&root);
498        let model = build_model(&diag);
499
500        let names: Vec<&str> = model.nodes.iter().map(|n| n.name.as_str()).collect();
501        assert_eq!(names, vec!["ops.jobs", "platformtime"], "sorted by name");
502
503        let provider_node = &model.nodes[1];
504        assert_eq!(provider_node.capabilities.len(), 1);
505        assert_eq!(provider_node.capabilities[0].name, "Clock");
506        assert_eq!(
507            provider_node.capabilities[0].origin,
508            CapabilityOrigin::Local
509        );
510        assert_eq!(provider_node.providers.len(), 1);
511        assert_eq!(provider_node.providers[0].provider_name, "SystemClock");
512        assert!(!provider_node.providers[0].external);
513
514        let consumer_node = &model.nodes[0];
515        assert_eq!(
516            consumer_node.capabilities,
517            vec![ArchCapability {
518                name: "Clock".to_string(),
519                origin: CapabilityOrigin::Consumed {
520                    from: "platformtime".to_string()
521                },
522                loc: consumer_node.capabilities[0].loc.clone(),
523            }]
524        );
525        // Regression: this node's declaration lives in `jobs.bynk`, not
526        // `platformtime.bynk` — a per-node uri computed from the wrong file
527        // would silently open the other context on click-to-code.
528        assert_eq!(consumer_node.loc.file, PathBuf::from("jobs.bynk"));
529
530        assert_eq!(model.edges.len(), 1);
531        let edge = &model.edges[0];
532        assert_eq!(edge.from, "ops.jobs");
533        assert_eq!(edge.to, "platformtime");
534        assert_eq!(edge.capabilities, vec!["Clock".to_string()]);
535        assert_eq!(edge.loc.file, PathBuf::from("jobs.bynk"));
536    }
537
538    // -- Regression: a multi-file unit's two files each `consumes` the same
539    // -- target (a different braced capability each) — the two `ConsumesDecl`s
540    // -- must merge into one edge (the union of both capability labels), not
541    // -- draw two arrows between the same pair of nodes, and the node's own
542    // -- bound-capability list must carry both without a stray duplicate.
543    const TWO_CAP_PROVIDER_SRC: &str = r#"context platformtime
544
545exports capability { Clock, Ping }
546
547capability Clock {
548  fn now() -> Effect[Int]
549}
550
551capability Ping {
552  fn ping() -> Effect[Int]
553}
554
555provides Clock = SystemClock {
556  fn now() -> Effect[Int] {
557    0
558  }
559}
560
561provides Ping = SystemPing {
562  fn ping() -> Effect[Int] {
563    1
564  }
565}
566"#;
567    const CONSUMER_FILE_A: &str = r#"context ops.jobs
568
569consumes platformtime { Clock }
570
571service run {
572  on call() -> Effect[Int] given Clock {
573    let now <- Clock.now()
574    now
575  }
576}
577"#;
578    const CONSUMER_FILE_B: &str = r#"context ops.jobs
579
580consumes platformtime { Ping }
581"#;
582
583    #[test]
584    fn a_multi_file_unit_s_repeated_consumes_target_merges_into_one_edge() {
585        let root = setup_project(
586            "multifile_consumes",
587            &[
588                ("platformtime.bynk", TWO_CAP_PROVIDER_SRC),
589                ("ops/jobs/a.bynk", CONSUMER_FILE_A),
590                ("ops/jobs/b.bynk", CONSUMER_FILE_B),
591            ],
592        );
593        let diag = crate::testkit::diagnose_project(&root);
594        let model = build_model(&diag);
595
596        assert_eq!(
597            model.edges.len(),
598            1,
599            "both consumes clauses target platformtime — one edge, not two"
600        );
601        let edge = &model.edges[0];
602        assert_eq!(edge.from, "ops.jobs");
603        assert_eq!(edge.to, "platformtime");
604        assert_eq!(
605            edge.capabilities,
606            vec!["Clock".to_string(), "Ping".to_string()]
607        );
608
609        let consumer = model.nodes.iter().find(|n| n.name == "ops.jobs").unwrap();
610        let cap_names: Vec<&str> = consumer
611            .capabilities
612            .iter()
613            .map(|c| c.name.as_str())
614            .collect();
615        assert_eq!(
616            cap_names,
617            vec!["Clock", "Ping"],
618            "no duplicate capability rows"
619        );
620    }
621
622    #[test]
623    fn whole_unit_consumes_draws_an_unlabelled_edge_with_no_bound_capability() {
624        const PLATFORM_SRC: &str = r#"context platform
625
626service Pinger {
627  on call(n: Int) -> Effect[Int] {
628    n
629  }
630}
631"#;
632        const CONSUMER_SRC: &str = r#"context consumer
633
634consumes platform
635
636service api {
637  on call(n: Int) -> Effect[Int] {
638    let v <- platform.Pinger(n)
639    v
640  }
641}
642"#;
643        let root = setup_project(
644            "wholeunit",
645            &[
646                ("platform.bynk", PLATFORM_SRC),
647                ("consumer.bynk", CONSUMER_SRC),
648            ],
649        );
650        let diag = crate::testkit::diagnose_project(&root);
651        let model = build_model(&diag);
652
653        assert_eq!(model.edges.len(), 1);
654        assert!(model.edges[0].capabilities.is_empty());
655        let consumer = model.nodes.iter().find(|n| n.name == "consumer").unwrap();
656        assert!(
657            consumer.capabilities.is_empty(),
658            "a whole-unit consumes flattens nothing into the local namespace"
659        );
660    }
661}