span-lang 0.4.0

Source positions, spans, and byte-to-line/col mapping for language tooling.
Documentation
# span-lang v0.2.0 — Core position & span types

**The correctness foundation.** v0.2.0 is the hard part of the roadmap, built
first rather than deferred: the byte position and span value types, and the
UTF-8-correct line/column resolver with its `O(log lines)` line index that every
diagnostic in the ecosystem will lean on. Four public types land — `BytePos`,
`Span`, `LineCol`, and `LineIndex` — each with rustdoc and a runnable example,
and the project invariants come under property tests cross-checked against a
naive reference resolver. No prior public API to break; this is the first release
with a surface.

## 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: it does not load source and does not render
diagnostics, which is what lets every layer above depend on it without inheriting
I/O or formatting.

## What's new in 0.2.0

### `BytePos` — the byte offset

A `Copy` newtype over a `u32`, so it is cheap to move and packs two-to-a-span. The
32-bit width bounds a single source to 4 GiB, the addressing envelope language
front-ends use. It exposes `new`, `to_u32`, `to_usize`, `u32` conversions both
ways, and a `Display` that prints the bare offset.

```rust
use span_lang::BytePos;

let at = BytePos::new(42);
let src = b"...";
let _ = at.to_usize(); // ready to index a byte slice
```

### `Span` — a packed half-open byte range

Two packed offsets, eight bytes, `Copy`. The range is half-open (`start`
included, `end` excluded), so the length is exactly `end - start` and adjacent
spans do not overlap. The `start <= end` invariant is enforced at construction:
`Span::new` orders its two arguments, so a span can never be built inverted and is
never constructed by a panicking path.

```rust
use span_lang::Span;

let a = Span::new(4, 10);
let b = Span::new(8, 14);
assert_eq!(a.merge(b), Span::new(4, 14)); // smallest covering range

// Folding a node's children into the node's own span.
let children = [Span::new(10, 12), Span::new(4, 6), Span::new(20, 25)];
let parent = children.iter().copied().reduce(Span::merge).unwrap();
assert_eq!(parent, Span::new(4, 25));
```

`merge` is commutative and associative, so the order spans are combined in never
changes the result — both properties are property-tested.

### `LineCol` — a resolved coordinate

A 1-based line and column. The column counts **Unicode scalar values** (`char`s),
not bytes, not UTF-16 units, and not grapheme clusters, so it never lands inside a
multi-byte sequence. It formats as `line:col`, the convention editors and
compilers display.

### `LineIndex` — byte ↔ line/column in `O(log lines)`

Built once per source with a single linear scan that records where each line
begins. After that, `line_col` is a binary search over those line starts followed
by a character count within the one located line; `offset` is the checked inverse.
Neither lookup allocates.

```rust
use span_lang::{BytePos, LineCol, LineIndex};

let index = LineIndex::new("let x = 1;\nlet y = 2;\n");
assert_eq!(index.line_col(BytePos::new(11)), LineCol::new(2, 1));
assert_eq!(index.offset(LineCol::new(2, 1)), Some(BytePos::new(11)));
```

`line_col` is **total**: an offset past the end clamps to the end, and an offset
inside a multi-byte character rounds down to the character start, so resolution
never panics and never returns a coordinate that is not a real position. `offset`
returns `None` for a coordinate that does not exist (line or column `0`, a line
past the last, a column past the end of its line). A `\r\n` is one line break, the
empty string is one line, and a source with no trailing newline keeps its final
unterminated line.

### Correctness — property tests against a naive reference

`tests/properties.rs` holds the section-4 invariants over a wide input space, with
sources drawn from an alphabet of ASCII, tabs, bare `\r`, `\r\n`, `\n`, and two-,
three-, and four-byte characters:

- `Span::new` always yields `start <= end`, ordering its arguments.
- `merge` is commutative, associative, and returns exactly `min(starts)..max(ends)`.
- Forward resolution agrees with a full naive character scan on every byte offset.
- Byte → (line, col) → byte round-trips for every valid position.

### Performance — benchmarks recorded

`benches/bench.rs` covers the hot paths with `criterion`. Latest local means
(Windows x86_64, Rust stable):

- `Span::merge`~0.6 ns/op
- `LineIndex::offset`~2.5 ns/op
- `LineIndex::line_col`~8.7 ns/op
- `LineIndex::new`~8.4 µs to index 1 000 lines

The forward lookup uses an ASCII fast path (a length read instead of a decode
loop) that the property tests prove agrees with the general path on every input.

## Breaking changes

**None.** v0.1.0 had no public API; this is the first surface. It is additive
across the remaining 0.x series and frozen at 1.0.

## 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
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: 21 unit + 21 doctests.
- `--all-features`: 21 unit + 6 property tests + 21 doctests.

## What's next

- **0.3.0 — Source coordinate mapping & line index.** Prove the `O(log lines)`
  lookup by benchmark scaling rather than by claim, and harden the empty-source,
  no-trailing-newline, and CRLF edges with their own coverage.

## Installation

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

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.1.0...v0.2.0`](https://github.com/jamesgober/span-lang/compare/v0.1.0...v0.2.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/span-lang/blob/main/CHANGELOG.md#020---2026-06-18).