// 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 offset i ends a line, and offset i + 1 starts 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 greatest L with starts[L] <= offset โ€” one std::upper_bound, minus one. Every offset in the document (including size(), 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 - 1 excludes 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 n bytes at pos: every start strictly greater than pos slides right by n. (A start equal to pos stays: 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 index k mints a brand-new start at pos + k + 1, spliced in sorted position.
  • Erase of len bytes at pos: starts inside (pos, pos + len] correspond to newlines that just vanished โ€” remove them. Starts beyond pos + len slide left by len.

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 pts

The 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 containing offset. Callers guarantee offset <= text.size(); remember offset == 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 pts

The incremental half: a LineIndex class that is told about every edit and shifts instead of rebuilding.

  • LineIndex(text) builds the initial array (reuse your line_starts logic).
  • on_insert(pos, s): shift starts > pos right by s.size(); splice in a new start at pos + k + 1 for each newline s[k], keeping the array sorted.
  • on_erase(pos, len): drop starts in (pos, pos + len]; shift starts > pos + len left by len. (len == 0 must 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.