1use std::collections::HashMap;
35use std::path::PathBuf;
36
37use bynk_check::analysis::ContextBoundaryInfo;
38use bynk_check::checker::{Ty, TyId, Types};
39use bynk_check::contract;
40use bynk_check::resolver::CrossContextService;
41use bynk_check::wire::{self, WireModel, WireRef};
42use bynk_syntax::ast::*;
43use bynk_syntax::span::Span;
44
45const HTTP_RESULT: &str = bynk_check::builtin_names::types::HTTP_RESULT;
46
47#[derive(Debug, Clone, PartialEq)]
53pub enum BoundaryKind {
54 Http { method: HttpMethod, path: String },
55 Call,
56 Cron { expr: String },
57 Message,
58 Open,
59 Close,
60 Event,
61}
62
63impl BoundaryKind {
64 fn from_handler(h: &Handler) -> Self {
65 match &h.kind {
66 HandlerKind::Http { method, path } => BoundaryKind::Http {
67 method: *method,
68 path: path.clone(),
69 },
70 HandlerKind::Call => BoundaryKind::Call,
71 HandlerKind::Cron { expr } => BoundaryKind::Cron { expr: expr.clone() },
72 HandlerKind::Message => BoundaryKind::Message,
73 HandlerKind::Open => BoundaryKind::Open,
74 HandlerKind::Close => BoundaryKind::Close,
75 HandlerKind::Event => BoundaryKind::Event,
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
87pub enum Envelope {
88 Empty,
90 Bare { param: String, shape: WireRef },
93 Keyed { params: Vec<(String, WireRef)> },
96}
97
98#[derive(Debug, Clone)]
103pub struct ContractForm {
104 pub normal_form: String,
105 pub hash: String,
106}
107
108#[derive(Debug, Clone, PartialEq)]
112pub enum ResponseOrigin {
113 DeclaredSuccess,
116 Constructed { span: Span },
119 BoundaryImplicit { why: &'static str },
121}
122
123#[derive(Debug, Clone, PartialEq)]
125pub struct HttpResponse {
126 pub status: u16,
127 pub variant: String,
131 pub origin: ResponseOrigin,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum NoCrossContextReason {
138 NotACallHandler,
141 SingleContext,
144}
145
146#[derive(Debug, Clone)]
150pub struct WireContractModel {
151 pub unit: String,
152 pub service: String,
153 pub kind: BoundaryKind,
154 pub handler_span: Span,
155 pub handler_line: usize,
158 pub envelope: Envelope,
159 pub contract: Option<ContractForm>,
161 pub boundary: WireModel,
165 pub type_sites: HashMap<String, Span>,
168 pub responses: Vec<HttpResponse>,
170 pub no_cross_context: Option<NoCrossContextReason>,
171}
172
173pub fn real_context_count(
192 boundary_info: &HashMap<String, ContextBoundaryInfo>,
193 unit_sources: &HashMap<String, Vec<PathBuf>>,
194) -> usize {
195 boundary_info
196 .keys()
197 .filter(|k| unit_sources.contains_key(k.as_str()))
198 .count()
199}
200
201pub fn wire_contract_at(
215 unit: &str,
216 text: &str,
217 offset: usize,
218 info: &ContextBoundaryInfo,
219 expr_types: &[(Span, TyId)],
220 tys: &Types,
221 context_count: usize,
222) -> Option<WireContractModel> {
223 let tokens = bynk_syntax::lexer::tokenize(text).ok()?;
224 let (parsed, _errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
225 let items: &[CommonsItem] = match parsed.as_ref()? {
226 SourceUnit::Context(c) => &c.items,
227 SourceUnit::Adapter(a) => &a.items,
228 SourceUnit::Commons(_) | SourceUnit::Suite(_) => return None,
229 };
230 for item in items {
231 if let CommonsItem::Service(s) = item
232 && let Some(h) = handler_at(&s.handlers, offset)
233 {
234 return wire_contract_for_service(
235 unit,
236 text,
237 &s.name.name,
238 h,
239 info,
240 expr_types,
241 tys,
242 context_count,
243 );
244 }
245 }
246 None
247}
248
249fn handler_at(handlers: &[Handler], offset: usize) -> Option<&Handler> {
250 handlers
251 .iter()
252 .find(|h| h.span.start <= offset && offset < h.span.end)
253}
254
255#[allow(clippy::too_many_arguments)]
269pub fn wire_contract_for_service(
270 unit: &str,
271 text: &str,
272 service_name: &str,
273 handler: &Handler,
274 info: &ContextBoundaryInfo,
275 expr_types: &[(Span, TyId)],
276 tys: &Types,
277 context_count: usize,
278) -> Option<WireContractModel> {
279 let real = info.services.get(service_name)?;
280 let mut synthetic = real.clone();
281 synthetic.handlers = vec![handler.clone()];
282 let services: HashMap<String, ServiceDecl> =
283 HashMap::from([(service_name.to_string(), synthetic)]);
284 let agents: HashMap<String, AgentDecl> = HashMap::new();
285
286 let boundary_names = wire::collect_boundary_types(&info.types, &services, &agents);
287 let insts =
288 wire::collect_generic_instantiations(&services, &agents, &boundary_names, &info.types);
289 let boundary = wire::boundary_model(&boundary_names, &info.types, insts, |_| {
292 wire::Provenance::Owned
293 });
294
295 let type_sites: HashMap<String, Span> = boundary_names
296 .iter()
297 .filter_map(|n| info.types.get(n).map(|d| (n.clone(), d.span)))
298 .collect();
299
300 let envelope = envelope_for(handler, &info.types);
301 let kind = BoundaryKind::from_handler(handler);
302
303 let no_cross_context = if context_count <= 1 {
304 Some(NoCrossContextReason::SingleContext)
305 } else if handler.kind != HandlerKind::Call {
306 Some(NoCrossContextReason::NotACallHandler)
307 } else {
308 None
309 };
310 let contract = if no_cross_context.is_none() {
311 Some(contract_form(service_name, handler, &info.types))
312 } else {
313 None
314 };
315
316 let responses = if matches!(kind, BoundaryKind::Http { .. }) {
317 http_responses(handler, expr_types, tys)
318 } else {
319 Vec::new()
320 };
321
322 Some(WireContractModel {
323 unit: unit.to_string(),
324 service: service_name.to_string(),
325 kind,
326 handler_span: handler.span,
327 handler_line: line_of(text, handler.span.start),
328 envelope,
329 contract,
330 boundary,
331 type_sites,
332 responses,
333 no_cross_context,
334 })
335}
336
337fn line_of(text: &str, offset: usize) -> usize {
339 text.get(..offset).unwrap_or(text).matches('\n').count() + 1
340}
341
342fn envelope_for(handler: &Handler, types: &HashMap<String, std::sync::Arc<TypeDecl>>) -> Envelope {
343 match handler.params.as_slice() {
344 [] => Envelope::Empty,
345 [p] => Envelope::Bare {
346 param: p.name.name.clone(),
347 shape: wire::wire_ref(&p.type_ref, types),
348 },
349 params => Envelope::Keyed {
350 params: params
351 .iter()
352 .map(|p| (p.name.name.clone(), wire::wire_ref(&p.type_ref, types)))
353 .collect(),
354 },
355 }
356}
357
358fn contract_form(
363 service_name: &str,
364 handler: &Handler,
365 types: &HashMap<String, std::sync::Arc<TypeDecl>>,
366) -> ContractForm {
367 let svc = CrossContextService {
368 name: service_name.to_string(),
369 params: handler
370 .params
371 .iter()
372 .map(|p| (p.name.name.clone(), p.type_ref.clone()))
373 .collect(),
374 return_type: handler.return_type.clone(),
375 span: handler.span,
376 };
377 let normal_form = contract::service_normal_form(&svc, types);
378 let hash = contract::contract_hash(&normal_form);
379 ContractForm { normal_form, hash }
380}
381
382fn http_responses(
388 handler: &Handler,
389 expr_types: &[(Span, TyId)],
390 tys: &Types,
391) -> Vec<HttpResponse> {
392 let declared_is_http_result =
393 matches!(strip_effect(&handler.return_type), TypeRef::HttpResult(..));
394
395 let mut out = Vec::new();
396 if declared_is_http_result {
397 out.push(HttpResponse {
398 status: 200,
399 variant: "Ok".to_string(),
400 origin: ResponseOrigin::DeclaredSuccess,
401 });
402 }
403
404 let mut walk = ResponseWalk {
405 expr_types,
406 tys,
407 declared_is_http_result,
408 seen: out.iter().map(|r| r.variant.clone()).collect(),
409 saw_option_question: false,
410 out: Vec::new(),
411 };
412 walk.walk_block(&handler.body);
413 out.extend(walk.out);
414
415 if !handler.params.is_empty() {
416 out.push(HttpResponse {
417 status: 400,
418 variant: "StructuralMismatch".to_string(),
419 origin: ResponseOrigin::BoundaryImplicit {
420 why: "every param is structurally re-validated on the way in; a malformed or \
421 refinement-violating value fails closed with a 400 the handler body \
422 never names",
423 },
424 });
425 }
426 if walk.saw_option_question {
427 out.push(HttpResponse {
428 status: 404,
429 variant: "NotFound".to_string(),
430 origin: ResponseOrigin::BoundaryImplicit {
431 why: "an `Option?` short-circuits to 404 on `None` (ADR 0177)",
432 },
433 });
434 }
435 out
436}
437
438fn strip_effect(t: &TypeRef) -> &TypeRef {
442 match t {
443 TypeRef::Effect(inner, _) => inner.as_ref(),
444 other => other,
445 }
446}
447
448struct ResponseWalk<'a> {
458 expr_types: &'a [(Span, TyId)],
459 tys: &'a Types,
461 declared_is_http_result: bool,
472 seen: std::collections::HashSet<String>,
473 saw_option_question: bool,
474 out: Vec<HttpResponse>,
475}
476
477impl<'a> ResponseWalk<'a> {
478 fn expr_ty(&self, span: Span) -> Option<std::sync::Arc<Ty>> {
479 self.expr_types
480 .iter()
481 .find(|(s, _)| *s == span)
482 .map(|(_, t)| self.tys.get(*t))
483 }
484
485 fn is_http_result_expr(&self, span: Span) -> bool {
494 match self.expr_ty(span).as_deref() {
495 Some(Ty::HttpResult(_)) => true,
496 Some(_) => false,
497 None => self.declared_is_http_result,
498 }
499 }
500
501 fn is_http_result_ident(&self, span: Span) -> bool {
512 matches!(self.expr_ty(span).as_deref(), Some(Ty::HttpResult(_)))
513 }
514
515 fn push(&mut self, variant: HttpVariant, span: Span) {
516 if self.seen.insert(variant.name.to_string()) {
517 self.out.push(HttpResponse {
518 status: variant.status,
519 variant: variant.name.to_string(),
520 origin: ResponseOrigin::Constructed { span },
521 });
522 }
523 }
524
525 fn walk_block(&mut self, b: &Block) {
526 for s in &b.statements {
527 let mut exprs = Vec::new();
528 statement_exprs(s, &mut exprs);
529 for e in exprs {
530 self.walk_expr(e);
531 }
532 }
533 self.walk_expr(&b.tail);
534 }
535
536 fn walk_expr(&mut self, e: &Expr) {
537 self.classify(e);
538 for child in expr_children(e) {
539 self.walk_expr(child);
540 }
541 }
542
543 fn classify(&mut self, e: &Expr) {
550 match &e.kind {
551 ExprKind::MethodCall {
552 receiver, method, ..
553 } => {
554 if let ExprKind::Ident(id) = &receiver.kind
555 && id.name == HTTP_RESULT
556 && let Some(v) = http_variant(&method.name)
557 {
558 self.push(v, e.span);
559 }
560 }
561 ExprKind::FieldAccess { receiver, field } => {
562 if let ExprKind::Ident(id) = &receiver.kind
563 && id.name == HTTP_RESULT
564 && let Some(v) = http_variant(&field.name)
565 {
566 self.push(v, e.span);
567 }
568 }
569 ExprKind::Ident(id) => {
570 if self.is_http_result_ident(e.span)
571 && let Some(v) = http_variant(&id.name)
572 {
573 self.push(v, e.span);
574 }
575 }
576 ExprKind::Call { name, .. } => {
577 if self.is_http_result_expr(e.span)
578 && let Some(v) = http_variant(&name.name)
579 {
580 self.push(v, e.span);
581 }
582 }
583 ExprKind::Ok(_) => {
584 if self.is_http_result_expr(e.span)
585 && let Some(v) = http_variant("Ok")
586 {
587 self.push(v, e.span);
588 }
589 }
590 ExprKind::Question(inner) => {
591 if matches!(self.expr_ty(inner.span).as_deref(), Some(Ty::Option(_))) {
592 self.saw_option_question = true;
593 }
594 }
595 _ => {}
596 }
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use std::path::PathBuf;
604
605 fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
609 let root = std::env::temp_dir().join(format!(
610 "bynk-ide-wire-contract-test-{test_name}-{}",
611 std::process::id()
612 ));
613 let _ = std::fs::remove_dir_all(&root);
614 std::fs::create_dir_all(&root).expect("create test root");
615 for (rel, contents) in files {
616 let p = root.join(rel);
617 if let Some(parent) = p.parent() {
618 std::fs::create_dir_all(parent).expect("create parent");
619 }
620 std::fs::write(&p, contents).expect("write file");
621 }
622 root
623 }
624
625 const RATELIMIT_SRC: &str = r#"context ratelimit
630
631consumes bynk { Clock }
632
633type ClientId = String where NonEmpty
634
635type RateView = {
636 allowed: Bool,
637 remaining: Int,
638 resetAt: Int,
639}
640
641agent Limiter {
642 key client: ClientId
643
644 store count: Cell[Int]
645
646 on call hit(now: Int) -> Effect[RateView] {
647 let _ <- count.update((c) => c + 1)
648 RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
649 }
650}
651
652service api from http {
653 on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
654 let now <- Clock.now()
655 let view <- Limiter(client).hit(now.toEpochMillis())
656 if view.allowed {
657 Ok(view)
658 } else {
659 TooManyRequests("rate limit exceeded")
660 }
661 }
662}
663"#;
664
665 fn find_offset(text: &str, needle: &str) -> usize {
666 text.find(needle)
667 .unwrap_or_else(|| panic!("`{needle}` not found in fixture"))
668 }
669
670 fn real_context_count(diag: &crate::ProjectDiagnostics) -> usize {
675 super::real_context_count(&diag.boundary_info, &diag.unit_sources)
676 }
677
678 #[test]
679 fn rate_limiter_get_check_client_is_a_bare_envelope_with_a_revalidated_client_id() {
680 let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
681 let diag = crate::testkit::diagnose_project(&root);
682 let info = diag
683 .boundary_info
684 .get("ratelimit")
685 .expect("boundary_info entry for ratelimit");
686
687 let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
688 let model = wire_contract_at(
689 "ratelimit",
690 RATELIMIT_SRC,
691 offset,
692 info,
693 &[],
694 &diag.ty_intern,
695 real_context_count(&diag),
696 )
697 .expect("a wire contract at the GET handler's header");
698
699 assert_eq!(model.unit, "ratelimit");
700 assert_eq!(model.service, "api");
701 assert_eq!(
702 model.kind,
703 BoundaryKind::Http {
704 method: HttpMethod::Get,
705 path: "/check/:client".to_string(),
706 }
707 );
708
709 let (param, shape) = match &model.envelope {
712 Envelope::Bare { param, shape } => (param, shape),
713 other => panic!("expected Envelope::Bare, got {other:?}"),
714 };
715 assert_eq!(param, "client");
716 assert!(
717 matches!(shape, WireRef::Named { name } if name == "ClientId"),
718 "the bare param's shape should resolve to the named ClientId type: {shape:?}"
719 );
720
721 let client_id = model
725 .boundary
726 .types
727 .iter()
728 .find(|t| t.name == "ClientId")
729 .expect("ClientId is a boundary type");
730 assert_eq!(client_id.provenance, bynk_check::wire::Provenance::Owned);
731 let bynk_check::wire::WireBody::Scalar(scalar) = &client_id.body else {
732 panic!("ClientId should be a scalar, got {:?}", client_id.body);
733 };
734 assert!(
735 scalar
736 .predicates
737 .iter()
738 .any(|p| matches!(p, PredKind::NonEmpty)),
739 "ClientId's predicates should include NonEmpty: {:?}",
740 scalar.predicates
741 );
742 assert_eq!(
743 scalar.revalidation,
744 bynk_check::wire::Revalidation::ViaConstructor
745 );
746 assert!(model.type_sites.contains_key("ClientId"));
747
748 assert_eq!(
752 model.no_cross_context,
753 Some(NoCrossContextReason::SingleContext)
754 );
755 assert!(model.contract.is_none());
756 }
757
758 #[test]
759 fn rate_limiter_response_set_has_declared_constructed_and_boundary_implicit() {
760 let root = setup_project("ratelimit-responses", &[("ratelimit.bynk", RATELIMIT_SRC)]);
761 let diag = crate::testkit::diagnose_project(&root);
762 let info = diag.boundary_info.get("ratelimit").expect("entry");
763
764 let rel = diag
767 .files
768 .iter()
769 .find(|f| {
770 f.source_path
771 .file_name()
772 .is_some_and(|n| n == "ratelimit.bynk")
773 })
774 .map(|f| f.source_path.clone())
775 .expect("ratelimit.bynk in the round's files");
776 let expr_types: &[(Span, TyId)] = diag
777 .expr_types
778 .get(&rel)
779 .map(|v| v.as_slice())
780 .unwrap_or(&[]);
781
782 let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
783 let model = wire_contract_at(
784 "ratelimit",
785 RATELIMIT_SRC,
786 offset,
787 info,
788 expr_types,
789 &diag.ty_intern,
790 real_context_count(&diag),
791 )
792 .expect("a wire contract at the GET handler's header");
793
794 let statuses: Vec<(u16, &str)> = model
795 .responses
796 .iter()
797 .map(|r| (r.status, r.variant.as_str()))
798 .collect();
799 assert!(
800 statuses.contains(&(200, "Ok")),
801 "declared success missing: {statuses:?}"
802 );
803 assert!(
804 statuses.contains(&(429, "TooManyRequests")),
805 "constructed TooManyRequests missing: {statuses:?}"
806 );
807 assert!(
808 statuses.iter().any(|&(s, _)| s == 400),
809 "boundary-implicit 400 (the handler has a param) missing: {statuses:?}"
810 );
811 assert!(
812 model
813 .responses
814 .iter()
815 .find(|r| r.status == 200)
816 .is_some_and(|r| r.origin == ResponseOrigin::DeclaredSuccess)
817 );
818 assert!(
819 model.responses.iter().any(|r| matches!(
820 r.origin,
821 ResponseOrigin::BoundaryImplicit { .. }
822 ) && r.status == 400)
823 );
824 }
825
826 const PROVIDER_SRC: &str = r#"context billing
829
830type Quote = { amount: Int, currency: String }
831
832service Pricing {
833 on call(sku: String, qty: Int) -> Effect[Quote] {
834 Quote { amount: qty * 100, currency: "USD" }
835 }
836}
837"#;
838 const CONSUMER_SRC: &str = r#"context storefront
839
840consumes billing
841
842service checkout {
843 on call(sku: String, qty: Int) -> Effect[Int] {
844 let q <- billing.Pricing(sku, qty)
845 q.amount
846 }
847}
848"#;
849
850 #[test]
851 fn two_context_call_handler_has_a_keyed_envelope_and_a_contract_hash() {
852 let root = setup_project(
853 "two-context",
854 &[
855 ("billing.bynk", PROVIDER_SRC),
856 ("storefront.bynk", CONSUMER_SRC),
857 ],
858 );
859 let diag = crate::testkit::diagnose_project(&root);
860 let info = diag
861 .boundary_info
862 .get("billing")
863 .expect("boundary_info entry for billing");
864
865 let offset = find_offset(PROVIDER_SRC, "on call(");
866 let model = wire_contract_at(
867 "billing",
868 PROVIDER_SRC,
869 offset,
870 info,
871 &[],
872 &diag.ty_intern,
873 real_context_count(&diag),
874 )
875 .expect("a wire contract at the `price` handler");
876
877 assert_eq!(model.kind, BoundaryKind::Call);
878 let params = match &model.envelope {
879 Envelope::Keyed { params } => params,
880 other => panic!("expected Envelope::Keyed for a two-param call, got {other:?}"),
881 };
882 assert_eq!(
883 params.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
884 vec!["sku", "qty"],
885 "keyed params stay in declaration order"
886 );
887
888 assert!(model.no_cross_context.is_none(), "two contexts + on call");
889 let contract = model.contract.expect("on call gets a contract form");
890 assert_eq!(
891 contract.hash,
892 bynk_check::contract::contract_hash(&contract.normal_form)
893 );
894
895 let svc = CrossContextService {
899 name: "Pricing".to_string(),
900 params: vec![
901 (
902 "sku".to_string(),
903 TypeRef::Base(BaseType::String, Span::new(0, 0)),
904 ),
905 (
906 "qty".to_string(),
907 TypeRef::Base(BaseType::Int, Span::new(0, 0)),
908 ),
909 ],
910 return_type: TypeRef::Named(Ident {
911 name: "Quote".to_string(),
912 span: Span::new(0, 0),
913 }),
914 span: Span::new(0, 0),
915 };
916 let independent_form = contract::service_normal_form(&svc, &info.types);
917 assert_eq!(contract.normal_form, independent_form);
918 assert_eq!(contract.hash, contract::contract_hash(&independent_form));
919 }
920
921 #[test]
922 fn zero_param_call_handler_is_the_empty_envelope() {
923 let src = r#"context solo
924
925service Ping {
926 on call() -> Effect[Int] {
927 1
928 }
929}
930"#;
931 let root = setup_project("zero-param", &[("solo.bynk", src)]);
932 let diag = crate::testkit::diagnose_project(&root);
933 let info = diag.boundary_info.get("solo").expect("entry");
934
935 let offset = find_offset(src, "on call()");
936 let model = wire_contract_at(
937 "solo",
938 src,
939 offset,
940 info,
941 &[],
942 &diag.ty_intern,
943 real_context_count(&diag),
944 )
945 .expect("a wire contract at the `Ping` handler");
946
947 assert!(
948 matches!(model.envelope, Envelope::Empty),
949 "zero params: the request body is not read, not an empty keyed object"
950 );
951 assert_eq!(
954 model.no_cross_context,
955 Some(NoCrossContextReason::SingleContext)
956 );
957 }
958
959 #[test]
965 fn bare_ident_collision_with_a_variant_name_is_not_misreported_without_expr_types() {
966 const SRC: &str = r#"context oddnames
967
968service api from http {
969 on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
970 let Found = 1
971 Found
972 }
973}
974"#;
975 let root = setup_project("ident-collision", &[("oddnames.bynk", SRC)]);
976 let diag = crate::testkit::diagnose_project(&root);
977 let info = diag
978 .boundary_info
979 .get("oddnames")
980 .expect("boundary_info entry for oddnames");
981
982 let offset = find_offset(SRC, "GET(\"/x\")");
983 let model = wire_contract_at(
987 "oddnames",
988 SRC,
989 offset,
990 info,
991 &[],
992 &diag.ty_intern,
993 real_context_count(&diag),
994 )
995 .expect("a wire contract at the GET handler");
996
997 assert!(
998 model.responses.iter().all(|r| r.variant != "Found"),
999 "a bare `Found` local must not be misreported as HttpResult.Found \
1000 without a recorded expr type: {:?}",
1001 model.responses
1002 );
1003 }
1004}