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}