Lowent Manual←↑→

unicode — Unicode property tables

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

This is where you ask “is this character a letter, a number, a space?”. With ASCII only, you could answer by hand: ge c 97 and le c 122 means lowercase. The moment '한', 'あ' or 'Ω' arrives, that approach collapses. The code points that are “letters” in Unicode span 658 scattered ranges, impossible to write by hand. This module carries those range tables extracted mechanically from the Unicode 15.1.0 source and answers by binary search.

use unicode .

proc ident_start input cp u64 . output bool . effects none . do
  if unicode.is_letter cp . do return true . end
  return eq cp 95 .
end

is_letter 54620 ('한') is true and is_letter 128512 (😀) is false — emoji are symbols, not letters. The input is a code point (u64), not a byte. Getting code points out of UTF-8 byte strings is utf8′s job, and the two are used together.

Tables are data — written as code, lies creep in. Without this module everyone writes letter tests by hand in their own programs, and those tests are almost always wrong. The problem is how they are wrong. A missed range comes out not as an error but as false — a silently wrong answer. A parser cuts a token at that character, a search misses that word, and no warning appears anywhere. Display width (term’s cp_width) had a usable default of 1 even when wrong, but here there is no such default.

Design and boundaries#

Tables are slice u8 returned by fns (string literals — no copy, no allocation), and there is no struct or state. Tables keep four invariants — a range is 12 lowercase hex bytes, sorted by start ascending, no overlaps (adjacent ranges merged), and end inclusive (start ≤ cp ≤ end). If you build a table yourself for has_cp, keep sorting and no overlap for binary search to hold.

Ops at a glance#

opWhat it does
tab_letter · tab_numberLetter (L) table, 658 ranges · number (N) table, 137 ranges
tab_punct · tab_space · tab_markPunctuation (P) · space (Z, no tab or newline) · mark (M) tables
tab_zerowidthZero width (Mn, Me, Cf) table, 354 ranges
tab_upper · tab_lower · tab_digitUppercase (Lu) 646 · lowercase (Ll) 658 · decimal digit (Nd) 64 ranges
tab_title · tab_modifier · tab_other_letterLt 10 · Lm 71 · Lo 509 ranges
tab_numletter · tab_numotherNl 12 · No 72 ranges
tab_toupper · tab_tolowerCase pair tables, 1423 · 1432 pairs
tab_casespecial103 code points that cannot be converted one-to-one
range_countNumber of ranges in a table (len tab / 12)
has_cpIs this code point in the table — binary search
is_letter · is_number · is_punct · is_space · is_markPredicates for each table
is_alnumLetter or number
is_upper · is_lower · is_digitLu · Ll · decimal digit (Nd)?
is_zerowidthZero width (combining or format character)?
is_title · is_modifier · is_other_letter · is_numletter · is_numotherLt · Lm · Lo · Nl · No?
to_upper · to_lowerTo uppercase · lowercase (unchanged if no mapping)
is_special_caseA code point that cannot be converted one-to-one (such as ß)?

Table 50.1 — Ops of unicode

Ops in detail#

Using it#

Counting words (runs of letters and numbers) in a UTF-8 string:

module wordcount .

use unicode .
use utf8 .

export proc count_words input s slice u8 . output option u64 . do
  var off u64 be 0 .
  var words u64 be 0 .
  var inword bool be false .
  while lt off (len s) . do
    let cp option u64 . be utf8.decode s off .
    guard is_some cp . else return none .
    let n u64 be utf8.seq_len (index s off) .
    guard gt n 0 . else return none .
    if unicode.is_alnum (some_value cp) . do
      if eq inword false . do set words (add words 1) . end
      set inword true .
    end
    if eq (unicode.is_alnum (some_value cp)) false . do
      set inword false .
    end
    set off (add off n) .
  end
  return some words .
end

"한글 word 123" gives 3 — a test that knows only ASCII cannot count '한글'. Regex \p{L} and \P{L} use exactly these tables (regex). If a regular expression is enough, use it; where only one code point needs asking, calling this module directly is much cheaper.

Counter-examples#

Counter-example. Passing a byte as is

rem ✘ index gives a byte. The first byte of '한' is 0xED
if unicode.is_letter (widen u64 (index s 0)) . do … end

0xED (237) is the code point U+00ED (í) — judged a letter by chance, but not the character being asked about. When handling UTF-8, always go through utf8.decode.

Counter-example. Using is_mark while counting width

It counts Mc (combining marks that take a cell) as 0 too. Width needs is_zerowidth — and term’s cp_width already handles width anyway.

Counter-example. Assuming to_upper always converts

ß comes back unchanged. If precision matters, ask is_special_case first; expanding to two or more characters is the caller’s decision.

Counter-example. Computing digit values with is_number

rem ✘ Ⅶ (U+2166) passes too, and cp − 48 is meaningless
if unicode.is_number cp . do set v (add (mul v 10) (sub cp 48)) . end

To accept decimal digits only, use is_digit.

Counter-example. Treating false from is_upper as lowercase

Korean, Chinese and Arabic characters are false for both. That test classifies most of the world’s characters as lowercase. Ask is_lower directly.

Cautions#