Releases: jamesgober/span-lang
Release list
v1.0.0 — Stable API
span-lang v1.0.0 — Stable
The API freeze. v1.0.0 stabilises the surface built across the 0.x series and
puts it under a SemVer promise: no public item is removed or changed incompatibly
before 2.0.0. There are no code changes from 0.4.0 — the position types, the
span arithmetic, the UTF-8-correct resolver, the line index, and Spanned<T> are
exactly as shipped, now declared final and verified green on all three platforms.
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.
The stable surface
Five public types, every item documented with a runnable example:
BytePos— a 4-byteCopybyte offset;new,to_u32,to_usize,u32
conversions,Display.Span— a packed half-openstart..endbyte range;new(orders its
arguments to upholdstart <= end),empty,start,end,len,is_empty,
contains, an associative and commutativemerge, total ordering,Display.LineCol— a 1-based line/column whose column counts Unicode scalar values;
new,line:colDisplay.LineIndex— built once per source;line_col(byte → coordinate, total,
O(log lines)),offset(the checked inverse),line_span(a line's text
range, terminator trimmed),line_count.Spanned<T>— a value paired with its span;new,map,as_ref,
value @ start..endDisplay.
An optional serde feature serialises every position type, with Span
deserialisation routed through its constructor so the start <= end invariant
holds for untrusted input.
The SemVer promise
In force from 1.0.0 and recorded in
docs/API.md:
- No public item is removed or changed incompatibly before
2.0.0. Additions, if
any, are new items only. - The
serdewire representations ofBytePos,Span,LineCol, and
Spanned<T>are part of the contract and will not change incompatibly within a
major version. - The
Displayformats —123,4..10,2:5,value @ 4..10— are stable. - MSRV is
1.85; an MSRV increase is treated as a minor change.
Correctness and performance, verified
The section-4 invariants are held by property tests cross-checked against a naive
reference resolver over UTF-8 input including multi-byte characters and CRLF:
Span::new orders its arguments; merge is commutative, associative, and exact;
forward resolution matches a full character scan on every offset; byte ↔ (line,
col) round-trips for every valid position; and line spans tile the source without
ever containing a terminator.
Performance is backed by criterion, with the O(log lines) lookup demonstrated
by scaling, not asserted. Representative means:
| Operation | Windows x86_64 | Linux x86_64 (WSL2) |
|---|---|---|
Span::merge |
~0.57 ns | ~0.57 ns |
LineIndex::offset |
~2.5 ns | ~2.0 ns |
LineIndex::line_col |
~8.7 ns | ~8.8 ns |
LineIndex::line_col @ 100 000 lines |
~22 ns | ~19 ns |
A 1 000-fold increase in line count costs roughly 2.6× the lookup time —
logarithmic, as the binary search guarantees by construction.
Breaking changes
None. v1.0.0 is byte-for-byte the 0.4.0 surface. Code written against
0.4.0 compiles and behaves identically; the only change is the stability
guarantee.
Verification
Verified green on Windows x86_64 (Rust stable 1.95.x, MSRV 1.85) and Linux x86_64
(WSL2 Ubuntu); macOS x86_64/ARM64 via the 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
cargo build --no-default-features --features serde
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 on both platforms. Counts at this tag:
- Default features: 32 unit + 7 property tests + 26 doctests.
--all-features: 32 unit + 7 property tests + 7 serde tests + 26 doctests.
What's next
The surface is frozen. Future releases are documentation, additional tests, and
internal optimisation under the SemVer promise — no public API change before a
hypothetical 2.0.0.
Installation
[dependencies]
span-lang = "1"
# With serialisation:
span-lang = { version = "1", features = ["serde"] }MSRV: Rust 1.85.
Documentation
Full diff: v0.4.0...v1.0.0.
Changelog: CHANGELOG.md.
v0.4.0 — Spanned<T>, serde, feature freeze
span-lang v0.4.0 — Spanned<T>, serde, feature freeze
The surface, completed and frozen. v0.4.0 adds the last two pieces of the
0.x design — the Spanned<T> wrapper that carries a value alongside its span, and
an optional serde feature that round-trips every position type — and then
declares the public API frozen. From here to 1.0.0 there are no new or changed
public items, only documentation, tests, and internal optimisation. 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.4.0
Spanned<T> — a value and where it came from
A parser threads spans through every token and node. Spanned<T> is the wrapper
that lets a value carry its location without the value type itself knowing
anything about positions — Copy whenever T is, ordered by span first so a
slice of nodes sorts into source order.
use span_lang::{Span, Spanned};
// A lexeme: the text "10" at bytes 2..4.
let raw = Spanned::new(Span::new(2, 4), "10");
// `map` lifts it into a typed node, keeping the span.
let parsed = raw.map(|s| s.parse::<u32>().unwrap());
assert_eq!(parsed.value, 10);
assert_eq!(parsed.span, Span::new(2, 4));map transforms the value and preserves the span; as_ref borrows the value
(yielding Spanned<&T>, mirroring Option::as_ref) so you can inspect or map it
without consuming the original; Display renders value @ start..end.
serde — round-tripping positions
Behind the opt-in serde feature, every position type derives Serialize and
Deserialize:
BytePosis#[serde(transparent)]— a bare number on the wire, not a wrapper
object.LineColandSpanned<T>serialise as structs of their public fields;
Spanned<T>round-trips for any serialisableT.Spanhas a hand-writtenDeserializethat routes the incoming{ start, end }
throughSpan::new, so a span read from an untrusted source upholds the
start <= endinvariant exactly as a constructed one does. An inverted pair on
the wire is normalised, never accepted as-is — input is validated at the
boundary, not after.
use span_lang::Span;
// Inverted on the wire → normalised on the way in.
let s: Span = serde_json::from_str(r#"{"start":10,"end":4}"#).unwrap();
assert_eq!(s, Span::new(4, 10));
assert!(s.start() <= s.end());tests/serde.rs round-trips BytePos, Span, LineCol, and Spanned<T> (over
both an owned String and a scalar) through serde_json, and asserts the
inverted-span normalisation. LineIndex is deliberately not serialisable: it
borrows a source and is rebuilt from text, not restored from bytes.
The surface is frozen
With Spanned<T> and serde in place, the public API is complete and now
frozen. docs/API.md carries a Stability
section recording the frozen surface and the SemVer promise from 1.0.0: no
public item removed or changed incompatibly before 2.0.0; the serde wire
representations and the Display formats are part of the contract; an MSRV
increase is a minor change. The remaining road to 1.0.0 is the full
property-test and benchmark suite green on all three platforms, and nothing else.
Breaking changes
None. Spanned<T> and the serde feature are purely additive; the feature is
off by default and has no effect on the API when disabled. Every v0.3.0 program
compiles and behaves identically.
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
cargo build --no-default-features --features serde
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo +1.85 build --all-features
cargo audit
cargo deny checkAll green. Counts at this tag:
- Default features: 32 unit + 7 property tests + 26 doctests.
--all-features: 32 unit + 7 property tests + 7 serde tests + 26 doctests.
What's next
- 1.0.0 — API freeze. The frozen surface stabilised and tagged:
docs/API.md
marked stable, the SemVer promise in force, and the full property-test and
benchmark suite green on Linux, macOS, and Windows.
Installation
[dependencies]
span-lang = "0.4"
# With serialisation:
span-lang = { version = "0.4", features = ["serde"] }MSRV: Rust 1.85.
Documentation
Full diff: v0.3.0...v0.4.0.
Changelog: CHANGELOG.md.
v0.3.0 — Source coordinate mapping & line index
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 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.
v0.2.0 — Core position & span types
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.
use span_lang::BytePos;
let at = BytePos::new(42);
let src = b"...";
let _ = at.to_usize(); // ready to index a byte sliceSpan — 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.
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 (chars),
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.
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::newalways yieldsstart <= end, ordering its arguments.mergeis commutative, associative, and returns exactlymin(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/opLineIndex::offset— ~2.5 ns/opLineIndex::line_col— ~8.7 ns/opLineIndex::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:
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 checkAll 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
[dependencies]
span-lang = "0.2"MSRV: Rust 1.85.
Documentation
Full diff: v0.1.0...v0.2.0.
Changelog: CHANGELOG.md.
v0.1.0 — Scaffold
span-lang v0.1.0 — Scaffold
The repository bootstrap. v0.1.0 stands up the crate, the tooling, and the
quality gates the implementation is built on — and nothing more. There is no
domain logic in this release on purpose: the position and span types, the UTF-8
line/column resolver, the line index, and the Spanned wrapper land across the
0.x series, each behind a passing gate. This tag exists so that the first line of
real code is written against a green CI, a fixed toolchain contract, and a
documented plan, rather than into an empty directory.
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: loading source is source-lang, rendering an error
that points at a span is diag-lang. It is the bottom crate of the -lang
language-construction family; everything above it references its types on every
token and every error.
What's in 0.1.0
Crate manifest and toolchain contract
Cargo.toml declares the full publish metadata — description, keywords,
categories, repository, homepage, dual Apache-2.0 OR MIT license, and authors —
on Rust 2024 edition with MSRV 1.85. The feature set is minimal and additive:
std on by default, with an opt-in serde feature reserved for serialising the
position types as they land. The crate carries no first-party dependencies, so it
builds and tests standalone today.
Quality gates, wired before the code
The CI matrix (.github/workflows/ci.yml) runs the full gate on Linux, macOS,
and Windows against both stable and the 1.85 MSRV: cargo fmt --check, clippy on
default and all features with -D warnings, the test suite on both feature sets,
and a documentation build with -D warnings. A separate security job runs
cargo audit and cargo deny check. deny.toml, clippy.toml, and
rustfmt.toml pin the license allow-list, the lint profile, and the
import-grouping style. The crate root sets #![forbid(unsafe_code)] and
#![deny(missing_docs)] from the first commit, and is no_std-ready behind the
std feature.
Documentation and plan
README.md and docs/API.md describe the intended surface and mark each item
still planned, so the public contract is legible before it is implemented.
dev/DIRECTIVES.md records the definition of done and the project-specific
invariants the property tests will hold to: a span's start never exceeds its end;
merge is associative and commutative and covers exactly the smallest enclosing
range; line/column resolution is UTF-8-correct and round-trips against a naive
reference. dev/ROADMAP.md front-loads the hard part — the UTF-8 line/column
resolver and its O(log lines) index at v0.2.0 — and carries an anti-deferral
rule: no listed hard task slips to a later phase without the file recording the
move and the reason.
Breaking changes
None. This is the first tag.
Verification
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo test --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo +1.85 build --all-features
cargo audit
cargo deny checkCounts at this tag: 1 unit test (a build smoke test), 0 doctests — there is no
public surface to exercise yet.
What's next
- 0.2.0 — Core position & span types.
BytePos, a compactCopySpan, and
the UTF-8-correct line/column resolver with itsO(log lines)index. Invariants
come under property tests against a naive reference. This is the hard part, and
it is not deferred.
Installation
[dependencies]
span-lang = "0.1"MSRV: Rust 1.85.
Documentation
Changelog: CHANGELOG.md.