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::{FileId, Span};
44
45const HTTP_RESULT: &str = bynk_check::builtin_names::types::HTTP_RESULT;
46
47#[derive(Debug, Clone, PartialEq)]
53pub enum BoundaryKind {
54 Http {
55 method: HttpMethod,
56 path: String,
57 },
58 Call,
59 Cron {
60 expr: String,
61 },
62 Message,
63 Open,
64 Close,
65 Event,
66 Rejected,
68}
69
70impl BoundaryKind {
71 fn from_handler(h: &Handler) -> Self {
72 match &h.kind {
73 HandlerKind::Http { method, path } => BoundaryKind::Http {
74 method: *method,
75 path: path.clone(),
76 },
77 HandlerKind::Call => BoundaryKind::Call,
78 HandlerKind::Cron { expr } => BoundaryKind::Cron { expr: expr.clone() },
79 HandlerKind::Message => BoundaryKind::Message,
80 HandlerKind::Open => BoundaryKind::Open,
81 HandlerKind::Close => BoundaryKind::Close,
82 HandlerKind::Event => BoundaryKind::Event,
83 HandlerKind::Rejected => BoundaryKind::Rejected,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
95pub enum Envelope {
96 Empty,
98 Bare { param: String, shape: WireRef },
101 Keyed { params: Vec<(String, WireRef)> },
104 Raw { param: String, content_type: String },
108}
109
110#[derive(Debug, Clone)]
115pub struct ContractForm {
116 pub normal_form: String,
117 pub hash: String,
118}
119
120#[derive(Debug, Clone, PartialEq)]
124pub enum ResponseOrigin {
125 DeclaredSuccess,
128 Constructed { span: Span },
131 BoundaryImplicit { why: &'static str },
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct HttpResponse {
138 pub status: u16,
139 pub variant: String,
143 pub origin: ResponseOrigin,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum NoCrossContextReason {
150 NotACallHandler,
153 SingleContext,
156}
157
158#[derive(Debug, Clone)]
162pub struct WireContractModel {
163 pub unit: String,
164 pub service: String,
165 pub kind: BoundaryKind,
166 pub handler_span: Span,
167 pub handler_line: usize,
170 pub envelope: Envelope,
171 pub contract: Option<ContractForm>,
173 pub boundary: WireModel,
177 pub type_sites: HashMap<String, Span>,
180 pub responses: Vec<HttpResponse>,
182 pub no_cross_context: Option<NoCrossContextReason>,
183}
184
185pub fn real_context_count(
204 boundary_info: &HashMap<String, ContextBoundaryInfo>,
205 unit_sources: &HashMap<String, Vec<PathBuf>>,
206) -> usize {
207 boundary_info
208 .keys()
209 .filter(|k| unit_sources.contains_key(k.as_str()))
210 .count()
211}
212
213pub fn wire_contract_at(
227 unit: &str,
228 text: &str,
229 offset: usize,
230 info: &ContextBoundaryInfo,
231 expr_types: &[(Span, TyId)],
232 tys: &Types,
233 context_count: usize,
234) -> Option<WireContractModel> {
235 let tokens = bynk_syntax::lexer::tokenize(text).ok()?;
236 let (parsed, _errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
237 let items: &[CommonsItem] = match parsed.as_ref()? {
238 SourceUnit::Context(c) => &c.items,
239 SourceUnit::Adapter(a) => &a.items,
240 SourceUnit::Commons(_) | SourceUnit::Suite(_) => return None,
241 };
242 for item in items {
243 if let CommonsItem::Service(s) = item
244 && let Some(h) = handler_at(&s.handlers, offset)
245 {
246 return wire_contract_for_service(
247 unit,
248 text,
249 &s.name.name,
250 h,
251 info,
252 expr_types,
253 tys,
254 context_count,
255 );
256 }
257 }
258 None
259}
260
261fn handler_at(handlers: &[Handler], offset: usize) -> Option<&Handler> {
262 handlers
263 .iter()
264 .find(|h| h.span.start <= offset && offset < h.span.end)
265}
266
267#[allow(clippy::too_many_arguments)]
281pub fn wire_contract_for_service(
282 unit: &str,
283 text: &str,
284 service_name: &str,
285 handler: &Handler,
286 info: &ContextBoundaryInfo,
287 expr_types: &[(Span, TyId)],
288 tys: &Types,
289 context_count: usize,
290) -> Option<WireContractModel> {
291 let real = info.services.get(service_name)?;
292 let mut synthetic = real.clone();
293 synthetic.handlers = vec![handler.clone()];
294 let services: HashMap<String, ServiceDecl> =
295 HashMap::from([(service_name.to_string(), synthetic)]);
296 let agents: HashMap<String, AgentDecl> = HashMap::new();
297
298 let boundary_names = wire::collect_boundary_types(&info.types, &services, &agents, &[]);
299 let insts =
300 wire::collect_generic_instantiations(&services, &agents, &boundary_names, &info.types, &[]);
301 let boundary = wire::boundary_model(&boundary_names, &info.types, insts, |_| {
304 wire::Provenance::Owned
305 });
306
307 let type_sites: HashMap<String, Span> = boundary_names
308 .iter()
309 .filter_map(|n| info.types.get(n).map(|d| (n.clone(), d.span)))
310 .collect();
311
312 let envelope = envelope_for(handler, &info.types);
313 let kind = BoundaryKind::from_handler(handler);
314
315 let no_cross_context = if context_count <= 1 {
316 Some(NoCrossContextReason::SingleContext)
317 } else if handler.kind != HandlerKind::Call {
318 Some(NoCrossContextReason::NotACallHandler)
319 } else {
320 None
321 };
322 let contract = if no_cross_context.is_none() {
323 Some(contract_form(service_name, handler, &info.types))
324 } else {
325 None
326 };
327
328 let responses = if matches!(kind, BoundaryKind::Http { .. }) {
329 http_responses(handler, real.span.file, expr_types, tys)
330 } else {
331 Vec::new()
332 };
333
334 Some(WireContractModel {
335 unit: unit.to_string(),
336 service: service_name.to_string(),
337 kind,
338 handler_span: handler.span,
339 handler_line: line_of(text, handler.span.start),
340 envelope,
341 contract,
342 boundary,
343 type_sites,
344 responses,
345 no_cross_context,
346 })
347}
348
349fn line_of(text: &str, offset: usize) -> usize {
351 text.get(..offset).unwrap_or(text).matches('\n').count() + 1
352}
353
354fn envelope_for(handler: &Handler, types: &HashMap<String, std::sync::Arc<TypeDecl>>) -> Envelope {
355 if let Some(content_type) = handler.raw_content_type() {
356 return Envelope::Raw {
357 param: "body".to_string(),
358 content_type: content_type.to_string(),
359 };
360 }
361 match handler.params.as_slice() {
362 [] => Envelope::Empty,
363 [p] => Envelope::Bare {
364 param: p.name.name.clone(),
365 shape: wire::wire_ref(&p.type_ref, types),
366 },
367 params => Envelope::Keyed {
368 params: params
369 .iter()
370 .map(|p| (p.name.name.clone(), wire::wire_ref(&p.type_ref, types)))
371 .collect(),
372 },
373 }
374}
375
376fn contract_form(
381 service_name: &str,
382 handler: &Handler,
383 types: &HashMap<String, std::sync::Arc<TypeDecl>>,
384) -> ContractForm {
385 let svc = CrossContextService {
386 name: service_name.to_string(),
387 params: handler
388 .params
389 .iter()
390 .map(|p| (p.name.name.clone(), p.type_ref.clone()))
391 .collect(),
392 return_type: handler.return_type.clone(),
393 span: handler.span,
394 foreign_callers: Vec::new(),
395 };
396 let normal_form = contract::service_normal_form(&svc, types);
397 let hash = contract::contract_hash(&normal_form);
398 ContractForm { normal_form, hash }
399}
400
401fn http_responses(
411 handler: &Handler,
412 file: FileId,
413 expr_types: &[(Span, TyId)],
414 tys: &Types,
415) -> Vec<HttpResponse> {
416 let declared_is_http_result =
417 matches!(strip_effect(&handler.return_type), TypeRef::HttpResult(..));
418
419 let mut out = Vec::new();
420 if declared_is_http_result {
421 out.push(HttpResponse {
422 status: 200,
423 variant: "Ok".to_string(),
424 origin: ResponseOrigin::DeclaredSuccess,
425 });
426 }
427
428 let mut walk = ResponseWalk {
429 expr_types,
430 file,
431 tys,
432 declared_is_http_result,
433 seen: out.iter().map(response_key).collect(),
434 saw_option_question: false,
435 out: Vec::new(),
436 };
437 walk.walk_block(&handler.body);
438 out.extend(walk.out);
439
440 if !handler.params.is_empty() {
441 out.push(HttpResponse {
442 status: 400,
443 variant: "StructuralMismatch".to_string(),
444 origin: ResponseOrigin::BoundaryImplicit {
445 why: "every param is structurally re-validated on the way in; a malformed or \
446 refinement-violating value fails closed with a 400 the handler body \
447 never names",
448 },
449 });
450 }
451 if handler.raw_content_type().is_some() {
452 out.push(HttpResponse {
453 status: 413,
454 variant: "PayloadTooLarge".to_string(),
455 origin: ResponseOrigin::BoundaryImplicit {
456 why: "a raw body is counted as it is read and cut off at the route's cap",
457 },
458 });
459 out.push(HttpResponse {
460 status: 415,
461 variant: "UnsupportedMediaType".to_string(),
462 origin: ResponseOrigin::BoundaryImplicit {
463 why: "the request's Content-Type is not the media type `@raw` declares",
464 },
465 });
466 }
467 if walk.saw_option_question {
468 out.push(HttpResponse {
469 status: 404,
470 variant: "NotFound".to_string(),
471 origin: ResponseOrigin::BoundaryImplicit {
472 why: "an `Option?` short-circuits to 404 on `None` (ADR 0177)",
473 },
474 });
475 }
476 out
477}
478
479fn strip_effect(t: &TypeRef) -> &TypeRef {
483 match t {
484 TypeRef::Effect(inner, _) => inner.as_ref(),
485 other => other,
486 }
487}
488
489struct ResponseWalk<'a> {
499 expr_types: &'a [(Span, TyId)],
500 file: FileId,
506 tys: &'a Types,
508 declared_is_http_result: bool,
519 seen: std::collections::HashSet<String>,
520 saw_option_question: bool,
521 out: Vec<HttpResponse>,
522}
523
524impl<'a> ResponseWalk<'a> {
525 fn expr_ty(&self, span: Span) -> Option<std::sync::Arc<Ty>> {
533 self.expr_types
534 .iter()
535 .find(|(s, _)| s.file == self.file && s.start == span.start && s.end == span.end)
536 .map(|(_, t)| self.tys.get(*t))
537 .filter(|t| !matches!(**t, Ty::Error))
538 }
539
540 fn is_http_result_expr(&self, span: Span) -> bool {
549 match self.expr_ty(span).as_deref() {
550 Some(Ty::HttpResult(_)) => true,
551 Some(_) => false,
552 None => self.declared_is_http_result,
553 }
554 }
555
556 fn is_http_result_ident(&self, span: Span) -> bool {
567 matches!(self.expr_ty(span).as_deref(), Some(Ty::HttpResult(_)))
568 }
569
570 fn push(&mut self, variant: HttpVariant, span: Span) {
571 self.push_status(variant, variant.status, span);
572 }
573
574 fn push_raw_error(&mut self, variant: HttpVariant, args: &[Expr], span: Span) {
577 if let Some(status) = args.first().and_then(raw_error_status) {
578 self.push_status(variant, status, span);
579 }
580 }
581
582 fn push_status(&mut self, variant: HttpVariant, status: u16, span: Span) {
583 if self.seen.insert(variant_key(variant.name, status)) {
584 self.out.push(HttpResponse {
585 status,
586 variant: variant.name.to_string(),
587 origin: ResponseOrigin::Constructed { span },
588 });
589 }
590 }
591
592 fn walk_block(&mut self, b: &Block) {
593 for s in &b.statements {
594 let mut exprs = Vec::new();
595 statement_exprs(s, &mut exprs);
596 for e in exprs {
597 self.walk_expr(e);
598 }
599 }
600 self.walk_expr(&b.tail);
601 }
602
603 fn walk_expr(&mut self, e: &Expr) {
604 self.classify(e);
605 for child in expr_children(e) {
606 self.walk_expr(child);
607 }
608 }
609
610 fn classify(&mut self, e: &Expr) {
617 match &e.kind {
618 ExprKind::MethodCall {
619 receiver,
620 method,
621 args,
622 ..
623 } => {
624 if let ExprKind::Ident(id) = &receiver.kind
625 && id.name == HTTP_RESULT
626 && let Some(v) = http_variant(&method.name)
627 {
628 if v.status == AUTHOR_CHOSEN_STATUS {
629 self.push_raw_error(v, args, e.span);
630 } else {
631 self.push(v, e.span);
632 }
633 }
634 }
635 ExprKind::FieldAccess { receiver, field } => {
636 if let ExprKind::Ident(id) = &receiver.kind
637 && id.name == HTTP_RESULT
638 && let Some(v) = http_variant(&field.name)
639 {
640 self.push(v, e.span);
641 }
642 }
643 ExprKind::Ident(id) => {
644 if self.is_http_result_ident(e.span)
645 && let Some(v) = http_variant(&id.name)
646 {
647 self.push(v, e.span);
648 }
649 }
650 ExprKind::Call { name, args, .. } => {
651 if self.is_http_result_expr(e.span)
652 && let Some(v) = http_variant(&name.name)
653 {
654 if v.status == AUTHOR_CHOSEN_STATUS {
655 self.push_raw_error(v, args, e.span);
656 } else {
657 self.push(v, e.span);
658 }
659 }
660 }
661 ExprKind::Ok(_) => {
662 if self.is_http_result_expr(e.span)
663 && let Some(v) = http_variant("Ok")
664 {
665 self.push(v, e.span);
666 }
667 }
668 ExprKind::Question(inner) => {
669 if matches!(self.expr_ty(inner.span).as_deref(), Some(Ty::Option(_))) {
670 self.saw_option_question = true;
671 }
672 }
673 _ => {}
674 }
675 }
676}
677
678fn variant_key(variant: &str, status: u16) -> String {
681 match http_variant(variant) {
682 Some(v) if v.status == AUTHOR_CHOSEN_STATUS => format!("{variant} {status}"),
683 _ => variant.to_string(),
684 }
685}
686
687fn response_key(r: &HttpResponse) -> String {
688 variant_key(&r.variant, r.status)
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use std::path::PathBuf;
695
696 fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
700 let root = std::env::temp_dir().join(format!(
701 "bynk-ide-wire-contract-test-{test_name}-{}",
702 std::process::id()
703 ));
704 let _ = std::fs::remove_dir_all(&root);
705 std::fs::create_dir_all(&root).expect("create test root");
706 for (rel, contents) in files {
707 let p = root.join(rel);
708 if let Some(parent) = p.parent() {
709 std::fs::create_dir_all(parent).expect("create parent");
710 }
711 std::fs::write(&p, contents).expect("write file");
712 }
713 root
714 }
715
716 const RATELIMIT_SRC: &str = r#"context ratelimit
721
722consumes bynk { Clock }
723
724type ClientId = String where NonEmpty
725
726type RateView = {
727 allowed: Bool,
728 remaining: Int,
729 resetAt: Int,
730}
731
732agent Limiter {
733 key client: ClientId
734
735 store count: Cell[Int]
736
737 on call hit(now: Int) -> Effect[RateView] {
738 let _ <- count.update((c) => c + 1)
739 RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
740 }
741}
742
743service api from http {
744 on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
745 let now <- Clock.now()
746 let view <- Limiter(client).hit(now.toEpochMillis())
747 if view.allowed {
748 Ok(view)
749 } else {
750 TooManyRequests("rate limit exceeded")
751 }
752 }
753}
754"#;
755
756 fn find_offset(text: &str, needle: &str) -> usize {
757 text.find(needle)
758 .unwrap_or_else(|| panic!("`{needle}` not found in fixture"))
759 }
760
761 fn real_context_count(diag: &crate::ProjectDiagnostics) -> usize {
766 super::real_context_count(&diag.boundary_info, &diag.unit_sources)
767 }
768
769 const RAW_SRC: &str = r#"context uploads
770
771service api from http {
772 limits { maxBody: 1000 }
773
774 @raw(contentType: "image/png")
775 on PUT("/avatars/:user") (user: String, body: Bytes) -> Effect[HttpResult[String]] by Visitor {
776 Ok(user)
777 }
778}
779"#;
780
781 #[test]
782 fn a_raw_route_is_a_raw_envelope_whatever_its_other_params_with_413_and_415() {
783 let root = setup_project("uploads", &[("uploads.bynk", RAW_SRC)]);
784 let diag = crate::testkit::diagnose_project(&root);
785 let info = diag
786 .boundary_info
787 .get("uploads")
788 .expect("boundary_info entry for uploads");
789 let offset = find_offset(RAW_SRC, "PUT(\"/avatars/:user\")");
790 let model = wire_contract_at(
791 "uploads",
792 RAW_SRC,
793 offset,
794 info,
795 &[],
796 &diag.ty_intern,
797 real_context_count(&diag),
798 )
799 .expect("a wire contract at the PUT handler's header");
800
801 match &model.envelope {
802 Envelope::Raw {
803 param,
804 content_type,
805 } => {
806 assert_eq!(param, "body");
807 assert_eq!(content_type, "image/png");
808 }
809 other => panic!("expected Envelope::Raw, got {other:?}"),
810 }
811 let statuses: Vec<u16> = model.responses.iter().map(|r| r.status).collect();
812 assert!(statuses.contains(&413), "{statuses:?}");
813 assert!(statuses.contains(&415), "{statuses:?}");
814 }
815
816 #[test]
817 fn rate_limiter_get_check_client_is_a_bare_envelope_with_a_revalidated_client_id() {
818 let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
819 let diag = crate::testkit::diagnose_project(&root);
820 let info = diag
821 .boundary_info
822 .get("ratelimit")
823 .expect("boundary_info entry for ratelimit");
824
825 let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
826 let model = wire_contract_at(
827 "ratelimit",
828 RATELIMIT_SRC,
829 offset,
830 info,
831 &[],
832 &diag.ty_intern,
833 real_context_count(&diag),
834 )
835 .expect("a wire contract at the GET handler's header");
836
837 assert_eq!(model.unit, "ratelimit");
838 assert_eq!(model.service, "api");
839 assert_eq!(
840 model.kind,
841 BoundaryKind::Http {
842 method: HttpMethod::Get,
843 path: "/check/:client".to_string(),
844 }
845 );
846
847 let (param, shape) = match &model.envelope {
850 Envelope::Bare { param, shape } => (param, shape),
851 other => panic!("expected Envelope::Bare, got {other:?}"),
852 };
853 assert_eq!(param, "client");
854 assert!(
855 matches!(shape, WireRef::Named { name } if name == "ClientId"),
856 "the bare param's shape should resolve to the named ClientId type: {shape:?}"
857 );
858
859 let client_id = model
863 .boundary
864 .types
865 .iter()
866 .find(|t| t.name == "ClientId")
867 .expect("ClientId is a boundary type");
868 assert_eq!(client_id.provenance, bynk_check::wire::Provenance::Owned);
869 let bynk_check::wire::WireBody::Scalar(scalar) = &client_id.body else {
870 panic!("ClientId should be a scalar, got {:?}", client_id.body);
871 };
872 assert!(
873 scalar
874 .predicates
875 .iter()
876 .any(|p| matches!(p, PredKind::NonEmpty)),
877 "ClientId's predicates should include NonEmpty: {:?}",
878 scalar.predicates
879 );
880 assert_eq!(
881 scalar.revalidation,
882 bynk_check::wire::Revalidation::ViaConstructor
883 );
884 assert!(model.type_sites.contains_key("ClientId"));
885
886 assert_eq!(
890 model.no_cross_context,
891 Some(NoCrossContextReason::SingleContext)
892 );
893 assert!(model.contract.is_none());
894 }
895
896 fn walk_expr_tys(
902 text: &str,
903 offset: usize,
904 file: FileId,
905 expr_types: &[(Span, TyId)],
906 tys: &Types,
907 ) -> Vec<(String, Option<std::sync::Arc<Ty>>)> {
908 fn collect<'e>(e: &'e Expr, out: &mut Vec<&'e Expr>) {
909 out.push(e);
910 for c in expr_children(e) {
911 collect(c, out);
912 }
913 }
914 let tokens = bynk_syntax::lexer::tokenize(text).expect("lexes");
915 let (parsed, _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
916 let Some(SourceUnit::Context(c)) = parsed else {
917 panic!("fixture is a context");
918 };
919 let handler = c
920 .items
921 .iter()
922 .find_map(|i| match i {
923 CommonsItem::Service(s) => handler_at(&s.handlers, offset),
924 _ => None,
925 })
926 .expect("a handler at offset");
927 let mut exprs = Vec::new();
928 for s in &handler.body.statements {
929 statement_exprs(s, &mut exprs);
930 }
931 exprs.push(&handler.body.tail);
932 let mut all = Vec::new();
933 for e in exprs {
934 collect(e, &mut all);
935 }
936 let walk = ResponseWalk {
937 expr_types,
938 file,
939 tys,
940 declared_is_http_result: true,
941 seen: Default::default(),
942 saw_option_question: false,
943 out: Vec::new(),
944 };
945 all.iter()
946 .map(|e| {
947 (
948 text[e.span.start..e.span.end].to_string(),
949 walk.expr_ty(e.span),
950 )
951 })
952 .collect()
953 }
954
955 #[test]
956 fn rate_limiter_response_set_has_declared_constructed_and_boundary_implicit() {
957 let root = setup_project("ratelimit-responses", &[("ratelimit.bynk", RATELIMIT_SRC)]);
958 let diag = crate::testkit::diagnose_project(&root);
959 let info = diag.boundary_info.get("ratelimit").expect("entry");
960
961 let rel = diag
964 .files
965 .iter()
966 .find(|f| {
967 f.source_path
968 .file_name()
969 .is_some_and(|n| n == "ratelimit.bynk")
970 })
971 .map(|f| f.source_path.clone())
972 .expect("ratelimit.bynk in the round's files");
973 let expr_types: &[(Span, TyId)] = diag
974 .expr_types
975 .get(&rel)
976 .map(|v| v.as_slice())
977 .unwrap_or(&[]);
978
979 assert!(
980 !expr_types.is_empty(),
981 "the clean fixture recorded no expr types"
982 );
983 let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
984 let walked = walk_expr_tys(
988 RATELIMIT_SRC,
989 offset,
990 info.services["api"].span.file,
991 expr_types,
992 &diag.ty_intern,
993 );
994 for src in ["Ok(view)", "TooManyRequests(\"rate limit exceeded\")"] {
995 let ty = walked
996 .iter()
997 .find(|(t, _)| t == src)
998 .unwrap_or_else(|| panic!("`{src}` not walked: {walked:?}"))
999 .1
1000 .as_deref();
1001 assert!(
1002 matches!(ty, Some(Ty::HttpResult(_))),
1003 "`{src}` should resolve to a recorded HttpResult, got {ty:?}"
1004 );
1005 }
1006 let model = wire_contract_at(
1007 "ratelimit",
1008 RATELIMIT_SRC,
1009 offset,
1010 info,
1011 expr_types,
1012 &diag.ty_intern,
1013 real_context_count(&diag),
1014 )
1015 .expect("a wire contract at the GET handler's header");
1016
1017 let statuses: Vec<(u16, &str)> = model
1018 .responses
1019 .iter()
1020 .map(|r| (r.status, r.variant.as_str()))
1021 .collect();
1022 assert!(
1023 statuses.contains(&(200, "Ok")),
1024 "declared success missing: {statuses:?}"
1025 );
1026 assert!(
1027 statuses.contains(&(429, "TooManyRequests")),
1028 "constructed TooManyRequests missing: {statuses:?}"
1029 );
1030 assert!(
1031 statuses.iter().any(|&(s, _)| s == 400),
1032 "boundary-implicit 400 (the handler has a param) missing: {statuses:?}"
1033 );
1034 assert!(
1035 model
1036 .responses
1037 .iter()
1038 .find(|r| r.status == 200)
1039 .is_some_and(|r| r.origin == ResponseOrigin::DeclaredSuccess)
1040 );
1041 assert!(
1042 model.responses.iter().any(|r| matches!(
1043 r.origin,
1044 ResponseOrigin::BoundaryImplicit { .. }
1045 ) && r.status == 400)
1046 );
1047 }
1048
1049 const PROVIDER_SRC: &str = r#"context billing
1052
1053type Quote = { amount: Int, currency: String }
1054
1055service Pricing {
1056 on call(sku: String, qty: Int) -> Effect[Quote] {
1057 Quote { amount: qty * 100, currency: "USD" }
1058 }
1059}
1060"#;
1061 const CONSUMER_SRC: &str = r#"context storefront
1062
1063consumes billing
1064
1065service checkout {
1066 on call(sku: String, qty: Int) -> Effect[Int] {
1067 let q <- billing.Pricing(sku, qty)
1068 q.amount
1069 }
1070}
1071"#;
1072
1073 #[test]
1074 fn two_context_call_handler_has_a_keyed_envelope_and_a_contract_hash() {
1075 let root = setup_project(
1076 "two-context",
1077 &[
1078 ("billing.bynk", PROVIDER_SRC),
1079 ("storefront.bynk", CONSUMER_SRC),
1080 ],
1081 );
1082 let diag = crate::testkit::diagnose_project(&root);
1083 let info = diag
1084 .boundary_info
1085 .get("billing")
1086 .expect("boundary_info entry for billing");
1087
1088 let offset = find_offset(PROVIDER_SRC, "on call(");
1089 let model = wire_contract_at(
1090 "billing",
1091 PROVIDER_SRC,
1092 offset,
1093 info,
1094 &[],
1095 &diag.ty_intern,
1096 real_context_count(&diag),
1097 )
1098 .expect("a wire contract at the `price` handler");
1099
1100 assert_eq!(model.kind, BoundaryKind::Call);
1101 let params = match &model.envelope {
1102 Envelope::Keyed { params } => params,
1103 other => panic!("expected Envelope::Keyed for a two-param call, got {other:?}"),
1104 };
1105 assert_eq!(
1106 params.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
1107 vec!["sku", "qty"],
1108 "keyed params stay in declaration order"
1109 );
1110
1111 assert!(model.no_cross_context.is_none(), "two contexts + on call");
1112 let contract = model.contract.expect("on call gets a contract form");
1113 assert_eq!(
1114 contract.hash,
1115 bynk_check::contract::contract_hash(&contract.normal_form)
1116 );
1117
1118 let svc = CrossContextService {
1122 name: "Pricing".to_string(),
1123 params: vec![
1124 (
1125 "sku".to_string(),
1126 TypeRef::Base(BaseType::String, Span::new(0, 0)),
1127 ),
1128 (
1129 "qty".to_string(),
1130 TypeRef::Base(BaseType::Int, Span::new(0, 0)),
1131 ),
1132 ],
1133 return_type: TypeRef::Named(Ident {
1134 name: "Quote".to_string(),
1135 span: Span::new(0, 0),
1136 }),
1137 span: Span::new(0, 0),
1138 foreign_callers: Vec::new(),
1139 };
1140 let independent_form = contract::service_normal_form(&svc, &info.types);
1141 assert_eq!(contract.normal_form, independent_form);
1142 assert_eq!(contract.hash, contract::contract_hash(&independent_form));
1143 }
1144
1145 #[test]
1146 fn zero_param_call_handler_is_the_empty_envelope() {
1147 let src = r#"context solo
1148
1149service Ping {
1150 on call() -> Effect[Int] {
1151 1
1152 }
1153}
1154"#;
1155 let root = setup_project("zero-param", &[("solo.bynk", src)]);
1156 let diag = crate::testkit::diagnose_project(&root);
1157 let info = diag.boundary_info.get("solo").expect("entry");
1158
1159 let offset = find_offset(src, "on call()");
1160 let model = wire_contract_at(
1161 "solo",
1162 src,
1163 offset,
1164 info,
1165 &[],
1166 &diag.ty_intern,
1167 real_context_count(&diag),
1168 )
1169 .expect("a wire contract at the `Ping` handler");
1170
1171 assert!(
1172 matches!(model.envelope, Envelope::Empty),
1173 "zero params: the request body is not read, not an empty keyed object"
1174 );
1175 assert_eq!(
1178 model.no_cross_context,
1179 Some(NoCrossContextReason::SingleContext)
1180 );
1181 }
1182
1183 #[test]
1189 fn bare_ident_collision_with_a_variant_name_is_not_misreported_without_expr_types() {
1190 const SRC: &str = r#"context oddnames
1191
1192service api from http {
1193 on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
1194 let Found = 1
1195 Found
1196 }
1197}
1198"#;
1199 let root = setup_project("ident-collision", &[("oddnames.bynk", SRC)]);
1200 let diag = crate::testkit::diagnose_project(&root);
1201 let info = diag
1202 .boundary_info
1203 .get("oddnames")
1204 .expect("boundary_info entry for oddnames");
1205
1206 let offset = find_offset(SRC, "GET(\"/x\")");
1207 let model = wire_contract_at(
1212 "oddnames",
1213 SRC,
1214 offset,
1215 info,
1216 &[],
1217 &diag.ty_intern,
1218 real_context_count(&diag),
1219 )
1220 .expect("a wire contract at the GET handler");
1221
1222 assert!(
1223 model.responses.iter().all(|r| r.variant != "Found"),
1224 "a bare `Found` local must not be misreported as HttpResult.Found \
1225 without a recorded expr type: {:?}",
1226 model.responses
1227 );
1228 }
1229
1230 #[test]
1237 fn bare_ident_variant_is_reported_from_real_expr_types() {
1238 const SRC: &str = r#"context flags
1239
1240service api from http {
1241 on DELETE("/flags/:name") (name: String) -> Effect[HttpResult[String]] by Visitor {
1242 NoContent
1243 }
1244}
1245"#;
1246 let root = setup_project("bare-ident-variant", &[("flags.bynk", SRC)]);
1247 let diag = crate::testkit::diagnose_project(&root);
1248 let info = diag.boundary_info.get("flags").expect("entry");
1249 let expr_types: &[(Span, TyId)] = diag
1250 .files
1251 .iter()
1252 .find(|f| f.source_path.file_name().is_some_and(|n| n == "flags.bynk"))
1253 .and_then(|f| diag.expr_types.get(&f.source_path))
1254 .map(|v| v.as_slice())
1255 .expect("flags.bynk's recorded expr types");
1256 assert!(
1257 expr_types.iter().all(|(s, _)| s.file != FileId::UNKNOWN),
1258 "the premise: analysis spans carry a real FileId"
1259 );
1260
1261 let offset = find_offset(SRC, "DELETE(");
1262 let model = wire_contract_at(
1263 "flags",
1264 SRC,
1265 offset,
1266 info,
1267 expr_types,
1268 &diag.ty_intern,
1269 real_context_count(&diag),
1270 )
1271 .expect("a wire contract at the DELETE handler");
1272
1273 assert!(
1274 model
1275 .responses
1276 .iter()
1277 .any(|r| r.status == 204 && r.variant == "NoContent"),
1278 "a tail `NoContent` must be reported as constructed: {:?}",
1279 model.responses
1280 );
1281 }
1282
1283 #[test]
1287 fn expr_ty_matches_by_range_and_treats_error_as_no_type() {
1288 let tys = Types::new();
1289 let file = FileId(0);
1290 let error = tys.intern(Ty::Error);
1291 let http = tys.intern(Ty::HttpResult(error));
1292 let expr_types = [
1293 (Span::new_in(file, 10, 20), http),
1294 (Span::new_in(file, 30, 40), error),
1295 (Span::new_in(FileId(1), 50, 60), http),
1296 ];
1297 let walk = ResponseWalk {
1298 expr_types: &expr_types,
1299 file,
1300 tys: &tys,
1301 declared_is_http_result: true,
1302 seen: Default::default(),
1303 saw_option_question: false,
1304 out: Vec::new(),
1305 };
1306
1307 assert!(matches!(
1309 walk.expr_ty(Span::new(10, 20)).as_deref(),
1310 Some(Ty::HttpResult(_))
1311 ));
1312 assert!(walk.is_http_result_ident(Span::new(10, 20)));
1313
1314 assert!(walk.expr_ty(Span::new(30, 40)).is_none());
1317 assert!(!walk.is_http_result_ident(Span::new(30, 40)));
1318 assert!(walk.is_http_result_expr(Span::new(30, 40)));
1319
1320 assert!(walk.expr_ty(Span::new(50, 60)).is_none());
1322 }
1323
1324 fn clean_expr_types<'d>(diag: &'d crate::ProjectDiagnostics, name: &str) -> &'d [(Span, TyId)] {
1328 for f in &diag.files {
1329 assert!(
1330 f.diagnostics.is_empty(),
1331 "{:?} has diagnostics: {:?}",
1332 f.source_path,
1333 f.diagnostics
1334 );
1335 }
1336 diag.files
1337 .iter()
1338 .find(|f| f.source_path.file_name().is_some_and(|n| n == name))
1339 .and_then(|f| diag.expr_types.get(&f.source_path))
1340 .map(|v| v.as_slice())
1341 .unwrap_or_else(|| panic!("{name}'s recorded expr types"))
1342 }
1343
1344 #[test]
1349 fn bare_ident_collision_with_a_variant_name_is_rejected_by_its_recorded_type() {
1350 const SRC: &str = r#"context oddnames
1351
1352service api from http {
1353 on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
1354 let Found = 1
1355 Ok(Found)
1356 }
1357}
1358"#;
1359 let root = setup_project("ident-collision-typed", &[("oddnames.bynk", SRC)]);
1360 let diag = crate::testkit::diagnose_project(&root);
1361 let info = diag.boundary_info.get("oddnames").expect("entry");
1362 let expr_types = clean_expr_types(&diag, "oddnames.bynk");
1363
1364 let offset = find_offset(SRC, "GET(\"/x\")");
1365 let walked = walk_expr_tys(
1366 SRC,
1367 offset,
1368 info.services["api"].span.file,
1369 expr_types,
1370 &diag.ty_intern,
1371 );
1372 assert!(
1373 walked.iter().any(|(t, ty)| t == "Found"
1374 && ty
1375 .as_deref()
1376 .is_some_and(|ty| !matches!(ty, Ty::HttpResult(_)))),
1377 "the tail `Found` should resolve to its recorded non-HttpResult type: {walked:?}"
1378 );
1379
1380 let model = wire_contract_at(
1381 "oddnames",
1382 SRC,
1383 offset,
1384 info,
1385 expr_types,
1386 &diag.ty_intern,
1387 real_context_count(&diag),
1388 )
1389 .expect("a wire contract at the GET handler");
1390 assert!(
1391 model.responses.iter().all(|r| r.variant != "Found"),
1392 "a bare `Found` local must not be reported as HttpResult.Found: {:?}",
1393 model.responses
1394 );
1395 }
1396
1397 #[test]
1401 fn option_question_in_a_handler_adds_the_boundary_implicit_404() {
1402 const SRC: &str = r#"context lookup
1403
1404fn find(n: String) -> Option[Int] {
1405 Some(1)
1406}
1407
1408service api from http {
1409 on GET("/x/:n") (n: String) -> Effect[HttpResult[Int]] by Visitor {
1410 let v = find(n)?
1411 Ok(v)
1412 }
1413}
1414"#;
1415 let root = setup_project("option-question", &[("lookup.bynk", SRC)]);
1416 let diag = crate::testkit::diagnose_project(&root);
1417 let info = diag.boundary_info.get("lookup").expect("entry");
1418 let expr_types = clean_expr_types(&diag, "lookup.bynk");
1419
1420 let model = wire_contract_at(
1421 "lookup",
1422 SRC,
1423 find_offset(SRC, "GET("),
1424 info,
1425 expr_types,
1426 &diag.ty_intern,
1427 real_context_count(&diag),
1428 )
1429 .expect("a wire contract at the GET handler");
1430 assert!(
1431 model.responses.iter().any(|r| r.status == 404
1432 && r.variant == "NotFound"
1433 && matches!(r.origin, ResponseOrigin::BoundaryImplicit { .. })),
1434 "an `Option?` short-circuit must add the boundary-implicit 404: {:?}",
1435 model.responses
1436 );
1437 }
1438
1439 #[test]
1446 fn a_sibling_files_span_at_the_same_range_is_not_this_files_type() {
1447 const A: &str = r#"context shop
1448
1449type Q = { n: Int }
1450
1451service api from http {
1452 on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
1453 let Found = 1
1454 Ok(Found)
1455 }
1456}
1457"#;
1458 let target = find_offset(A, "Ok(Found)") + "Ok(".len();
1459 let head = "context shop\n\nfn Q.r(self) -> HttpResult[Int] {\n";
1460 let pad = target
1461 .checked_sub(head.len())
1462 .expect("a.bynk's `Found` sits past b.bynk's header");
1463 let b = format!("{head}{}Ok(1)\n}}\n", " ".repeat(pad));
1464 assert_eq!(
1465 b.find("Ok(1)"),
1466 Some(target),
1467 "the padding lines the spans up"
1468 );
1469
1470 let root = setup_project("sibling-span", &[("shop/a.bynk", A), ("shop/b.bynk", &b)]);
1471 let diag = crate::testkit::diagnose_project(&root);
1472 let info = diag.boundary_info.get("shop").expect("entry");
1473 let expr_types = clean_expr_types(&diag, "a.bynk");
1474 let file = info.services["api"].span.file;
1475 assert!(
1476 expr_types.iter().any(|(s, t)| s.file != file
1477 && (s.start, s.end) == (target, target + 5)
1478 && matches!(*diag.ty_intern.get(*t), Ty::HttpResult(_))),
1479 "the premise: a.bynk's expr types hold b.bynk's `Ok(1)` at the same range"
1480 );
1481
1482 let model = wire_contract_at(
1483 "shop",
1484 A,
1485 find_offset(A, "GET("),
1486 info,
1487 expr_types,
1488 &diag.ty_intern,
1489 real_context_count(&diag),
1490 )
1491 .expect("a wire contract at the GET handler");
1492 assert!(
1493 model.responses.iter().all(|r| r.variant != "Found"),
1494 "b.bynk's `Ok(1)` must not type a.bynk's `Found`: {:?}",
1495 model.responses
1496 );
1497 }
1498}