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}