// 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
10xxxxxxbytes. 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 0xAFmechanically 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's0xC0/0xC1outright,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 (leads0xF5โ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 ptsImplement 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 ptsImplement 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 iffbhas the bit pattern10xxxxxx.next_boundary(s, i): the first codepoint boundary strictly afteri(caller guaranteesi < s.size()). Ifs[i]is a lead byte declaring length L, the boundary is ati + Lโ but stop early at any byte that is not a continuation, and never run past the end of the string. Ifs[i]is itself a continuation byte (malformed here), the boundary isi + 1.prev_boundary(s, i): the last boundary strictly beforei(caller guarantees0 < i <= s.size()). Step back over at most 3 continuation bytes to find a lead candidate at positionj. Ifs[j]is still a continuation byte (malformed run), returni - 1. Otherwise check the lead's declared lengthL(1 for ASCII and other non-lead values): ifj + L >= ithe sequence coversi, so returnj; if it falls short (e.g."a"followed by a stray continuation byte), the bytes between are orphans โ returni - 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.