Skip to content

v0.3.0 — Source coordinate mapping & line index

Pre-release
Pre-release

Choose a tag to compare

@jamesgober jamesgober released this 19 Jun 06:25

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.

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:

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

[dependencies]
span-lang = "0.3"

MSRV: Rust 1.85.

Documentation


Full diff: v0.2.0...v0.3.0.
Changelog: CHANGELOG.md.