intern-lang 1.0.0

Fast string and symbol interning - identifier comparisons become integer comparisons.
Documentation
# intern-lang v0.4.0 — serde, exhaustion contract, feature freeze

**The surface is complete.** v0.4.0 adds the last three pieces of the public API —
a defined non-panicking error for symbol-space exhaustion, optional `serde` for
`Symbol`, and the inverse of `Symbol::as_u32` — and then declares the surface
**frozen**: nothing will be added or changed before `1.0.0`, which will mark it
stable. Everything here is additive; no breaking changes.

## What is intern-lang?

A string interner. It maps each distinct string to a small, copyable `Symbol`,
stores the bytes once in a contiguous backing store, and hands back integer
handles so that comparing two names is an integer comparison rather than a byte
walk. It is the deduplication layer beneath the lexer and the symbol table.

## What's new in 0.4.0

### `try_intern` and `InternError` — the exhaustion contract

Interning a new string consumes one symbol from a finite space of `u32::MAX`. The
infallible `intern` saturates at that bound rather than panic, which is right for
the common case but hides the boundary from callers who must account for it.
`try_intern` makes the boundary a value:

```rust
use intern_lang::{InternError, Interner};

let mut interner = Interner::new();

let sym = interner.try_intern("identifier")?;       // Ok(symbol)
assert_eq!(interner.try_intern("identifier"), Ok(sym)); // dedup never errors
# Ok::<(), InternError>(())
```

`InternError` is a typed, `#[non_exhaustive]` enum implementing
`core::error::Error` — no `thiserror`, no `Box<dyn Error>`, and `no_std`-clean. Its
one variant today, `SymbolSpaceExhausted`, is returned when a *new* string can no
longer be assigned a symbol; interning a string already present never fails.
`ConcurrentInterner::try_intern` mirrors it with the same two-step locking.

This is the milestone's headline guarantee — "symbol-space exhaustion is reported,
never silently wrapped" — and it is **property-tested at the boundary**. Because
exhausting the real `u32::MAX` space would need tens of gigabytes, the interner
carries an internal symbol-space bound (always `u32::MAX` in normal use) that the
property test lowers, then drives interning right up to and past the edge: a new
string returns `Err(SymbolSpaceExhausted)`, while a string already held still
interns cleanly.

### `serde` for `Symbol`

Behind the `serde` feature, `Symbol` serializes transparently as its integer id
and deserializes back, rejecting `0` (a `Symbol` is a `NonZeroU32`):

```rust
# #[cfg(feature = "serde")] {
use intern_lang::{Interner, Symbol};

let mut interner = Interner::new();
let sym = interner.intern("persisted");

let json = serde_json::to_string(&sym).unwrap();   // "1"
let back: Symbol = serde_json::from_str(&json).unwrap();
assert_eq!(back, sym);
# }
```

The wire form is exactly the id — a stable contract verified by a property test
asserting `to_string(&sym) == sym.as_u32().to_string()` for every id, alongside a
full serialize/deserialize round-trip property. A symbol is only meaningful with
the interner that issued it, so persist symbols alongside that interner.

### `Symbol::from_u32`

The inverse of `as_u32`, for rebuilding a symbol from an id stored outside the
interner (a file, a wire message, an external table):

```rust
use intern_lang::Symbol;

assert_eq!(Symbol::from_u32(7).map(|s| s.as_u32()), Some(7));
assert_eq!(Symbol::from_u32(0), None); // zero is never a valid id
```

It rebuilds only the handle; whether the id names anything is decided by
`resolve`, which returns `None` for an out-of-range id.

### Feature freeze

`docs/API.md` now carries a [Stability](https://github.com/jamesgober/intern-lang/blob/main/docs/API.md#stability)
section: the documented items are the complete public surface, and none will be
added, removed, or changed in signature or behaviour before `1.0.0`. Two
extension points are reserved deliberately — `InternError` is `#[non_exhaustive]`,
and SemVer's additive rule still permits new items later — but nothing in the
current surface will be taken away or altered. The MSRV stays at 1.85.

## Breaking changes

**None.** `try_intern`, `InternError`, `Symbol::from_u32`, and the `serde`
serialisation are all additive.

## Verification

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

```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
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo +1.85 test --all-features
cargo audit
cargo deny check
```

All green. Counts at this tag:

- Default features: 70 tests (27 unit + 17 integration/property + 26 doctests).
- `--all-features`: 75 tests (adds the 5-test `serde` round-trip suite).
- `--no-default-features`: the single-threaded interner builds clean as `no_std`.

## What's next

- **1.0.0 — API freeze.** No new public API; the frozen surface is marked stable
  with the SemVer promise, and the full property-test and benchmark suite is run
  green on all three platforms.

## Installation

```toml
[dependencies]
intern-lang = "0.4"

# optional: serde support for `Symbol`
intern-lang = { version = "0.4", features = ["serde"] }
```

MSRV: Rust 1.85.

## Documentation

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

---

**Full diff:** [`v0.3.0...v0.4.0`](https://github.com/jamesgober/intern-lang/compare/v0.3.0...v0.4.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/intern-lang/blob/main/CHANGELOG.md#040---2026-06-20).