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