v0.3.0 — Source coordinate mapping & line index
Pre-releasespan-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.
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 lineThe 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
\rnot followed by\nis 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
\nis two lines;
the empty string is one. line_spanis 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:
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 checkAll 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. ASpanned<T>wrapper pairing a
value with itsSpan, an optionalserdefeature that round-trips every public
position type (with invariant-preserving deserialisation), and the public
surface declared frozen ahead of 1.0.
Installation
[dependencies]
span-lang = "0.3"MSRV: Rust 1.85.
Documentation
Full diff: v0.2.0...v0.3.0.
Changelog: CHANGELOG.md.