ADR-030: A Program Is More Than One File, and That Is Name Resolution

Status: Superseded by ADR-286 — its D3-D7 are D2, D3, D7, D8, D9 there; its D1-D2 are not carried, displaced by ADR-047 D1 (D1 there) Related: ADR-011 D2 (the lowering is name for name), ADR-020 (the ledger, which is keyed by name), ADR-024 (the checker that resolves through those names)


1. Context

Part I 9.1 is one sentence — every file in Nikaia is implicitly a module — and Part I 9.2 and 9.3 spend a page on what pub means across one. Part III 13.1 lays out src/main.nika beside a manifest, a lockfile and a ledger. None of it existed: use parsed, lowered to a comment, and a program was one file.

The reason it stayed that way is ADR-011 D2. The lowering is name for name, and nothing in this compiler resolves a module — a deliberate limit that paid for itself in every other decision. Multi-file compilation looks like the one feature that has to break it.

It does not. What follows is the argument that it is the opposite: multi-file compilation is name resolution, Nikaia already does name resolution in exactly one place, and that place has been shaped for this since ADR-020.


2. The observation this rests on

std.contracts has looked like this since the day it existed:

[fn."fs::map"]
[fn."io::lines"]
[fn."text::digit_value"]

Module-qualified keys, in a file, read by name. And everything built on the ledger since then resolves a call by asking it for a::b: the sync check (ADR-020), the type checker (ADR-024), the provenance analysis (ADR-010), Kap 5.1’s options, ADR-025’s fallible loops, ADR-027’s inferred sync, ADR-028’s method resolution.

So a program’s own ledger takes the same shape, and none of those learns a new trick. The file they ask simply has more in it.


3. Decisions

D1 — A module is a file, beside the entry, named by one word.

use utils wants utils.nika in the entry’s directory. Part I 9.1 says a file is a module; this says which file.

One segment. use std::fs names the library and never a file — that is the one path a use may have. Anything else with a path is refused with a sentence, because a nested module is a nested mod with a resolution rule of its own, and inventing one to avoid an error message is how a language acquires two.

D2 — Every module becomes a mod at the crate root, and the lowering stays name for name.

pub use nikaia_std::prelude::*;

pub mod stock { use super::*;  pub fn total(…) -> i64 { … } }
pub mod page  { use super::*;  pub fn render(…) -> String { … } }

fn main() { … }          // the entry module's items, because `main` has to be here

stock::total(…) in Nikaia is stock::total(…) in Rust. Nothing resolved it — ADR-011 D2 is untouched. What changed is that the compiler now knows which files take part, which is a question about the file system and not about names.

use super::*; is one line doing two jobs: it brings in the preamble the crate root wrote, and it brings in the sibling modules, because a mod b at the root is a name at the root.

D3 — pub becomes pub, so the language below enforces Part I 9.2.

A private item is private to its mod, which is its file. This is ADR-017 D2’s move again: put the rule where the language below can act on it rather than re-implementing it.

It caught a bug that one file could not show. The emitter wrote pub on every struct field, whatever the source said. In a single-file program nothing can tell; across a boundary it silently grants access Part I 9.2 denies. pub on a field also did not parse, though 9.2 specifies it — so the one thing that would have made the bug visible was missing too. Both are fixed here, and the golden fixture that had frozen the old behaviour is regenerated.

D4 — NK1110: privacy in Nikaia’s words, before rustc is asked.

D3 is enforcement; this is the message. Part III C.1 calls an untranslated backend error a bug in this compiler, and reaching a private item across files is now an ordinary thing to do wrong.

error[NK1110]: `secret` is private to `utils.nika`
   4 |     let n = utils::secret()
           ^
     = an item is private to the file that declares it unless it says `pub` (Part I, 9.2)
     help: write `pub fn secret` in `utils.nika`, or reach it through something that is public

How it knows a call crosses a boundary, without knowing where it is. Only a call written module::item can be from another file — a call inside utils.nika writes secret(), unqualified. The checker is told which names are modules, which is what separates utils::secret from Stats::new; that is the whole of the extra knowledge it needs.

D5 — One ledger for the project, and the contracts are inferred over all of it at once.

Part III 13.5 puts nikaia.contracts at the project root, and D5 is why that is not merely tidy: sync is a fixpoint over the call graph (ADR-027 D1), and a call graph that stopped at a file boundary would give a different answer depending on which file was looked at first. 13.5’s determinism is a promise about the source tree.

Modules are recorded in name order rather than in the order their imports were discovered, for the same reason.

D6 — The cache is keyed on every file, and ADR-021 D13 is narrowed honestly.

Part III 13.1 already asks for “the SHA256 of each .nika source that took part”. It cannot be computed without knowing what took part, and that means parsing the entry. So ADR-021 D13’s on a hit nothing is parsed becomes nothing is parsed twice: the parse is what tells the compiler which files the program is, and a hit still saves the lowering, the checks and the inference — the larger part, and the parse itself became 20 % cheaper with the whitespace hoist.

A single file is unchanged in every respect, including the key’s bytes: the joined source of one file is that file.

D7 — The source map records which file, so ADR-012 survives the join.

A rustc message about the third module is traded back for a place in the third module. Each unit is emitted with offsets of its own and the driver stamps them as it joins the text; without that step a message about one file would point into another.


4. Consequences

examples/inventory/ is the first program here made of more than one file — the same program examples/report/src/main.nika is, in three. Both are kept on purpose: a reader can see what a boundary buys and what it costs on a program small enough to hold in the head. What it buys is that stock.nika keeps weight to itself and that neither file has to be read to use the other.

The example harness reads a program rather than a file, so an example may be one file or several and the tests do not care which.

A bug that only a second file could reach: "{utils::double(21)}" was read as the expression utils written with the format specifier :double(21). A hole in an interpolated string is Nikaia source, and Nikaia source names things across modules. :: is a path now.


5. What was rejected


6. What Stage 0 does today

7. What it does not, and why each is where it is

This site is open source. Improve this page.