Lowent Manual←↑→

utf8 — UTF-8 code point iteration and validation

Source
lib/utf8.low
Layer
L0 — pure computation
Capabilities
none

Walks and checks UTF-8 byte strings by code point. A code point is the number Unicode gives a character ('한' = U+D55C = 54620). In UTF-8 one code point takes 1 … 4 bytes — one number is not one byte. Use it to count characters in a string mixing Korean and emoji, or to see whether input is valid UTF-8.

use utf8 .

let c option u64 . be utf8.decode s 0 .
guard is_some c . else return 1 .

0 is not “the first character” but byte offset 0. Every position in this module is a byte offset, and advancing by character is done by next.

Why validation is needed. UTF-8 is variable-length, so not every byte string is valid. A sequence may be cut short, a continuation byte may be wrong, or the same character may be written in more bytes than needed — an overlong encoding. Overlong encodings are especially dangerous: if the same code point can be written as two byte strings, a filter like “does this string contain /” lies. It is a classic path for bypassing security filters.

Why it is a library. str is bytes (chapter 9), and UTF-8 was not enforced as a type invariant. Encoding is a choice of a higher layer, and this module is that layer. Rust enforces UTF-8 on str to get character iteration for free, at the cost of validation on every construction path. Here validation is paid only when wanted, and in exchange byte slicing (subslice) cannot fail. No builtin was added.

Design and boundaries#

Code point rangeBytesLead byteContinuation bytes
U+0000 … U+007F10xxxxxxxnone
U+0080 … U+07FF2110xxxxx10xxxxxx × 1
U+0800 … U+FFFF31110xxxx10xxxxxx × 2
U+10000 … U+10FFFF411110xxx10xxxxxx × 3

Table 50.1 — UTF-8 byte layout

Three things follow from the table. The lead byte alone gives the length (seq_len). 10xxxxxx in lead position is not the start of a character. A value smaller than the minimum code point each length can hold (at least 128 for 2 bytes, 2048 for 3, 65536 for 4) is overlong — decode filters with these thresholds.

Ops at a glance#

opSignatureWhen it cannot
is_cont(b u8) → boolnever fails
seq_len(b u8) → u640 if not a lead byte
decode(s slice u8, at u64) → option u64none if invalid, truncated or out of range
next(s slice u8, at u64) → option u64none if invalid, truncated or at the end
count_chars(s slice u8) → option u64none if any byte is invalid
is_valid(s slice u8) → boolnever fails

Table 50.2 — Ops of utf8 — all fn · effects none

Ops in detail#

This module holds no state, so where and from where to read is always given by the caller.

Using it#

Validate first, read values with decode, advance with next. "한" is 3 bytes, 1 character, value 54620 (U+D55C).

module cpdump .

use utf8 .
use fmt .

proc main
  input out cap io .
  input al  cap allocator .
  output u8 .
  effects alloc io .
do
  let s slice u8 be "a한😀" .
  guard utf8.is_valid s . else return 65 .

  let g option mut slice u8 . . be alloc_bytes al capacity 128 .
  guard is_some g . else return 70 .
  let buf mut slice u8 . be some_value g .
  var pos u64 be 0 .

  var i u64 be 0 .
  while lt i (len s) . do
    let c option u64 . be utf8.decode s i .
    guard is_some c . else return 66 .
    let a option u64 . be fmt.put_str buf pos "U+" .
    guard is_some a . else return 71 .
    let b option u64 . be fmt.put_hex buf (some_value a) (some_value c) .
    guard is_some b . else return 72 .
    let d option u64 . be fmt.put_nl buf (some_value b) .
    guard is_some d . else return 73 .
    set pos (some_value d) .
    let nx option u64 . be utf8.next s i .
    guard is_some nx . else return 67 .
    set i (some_value nx) .
  end

  let w u64 be write_out out 1 (subslice buf 0 pos) .
  return 0 .
end

The output is three lines: U+61, U+d55c, U+1f600. In one pass i jumps 0 → 1 → 4 → 8, through a (1 byte), 한 (3 bytes) and 😀 (4 bytes). Never advance with add i 1 — on the second round it would point into the middle of a character. If you only need the count, it is one line: let n option u64 . be utf8.count_chars s .

Counter-examples#

Counter-example. Patching none with a replacement character and carrying on

Skipping a byte with set i (add i 1) when next fails lets invalid input through silently, and comparisons and filters above stand on a false premise. There is no error — it is a silently wrong answer, and the character count quietly grows. Feed invalid input on purpose and see whether later processing runs although is_valid is false. The only fix is to stop at none.

Counter-example. Treating a byte offset as a character number

decode s 1 is not “the second character” but “read at byte 1”. Byte 1 of "한글" is the middle of a sequence, so it is none. If is_valid is true but one position gives none, that position is not a character boundary.

Counter-example. Treating none from count_chars as 0

none is not “no characters” but “invalid UTF-8”. An empty slice is some 0. Merge the two and broken input can never be told apart from input with no characters. Taking it out with some_value without checking stops at run time with E-VM-NONE.

Counter-example. Slicing unvalidated input with subslice and assuming it is valid

Byte slicing always succeeds but can cut through the middle of a sequence. If the original is fine but only the piece fails is_valid, the cut was mid-character. Nobody warns at the cut, and it shows only when output breaks much later. To cut at character boundaries, cut only at positions next gave.

Cautions#