beck_core/
project.rs

1//! Multi-module compilation: check against signatures, then link.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.6's
4//! consequence, spelled out: "modules compile against signatures (true separate compilation,
5//! parallel builds); body edits don't invalidate downstream modules".
6//!
7//! # Two passes, and the difference between them is the whole point
8//!
9//! **Checking** a module needs the *interfaces* of what it imports and nothing else. Its types
10//! unify against imported types, its rows widen with imported rows, and its placement is solved
11//! within the module — because §3.6 makes placement part of the published signature, so an imported
12//! name's tier is a given rather than a variable. That is why editing a body downstream is free:
13//! there is nothing in the interface for it to change.
14//!
15//! **Linking** needs the bodies, because a program that runs has to have code in it. This is the
16//! `.mli`/`.ml` division, and it is worth being explicit that the two halves have different inputs:
17//! an interface is enough to *compile* against, and never enough to *run*.
18//!
19//! # What the link step does and does not do
20//!
21//! It merges checked modules into one [`crate::check::Program`] and slices that. Every imported
22//! definition arrives with its placement already decided and marked as such, so the root module's
23//! solve cannot move it — a downstream edit re-placing an upstream function would be exactly the
24//! failure §3.6 exists to prevent.
25//!
26//! It does **not** namespace: two modules that define the same name are an error rather than a
27//! shadowing rule, because Phase 2 has no qualified references to disambiguate with. Named, rather
28//! than discovered.
29//!
30//! # Where a module comes from
31//!
32//! A [`Loader`] answers for the program being compiled — for the CLI, the directory the root module
33//! lives in. What it cannot answer for, [`crate::stdlib`] does: the standard library's Beck half is
34//! carried in the compiler, so `import bignum` resolves from any directory
35//! ([`10`](../../../../../docs/10-decisions.md) D23,
36//! [`adr/0018`](../../../../../docs/adr/0018-the-standard-library-is-carried-in-the-compiler.md)).
37//!
38//! The order is loader first, library second, and it is not arbitrary: a project must be able to
39//! keep the name of a module it already has when the standard library grows one, and a library
40//! being *worked on* — `lib/decimal.beck` importing `bignum` — must get the file beside it rather
41//! than the copy the compiler was built with.
42
43use std::collections::{BTreeMap, BTreeSet};
44
45use beck_diag::{Diagnostic, Diagnostics, Span};
46
47use crate::check::{check_module_importing, Mode, Program};
48use crate::iface::Interface;
49use crate::place;
50use crate::split::Placed;
51
52/// One module's sources: its implementation, and its published interface if one is checked in.
53#[derive(Clone, Debug, Default)]
54pub struct Sources {
55    /// The `.beck` file. Required to link; optional to check against.
56    pub module: Option<String>,
57    /// The `.becki` file, if it is checked in. When present it is what downstream modules see —
58    /// which is the point: reviewing the contract is reviewing this file.
59    pub interface: Option<String>,
60    /// Where the module text came from, if it was a file. Two things depend on it and both are
61    /// visible to a user: which surface the text is in — `.sx` selects the S-expression reader
62    /// (§2.2) — and what a diagnostic calls the file. Defaults to `<name>.beck`.
63    pub path: Option<String>,
64}
65
66/// Where modules come from.
67pub trait Loader {
68    fn load(&self, name: &str) -> Option<Sources>;
69}
70
71impl<F: Fn(&str) -> Option<Sources>> Loader for F {
72    fn load(&self, name: &str) -> Option<Sources> {
73        self(name)
74    }
75}
76
77/// A module, checked and placed, with its published interface.
78pub struct Checked {
79    pub program: Program,
80    pub interface: Interface,
81}
82
83/// Check one module against its imports' interfaces, and solve its placement.
84pub fn check_one(
85    name: &str,
86    src: &str,
87    imports: &[(String, Interface)],
88    lock: Option<&place::Lock>,
89    diags: &mut Diagnostics,
90) -> Checked {
91    let mut map = beck_diag::SourceMap::new();
92    let file = map.add(name, src);
93    check_one_in(file, name, src, imports, lock, diags)
94}
95
96/// The same, against a caller's source map so that diagnostics point at the right file.
97pub fn check_one_in(
98    file: beck_diag::FileId,
99    name: &str,
100    src: &str,
101    imports: &[(String, Interface)],
102    lock: Option<&place::Lock>,
103    diags: &mut Diagnostics,
104) -> Checked {
105    check_one_in_with(file, name, src, imports, &[], lock, diags)
106}
107
108/// The same, with the **parsed** modules this one imports, so their macros are in scope.
109///
110/// A macro crosses an import the way every other declaration does ([`beck_macro::expand_module_with`]),
111/// and what it needs is the imported module's *source* rather than its interface — a macro has no
112/// signature to publish. So this takes parsed modules and [`check_one_in`] passes none, which is
113/// what a caller compiling one file alone has.
114#[allow(clippy::too_many_arguments)]
115pub fn check_one_in_with(
116    file: beck_diag::FileId,
117    name: &str,
118    src: &str,
119    imports: &[(String, Interface)],
120    macros_from: &[&beck_syntax::Node],
121    lock: Option<&place::Lock>,
122    diags: &mut Diagnostics,
123) -> Checked {
124    let parsed = beck_syntax::parse_file(file, name, src, diags);
125    let expanded = beck_macro::expand_module_with(&parsed, macros_from, diags);
126    let mut program = check_module_importing(&expanded, Mode::Module, imports, macros_from, diags);
127    let solution = place::solve(&program, lock);
128    place::apply(&mut program, &solution);
129    place::check_placement(&program, diags);
130    // Only the per-module half here. Whether a capability has a holder is a question about the
131    // linked program, and a module that holds `cap.session` while the wiring lives elsewhere is
132    // the *correct* factoring, not a violation.
133    crate::secure::check_boundaries(&program, diags);
134    // Per module, for the same reason: a class literal is written in the module that writes it,
135    // and the table it is held against is the compiler's rather than the program's.
136    crate::style::check_classes(&program, diags);
137    let interface = Interface::of(&program);
138    Checked { program, interface }
139}
140
141/// Every module imported by a module that declares a macro.
142///
143/// A macro body resolves the `def`s of the modules *its own* module imports, so which sources have
144/// to be kept is decided by the importer rather than by the imported file. Pre-filtered by the same
145/// text test [`parsed_if_it_declares_a_macro`] uses and for the same reason: only a module that
146/// contains the word pays the parse that finds its imports.
147fn imports_of_modules_declaring_a_macro(
148    order: &[String],
149    sources: &BTreeMap<String, (Sources, beck_diag::FileId)>,
150) -> BTreeSet<String> {
151    let imports = |name: &str| -> Vec<String> {
152        let Some((src, file)) = sources.get(name) else {
153            return Vec::new();
154        };
155        let Some(text) = &src.module else {
156            return Vec::new();
157        };
158        let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
159        imports_of(*file, &display, text)
160    };
161
162    let mut pending: Vec<String> = Vec::new();
163    for name in order {
164        let Some((src, _)) = sources.get(name) else {
165            continue;
166        };
167        if src.module.as_deref().is_some_and(|t| t.contains("macro ")) {
168            pending.extend(imports(name));
169        }
170    }
171    // Closed over imports, because a macro body resolves in **its own** module's environment: a
172    // macro declared in `decimal` and used three modules away still calls what `decimal` imports.
173    let mut out: BTreeSet<String> = BTreeSet::new();
174    while let Some(name) = pending.pop() {
175        if !out.insert(name.clone()) {
176            continue;
177        }
178        pending.extend(imports(&name));
179    }
180    out
181}
182
183/// A parse whose diagnostics are dropped, because the caller has already reported them.
184fn parse_quietly(file: beck_diag::FileId, display: &str, src: &str) -> beck_syntax::Node {
185    let mut quiet = Diagnostics::new();
186    beck_syntax::parse_file(file, display, src, &mut quiet)
187}
188
189/// A module's parse, but only if it declares a macro somebody importing it could use.
190///
191/// The text is searched before anything is parsed, and deliberately: the answer is "no" for almost
192/// every module in almost every build, and a second parse is what this exists to avoid. A false
193/// *positive* costs one wasted parse and a false negative is impossible — `macro` is a keyword, so
194/// a module that declares one contains the word.
195///
196/// Parsed a second time rather than threaded out of [`check_one_in_with`], because the alternative
197/// makes every caller of that function hold a syntax tree it has no use for; the diagnostics of
198/// this parse are dropped for the same reason they would be duplicates.
199fn parsed_if_it_declares_a_macro(
200    file: beck_diag::FileId,
201    display: &str,
202    src: &str,
203) -> Option<beck_syntax::Node> {
204    if !src.contains("macro ") {
205        return None;
206    }
207    let mut quiet = Diagnostics::new();
208    let parsed = beck_syntax::parse_file(file, display, src, &mut quiet);
209    parsed
210        .args
211        .iter()
212        .any(|i| i.is_form(beck_syntax::sym::MACRO) || i.is_form(beck_syntax::sym::TYPED_MACRO))
213        .then_some(parsed)
214}
215
216/// The modules a source file imports, in source order.
217pub fn imports_of(file: beck_diag::FileId, name: &str, src: &str) -> Vec<String> {
218    let mut diags = Diagnostics::new();
219    let parsed = beck_syntax::parse_file(file, name, src, &mut diags);
220    parsed
221        .args
222        .iter()
223        .skip(1)
224        .filter(|n| n.is_form(beck_syntax::sym::IMPORT))
225        .filter_map(|n| n.args.first().and_then(|a| a.as_var()))
226        .map(|s| s.as_str().to_string())
227        .collect()
228}
229
230/// A checked, linked project, before it is sliced.
231///
232/// Separate from [`compile_project`] because publishing an interface and typechecking a library are
233/// things a module can do without being an application. Only slicing needs a merge point, a durable
234/// fold and a page — and a policy module that has none of those is not broken, it is a policy
235/// module.
236pub struct Project {
237    pub program: Program,
238    /// The root module's placement, for `beck explain place`.
239    pub solution: place::Solution,
240    /// The root module's published contract.
241    pub interface: Interface,
242    /// Modules reached through an `import` that have a `.becki` and **no** `.beck`.
243    ///
244    /// Checking against one is the whole of §3.6 and is not an error — the contract is enough to
245    /// compile against. It is never enough to *run*, because there are no bodies to link, so it is
246    /// recorded here rather than refused, and [`require_implementations`] is the question a caller
247    /// that means to execute the program asks.
248    pub unimplemented: Vec<String>,
249}
250
251/// Check and link a project, stopping before the slicer.
252pub fn check_project(
253    root: &str,
254    loader: &dyn Loader,
255    lock: Option<&place::Lock>,
256    map: &mut beck_diag::SourceMap,
257    diags: &mut Diagnostics,
258) -> Option<Project> {
259    let mut order: Vec<String> = Vec::new();
260    let mut visiting: Vec<String> = Vec::new();
261    let mut sources: BTreeMap<String, (Sources, beck_diag::FileId)> = BTreeMap::new();
262    // Which of them came from the compiler rather than from the caller's directory, because a
263    // standard-library module's tests are not the program's — see where this is read, below.
264    let mut from_library: BTreeSet<String> = BTreeSet::new();
265
266    // Depth-first over imports, deepest first, so a module is checked only once everything it
267    // depends on has an interface.
268    #[allow(clippy::too_many_arguments)]
269    fn visit(
270        name: &str,
271        loader: &dyn Loader,
272        map: &mut beck_diag::SourceMap,
273        sources: &mut BTreeMap<String, (Sources, beck_diag::FileId)>,
274        from_library: &mut BTreeSet<String>,
275        order: &mut Vec<String>,
276        visiting: &mut Vec<String>,
277        diags: &mut Diagnostics,
278    ) {
279        if order.iter().any(|n| n == name) {
280            return;
281        }
282        if visiting.iter().any(|n| n == name) {
283            diags.push(
284                Diagnostic::error(
285                    "B0602",
286                    format!("module `{name}` imports itself, directly or through a cycle"),
287                    Span::NONE,
288                )
289                .with_note(format!("the cycle is {} → {name}", visiting.join(" → ")))
290                .with_note(
291                    "a module's interface is derived from its body, so a cycle would mean each \
292                     module needed the other's contract before either had one",
293                ),
294            );
295            return;
296        }
297        // The caller's directory first, the standard library second — the module doc says why the
298        // order is that way round rather than the other.
299        let loaded = loader.load(name).or_else(|| {
300            crate::stdlib::sources(name).inspect(|_| {
301                from_library.insert(name.to_string());
302            })
303        });
304        let Some(src) = loaded else {
305            diags.push(
306                Diagnostic::error("B0603", format!("cannot find module `{name}`"), Span::NONE)
307                    .with_note(format!(
308                        "looked for `{name}.becki` and `{name}.beck` beside the root module, and \
309                         for a standard-library module called `{name}`"
310                    )),
311            );
312            return;
313        };
314        let text = src
315            .module
316            .clone()
317            .or_else(|| src.interface.clone())
318            .unwrap_or_default();
319        let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
320        let file = map.add(display.clone(), text.clone());
321        visiting.push(name.to_string());
322        for dep in imports_of(file, &display, &text) {
323            visit(
324                &dep,
325                loader,
326                map,
327                sources,
328                from_library,
329                order,
330                visiting,
331                diags,
332            );
333        }
334        visiting.pop();
335        sources.insert(name.to_string(), (src, file));
336        order.push(name.to_string());
337    }
338
339    visit(
340        root,
341        loader,
342        map,
343        &mut sources,
344        &mut from_library,
345        &mut order,
346        &mut visiting,
347        diags,
348    );
349    if diags.has_errors() {
350        return None;
351    }
352
353    let mut interfaces: BTreeMap<String, Interface> = BTreeMap::new();
354    let mut checked: Vec<Checked> = Vec::new();
355    // The modules whose **source** somebody downstream needs, parsed. Two reasons a module is in
356    // here, and they are different: it declares a macro an importer will expand, or an importer
357    // declares a macro whose *body* calls this module's `def`s. The second is why the set cannot
358    // be decided by looking at the imported module alone — nothing about `dates.beck` says whether
359    // the file importing it has a macro — and it is what `needed_by_a_macro` below answers.
360    //
361    // Still not every module: both questions are pre-filtered by the same cheap text test, so a
362    // build with no macros anywhere parses nothing twice.
363    let mut macro_sources: BTreeMap<String, beck_syntax::Node> = BTreeMap::new();
364    let mut unimplemented: Vec<String> = Vec::new();
365    let needed_by_a_macro = imports_of_modules_declaring_a_macro(&order, &sources);
366    // What each module imports, filled in as the modules are checked. Dependency order is what
367    // makes a lookup here answer: a module's imports are recorded before anything that imports it.
368    let mut import_names: BTreeMap<String, Vec<String>> = BTreeMap::new();
369
370    for name in &order {
371        let Some((src, file)) = sources.get(name) else {
372            continue;
373        };
374        let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
375        let deps: Vec<(String, Interface)> = {
376            let text = src.module.clone().or_else(|| src.interface.clone());
377            imports_of(*file, &display, text.as_deref().unwrap_or(""))
378                .into_iter()
379                .filter_map(|d| interfaces.get(&d).map(|i| (d, i.clone())))
380                .collect()
381        };
382
383        // The published interface, if one is checked in, is what downstream sees — not what this
384        // module happens to compile to today. That is the difference between a contract and a
385        // description, and it is the reason `beck iface` writes a file rather than a cache entry.
386        if let Some(text) = &src.interface {
387            // Against `deps`, for the reason a module is: a published `impl` or bound names a
388            // trait that may live in another module, and a contract read with nothing to resolve
389            // it against reports `B0383` on a file `beck iface` wrote.
390            let published = Interface::parse_with(name, text, &deps, map, diags);
391            interfaces.insert(name.clone(), published);
392        }
393
394        let Some(module_src) = &src.module else {
395            // Interface only: it can be checked against, but there is no code to link.
396            //
397            // The **root** is refused here, because a project whose root is a contract is not a
398            // program at all and nothing downstream could make it one. A *dependency* is only a
399            // problem for running, so it is recorded and [`require_implementations`] asks.
400            if name == root {
401                diags.push(
402                    Diagnostic::error(
403                        "B0604",
404                        format!("`{name}` has an interface but no implementation"),
405                        Span::NONE,
406                    )
407                    .with_note("an interface is enough to compile against and never enough to run"),
408                );
409            } else {
410                unimplemented.push(name.clone());
411            }
412            continue;
413        };
414
415        // The parsed form of each import that has a macro in it. `deps` is the same list read for
416        // interfaces, so a macro is visible exactly where the module that declares it is imported —
417        // directly, not through somebody else's import, which is the rule a `def` already follows.
418        // Closed over imports rather than the direct ones: a macro crosses on its source, and its
419        // body resolves the `def`s **its own** module can see (`docs/02` §2.4). A macro declared in
420        // `decimal` and used by a program that imports `decimal` still calls what `decimal`
421        // imports, and that module is two steps from here. Merging them flat is not a scope
422        // violation — Beck links modules into one namespace and `B0601` refuses a name defined
423        // twice — but it is a looseness in one direction: a body can reach a name its module
424        // reaches only transitively.
425        let mut reach: Vec<String> = deps.iter().map(|(d, _)| d.clone()).collect();
426        let mut seen: BTreeSet<String> = BTreeSet::new();
427        while let Some(d) = reach.pop() {
428            if !seen.insert(d.clone()) {
429                continue;
430            }
431            if let Some(next) = import_names.get(&d) {
432                reach.extend(next.iter().cloned());
433            }
434        }
435        let macros_from: Vec<&beck_syntax::Node> =
436            seen.iter().filter_map(|d| macro_sources.get(d)).collect();
437        import_names.insert(name.clone(), deps.iter().map(|(d, _)| d.clone()).collect());
438        let mut one = check_one_in_with(
439            *file,
440            &display,
441            module_src,
442            &deps,
443            &macros_from,
444            lock,
445            diags,
446        );
447        // Kept when this module has a macro somebody downstream could use, or when somebody
448        // downstream has a macro whose body will call what this module defines.
449        let kept = parsed_if_it_declares_a_macro(*file, &display, module_src).or_else(|| {
450            needed_by_a_macro
451                .contains(name)
452                .then(|| parse_quietly(*file, &display, module_src))
453        });
454        if let Some(parsed) = kept {
455            macro_sources.insert(name.clone(), parsed);
456        }
457        // A standard-library module's `test` blocks are the *compiler's* tests, not this program's.
458        // They are still checked — a library that stopped compiling its own tests would be broken —
459        // and they are dropped before the link, so `beck test` on a program that imports `bignum`
460        // reports the program's tests and not two hundred of ours. `beck-cli/tests/stdlib.rs` is
461        // where they run (§21.2's rule that a program's behaviour is asserted in the program still
462        // holds; the program asserting them is the library file itself).
463        if from_library.contains(name) {
464            one.program.tests.clear();
465        }
466        // Where both exist, the checked-in interface is the contract and the module must meet it.
467        if let Some(published) = interfaces.get(name) {
468            if published.digest() != one.interface.digest() {
469                diags.push(
470                    Diagnostic::error(
471                        "B0605",
472                        format!("`{name}` does not match its published interface"),
473                        Span::NONE,
474                    )
475                    .with_note(format!(
476                        "`{name}.becki` says {} and the module compiles to {}",
477                        published.digest(),
478                        one.interface.digest()
479                    ))
480                    .with_fix("regenerate it with `beck iface`, and review the diff"),
481                );
482            }
483        } else {
484            interfaces.insert(name.clone(), one.interface.clone());
485        }
486        checked.push(one);
487    }
488
489    if diags.has_errors() {
490        return None;
491    }
492
493    let interface = interfaces.get(root).cloned().unwrap_or_default();
494    let mut merged = link(root, checked, diags)?;
495    // Once, on the whole linked program: a last read in one module is a last read after linking.
496    crate::liveness::mark_program(&mut merged);
497    crate::frames::reserve_program(&mut merged);
498    crate::fields::order_program(&mut merged);
499    // Now the whole program exists, so the whole-program questions can be asked.
500    crate::secure::check_capabilities(&merged, diags);
501    if diags.has_errors() {
502        return None;
503    }
504    // Every placement was decided by the module that owns it and is pinned by the link; solving
505    // over the merged program is how those decisions are collected for `beck explain place`.
506    let solution = place::solve(&merged, lock);
507    Some(Project {
508        program: merged,
509        solution,
510        interface,
511        unimplemented,
512    })
513}
514
515/// Refuse a project that cannot run because one of its modules has no implementation.
516///
517/// Separate from [`check_project`] because "this typechecks against the contracts it was given"
518/// and "this can be executed" are two questions, and §3.6's separate compilation is the first one
519/// being useful on its own: `beck check` and `beck iface` work against a `.becki` with no `.beck`
520/// beside it, and refusing that would delete the feature.
521///
522/// Executing is the other question. A module that contributed no bodies is simply absent from the
523/// link, so every call into it dangles and the failure arrives from the linker as `no such
524/// definition` — an absence, with nothing naming the module or saying why. `B0604` already says
525/// the right thing and was reachable only when such a module was the *root*.
526pub fn require_implementations(project: &Project, diags: &mut Diagnostics) -> bool {
527    for name in &project.unimplemented {
528        diags.push(
529            Diagnostic::error(
530                "B0604",
531                format!("`{name}` has an interface but no implementation"),
532                Span::NONE,
533            )
534            .with_note("an interface is enough to compile against and never enough to run")
535            .with_note(format!(
536                "`{name}.becki` was found and `{name}.beck` was not, so nothing this program calls \
537                 in it has a body to link"
538            )),
539        );
540    }
541    project.unimplemented.is_empty()
542}
543
544/// Slice a checked project into the roles the runtime drives.
545///
546/// Separate from [`check_project`] so that "this typechecks" and "this is a runnable application"
547/// are two answers rather than one: a library gets the first and not the second, and that is not a
548/// failure.
549pub fn slice(project: Project, diags: &mut Diagnostics) -> Option<Placed> {
550    let solution = project.solution;
551    crate::split::split(project.program, diags).map(|mut p| {
552        p.placement = solution;
553        p
554    })
555}
556
557/// Slice a project, or wrap it as a library if the only thing wrong with it is that it is one.
558///
559/// [`slice()`] answers the *application* question and a module that is not an application is still a
560/// module — `beck check` has said so since Phase 2. What it could not do was give that module back
561/// to a caller, so a library had no way to run its own tests
562/// (`docs/22-phase-3-report.md` §22.6, `docs/25-benchmarks-and-expressiveness.md` §25.6 item 1).
563///
564/// The B0500/B0501/B0505 diagnostics are **dropped** on that path rather than downgraded to
565/// warnings, because they are answers to a question this caller did not ask. Every other diagnostic
566/// is kept and the result is `None`: a library with a type error is a broken module, not a library.
567pub fn slice_or_library(project: Project, diags: &mut Diagnostics) -> Option<Placed> {
568    let program = project.program.clone();
569    let solution = project.solution.clone();
570    let mut slicing = Diagnostics::new();
571    if let Some(mut placed) = crate::split::split(project.program, &mut slicing) {
572        diags.extend(slicing);
573        placed.placement = solution;
574        return Some(placed);
575    }
576    if !slicing.iter().all(|d| NOT_AN_APPLICATION.contains(&d.code)) {
577        diags.extend(slicing);
578        return None;
579    }
580    // The graph is rebuilt rather than kept from the failed slice, because `split` consumed the
581    // program. A graph that cannot be built is a real error and lands in `diags`.
582    let graph = crate::signal::Graph::build(&program, diags)?;
583    let wire_id = format!("lib:{}", program.name);
584    Some(Placed::library(program, graph, wire_id))
585}
586
587/// The diagnostics that mean "this module is a library", not "this module is wrong".
588///
589/// Each is the slicer reporting a missing *application* part — a merge point, a durable fold, a
590/// page. A domain module has none of them by design.
591pub const NOT_AN_APPLICATION: [&str; 3] = ["B0500", "B0501", "B0505"];
592
593/// Compile a whole project: check, link, and slice.
594///
595/// The caller's `SourceMap` is what every module is added to, because a diagnostic about the third
596/// module in a project has to be renderable by whoever asked for the first.
597pub fn compile_project(
598    root: &str,
599    loader: &dyn Loader,
600    lock: Option<&place::Lock>,
601    map: &mut beck_diag::SourceMap,
602    diags: &mut Diagnostics,
603) -> Option<Placed> {
604    let project = check_project(root, loader, lock, map, diags)?;
605    // This entry point builds something to run, so a module with no bodies is refused here rather
606    // than discovered by the linker as `no such definition`.
607    if !require_implementations(&project, diags) {
608        return None;
609    }
610    slice(project, diags)
611}
612
613/// Merge checked modules into one program.
614fn link(root: &str, modules: Vec<Checked>, diags: &mut Diagnostics) -> Option<Program> {
615    let mut out: Option<Program> = None;
616    let mut seen: BTreeSet<std::sync::Arc<str>> = BTreeSet::new();
617
618    for Checked { mut program, .. } in modules {
619        // An imported definition's placement is part of its published signature (§3.6), so at link
620        // time it is a given. Marking it annotated is how the root's solve is told not to move it.
621        for def in program.defs.values_mut() {
622            def.tier_is_annotated = true;
623        }
624        for s in program.signals.iter_mut() {
625            s.tier_is_annotated = true;
626        }
627
628        let Some(acc) = out.as_mut() else {
629            seen.extend(program.defs.keys().cloned());
630            out = Some(program);
631            continue;
632        };
633        for (name, def) in program.defs {
634            if !seen.insert(name.clone()) {
635                diags.push(
636                    Diagnostic::error(
637                        "B0601",
638                        format!("`{name}` is defined in more than one module"),
639                        def.span,
640                    )
641                    .with_note(
642                        "Phase 2 links modules into one namespace and has no qualified reference \
643                         to tell two definitions apart, so a clash is an error rather than a \
644                         shadowing rule",
645                    ),
646                );
647                continue;
648            }
649            acc.def_order.push(name.clone());
650            acc.defs.insert(name, def);
651        }
652        for (n, t) in program.types {
653            acc.types.entry(n).or_insert(t);
654        }
655        acc.own_types.extend(program.own_types);
656        acc.signals.extend(program.signals);
657        acc.tests.extend(program.tests);
658        // The doc comments too. Without this the merged program keeps only the *first* module's,
659        // which is the deepest import rather than the root — so `beck doc` on a module that imports
660        // another documented the wrong module's names. Invisible until a module in `lib/` imported
661        // one (`docs/46` §46.8); a clash is impossible for a definition, because `B0601` above
662        // already refuses two modules defining one name.
663        acc.docs.extend(program.docs);
664        // `identity = external(issuer=…)` is a property of the *program*, so it survives the link
665        // from whichever module wrote it — and the accumulator starts as the **first** module,
666        // which is the deepest import rather than the root (`docs/46` §46.8's shape). Leaving it
667        // to `out`'s initial value would mean a root that declares one and imports a module that
668        // does not gets no identity at all.
669        match (acc.identity.is_some(), program.identity) {
670            (false, Some(decl)) => acc.identity = Some(decl),
671            (true, Some(decl)) => diags.push(
672                Diagnostic::error(
673                    "B0359",
674                    "identity is declared in more than one module",
675                    decl.span(),
676                )
677                .with_note(
678                    "who authenticates a program's clients is one answer for the whole program, \
679                     and a linked module set is one program",
680                ),
681            ),
682            (_, None) => {}
683        }
684    }
685
686    let mut merged = out?;
687    merged.name = root.to_string();
688    (!diags.has_errors()).then_some(merged)
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use crate::ty::Tier;
695
696    /// A three-module project: a domain, a policy over it, and the app that wires them.
697    fn project() -> BTreeMap<String, Sources> {
698        let domain = r#"
699type Id = newtype[Str]
700
701model Todo:
702    id: Id
703    text: Str
704    done: Bool
705    owner: Str
706
707model State:
708    todos: Map[Id, Todo]
709
710union Command:
711    Add(id: Id, text: Str)
712    Toggle(id: Id)
713
714union Event:
715    Added(id: Id, text: Str)
716    Toggled(id: Id)
717
718union Rejection:
719    BlankText
720    NotOwner
721
722def apply_event(s: State, env: Envelope[Event]) -> State:
723    match env.body:
724        case Added(id, text):
725            return s.with(todos=map_insert(s.todos, id, Todo(id=id, text=text, done=False, owner=env.actor)))
726        case Toggled(id):
727            return s
728"#;
729        let policy = r#"
730import domain
731
732def validate(s: State, p: Proposal) -> Result[list[Event], Rejection]:
733    match p.command:
734        case Add(id, text):
735            if str_is_empty(str_trim(text)):
736                return Err(error=BlankText)
737            return Ok(value=[Added(id=id, text=text)])
738        case Toggle(id):
739            return Ok(value=[Toggled(id=id)])
740"#;
741        let app = r#"
742import domain
743import policy
744
745def view(s: State, session: Session) -> Html:
746    return ui:
747        main:
748            h1: "todos"
749            footer: (str(map_len(s.todos)) + " todos")
750
751proposals: Stream[Proposal] = merge_clients()
752events: Stream[Event] = decide(proposals, todos, validate)
753todos: Signal[State] = durable(fold(apply_event, State(todos={}), events))
754page: Signal[Html] = per_session(todos, view)
755"#;
756        BTreeMap::from([
757            (
758                "domain".to_string(),
759                Sources {
760                    module: Some(domain.into()),
761                    interface: None,
762                    path: None,
763                },
764            ),
765            (
766                "policy".to_string(),
767                Sources {
768                    module: Some(policy.into()),
769                    interface: None,
770                    path: None,
771                },
772            ),
773            (
774                "app".to_string(),
775                Sources {
776                    module: Some(app.into()),
777                    interface: None,
778                    path: None,
779                },
780            ),
781        ])
782    }
783
784    fn compile(files: &BTreeMap<String, Sources>) -> (Option<Placed>, Diagnostics) {
785        let mut diags = Diagnostics::new();
786        let mut map = beck_diag::SourceMap::new();
787        let out = compile_project(
788            "app",
789            &|n: &str| files.get(n).cloned(),
790            None,
791            &mut map,
792            &mut diags,
793        );
794        (out, diags)
795    }
796
797    #[test]
798    fn a_three_module_project_compiles_links_and_places() {
799        let files = project();
800        let (placed, d) = compile(&files);
801        assert!(
802            !d.has_errors(),
803            "{:?}",
804            d.iter().map(|x| (x.code, &x.message)).collect::<Vec<_>>()
805        );
806        let placed = placed.expect("it links");
807        // Definitions from all three modules are in the linked program…
808        for name in ["apply_event", "validate", "view"] {
809            assert!(placed.program.defs.contains_key(name), "missing {name}");
810        }
811        // …and the app's own wiring is placed as it would be alone.
812        let tier = |n: &str| {
813            placed
814                .program
815                .signals
816                .iter()
817                .find(|s| s.name.as_ref() == n)
818                .map(|s| s.tier)
819        };
820        assert_eq!(tier("proposals"), Some(Tier::Server));
821        assert_eq!(tier("todos"), Some(Tier::Data));
822        assert_eq!(tier("page"), Some(Tier::Client));
823    }
824
825    #[test]
826    fn a_body_edit_upstream_does_not_change_any_downstream_contract() {
827        // §3.6's firewall, at project scale. `domain`'s body changes; its interface does not; so
828        // nothing downstream has anything to recompile against.
829        let files = project();
830        let before = {
831            let mut d = Diagnostics::new();
832            check_one(
833                "domain",
834                files["domain"].module.as_ref().unwrap(),
835                &[],
836                None,
837                &mut d,
838            )
839            .interface
840        };
841
842        let mut edited = files.clone();
843        let body = files["domain"].module.as_ref().unwrap().replace(
844            "case Toggled(id):\n            return s",
845            "case Toggled(id):\n            return s.with(todos=s.todos)",
846        );
847        edited.get_mut("domain").unwrap().module = Some(body);
848
849        let after = {
850            let mut d = Diagnostics::new();
851            check_one(
852                "domain",
853                edited["domain"].module.as_ref().unwrap(),
854                &[],
855                None,
856                &mut d,
857            )
858            .interface
859        };
860        assert_eq!(before.digest(), after.digest());
861
862        // …and the project still compiles, which is the other half: the firewall is only useful if
863        // what it protects still works.
864        let (placed, d) = compile(&edited);
865        assert!(!d.has_errors());
866        assert!(placed.is_some());
867    }
868
869    #[test]
870    fn a_checked_in_interface_that_the_module_no_longer_meets_is_an_error() {
871        // The failure mode a generated-and-committed contract exists to catch: the file says one
872        // thing, the code does another, and downstream believed the file.
873        let mut files = project();
874        let mut d = Diagnostics::new();
875        let iface = check_one(
876            "domain",
877            files["domain"].module.as_ref().unwrap(),
878            &[],
879            None,
880            &mut d,
881        )
882        .interface;
883        // Publish a contract, then widen the module's row behind it.
884        files.get_mut("domain").unwrap().interface = Some(iface.render());
885        let widened = files["domain"].module.as_ref().unwrap().replace(
886            "def apply_event(s: State, env: Envelope[Event]) -> State:",
887            "def apply_event(s: State, env: Envelope[Event]) -> State uses log:\n    return apply(s, env)\n\ndef apply(s: State, env: Envelope[Event]) -> State:",
888        );
889        files.get_mut("domain").unwrap().module = Some(widened);
890        let (_, d) = compile(&files);
891        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
892        assert!(codes.contains(&"B0605"), "got {codes:?}");
893    }
894
895    #[test]
896    fn an_import_cycle_is_reported_rather_than_looped_on() {
897        let files = BTreeMap::from([
898            (
899                "a".to_string(),
900                Sources {
901                    module: Some("import b\n\ndef f() -> Int:\n    return 1\n".into()),
902                    interface: None,
903                    path: None,
904                },
905            ),
906            (
907                "b".to_string(),
908                Sources {
909                    module: Some("import a\n\ndef g() -> Int:\n    return 2\n".into()),
910                    interface: None,
911                    path: None,
912                },
913            ),
914        ]);
915        let mut diags = Diagnostics::new();
916        let mut map = beck_diag::SourceMap::new();
917        compile_project(
918            "a",
919            &|n: &str| files.get(n).cloned(),
920            None,
921            &mut map,
922            &mut diags,
923        );
924        assert!(diags.iter().any(|d| d.code == "B0602"), "{:?}", diags.len());
925    }
926
927    #[test]
928    fn a_missing_module_says_what_it_looked_for() {
929        let mut diags = Diagnostics::new();
930        let files: BTreeMap<String, Sources> = BTreeMap::from([(
931            "a".to_string(),
932            Sources {
933                module: Some("import nowhere\n\ndef f() -> Int:\n    return 1\n".into()),
934                interface: None,
935                path: None,
936            },
937        )]);
938        let mut map = beck_diag::SourceMap::new();
939        compile_project(
940            "a",
941            &|n: &str| files.get(n).cloned(),
942            None,
943            &mut map,
944            &mut diags,
945        );
946        assert!(diags.iter().any(|d| d.code == "B0603"));
947    }
948
949    /// D23: a module the loader has never heard of still resolves if the library has it.
950    #[test]
951    fn the_standard_library_resolves_with_no_file_beside_the_root() {
952        let files: BTreeMap<String, Sources> = BTreeMap::from([(
953            "app".to_string(),
954            Sources {
955                module: Some(
956                    "import format\n\ndef nine(x: Float) -> Str:\n    return fixed(x, 9)\n".into(),
957                ),
958                interface: None,
959                path: None,
960            },
961        )]);
962        let mut diags = Diagnostics::new();
963        let mut map = beck_diag::SourceMap::new();
964        let project = check_project(
965            "app",
966            &|n: &str| files.get(n).cloned(),
967            None,
968            &mut map,
969            &mut diags,
970        );
971        assert!(
972            !diags.has_errors(),
973            "{:?}",
974            diags
975                .iter()
976                .map(|d| (d.code, &d.message))
977                .collect::<Vec<_>>()
978        );
979        let project = project.expect("it links");
980        assert!(project.program.defs.contains_key("fixed"));
981        // And the library's own tests are the library's: they do not become this program's.
982        assert!(
983            project.program.tests.is_empty(),
984            "{} imported test(s) from the standard library",
985            project.program.tests.len()
986        );
987    }
988
989    /// The loader wins, so a project keeps its own module when the library grows that name.
990    #[test]
991    fn a_module_beside_the_root_shadows_the_standard_library_module_of_the_same_name() {
992        let files = BTreeMap::from([
993            (
994                "format".to_string(),
995                Sources {
996                    module: Some(
997                        "def fixed(x: Float, places: Int) -> Str:\n    return \"mine\"\n".into(),
998                    ),
999                    interface: None,
1000                    path: None,
1001                },
1002            ),
1003            (
1004                "app".to_string(),
1005                Sources {
1006                    module: Some(
1007                        "import format\n\ndef nine(x: Float) -> Str:\n    return fixed(x, 9)\n"
1008                            .into(),
1009                    ),
1010                    interface: None,
1011                    path: None,
1012                },
1013            ),
1014        ]);
1015        let mut diags = Diagnostics::new();
1016        let mut map = beck_diag::SourceMap::new();
1017        let project = check_project(
1018            "app",
1019            &|n: &str| files.get(n).cloned(),
1020            None,
1021            &mut map,
1022            &mut diags,
1023        );
1024        // One `fixed`, not two: the library's copy was never loaded, so there is nothing to clash
1025        // with (`B0601`).
1026        assert!(
1027            !diags.has_errors(),
1028            "{:?}",
1029            diags
1030                .iter()
1031                .map(|d| (d.code, &d.message))
1032                .collect::<Vec<_>>()
1033        );
1034        assert!(project
1035            .expect("it links")
1036            .program
1037            .defs
1038            .contains_key("fixed"));
1039    }
1040
1041    #[test]
1042    fn two_modules_defining_one_name_is_an_error_and_not_a_shadowing_rule() {
1043        let files = BTreeMap::from([
1044            (
1045                "lib".to_string(),
1046                Sources {
1047                    module: Some("def helper() -> Int:\n    return 1\n".into()),
1048                    interface: None,
1049                    path: None,
1050                },
1051            ),
1052            (
1053                "app".to_string(),
1054                Sources {
1055                    module: Some("import lib\n\ndef helper() -> Int:\n    return 2\n".into()),
1056                    interface: None,
1057                    path: None,
1058                },
1059            ),
1060        ]);
1061        let mut diags = Diagnostics::new();
1062        let mut map = beck_diag::SourceMap::new();
1063        compile_project(
1064            "app",
1065            &|n: &str| files.get(n).cloned(),
1066            None,
1067            &mut map,
1068            &mut diags,
1069        );
1070        assert!(
1071            diags.iter().any(|d| d.code == "B0601"),
1072            "{:?}",
1073            diags.iter().map(|d| d.code).collect::<Vec<_>>()
1074        );
1075    }
1076}