span-lang 0.4.0

Source positions, spans, and byte-to-line/col mapping for language tooling.
Documentation
# span-lang v0.4.0 — Spanned<T>, serde, feature freeze

**The surface, completed and frozen.** v0.4.0 adds the last two pieces of the
0.x design — the `Spanned<T>` wrapper that carries a value alongside its span, and
an optional `serde` feature that round-trips every position type — and then
declares the public API frozen. From here to `1.0.0` there are no new or changed
public items, only documentation, tests, and internal optimisation. No breaking
changes.

## What is span-lang?

The source-position substrate for language tooling. It defines the small,
copyable coordinate types that a lexer, parser, and diagnostic renderer all
share: a byte position, a byte-offset span, a resolved line/column, and the index
that maps between them — correctly over UTF-8, across `\n` and `\r\n`. It owns
positions and nothing else.

## What's new in 0.4.0

### `Spanned<T>` — a value and where it came from

A parser threads spans through every token and node. `Spanned<T>` is the wrapper
that lets a value carry its location without the value type itself knowing
anything about positions — `Copy` whenever `T` is, ordered by span first so a
slice of nodes sorts into source order.

```rust
use span_lang::{Span, Spanned};

// A lexeme: the text "10" at bytes 2..4.
let raw = Spanned::new(Span::new(2, 4), "10");

// `map` lifts it into a typed node, keeping the span.
let parsed = raw.map(|s| s.parse::<u32>().unwrap());
assert_eq!(parsed.value, 10);
assert_eq!(parsed.span, Span::new(2, 4));
```

`map` transforms the value and preserves the span; `as_ref` borrows the value
(yielding `Spanned<&T>`, mirroring `Option::as_ref`) so you can inspect or map it
without consuming the original; `Display` renders `value @ start..end`.

### `serde` — round-tripping positions

Behind the opt-in `serde` feature, every position type derives `Serialize` and
`Deserialize`:

- `BytePos` is `#[serde(transparent)]` — a bare number on the wire, not a wrapper
  object.
- `LineCol` and `Spanned<T>` serialise as structs of their public fields;
  `Spanned<T>` round-trips for any serialisable `T`.
- `Span` has a hand-written `Deserialize` that routes the incoming `{ start, end }`
  through `Span::new`, so a span read from an untrusted source upholds the
  `start <= end` invariant exactly as a constructed one does. An inverted pair on
  the wire is normalised, never accepted as-is — input is validated at the
  boundary, not after.

```rust
use span_lang::Span;

// Inverted on the wire → normalised on the way in.
let s: Span = serde_json::from_str(r#"{"start":10,"end":4}"#).unwrap();
assert_eq!(s, Span::new(4, 10));
assert!(s.start() <= s.end());
```

`tests/serde.rs` round-trips `BytePos`, `Span`, `LineCol`, and `Spanned<T>` (over
both an owned `String` and a scalar) through `serde_json`, and asserts the
inverted-span normalisation. `LineIndex` is deliberately not serialisable: it
borrows a source and is rebuilt from text, not restored from bytes.

### The surface is frozen

With `Spanned<T>` and `serde` in place, the public API is complete and now
**frozen**. `docs/API.md` carries a [Stability](https://github.com/jamesgober/span-lang/blob/main/docs/API.md#stability)
section recording the frozen surface and the SemVer promise from `1.0.0`: no
public item removed or changed incompatibly before `2.0.0`; the serde wire
representations and the `Display` formats are part of the contract; an MSRV
increase is a minor change. The remaining road to `1.0.0` is the full
property-test and benchmark suite green on all three platforms, and nothing else.

## Breaking changes

**None.** `Spanned<T>` and the `serde` feature are purely additive; the feature is
off by default and has no effect on the API when disabled. Every v0.3.0 program
compiles and behaves identically.

## Verification

Run on Windows x86_64, Rust stable 1.95.x and MSRV 1.85; identical commands pass
on Linux (WSL2 Ubuntu) and via the configured CI matrix:

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

All green. Counts at this tag:

- Default features: 32 unit + 7 property tests + 26 doctests.
- `--all-features`: 32 unit + 7 property tests + 7 serde tests + 26 doctests.

## What's next

- **1.0.0 — API freeze.** The frozen surface stabilised and tagged: `docs/API.md`
  marked stable, the SemVer promise in force, and the full property-test and
  benchmark suite green on Linux, macOS, and Windows.

## Installation

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

# With serialisation:
span-lang = { version = "0.4", features = ["serde"] }
```

MSRV: Rust 1.85.

## Documentation

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

---

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