Skip to main content

morphir_core/ir/layout/
read.rs

1//! Reading a document tree: a map of logical paths to text becomes the distribution the equivalent
2//! single document would have produced.
3//!
4//! Mirrors `IR/src/layout/read-tree.ts` in `ecosystem/morphir-typescript`; see
5//! `.dev/docs/superpowers/maps/2026-09-17-reference-tree-layout-map.md` section 4. A tree is a
6//! distribution taken apart, so reading one is putting it back together: the root manifest says
7//! which kind it is and which packages live under `deps/`, each `…/module` file says what its
8//! directory holds, and each node file is one type or one value. Nothing here parses anything
9//! itself — the caller's parser turns text into the payload its profile produces, and the tree's
10//! `TreeModel` decodes that and assembles the pieces. What this module adds is what a single
11//! document does not have: which file a name is in, which package a directory belongs to, and the
12//! rule that every file under `pkg/` and `deps/` is claimed by exactly one module.
13//!
14//! A directory carries no order, so modules are assembled in logical-path order: a distribution
15//! whose modules were written in some other order comes back sorted. That is the one thing a tree
16//! does not preserve, and it is why a round trip is stable even though the writer never sorts.
17//!
18//! Two cursor conventions meet here. A file that is not there at all is reported at its bare
19//! logical path; everything else is `<logical path>#<json pointer>`, whether the diagnostic is
20//! about the shape of the tree or is a file reader's own, re-cursored onto the file it came from.
21
22use std::collections::{HashMap, HashSet};
23
24use indexmap::IndexMap;
25
26use super::model::{
27    AssembledModule, Entries, Envelope, ModuleFile, ModuleFileOf, Node, Packages, Payload, Role,
28    TreeModel, TypeNode, ValueNode,
29};
30use super::paths::{
31    MANIFEST, NodeFileKind, PathKind, Root, VERSION_SLOT, classify, node_file_path,
32};
33use super::v4_model::V4;
34use super::{Profile, Tree};
35// The stack a whole tree read grows onto, and the headroom below which it grows one, are the JSON
36// reader's own figures — borrowed rather than copied. A tree is read on one stack: the growth
37// happens once, around the whole read, rather than once per file, and every file's parse then finds
38// a stack deeper than `RED_ZONE` and grows no further. That only holds while the two agree, so
39// there is one pair of constants rather than two.
40use crate::ir::json::{READ_STACK_BYTES, RED_ZONE};
41use crate::ir::v4::{IRFile, SpellingMode, with_spelling_mode};
42use crate::ir::{Diagnostic, DiagnosticCode, DiagnosticStage, Warning};
43use crate::naming::{self, Name, PackageName};
44
45/// Reads a document tree into the [`IRFile`] the equivalent single document would have produced,
46/// with the warnings its files produced.
47///
48/// Every file is parsed with the one `profile`: a tree has no per-file profile, so a file spelled
49/// in the other one simply fails to parse and the parse diagnostic is re-cursored onto its path.
50///
51/// Fails at the first thing that is wrong, in the reference's order: the manifest's presence, the
52/// manifest itself, then each package's module directories in sorted order, and — last of all —
53/// the first file under `pkg/` or `deps/` that no module claimed.
54pub fn read_tree(files: &Tree, profile: Profile) -> Result<(IRFile, Vec<Warning>), Diagnostic> {
55    read_tree_with::<V4>(files, &|text| profile.read(text))
56}
57
58/// Reads a document tree whose files `M` decodes, each file's text parsed by `parse`.
59///
60/// [`read_tree`] is this with the v4 model and a profile's own parser; the layout rules — the
61/// order of checks, the claims, the stray check and the cursors — are the same for every model.
62pub(crate) fn read_tree_with<M: TreeModel>(
63    files: &Tree,
64    parse: &dyn Fn(&str) -> Result<M::Doc, Diagnostic>,
65) -> Result<(M::File, Vec<Warning>), Diagnostic> {
66    stacker::maybe_grow(RED_ZONE, READ_STACK_BYTES, || {
67        Reader::<M> {
68            files,
69            parse,
70            consumed: HashSet::new(),
71            warnings: Vec::new(),
72            module_dirs_by_owner: HashMap::new(),
73            version: None,
74        }
75        .read()
76    })
77}
78
79/// One tree read in progress: what it was given, what it has claimed, and what it has to say.
80struct Reader<'a, M: TreeModel> {
81    files: &'a Tree,
82    /// Turns one file's text into the payload `M` decodes.
83    parse: &'a dyn Fn(&str) -> Result<M::Doc, Diagnostic>,
84    /// The paths a file was parsed from, recorded before the file is decoded, so a file that
85    /// failed to decode is still claimed and the stray check does not blame it twice.
86    consumed: HashSet<String>,
87    /// Each warning with the path it came from, so the collected list can be sorted by path and
88    /// not depend on the order the tree was walked in.
89    warnings: Vec<(String, Warning)>,
90    /// Every module directory, grouped by owning package, computed once the manifest names the
91    /// packages — see [`PackageRoots::module_dirs_by_owner`].
92    module_dirs_by_owner: HashMap<(Root, String), Vec<String>>,
93    /// The manifest's `formatVersion`, as canonical JSON text, once the manifest is read: every
94    /// other file of the tree has to say the same.
95    version: Option<String>,
96}
97
98/// One module directory: which root it is under, the directory itself, and where its manifest is.
99struct Where {
100    root: Root,
101    dir: String,
102    manifest_path: String,
103}
104
105impl Where {
106    fn new(root: Root, dir: String) -> Self {
107        let manifest_path = super::paths::module_manifest_path(root, &dir);
108        Self {
109            root,
110            dir,
111            manifest_path,
112        }
113    }
114}
115
116/// One package the manifest named, and the directory prefix its modules sit under.
117#[derive(PartialEq)]
118struct PackageRoot {
119    root: Root,
120    name: PackageName,
121    /// The escaped package path itself, with no version slot.
122    pkg_path: String,
123    /// The prefix every one of the package's module directories starts with: the package path
124    /// under `pkg/`, and the package path plus the bare version slot under `deps/`.
125    prefix: String,
126    /// [`Self::prefix`] with the trailing separator, computed once here rather than reallocated on
127    /// every directory it is compared against.
128    prefix_slash: String,
129}
130
131/// The packages a tree holds: the manifest's own, then its dependencies in the manifest's order.
132///
133/// A directory tree does not order its dependencies; the manifest does.
134struct PackageRoots {
135    own: PackageRoot,
136    deps: Vec<PackageRoot>,
137}
138
139impl PackageRoots {
140    fn of<K, E>(manifest: &Envelope<K, E>) -> Self {
141        let own_path = naming::escaped_path(manifest.package.as_path());
142        let own_prefix_slash = format!("{own_path}/");
143        Self {
144            own: PackageRoot {
145                root: Root::Pkg,
146                name: manifest.package.clone(),
147                pkg_path: own_path.clone(),
148                prefix: own_path,
149                prefix_slash: own_prefix_slash,
150            },
151            deps: manifest
152                .dependencies
153                .iter()
154                .map(|name| {
155                    let prefix = super::paths::package_dir(Root::Deps, name);
156                    let prefix_slash = format!("{prefix}/");
157                    PackageRoot {
158                        root: Root::Deps,
159                        pkg_path: naming::escaped_path(name.as_path()),
160                        prefix,
161                        prefix_slash,
162                        name: name.clone(),
163                    }
164                })
165                .collect(),
166        }
167    }
168
169    fn all(&self) -> impl Iterator<Item = &PackageRoot> {
170        std::iter::once(&self.own).chain(self.deps.iter())
171    }
172
173    /// Which package a directory belongs to: the listed one, under the same root, whose prefix it
174    /// starts with — a strict prefix, so a directory equal to the package directory is not owned.
175    ///
176    /// Under `deps/` the prefix ends in the version slot, so a package `a` and a package `a/b` can
177    /// never both prefix one directory (decision 0015); a manifest listing the same dependency
178    /// twice is refused by the manifest decoder before this is ever asked. At most one package
179    /// matches, so taking the first is taking the only one.
180    fn owner(&self, root: Root, dir: &str) -> Option<&PackageRoot> {
181        self.all()
182            .find(|p| p.root == root && dir.starts_with(&p.prefix_slash))
183    }
184
185    /// Every module directory the tree holds, grouped by the package that owns it — one pass over
186    /// the tree's keys rather than one rescan per package, since a directory's owner never changes
187    /// between packages asking.
188    fn module_dirs_by_owner(&self, files: &Tree) -> HashMap<(Root, String), Vec<String>> {
189        let mut groups: HashMap<(Root, String), Vec<String>> = HashMap::new();
190        for path in files.keys() {
191            let PathKind::Module { root, dir } = classify(path) else {
192                continue;
193            };
194            if let Some(owner) = self.owner(root, &dir) {
195                groups
196                    .entry((owner.root, owner.pkg_path.clone()))
197                    .or_default()
198                    .push(dir);
199            }
200        }
201        for dirs in groups.values_mut() {
202            dirs.sort();
203        }
204        groups
205    }
206}
207
208/// A listing of one module's types or values: the names whose files have to be read, or the
209/// entries the manifest wrote out inline.
210///
211/// Owned, because the entries are the model's: an inline listing moves into the module rather than
212/// being copied out of the manifest.
213enum Listing<T> {
214    Names(Vec<Name>),
215    Inline(IndexMap<String, T>),
216}
217
218impl<M: TreeModel> Reader<'_, M> {
219    fn read(mut self) -> Result<(M::File, Vec<Warning>), Diagnostic> {
220        if !self.files.contains_key(MANIFEST) {
221            return Err(Diagnostic::new(
222                DiagnosticCode::MissingMember,
223                DiagnosticStage::Semantic,
224                MANIFEST,
225                "missing member \"manifest\"",
226            ));
227        }
228        let envelope = self.read_file(MANIFEST, M::decode_manifest)?;
229        let roots = PackageRoots::of(&envelope);
230        self.module_dirs_by_owner = roots.module_dirs_by_owner(self.files);
231        let packages = self.packages(&envelope, &roots)?;
232        let file = M::assemble(envelope, packages)?;
233
234        // Everything under `pkg/` or `deps/` belongs to a module; a file no module manifest
235        // claimed is in the wrong package, spelled in a way the grammar does not recognize, or
236        // simply left behind, and either way the tree is not the distribution it says it is. Only
237        // files outside those two roots are ignored — and this runs last, so a tree with both a
238        // stray file and a defect inside a module reports the defect.
239        if let Some(stray) = self.stray() {
240            return Err(shape(&stray, "/", stray_message(&stray, &roots)));
241        }
242
243        self.warnings.sort_by(|left, right| left.0.cmp(&right.0));
244        let warnings = self
245            .warnings
246            .into_iter()
247            .map(|(_, warning)| warning)
248            .collect();
249        Ok((file, warnings))
250    }
251
252    /// Every module of every package the manifest named: its own package first, then each
253    /// dependency in the manifest's order, each read in the role the model gives its root for
254    /// the manifest's kind.
255    fn packages(
256        &mut self,
257        envelope: &Envelope<M::Kind, M::Extra>,
258        roots: &PackageRoots,
259    ) -> Result<Packages<M>, Diagnostic> {
260        let own = self.package(&roots.own, M::role(envelope.kind, Root::Pkg))?;
261        let role = M::role(envelope.kind, Root::Deps);
262        let mut dependencies = Vec::with_capacity(roots.deps.len());
263        for package in &roots.deps {
264            dependencies.push((package.name.clone(), self.package(package, role)?));
265        }
266        Ok(Packages { own, dependencies })
267    }
268
269    // =========================================================================
270    // Per package
271    // =========================================================================
272
273    /// One package's modules, in logical-path order, each with its listings resolved.
274    fn package(
275        &mut self,
276        package: &PackageRoot,
277        role: Role,
278    ) -> Result<Vec<AssembledModule<M>>, Diagnostic> {
279        let mut modules = Vec::new();
280        for dir in self.module_dirs(package) {
281            let at = Where::new(package.root, dir);
282            let ModuleFile {
283                path,
284                public,
285                doc,
286                types,
287                values,
288                file_names,
289            } = self.read_module_manifest(&at, package, role)?;
290            let types = self.resolve(
291                &at,
292                &file_names,
293                listing(types, &at, "types", role)?,
294                |reader, at, name, stem| reader.load_type(at, name, stem, role),
295            )?;
296            let values = self.resolve(
297                &at,
298                &file_names,
299                listing(values, &at, "values", role)?,
300                |reader, at, name, stem| reader.load_value(at, name, stem, role),
301            )?;
302            modules.push(AssembledModule {
303                path,
304                public,
305                doc,
306                types,
307                values,
308            });
309        }
310        Ok(modules)
311    }
312
313    /// The module directories of one package, in logical-path order.
314    ///
315    /// A directory is a module exactly when it holds a `module` file; a directory of node files
316    /// without one is left unclaimed and reported as such by the stray check.
317    fn module_dirs(&self, package: &PackageRoot) -> Vec<String> {
318        self.module_dirs_by_owner
319            .get(&(package.root, package.pkg_path.clone()))
320            .cloned()
321            .unwrap_or_default()
322    }
323
324    // =========================================================================
325    // Per module
326    // =========================================================================
327
328    /// A module manifest, checked against the directory it was found in.
329    ///
330    /// The directory is the authority on where a module lives: a manifest that disagrees would put
331    /// the same module in two places at once. The module's *name*, on the other hand, is the
332    /// manifest's, because the escaped directory cannot tell a word from an initialism.
333    fn read_module_manifest(
334        &mut self,
335        at: &Where,
336        package: &PackageRoot,
337        role: Role,
338    ) -> Result<ModuleFileOf<M>, Diagnostic> {
339        let manifest = self.read_file(&at.manifest_path, |value, cursor| {
340            M::decode_module(value, cursor, role)
341        })?;
342        // `owner` matched a strict prefix and a separator, so the relative directory is what
343        // follows both; the fallback keeps this total rather than trusting the arithmetic.
344        let relative = at.dir.get(package.prefix.len() + 1..).unwrap_or_default();
345        let spelled = naming::escaped_path(&manifest.path);
346        if spelled != relative {
347            return Err(shape(
348                &at.manifest_path,
349                "/path",
350                format!("module path \"{spelled}\" does not match its directory \"{relative}\""),
351            ));
352        }
353        Ok(manifest)
354    }
355
356    /// One listing, resolved to the entries it names.
357    ///
358    /// A names-style listing reads one file per name, in the order the manifest listed them; an
359    /// inline listing is already the entries themselves. The model keys entries by their canonical
360    /// name, so a listing that names one name twice reads its file twice and keeps one entry.
361    fn resolve<T>(
362        &mut self,
363        at: &Where,
364        file_names: &[(Name, String)],
365        listing: Listing<T>,
366        load: impl Fn(&mut Self, &Where, &Name, &str) -> Result<T, Diagnostic>,
367    ) -> Result<IndexMap<String, T>, Diagnostic> {
368        match listing {
369            Listing::Inline(items) => Ok(items),
370            Listing::Names(names) => {
371                let mut out = IndexMap::with_capacity(names.len());
372                for name in names {
373                    let value = load(self, at, &name, &stem_of(file_names, &name))?;
374                    out.insert(name.to_canonical_string(), value);
375                }
376                Ok(out)
377            }
378        }
379    }
380
381    /// One listed type's `.type` file, refused when it holds the other role's node.
382    fn load_type(
383        &mut self,
384        at: &Where,
385        name: &Name,
386        stem: &str,
387        role: Role,
388    ) -> Result<TypeNode<M>, Diagnostic> {
389        let (path, node) = self.node_file(
390            at,
391            NodeFileKind::Type,
392            |value, cursor| M::decode_type_file(value, cursor, role),
393            name,
394            stem,
395        )?;
396        body(&path, node, role)
397    }
398
399    /// One listed value's `.value` file, the other half of [`Self::load_type`].
400    fn load_value(
401        &mut self,
402        at: &Where,
403        name: &Name,
404        stem: &str,
405        role: Role,
406    ) -> Result<ValueNode<M>, Diagnostic> {
407        let (path, node) = self.node_file(
408            at,
409            NodeFileKind::Value,
410            |value, cursor| M::decode_value_file(value, cursor, role),
411            name,
412            stem,
413        )?;
414        body(&path, node, role)
415    }
416
417    /// The file one listed name lives in, checked against the name that pointed at it.
418    ///
419    /// A manifest that lists a name with no file, or a file whose own name is not the one that
420    /// found it, would silently rename a definition.
421    fn node_file<T>(
422        &mut self,
423        at: &Where,
424        kind: NodeFileKind,
425        read: impl FnOnce(&M::Doc, &str) -> Result<(Name, T), Diagnostic>,
426        name: &Name,
427        stem: &str,
428    ) -> Result<(String, T), Diagnostic> {
429        let path = node_file_path(at.root, &at.dir, stem, kind);
430        if !self.files.contains_key(&path) {
431            return Err(Diagnostic::new(
432                DiagnosticCode::MissingMember,
433                DiagnosticStage::Semantic,
434                path.clone(),
435                format!(
436                    "{} lists \"{}\" but there is no {path}",
437                    at.manifest_path,
438                    name.to_canonical_string()
439                ),
440            ));
441        }
442        let (found, file) = self.read_file(&path, read)?;
443        if &found != name {
444            return Err(shape(
445                &path,
446                "/name",
447                format!(
448                    "expected \"{}\", the name {} listed, found \"{}\"",
449                    name.to_canonical_string(),
450                    at.manifest_path,
451                    found.to_canonical_string()
452                ),
453            ));
454        }
455        Ok((path, file))
456    }
457
458    // =========================================================================
459    // Per file
460    // =========================================================================
461
462    /// One file of the tree, parsed by the caller's parser and decoded as the node it is.
463    ///
464    /// The path is claimed as soon as the text is parsed and before it is decoded, so a file that
465    /// fails to decode is never also reported as unclaimed. The file's own diagnostics and
466    /// warnings come back re-cursored onto its logical path.
467    ///
468    /// Under a model whose files repeat the manifest's version
469    /// ([`TreeModel::FILES_REPEAT_MANIFEST_VERSION`]), a parsed file is held to the manifest's
470    /// `formatVersion` before it is decoded: the tree is one distribution, so a file of another
471    /// version is refused for that alone, whatever else it says.
472    fn read_file<T>(
473        &mut self,
474        path: &str,
475        read: impl FnOnce(&M::Doc, &str) -> Result<T, Diagnostic>,
476    ) -> Result<T, Diagnostic> {
477        let Some(text) = self.files.get(path) else {
478            return Err(Diagnostic::new(
479                DiagnosticCode::MissingMember,
480                DiagnosticStage::Semantic,
481                path,
482                format!("missing file \"{path}\""),
483            ));
484        };
485        let parsed = (self.parse)(text);
486        self.consumed.insert(path.to_owned());
487        let value = parsed.map_err(|diagnostic| recursor(path, diagnostic))?;
488
489        if M::FILES_REPEAT_MANIFEST_VERSION {
490            self.agree(path, &value)?;
491        }
492
493        let (decoded, warnings) = with_spelling_mode(SpellingMode::Current, || read(&value, ""));
494        for warning in warnings {
495            self.warnings.push((
496                path.to_owned(),
497                Warning {
498                    code: warning.code,
499                    cursor: at(path, &warning.cursor),
500                },
501            ));
502        }
503        decoded.map_err(|diagnostic| recursor(path, diagnostic))
504    }
505
506    /// Records the manifest's `formatVersion`, or holds any other file to it.
507    fn agree(&mut self, path: &str, value: &M::Doc) -> Result<(), Diagnostic> {
508        let found = value.format_version();
509        if path == MANIFEST {
510            self.version = found;
511            return Ok(());
512        }
513        // A file that says no version at all is left to its decoder, which answers
514        // `missing_format_version` as it would for a single document.
515        match (&self.version, found) {
516            (Some(expected), Some(found)) if found != *expected => Err(Diagnostic::normalization(
517                DiagnosticCode::VersionMismatch,
518                format!("{path}#/formatVersion"),
519                format!("formatVersion {found} does not match the manifest's {expected}"),
520            )),
521            _ => Ok(()),
522        }
523    }
524
525    /// The first file under `pkg/` or `deps/` that no module claimed, in sorted order.
526    ///
527    /// A [`Tree`] iterates its keys in sorted order, so the first unclaimed one found is the first
528    /// in sorted order — which is the one and only stray the reference reports.
529    fn stray(&self) -> Option<String> {
530        self.files
531            .keys()
532            .find(|path| !self.consumed.contains(*path) && is_under_package_root(path))
533            .cloned()
534    }
535}
536
537// =============================================================================
538// Node files
539// =============================================================================
540
541/// The node a file carries, or the refusal a node of the other role earns: a definitions module
542/// holds only definition files, and a specifications module only specification files.
543fn body<D, S>(path: &str, node: Node<D, S>, role: Role) -> Result<Node<D, S>, Diagnostic> {
544    match (role, node) {
545        (Role::Definitions, Node::Spec(_)) => Err(shape(path, "/", "expected a definition file")),
546        (Role::Specifications, Node::Def(_)) => {
547            Err(shape(path, "/", "expected a specification file"))
548        }
549        (_, node) => Ok(node),
550    }
551}
552
553// =============================================================================
554// Listings
555// =============================================================================
556
557/// A listing read in the role its module has, each inline entry as the node that role holds.
558///
559/// A module manifest decoded in one role never comes back in the other role's inline style —
560/// which of the two an inline object is read as is decided by the role, never guessed from the
561/// shape — so the two mismatched arms cannot happen. They are still refusals rather than empty
562/// listings: silently dropping a module's entries would turn a defect in this reader into a
563/// distribution missing half of itself.
564fn listing<D, S>(
565    entries: Entries<D, S>,
566    at: &Where,
567    member: &str,
568    role: Role,
569) -> Result<Listing<Node<D, S>>, Diagnostic> {
570    match (entries, role) {
571        (Entries::Names(names), _) => Ok(Listing::Names(names)),
572        (Entries::Definitions(items), Role::Definitions) => Ok(Listing::Inline(
573            items
574                .into_iter()
575                .map(|(key, item)| (key, Node::Def(item)))
576                .collect(),
577        )),
578        (Entries::Specifications(items), Role::Specifications) => Ok(Listing::Inline(
579            items
580                .into_iter()
581                .map(|(key, item)| (key, Node::Spec(item)))
582                .collect(),
583        )),
584        (Entries::Specifications(_), Role::Definitions) => Err(shape(
585            &at.manifest_path,
586            &format!("/{member}"),
587            format!("expected definitions in {member}, found specifications"),
588        )),
589        (Entries::Definitions(_), Role::Specifications) => Err(shape(
590            &at.manifest_path,
591            &format!("/{member}"),
592            format!("expected specifications in {member}, found definitions"),
593        )),
594    }
595}
596
597/// The stem a name's file is under: the one the manifest recorded for a name the path budget
598/// truncated, the escaped name otherwise.
599///
600/// A reader trusts `fileNames`. It never recomputes the truncation and never checks that the
601/// recorded stem is the one the budget would have produced — the manifest is what says where a
602/// file is.
603fn stem_of(file_names: &[(Name, String)], name: &Name) -> String {
604    let canonical = name.to_canonical_string();
605    file_names
606        .iter()
607        .find(|(listed, _)| listed.to_canonical_string() == canonical)
608        .map(|(_, stem)| stem.clone())
609        .unwrap_or_else(|| naming::file_stem(name))
610}
611
612// =============================================================================
613// Cursors and the stray message
614// =============================================================================
615
616/// Whether a logical path is one a distribution owns, whatever shape it has.
617///
618/// A plain string test, not a classification: a path under those two roots that the grammar does
619/// not recognize is still the distribution's to account for.
620fn is_under_package_root(path: &str) -> bool {
621    path.starts_with("pkg/") || path.starts_with("deps/")
622}
623
624/// A diagnostic about the tree itself rather than about the inside of one file: the cursor is the
625/// logical path, with the file's own pointer after `#`.
626fn shape(path: &str, pointer: &str, message: impl Into<String>) -> Diagnostic {
627    Diagnostic::new(
628        DiagnosticCode::InvalidDistributionShape,
629        DiagnosticStage::Semantic,
630        format!("{path}#{pointer}"),
631        message,
632    )
633}
634
635/// A file's own diagnostic, re-cursored onto the path it came from. The code, stage, message and
636/// location stay the file decoder's; only the cursor grows a prefix.
637fn recursor(path: &str, diagnostic: Diagnostic) -> Diagnostic {
638    Diagnostic {
639        cursor: at(path, &diagnostic.cursor),
640        ..diagnostic
641    }
642}
643
644/// A cursor inside one file: the file's logical path, then the pointer, with a root pointer
645/// spelled `/`.
646fn at(path: &str, cursor: &str) -> String {
647    let pointer = if cursor.is_empty() { "/" } else { cursor };
648    format!("{path}#{pointer}")
649}
650
651/// The message for a file no module claimed.
652///
653/// A `deps/` directory whose leading segments match a listed dependency is missing or misspelling
654/// the version slot, and the more useful of the three wordings says which; anything else belongs
655/// to no listed package at all, the way any unclaimed file does.
656fn stray_message(path: &str, packages: &PackageRoots) -> String {
657    const GENERIC: &str = "file belongs to no module";
658
659    let (root, dir) = match classify(path) {
660        PathKind::Module { root, dir } => (root, dir),
661        PathKind::Type { root, dir, .. } | PathKind::Value { root, dir, .. } => (root, dir),
662        PathKind::Manifest | PathKind::Other => return GENERIC.to_owned(),
663    };
664    if root != Root::Deps {
665        return GENERIC.to_owned();
666    }
667
668    for package in packages.all() {
669        if package.root != Root::Deps {
670            continue;
671        }
672        if dir != package.pkg_path && !dir.starts_with(&format!("{}/", package.pkg_path)) {
673            continue;
674        }
675        let segment = dir
676            .get(package.pkg_path.len() + 1..)
677            .unwrap_or_default()
678            .split('/')
679            .next()
680            .unwrap_or_default();
681        if segment.starts_with(VERSION_SLOT) && segment != VERSION_SLOT {
682            return format!(
683                "the dependency directory's version segment \"{segment}\" carries a version, but \
684                 the v4 model has no package version to hold (decision 0015); expected a bare \
685                 \"{VERSION_SLOT}\""
686            );
687        }
688    }
689    format!(
690        "{GENERIC}; a dependency directory expects a version segment (\"{VERSION_SLOT}\") after \
691         the package path"
692    )
693}