Skip to main content

morphir_core/ir/layout/
write.rs

1//! Writing a document tree: a distribution becomes a list of logical paths and their text.
2//!
3//! Mirrors `IR/src/layout/write-tree.ts` in `ecosystem/morphir-typescript`; see
4//! `.dev/docs/superpowers/maps/2026-09-17-reference-tree-layout-map.md` section 5. Nothing here
5//! touches a filesystem: the profile is the only thing that decides what the text looks like, and
6//! the result is an ordered list a caller writes, streams or compares as it likes.
7//!
8//! The canonical layout is the manifest style: one definition per file, the module manifest
9//! listing names rather than inlining them, and `fileNames` present only for the names the path
10//! budget had to cut. The order is the order the specification gives — the distribution manifest,
11//! then each module's own manifest followed by its types and then its values, the own package
12//! before the dependencies — and modules keep the order the model carries them in, never sorted:
13//! a reader sorts on the way in, so a round trip is stable without the writer reordering anything.
14//!
15//! The budget is the reason writing a tree can fail at all. A path is measured physically,
16//! extension included, from the distribution root; when a stem cannot be cut small enough, or when
17//! the module directory alone is already over, there is no tree to write. The budget's *floor* is
18//! a reader's rule, not a writer's: a small budget earns a refusal here only by producing a path
19//! that does not fit.
20//!
21//! The per-module writers are public and take one module each, so a caller streaming a
22//! distribution can emit a module's files without holding the whole tree.
23//!
24//! What a file says is the version's business: the layout here — the budget, the stems, the order
25//! — hands a [`TreeModel`] the pieces to encode, and the profile renders what the model returns.
26
27use std::collections::{HashMap, HashSet};
28
29use indexmap::IndexMap;
30
31use super::Profile;
32use super::model::{Envelope, ModuleHeader, Node, Role, TreeModel, TypeNodeRef, ValueNodeRef};
33use super::paths::{
34    MANIFEST, NodeFileKind, Root, module_dir, module_dir_prefix, module_manifest_path,
35    node_file_path, package_dir, to_physical,
36};
37use super::stems::stem_for;
38use super::v4_model::{V4, V4Extra};
39use crate::ir::v4::access::{Access, AccessControlled};
40use crate::ir::v4::distribution::{Distribution, EntryPoints};
41use crate::ir::v4::module::{ModuleDefinition, ModuleSpecification};
42use crate::ir::v4::package::{PackageDefinition, PackageSpecification};
43use crate::ir::v4::tree_files::DistributionKind;
44use crate::ir::v4::{DocumentMeta, FormatVersion, IRFile, LinkedMetadataCarrier};
45use crate::ir::{Diagnostic, DiagnosticCode, DiagnosticStage};
46use crate::naming::{ModuleName, Name, PackageName};
47
48/// How a distribution is laid out: which profile spells it, and how long a path may be.
49///
50/// The budget is a count of characters of the *physical* path from the distribution root, the
51/// profile's extension included. There is no floor here: [`crate::ir::v4::tree_files::MIN_PATH_BUDGET`]
52/// is what a reader holds a manifest to, and enforcing it again on write would refuse a tree the
53/// reference writes.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct TreePolicy {
56    pub profile: Profile,
57    pub path_budget: u32,
58}
59
60/// One entry of a module, paired with the file stem the budget gave it.
61struct Stem<'a, T> {
62    name: Name,
63    value: &'a T,
64    stem: String,
65    truncated: bool,
66}
67
68/// Everything the distribution manifest says, held apart from the distribution it describes.
69///
70/// A distribution manifest names its package, kind, dependencies, entry points, and optional 4.1
71/// document metadata — never a module — so a caller streaming one module at a time can write the
72/// manifest at the end. [`write_manifest`] builds one from a complete distribution; a streaming
73/// writer builds one from its header, metadata event, and dependency names.
74#[derive(Debug, Clone, PartialEq)]
75pub struct ManifestHeader {
76    pub format_version: FormatVersion,
77    pub distribution: DistributionKind,
78    pub package: PackageName,
79    /// The dependency packages, in the order the distribution lists them.
80    pub dependencies: Vec<PackageName>,
81    /// An application's entry points; empty on the other two kinds.
82    pub entry_points: EntryPoints,
83    /// The 4.1 document graph and its source records, owned by the manifest.
84    pub metadata: Option<Box<DocumentMeta>>,
85}
86
87/// The distribution manifest a header spells: the tree's root file, and the only one that names
88/// the whole.
89///
90/// Refuses metadata on a pre-4.1 manifest instead of publishing an invalid or empty file.
91pub fn write_manifest_header(
92    header: &ManifestHeader,
93    policy: &TreePolicy,
94) -> Result<(String, String), Diagnostic> {
95    let envelope = Envelope {
96        kind: header.distribution,
97        package: header.package.clone(),
98        path_budget: policy.path_budget,
99        dependencies: header.dependencies.clone(),
100        extra: V4Extra {
101            format_version: header.format_version.clone(),
102            entry_points: header.entry_points.clone(),
103            metadata: header.metadata.clone(),
104        },
105    };
106    let value = V4::encode_manifest(&envelope)?;
107    Ok((MANIFEST.to_owned(), policy.profile.write(&value)))
108}
109
110/// The distribution manifest of a whole distribution.
111pub fn write_manifest(file: &IRFile, policy: &TreePolicy) -> Result<(String, String), Diagnostic> {
112    write_manifest_header(&manifest_header(file), policy)
113}
114
115/// The header a complete distribution carries.
116fn manifest_header(file: &IRFile) -> ManifestHeader {
117    ManifestHeader {
118        format_version: file.format_version.clone(),
119        distribution: kind_of(&file.distribution),
120        package: file.distribution.package_name().clone(),
121        dependencies: dependency_names(&file.distribution),
122        entry_points: entry_points_of(&file.distribution),
123        metadata: file.metadata.clone(),
124    }
125}
126
127/// Lays one module definition out as its manifest and one file per type and value.
128///
129/// The files come back in the reference's emission order: the module manifest first, then the
130/// types and then the values, each in the order the module lists them.
131pub fn write_definition_module(
132    root: Root,
133    package: &PackageName,
134    module_name: &str,
135    module: &AccessControlled<ModuleDefinition>,
136    format_version: &FormatVersion,
137    policy: &TreePolicy,
138) -> Result<Vec<(String, String)>, Diagnostic> {
139    refuse_old_version_metadata(module, format_version)?;
140    let path = module_path(root, package, module_name)?;
141    let definition = &module.value;
142    let header = ModuleHeader {
143        path: path.into_path(),
144        public: module.access != Access::Private,
145        doc: definition.doc.clone(),
146    };
147    write_module_with::<V4>(
148        root,
149        package,
150        &header,
151        Role::Definitions,
152        &nodes(&definition.types, Node::Def),
153        &nodes(&definition.values, Node::Def),
154        policy,
155        format_version,
156        &|doc| policy.profile.write(doc),
157    )
158}
159
160/// Lays one module specification out the same way, with `spec` bodies.
161///
162/// A module manifest has no place for annotations, so a specification carrying any cannot be
163/// written as a tree at all — and that is decided before the budget is, so a module with both
164/// problems reports the one a larger budget would not fix. A specification publishes nothing
165/// private, so its manifest never writes an `access` member.
166pub fn write_specification_module(
167    root: Root,
168    package: &PackageName,
169    module_name: &str,
170    module: &ModuleSpecification,
171    format_version: &FormatVersion,
172    policy: &TreePolicy,
173) -> Result<Vec<(String, String)>, Diagnostic> {
174    refuse_old_version_metadata(module, format_version)?;
175    let path = module_path(root, package, module_name)?;
176    let dir = module_dir(root, package, path.as_path());
177
178    if !module.annotations.is_empty() {
179        return Err(Diagnostic::new(
180            DiagnosticCode::InvalidDistributionShape,
181            DiagnosticStage::Semantic,
182            module_manifest_path(root, &dir),
183            "module annotations cannot be written to a document tree",
184        ));
185    }
186
187    let header = ModuleHeader {
188        path: path.into_path(),
189        public: true,
190        doc: module.doc.clone(),
191    };
192    write_module_with::<V4>(
193        root,
194        package,
195        &header,
196        Role::Specifications,
197        &nodes(&module.types, Node::Spec),
198        &nodes(&module.values, Node::Spec),
199        policy,
200        format_version,
201        &|doc| policy.profile.write(doc),
202    )
203}
204
205/// A module's entries of one kind as the nodes a model encodes, in listing order. The nodes
206/// borrow the entries: only the model's encoder copies one, for the file it is writing.
207fn nodes<'a, T, N>(entries: &'a IndexMap<String, T>, node: fn(&'a T) -> N) -> IndexMap<String, N> {
208    entries
209        .iter()
210        .map(|(key, value)| (key.clone(), node(value)))
211        .collect()
212}
213
214/// Lays a whole distribution out as a document tree under `policy`.
215///
216/// The list is the tree in emission order: the distribution manifest, the own package's modules,
217/// then the dependencies in the order the model lists them. A `Library` or `Specs` tree's
218/// dependencies are package specifications; an `Application` links its dependencies statically, so
219/// its `deps/` holds definitions (distributions-0010). A logical path appears exactly once, at the
220/// position it was first written and carrying the last text written to it — see [`Files`].
221///
222/// Fails with `invalid_distribution_shape` when the path budget cannot hold the tree or when
223/// linked metadata is written under an older format version.
224pub fn write_tree(file: &IRFile, policy: &TreePolicy) -> Result<Vec<(String, String)>, Diagnostic> {
225    if file.format_version != FormatVersion::String("4.1.0".to_owned())
226        && file.has_linked_metadata()
227    {
228        return Err(Diagnostic::new(
229            DiagnosticCode::InvalidDistributionShape,
230            DiagnosticStage::Semantic,
231            "",
232            "linked metadata requires formatVersion 4.1.0",
233        ));
234    }
235    let mut out = Files::default();
236    out.set(write_manifest(file, policy)?);
237    let format_version = &file.format_version;
238
239    match &file.distribution {
240        Distribution::Library(content) => {
241            write_definition_modules(
242                Root::Pkg,
243                &content.package_name,
244                &content.def,
245                format_version,
246                policy,
247                &mut out,
248            )?;
249            for (key, specification) in &content.dependencies {
250                let package = dependency_name(key)?;
251                write_specification_modules(
252                    Root::Deps,
253                    &package,
254                    specification,
255                    format_version,
256                    policy,
257                    &mut out,
258                )?;
259            }
260        }
261        Distribution::Specs(content) => {
262            write_specification_modules(
263                Root::Pkg,
264                &content.package_name,
265                &content.spec,
266                format_version,
267                policy,
268                &mut out,
269            )?;
270            for (key, specification) in &content.dependencies {
271                let package = dependency_name(key)?;
272                write_specification_modules(
273                    Root::Deps,
274                    &package,
275                    specification,
276                    format_version,
277                    policy,
278                    &mut out,
279                )?;
280            }
281        }
282        Distribution::Application(content) => {
283            write_definition_modules(
284                Root::Pkg,
285                &content.package_name,
286                &content.def,
287                format_version,
288                policy,
289                &mut out,
290            )?;
291            for (key, definition) in &content.dependencies {
292                let package = dependency_name(key)?;
293                write_definition_modules(
294                    Root::Deps,
295                    &package,
296                    definition,
297                    format_version,
298                    policy,
299                    &mut out,
300                )?;
301            }
302        }
303    }
304
305    Ok(out.into_vec())
306}
307
308fn refuse_old_version_metadata<T: LinkedMetadataCarrier>(
309    value: &T,
310    version: &FormatVersion,
311) -> Result<(), Diagnostic> {
312    if *version != FormatVersion::String("4.1.0".to_owned()) && value.contains_linked_metadata() {
313        return Err(Diagnostic::new(
314            DiagnosticCode::InvalidDistributionShape,
315            DiagnosticStage::Semantic,
316            "",
317            "linked metadata requires formatVersion 4.1.0",
318        ));
319    }
320    Ok(())
321}
322
323/// The tree as the reference accumulates it: a map keyed by logical path, iterated in the order
324/// each path was *first* written.
325///
326/// Two module keys can escape to one directory — `user-ID` and `user--id` are the two canonical
327/// encodings of one name, and nothing validates a module key on read — so two modules can write
328/// the same paths. The reference's `Map.set` keeps one entry per path: the last value written,
329/// under the position the path first took. Accumulating into a plain list instead would emit the
330/// path twice, and a tree is a map of files.
331#[derive(Default)]
332pub(crate) struct Files {
333    entries: Vec<(String, String)>,
334    positions: HashMap<String, usize>,
335}
336
337impl Files {
338    pub(crate) fn set(&mut self, (path, text): (String, String)) {
339        match self.positions.get(&path) {
340            Some(&at) => self.entries[at].1 = text,
341            None => {
342                self.positions.insert(path.clone(), self.entries.len());
343                self.entries.push((path, text));
344            }
345        }
346    }
347
348    pub(crate) fn into_vec(self) -> Vec<(String, String)> {
349        self.entries
350    }
351}
352
353// =============================================================================
354// Per package
355// =============================================================================
356
357fn write_definition_modules(
358    root: Root,
359    package: &PackageName,
360    definition: &PackageDefinition,
361    format_version: &FormatVersion,
362    policy: &TreePolicy,
363    out: &mut Files,
364) -> Result<(), Diagnostic> {
365    for (name, module) in &definition.modules {
366        for file in write_definition_module(root, package, name, module, format_version, policy)? {
367            out.set(file);
368        }
369    }
370    Ok(())
371}
372
373fn write_specification_modules(
374    root: Root,
375    package: &PackageName,
376    specification: &PackageSpecification,
377    format_version: &FormatVersion,
378    policy: &TreePolicy,
379    out: &mut Files,
380) -> Result<(), Diagnostic> {
381    for (name, module) in &specification.modules {
382        for file in write_specification_module(root, package, name, module, format_version, policy)?
383        {
384            out.set(file);
385        }
386    }
387    Ok(())
388}
389
390// =============================================================================
391// Per module
392// =============================================================================
393
394/// The body every per-module writer shares, whichever model `M` spells the files: the module
395/// directory has to fit, then every stem, then the manifest — which is written last of the three
396/// because `fileNames` is exactly the list of names the budget had to cut — and then one file per
397/// entry. `render` turns what the model encodes into the profile's text.
398///
399/// The files come back in the reference's emission order: the module manifest first, then the
400/// types and then the values, each in the order the module lists them.
401#[allow(clippy::too_many_arguments)]
402pub(crate) fn write_module_with<M: TreeModel>(
403    root: Root,
404    package: &PackageName,
405    header: &ModuleHeader,
406    role: Role,
407    types: &IndexMap<String, TypeNodeRef<'_, M>>,
408    values: &IndexMap<String, ValueNodeRef<'_, M>>,
409    policy: &TreePolicy,
410    version: &M::Version,
411    render: &dyn Fn(&M::Doc) -> String,
412) -> Result<Vec<(String, String)>, Diagnostic> {
413    let dir = module_dir(root, package, &header.path);
414    fits(root, &dir, policy)?;
415    let type_stems = stems_for(types, root, &dir, NodeFileKind::Type, policy)?;
416    let value_stems = stems_for(values, root, &dir, NodeFileKind::Value, policy)?;
417
418    let type_names: Vec<Name> = type_stems.iter().map(|s| s.name.clone()).collect();
419    let value_names: Vec<Name> = value_stems.iter().map(|s| s.name.clone()).collect();
420    let file_names: Vec<(Name, String)> = truncated(&type_stems)
421        .chain(truncated(&value_stems))
422        .collect();
423
424    let mut out = Vec::with_capacity(1 + type_stems.len() + value_stems.len());
425    let manifest_path = module_manifest_path(root, &dir);
426    let manifest = M::encode_module(
427        version,
428        header,
429        role,
430        (&type_names, &value_names),
431        &file_names,
432    )
433    .map_err(|diagnostic| in_file(&manifest_path, diagnostic))?;
434    out.push((manifest_path, render(&manifest)));
435
436    for stem in &type_stems {
437        let path = node_file_path(root, &dir, &stem.stem, NodeFileKind::Type);
438        let file = M::encode_type_file(version, &stem.name, stem.value)
439            .map_err(|diagnostic| in_file(&path, diagnostic))?;
440        out.push((path, render(&file)));
441    }
442
443    for stem in &value_stems {
444        let path = node_file_path(root, &dir, &stem.stem, NodeFileKind::Value);
445        let file = M::encode_value_file(version, &stem.name, stem.value)
446            .map_err(|diagnostic| in_file(&path, diagnostic))?;
447        out.push((path, render(&file)));
448    }
449
450    Ok(out)
451}
452
453/// The names whose stem the budget had to cut, each with the stem its file is under.
454fn truncated<'a, T>(stems: &'a [Stem<'a, T>]) -> impl Iterator<Item = (Name, String)> + 'a {
455    stems
456        .iter()
457        .filter(|stem| stem.truncated)
458        .map(|stem| (stem.name.clone(), stem.stem.clone()))
459}
460
461/// The stems of one kind inside one module, in listing order.
462///
463/// Escaping is injective, so two untruncated stems collide only when two listing keys spell the
464/// same name; two truncated ones can collide on their own. Either way, silently overwriting one
465/// file with another is the one outcome worth refusing, and the cursor is the physical path the
466/// second file would have taken.
467fn stems_for<'a, T>(
468    items: &'a IndexMap<String, T>,
469    root: Root,
470    dir: &str,
471    kind: NodeFileKind,
472    policy: &TreePolicy,
473) -> Result<Vec<Stem<'a, T>>, Diagnostic> {
474    let prefix = module_dir_prefix(root, dir);
475    let suffix = format!(".{}{}", kind.as_str(), policy.profile.extension());
476    let mut seen: HashSet<String> = HashSet::with_capacity(items.len());
477    let mut out = Vec::with_capacity(items.len());
478
479    for (key, value) in items {
480        let name = entry_name(key, root, dir)?;
481        let chosen = stem_for(&name, &prefix, &suffix, policy.path_budget)?;
482        if seen.contains(&chosen.stem) {
483            return Err(Diagnostic::new(
484                DiagnosticCode::InvalidDistributionShape,
485                DiagnosticStage::Semantic,
486                format!("{prefix}{}{suffix}", chosen.stem),
487                format!(
488                    "two {} names share the file stem \"{}\"",
489                    kind.as_str(),
490                    chosen.stem
491                ),
492            ));
493        }
494        seen.insert(chosen.stem.clone());
495        out.push(Stem {
496            name,
497            value,
498            stem: chosen.stem,
499            truncated: chosen.truncated,
500        });
501    }
502
503    Ok(out)
504}
505
506/// The module directory has to fit before anything inside it can: `module` is the shortest leaf a
507/// module has, so if that is already over the budget no choice of stem can rescue the module.
508fn fits(root: Root, dir: &str, policy: &TreePolicy) -> Result<(), Diagnostic> {
509    let physical = to_physical(&module_manifest_path(root, dir), policy.profile);
510    if physical.chars().count() > policy.path_budget as usize {
511        return Err(Diagnostic::new(
512            DiagnosticCode::InvalidDistributionShape,
513            DiagnosticStage::Semantic,
514            physical.clone(),
515            format!("path budget {} cannot fit {physical}", policy.path_budget),
516        ));
517    }
518    Ok(())
519}
520
521// =============================================================================
522// Names out of the model's map keys
523// =============================================================================
524
525/// A module's name, as the package's listing spells it.
526///
527/// The model keys its modules by their canonical string, so the name has to be parsed back out;
528/// a key that does not name a module path is `invalid_path`, cursored at the module manifest the
529/// key would have produced.
530fn module_path(
531    root: Root,
532    package: &PackageName,
533    module_name: &str,
534) -> Result<ModuleName, Diagnostic> {
535    ModuleName::from_canonical_string(module_name).map_err(|message| {
536        let dir = format!("{}/{module_name}", package_dir(root, package));
537        Diagnostic::new(
538            DiagnosticCode::InvalidPath,
539            DiagnosticStage::Semantic,
540            module_manifest_path(root, &dir),
541            message,
542        )
543    })
544}
545
546/// One type's or value's name, out of its listing key.
547fn entry_name(key: &str, root: Root, dir: &str) -> Result<Name, Diagnostic> {
548    Name::from_canonical_string(key).map_err(|message| {
549        Diagnostic::new(
550            DiagnosticCode::InvalidName,
551            DiagnosticStage::Semantic,
552            module_manifest_path(root, dir),
553            message,
554        )
555    })
556}
557
558/// A dependency's package name, out of the key the distribution lists it under. The manifest is
559/// where that name is spelled, so that is where an unparseable one is reported.
560fn dependency_name(key: &str) -> Result<PackageName, Diagnostic> {
561    PackageName::from_canonical_string(key).map_err(|message| {
562        Diagnostic::new(
563            DiagnosticCode::InvalidPath,
564            DiagnosticStage::Semantic,
565            MANIFEST,
566            message,
567        )
568    })
569}
570
571// =============================================================================
572// The distribution manifest's members
573// =============================================================================
574
575fn kind_of(distribution: &Distribution) -> DistributionKind {
576    match distribution {
577        Distribution::Library(_) => DistributionKind::Library,
578        Distribution::Specs(_) => DistributionKind::Specs,
579        Distribution::Application(_) => DistributionKind::Application,
580    }
581}
582
583/// The dependency package names, in the order the distribution lists them.
584///
585/// The keys are canonical package names already, so this parse is a round trip; the permissive
586/// parser keeps [`write_manifest`] total, and [`write_tree`] refuses an unparseable key on its own
587/// when it comes to lay the dependency's files out.
588fn dependency_names(distribution: &Distribution) -> Vec<PackageName> {
589    let keys: Vec<&String> = match distribution {
590        Distribution::Library(content) => content.dependencies.keys().collect(),
591        Distribution::Specs(content) => content.dependencies.keys().collect(),
592        Distribution::Application(content) => content.dependencies.keys().collect(),
593    };
594    keys.into_iter()
595        .map(|key| PackageName::parse(key))
596        .collect()
597}
598
599/// Entry points belong to an application; the other two kinds have none to write.
600fn entry_points_of(distribution: &Distribution) -> EntryPoints {
601    match distribution {
602        Distribution::Application(content) => content.entry_points.clone(),
603        Distribution::Library(_) | Distribution::Specs(_) => EntryPoints::new(),
604    }
605}
606
607// =============================================================================
608// The model boundary
609// =============================================================================
610
611/// A model's diagnostic about one file, cursored onto the file's logical path: the path alone for
612/// a failure at the file's root, the path and the pointer otherwise.
613fn in_file(path: &str, diagnostic: Diagnostic) -> Diagnostic {
614    let cursor = if diagnostic.cursor.is_empty() {
615        path.to_owned()
616    } else {
617        format!("{path}#{}", diagnostic.cursor)
618    };
619    Diagnostic {
620        cursor,
621        ..diagnostic
622    }
623}