Skip to main content

nova_pm/
graph.rs

1//! The package graph (specs
2//! `docs/superpowers/specs/2026-10-08-phase-3-3a-local-packages-design.md`
3//! §3 and
4//! `docs/superpowers/specs/2026-10-09-phase-3-3b-index-and-publishing-design.md`
5//! §5.4): the root package, and every package its dependencies reach,
6//! found by path or through `nova.lock` and the registry directory. The
7//! graph never touches the network.
8
9use std::collections::{HashMap, VecDeque};
10use std::path::{Path, PathBuf};
11
12use nova_diagnostics::{Diagnostic, FileDb, FileId, Severity, Span};
13use semver::VersionReq;
14
15use crate::lock::{parse_lock, Lock, LOCKFILE};
16use crate::manifest::{Dependency, Manifest};
17use crate::resolve::Requirement;
18use crate::{check_name, import_name, index_dir_name, real_path, registry_dir, MANIFEST};
19
20/// A package's index in its [`Graph`]. The root is `PackageId(0)`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
22pub struct PackageId(pub u32);
23
24/// A dependency that resolved.
25#[derive(Debug, Clone)]
26pub struct Edge {
27    /// The name the dependent imports it by (spec §3.4).
28    pub import_name: String,
29    pub package: PackageId,
30    /// The entry in the declaring manifest.
31    pub span: Span,
32}
33
34/// One package of a [`Graph`].
35#[derive(Debug, Clone)]
36pub struct GraphPackage {
37    pub name: String,
38    pub import_name: String,
39    /// Where its files are read: the root as the caller spelled it, and a
40    /// dependency joined onto its declarer's `dir`, such as `../geom`.
41    pub dir: PathBuf,
42    /// [`real_path`] of `dir`, which identifies the package (spec §3.3).
43    pub canonical: PathBuf,
44    pub manifest: Manifest,
45    /// Its `nova.toml`, in the caller's `FileDb`.
46    pub manifest_file: FileId,
47    pub has_lib: bool,
48    pub has_main: bool,
49    /// Whether it was found in the registry directory (spec 3.3b §5.4).
50    /// Its warnings are not shown.
51    pub registry: bool,
52    pub dependencies: Vec<Edge>,
53    /// Read for the root package only (spec §3.3).
54    pub dev_dependencies: Vec<Edge>,
55}
56
57/// The root package, and every package its dependencies reach.
58#[derive(Debug, Clone)]
59pub struct Graph {
60    /// The root first.
61    pub packages: Vec<GraphPackage>,
62}
63
64impl Graph {
65    pub fn root(&self) -> &GraphPackage {
66        &self.packages[0]
67    }
68
69    pub fn package(&self, id: PackageId) -> &GraphPackage {
70        &self.packages[id.0 as usize]
71    }
72
73    /// Every package's canonical directory, the root's first.
74    pub fn dirs(&self) -> Vec<PathBuf> {
75        self.packages.iter().map(|p| p.canonical.clone()).collect()
76    }
77
78    /// The root's own entry through which `id` is first reached, breadth
79    /// first, dependencies before dev-dependencies. `None` for the root.
80    /// The language server shows a dependency's problems there (spec §6).
81    pub fn reached_through(&self, id: PackageId) -> Option<Span> {
82        let root = self.root();
83        let mut via: HashMap<PackageId, Span> = HashMap::new();
84        let mut queue = VecDeque::new();
85        for edge in root.dependencies.iter().chain(&root.dev_dependencies) {
86            if edge.package != PackageId(0) && !via.contains_key(&edge.package) {
87                via.insert(edge.package, edge.span);
88                queue.push_back(edge.package);
89            }
90        }
91        while let Some(package) = queue.pop_front() {
92            let span = via[&package];
93            for edge in &self.package(package).dependencies {
94                if edge.package != PackageId(0) && !via.contains_key(&edge.package) {
95                    via.insert(edge.package, span);
96                    queue.push_back(edge.package);
97                }
98            }
99        }
100        via.get(&id).copied()
101    }
102}
103
104/// Where the graph finds registry packages (spec 3.3b §5.4).
105#[derive(Debug, Clone)]
106pub struct Offline {
107    /// `$NOVA_HOME/registry`. `None` when `$NOVA_HOME` cannot be found.
108    pub registry: Option<PathBuf>,
109    /// The lock to use instead of the root's `nova.lock`: `nova add`
110    /// checks the lock it would write this way.
111    pub lock: Option<Lock>,
112    /// Whether the root's `[dev-dependencies]` are read. Publishing's
113    /// verification reads none (spec 3.3b §6.6).
114    pub dev: bool,
115}
116
117impl Offline {
118    /// The registry directory from this process's environment, the root's
119    /// own `nova.lock`, and its dev-dependencies.
120    pub fn from_env() -> Offline {
121        Offline {
122            registry: registry_dir(),
123            lock: None,
124            dev: true,
125        }
126    }
127}
128
129/// Read the package at `root`, the directory holding its `nova.toml` (empty
130/// for the current directory), and every package its dependencies reach.
131/// Every manifest goes into `db`, so each diagnostic's label renders.
132///
133/// The graph is partial on error: what resolved is kept, with the
134/// diagnostics. It is `None` only when the root's own manifest cannot be
135/// read or parsed.
136pub fn graph(root: &Path, db: &mut FileDb) -> (Option<Graph>, Vec<Diagnostic>) {
137    graph_with(root, None, &Offline::from_env(), db)
138}
139
140/// [`graph`], with the root's `nova.toml` taken from `text` when it is
141/// given: `nova add` checks a new entry this way before writing it.
142pub fn graph_from(
143    root: &Path,
144    text: Option<&str>,
145    db: &mut FileDb,
146) -> (Option<Graph>, Vec<Diagnostic>) {
147    graph_with(root, text, &Offline::from_env(), db)
148}
149
150/// [`graph_from`], with registry packages found as `offline` says.
151pub fn graph_with(
152    root: &Path,
153    text: Option<&str>,
154    offline: &Offline,
155    db: &mut FileDb,
156) -> (Option<Graph>, Vec<Diagnostic>) {
157    let mut builder = Builder::new(root, db, offline, Mode::Graph);
158    let Some(package) = builder.read(root, text, false) else {
159        return (None, builder.diagnostics);
160    };
161    builder.check_root(&package);
162    builder.packages.push(package);
163    builder.walk();
164    let Builder {
165        diagnostics,
166        packages,
167        ..
168    } = builder;
169    (Some(Graph { packages }), diagnostics)
170}
171
172/// Every version entry that the root and its path packages declare, for
173/// the resolver (spec 3.3b §4.1): who made each one, and the root's entry
174/// through which it is reached. The root's dev-dependencies count when
175/// `dev` is set. The root's `nova.toml` is `text` when it is given.
176///
177/// Registry packages are not read: their requirements come from the index.
178/// The diagnostics are the manifests' and the path graph's, without M0013,
179/// since `nova add` syncs a package before it has a source file.
180pub fn requirements(
181    root: &Path,
182    text: Option<&str>,
183    dev: bool,
184    db: &mut FileDb,
185) -> (Vec<Requirement>, Vec<Diagnostic>) {
186    let offline = Offline {
187        registry: None,
188        lock: None,
189        dev,
190    };
191    let mut builder = Builder::new(root, db, &offline, Mode::Collect);
192    let Some(package) = builder.read(root, text, false) else {
193        return (Vec::new(), builder.diagnostics);
194    };
195    builder.check_root(&package);
196    builder.packages.push(package);
197    builder.walk();
198    let graph = Graph {
199        packages: std::mem::take(&mut builder.packages),
200    };
201    let found = builder
202        .requirements
203        .into_iter()
204        .map(|(mut requirement, from)| {
205            if let Some(span) = graph.reached_through(from) {
206                requirement.span = span;
207            }
208            requirement
209        })
210        .collect();
211    (found, builder.diagnostics)
212}
213
214/// Whether `name` is one of Nova's keywords, which no import name may be.
215fn is_keyword(name: &str) -> bool {
216    nova_lexer::KEYWORDS.contains(&name)
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220enum Visit {
221    New,
222    OnPath,
223    Done,
224}
225
226/// What the builder does with a version entry.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228enum Mode {
229    /// Find its package through the lock and the registry directory.
230    Graph,
231    /// Record it as a [`Requirement`].
232    Collect,
233}
234
235/// The root's `nova.lock`, once read.
236enum LockState {
237    Missing,
238    Unreadable,
239    Read(Lock),
240}
241
242struct Builder<'a> {
243    db: &'a mut FileDb,
244    offline: &'a Offline,
245    mode: Mode,
246    /// The root's directory, where its `nova.lock` is.
247    root: PathBuf,
248    diagnostics: Vec<Diagnostic>,
249    packages: Vec<GraphPackage>,
250    /// Read at the first registry entry.
251    lock: Option<LockState>,
252    /// In [`Mode::Collect`]: each version entry, and the package that made
253    /// it.
254    requirements: Vec<(Requirement, PackageId)>,
255}
256
257impl<'a> Builder<'a> {
258    fn new(root: &Path, db: &'a mut FileDb, offline: &'a Offline, mode: Mode) -> Builder<'a> {
259        Builder {
260            db,
261            offline,
262            mode,
263            root: root.to_path_buf(),
264            diagnostics: Vec::new(),
265            packages: Vec::new(),
266            lock: None,
267            requirements: Vec::new(),
268        }
269    }
270}
271
272impl Builder<'_> {
273    fn error(&mut self, code: &str, message: String, at: Span, label: &str) {
274        self.diagnostics
275            .push(Diagnostic::error(code, message).with_primary_label(at, label));
276    }
277
278    /// M0013 and M0012 on the root package itself. M0013 is the graph's
279    /// alone (see [`requirements`]).
280    fn check_root(&mut self, package: &GraphPackage) {
281        if self.mode == Mode::Graph && !package.has_lib && !package.has_main {
282            self.error(
283                "M0013",
284                format!(
285                    "package `{}` has neither src/lib.nova nor src/main.nova",
286                    package.name
287                ),
288                package.manifest.package.span,
289                "a package needs a library, a program, or both",
290            );
291        }
292        if package.has_lib && is_keyword(&package.import_name) {
293            self.error(
294                "M0012",
295                format!(
296                    "library `{}` would be imported as `{}`, which is a keyword",
297                    package.name, package.import_name
298                ),
299                package.manifest.package.span,
300                "choose another name",
301            );
302        }
303    }
304
305    /// Expand every package, breadth first from the root, then find the
306    /// cycles.
307    fn walk(&mut self) {
308        let mut queue = VecDeque::from([PackageId(0)]);
309        while let Some(id) = queue.pop_front() {
310            self.expand(id, &mut queue);
311        }
312        self.cycles();
313    }
314
315    /// The package whose manifest is `dir/nova.toml`, or `text` when it is
316    /// given, with no edges yet. `None` when the manifest cannot be read or
317    /// has errors. A registry package's warnings are dropped (spec 3.3b
318    /// §5.4).
319    fn read(&mut self, dir: &Path, text: Option<&str>, registry: bool) -> Option<GraphPackage> {
320        let path = dir.join(MANIFEST);
321        let source = match text {
322            Some(text) => text.to_string(),
323            None => match std::fs::read_to_string(&path) {
324                Ok(source) => source,
325                Err(error) => {
326                    self.diagnostics.push(Diagnostic::error(
327                        "M0001",
328                        format!("cannot read {}: {error}", path.display()),
329                    ));
330                    return None;
331                }
332            },
333        };
334        let file = self.db.add(path.display().to_string(), source.as_str());
335        let (manifest, diagnostics) = crate::manifest::parse(&source, file);
336        self.diagnostics.extend(
337            diagnostics
338                .into_iter()
339                .filter(|d| !registry || d.severity == Severity::Error),
340        );
341        let manifest = manifest?;
342        let canonical = real_path(if dir.as_os_str().is_empty() {
343            Path::new(".")
344        } else {
345            dir
346        });
347        let src = dir.join("src");
348        Some(GraphPackage {
349            name: manifest.package.name.clone(),
350            import_name: import_name(&manifest.package.name),
351            dir: dir.to_path_buf(),
352            canonical,
353            has_lib: src.join("lib.nova").is_file(),
354            has_main: src.join("main.nova").is_file(),
355            registry,
356            manifest,
357            manifest_file: file,
358            dependencies: Vec::new(),
359            dev_dependencies: Vec::new(),
360        })
361    }
362
363    /// Resolve package `id`'s entries, and for the root its
364    /// dev-dependencies when they are read, queueing each package read for
365    /// the first time.
366    fn expand(&mut self, id: PackageId, queue: &mut VecDeque<PackageId>) {
367        let package = &self.packages[id.0 as usize];
368        let mut entries: Vec<(Dependency, bool)> = package
369            .manifest
370            .dependencies
371            .iter()
372            .cloned()
373            .map(|d| (d, false))
374            .collect();
375        if id == PackageId(0) && self.offline.dev {
376            entries.extend(
377                package
378                    .manifest
379                    .dev_dependencies
380                    .iter()
381                    .cloned()
382                    .map(|d| (d, true)),
383            );
384        }
385        let usable = self.names_ok(id, &entries);
386        for ((dependency, dev), ok) in entries.iter().zip(usable) {
387            if !ok {
388                continue;
389            }
390            if let Some(edge) = self.edge(id, dependency, queue) {
391                let package = &mut self.packages[id.0 as usize];
392                if *dev {
393                    package.dev_dependencies.push(edge);
394                } else {
395                    package.dependencies.push(edge);
396                }
397            }
398        }
399    }
400
401    /// M0012 (spec §3.4): which of package `id`'s entries may be resolved.
402    /// `[dependencies]` and `[dev-dependencies]` count together, since a
403    /// test module sees both.
404    fn names_ok(&mut self, id: PackageId, entries: &[(Dependency, bool)]) -> Vec<bool> {
405        let own = self.packages[id.0 as usize].import_name.clone();
406        let mut seen: Vec<(String, String)> = Vec::new();
407        let mut ok = Vec::new();
408        for (dependency, _) in entries {
409            let name = import_name(&dependency.name);
410            let problem = if is_keyword(&name) {
411                Some(format!(
412                    "dependency `{}` would be imported as `{name}`, which is a keyword",
413                    dependency.name
414                ))
415            } else if let Some((_, key)) = seen.iter().find(|(n, _)| *n == name) {
416                Some(if *key == dependency.name {
417                    format!(
418                        "`{}` is in both [dependencies] and [dev-dependencies]",
419                        dependency.name
420                    )
421                } else {
422                    format!(
423                        "`{key}` and `{}` are both imported as `{name}`",
424                        dependency.name
425                    )
426                })
427            } else if id == PackageId(0) && name == own {
428                Some(format!(
429                    "dependency `{}` would be imported as `{name}`, this package's own name",
430                    dependency.name
431                ))
432            } else {
433                None
434            };
435            match problem {
436                Some(message) => {
437                    self.error("M0012", message, dependency.span, "choose another name");
438                    ok.push(false);
439                }
440                None => {
441                    seen.push((name, dependency.name.clone()));
442                    ok.push(true);
443                }
444            }
445        }
446        ok
447    }
448
449    /// Resolve one entry of package `from` (spec §3.2, §3.3), reading the
450    /// package it names if this is the first time.
451    fn edge(
452        &mut self,
453        from: PackageId,
454        dependency: &Dependency,
455        queue: &mut VecDeque<PackageId>,
456    ) -> Option<Edge> {
457        let path = match (&dependency.path, &dependency.version) {
458            (Some(path), _) => path,
459            (None, Some(req)) => return self.registry_edge(from, dependency, &req.clone(), queue),
460            // The manifest parser gives every entry one or the other.
461            (None, None) => return None,
462        };
463        let at = dependency.path_span.unwrap_or(dependency.span);
464        let declarer = &self.packages[from.0 as usize];
465        let dir = declarer.dir.join(path);
466        let declarer_name = declarer.name.clone();
467        let declarer_canonical = declarer.canonical.clone();
468        if !dir.join(MANIFEST).is_file() {
469            self.error(
470                "M0007",
471                format!(
472                    "dependency `{}`: {} is not a directory holding a nova.toml",
473                    dependency.name,
474                    dir.display()
475                ),
476                at,
477                "no nova.toml here",
478            );
479            return None;
480        }
481        let canonical = real_path(&dir);
482        if canonical == declarer_canonical {
483            self.error(
484                "M0010",
485                format!("dependency cycle: `{declarer_name}` depends on itself"),
486                at,
487                "this is the package's own directory",
488            );
489            return None;
490        }
491        let package = match self.packages.iter().position(|p| p.canonical == canonical) {
492            Some(index) => {
493                let found = &self.packages[index].name;
494                if *found != dependency.name {
495                    let message =
496                        format!("dependency `{}` is the package `{found}`", dependency.name);
497                    self.error(
498                        "M0008",
499                        message,
500                        dependency.span,
501                        "an entry's key must be the package's name",
502                    );
503                    return None;
504                }
505                // Only the root can be reached again without its library
506                // checked: a dev-dependency that depends back on it.
507                if !self.packages[index].has_lib {
508                    self.error(
509                        "M0009",
510                        format!(
511                            "dependency `{}` is not a library: {} has no src/lib.nova",
512                            dependency.name,
513                            dir.display()
514                        ),
515                        at,
516                        "a dependency needs src/lib.nova",
517                    );
518                    return None;
519                }
520                PackageId(index as u32)
521            }
522            None => {
523                let new = self.read(&dir, None, false)?;
524                self.admit(new, dependency, at, queue)?
525            }
526        };
527        Some(Edge {
528            import_name: import_name(&dependency.name),
529            package,
530            span: dependency.span,
531        })
532    }
533
534    /// A package read for the first time, checked against the entry that
535    /// named it (M0008, M0009, M0011), then added to the graph and queued.
536    fn admit(
537        &mut self,
538        new: GraphPackage,
539        dependency: &Dependency,
540        at: Span,
541        queue: &mut VecDeque<PackageId>,
542    ) -> Option<PackageId> {
543        if new.name != dependency.name {
544            let message = format!(
545                "dependency `{}` is the package `{}`",
546                dependency.name, new.name
547            );
548            self.error(
549                "M0008",
550                message,
551                dependency.span,
552                "an entry's key must be the package's name",
553            );
554            return None;
555        }
556        if !new.has_lib {
557            self.error(
558                "M0009",
559                format!(
560                    "dependency `{}` is not a library: {} has no src/lib.nova",
561                    dependency.name,
562                    new.dir.display()
563                ),
564                at,
565                "a dependency needs src/lib.nova",
566            );
567            return None;
568        }
569        if let Some(other) = self.packages.iter().find(|p| p.name == new.name) {
570            let message = format!(
571                "two packages named `{}` in one build: {} and {}",
572                new.name,
573                other.dir.display(),
574                new.dir.display()
575            );
576            self.error(
577                "M0011",
578                message,
579                dependency.span,
580                "the second package of that name",
581            );
582            return None;
583        }
584        self.packages.push(new);
585        let id = PackageId(self.packages.len() as u32 - 1);
586        queue.push_back(id);
587        Some(id)
588    }
589
590    /// Resolve a version entry of package `from` through the lock and the
591    /// registry directory (spec 3.3b §5.4), or in [`Mode::Collect`] record
592    /// it.
593    fn registry_edge(
594        &mut self,
595        from: PackageId,
596        dependency: &Dependency,
597        req: &VersionReq,
598        queue: &mut VecDeque<PackageId>,
599    ) -> Option<Edge> {
600        // An index knows only package names; any other key is the
601        // manifest's error, not the index's.
602        if let Err(why) = check_name(&dependency.name) {
603            self.error("M0003", why, dependency.span, "not a valid package name");
604            return None;
605        }
606        if self.mode == Mode::Collect {
607            let by = self.packages[from.0 as usize].name.clone();
608            let requirement = Requirement {
609                name: dependency.name.clone(),
610                req: req.clone(),
611                by,
612                span: dependency.span,
613            };
614            self.requirements.push((requirement, from));
615            return None;
616        }
617        let name = &dependency.name;
618        let found = self
619            .lock()
620            .map(|lock| (lock.index.clone(), lock.find(name).cloned()));
621        let Some((index, Some(locked))) = found else {
622            self.missing(
623                dependency,
624                format!("dependency `{name}` is not downloaded yet; run `nova fetch`"),
625            );
626            return None;
627        };
628        if !req.matches(&locked.version) {
629            self.missing(
630                dependency,
631                format!(
632                    "dependency `{name}` is locked at {}, which does not meet `{req}`; run \
633                     `nova fetch`",
634                    locked.version
635                ),
636            );
637            return None;
638        }
639        let Some(registry) = self.offline.registry.clone() else {
640            self.missing(
641                dependency,
642                format!(
643                    "dependency `{name}` cannot be found: cannot place the package cache: set \
644                     NOVA_HOME to a writable directory"
645                ),
646            );
647            return None;
648        };
649        let dir = registry
650            .join("src")
651            .join(index_dir_name(&index))
652            .join(format!("{name}-{}", locked.version));
653        if !dir.join(MANIFEST).is_file() {
654            self.missing(
655                dependency,
656                format!("dependency `{name}` is not downloaded yet; run `nova fetch`"),
657            );
658            return None;
659        }
660        let canonical = real_path(&dir);
661        let package = match self.packages.iter().position(|p| p.canonical == canonical) {
662            Some(index) => PackageId(index as u32),
663            None => {
664                let new = self.read(&dir, None, true)?;
665                // Unpacking checked both (spec §5.2); a copy edited since is
666                // refused here.
667                if let Some(entry) = new.manifest.dependencies.iter().find(|d| d.path.is_some()) {
668                    self.error(
669                        "M0017",
670                        format!(
671                            "downloaded package `{name}` {} has a path dependency `{}`",
672                            locked.version, entry.name
673                        ),
674                        dependency.span,
675                        "declared here",
676                    );
677                    return None;
678                }
679                let mut names: Vec<String> = new
680                    .manifest
681                    .dependencies
682                    .iter()
683                    .map(|d| d.name.clone())
684                    .collect();
685                names.sort();
686                if names != locked.dependencies {
687                    self.missing(
688                        dependency,
689                        format!(
690                            "the downloaded copy of `{name}` {} does not match nova.lock; delete \
691                             {} and run `nova fetch`",
692                            locked.version,
693                            dir.display()
694                        ),
695                    );
696                    return None;
697                }
698                self.admit(new, dependency, dependency.span, queue)?
699            }
700        };
701        Some(Edge {
702            import_name: import_name(name),
703            package,
704            span: dependency.span,
705        })
706    }
707
708    /// M0005 on `dependency`: a registry entry the cache cannot satisfy.
709    fn missing(&mut self, dependency: &Dependency, message: String) {
710        self.diagnostics.push(
711            Diagnostic::error("M0005", message)
712                .with_primary_label(dependency.span, "declared here"),
713        );
714    }
715
716    /// The lock registry entries are found through: [`Offline::lock`], else
717    /// the root's `nova.lock`, read once. M0016 when it cannot be read.
718    fn lock(&mut self) -> Option<&Lock> {
719        if self.lock.is_none() {
720            let state = match &self.offline.lock {
721                Some(lock) => LockState::Read(lock.clone()),
722                None => {
723                    let path = self.root.join(LOCKFILE);
724                    match std::fs::read_to_string(&path) {
725                        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
726                            LockState::Missing
727                        }
728                        Err(error) => {
729                            self.diagnostics.push(
730                                Diagnostic::error(
731                                    "M0016",
732                                    format!("nova.lock cannot be read: {error}"),
733                                )
734                                .with_note("delete nova.lock, or run `nova update`"),
735                            );
736                            LockState::Unreadable
737                        }
738                        Ok(text) => {
739                            let file = self.db.add(path.display().to_string(), text.as_str());
740                            match parse_lock(&text, file) {
741                                Ok(lock) => LockState::Read(lock),
742                                Err(diagnostic) => {
743                                    self.diagnostics.push(diagnostic);
744                                    LockState::Unreadable
745                                }
746                            }
747                        }
748                    }
749                }
750            };
751            self.lock = Some(state);
752        }
753        match &self.lock {
754            Some(LockState::Read(lock)) => Some(lock),
755            _ => None,
756        }
757    }
758
759    /// M0010 for each cycle of `[dependencies]` edges (spec §3.3), found
760    /// depth first from every package.
761    fn cycles(&mut self) {
762        let count = self.packages.len();
763        let mut state = vec![Visit::New; count];
764        let mut path = Vec::new();
765        for start in 0..count {
766            if state[start] == Visit::New {
767                self.visit(PackageId(start as u32), &mut state, &mut path);
768            }
769        }
770    }
771
772    fn visit(&mut self, id: PackageId, state: &mut [Visit], path: &mut Vec<PackageId>) {
773        state[id.0 as usize] = Visit::OnPath;
774        path.push(id);
775        for edge in self.packages[id.0 as usize].dependencies.clone() {
776            match state[edge.package.0 as usize] {
777                Visit::OnPath => {
778                    let start = path.iter().position(|p| *p == edge.package).unwrap_or(0);
779                    let names: Vec<&str> = path[start..]
780                        .iter()
781                        .chain([&edge.package])
782                        .map(|p| self.packages[p.0 as usize].name.as_str())
783                        .collect();
784                    let message = format!("dependency cycle: {}", names.join(" -> "));
785                    self.error("M0010", message, edge.span, "this entry closes the cycle");
786                }
787                Visit::New => self.visit(edge.package, state, path),
788                Visit::Done => {}
789            }
790        }
791        path.pop();
792        state[id.0 as usize] = Visit::Done;
793    }
794}