// lesson: the-line-index
The Line Index
The piece table answers "what byte is at offset 12,345?" โ but nothing an editor displays is phrased in offsets. The screen shows lines; the scrollbar is a fraction of lines; clicking needs "which line is under y"; damage tracking (lesson 12) wants "which rows does this edit dirty?". Between the flat byte world and the line world sits one small, crucial structure: the line index โ a sorted array of the byte offsets where each line begins.
The definition that makes every edge case fall out cleanly:
- Offset 0 is always a line start (even in an empty document โ an empty document has one line, which is empty).
- Every
'\n'at offsetiends a line, and offseti + 1starts the next one. Consequently"a\nb"has starts[0, 2], and"a\n"has starts[0, 2]too โ the second line exists and is empty. This matches what every editor shows: a file ending in a newline has one final empty line your caret can sit on.
Two lookups then run the whole show, both trivial over a sorted array:
line_of(offset): the greatestLwithstarts[L] <= offsetโ onestd::upper_bound, minus one. Every offset in the document (includingsize(), where the end-of-document caret sits) belongs to exactly one line. Note the convention this implies: the offset of a'\n'still belongs to the line it terminates.line_span(L): the line's content is[starts[L], starts[L+1] - 1)for interior lines (the- 1excludes the newline), and[starts[L], text.size())for the last one.
A pleasant consequence of UTF-8's design (lesson 6): since bytes below
0x80 never occur inside a multi-byte sequence, scanning raw bytes for
0x0A finds exactly the real newlines โ the line index never needs to
decode anything. (Full Unicode also defines exotic breaks โ U+2028 LINE
SEPARATOR and friends โ which real editors mostly ignore for line
structure, and so do we. Windows CRLF? Treat the '\r' as an ordinary
byte of line content and strip it at file load/save time; normalizing at
the boundary keeps '\n'-only logic everywhere inside, which is exactly
what VS Code and friends do.)
Keeping it current: shift, don't rebuild
Rebuilding the index is O(document) โ fine on load, absurd per keystroke on a big file (type "hello" and you've re-scanned 500 MB five times). But look at what an edit actually does to the array:
- Insert of
nbytes atpos: every start strictly greater thanposslides right byn. (A start equal toposstays: inserting at the head of a line prepends to that line; its start doesn't move.) If the inserted text itself contains newlines, each'\n'at inserted indexkmints a brand-new start atpos + k + 1, spliced in sorted position. - Erase of
lenbytes atpos: starts inside(pos, pos + len]correspond to newlines that just vanished โ remove them. Starts beyondpos + lenslide left bylen.
Both are O(lines after the edit point) array traffic and O(newlines in the change) new entries โ independent of document size. The tests keep you honest with a torture loop comparing your incremental index against a from-scratch rebuild after every random edit; if the two ever disagree, you'll get the exact reproducing sequence for free (it's deterministic).
This "notify the index after each document edit" pattern โ the piece
table doesn't know the index exists; the editor calls on_insert/
on_erase on both โ is the shape all derived state takes in this
course: the layout cache (lesson 9) and damage list (lesson 12) hang off
the same notifications.
โบ Line Starts
12 ptsThe read-only half: build the index from text, and the two lookups.
line_starts(text): offsets of every line start, per the definition above. Always non-empty;[0]for an empty text.line_of(starts, offset): index of the line containingoffset. Callers guaranteeoffset <= text.size(); rememberoffset == text.size()belongs to the last line.line_span(starts, text_size, line): half-open byte range of the line's content, newline excluded.
Log in to submit a solution and earn points.
โบ An Index That Keeps Up
15 ptsThe incremental half: a LineIndex class that is told about every edit
and shifts instead of rebuilding.
LineIndex(text)builds the initial array (reuse yourline_startslogic).on_insert(pos, s): shift starts> posright bys.size(); splice in a new start atpos + k + 1for each newlines[k], keeping the array sorted.on_erase(pos, len): drop starts in(pos, pos + len]; shift starts> pos + lenleft bylen. (len == 0must be a no-op.)- Lookups as before:
line_count,start_of(line),line_of(offset).
The final test performs 300 scripted random edits, comparing your index to a rebuilt-from-scratch one after each. Any divergence fails immediately โ and because the "random" sequence is a fixed LCG, a failure reproduces identically every run, which is exactly how you'd debug it.
Log in to submit a solution and earn points.