Unicode

In Luau, strings are a collection of bytes with no additional metadata. It is common for strings to be encoded as either plain ANSI or UTF-8, but when using anything other than ANSI functions such as string.lower break.

Eryx introduces full Unicode support through the global unicode library and the UnicodeString type.

Additionally, a new global named u is introduced. This function takes either a UTF-8 string or an existing UnicodeString, and returns a UnicodeString. The primary way to use this function is as a literal, such as foo(u"hello world").

local value = u"caf\u{00E9}"

print(typeof(value)) -- "UnicodeString"
print(tostring(value)) -- "caf\u{00E9}"

All raw string arguments accepted by unicode are expected to contain valid UTF-8 unless the function specifically documents that it works with encoded byte strings, such as detectbom, fromutf16, and fromutf32.

u

function u(value: string | UnicodeString): UnicodeString

Converts a UTF-8 string into a UnicodeString. If value is already a UnicodeString, it is returned unchanged.

UnicodeString values keep cached codepoint boundaries, so methods such as :sub, :codepoint, and #value work in codepoints instead of bytes.

UnicodeString

UnicodeString stores validated UTF-8 text and exposes methods through the same table used by the global unicode library. That means many functions are available in both forms:

unicode.normalise("e\u{0301}", "NFC")
u"e\u{0301}":normalise("NFC")

The type also implements a few Luau operations directly:

Operation Meaning
#value Returns the number of codepoints
tostring(value) Returns the underlying UTF-8 bytes as a Luau string
left == right Compares two UnicodeString values by exact UTF-8 contents
left .. right Concatenates a UnicodeString with another UnicodeString or string, returning a UnicodeString

These operations correspond to the __len, __tostring, __eq, and __concat metamethods.

The # operator and UnicodeString:len() return the number of codepoints, not bytes and not grapheme clusters. For grapheme cluster length, use unicode.len(value).

local value = u"A\u{00E9}\u{1F642}"

print(#value) -- 3 codepoints
print(value:sub(2, -1)) -- "\u{00E9}\u{1F642}"

unicode.version

unicode.version: string

The Unicode standard version used by Eryx. At the time of writing this value is "17.0.0".

unicode.fromname

function unicode.fromname(
    name: string,
    default: (string | UnicodeString)?
): UnicodeString?

Looks up a Unicode character name or named sequence and returns it as a UnicodeString.

Names are matched case-insensitively. Extra whitespace and underscores are treated as spaces, so "left_curly_bracket" and " LEFT CURLY BRACKET" both resolve to {.

If name cannot be found, default is returned as a UnicodeString when provided. Otherwise, the function returns nil.

print(unicode.fromname("SNOWMAN")) -- "\u{2603}"
print(unicode.fromname("KEYCAP DIGIT ONE")) -- "1\u{FE0F}\u{20E3}"
print(unicode.fromname("not real", "?")) -- "?"

unicode.tonumber

function unicode.tonumber(grapheme: string | UnicodeString): number?

Returns the Unicode numeric value of a single-codepoint string. This includes decimal digits and numeric characters such as fractions.

If the input is not exactly one codepoint, or the codepoint has no Unicode numeric value, this returns nil.

print(unicode.tonumber("9")) -- 9
print(unicode.tonumber("\u{00BD}")) -- 0.5
print(unicode.tonumber("ab")) -- nil

unicode.category

function unicode.category(codepoint: number): string

Returns the Unicode general category for a codepoint number, such as "Lu", "Ll", "Nd", or "Mn".

The codepoint must be in the range 0 through 0x10ffff.

print(unicode.category(0x41)) -- "Lu"
print(unicode.category(0x0301)) -- "Mn"

When called as a UnicodeString method, category accepts a one-based codepoint index:

function UnicodeString:category(index: number?): string

Negative indices count back from the end. The default index is 1.

unicode.name

function unicode.name(grapheme: string | UnicodeString): string

Returns the Unicode name for a string containing exactly one codepoint.

This function errors if the input has zero codepoints, multiple codepoints, or no Unicode name.

print(unicode.name("A")) -- "LATIN CAPITAL LETTER A"

unicode.normalise

function unicode.normalise(
    source: string | UnicodeString,
    format: "NFC" | "NFKC" | "NFD" | "NFKD"
): UnicodeString

Returns source normalized to one of the four Unicode normalization forms.

NFC and NFD perform canonical normalization. NFKC and NFKD also apply compatibility decomposition, which can change formatting distinctions.

local composed = unicode.normalise("e\u{0301}", "NFC")
print(composed) -- "\u{00E9}"

unicode.isnormalised

function unicode.isnormalised(
    source: string | UnicodeString,
    format: "NFC" | "NFKC" | "NFD" | "NFKD"
): boolean

Returns whether source is already normalized to the requested form.

print(unicode.isnormalised("\u{00E9}", "NFC")) -- true
print(unicode.isnormalised("e\u{0301}", "NFC")) -- false

unicode.foldcase

function unicode.foldcase(source: string | UnicodeString): UnicodeString

Applies Unicode case folding. This is useful for case-insensitive comparison.

print(unicode.foldcase("Stra\u{00DF}e")) -- "strasse"

unicode.touppercase

function unicode.touppercase(source: string | UnicodeString): UnicodeString

Applies Unicode uppercase mapping.

print(unicode.touppercase("stra\u{00DF}e")) -- "STRASSE"

unicode.tolowercase

function unicode.tolowercase(source: string | UnicodeString): UnicodeString

Applies Unicode lowercase mapping.

print(unicode.tolowercase("HELLO")) -- "hello"

unicode.totitlecase

function unicode.totitlecase(source: string | UnicodeString): UnicodeString

Applies titlecase mapping to the first codepoint and lowercase mapping to the remaining codepoints.

print(unicode.totitlecase("hELLO")) -- "Hello"

unicode.graphemes

function unicode.graphemes(source: string | UnicodeString): () -> UnicodeString?

Returns an iterator over Unicode extended grapheme clusters. Each value yielded by the iterator is a UnicodeString.

This is the API to use when you want to move through text by what users generally perceive as characters.

for grapheme in unicode.graphemes("e\u{0301}\u{1F642}") do
    print(grapheme)
end

unicode.len

function unicode.len(source: string | UnicodeString): number

Returns the number of grapheme clusters in source.

This differs from #u(source), which counts codepoints.

print(unicode.len("e\u{0301}")) -- 1
print(#u"e\u{0301}") -- 2

unicode.sub

function unicode.sub(
    source: string | UnicodeString,
    first: number,
    last: number?
): UnicodeString

Returns a substring using one-based grapheme cluster indices. Negative indices count back from the end, and last defaults to -1.

print(unicode.sub("A\u{00E9}\u{1F642}", 2, -1)) -- "\u{00E9}\u{1F642}"

unicode.detectbom

function unicode.detectbom(source: string): ("little" | "big")?, ("utf16" | "utf32")?, number

Detects a UTF-16 or UTF-32 byte order mark at the beginning of source.

Returns the byte order, encoding kind, and BOM byte length. If no BOM is present, this returns nil, nil, and 0.

local bytes = unicode.toutf16("A", "little", true)
local endian, kind, length = unicode.detectbom(bytes)

print(endian, kind, length) -- "little", "utf16", 2

unicode.fromutf16

function unicode.fromutf16(
    source: string,
    endian: ("little" | "big")?,
    strict: boolean?,
    preserveBom: boolean?
): UnicodeString

Decodes a UTF-16 byte string into a UnicodeString.

If endian is omitted, little-endian is used unless a BOM is present. By default, a leading BOM is used to detect byte order and is not included in the returned string. Set preserveBom to true to keep it as U+FEFF.

If a BOM is present and conflicts with the explicit endian argument, the function errors. The byte length must be even.

The strict argument changes the error message used for unpaired surrogates, but unpaired surrogates always error because they cannot be represented by UnicodeString.

unicode.fromutf32

function unicode.fromutf32(
    source: string,
    endian: ("little" | "big")?,
    strict: boolean?,
    preserveBom: boolean?
): UnicodeString

Decodes a UTF-32 byte string into a UnicodeString.

If endian is omitted, little-endian is used unless a BOM is present. By default, a leading BOM is used to detect byte order and is not included in the returned string. Set preserveBom to true to keep it as U+FEFF.

If a BOM is present and conflicts with the explicit endian argument, the function errors. The byte length must be a multiple of four. Codepoints outside the Unicode scalar range, including surrogate codepoints, error.

The strict argument is accepted for symmetry with fromutf16, but UTF-32 decoding always rejects invalid codepoints.

unicode.toutf16

function unicode.toutf16(
    source: string | UnicodeString,
    endian: ("little" | "big")?,
    includeBom: boolean?,
    strict: boolean?
): string

Encodes UTF-8 text as UTF-16 bytes.

endian defaults to "little". includeBom defaults to true, so unicode.toutf16("A") starts with a UTF-16 little-endian BOM. The strict argument is accepted by the type surface but is not currently used during encoding.

local bytes = unicode.toutf16("A\u{1F642}", "big", false)
print(unicode.fromutf16(bytes, "big")) -- "A\u{1F642}"

unicode.toutf32

function unicode.toutf32(
    source: string | UnicodeString,
    endian: ("little" | "big")?,
    includeBom: boolean?
): string

Encodes UTF-8 text as UTF-32 bytes.

endian defaults to "little". includeBom defaults to false.

local bytes = unicode.toutf32("A\u{1F642}", "big", true)
print(unicode.fromutf32(bytes)) -- "A\u{1F642}"

UnicodeString methods

These methods operate only on existing UnicodeString values.

function UnicodeString:len(): number
function UnicodeString:sub(first: number, last: number?): UnicodeString
function UnicodeString:codepoint(first: number?, last: number?): ...number
function UnicodeString:codepoints(): () -> number?
function UnicodeString:find(needle: UnicodeString, init: number?): (number?, number?)
function UnicodeString:rep(count: number): UnicodeString
function UnicodeString:reverse(): UnicodeString
function UnicodeString:split(separator: UnicodeString): { UnicodeString }

UnicodeString:len, :sub, :codepoint, :codepoints, :find, :reverse, and :split(u"") work in codepoint units. Negative indices count back from the end where applicable.

UnicodeString:find searches for an exact UTF-8 substring and returns one-based codepoint bounds when the match aligns to codepoint boundaries. It returns nil when no match is found.

UnicodeString:split returns an array of UnicodeString parts. An empty separator splits the string into individual codepoints.

The following methods are also available on UnicodeString and behave like the corresponding unicode functions with self as the first argument:

function UnicodeString:tonumber(): number?
function UnicodeString:name(): string
function UnicodeString:normalise(format: "NFC" | "NFKC" | "NFD" | "NFKD"): UnicodeString
function UnicodeString:isnormalised(format: "NFC" | "NFKC" | "NFD" | "NFKD"): boolean
function UnicodeString:foldcase(): UnicodeString
function UnicodeString:touppercase(): UnicodeString
function UnicodeString:tolowercase(): UnicodeString
function UnicodeString:totitlecase(): UnicodeString
function UnicodeString:graphemes(): () -> UnicodeString?
function UnicodeString:toutf16(endian: ("little" | "big")?, includeBom: boolean?, strict: boolean?): string
function UnicodeString:toutf32(endian: ("little" | "big")?, includeBom: boolean?): string