beck_core/iface.rs
1//! `.becki` — the published module signature, and the separate-compilation firewall.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.6, whose
4//! heading is "Modularity and separate compilation (**do not defer**)", and whose rule is one
5//! sentence:
6//!
7//! > **Placement, effects, and event/command types are part of a module's published signature.**
8//! > Inference is intra-module; boundaries are declared.
9//!
10//! [`docs/01-vision-and-premise.md`](../../../../../docs/01-vision-and-premise.md) §1.6 says why this
11//! is the item that cannot slip: it is the historical killer of tierless languages. A language that
12//! must see every module to place any of them has a build time proportional to the whole program
13//! and an API surface nobody can review.
14//!
15//! # What a `.becki` is
16//!
17//! A **file in the ordinary Beck surface**, generated by `beck iface`, checked in, and reviewed like
18//! an `.mli`. It contains the module's types verbatim and one bodyless `def` per published name:
19//!
20//! ```text
21//! # orders.becki — generated by `beck iface`. This is the module's published contract.
22//!
23//! model Order:
24//! id: OrderId
25//! total: Int
26//!
27//! @on(any)
28//! def recent(customer: Str, limit: Int) -> list[Order]
29//!
30//! @signal
31//! @on(data)
32//! def orders() -> Signal[Map[OrderId, Order]] uses durable
33//! ```
34//!
35//! Two decisions worth stating, because §3.6's illustrative syntax suggests otherwise:
36//!
37//! * **It is Beck, not a second notation.** §3.6 sketches `orders : Signal[…] ! { durable }
38//! @on(server)`, which would need a parser of its own. A bodyless `def` says the same thing in a
39//! grammar the lexer, parser, printer, formatter and editor already handle, and `beck fmt` keeps
40//! it tidy for free. The one parser change it needed — a `def` may have no body — is a
41//! declaration, which is a thing the language wanted anyway (a trait's method signature is one).
42//! * **A published signal is a nullary declaration**, marked `@signal`. A signal is a *value* of
43//! type `Signal[T]`, and declaring a value with no arguments is what an interface file has always
44//! done; the marker distinguishes it from a function that happens to return one.
45//!
46//! # Why the digest matters
47//!
48//! [`Interface::digest`] is the firewall in one value. A body edit does not change it, so a
49//! downstream module's check is served from the memo; an effect or a type or a placement change
50//! does change it, so the downstream module is re-checked — and, if the change is breaking,
51//! `beck check --wire-compat` says so before the deploy does.
52
53use std::collections::BTreeMap;
54use std::fmt::Write as _;
55use std::sync::Arc;
56
57use beck_diag::{Diagnostic, Diagnostics, SourceMap};
58
59use crate::check::Program;
60use crate::ty::{self, Effect, ImplSig, Row, Scheme, Tier, TraitSig, Ty, TyDecl};
61
62/// One published name.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct Item {
65 pub name: Arc<str>,
66 /// The trait bounds on this name's type parameters. A bounded definition publishes the bound
67 /// and *not* the dictionary parameters it was lowered with: those are named
68 /// `Trait::method@T` and belong to the lowering rather than to the contract, and an importing
69 /// module reconstructs them from the bound.
70 pub bounds: Vec<(Arc<str>, Vec<Arc<str>>)>,
71 pub kind: Kind,
72 /// The effect row, as atoms. A published row is always closed: §3.6 says boundaries are
73 /// declared, and a row variable is an inference artefact that has no meaning outside the module
74 /// that solved it.
75 pub effects: Vec<Effect>,
76 pub tier: Tier,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum Kind {
81 Function {
82 /// The names a generic definition quantifies over, in the order written. Empty for the
83 /// monomorphic case, which is every definition written before `docs/32`.
84 typarams: Vec<Arc<str>>,
85 params: Vec<(Arc<str>, Ty)>,
86 ret: Ty,
87 },
88 Signal {
89 ty: Ty,
90 },
91}
92
93/// A module's published signature.
94#[derive(Clone, Debug, PartialEq, Eq, Default)]
95pub struct Interface {
96 pub module: String,
97 /// Types in declaration order, so the rendered file is stable.
98 pub types: Vec<TyDecl>,
99 /// The `trait` declarations this module owns.
100 pub traits: Vec<TraitSig>,
101 /// The `impl` headers this module owns — the bodies stay behind, and what crosses is that the
102 /// implementation exists and what it promises. Without these, a call in an importing module has
103 /// a trait and nothing to resolve it to.
104 pub impls: Vec<ImplSig>,
105 pub items: Vec<Item>,
106}
107
108impl Interface {
109 /// Extract the interface of a checked, placed program.
110 pub fn of(program: &Program) -> Interface {
111 // Only what this module declares. The prelude belongs to the language, and an imported
112 // type is published by the module that owns it — republishing one would make two modules
113 // claim the same contract.
114 // Declaration order, not alphabetical: a type may name one declared before it, and the
115 // rendered file has to be a file the checker can read back.
116 let types: Vec<TyDecl> = program
117 .own_types
118 .iter()
119 .filter_map(|n| program.types.get(n).cloned())
120 .collect();
121 let traits = program.traits.clone();
122 let impls = program.impls.clone();
123
124 let mut items = Vec::new();
125 for name in &program.def_order {
126 let Some(d) = program.defs.get(name) else {
127 continue;
128 };
129 // A desugared impl method is not part of the contract: its name is compiler-generated
130 // and no parser could read it back. What crosses instead is the `impl` header, which
131 // says the implementation exists, and the trait, which says what it promises.
132 if crate::check::is_impl_method(name) {
133 continue;
134 }
135 // A bounded definition publishes its written parameters. The dictionaries were appended
136 // by the lowering and are recovered by the importer from the same bound.
137 let written = d.params.len() - dict_count(d);
138 items.push(Item {
139 name: d.name.clone(),
140 bounds: d.bounds.clone(),
141 kind: if d.declares_signal {
142 Kind::Signal {
143 ty: close_rows(&d.ret),
144 }
145 } else {
146 Kind::Function {
147 typarams: d.typarams.clone(),
148 params: d.params[..written]
149 .iter()
150 .map(|(_, n, t)| (n.clone(), close_rows(t)))
151 .collect(),
152 ret: close_rows(&d.ret),
153 }
154 },
155 effects: published(&d.row),
156 tier: d.tier,
157 });
158 }
159 for s in &program.signals {
160 items.push(Item {
161 name: s.name.clone(),
162 bounds: Vec::new(),
163 kind: Kind::Signal {
164 ty: close_rows(&s.ty),
165 },
166 effects: published(&s.row),
167 tier: s.tier,
168 });
169 }
170 items.sort_by(|a, b| a.name.cmp(&b.name));
171
172 Interface {
173 module: program.name.clone(),
174 types,
175 traits,
176 impls,
177 items,
178 }
179 }
180
181 pub fn item(&self, name: &str) -> Option<&Item> {
182 self.items.iter().find(|i| i.name.as_ref() == name)
183 }
184
185 /// A content hash of everything a downstream module can depend on.
186 ///
187 /// Deliberately *not* a hash of the rendered file: a comment, or a change of field order in the
188 /// printer, must not invalidate a build. It is a hash of the meaning.
189 pub fn digest(&self) -> String {
190 let mut h = blake3::Hasher::new();
191 h.update(self.module.as_bytes());
192 // Sorted, unlike the rendered order: moving two independent declarations past each other
193 // changes the file and not the contract, and the digest is about the contract.
194 let mut types: Vec<String> = self.types.iter().map(type_signature).collect();
195 types.sort();
196 for t in &types {
197 h.update(b"T");
198 h.update(t.as_bytes());
199 }
200 for t in &self.traits {
201 h.update(b"R");
202 h.update(trait_signature(t).as_bytes());
203 }
204 for i in &self.impls {
205 h.update(b"M");
206 h.update(impl_signature(i).as_bytes());
207 }
208 for i in &self.items {
209 h.update(b"I");
210 h.update(i.name.as_bytes());
211 h.update(item_signature(i).as_bytes());
212 }
213 h.finalize().to_hex()[..32].to_string()
214 }
215
216 /// Render the `.becki` file.
217 pub fn render(&self) -> String {
218 let mut out = String::new();
219 let _ = writeln!(
220 out,
221 "# {}.becki — generated by `beck iface`.\n\
222 #\n\
223 # This is the module's published contract: its types, and every name's signature with\n\
224 # the effects it performs and the tier it runs on. Review it like an .mli — a change\n\
225 # here is an API change, and `beck check --wire-compat` will say which kind.\n",
226 self.module
227 );
228 for t in &self.types {
229 out.push_str(&render_type(t));
230 out.push('\n');
231 }
232 for t in &self.traits {
233 out.push_str(&render_trait(t));
234 out.push('\n');
235 }
236 // After the traits and before the definitions, because an impl names both a trait and a
237 // type and the checker reads a file once, in order.
238 for i in &self.impls {
239 let _ = writeln!(out, "{}", render_impl(i));
240 }
241 if !self.impls.is_empty() {
242 out.push('\n');
243 }
244 for i in &self.items {
245 if let Kind::Signal { .. } = i.kind {
246 out.push_str("@signal\n");
247 }
248 let _ = writeln!(out, "@on({})", i.tier.name());
249 out.push_str(&render_item(i));
250 out.push('\n');
251 }
252 out
253 }
254
255 /// Read a `.becki` back.
256 ///
257 /// The **caller's** `SourceMap`, for the same reason [`crate::project`] gives one to every
258 /// module it loads: a diagnostic about `orders.becki` has to point into `orders.becki`. A map
259 /// made and dropped here would leave every diagnostic carrying a `FileId` the renderer resolves
260 /// against somebody else's file, which is worse than no span at all — it points confidently at
261 /// an unrelated line.
262 pub fn parse(
263 module: &str,
264 src: &str,
265 map: &mut SourceMap,
266 diags: &mut Diagnostics,
267 ) -> Interface {
268 let file = map.add(format!("{module}.becki"), src);
269 let node = beck_syntax::parse_file(file, module, src, diags);
270 // An interface is checked as a module: the same resolver, the same type syntax, the same
271 // diagnostics. What makes it an interface is that every `def` in it is bodyless.
272 let program =
273 crate::check::check_module_with(&node, crate::check::Mode::Interface, &[], diags);
274 for name in &program.def_order {
275 if let Some(d) = program.defs.get(name) {
276 if !d.is_declaration {
277 diags.push(
278 Diagnostic::error(
279 "B0600",
280 format!("`{name}` has a body, so it is not a signature"),
281 d.span,
282 )
283 .with_note(
284 "a `.becki` publishes what a module offers, not how it does it; \
285 regenerate it with `beck iface`",
286 ),
287 );
288 }
289 }
290 }
291 let mut iface = Interface::of(&program);
292 // Through the same normalisation the parser applies, so a contract read from `orders.becki`
293 // and one derived from `orders.beck` agree on what module they describe — otherwise the
294 // digest would differ for a reason nobody wrote down.
295 iface.module = beck_syntax::module_ident(module);
296 // `Interface::of` reads placement off the program, and a parsed interface has no solver
297 // behind it — the tiers are the ones written in the file, which is what `@on(…)` means.
298 iface
299 }
300
301 /// Register this interface's types and names in a checker's environment.
302 pub fn exports(&self) -> (BTreeMap<Arc<str>, TyDecl>, BTreeMap<Arc<str>, Export>) {
303 let types = self
304 .types
305 .iter()
306 .map(|t| (t.name().clone(), t.clone()))
307 .collect();
308 let names = self
309 .items
310 .iter()
311 .map(|i| {
312 let scheme = match &i.kind {
313 // `generic` and not `mono`: an importer has to instantiate a published
314 // `map[T, U]` afresh per call, or the first use would fix the second's types.
315 Kind::Function {
316 typarams,
317 params,
318 ret,
319 } => Scheme::generic(
320 typarams.clone(),
321 Ty::fun_eff(
322 params.iter().map(|(_, t)| t.clone()).collect(),
323 ret.clone(),
324 Row::of(i.effects.iter().cloned()),
325 ),
326 ),
327 Kind::Signal { ty } => Scheme::mono(ty.clone()),
328 };
329 (
330 i.name.clone(),
331 Export {
332 scheme,
333 bounds: i.bounds.clone(),
334 row: Row::of(i.effects.iter().cloned()),
335 tier: i.tier,
336 is_signal: matches!(i.kind, Kind::Signal { .. }),
337 },
338 )
339 })
340 .collect();
341 (types, names)
342 }
343}
344
345/// What an importing module learns about one imported name.
346#[derive(Clone, Debug)]
347pub struct Export {
348 pub scheme: Scheme,
349 /// The bounds the importer has to reconstruct dictionaries from.
350 pub bounds: Vec<(Arc<str>, Vec<Arc<str>>)>,
351 pub row: Row,
352 pub tier: Tier,
353 pub is_signal: bool,
354}
355
356/// Close every row inside a type.
357///
358/// A row *variable* is an inference artefact: its number comes from the order the checker happened
359/// to mint it in, so two compilations of the same unchanged module would publish different-looking
360/// contracts and the firewall would never hold. §3.6 says boundaries are declared, and a variable is
361/// not a declaration.
362///
363/// The cost is named rather than hidden: **effect polymorphism does not cross a module boundary in
364/// Phase 2.** An exported higher-order function publishes its function parameter's row as closed, so
365/// an importer passing an effectful argument where the contract says pure is refused. That is the
366/// sound direction — it rejects a program that could have been accepted, never the reverse — and it
367/// is the first thing to revisit if a real library runs into it.
368fn close_rows(t: &Ty) -> Ty {
369 match t {
370 Ty::Var(v) => Ty::Var(*v),
371 Ty::Con(n, args) => Ty::Con(n.clone(), args.iter().map(close_rows).collect()),
372 Ty::Fun(ps, r, row) => Ty::Fun(
373 ps.iter().map(close_rows).collect(),
374 Box::new(close_rows(r)),
375 Row::of(row.atoms.iter().filter(|e| !e.is_ambient()).cloned()),
376 ),
377 }
378}
379
380/// The row as published: closed, and without the ambient set (§3.2).
381fn published(row: &Row) -> Vec<Effect> {
382 let mut out = row.visible();
383 out.sort();
384 out
385}
386
387fn item_signature(i: &Item) -> String {
388 let bounds: String = i
389 .bounds
390 .iter()
391 .map(|(p, ts)| format!("{p}:{};", ts.join("+")))
392 .collect();
393 let ty = match &i.kind {
394 // The type parameters are part of the signature the digest covers: `map[T, U]` and
395 // `map[T]` are different contracts even when the printed parameter list is the same.
396 Kind::Function {
397 typarams,
398 params,
399 ret,
400 } => format!(
401 "{bounds}{}({}) -> {ret}",
402 if typarams.is_empty() {
403 String::new()
404 } else {
405 format!("[{}]", typarams.join(", "))
406 },
407 params
408 .iter()
409 .map(|(n, t)| format!("{n}: {t}"))
410 .collect::<Vec<_>>()
411 .join(", ")
412 ),
413 Kind::Signal { ty } => format!("{ty}"),
414 };
415 format!(
416 "{ty} ! {{{}}} @on({})",
417 i.effects
418 .iter()
419 .map(|e| e.name())
420 .collect::<Vec<_>>()
421 .join(", "),
422 i.tier.name()
423 )
424}
425
426/// A type's structure, transitively — every field of every variant, through every named type it
427/// reaches.
428///
429/// §4.3 asks for an operation id that is "content-derived … stable across refactors that don't
430/// change the signature". A hash of the type's *name* satisfies neither half: it does not move when
431/// a field is added, which is the change that breaks every open tab. This is what content means.
432pub fn structural(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> String {
433 fn go(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>, seen: &mut Vec<Arc<str>>, out: &mut String) {
434 match ty {
435 Ty::Var(v) => {
436 let _ = write!(out, "?{v}");
437 }
438 Ty::Fun(ps, r, row) => {
439 out.push('(');
440 for p in ps {
441 go(p, types, seen, out);
442 out.push(',');
443 }
444 out.push_str(")->");
445 go(r, types, seen, out);
446 let _ = write!(out, "!{row}");
447 }
448 Ty::Con(n, args) => {
449 out.push_str(n);
450 if !args.is_empty() {
451 out.push('[');
452 for a in args {
453 go(a, types, seen, out);
454 out.push(',');
455 }
456 out.push(']');
457 }
458 if seen.iter().any(|s| s == n) {
459 return;
460 }
461 let Some(decl) = types.get(n.as_ref()) else {
462 return;
463 };
464 seen.push(n.clone());
465 out.push('{');
466 // Instantiated, so the hash describes the shape this mention actually has:
467 // `Envelope[Added]` carries a `body: Added`, and hashing the declaration's
468 // `body: ?1000000` instead would make it the same boundary as `Envelope[Toggled]`.
469 let at = |t: &Ty| ty::instantiate_decl(t, args);
470 match decl {
471 TyDecl::Model { fields, .. } => {
472 for (f, t) in fields {
473 let _ = write!(out, "{f}:");
474 go(&at(t), types, seen, out);
475 out.push(';');
476 }
477 }
478 TyDecl::Union { variants, .. } => {
479 for v in variants {
480 let _ = write!(out, "{}(", v.name);
481 for (f, t) in &v.fields {
482 let _ = write!(out, "{f}:");
483 go(&at(t), types, seen, out);
484 out.push(',');
485 }
486 out.push_str(");");
487 }
488 }
489 TyDecl::Newtype { inner, .. } | TyDecl::Alias { ty: inner, .. } => {
490 go(&at(inner), types, seen, out)
491 }
492 }
493 out.push('}');
494 seen.pop();
495 }
496 }
497 }
498 let mut out = String::new();
499 go(ty, types, &mut Vec::new(), &mut out);
500 out
501}
502
503fn fields_as_written(fields: &[(Arc<str>, Ty)], d: &TyDecl) -> String {
504 fields
505 .iter()
506 .map(|(n, t)| format!("{n}: {}", d.as_written(t)))
507 .collect::<Vec<_>>()
508 .join(", ")
509}
510
511fn type_signature(d: &TyDecl) -> String {
512 let p = d.param_brackets();
513 match d {
514 TyDecl::Model { name, fields, .. } => {
515 format!("model {name}{p} {{{}}}", fields_as_written(fields, d))
516 }
517 TyDecl::Union { name, variants, .. } => format!(
518 "union {name}{p} {{{}}}",
519 variants
520 .iter()
521 .map(|v| format!("{}({})", v.name, fields_as_written(&v.fields, d)))
522 .collect::<Vec<_>>()
523 .join(" | ")
524 ),
525 TyDecl::Newtype { name, inner, .. } => {
526 format!("newtype {name}{p} = {}", d.as_written(inner))
527 }
528 TyDecl::Alias { name, ty, .. } => format!("alias {name}{p} = {}", d.as_written(ty)),
529 }
530}
531
532fn render_type(d: &TyDecl) -> String {
533 let mut out = String::new();
534 let p = d.param_brackets();
535 match d {
536 TyDecl::Model { name, fields, .. } => {
537 let _ = writeln!(out, "model {name}{p}:");
538 if fields.is_empty() {
539 let _ = writeln!(out, " pass");
540 }
541 for (n, t) in fields {
542 let _ = writeln!(out, " {n}: {}", d.as_written(t));
543 }
544 }
545 TyDecl::Union { name, variants, .. } => {
546 let _ = writeln!(out, "union {name}{p}:");
547 for v in variants {
548 if v.fields.is_empty() {
549 let _ = writeln!(out, " {}", v.name);
550 } else {
551 let _ = writeln!(out, " {}({})", v.name, fields_as_written(&v.fields, d));
552 }
553 }
554 }
555 TyDecl::Newtype { name, inner, .. } => {
556 let _ = writeln!(out, "type {name}{p} = newtype[{}]", d.as_written(inner));
557 }
558 TyDecl::Alias { name, ty, .. } => {
559 let _ = writeln!(out, "type {name}{p} = {}", d.as_written(ty));
560 }
561 }
562 out
563}
564
565/// How many of a definition's parameters the bound lowering appended.
566///
567/// Counted from the names rather than tracked separately, because the names are what makes them
568/// recognisable — a dictionary is `Trait::method@T`, and nothing a program writes can be.
569fn dict_count(d: &crate::check::Def) -> usize {
570 d.params
571 .iter()
572 .filter(|(_, n, _)| crate::check::is_impl_method(n))
573 .count()
574}
575
576fn render_trait(t: &TraitSig) -> String {
577 let mut out = String::new();
578 let _ = writeln!(out, "trait {}:", t.name);
579 for m in &t.methods {
580 let params: Vec<String> = m
581 .params
582 .iter()
583 .map(|(n, ty)| {
584 // `self: Self` is written `self`, which is the notation the surface uses and the
585 // one the parser gives the implicit type back to.
586 if n.as_ref() == "self" && ty.con_name() == Some("Self") {
587 "self".to_string()
588 } else {
589 format!("{n}: {ty}")
590 }
591 })
592 .collect();
593 let uses = if m.effects.is_empty() {
594 String::new()
595 } else {
596 format!(
597 " uses {}",
598 m.effects
599 .iter()
600 .map(|e| e.name())
601 .collect::<Vec<_>>()
602 .join(", ")
603 )
604 };
605 let _ = writeln!(
606 out,
607 " def {}({}) -> {}{uses}",
608 m.name,
609 params.join(", "),
610 m.ret
611 );
612 }
613 out
614}
615
616fn render_impl(i: &ImplSig) -> String {
617 let params = if i.params.is_empty() {
618 String::new()
619 } else {
620 format!(
621 "[{}]",
622 i.params
623 .iter()
624 .map(|p| p.to_string())
625 .collect::<Vec<_>>()
626 .join(", ")
627 )
628 };
629 let header = format!("impl{params} {} for {}", i.trait_name, i.target);
630 if i.effects.is_empty() {
631 return header;
632 }
633 // A method that performs something publishes what: a caller in another module resolves through
634 // this header and has nowhere else to learn it (`docs/47` §47.3). A pure method says nothing,
635 // so an impl of a pure trait reads exactly as it did before this existed.
636 let mut out = header;
637 out.push_str(":\n");
638 for (name, row) in &i.effects {
639 let atoms: Vec<String> = row.iter().map(|e| e.name()).collect();
640 // `def add() uses …` — a bodyless `def` with an empty parameter list, because that is
641 // already a shape the reader has: the trait supplies the parameters, and repeating them
642 // here would be the second copy §37's impl rule exists to refuse.
643 let _ = writeln!(out, " def {name}() uses {}", atoms.join(", "));
644 }
645 out.pop();
646 out
647}
648
649fn trait_signature(t: &TraitSig) -> String {
650 let mut out = format!("trait {}{{", t.name);
651 for m in &t.methods {
652 let _ = write!(
653 out,
654 "{}({}) -> {} !{{{}}};",
655 m.name,
656 m.params
657 .iter()
658 .map(|(n, ty)| format!("{n}: {ty}"))
659 .collect::<Vec<_>>()
660 .join(", "),
661 m.ret,
662 m.effects
663 .iter()
664 .map(|e| e.name())
665 .collect::<Vec<_>>()
666 .join(",")
667 );
668 }
669 out.push('}');
670 out
671}
672
673fn impl_signature(i: &ImplSig) -> String {
674 format!(
675 "impl[{}] {} for {}",
676 i.params.join(","),
677 i.trait_name,
678 i.target
679 )
680}
681
682/// One published name, as a `.becki` line.
683///
684/// Public because `beck lsp` shows it on hover, and `docs/04` §4.6 forbids a second renderer: what
685/// an editor says a name's signature is has to be what `beck iface` publishes it as.
686pub fn render_item(i: &Item) -> String {
687 let uses = if i.effects.is_empty() {
688 String::new()
689 } else {
690 format!(
691 " uses {}",
692 i.effects
693 .iter()
694 .map(|e| e.name())
695 .collect::<Vec<_>>()
696 .join(", ")
697 )
698 };
699 match &i.kind {
700 Kind::Function {
701 typarams,
702 params,
703 ret,
704 } => format!(
705 "def {}{}({}) -> {ret}{uses}\n",
706 i.name,
707 if typarams.is_empty() {
708 String::new()
709 } else {
710 format!(
711 "[{}]",
712 typarams
713 .iter()
714 .map(|t| match i.bounds.iter().find(|(p, _)| p == t) {
715 Some((_, traits)) => format!(
716 "{t}: {}",
717 traits
718 .iter()
719 .map(|x| x.to_string())
720 .collect::<Vec<_>>()
721 .join(" + ")
722 ),
723 None => t.to_string(),
724 })
725 .collect::<Vec<_>>()
726 .join(", ")
727 )
728 },
729 params
730 .iter()
731 .map(|(n, t)| format!("{n}: {t}"))
732 .collect::<Vec<_>>()
733 .join(", ")
734 ),
735 Kind::Signal { ty } => format!("def {}() -> {ty}{uses}\n", i.name),
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::compile_str;
743
744 fn iface_of(src: &str) -> Interface {
745 let (placed, d, map) = compile_str("todo.beck", src);
746 assert!(!d.has_errors(), "{}", d.render(&map));
747 Interface::of(&placed.expect("it compiles").program)
748 }
749
750 #[test]
751 fn an_interface_carries_placement_effects_and_types() {
752 // §3.6's rule, item by item: "Placement, effects, and event/command types are part of a
753 // module's published signature."
754 let i = iface_of(crate::split::tests::TODO);
755 let todos = i.item("todos").expect("the signal is published");
756 assert_eq!(todos.tier, Tier::Data);
757 assert_eq!(todos.effects, vec![Effect::Durable]);
758 assert!(matches!(todos.kind, Kind::Signal { .. }));
759 assert!(i.item("apply_event").is_some());
760 // The event and command unions are part of it, because they are the wire.
761 assert!(i.types.iter().any(|t| t.name().as_ref() == "Event"));
762 assert!(i.types.iter().any(|t| t.name().as_ref() == "Command"));
763 // The prelude is not: `Envelope` and `Session` belong to the language, not the module.
764 assert!(!i.types.iter().any(|t| t.name().as_ref() == "Envelope"));
765 }
766
767 #[test]
768 fn a_body_edit_does_not_change_the_interface_digest() {
769 // The firewall, in one assertion. This is the property separate compilation is built on:
770 // §3.6's "body edits don't invalidate downstream modules".
771 let before = iface_of(crate::split::tests::TODO);
772 let edited = crate::split::tests::TODO.replace(
773 r#""done" if t.done else """#,
774 r#""done" if t.done else " ""#,
775 );
776 let after = iface_of(&edited);
777 assert_eq!(before.digest(), after.digest());
778 assert_eq!(before, after);
779 }
780
781 #[test]
782 fn widening_an_effect_changes_the_digest() {
783 // §3.6: "effect widening is a breaking API change". It cannot be one if it is invisible.
784 let before = iface_of(crate::split::tests::TODO);
785 let widened = crate::split::tests::TODO.replace(
786 "def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection]:",
787 "def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection] uses net.out(audit.example.com):",
788 );
789 let after = iface_of(&widened);
790 assert_ne!(
791 before.digest(),
792 after.digest(),
793 "a library that starts phoning home cannot do so silently"
794 );
795 // …and the new atom is in the published row, by name, with its host.
796 let owned = after.item("owned").expect("owned is published");
797 assert!(owned
798 .effects
799 .contains(&Effect::NetOut("audit.example.com".into())));
800 }
801
802 #[test]
803 fn widening_an_effect_on_the_view_path_does_not_merely_change_the_digest() {
804 // The stronger half of the same story, and the one worth having a test of its own: adding
805 // `net.out(a-host)` to a function the *view* calls does not produce a new interface to
806 // review. It produces a compile error, because the page runs in a browser and a browser
807 // cannot reach a named host. The API change is caught before it is an API.
808 let widened = crate::split::tests::TODO.replace(
809 "def done_class(t: Todo) -> Str:",
810 "def done_class(t: Todo) -> Str uses net.out(telemetry.example.com):",
811 );
812 let (_, d, _) = compile_str("todo.beck", &widened);
813 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
814 assert!(codes.contains(&"B0401"), "got {codes:?}");
815 }
816
817 #[test]
818 fn changing_a_placement_changes_the_digest() {
819 let before = iface_of(crate::split::tests::TODO);
820 let moved = crate::split::tests::TODO.replace("@on(data)\ntodos", "@on(server)\ntodos");
821 assert_ne!(before.digest(), iface_of(&moved).digest());
822 }
823
824 #[test]
825 fn an_interface_round_trips_through_its_file_form() {
826 // Rendered, re-read, and identical — otherwise the checked-in file and the compiler's idea
827 // of the contract could drift, which is the failure the file exists to prevent.
828 let original = iface_of(crate::split::tests::TODO);
829 let text = original.render();
830 let mut diags = Diagnostics::new();
831 let mut map = SourceMap::new();
832 let reread = Interface::parse("todo", &text, &mut map, &mut diags);
833 assert!(!diags.has_errors(), "{}\n---\n{text}", diags.render(&map));
834 assert_eq!(
835 original.digest(),
836 reread.digest(),
837 "rendered:\n{text}\n\noriginal {original:#?}\n\nreread {reread:#?}"
838 );
839 }
840
841 /// The same, for a library — which is what a module publishing only types and functions is.
842 fn iface_of_library(src: &str) -> Interface {
843 let (placed, d, map) = crate::compile_or_library_str("g.beck", src);
844 assert!(!d.has_errors(), "{}", d.render(&map));
845 Interface::of(&placed.expect("it compiles").program)
846 }
847
848 /// A library whose published types are parameterised, through the file form and back.
849 const GENERIC: &str = "\
850model Stamped[T]:
851 at: Int
852 what: T
853
854union Verdict[T]:
855 Waiting(item: T)
856 Failed(item: T, why: Str)
857
858type Trail[T] = list[Stamped[T]]
859
860def stamp[T](at: Int, what: T) -> Stamped[T]:
861 return Stamped(at=at, what=what)
862
863def length(t: Trail[Str]) -> Int:
864 return list_len(t)
865
866def held(v: Verdict[Str]) -> Str:
867 match v:
868 case Waiting(item):
869 return item
870 case Failed(item, why):
871 return why
872";
873
874 #[test]
875 fn a_parameterised_declaration_round_trips_through_the_file_form() {
876 // A `.becki` is source, and the checker holds a declaration's parameters positionally. If
877 // rendering did not put the names back, the file would say `what: ?1000000` and nothing
878 // would read it again.
879 let original = iface_of_library(GENERIC);
880 let text = original.render();
881 assert!(text.contains("model Stamped[T]:"), "{text}");
882 assert!(text.contains("what: T"), "{text}");
883 assert!(text.contains("union Verdict[T]:"), "{text}");
884 assert!(text.contains("type Trail[T] = list[Stamped[T]]"), "{text}");
885 assert!(
886 !text.contains('?'),
887 "no positional variable escapes:\n{text}"
888 );
889
890 let mut diags = Diagnostics::new();
891 let mut map = SourceMap::new();
892 let reread = Interface::parse("g", &text, &mut map, &mut diags);
893 assert!(!diags.has_errors(), "{}\n---\n{text}", diags.render(&map));
894 assert_eq!(original.digest(), reread.digest(), "rendered:\n{text}");
895 }
896
897 #[test]
898 fn the_same_declaration_at_two_arguments_is_two_boundaries() {
899 // The question `sicp/refusals/generic-type.beck` asked of `--wire-compat`: whether
900 // `Stamped[Int]` and `Stamped[Str]` are the same boundary. They are not, and the structural
901 // hash is where that is decided — a declaration is published once, parameterised, and every
902 // *mention* of it carries the arguments that make it a type.
903 let ints = iface_of_library(&GENERIC.replace("Trail[Str]", "Trail[Int]"));
904 let strs = iface_of_library(GENERIC);
905 assert_ne!(ints.digest(), strs.digest());
906 assert_eq!(
907 ints.types.iter().find(|t| t.name().as_ref() == "Stamped"),
908 strs.types.iter().find(|t| t.name().as_ref() == "Stamped"),
909 "the declaration itself did not change; only what a signature applied it to"
910 );
911 }
912
913 #[test]
914 fn an_interface_with_a_body_in_it_is_refused() {
915 let mut diags = Diagnostics::new();
916 let mut map = SourceMap::new();
917 Interface::parse(
918 "x",
919 "def f(a: Int) -> Int:\n return a\n",
920 &mut map,
921 &mut diags,
922 );
923 assert!(diags.iter().any(|d| d.code == "B0600"));
924 }
925}