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}