Skip to main content

zelkova_compiler/
dependencies.rs

1//! Walk package's module dependencies
2//!
3//! Within a package, each `Module` will depends on other `Module`s. The aim
4//! of the dependencies module is to provide a way to sort the modules in a dependent
5//! way, allowing parallel action on which module. We will also find out existing
6//! cyclic dependencies and return all of them as error.
7//!
8//! We are currently using the excellent `petgraph` crate for sorting our dependencies
9//! and discovering cycle. We currently use the results of the cycle discovery to drive
10//! how we execute the processing. If we want to introduce parallel processing of the
11//! different module, we will have to be a bit more thorough because we currently don't
12//! have any information _at processing time_ to know when to block the check of some
13//! modules. We don't know how we will do most of that processing, so it's a bit
14//! premature to actually implement multi threading here.
15//! Let's see how it goes first on a single thread and we can revisit this decision
16//! later on.
17//!
18
19use super::name::Name;
20use super::source::files::SourceFileId;
21use super::SpanLabel;
22use log::debug;
23use std::collections::{HashMap, HashSet, VecDeque};
24use zelkova_syntax::parser::Module;
25use zelkova_syntax::position::NodeSpan;
26
27use petgraph::graph::{DiGraph, NodeIndex};
28
29/// What a checker hands [`ModuleWalker::check_in_order`] back for one module.
30///
31/// A module with errors can still have a shape: it has the interface its declarations
32/// that canonicalized give it, whatever the typer says of them, and a typed tree for
33/// each declaration the typer did not reject
34/// ([`DEC-23`](../../docs/decisions/dec-23.md) decisions 1, 2 and 5). So "checked" and
35/// "failed" are not the only two answers: [`Module`](Outcome::Module) carries a module
36/// together with everything wrong with it.
37#[derive(Debug)]
38pub enum Outcome<M, E> {
39    /// Nothing to publish.
40    Failed(E),
41    /// A module, and everything wrong with it. An empty list does not say the module
42    /// checked: a checker may drop an error that restates another module's failure, so the
43    /// caller reads the module for whether it is whole as well.
44    Module(M, Vec<E>),
45}
46
47// TODO Improve this with barrier on each module, to be able to parallelize
48// the processing down thel line.
49pub struct ModuleWalker<'a> {
50    /// The list of modules to process. We loose the barrier orders
51    /// so we can't really process them in parallel at the moment.
52    modules: Vec<&'a Module>,
53}
54
55impl<'a> std::fmt::Debug for ModuleWalker<'a> {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let modules: Vec<_> = self.modules.iter().map(|m| &m.name).collect();
58
59        f.debug_struct("ModuleWalker")
60            .field("modules", &modules)
61            .finish()
62    }
63}
64
65/// One `import` that forms an edge of a dependency cycle.
66///
67/// `from` imports `to` — the module the note lists right after it, wrapping
68/// around to the cycle's start for the last edge — and `span`/`file` are where
69/// that specific `import` line was written, when known.
70///
71/// `file` comes from the `module_files` map [`ModuleWalker::new`] is handed by
72/// `compile_package`, the only place that knows which file a parsed module came
73/// from — the same reasoning `ERR-5` used for [`crate::Interface::file`].
74/// It is `None` for every edge built in this module's own tests, which hand-build
75/// modules with nothing on disk behind them.
76#[derive(Debug, PartialEq, Clone)]
77pub struct CycleEdge {
78    pub from: Name,
79    pub to: Name,
80    pub span: NodeSpan,
81    pub file: Option<SourceFileId>,
82}
83
84/// A dependency cycle: one loop through a strongly-connected component of the
85/// import graph, alongside the `import` that created each edge between consecutive
86/// modules (wrapping around to the first), and any component member that loop does
87/// not pass through.
88#[derive(Debug, PartialEq, Clone)]
89pub struct Cycle {
90    /// *One* cycle within the strongly-connected component, in cycle order —
91    /// what the summary note is built from. Not necessarily every module in the
92    /// component: a component with several overlapping loops has more than one
93    /// cycle in it, and picking the shortest one through a single start node (see
94    /// `cycle_walk`) can leave others out. Finding a cycle that covers every
95    /// member is the Hamiltonian cycle problem, and no such cycle need exist, so
96    /// this does not try; the members left out are in [`Cycle::others`] instead,
97    /// and the note names them so a user does not break one loop and immediately
98    /// hit the next one hiding in the same component.
99    pub path: Vec<Name>,
100    /// The other modules in the same component: genuinely part of the circular
101    /// dependency, but not on the particular cycle `path` names. Empty whenever
102    /// `path` already covers the component, which every fixture and every real
103    /// import graph seen so far is.
104    pub others: Vec<Name>,
105    /// `edges[i]` is the `import` written in `path[i]` that names `path[i + 1]`
106    /// (or `path[0]`, for the last edge). Same length as `path`, and every entry
107    /// is a real `import`, because `cycle_walk` only ever returns a path whose
108    /// consecutive pairs are edges of the graph.
109    pub edges: Vec<CycleEdge>,
110}
111
112#[derive(Debug, PartialEq)]
113pub enum Error {
114    CycleDetected(Vec<Cycle>),
115}
116
117/// A dependency cycle belongs to the package, not to any one module, so its message
118/// names the modules involved rather than being prefixed with one of them, and its
119/// labels — one per `import` that forms an edge — each carry their own file rather
120/// than relying on a fallback the way a single-module phase error can
121/// (`CompilationError::DependenciesError` renders this without one, see there).
122impl crate::PhaseError for Error {
123    fn message(&self) -> String {
124        match self {
125            Error::CycleDetected(cycles) => format!(
126                "{} circular dependenc{} between modules",
127                cycles.len(),
128                if cycles.len() == 1 { "y" } else { "ies" }
129            ),
130        }
131    }
132
133    fn notes(&self) -> Vec<String> {
134        match self {
135            // Each cycle is written back to its start, so it reads as the loop it is:
136            // `A -> B -> A`. A component that holds more than the one loop gets a
137            // second note naming the members that loop leaves out, so breaking it
138            // does not just uncover the next cycle in the same component
139            // (`Cycle::others`).
140            Error::CycleDetected(cycles) => cycles
141                .iter()
142                .flat_map(|cycle| {
143                    let mut path: Vec<String> =
144                        cycle.path.iter().map(|name| name.to_string()).collect();
145                    if let Some(first) = path.first().cloned() {
146                        path.push(first);
147                    }
148                    let mut notes = vec![format!("cycle: {}", path.join(" -> "))];
149
150                    if !cycle.others.is_empty() {
151                        let others: Vec<String> =
152                            cycle.others.iter().map(|name| name.to_string()).collect();
153                        notes.push(format!(
154                            "also part of this circular dependency, on another loop through the same modules: {}",
155                            others.join(", ")
156                        ));
157                    }
158
159                    notes
160                })
161                .collect(),
162        }
163    }
164
165    fn labels(&self) -> Vec<SpanLabel> {
166        match self {
167            Error::CycleDetected(cycles) => cycles
168                .iter()
169                .flat_map(|cycle| {
170                    cycle.edges.iter().filter_map(|edge| {
171                        // An edge with no span (a hand-built test module) or no known
172                        // file (nothing in the real pipeline, since `module_files`
173                        // covers every parsed module) has nowhere honest to point;
174                        // it is dropped rather than guessed at, the same rule
175                        // `Interface::source_span` follows.
176                        let span = edge.span.span()?;
177                        let file = edge.file?;
178                        Some(SpanLabel {
179                            span,
180                            // Every edge is equally the cause — breaking any one
181                            // of them breaks the loop — so a label states only its
182                            // own edge and leaves the loop to the note. Saying
183                            // "closing the cycle" here would put that claim on all
184                            // N labels when only the last edge closes it.
185                            message: format!("`{}` imports `{}` here", edge.from, edge.to),
186                            primary: true,
187                            file: Some(file),
188                        })
189                    })
190                })
191                .collect(),
192        }
193    }
194}
195
196/// Walk `members` — one strongly-connected component `tarjan_scc` found, so every
197/// node in it can reach every other — into an actual directed cycle through
198/// `start`: a sequence of modules where each one really does import the next, and
199/// the last one imports the first.
200///
201/// `tarjan_scc`'s own ordering of a component does not promise this: it is
202/// membership, not a walk, so consecutive entries are not necessarily connected by
203/// an edge, and the note used to print a "cycle" the user could not follow. That is
204/// what `ERR-6` set out to fix, and `build_cycle` needs it to be true a second time
205/// over, because it looks up the `import` behind each consecutive pair and an edge
206/// that does not exist has no `import` to point a label at.
207///
208/// So the guarantee has to hold by construction, not by luck. This is a breadth-first
209/// search from `start` over in-component edges only, stopping at the first node found
210/// to import `start` back; the path is then reconstructed backwards along the BFS
211/// tree. Because BFS pops nodes in non-decreasing distance order, that is the
212/// *shortest* cycle through `start`, and because a BFS tree path never repeats a
213/// node, it is a simple one. A greedy walk that just takes the first unvisited
214/// neighbour cannot promise this — in a component with two loops sharing a node
215/// (`B` imports `C` and `E`; `C` imports `D`; `D` imports `B`; `E` imports `B`) it
216/// wanders into `E`, dead-ends, and hands back `[D, B, E]` even though `E` does not
217/// import `D`.
218///
219/// The cycle it finds may be a strict subset of `members` — see [`Cycle::path`] for
220/// why that is not fixable in general and what is reported instead.
221///
222/// `start` must be one of `members`. Callers get that from the `partition` in
223/// [`ModuleWalker::new`], which is also where the component is known to be
224/// non-empty; taking it as a parameter is what keeps this function off an unchecked
225/// `members[0]`.
226///
227/// The returned path is empty only if `start` lies on no cycle at all, which a
228/// component `tarjan_scc` reported with more than one member cannot produce.
229fn cycle_walk(
230    graph: &DiGraph<&Module, ()>,
231    members: &[NodeIndex],
232    start: NodeIndex,
233) -> Vec<NodeIndex> {
234    let member_set: HashSet<NodeIndex> = members.iter().copied().collect();
235
236    // How each node was first reached. `start` is deliberately absent: it is the
237    // target, so reaching it is what ends the search rather than something to
238    // record a predecessor for.
239    let mut predecessor: HashMap<NodeIndex, NodeIndex> = HashMap::new();
240    let mut queue: VecDeque<NodeIndex> = VecDeque::from([start]);
241    // The node that closes the loop: the first one popped that imports `start`.
242    let mut closing: Option<NodeIndex> = None;
243
244    'search: while let Some(current) = queue.pop_front() {
245        for next in graph.neighbors(current).filter(|n| member_set.contains(n)) {
246            if next == start {
247                closing = Some(current);
248                break 'search;
249            }
250
251            // First time we reach `next` is along a shortest path to it, so a
252            // later, longer way in is ignored.
253            if predecessor.contains_key(&next) {
254                continue;
255            }
256
257            predecessor.insert(next, current);
258            queue.push_back(next);
259        }
260    }
261
262    let mut path = Vec::new();
263
264    if let Some(closing) = closing {
265        let mut node = closing;
266        path.push(node);
267
268        // Back up the BFS tree to `start`. `predecessor` was built by this search,
269        // so every node on that chain has one until `start` is reached; the `None`
270        // arm cannot fire, and stopping is the right thing if it somehow did.
271        while node != start {
272            match predecessor.get(&node) {
273                Some(&previous) => {
274                    node = previous;
275                    path.push(node);
276                }
277                None => break,
278            }
279        }
280
281        path.reverse();
282    }
283
284    path
285}
286
287/// Turn one strongly-connected component into the [`Cycle`] a diagnostic renders:
288/// the module path `cycle_walk` finds, the `import` behind each edge of it, and the
289/// component members that path leaves out.
290///
291/// `start` is the node the cycle is found through; [`ModuleWalker::new`] takes it
292/// from the component itself, which is how this avoids indexing one.
293fn build_cycle(
294    graph: &DiGraph<&Module, ()>,
295    members: &[NodeIndex],
296    start: NodeIndex,
297    module_files: &HashMap<Name, SourceFileId>,
298) -> Cycle {
299    let node_path = cycle_walk(graph, members, start);
300    let on_path: HashSet<NodeIndex> = node_path.iter().copied().collect();
301
302    let path: Vec<Name> = node_path
303        .iter()
304        .map(|&idx| graph[idx].name.clone())
305        .collect();
306
307    let mut others: Vec<Name> = members
308        .iter()
309        .filter(|idx| !on_path.contains(idx))
310        .map(|&idx| graph[idx].name.clone())
311        .collect();
312    // `tarjan_scc`'s ordering within a component is an implementation detail; a
313    // note the user reads should not vary with it. Sorted on the identifier text
314    // rather than through an `Ord` on `Name`, which `name.rs` deliberately does not
315    // derive.
316    others.sort_by(|l, r| l.as_str().cmp(r.as_str()));
317
318    let edges = node_path
319        .iter()
320        .enumerate()
321        .map(|(i, &idx)| {
322            let from_module = graph[idx];
323            let to_idx = node_path[(i + 1) % node_path.len()];
324            let to_name = graph[to_idx].name.clone();
325
326            // The specific `import …` line in `from_module` that names `to_name` —
327            // there may be several imports in `from_module`, but at most one of
328            // them names this particular neighbour. `cycle_walk` guarantees this is
329            // a real edge, so the only reason to find nothing is a hand-built
330            // module in this file's own tests, which carry no source text.
331            let import = from_module.imports.iter().find(|imp| imp.name == to_name);
332
333            CycleEdge {
334                from: from_module.name.clone(),
335                to: to_name,
336                span: import.map(|imp| imp.span).unwrap_or_else(NodeSpan::none),
337                file: import.and_then(|_| module_files.get(&from_module.name).copied()),
338            }
339        })
340        .collect();
341
342    Cycle {
343        path,
344        others,
345        edges,
346    }
347}
348
349/// Put the [default imports](crate::default_imports) into the import
350/// graph, so a module that never wrote `import Basics` is still checked after it.
351///
352/// Canonicalization resolves an implicit import against an `Interface` that already
353/// exists, and nothing but this graph decides which module is checked first — so
354/// without these edges whether `1 + 2` resolved would come down to the order the
355/// source files happened to load in.
356///
357/// `is_core` is `package.is_core()`, the package's own answer — a property of the
358/// package's name rather than of `names`, which here is only one root's worth of
359/// modules.
360fn add_default_import_edges(
361    graph: &mut DiGraph<&Module, ()>,
362    names: &HashMap<&Name, NodeIndex>,
363    is_core: bool,
364) {
365    if is_core {
366        return;
367    }
368
369    let mut importers: Vec<(&Name, NodeIndex)> =
370        names.iter().map(|(&name, &idx)| (name, idx)).collect();
371    importers.sort_by(|left, right| left.0.as_str().cmp(right.0.as_str()));
372
373    for default in crate::default_imports::DEFAULT_IMPORTS {
374        let target = default.name();
375        let Some(&target_idx) = names.get(&target) else {
376            continue;
377        };
378
379        for &(module_name, module_idx) in &importers {
380            if crate::default_imports::is_default(module_name) {
381                continue;
382            }
383
384            // A written import already carries the edge, and replaces the implicit
385            // import anyway.
386            if graph[module_idx].imports.iter().any(|i| i.name == target) {
387                continue;
388            }
389
390            if petgraph::algo::has_path_connecting(&*graph, target_idx, module_idx, None) {
391                continue;
392            }
393
394            graph.add_edge(module_idx, target_idx, ());
395        }
396    }
397}
398
399impl<'a> ModuleWalker<'a> {
400    /// `module_files` is how a cycle's edges learn which file each `import` was
401    /// written in (`CycleEdge::file`) — this is driver code, the same as
402    /// `check_in_order` below, so it is handed the map `compile_package` already
403    /// built while parsing rather than trying to discover it itself. Every test in
404    /// this module passes an empty map: their modules are hand-built, with no file
405    /// on disk to name, and `CycleEdge::file` degrades to `None` for them exactly
406    /// as documented there.
407    pub fn new(
408        modules: &'a [Module],
409        module_files: &HashMap<Name, SourceFileId>,
410        package: &crate::PackageName,
411    ) -> Result<ModuleWalker<'a>, Error> {
412        ModuleWalker::new_for_root(modules, module_files, package)
413    }
414
415    /// A walker over the modules of *one* of a package's two source roots.
416    ///
417    /// A package has a `src/` and a `tests/`, and each is walked and checked on its own,
418    /// but whether `package` is exempt from the default imports
419    /// ([`PackageName::is_core`](crate::PackageName::is_core)) does not depend
420    /// on which root — or which modules — either one happens to hold, so the two roots
421    /// of one package always get the same answer without either walker having to be
422    /// told the other's modules.
423    ///
424    /// The import graph is built from `modules` alone either way. An import naming a
425    /// module of the other root resolves through an
426    /// [`Interface`](crate::Interface) or not at all, the same as an import
427    /// naming another package's module.
428    pub fn new_for_root(
429        modules: &'a [Module],
430        module_files: &HashMap<Name, SourceFileId>,
431        package: &crate::PackageName,
432    ) -> Result<ModuleWalker<'a>, Error> {
433        let mut graph = DiGraph::new();
434
435        let mut names = HashMap::new();
436
437        // First populate the graph with all nodes, and keep a reference from Path to the node index
438        for module in modules.iter() {
439            let idx = graph.add_node(module);
440            names.insert(&module.name, idx);
441        }
442
443        // Now that all modules have been inserted, we can create edges
444        for module in modules.iter() {
445            let m_idx = names.get(&module.name).unwrap();
446
447            // When building the dependency graph, we exclude all imports for
448            // modules not in this package. The reason is simple: a dependent
449            // package cannot depends on the current package, so no cycle to detect
450            for dep in module
451                .imports
452                .iter()
453                .filter_map(|import| names.get(&import.name))
454            {
455                graph.add_edge(*m_idx, *dep, ());
456            }
457        }
458
459        add_default_import_edges(&mut graph, &names, package.is_core());
460
461        // Find the strongly connected graphs (scc), if there are more than one node per scc
462        // it means there is a circular dependency.
463        let scc = petgraph::algo::tarjan_scc(&graph);
464
465        let (cycles, deps): (Vec<_>, Vec<_>) = scc.iter().partition(|&v| v.len() > 1);
466
467        if cycles.is_empty() {
468            let modules = deps.into_iter().flatten().map(|&idx| graph[idx]).collect();
469
470            Ok(ModuleWalker { modules })
471        } else {
472            // `partition` above already established every component here has more
473            // than one member, so `first()` is always `Some`; taking the start node
474            // from it here, rather than indexing inside `cycle_walk`, is what keeps
475            // that guarantee next to the check that establishes it.
476            let c = cycles
477                .into_iter()
478                .filter_map(|members| {
479                    members
480                        .first()
481                        .map(|&start| build_cycle(&graph, members, start, module_files))
482                })
483                .collect();
484
485            Err(Error::CycleDetected(c))
486        }
487    }
488
489    /// Given a package name, a set of existing interfaces and a checker function,
490    /// check each module in its dependencies order.
491    ///
492    /// Every module is checked regardless of whether an earlier one failed, and each
493    /// comes back as one [`Outcome`], in the order it was checked (`BUG-2`). Every
494    /// module the checker hands back has its `Interface` inserted into `interfaces` as
495    /// it goes, whether or not it came back with errors, so later modules keep resolving
496    /// against earlier ones: an importer of a module with an error in one declaration is
497    /// checked against the interface that module has, and reports nothing about the
498    /// import ([`DEC-23` decision
499    /// 1](../../docs/decisions/dec-23.md#1--a-module-publishes-the-interface-it-has-whichever-phase-failed)).
500    /// Only an [`Outcome::Failed`] module is absent from what follows it.
501    ///
502    /// `module_files` is how a checked module's `Interface` learns the file it was
503    /// read from (`Interface::file`, `ERR-5`) — `check` itself is a phase-orchestrating
504    /// function and, like every phase, never knows it. This method is driver code
505    /// rather than a phase, the same as `compile_package`, so it is the one that can
506    /// look the id up and stamp it on before the interface goes into the shared map.
507    /// A module missing from `module_files` — there is none in the real pipeline,
508    /// since only a module that already parsed reaches here — simply leaves that
509    /// interface's `file` as `None`, same as a hand-built one.
510    ///
511    /// `check` takes `package` and nothing beyond it to say whether this build is
512    /// exempt from the default imports — `PackageName::is_core` is a function of
513    /// `package` alone, so a checker derives its own answer from the same package
514    /// this walker was built from rather than being handed one separately.
515    #[allow(clippy::type_complexity)]
516    pub fn check_in_order<M: crate::Checked, E>(
517        &self,
518        package: &crate::PackageName,
519        interfaces: &mut HashMap<Name, crate::Interface>,
520        module_files: &HashMap<Name, crate::source::files::SourceFileId>,
521        check: fn(
522            package: &crate::PackageName,
523            interfaces: &HashMap<Name, crate::Interface>,
524            source: &zelkova_syntax::parser::Module,
525        ) -> Outcome<M, E>,
526    ) -> Vec<Outcome<M, E>> {
527        let mut outcomes = Vec::new();
528
529        for module in self.modules.iter() {
530            let outcome = check(package, interfaces, module);
531
532            // A module the checker handed back is available to the modules after it,
533            // with or without errors.
534            if let Outcome::Module(m, _) = &outcome {
535                let iface_name = m.name().name().clone();
536                // Driver code, so this is where the module's file is known: the
537                // interface carries it so a *later* module's diagnostic can point
538                // back into this one's source (`ERR-5`).
539                let iface = m.to_interface(module_files.get(&module.name).copied());
540                debug!("Inserting {} with value {:?}", iface_name, iface);
541                interfaces.insert(iface_name, iface);
542            }
543
544            outcomes.push(outcome);
545        }
546
547        outcomes
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::{canonical, parser, CheckedModule, Interface, ModuleName, Name, PackageName};
555    use zelkova_syntax::parser::{Exposing, Import};
556    use zelkova_syntax::position::NodeSpan;
557
558    fn module<S: Into<String>>(name: S, deps: Vec<S>) -> Module {
559        let imports = deps
560            .into_iter()
561            .map(|n| Import {
562                name: Name::new(n),
563                alias: None,
564                exposing: Exposing::Open,
565                // Hand-built, not parsed: there is no source text behind it.
566                span: NodeSpan::none(),
567            })
568            .collect();
569
570        Module {
571            name: Name::new(name),
572            binding_foreign: false,
573            exposing: Exposing::Open,
574            exposing_span: NodeSpan::none(),
575            imports,
576            infixes: vec![],
577            types: vec![],
578            classes: vec![],
579            instances: vec![],
580            functions: vec![],
581            failed: vec![],
582        }
583    }
584
585    fn name<S: Into<String>>(s: S) -> Name {
586        Name::new(s)
587    }
588
589    /// A package other than `zelkova-core`, for a `ModuleWalker` whose modules are
590    /// not shaped like the standard library — every fixture below except the ones
591    /// that name `Basics` on purpose.
592    fn ordinary_package() -> PackageName {
593        PackageName::new("author-project").unwrap()
594    }
595
596    /// A `CycleEdge` matching what `module()` above produces: no span, since
597    /// there is no source text behind a hand-built import, and no file, since
598    /// these tests pass an empty `module_files` map to `ModuleWalker::new`.
599    fn edge<S: Into<String>>(from: S, to: S) -> CycleEdge {
600        CycleEdge {
601            from: Name::new(from),
602            to: Name::new(to),
603            span: NodeSpan::none(),
604            file: None,
605        }
606    }
607
608    /// An empty canonical module standing in for whatever the real checker would
609    /// have produced. Shared by the checkers below, which differ only in which
610    /// modules they refuse.
611    fn dummy_module(package: &PackageName, source: &parser::Module) -> CheckedModule {
612        let canonical = canonical::Module {
613            name: ModuleName::new(package.clone(), source.name.clone()),
614            exports: canonical::Exports::Everything,
615            exposing_span: source.exposing_span,
616            infixes: HashMap::new(),
617            types: HashMap::new(),
618            classes: HashMap::new(),
619            instances: Vec::new(),
620            imported_instances: Vec::new(),
621            values: HashMap::new(),
622            broken: Vec::new(),
623            binding_foreign: false,
624            incomplete: false,
625        };
626        let ir = crate::ir::build(&canonical, HashMap::new(), Vec::new());
627
628        CheckedModule { canonical, ir }
629    }
630
631    fn dummy_check(
632        package: &PackageName,
633        _interfaces: &HashMap<Name, Interface>,
634        source: &parser::Module,
635    ) -> Outcome<CheckedModule, ()> {
636        Outcome::Module(dummy_module(package, source), vec![])
637    }
638
639    /// Like `dummy_check`, except module `b` always fails. Used to pin `BUG-2`:
640    /// `check_in_order` must keep checking (and reporting) every other module
641    /// rather than discarding them because one sibling failed.
642    ///
643    /// The error carries the failing module's name rather than being `()` so the
644    /// test can assert *which* module failed; with `E = ()` the type system already
645    /// guarantees the contents and only the arity would be under test.
646    fn dummy_check_fails_for_b(
647        package: &PackageName,
648        _interfaces: &HashMap<Name, Interface>,
649        source: &parser::Module,
650    ) -> Outcome<CheckedModule, Name> {
651        if source.name.as_str() == "b" {
652            Outcome::Failed(source.name.clone())
653        } else {
654            Outcome::Module(dummy_module(package, source), vec![])
655        }
656    }
657
658    /// Like `dummy_check`, except module `b` comes back with an error beside its
659    /// module, the way a module with a type error does.
660    fn dummy_check_reports_for_b(
661        package: &PackageName,
662        _interfaces: &HashMap<Name, Interface>,
663        source: &parser::Module,
664    ) -> Outcome<CheckedModule, Name> {
665        let errors = if source.name.as_str() == "b" {
666            vec![source.name.clone()]
667        } else {
668            vec![]
669        };
670        Outcome::Module(dummy_module(package, source), errors)
671    }
672
673    /// The name of each module an outcome holds, and the errors of every outcome, in
674    /// the order `check_in_order` handed them back.
675    fn modules_and_errors<E>(outcomes: Vec<Outcome<CheckedModule, E>>) -> (Vec<String>, Vec<E>) {
676        let mut modules = Vec::new();
677        let mut errors = Vec::new();
678        for outcome in outcomes {
679            match outcome {
680                Outcome::Module(m, module_errors) => {
681                    modules.push(m.canonical.name.name().as_str().to_string());
682                    errors.extend(module_errors);
683                }
684                Outcome::Failed(error) => errors.push(error),
685            }
686        }
687        (modules, errors)
688    }
689
690    fn assert_walker_processed_order(walker: ModuleWalker, expected: Vec<&str>) {
691        let name = ordinary_package();
692        let mut ifaces = HashMap::new();
693        let module_files = HashMap::new();
694        let (modules, errors) = modules_and_errors(walker.check_in_order(
695            &name,
696            &mut ifaces,
697            &module_files,
698            dummy_check,
699        ));
700
701        assert_eq!(errors, Vec::<()>::new());
702        assert_eq!(
703            modules,
704            expected
705                .into_iter()
706                .map(|s| s.to_string())
707                .collect::<Vec<_>>()
708        );
709    }
710
711    #[test]
712    fn dependencies_without_cycle() {
713        let a = module("a", vec![]);
714        let b = module("b", vec!["a"]);
715        let c = module("c", vec!["b"]);
716        let d = module("d", vec!["a"]);
717
718        let modules = vec![a, b, c, d];
719        let module_files = HashMap::new();
720
721        let walker = ModuleWalker::new(&modules, &module_files, &ordinary_package());
722
723        assert_walker_processed_order(walker.expect("no errors here"), vec!["a", "b", "c", "d"])
724    }
725
726    /// The property `cycle_walk` is supposed to guarantee, asserted directly against
727    /// the source modules rather than against `path` restated as `edges`: every
728    /// consecutive pair in `path`, wrapping around from the last back to the first,
729    /// is an `import` actually written in the earlier module.
730    ///
731    /// Checking `Cycle::edges` instead would pin nothing — `build_cycle` derives
732    /// `from`/`to` from consecutive `path` entries unconditionally, and the two
733    /// fields that could tell a real edge from a fabricated one are both dead in
734    /// these fixtures (`file` is always `None` with an empty `module_files`, and
735    /// `NodeSpan`'s `PartialEq` always returns `true`, per CLAUDE.md).
736    fn assert_path_is_a_real_cycle(cycle: &Cycle, modules: &[Module]) {
737        assert!(
738            cycle.path.len() > 1,
739            "a cycle between modules needs at least two of them, got {:?}",
740            cycle.path
741        );
742
743        for (i, from) in cycle.path.iter().enumerate() {
744            let to = &cycle.path[(i + 1) % cycle.path.len()];
745            let from_module = modules
746                .iter()
747                .find(|m| &m.name == from)
748                .unwrap_or_else(|| panic!("`{}` is one of the fixture modules", from));
749
750            assert!(
751                from_module.imports.iter().any(|imp| &imp.name == to),
752                "`{}` does not import `{}`, so the reported cycle {:?} is not a walk \
753                 this graph's edges can produce",
754                from,
755                to,
756                cycle.path
757            );
758        }
759    }
760
761    /// Pins two things together: which modules a cycle names (as before this
762    /// ticket), and — new for `ERR-6` — that its `path` is a genuine walk along
763    /// real `import` edges rather than `tarjan_scc`'s raw, edge-agnostic
764    /// component order. Before this change the note for the first cycle here
765    /// printed `b -> c -> a -> b`, which is not a walk this graph's edges (`a ->
766    /// c`, `b -> a`, `c -> b`) can produce — `b -> c` is not an edge. `path` is
767    /// now `[b, a, c]`, which is: `b -> a` (b imports a), `a -> c` (a imports c),
768    /// `c -> b` (c imports b, closing the loop).
769    ///
770    /// The hardcoded `path`/`edges` comparison pins *this* graph's answer; the
771    /// `assert_path_is_a_real_cycle` calls pin the general property, and are what
772    /// would catch a walk that closed its loop on an edge that does not exist. Both
773    /// components here are simple cycles covering every member, so `others` is
774    /// empty — `cycle_reports_component_members_left_off_the_loop` covers the case
775    /// where it is not.
776    ///
777    /// Mutation-checked by reverting `cycle_walk` to return `members` verbatim
778    /// (`tarjan_scc`'s raw order) instead of searching it: this test goes red
779    /// (`[b, c, a]` instead of `[b, a, c]`, and `b` does not import `c`).
780    /// `dependency_cycle_labels_each_import` in `crates/zelkova/tests/pipeline.rs` stays green
781    /// under that same mutation and says so itself — its two-module fixture has only
782    /// one possible walk, so the raw component order and a real walk coincide.
783    #[test]
784    fn dependencies_with_two_cycles() {
785        let a = module("a", vec!["c"]);
786        let b = module("b", vec!["a"]);
787        let c = module("c", vec!["b"]);
788
789        let d = module("d", vec!["a", "f"]);
790        let e = module("e", vec!["d"]);
791        let f = module("f", vec!["e"]);
792
793        let modules = vec![a, b, c, d, e, f];
794        let module_files = HashMap::new();
795
796        let walker = ModuleWalker::new(&modules, &module_files, &ordinary_package());
797
798        let res = walker.expect_err("I'm expecting an error");
799
800        let Error::CycleDetected(cycles) = &res;
801        for cycle in cycles {
802            assert_path_is_a_real_cycle(cycle, &modules);
803        }
804
805        assert_eq!(
806            res,
807            Error::CycleDetected(vec![
808                Cycle {
809                    path: vec![name("b"), name("a"), name("c")],
810                    others: vec![],
811                    edges: vec![edge("b", "a"), edge("a", "c"), edge("c", "b")],
812                },
813                Cycle {
814                    path: vec![name("e"), name("d"), name("f")],
815                    others: vec![],
816                    edges: vec![edge("e", "d"), edge("d", "f"), edge("f", "e")],
817                },
818            ])
819        )
820    }
821
822    /// Every ordering of `specs` — a list of `(module name, its imports)` — handed to
823    /// `ModuleWalker::new` in turn, with `assert` run on each resulting `Cycle`.
824    ///
825    /// The declaration order is what decides `tarjan_scc`'s ordering of a component,
826    /// and therefore which member `build_cycle` starts its search from. A fixture
827    /// written in one fixed order only ever exercises one start node, and a walk that
828    /// is wrong from *some* start nodes will pass it by luck — which is exactly how
829    /// the dead-end below went unnoticed. Running every permutation removes the luck.
830    fn for_every_declaration_order(
831        specs: &[(&str, Vec<&str>)],
832        assert: impl Fn(&Cycle, &[Module], &[&str]),
833    ) {
834        // Lexicographic permutations of the indices into `specs`, so the whole set is
835        // covered without pulling in a crate for it.
836        let mut order: Vec<usize> = (0..specs.len()).collect();
837
838        loop {
839            let modules: Vec<Module> = order
840                .iter()
841                .map(|&i| module(specs[i].0, specs[i].1.clone()))
842                .collect();
843            let names: Vec<&str> = order.iter().map(|&i| specs[i].0).collect();
844            let module_files = HashMap::new();
845
846            let res = ModuleWalker::new(&modules, &module_files, &ordinary_package())
847                .expect_err("every fixture here has a cycle in it");
848
849            let Error::CycleDetected(cycles) = &res;
850            assert_eq!(
851                cycles.len(),
852                1,
853                "one component, so one cycle, in declaration order {:?}: {:?}",
854                names,
855                cycles
856            );
857
858            assert(&cycles[0], &modules, &names);
859
860            // Next permutation.
861            let Some(pivot) = (0..order.len() - 1)
862                .rev()
863                .find(|&i| order[i] < order[i + 1])
864            else {
865                break;
866            };
867            let successor = (pivot + 1..order.len())
868                .rev()
869                .find(|&i| order[i] > order[pivot])
870                .expect("`pivot` was chosen because `pivot + 1` qualifies");
871            order.swap(pivot, successor);
872            order[pivot + 1..].reverse();
873        }
874    }
875
876    /// The counterexample that sank the original greedy walk: a component holding
877    /// two loops that share a node.
878    ///
879    /// ```text
880    /// b imports c, e      b -> c -> d -> b
881    /// c imports d         b -> e -> b
882    /// d imports b
883    /// e imports b
884    /// ```
885    ///
886    /// `{b, c, d, e}` is one component. The greedy walk started at whichever member
887    /// `tarjan_scc` listed first, took the first *unvisited* in-component neighbour
888    /// at each step, and could walk itself into a corner: starting from `d` it went
889    /// to `b`, from `b` to `e`, and `e`'s only neighbour `b` was already visited — so
890    /// it stopped and returned `[d, b, e]`, whose wraparound `e -> d` is not an edge
891    /// of this graph at all. The note then claimed `cycle: d -> b -> e -> d`, stating
892    /// something false about the user's source, and `build_cycle` found no `import`
893    /// behind that step so its label was silently dropped.
894    ///
895    /// Whether the start node is `d` depends on the declaration order, hence
896    /// `for_every_declaration_order`.
897    ///
898    /// Mutation-checked against exactly that: restoring the greedy walk turns this
899    /// test red on `e` not importing `d`.
900    #[test]
901    fn cycle_closes_on_a_real_import_when_two_loops_share_a_module() {
902        let specs = [
903            ("b", vec!["c", "e"]),
904            ("c", vec!["d"]),
905            ("d", vec!["b"]),
906            ("e", vec!["b"]),
907        ];
908
909        for_every_declaration_order(&specs, |cycle, modules, order| {
910            assert_path_is_a_real_cycle(cycle, modules);
911            // Guard against the property being satisfied vacuously by a walk that
912            // stopped one step in.
913            assert!(
914                cycle.path.len() >= 2,
915                "declaration order {:?} produced {:?}",
916                order,
917                cycle.path
918            );
919        });
920    }
921
922    /// A component whose loops do not all share their members: the reported `path`
923    /// is necessarily a strict subset of it.
924    ///
925    /// ```text
926    /// a imports b         a -> b -> d -> a
927    /// b imports d, c      b -> c -> b
928    /// c imports b
929    /// d imports a
930    /// ```
931    ///
932    /// `{a, b, c, d}` is one component holding two loops that overlap only at `b`.
933    /// No cycle covers all four, so whichever one `cycle_walk` reports leaves
934    /// members out — and a user who breaks only the loop they were shown walks
935    /// straight into the other. `Cycle::others` is how the ones left out stay
936    /// visible, and `notes()` renders them.
937    ///
938    /// Mutation-checked by hardcoding `others: vec![]` in `build_cycle`: the union
939    /// assertion below goes red.
940    #[test]
941    fn cycle_reports_component_members_left_off_the_loop() {
942        use crate::PhaseError;
943
944        let specs = [
945            ("a", vec!["b"]),
946            ("b", vec!["d", "c"]),
947            ("c", vec!["b"]),
948            ("d", vec!["a"]),
949        ];
950
951        for_every_declaration_order(&specs, |cycle, modules, order| {
952            // Whichever loop was picked, it has to be a real one…
953            assert_path_is_a_real_cycle(cycle, modules);
954
955            // …and no member of the component may go unmentioned.
956            let mut named: Vec<Name> = cycle
957                .path
958                .iter()
959                .chain(cycle.others.iter())
960                .cloned()
961                .collect();
962            named.sort_by(|l, r| l.as_str().cmp(r.as_str()));
963            assert_eq!(
964                named,
965                vec![name("a"), name("b"), name("c"), name("d")],
966                "`path` plus `others` must name the whole component; declaration \
967                 order {:?} gave path {:?} and others {:?}",
968                order,
969                cycle.path,
970                cycle.others
971            );
972
973            // No cycle here covers all four, so something is always left out — and
974            // the note, not just the struct, has to say what.
975            assert!(
976                !cycle.others.is_empty(),
977                "no loop in this component covers it, so `others` cannot be empty; \
978                 declaration order {:?} gave path {:?}",
979                order,
980                cycle.path
981            );
982
983            let notes = Error::CycleDetected(vec![cycle.clone()]).notes();
984            assert!(
985                notes.iter().any(|n| {
986                    n.starts_with("also part of this circular dependency")
987                        && cycle.others.iter().all(|o| n.contains(o.as_str()))
988                }),
989                "expected a note naming the members left off the loop, got {:?}",
990                notes
991            );
992        });
993    }
994
995    /// `LANG-57`: `Basics` importing the `Js.Basics` facade — `std/core`'s own
996    /// shape — is not a dependency cycle. `zelkova-core` adds no implicit edges at
997    /// all (below), so the only edge here is the written one, and `Js.Basics` is
998    /// checked first because `Basics` actually imports it.
999    #[test]
1000    fn a_facade_a_default_import_is_built_from_is_not_a_cycle() {
1001        let basics = module("Basics", vec!["Js.Basics"]);
1002        let js_basics = module("Js.Basics", vec![]);
1003
1004        let modules = vec![basics, js_basics];
1005        let module_files = HashMap::new();
1006
1007        let walker = ModuleWalker::new(&modules, &module_files, &PackageName::core())
1008            .expect("the standard library's own shape is not a cycle");
1009
1010        assert_walker_processed_order(walker, vec!["Js.Basics", "Basics"]);
1011    }
1012
1013    /// `LANG-57`: `zelkova-core` adds no implicit edges at all — not even one that
1014    /// would otherwise be perfectly safe to add — while an ordinary package is
1015    /// unaffected (which for `add_default_import_edges` has always meant "still no
1016    /// edges, because none of the eight is ever a node in its graph").
1017    ///
1018    /// Mutation-checked by dropping the `is_core` guard at the top of
1019    /// `add_default_import_edges`: the first assertion then finds the edge
1020    /// `Widget -> Basics` that guard exists to suppress.
1021    #[test]
1022    fn a_core_package_gets_no_implicit_edges() {
1023        let core_shaped = implicit_edges(
1024            &PackageName::core(),
1025            &["Widget", "Basics"],
1026            &[vec![], vec![]],
1027        );
1028        assert_eq!(
1029            core_shaped,
1030            Vec::new(),
1031            "`zelkova-core` must add no implicit edges, got {:?}",
1032            core_shaped
1033        );
1034
1035        let ordinary = implicit_edges(&ordinary_package(), &["Widget", "Aux"], &[vec![], vec![]]);
1036        assert_eq!(
1037            ordinary,
1038            Vec::new(),
1039            "an ordinary package naming neither Basics nor any other default \
1040             still adds none, got {:?}",
1041            ordinary
1042        );
1043    }
1044
1045    #[test]
1046    fn dependencies_with_branches() {
1047        /*
1048        a <- c <- b <- d
1049          <- e <- f <- g <- h
1050
1051        i -> b
1052          -> e
1053          <- h
1054        i should be after e, c, and b; h should be after i; no guarantee on the order when there is no constraint
1055        */
1056        let a = module("a", vec![]);
1057        let b = module("c", vec!["a"]);
1058        let c = module("b", vec!["c"]);
1059
1060        let d = module("d", vec!["c"]);
1061        let e = module("e", vec!["a"]);
1062        let f = module("f", vec!["e"]);
1063        let g = module("g", vec!["f"]);
1064        let h = module("h", vec!["g", "i"]);
1065
1066        let i = module("i", vec!["b", "e"]);
1067
1068        let modules = vec![a, c, b, d, e, f, g, h, i];
1069        let module_files = HashMap::new();
1070
1071        let walker = ModuleWalker::new(&modules, &module_files, &ordinary_package());
1072
1073        assert_walker_processed_order(
1074            walker.expect("no errors here"),
1075            vec!["a", "c", "b", "d", "e", "f", "g", "i", "h"],
1076        )
1077    }
1078
1079    /// `BUG-2`: one failing module used to make `check_in_order` discard every
1080    /// module that checked successfully, returning `Err(errors)` with no way to
1081    /// recover the successes. It must hand back both: the modules that checked, and
1082    /// the errors from the ones that didn't.
1083    ///
1084    /// Mutation-checked by keeping only the `Failed` outcomes whenever one is present
1085    /// (`outcomes.retain(..)` before the return) — the old discard behaviour, expressed
1086    /// in a way that still compiles against the return type. It turns this test red,
1087    /// since `successes` then comes back empty instead of `["a", "c"]`.
1088    #[test]
1089    fn check_in_order_keeps_successful_modules_when_one_fails() {
1090        let a = module("a", vec![]);
1091        let b = module("b", vec!["a"]);
1092        let c = module("c", vec!["b"]);
1093
1094        let modules = vec![a, b, c];
1095        let module_files = HashMap::new();
1096        let name = ordinary_package();
1097        let walker = ModuleWalker::new(&modules, &module_files, &name).expect("no errors here");
1098
1099        let mut ifaces = HashMap::new();
1100        let (successes, errors) = modules_and_errors(walker.check_in_order(
1101            &name,
1102            &mut ifaces,
1103            &module_files,
1104            dummy_check_fails_for_b,
1105        ));
1106
1107        assert_eq!(
1108            errors,
1109            vec![Name::new("b")],
1110            "expected exactly the one error, and for `b`"
1111        );
1112        assert_eq!(
1113            successes,
1114            vec!["a".to_string(), "c".to_string()],
1115            "expected the non-failing modules to still be returned"
1116        );
1117        assert!(
1118            !ifaces.contains_key(&Name::new("b")),
1119            "a module that failed has no interface to publish"
1120        );
1121    }
1122
1123    /// A module that comes back with errors beside it publishes its interface all the
1124    /// same, so the modules importing it are checked against it.
1125    ///
1126    /// Mutation-checked by inserting the interface only for an `Outcome::Module` whose
1127    /// error list is empty: `b`'s interface is then missing and this turns red.
1128    #[test]
1129    fn check_in_order_publishes_a_module_that_came_back_with_errors() {
1130        let a = module("a", vec![]);
1131        let b = module("b", vec!["a"]);
1132        let c = module("c", vec!["b"]);
1133
1134        let modules = vec![a, b, c];
1135        let module_files = HashMap::new();
1136        let name = ordinary_package();
1137        let walker = ModuleWalker::new(&modules, &module_files, &name).expect("no errors here");
1138
1139        let mut ifaces = HashMap::new();
1140        let outcomes =
1141            walker.check_in_order(&name, &mut ifaces, &module_files, dummy_check_reports_for_b);
1142
1143        let (modules, errors) = modules_and_errors(outcomes);
1144        assert_eq!(modules, vec!["a", "b", "c"]);
1145        assert_eq!(errors, vec![Name::new("b")]);
1146
1147        let mut published: Vec<&str> = ifaces.keys().map(Name::as_str).collect();
1148        published.sort();
1149        assert_eq!(published, vec!["a", "b", "c"]);
1150    }
1151
1152    /// The implicit edges `add_default_import_edges` adds, as slot pairs.
1153    ///
1154    /// Slots rather than names, so that two namings of one package shape produce
1155    /// directly comparable answers.
1156    fn implicit_edges(
1157        package: &PackageName,
1158        names: &[&str],
1159        written: &[Vec<usize>],
1160    ) -> Vec<(usize, usize)> {
1161        let modules: Vec<Module> = names
1162            .iter()
1163            .enumerate()
1164            .map(|(i, n)| {
1165                let deps: Vec<&str> = written[i].iter().map(|&j| names[j]).collect();
1166                module(*n, deps)
1167            })
1168            .collect();
1169
1170        let mut graph = DiGraph::new();
1171        let mut idx_of = HashMap::new();
1172        let mut slot_of = HashMap::new();
1173        for (slot, m) in modules.iter().enumerate() {
1174            let idx = graph.add_node(m);
1175            idx_of.insert(&m.name, idx);
1176            slot_of.insert(idx, slot);
1177        }
1178        for m in modules.iter() {
1179            let from = idx_of[&m.name];
1180            for &to in m.imports.iter().filter_map(|i| idx_of.get(&i.name)) {
1181                graph.add_edge(from, to, ());
1182            }
1183        }
1184
1185        let before = graph.edge_count();
1186        add_default_import_edges(&mut graph, &idx_of, package.is_core());
1187
1188        let mut added: Vec<(usize, usize)> = graph
1189            .edge_indices()
1190            .skip(before)
1191            .filter_map(|e| graph.edge_endpoints(e))
1192            .map(|(from, to)| (slot_of[&from], slot_of[&to]))
1193            .collect();
1194        added.sort_unstable();
1195        added
1196    }
1197}