# span-lang v0.3.0 — Source coordinate mapping & line index

**Proof, not claim.** v0.2.0 built the line index; v0.3.0 verifies it. The
`O(log lines)` forward lookup is now demonstrated by a benchmark that scales the
line count across three orders of magnitude, the empty-source / no-trailing-newline
/ CRLF edges have dedicated coverage, and the index gains the one mapping a
diagnostic renderer needs that point lookups could not provide: the byte span of a
line's text. One additive method, `LineIndex::line_span`. 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.3.0

### `LineIndex::line_span` — a line's text range

A point lookup tells you where an offset is; rendering a diagnostic also needs the
*extent* of the line, to print it and underline the span within it. `line_span`
returns the byte [`Span`] of a 1-based line's text with its terminator trimmed —
the trailing `\n`, and a `\r` before it for a `\r\n` ending — so the span slices
the source to exactly the displayable line.

```rust
use span_lang::LineIndex;

let src = "first\r\nsecond\nthird";
let index = LineIndex::new(src);

let render = |line| {
    let s = index.line_span(line).unwrap();
    &src[s.start().to_usize()..s.end().to_usize()]
};
assert_eq!(render(1), "first");  // CRLF trimmed
assert_eq!(render(2), "second"); // LF trimmed
assert_eq!(render(3), "third");  // final unterminated line
```

The line's start is found in `O(log lines)`; trimming the terminator inspects at
most two bytes, so the lookup is allocation-free and never re-scans the source.
A line past the last, or line `0`, returns `None`.

### `O(log lines)` — verified by benchmark scaling

The roadmap's exit criterion was to prove the lookup is logarithmic by
measurement, not assertion. The new `line_col_scaling` benchmark resolves a
position at a fixed relative depth across sources of 100, 1 000, 10 000, and
100 000 lines. A linear scan would slow by 1 000× across that range; a binary
search adds a near-constant step per tenfold. Measured (Windows x86_64, Rust
stable):

| Lines | `line_col` | vs. 100-line baseline |
|------:|-----------:|----------------------:|
| 100 | ~8.5 ns | 1.0× |
| 1 000 | ~11.1 ns | 1.3× |
| 10 000 | ~17.6 ns | 2.1× |
| 100 000 | ~22.0 ns | 2.6× |

A 1 000-fold increase in line count costs 2.6× the time — logarithmic, as the
binary search guarantees by construction.

### Line-ending edges, covered

The empty string, a source with no trailing newline, `\r\n`, a lone `\r`,
consecutive newlines, and a single newline each have a dedicated test:

- A lone `\r` not followed by `\n` is now documented and tested as an ordinary
  character, not a line break — matching how language front-ends split source.
- Consecutive newlines produce separate empty lines; a single `\n` is two lines;
  the empty string is one.
- `line_span` is checked over LF, CRLF, and unterminated final lines, and a
  property test asserts that line spans tile the source in order and never contain
  a terminator.

## Breaking changes

**None.** `line_span` is purely additive; every v0.2.0 program compiles and
behaves identically. The surface remains additive across the rest of the 0.x
series and freezes 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 bench --bench bench
cargo audit
cargo deny check
```

All green. Counts at this tag:

- Default features: 28 unit + 22 doctests.
- `--all-features`: 28 unit + 7 property tests + 22 doctests.

## What's next

- **0.4.0 — `Spanned<T>`, serde, feature freeze.** A `Spanned<T>` wrapper pairing a
  value with its `Span`, an optional `serde` feature that round-trips every public
  position type (with invariant-preserving deserialisation), and the public
  surface declared frozen ahead of 1.0.

## Installation

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

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