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_with, 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    let parsed = beck_syntax::parse_file(file, name, src, diags);
106    let expanded = beck_macro::expand_module(&parsed, diags);
107    let mut program = check_module_with(&expanded, Mode::Module, imports, diags);
108    let solution = place::solve(&program, lock);
109    place::apply(&mut program, &solution);
110    place::check_placement(&program, diags);
111    // Only the per-module half here. Whether a capability has a holder is a question about the
112    // linked program, and a module that holds `cap.session` while the wiring lives elsewhere is
113    // the *correct* factoring, not a violation.
114    crate::secure::check_boundaries(&program, diags);
115    let interface = Interface::of(&program);
116    Checked { program, interface }
117}
118
119/// The modules a source file imports, in source order.
120pub fn imports_of(file: beck_diag::FileId, name: &str, src: &str) -> Vec<String> {
121    let mut diags = Diagnostics::new();
122    let parsed = beck_syntax::parse_file(file, name, src, &mut diags);
123    parsed
124        .args
125        .iter()
126        .skip(1)
127        .filter(|n| n.is_form(beck_syntax::sym::IMPORT))
128        .filter_map(|n| n.args.first().and_then(|a| a.as_var()))
129        .map(|s| s.as_str().to_string())
130        .collect()
131}
132
133/// A checked, linked project, before it is sliced.
134///
135/// Separate from [`compile_project`] because publishing an interface and typechecking a library are
136/// things a module can do without being an application. Only slicing needs a merge point, a durable
137/// fold and a page — and a policy module that has none of those is not broken, it is a policy
138/// module.
139pub struct Project {
140    pub program: Program,
141    /// The root module's placement, for `beck explain place`.
142    pub solution: place::Solution,
143    /// The root module's published contract.
144    pub interface: Interface,
145}
146
147/// Check and link a project, stopping before the slicer.
148pub fn check_project(
149    root: &str,
150    loader: &dyn Loader,
151    lock: Option<&place::Lock>,
152    map: &mut beck_diag::SourceMap,
153    diags: &mut Diagnostics,
154) -> Option<Project> {
155    let mut order: Vec<String> = Vec::new();
156    let mut visiting: Vec<String> = Vec::new();
157    let mut sources: BTreeMap<String, (Sources, beck_diag::FileId)> = BTreeMap::new();
158    // Which of them came from the compiler rather than from the caller's directory, because a
159    // standard-library module's tests are not the program's — see where this is read, below.
160    let mut from_library: BTreeSet<String> = BTreeSet::new();
161
162    // Depth-first over imports, deepest first, so a module is checked only once everything it
163    // depends on has an interface.
164    #[allow(clippy::too_many_arguments)]
165    fn visit(
166        name: &str,
167        loader: &dyn Loader,
168        map: &mut beck_diag::SourceMap,
169        sources: &mut BTreeMap<String, (Sources, beck_diag::FileId)>,
170        from_library: &mut BTreeSet<String>,
171        order: &mut Vec<String>,
172        visiting: &mut Vec<String>,
173        diags: &mut Diagnostics,
174    ) {
175        if order.iter().any(|n| n == name) {
176            return;
177        }
178        if visiting.iter().any(|n| n == name) {
179            diags.push(
180                Diagnostic::error(
181                    "B0602",
182                    format!("module `{name}` imports itself, directly or through a cycle"),
183                    Span::NONE,
184                )
185                .with_note(format!("the cycle is {} → {name}", visiting.join(" → ")))
186                .with_note(
187                    "a module's interface is derived from its body, so a cycle would mean each \
188                     module needed the other's contract before either had one",
189                ),
190            );
191            return;
192        }
193        // The caller's directory first, the standard library second — the module doc says why the
194        // order is that way round rather than the other.
195        let loaded = loader.load(name).or_else(|| {
196            crate::stdlib::sources(name).inspect(|_| {
197                from_library.insert(name.to_string());
198            })
199        });
200        let Some(src) = loaded else {
201            diags.push(
202                Diagnostic::error("B0603", format!("cannot find module `{name}`"), Span::NONE)
203                    .with_note(format!(
204                        "looked for `{name}.becki` and `{name}.beck` beside the root module, and \
205                         for a standard-library module called `{name}`"
206                    )),
207            );
208            return;
209        };
210        let text = src
211            .module
212            .clone()
213            .or_else(|| src.interface.clone())
214            .unwrap_or_default();
215        let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
216        let file = map.add(display.clone(), text.clone());
217        visiting.push(name.to_string());
218        for dep in imports_of(file, &display, &text) {
219            visit(
220                &dep,
221                loader,
222                map,
223                sources,
224                from_library,
225                order,
226                visiting,
227                diags,
228            );
229        }
230        visiting.pop();
231        sources.insert(name.to_string(), (src, file));
232        order.push(name.to_string());
233    }
234
235    visit(
236        root,
237        loader,
238        map,
239        &mut sources,
240        &mut from_library,
241        &mut order,
242        &mut visiting,
243        diags,
244    );
245    if diags.has_errors() {
246        return None;
247    }
248
249    let mut interfaces: BTreeMap<String, Interface> = BTreeMap::new();
250    let mut checked: Vec<Checked> = Vec::new();
251
252    for name in &order {
253        let Some((src, file)) = sources.get(name) else {
254            continue;
255        };
256        let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
257        let deps: Vec<(String, Interface)> = {
258            let text = src.module.clone().or_else(|| src.interface.clone());
259            imports_of(*file, &display, text.as_deref().unwrap_or(""))
260                .into_iter()
261                .filter_map(|d| interfaces.get(&d).map(|i| (d, i.clone())))
262                .collect()
263        };
264
265        // The published interface, if one is checked in, is what downstream sees — not what this
266        // module happens to compile to today. That is the difference between a contract and a
267        // description, and it is the reason `beck iface` writes a file rather than a cache entry.
268        if let Some(text) = &src.interface {
269            let published = Interface::parse(name, text, map, diags);
270            interfaces.insert(name.clone(), published);
271        }
272
273        let Some(module_src) = &src.module else {
274            // Interface only: it can be checked against, but there is no code to link.
275            if name == root {
276                diags.push(
277                    Diagnostic::error(
278                        "B0604",
279                        format!("`{name}` has an interface but no implementation"),
280                        Span::NONE,
281                    )
282                    .with_note("an interface is enough to compile against and never enough to run"),
283                );
284            }
285            continue;
286        };
287
288        let mut one = check_one_in(*file, &display, module_src, &deps, lock, diags);
289        // A standard-library module's `test` blocks are the *compiler's* tests, not this program's.
290        // They are still checked — a library that stopped compiling its own tests would be broken —
291        // and they are dropped before the link, so `beck test` on a program that imports `bignum`
292        // reports the program's tests and not two hundred of ours. `beck-cli/tests/stdlib.rs` is
293        // where they run (§21.2's rule that a program's behaviour is asserted in the program still
294        // holds; the program asserting them is the library file itself).
295        if from_library.contains(name) {
296            one.program.tests.clear();
297        }
298        // Where both exist, the checked-in interface is the contract and the module must meet it.
299        if let Some(published) = interfaces.get(name) {
300            if published.digest() != one.interface.digest() {
301                diags.push(
302                    Diagnostic::error(
303                        "B0605",
304                        format!("`{name}` does not match its published interface"),
305                        Span::NONE,
306                    )
307                    .with_note(format!(
308                        "`{name}.becki` says {} and the module compiles to {}",
309                        published.digest(),
310                        one.interface.digest()
311                    ))
312                    .with_fix("regenerate it with `beck iface`, and review the diff"),
313                );
314            }
315        } else {
316            interfaces.insert(name.clone(), one.interface.clone());
317        }
318        checked.push(one);
319    }
320
321    if diags.has_errors() {
322        return None;
323    }
324
325    let interface = interfaces.get(root).cloned().unwrap_or_default();
326    let mut merged = link(root, checked, diags)?;
327    // Once, on the whole linked program: a last read in one module is a last read after linking.
328    crate::liveness::mark_program(&mut merged);
329    crate::frames::reserve_program(&mut merged);
330    crate::fields::order_program(&mut merged);
331    // Now the whole program exists, so the whole-program questions can be asked.
332    crate::secure::check_capabilities(&merged, diags);
333    if diags.has_errors() {
334        return None;
335    }
336    // Every placement was decided by the module that owns it and is pinned by the link; solving
337    // over the merged program is how those decisions are collected for `beck explain place`.
338    let solution = place::solve(&merged, lock);
339    Some(Project {
340        program: merged,
341        solution,
342        interface,
343    })
344}
345
346/// Slice a checked project into the roles the runtime drives.
347///
348/// Separate from [`check_project`] so that "this typechecks" and "this is a runnable application"
349/// are two answers rather than one: a library gets the first and not the second, and that is not a
350/// failure.
351pub fn slice(project: Project, diags: &mut Diagnostics) -> Option<Placed> {
352    let solution = project.solution;
353    crate::split::split(project.program, diags).map(|mut p| {
354        p.placement = solution;
355        p
356    })
357}
358
359/// Slice a project, or wrap it as a library if the only thing wrong with it is that it is one.
360///
361/// [`slice()`] answers the *application* question and a module that is not an application is still a
362/// module — `beck check` has said so since Phase 2. What it could not do was give that module back
363/// to a caller, so a library had no way to run its own tests
364/// (`docs/22-phase-3-report.md` §22.6, `docs/25-benchmarks-and-expressiveness.md` §25.6 item 1).
365///
366/// The B0500/B0501/B0505 diagnostics are **dropped** on that path rather than downgraded to
367/// warnings, because they are answers to a question this caller did not ask. Every other diagnostic
368/// is kept and the result is `None`: a library with a type error is a broken module, not a library.
369pub fn slice_or_library(project: Project, diags: &mut Diagnostics) -> Option<Placed> {
370    let program = project.program.clone();
371    let solution = project.solution.clone();
372    let mut slicing = Diagnostics::new();
373    if let Some(mut placed) = crate::split::split(project.program, &mut slicing) {
374        diags.extend(slicing);
375        placed.placement = solution;
376        return Some(placed);
377    }
378    if !slicing.iter().all(|d| NOT_AN_APPLICATION.contains(&d.code)) {
379        diags.extend(slicing);
380        return None;
381    }
382    // The graph is rebuilt rather than kept from the failed slice, because `split` consumed the
383    // program. A graph that cannot be built is a real error and lands in `diags`.
384    let graph = crate::signal::Graph::build(&program, diags)?;
385    let wire_id = format!("lib:{}", program.name);
386    Some(Placed::library(program, graph, wire_id))
387}
388
389/// The diagnostics that mean "this module is a library", not "this module is wrong".
390///
391/// Each is the slicer reporting a missing *application* part — a merge point, a durable fold, a
392/// page. A domain module has none of them by design.
393pub const NOT_AN_APPLICATION: [&str; 3] = ["B0500", "B0501", "B0505"];
394
395/// Compile a whole project: check, link, and slice.
396///
397/// The caller's `SourceMap` is what every module is added to, because a diagnostic about the third
398/// module in a project has to be renderable by whoever asked for the first.
399pub fn compile_project(
400    root: &str,
401    loader: &dyn Loader,
402    lock: Option<&place::Lock>,
403    map: &mut beck_diag::SourceMap,
404    diags: &mut Diagnostics,
405) -> Option<Placed> {
406    let project = check_project(root, loader, lock, map, diags)?;
407    slice(project, diags)
408}
409
410/// Merge checked modules into one program.
411fn link(root: &str, modules: Vec<Checked>, diags: &mut Diagnostics) -> Option<Program> {
412    let mut out: Option<Program> = None;
413    let mut seen: BTreeSet<std::sync::Arc<str>> = BTreeSet::new();
414
415    for Checked { mut program, .. } in modules {
416        // An imported definition's placement is part of its published signature (§3.6), so at link
417        // time it is a given. Marking it annotated is how the root's solve is told not to move it.
418        for def in program.defs.values_mut() {
419            def.tier_is_annotated = true;
420        }
421        for s in program.signals.iter_mut() {
422            s.tier_is_annotated = true;
423        }
424
425        let Some(acc) = out.as_mut() else {
426            seen.extend(program.defs.keys().cloned());
427            out = Some(program);
428            continue;
429        };
430        for (name, def) in program.defs {
431            if !seen.insert(name.clone()) {
432                diags.push(
433                    Diagnostic::error(
434                        "B0601",
435                        format!("`{name}` is defined in more than one module"),
436                        def.span,
437                    )
438                    .with_note(
439                        "Phase 2 links modules into one namespace and has no qualified reference \
440                         to tell two definitions apart, so a clash is an error rather than a \
441                         shadowing rule",
442                    ),
443                );
444                continue;
445            }
446            acc.def_order.push(name.clone());
447            acc.defs.insert(name, def);
448        }
449        for (n, t) in program.types {
450            acc.types.entry(n).or_insert(t);
451        }
452        acc.own_types.extend(program.own_types);
453        acc.signals.extend(program.signals);
454        acc.tests.extend(program.tests);
455        // The doc comments too. Without this the merged program keeps only the *first* module's,
456        // which is the deepest import rather than the root — so `beck doc` on a module that imports
457        // another documented the wrong module's names. Invisible until a module in `lib/` imported
458        // one (`docs/56` §56.5); a clash is impossible for a definition, because `B0601` above
459        // already refuses two modules defining one name.
460        acc.docs.extend(program.docs);
461        // `identity = external(issuer=…)` is a property of the *program*, so it survives the link
462        // from whichever module wrote it — and the accumulator starts as the **first** module,
463        // which is the deepest import rather than the root (`docs/56` §56.5's shape). Leaving it
464        // to `out`'s initial value would mean a root that declares one and imports a module that
465        // does not gets no identity at all.
466        match (acc.identity.is_some(), program.identity) {
467            (false, Some(decl)) => acc.identity = Some(decl),
468            (true, Some(decl)) => diags.push(
469                Diagnostic::error(
470                    "B0359",
471                    "identity is declared in more than one module",
472                    decl.span(),
473                )
474                .with_note(
475                    "who authenticates a program's clients is one answer for the whole program, \
476                     and a linked module set is one program",
477                ),
478            ),
479            (_, None) => {}
480        }
481    }
482
483    let mut merged = out?;
484    merged.name = root.to_string();
485    (!diags.has_errors()).then_some(merged)
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::ty::Tier;
492
493    /// A three-module project: a domain, a policy over it, and the app that wires them.
494    fn project() -> BTreeMap<String, Sources> {
495        let domain = r#"
496type Id = newtype[Str]
497
498model Todo:
499    id: Id
500    text: Str
501    done: Bool
502    owner: Str
503
504model State:
505    todos: Map[Id, Todo]
506
507union Command:
508    Add(id: Id, text: Str)
509    Toggle(id: Id)
510
511union Event:
512    Added(id: Id, text: Str)
513    Toggled(id: Id)
514
515union Rejection:
516    BlankText
517    NotOwner
518
519def apply_event(s: State, env: Envelope[Event]) -> State:
520    match env.body:
521        case Added(id, text):
522            return s.with(todos=map_insert(s.todos, id, Todo(id=id, text=text, done=False, owner=env.actor)))
523        case Toggled(id):
524            return s
525"#;
526        let policy = r#"
527import domain
528
529def validate(s: State, p: Proposal) -> Result[list[Event], Rejection]:
530    match p.command:
531        case Add(id, text):
532            if str_is_empty(str_trim(text)):
533                return Err(error=BlankText)
534            return Ok(value=[Added(id=id, text=text)])
535        case Toggle(id):
536            return Ok(value=[Toggled(id=id)])
537"#;
538        let app = r#"
539import domain
540import policy
541
542def view(s: State, session: Session) -> Html:
543    return ui:
544        main:
545            h1: "todos"
546            footer: (str(map_len(s.todos)) + " todos")
547
548proposals: Stream[Proposal] = merge_clients()
549events: Stream[Event] = decide(proposals, todos, validate)
550todos: Signal[State] = durable(fold(apply_event, State(todos={}), events))
551page: Signal[Html] = per_session(todos, view)
552"#;
553        BTreeMap::from([
554            (
555                "domain".to_string(),
556                Sources {
557                    module: Some(domain.into()),
558                    interface: None,
559                    path: None,
560                },
561            ),
562            (
563                "policy".to_string(),
564                Sources {
565                    module: Some(policy.into()),
566                    interface: None,
567                    path: None,
568                },
569            ),
570            (
571                "app".to_string(),
572                Sources {
573                    module: Some(app.into()),
574                    interface: None,
575                    path: None,
576                },
577            ),
578        ])
579    }
580
581    fn compile(files: &BTreeMap<String, Sources>) -> (Option<Placed>, Diagnostics) {
582        let mut diags = Diagnostics::new();
583        let mut map = beck_diag::SourceMap::new();
584        let out = compile_project(
585            "app",
586            &|n: &str| files.get(n).cloned(),
587            None,
588            &mut map,
589            &mut diags,
590        );
591        (out, diags)
592    }
593
594    #[test]
595    fn a_three_module_project_compiles_links_and_places() {
596        let files = project();
597        let (placed, d) = compile(&files);
598        assert!(
599            !d.has_errors(),
600            "{:?}",
601            d.iter().map(|x| (x.code, &x.message)).collect::<Vec<_>>()
602        );
603        let placed = placed.expect("it links");
604        // Definitions from all three modules are in the linked program…
605        for name in ["apply_event", "validate", "view"] {
606            assert!(placed.program.defs.contains_key(name), "missing {name}");
607        }
608        // …and the app's own wiring is placed as it would be alone.
609        let tier = |n: &str| {
610            placed
611                .program
612                .signals
613                .iter()
614                .find(|s| s.name.as_ref() == n)
615                .map(|s| s.tier)
616        };
617        assert_eq!(tier("proposals"), Some(Tier::Server));
618        assert_eq!(tier("todos"), Some(Tier::Data));
619        assert_eq!(tier("page"), Some(Tier::Client));
620    }
621
622    #[test]
623    fn a_body_edit_upstream_does_not_change_any_downstream_contract() {
624        // §3.6's firewall, at project scale. `domain`'s body changes; its interface does not; so
625        // nothing downstream has anything to recompile against.
626        let files = project();
627        let before = {
628            let mut d = Diagnostics::new();
629            check_one(
630                "domain",
631                files["domain"].module.as_ref().unwrap(),
632                &[],
633                None,
634                &mut d,
635            )
636            .interface
637        };
638
639        let mut edited = files.clone();
640        let body = files["domain"].module.as_ref().unwrap().replace(
641            "case Toggled(id):\n            return s",
642            "case Toggled(id):\n            return s.with(todos=s.todos)",
643        );
644        edited.get_mut("domain").unwrap().module = Some(body);
645
646        let after = {
647            let mut d = Diagnostics::new();
648            check_one(
649                "domain",
650                edited["domain"].module.as_ref().unwrap(),
651                &[],
652                None,
653                &mut d,
654            )
655            .interface
656        };
657        assert_eq!(before.digest(), after.digest());
658
659        // …and the project still compiles, which is the other half: the firewall is only useful if
660        // what it protects still works.
661        let (placed, d) = compile(&edited);
662        assert!(!d.has_errors());
663        assert!(placed.is_some());
664    }
665
666    #[test]
667    fn a_checked_in_interface_that_the_module_no_longer_meets_is_an_error() {
668        // The failure mode a generated-and-committed contract exists to catch: the file says one
669        // thing, the code does another, and downstream believed the file.
670        let mut files = project();
671        let mut d = Diagnostics::new();
672        let iface = check_one(
673            "domain",
674            files["domain"].module.as_ref().unwrap(),
675            &[],
676            None,
677            &mut d,
678        )
679        .interface;
680        // Publish a contract, then widen the module's row behind it.
681        files.get_mut("domain").unwrap().interface = Some(iface.render());
682        let widened = files["domain"].module.as_ref().unwrap().replace(
683            "def apply_event(s: State, env: Envelope[Event]) -> State:",
684            "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:",
685        );
686        files.get_mut("domain").unwrap().module = Some(widened);
687        let (_, d) = compile(&files);
688        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
689        assert!(codes.contains(&"B0605"), "got {codes:?}");
690    }
691
692    #[test]
693    fn an_import_cycle_is_reported_rather_than_looped_on() {
694        let files = BTreeMap::from([
695            (
696                "a".to_string(),
697                Sources {
698                    module: Some("import b\n\ndef f() -> Int:\n    return 1\n".into()),
699                    interface: None,
700                    path: None,
701                },
702            ),
703            (
704                "b".to_string(),
705                Sources {
706                    module: Some("import a\n\ndef g() -> Int:\n    return 2\n".into()),
707                    interface: None,
708                    path: None,
709                },
710            ),
711        ]);
712        let mut diags = Diagnostics::new();
713        let mut map = beck_diag::SourceMap::new();
714        compile_project(
715            "a",
716            &|n: &str| files.get(n).cloned(),
717            None,
718            &mut map,
719            &mut diags,
720        );
721        assert!(diags.iter().any(|d| d.code == "B0602"), "{:?}", diags.len());
722    }
723
724    #[test]
725    fn a_missing_module_says_what_it_looked_for() {
726        let mut diags = Diagnostics::new();
727        let files: BTreeMap<String, Sources> = BTreeMap::from([(
728            "a".to_string(),
729            Sources {
730                module: Some("import nowhere\n\ndef f() -> Int:\n    return 1\n".into()),
731                interface: None,
732                path: None,
733            },
734        )]);
735        let mut map = beck_diag::SourceMap::new();
736        compile_project(
737            "a",
738            &|n: &str| files.get(n).cloned(),
739            None,
740            &mut map,
741            &mut diags,
742        );
743        assert!(diags.iter().any(|d| d.code == "B0603"));
744    }
745
746    /// D23: a module the loader has never heard of still resolves if the library has it.
747    #[test]
748    fn the_standard_library_resolves_with_no_file_beside_the_root() {
749        let files: BTreeMap<String, Sources> = BTreeMap::from([(
750            "app".to_string(),
751            Sources {
752                module: Some(
753                    "import format\n\ndef nine(x: Float) -> Str:\n    return fixed(x, 9)\n".into(),
754                ),
755                interface: None,
756                path: None,
757            },
758        )]);
759        let mut diags = Diagnostics::new();
760        let mut map = beck_diag::SourceMap::new();
761        let project = check_project(
762            "app",
763            &|n: &str| files.get(n).cloned(),
764            None,
765            &mut map,
766            &mut diags,
767        );
768        assert!(
769            !diags.has_errors(),
770            "{:?}",
771            diags
772                .iter()
773                .map(|d| (d.code, &d.message))
774                .collect::<Vec<_>>()
775        );
776        let project = project.expect("it links");
777        assert!(project.program.defs.contains_key("fixed"));
778        // And the library's own tests are the library's: they do not become this program's.
779        assert!(
780            project.program.tests.is_empty(),
781            "{} imported test(s) from the standard library",
782            project.program.tests.len()
783        );
784    }
785
786    /// The loader wins, so a project keeps its own module when the library grows that name.
787    #[test]
788    fn a_module_beside_the_root_shadows_the_standard_library_module_of_the_same_name() {
789        let files = BTreeMap::from([
790            (
791                "format".to_string(),
792                Sources {
793                    module: Some(
794                        "def fixed(x: Float, places: Int) -> Str:\n    return \"mine\"\n".into(),
795                    ),
796                    interface: None,
797                    path: None,
798                },
799            ),
800            (
801                "app".to_string(),
802                Sources {
803                    module: Some(
804                        "import format\n\ndef nine(x: Float) -> Str:\n    return fixed(x, 9)\n"
805                            .into(),
806                    ),
807                    interface: None,
808                    path: None,
809                },
810            ),
811        ]);
812        let mut diags = Diagnostics::new();
813        let mut map = beck_diag::SourceMap::new();
814        let project = check_project(
815            "app",
816            &|n: &str| files.get(n).cloned(),
817            None,
818            &mut map,
819            &mut diags,
820        );
821        // One `fixed`, not two: the library's copy was never loaded, so there is nothing to clash
822        // with (`B0601`).
823        assert!(
824            !diags.has_errors(),
825            "{:?}",
826            diags
827                .iter()
828                .map(|d| (d.code, &d.message))
829                .collect::<Vec<_>>()
830        );
831        assert!(project
832            .expect("it links")
833            .program
834            .defs
835            .contains_key("fixed"));
836    }
837
838    #[test]
839    fn two_modules_defining_one_name_is_an_error_and_not_a_shadowing_rule() {
840        let files = BTreeMap::from([
841            (
842                "lib".to_string(),
843                Sources {
844                    module: Some("def helper() -> Int:\n    return 1\n".into()),
845                    interface: None,
846                    path: None,
847                },
848            ),
849            (
850                "app".to_string(),
851                Sources {
852                    module: Some("import lib\n\ndef helper() -> Int:\n    return 2\n".into()),
853                    interface: None,
854                    path: None,
855                },
856            ),
857        ]);
858        let mut diags = Diagnostics::new();
859        let mut map = beck_diag::SourceMap::new();
860        compile_project(
861            "app",
862            &|n: &str| files.get(n).cloned(),
863            None,
864            &mut map,
865            &mut diags,
866        );
867        assert!(
868            diags.iter().any(|d| d.code == "B0601"),
869            "{:?}",
870            diags.iter().map(|d| d.code).collect::<Vec<_>>()
871        );
872    }
873}