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}