1use 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#[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 Local,
63 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 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#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ArchNode {
103 pub name: String,
105 pub kind: NodeKind,
106 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#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ArchEdge {
117 pub from: String,
118 pub to: String,
119 pub capabilities: Vec<String>,
122 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
132pub fn architecture_model(
144 unit_sources: &HashMap<String, Vec<PathBuf>>,
145 snapshots: &HashMap<PathBuf, String>,
146 sequence_info: &HashMap<String, ContextSequenceInfo>,
147) -> ArchModel {
148 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 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 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 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 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 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 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 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 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}