// lesson: utf-8-from-scratch

UTF-8 From Scratch

Your editor's buffer will hold bytes. The user's text is characters โ€” more precisely Unicode codepoints, integers from U+0000 to U+10FFFF. The bridge is UTF-8, and an editor cannot outsource this bridge: caret movement, backspace, hit testing, and clipboard exchange all need to know where one codepoint ends and the next begins. Get it wrong and pressing Backspace after typing "รฉ" deletes half of the รฉ, leaving a mangled byte that renders as garbage forever after. So we build the decoder ourselves, to specification, and the tests hold you to the specification's sharp edges.

The encoding, in one table

UTF-8 (Thompson & Pike, 1992 โ€” famously sketched on a diner placemat) encodes each codepoint in 1โ€“4 bytes. The first byte's high bits announce the sequence length; continuation bytes always start 10:

codepoint range      bytes  bit layout
U+0000  .. U+007F    1      0xxxxxxx
U+0080  .. U+07FF    2      110xxxxx 10xxxxxx
U+0800  .. U+FFFF    3      1110xxxx 10xxxxxx 10xxxxxx
U+10000 .. U+10FFFF  4      11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Decoding is: read the lead byte, learn the length, shift in the low six bits of each continuation byte. รฉ (U+00E9) is 0xC3 0xA9: 110_00011 + 10_101001 โ†’ 00011_101001 = 0xE9.

Three properties made UTF-8 conquer the world, and each one matters to your editor directly:

  • ASCII is unchanged. Every ASCII file is already valid UTF-8, and bytes < 0x80 never appear inside a multi-byte sequence. Your lesson-8 line index can scan for '\n' bytes without decoding anything.
  • Self-synchronizing. From any byte position you can find the nearest codepoint boundary by skipping backward over at most three 10xxxxxx bytes. Caret movement never has to decode from the start of the file โ€” this is the entire subject of the second challenge.
  • Byte-wise sort order equals codepoint order. Not editor-critical, but it's why UTF-8 works in filenames and databases without special collation shims.

The sharp edges

A decoder that only handles well-formed input is half a decoder. Editors open arbitrary files: truncated downloads, Latin-1 mislabeled as UTF-8, binary garbage. The Unicode standard is precise about what's invalid, and two rules exist for security reasons, not tidiness:

  • Overlong encodings are forbidden. The byte pair 0xC0 0xAF mechanically decodes to 0x2F โ€” / โ€” but the two-byte form of a codepoint that fits in one byte is illegal. History's reason: pre-2001 decoders that accepted overlongs let attackers smuggle ../ past path-validation code that only checked for the one-byte spelling. Your decoder must reject any sequence longer than its codepoint needs (that's 0xC0/0xC1 outright, 0xE0 0x80-0x9F, 0xF0 0x80-0x8F).
  • Surrogates (U+D800โ€“U+DFFF) are forbidden in UTF-8. They are UTF-16 plumbing, not characters; three-byte sequences encoding them (0xED 0xA0-0xBF ...) are invalid. Also invalid: anything above U+10FFFF (leads 0xF5โ€“0xFF), stray continuation bytes, and sequences cut off early.

What should an editor do with invalid bytes? The universal answer (HTML5, Rust's String::from_utf8_lossy, every serious editor): replace each offending byte with U+FFFD ๏ฟฝ, the replacement character, and carry on. We adopt the simplest standard-sanctioned policy: an invalid or truncated sequence consumes exactly one byte and yields U+FFFD; then decoding resumes at the next byte. One byte, not "the whole broken sequence" โ€” restarting at the very next byte is what lets a single corrupted byte in the middle of a CJK file cost one ๏ฟฝ instead of desynchronizing the rest of the line.

A design note on types: the decoder returns char32_t (a codepoint is an integer โ€” C++'s dedicated type documents the intent), and takes std::string_view โ€” a non-owning pointer+length pair. All the text plumbing from here on accepts string_view, so it works on a std::string, a piece-table span, or a memory-mapped file without copying.

Boundaries without decoding

The caret challenge is subtler than it looks. "Move left one character" must work even when the text isn't valid UTF-8 โ€” the user can place the caret in a file full of garbage, and Backspace still has to delete something sensible. The self-synchronization property gives the algorithm: from byte index i, step back over continuation bytes (at most 3 โ€” anything more is malformed anyway), landing on a lead byte. Then sanity-check: does that lead byte actually claim enough length to reach past where you started? If yes, it's the boundary; if no, the bytes were malformed, and the single byte before i is treated as its own one-column unit โ€” the ๏ฟฝ policy again, applied to navigation. The same logic runs forward for "move right".

This byte-level view is deliberately humble: real cursor movement also knows about combining marks (e + U+0301 = รฉ as two codepoints), emoji joined by zero-width joiners, and other grapheme-cluster rules โ€” that's another layer (with its own Unicode annex, UAX #29) built on top of codepoint boundaries, and libraries like ICU provide it. Codepoint boundaries are the load-bearing floor, and they're ours to get exactly right.

โ€บ A Strict Decoder

15 pts

Implement decode_utf8, encode_utf8, and decode_all.

decode_utf8(s, i) decodes the sequence starting at byte i (the caller guarantees i < s.size()), returning the codepoint and the number of bytes consumed. On any invalid input โ€” stray continuation byte, bad lead, missing/malformed continuation, overlong form, surrogate, value above U+10FFFF, or truncation at end of input โ€” return {0xFFFD, 1}: replace one byte, resume after it.

A clean implementation order: find the expected length from the lead byte; verify every continuation byte matches 10xxxxxx (and is present); assemble the value; then reject it if it's a surrogate, above U+10FFFF, or shorter-encodable (assembled value below the minimum for that length โ€” 0x80 for 2 bytes, 0x800 for 3, 0x10000 for 4).

encode_utf8(cp, out) writes 1โ€“4 bytes into out and returns the count (the caller guarantees cp is a valid scalar value โ€” you'll use this for keyboard input, which is always valid). decode_all maps a whole string through decode_utf8.

Mind char signedness: byte values โ‰ฅ 0x80 are negative as char. Route everything through unsigned char before comparing.

Log in to submit a solution and earn points.

โ€บ Boundaries for the Caret

12 pts

Implement the two functions caret movement is built on. They must behave sensibly on malformed input too โ€” one byte of garbage is one caret position, per the ๏ฟฝ policy.

  • is_continuation(b): true iff b has the bit pattern 10xxxxxx.
  • next_boundary(s, i): the first codepoint boundary strictly after i (caller guarantees i < s.size()). If s[i] is a lead byte declaring length L, the boundary is at i + L โ€” but stop early at any byte that is not a continuation, and never run past the end of the string. If s[i] is itself a continuation byte (malformed here), the boundary is i + 1.
  • prev_boundary(s, i): the last boundary strictly before i (caller guarantees 0 < i <= s.size()). Step back over at most 3 continuation bytes to find a lead candidate at position j. If s[j] is still a continuation byte (malformed run), return i - 1. Otherwise check the lead's declared length L (1 for ASCII and other non-lead values): if j + L >= i the sequence covers i, so return j; if it falls short (e.g. "a" followed by a stray continuation byte), the bytes between are orphans โ€” return i - 1.

Declared lengths: 0xxxxxxx โ†’ 1, 110xxxxx โ†’ 2, 1110xxxx โ†’ 3, 11110xxx โ†’ 4; treat anything else (11111xxx) as 1.

Log in to submit a solution and earn points.