# 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);
&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):
| 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).