module-lang 1.0.0

Module and import resolution for multi-file languages.
Documentation
# module-lang v0.2.0 — Module & import resolution

**The core, front-loaded.** v0.2.0 turns the scaffold into a working resolver. It
introduces the `ModuleGraph` that holds many modules, the stable `ModuleId` that
names each, and the `define` / `import` / `resolve` surface that answers what every
`use`/`import` asks — *which item does this name refer to?* — with visibility and
import-cycle detection. This was the hard part of the roadmap, and the roadmap put
it first on purpose: get name resolution wrong and the result is a miscompile, not a
cosmetic bug, so it is proven now, against a naive reference resolver, before any
later surface is built on top of it.

## What is module-lang?

The module and import resolution layer of a compiler front-end. Given the modules a
front-end has already parsed — each exporting a set of named items — it builds the
module graph, resolves each import to its target, reports unresolved names and
import cycles as defined errors, and honours item visibility. It sits in the SEMA
tier above `symbol-lang`, which interns the names, and `source-lang`, which owns the
files those modules came from. It owns module and import resolution only — no
parsing, no type checking, no code generation.

## What's new in 0.2.0

### `ModuleGraph<T>` — modules, items, and the edges between them

The central type, generic over the payload `T` a name resolves to — a definition
id, an AST node, a type — with no trait bounds. One module is added per source file;
each declares names by `define` (items it owns) or `import` (items it pulls in), and
a module's namespace is flat: a name is declared at most once.

```rust
use intern_lang::Interner;
use module_lang::{ModuleGraph, Visibility};
use source_lang::SourceMap;

let mut sources = SourceMap::new();
let mut names = Interner::new();
let helper = names.intern("helper");

let mut graph: ModuleGraph<&str> = ModuleGraph::new();
let app = graph.add_module(names.intern("app"), sources.add("app.lang", "")?);
let util = graph.add_module(names.intern("util"), sources.add("util.lang", "")?);

graph.define(util, helper, Visibility::Public, "fn helper")?;
graph.import(app, util, helper)?;

assert_eq!(graph.resolve(app, helper), Ok(&"fn helper"));
# Ok::<(), Box<dyn std::error::Error>>(())
```

### `resolve` — local hits, import chains, and cycles

A name defined in a module wins, public or private — a module always sees its own
names. An imported name is followed to its source module, and onward along any chain
of re-exports, to the public definition it names. The walk is iterative and records
the `(module, name)` pairs it has visited, so an import chain that loops back is a
defined `ResolveError::ImportCycle` in bounded time rather than unbounded recursion.
A local hit allocates nothing; only crossing an import edge touches the heap.

Names within a module live in a `BTreeMap` keyed on the interned `Symbol`, so a
lookup compares integers in `O(log items)` and iteration is deterministic. The
benchmarks at this tag (Windows x86_64, release): a local-definition hit resolves in
**~4.5 ns**, a miss in **~1.9 ns**, and a 32-deep re-export chain in **~195 ns**
(about 6 ns per hop); building a graph of 64 modules × 64 items takes **~55 µs**.

### `Visibility` — the module boundary

`Public` / `Private` gates *cross-module* access only. A module reaches its own
private names; an import from another module reaches a name only if it is public.

```rust
# use intern_lang::Interner;
# use module_lang::{ModuleGraph, ResolveError, Visibility};
# use source_lang::SourceMap;
# let mut sources = SourceMap::new();
# let mut names = Interner::new();
let mut graph: ModuleGraph<()> = ModuleGraph::new();
let lib = graph.add_module(names.intern("lib"), sources.add("lib", "")?);
let app = graph.add_module(names.intern("app"), sources.add("app", "")?);
let secret = names.intern("secret");

graph.define(lib, secret, Visibility::Private, ())?;
assert!(graph.resolve(lib, secret).is_ok());        // visible at home

graph.import(app, lib, secret)?;
assert!(matches!(                                    // not through an import
    graph.resolve(app, secret),
    Err(ResolveError::Private { .. }),
));
# Ok::<(), Box<dyn std::error::Error>>(())
```

### `ModuleId` — a stable, opaque handle

Adding a module returns a `ModuleId`: a 32-bit, `Copy` handle that stays valid for
the life of the graph, because modules are only ever appended. It is opaque — no
public constructor — so an id can only come from the graph that holds the module it
names; an id from a different graph is a defined `ResolveError::UnknownModule`, never
an out-of-bounds read.

### `ResolveError` — every failure is defined

Building the graph and resolving through it fail only in named ways:
`UnknownModule`, `DuplicateName`, `Unresolved`, `Private`, and `ImportCycle`. Each
carries the `ModuleId` and `Symbol` it concerns, so a caller can recover the
spelling from its own interner and build a richer diagnostic; the `Display` form is
a terse fallback, since module-lang does not own the interner. The enum is
`#[non_exhaustive]`, ready for the failures a later phase adds.

### `symbol-lang` and `source-lang`, wired and used

The dependencies are now real, not reserved: every name — module and item — is a
`symbol_lang::Symbol`, and each module records the `source_lang::SourceId` of the
file it was read from. Both handle types are re-exported from the crate root, so a
downstream consuming module-lang's API does not also have to name those crates.

### Property tests against a naive reference resolver

`tests/resolution.rs` applies a random sequence of `define`/`import` operations to
both the graph and an independent, deliberately naive model (a `BTreeMap` per module
plus a recursive resolver with a `HashSet` visited set), then resolves every
`(module, name)` pair in both and compares — across the cycles, private targets, and
missing names that random graphs produce. The invariants held:

- Ids are unique, stable, and equal to insertion order.
- Resolution agrees with the naive reference on every pair, and is deterministic.
- A duplicate definition or colliding import is rejected where it is added.
- Visibility is enforced across import boundaries but not within a module.
- An import cycle is reported, never looped; a separate 4096-deep chain test
  confirms the iterative walk does not overflow the stack.

## Breaking changes

**None.** v0.1.0 had no public surface; everything here is additive.

## Verification

Run on Windows x86_64 (Rust stable and 1.85 MSRV); the same commands run on Linux
(WSL2 Ubuntu) and across the CI matrix on Linux, macOS, and Windows:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
cargo build --no-default-features
cargo +1.85 build --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo deny check
cargo audit
```

All green. Counts at this tag:

- Default features: 15 unit + 2 integration + 16 doctests.
- `--all-features`: identical (serde adds no runtime surface).

## What's next

- **1.0.0 — API freeze.** Stabilise the resolution surface and freeze it under
  Semantic Versioning, with the full property-test and benchmark suite green on all
  three platforms. Renamed (`as`) imports and nested module paths are the obvious
  candidates to land before the freeze, each additive.

## Installation

```toml
[dependencies]
module-lang = "0.2"
```

MSRV: Rust 1.85.

## Documentation

- [README]https://github.com/jamesgober/module-lang/blob/main/README.md
- [API Reference]https://github.com/jamesgober/module-lang/blob/main/docs/API.md
- [CHANGELOG]https://github.com/jamesgober/module-lang/blob/main/CHANGELOG.md

---

**Full diff:** [`v0.1.0...v0.2.0`](https://github.com/jamesgober/module-lang/compare/v0.1.0...v0.2.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/module-lang/blob/main/CHANGELOG.md#020---2026-06-29).