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 ¯os_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}