plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-std/Str.plum
module std
import std/List
import std/Buffer
import std/Bool
import std/Number
# Any type that can be converted to a str needs to implement this trait
trait ToStr =
toStr() -> Str
# A Str is an immutable byte sequence, backed by a real `Buffer` — a genuine
# `type`/`data` field, not a compiler special case: `Str` is an ordinary
# wasm-gc struct wrapping a `Buffer` (itself a wasm-gc struct wrapping a raw
# `[]Byte`), the same generic struct-of-fields codegen every other class
# gets. Only `byteToStr` (build a length-1 `Str` from a raw byte) and the
# string-literal/concatenation/equality/int-to-string runtime helpers still
# need to know that shape directly (see `plum-wasm-codegen`'s
# `compileIntrinsicFnBody`/`registerStringConcatHelper`/etc) — everything
# else, including `length`/`byteAt` below, is expressed by just calling
# through to `Buffer`.
#
# `Str` values are never actually MUTATED in place — a `Buffer` is mutable,
# but nothing here ever reassigns an existing `Str`'s `data` field or writes
# into its `Buffer` after construction; building a "changed" string always
# means constructing a brand new `Str`/`Buffer` pair (`Buffer.write`'s own
# callers are always building up a FRESH buffer before wrapping it, never
# mutating an already-published `Str`'s backing storage).
enum Str(Comparable, ToStr, Readable, Writable) =
| Str(data: Buffer)
# Number of bytes in the string — `self.data` (a `Buffer`) already tracks
# this, so unlike `byteAt`/`byteToStr` this needs no compiler intrinsic of
# its own.
fun length(self) -> Int =
self.data.length()
# The raw byte value (0-255) at index `i`. Traps if `i` is out of range —
# `self.data.data` (the `Buffer`'s own `[]Byte`) is a compiler intrinsic
# (see `libs/std/bytes.plum`'s `ByteSlice.get`), converted from `Byte` to
# a plain `Int` since that's this method's established contract.
fun byteAt(self, i: Int) -> Int =
self.data.data.get(i).toInt()
# FNV-1a over the raw UTF-8 bytes — used by `Map[K: Hashable, V]` (see
# `map.plum`) to bucket `Str` keys. Not cryptographic, just a well-known,
# decently-distributed general-purpose hash; wraps silently on overflow
# (ordinary 64-bit multiplication), which is fine — only the bit pattern
# matters, not the numeric value itself.
fun hash(self) -> Int =
h := 0xcbf29ce484222325
i := 0
while i < self.length()
h = {h ^ self.byteAt(i)} * 0x100000001b3
i = i + 1
return h
# The single-character substring at index `i`. (Was declared to return a
# `Char` type, but `Char` was never actually defined anywhere in `libs/std` —
# returning a length-1 `Str` instead makes it immediately usable with every
# other `Str` method, e.g. concatenation, with no conversion step.)
fun get(self, i: Int) -> Str =
byteToStr(self.byteAt(i))
# `Str` is a byte array (UTF-8 encoded), NOT a sequence of Unicode
# characters — `length`/`byteAt`/`get`/`sub`/`slice` all operate on raw
# bytes, so indexing into (or slicing) a string containing non-ASCII text
# with them can land in the MIDDLE of a multi-byte character. This is the
# same design real languages with an efficient string type use (Go, Rust:
# a byte buffer, with separate codepoint-aware iteration built on top) —
# a `List` of codepoints/bytes would need a heap-allocated node PER
# CHARACTER with only sequential access, several orders of magnitude
# slower for something as basic as string length, and `List` is currently
# broken for any generic-with-args field regardless (see
# `plum_generic_field_type_gap`). These three methods are that
# "codepoint-aware iteration built on top" layer:
#
# How many bytes the UTF-8 character STARTING at byte index `i` occupies
# (1-4), decoded from its leading byte's high bits. Passing the byte index
# of a CONTINUATION byte (the 2nd/3rd/4th byte of a multi-byte character)
# is a misuse this can't detect and returns a meaningless answer.
fun codePointByteLength(self, i: Int) -> Int =
b := self.byteAt(i)
if b < 0x80
return 1
if b & 0xE0 == 0xC0
return 2
if b & 0xF0 == 0xE0
return 3
return 4
# Decodes the single Unicode codepoint (as its raw integer value) starting
# at byte index `i`.
fun codePointAt(self, i: Int) -> Int =
n := self.codePointByteLength(i)
b0 := self.byteAt(i)
if n == 1
return b0
b1 := self.byteAt(i + 1)
if n == 2
return {b0 & 0x1F} << 6 | {b1 & 0x3F}
b2 := self.byteAt(i + 2)
if n == 3
return {b0 & 0x0F} << 12 | {b1 & 0x3F} << 6 | {b2 & 0x3F}
b3 := self.byteAt(i + 3)
return {b0 & 0x07} << 18 | {b1 & 0x3F} << 12 | {b2 & 0x3F} << 6 | {b3 & 0x3F}
# Number of Unicode CHARACTERS (codepoints) — as opposed to `length`,
# which counts bytes and only agrees with this for pure-ASCII text.
fun runeLength(self) -> Int =
runeLengthFrom(self, 0, self.length(), 0)
# The `charIndex`-th Unicode character, re-encoded as its own (1-4 byte)
# `Str`. Unlike `get` (which indexes by BYTE and can split a character in
# half), this always returns a complete character.
fun runeAt(self, charIndex: Int) -> Str =
runeAtFrom(self, 0, charIndex)
fun contains(self, search: Str) -> Bool =
self.indexOf(search) >= 0
fun indexOf(self, sub: Str) -> Int =
n := self.length()
m := sub.length()
if m == 0
return 0
i := 0
while i <= n - m
if matchesAt(self, sub, i, m)
return i
i = i + 1
return -1
fun test(self, pattern: Regex) -> Bool =
todo
fun startsWith(self, search: Str) -> Bool =
matchesAt(self, search, 0, search.length())
fun endsWith(self, search: Str) -> Bool =
matchesAt(self, search, self.length() - search.length(), search.length())
fun concat(self, other: Str) -> Str =
self + other
fun toStr(self) -> Str =
self
fun matchPattern(self, pattern: Regex) -> List =
todo
fun matchAll(self, pattern: Regex) -> List =
todo
# Pads the start of `self` with repeated copies of `sub` until its length is
# at least `count`. If `sub` doesn't evenly divide the needed amount, the
# final length may overshoot `count` by up to `sub.length() - 1`.
fun padStart(self, sub: Str, count: Int) -> Str =
if self.length() >= count || sub.length() == 0
return self
return {sub + self}.padStart(sub, count)
fun padEnd(self, sub: Str, count: Int) -> Str =
if self.length() >= count || sub.length() == 0
return self
return {self + sub}.padEnd(sub, count)
fun repeat(self, count: Int) -> Str =
if count <= 0
return ""
return self + self.repeat(count - 1)
fun replace(self, pattern: Regex, sub: Str) -> Str =
todo
fun replaceAll(self, pattern: Regex, sub: Str) -> Str =
todo
fun search(self, pattern: Regex) -> Str =
todo
# JS-`String.prototype.slice`-style: a negative `start`/`e` counts from the
# end. Clamped to a valid range; `e` is exclusive.
fun slice(self, start: Int, e: Int) -> Str =
len := self.length()
s := start < 0 ? len + start : start
en := e < 0 ? len + e : e
return self.sub(s, en)
# Splits on every occurrence of `separator` (byte-exact, not regex).
# `limit <= 0` means unlimited; `limit > 0` caps the RESULT COUNT at
# `limit`, with the last element holding everything left unsplit — Go's
# `strings.SplitN` semantics, not JS's (which instead discards anything
# past the limit). An empty `separator` splits into individual bytes.
fun split(self, separator: Str, limit: Int) -> List[Str] =
result := List(head: None, tail: None, size: 0)
len := self.length()
sep_len := separator.length()
if sep_len == 0
j := 0
while j < len
result.add(self.get(j))
j = j + 1
return result
start := 0
i := 0
count := 1
while i <= len - sep_len
if limit > 0 && count >= limit
break
if matchesAt(self, separator, i, sep_len)
result.add(self.sub(start, i))
start = i + sep_len
i = start
count = count + 1
else
i = i + 1
result.add(self.sub(start, len))
return result
# Plain substring, `start`/`e` clamped into `[0, length]`; `e` is exclusive.
# Unlike `slice`, negative indices are just clamped to 0, not counted from
# the end.
fun sub(self, start: Int, e: Int) -> Str =
len := self.length()
s := start < 0 ? 0 : start
en := e > len ? len : e
if s >= en
return ""
return self.get(s) + self.sub(s + 1, en)
fun toLower(self) -> Str =
mapCaseFrom(self, 0, self.length(), False)
# Reverses a Str BY CHARACTER (codepoint), not by byte — reversing raw
# UTF-8 bytes would scramble every multi-byte character's own byte order
# along with the string's, corrupting any non-ASCII text.
fun reverse(self) -> Str =
reverseFrom(self, 0)
fun camelCase(self) -> Str =
caseConvertFrom(self, 0, self.length(), "", 2, 0, 0)
fun snakeCase(self) -> Str =
caseConvertFrom(self, 0, self.length(), "_", 1, 0, 0)
fun capitalize(self) -> Str =
if self.length() == 0
return self
return self.get(0).upperCase() + self.sub(1, self.length()).toLower()
fun kebabCase(self) -> Str =
caseConvertFrom(self, 0, self.length(), "-", 1, 0, 0)
fun lowerCase(self) -> Str =
self.toLower()
fun lowerFirst(self) -> Str =
if self.length() == 0
return self
return self.get(0).toLower() + self.sub(1, self.length())
fun upperCase(self) -> Str =
mapCaseFrom(self, 0, self.length(), True)
fun upperFirst(self) -> Str =
if self.length() == 0
return self
return self.get(0).upperCase() + self.sub(1, self.length())
fun startCase(self) -> Str =
caseConvertFrom(self, 0, self.length(), " ", 4, 0, 0)
# Diacritic stripping needs a Unicode decomposition table (`é` -> `e`, ...) —
# far more than the ASCII byte-array `Str` primitives here support. Left as a
# known gap rather than a wrong "ASCII-only" approximation.
fun deburr(self) -> Str =
todo
fun escape(self) -> Str =
escapeFrom(self, 0, self.length())
fun escapeRegExp(self) -> Str =
escapeRegExpFrom(self, 0, self.length())
# Pads BOTH sides of `self` with repeated copies of `sub` until its length
# is at least `count`, split as evenly as possible between the two sides
# (the left side gets any odd byte of the remainder, matching lodash's
# `_.pad`). Inherits `padStart`/`padEnd`'s own overshoot behavior when
# `sub` doesn't evenly divide the needed amount on either side.
fun pad(self, sub: Str, count: Int) -> Str =
len := self.length()
if len >= count || sub.length() == 0
return self
total := count - len
left := total / 2
right := total - left
return self.padStart(sub, len + left).padEnd(sub, len + left + right)
# `template` (lodash's `_.template`) compiles a string containing
# `<%= expr %>`-style placeholders into a REUSABLE function that renders it
# against different data each call — that needs the ability to compile and
# run Plum source AT RUNTIME (from a `Str` value, not a `.plum` file), which
# the language has no way to do (no `eval`, no dynamic codegen from within
# a running program). Left as a known gap rather than a fundamentally
# different, simpler feature (e.g. a one-shot find/replace) under the same
# name.
fun template(self) -> Str =
todo
fun trim(self) -> Str =
self.trimStart().trimEnd()
fun trimEnd(self) -> Str =
trimEndFrom(self, self.length())
fun trimStart(self) -> Str =
trimStartFrom(self, 0, self.length())
# Truncates `self` to at most `length` bytes (INCLUDING the "..." suffix,
# lodash's default `omission` string) if it's longer than `length`; returns
# `self` unchanged otherwise. `length` too small to fit the full "..."
# itself just returns as many of its leading bytes as fit.
fun truncate(self, length: Int) -> Str =
if self.length() <= length
return self
omission := "..."
if length <= omission.length()
return omission.sub(0, length)
return self.sub(0, length - omission.length()) + omission
fun unescape(self) -> Str =
unescapeFrom(self, 0, self.length())
# Space-joined, un-cased word boundaries (splits on whitespace/punctuation
# AND on a lower->upper "camel" transition, e.g. "fooBar baz" -> "foo Bar baz").
fun words(self) -> Str =
caseConvertFrom(self, 0, self.length(), " ", 0, 0, 0)
# A new 1-byte Str holding `b`'s low 8 bits — a compiler intrinsic (there's no
# way to build a new array from Plum source), the counterpart to `byteAt`.
fun byteToStr(b: Int) -> Str =
todo
fun isAsciiUpper(b: Int) -> Bool =
b >= 65 && b <= 90 # 'A'..'Z'
fun isAsciiLower(b: Int) -> Bool =
b >= 97 && b <= 122 # 'a'..'z'
fun isAsciiDigit(b: Int) -> Bool =
b >= 48 && b <= 57 # '0'..'9'
# Byte 0x80+ is either a UTF-8 continuation byte or the lead byte of a
# multi-byte character — never an ASCII separator — so it's treated as
# "part of a word" here. Without this, `caseConvertFrom` (backing `words`/
# `camelCase`/`snakeCase`/`kebabCase`/`startCase`) would treat every
# non-ASCII character as a word BOUNDARY, shredding any text that isn't
# pure ASCII into a separate "word" per byte.
fun isWordByte(b: Int) -> Bool =
isAsciiUpper(b) || isAsciiLower(b) || isAsciiDigit(b) || b >= 0x80
fun toLowerByte(b: Int) -> Int =
isAsciiUpper(b) ? b + 32 : b
fun toUpperByte(b: Int) -> Int =
isAsciiLower(b) ? b - 32 : b
fun isCamelBoundary(prev_byte: Int, cur_byte: Int) -> Bool =
isAsciiLower(prev_byte) && isAsciiUpper(cur_byte)
# True if `s.byteAt(offset)..offset+subLen` equals `sub` exactly (and is in
# bounds — out-of-range always reports no match rather than trapping, so every
# caller can test candidate positions/lengths freely).
fun matchesAt(s: Str, sub: Str, offset: Int, sub_len: Int) -> Bool =
if offset < 0 || offset + sub_len > s.length()
return False
j := 0
while j < sub_len
if s.byteAt(offset + j) != sub.byteAt(j)
return False
j = j + 1
return True
fun reverseFrom(s: Str, i: Int) -> Str =
if i >= s.length()
return ""
n := s.codePointByteLength(i)
return reverseFrom(s, i + n) + s.sub(i, i + n)
fun runeLengthFrom(s: Str, i: Int, len: Int, count: Int) -> Int =
if i >= len
return count
n := s.codePointByteLength(i)
return runeLengthFrom(s, i + n, len, count + 1)
fun runeAtFrom(s: Str, byte_index: Int, chars_remaining: Int) -> Str =
n := s.codePointByteLength(byte_index)
if chars_remaining == 0
return s.sub(byte_index, byte_index + n)
return runeAtFrom(s, byte_index + n, chars_remaining - 1)
# Encodes a single Unicode codepoint (its raw integer value) as its own
# UTF-8 `Str` (1-4 bytes) — the reverse of `Str.codePointAt`. `b0`/`b1`/`b2`
# are declared ONCE up front and reassigned per branch (rather than
# redeclared with `:=` in each `if`) — `if` bodies share one flat scope with
# the rest of the function here (there's no per-branch isolation), so a
# second `:=` for the same name would be a compile error.
fun codePointToStr(cp: Int) -> Str =
if cp < 0x80
return byteToStr(cp)
b0 := 0xC0 | {cp >> 6}
b1 := 0x80 | {cp & 0x3F}
if cp < 0x800
return byteToStr(b0) + byteToStr(b1)
b0 = 0xE0 | {cp >> 12}
b1 = 0x80 | {{cp >> 6} & 0x3F}
b2 := 0x80 | {cp & 0x3F}
if cp < 0x10000
return byteToStr(b0) + byteToStr(b1) + byteToStr(b2)
b0 = 0xF0 | {cp >> 18}
b1 = 0x80 | {{cp >> 12} & 0x3F}
b2 = 0x80 | {{cp >> 6} & 0x3F}
b3 := 0x80 | {cp & 0x3F}
return byteToStr(b0) + byteToStr(b1) + byteToStr(b2) + byteToStr(b3)
fun trimStartFrom(s: Str, i: Int, len: Int) -> Str =
if i >= len
return ""
b := s.byteAt(i)
if b != 32 && b != 9 && b != 10 && b != 13
return s.sub(i, len)
return trimStartFrom(s, i + 1, len)
fun trimEndFrom(s: Str, len: Int) -> Str =
if len <= 0
return ""
b := s.byteAt(len - 1)
if b != 32 && b != 9 && b != 10 && b != 13
return s.sub(0, len)
return trimEndFrom(s, len - 1)
fun escapeFrom(s: Str, i: Int, len: Int) -> Str =
if i >= len
return ""
return escapeChar(s, i) + escapeFrom(s, i + 1, len)
fun escapeChar(s: Str, i: Int) -> Str =
match s.byteAt(i)
38 => "&"
60 => "<"
62 => ">"
34 => """
39 => "'"
_ => s.get(i)
fun isRegExpMetaByte(b: Int) -> Bool =
b == 94 || b == 36 || b == 92 || b == 46 || b == 42 || b == 43 || b == 63
|| b == 40 || b == 41 || b == 91 || b == 93 || b == 123 || b == 125 || b == 124
fun escapeRegExpFrom(s: Str, i: Int, len: Int) -> Str =
if i >= len
return ""
piece := isRegExpMetaByte(s.byteAt(i)) ? "\\" + s.get(i) : s.get(i)
return piece + escapeRegExpFrom(s, i + 1, len)
fun unescapeFrom(s: Str, i: Int, len: Int) -> Str =
if i >= len
return ""
if matchesAt(s, "&", i, 5)
return "&" + unescapeFrom(s, i + 5, len)
if matchesAt(s, "<", i, 4)
return "<" + unescapeFrom(s, i + 4, len)
if matchesAt(s, ">", i, 4)
return ">" + unescapeFrom(s, i + 4, len)
if matchesAt(s, """, i, 6)
return "\"" + unescapeFrom(s, i + 6, len)
if matchesAt(s, "'", i, 5)
return "'" + unescapeFrom(s, i + 5, len)
return s.get(i) + unescapeFrom(s, i + 1, len)
# Shared walker behind `words`/`camelCase`/`snakeCase`/`kebabCase`/`upperCase`/
# `toLower`/`startCase`: splits on non-word bytes and on a lower->upper "camel"
# transition, joining the surviving words with `sep` and case-mapping each
# byte per `mode`:
# 0 = leave case as-is (words) 1 = lowercase everything (snake/kebab)
# 2 = camelCase (word 1 lowercase, rest Capitalized) 4 = Capitalize every word (startCase)
# (`toLower`/`upperCase` are NOT built on this — they map every byte 1:1
# including non-word bytes like spaces/punctuation, which this function drops.)
fun caseConvertFrom(s: Str, i: Int, len: Int, sep: Str, mode: Int, word_index: Int, prev_byte: Int) -> Str =
if i >= len
return ""
b := s.byteAt(i)
if !isWordByte(b)
return caseConvertFrom(s, i + 1, len, sep, mode, word_index, 0)
is_word_start := prev_byte == 0 || isCamelBoundary(prev_byte, b)
word_index_next := is_word_start ? word_index + 1 : word_index
prefix := {is_word_start && word_index > 0} ? sep : ""
out_byte := caseConvertByte(b, mode, is_word_start, word_index_next)
return prefix + byteToStr(out_byte) + caseConvertFrom(s, i + 1, len, sep, mode, word_index_next, b)
fun caseConvertByte(b: Int, mode: Int, is_word_start: Bool, word_index: Int) -> Int =
match mode
0 => b
1 => toLowerByte(b)
2 => word_index == 1 ? toLowerByte(b) : {is_word_start ? toUpperByte(b) : toLowerByte(b)}
4 => is_word_start ? toUpperByte(b) : toLowerByte(b)
_ => b
# Simple 1:1 byte map — every byte (including non-word bytes like spaces and
# punctuation) is preserved; only letters are case-mapped. This is what
# `toLower`/`upperCase` need but `caseConvertFrom` above (word-boundary
# splitting/rejoining) doesn't provide.
fun mapCaseFrom(s: Str, i: Int, len: Int, upper: Bool) -> Str =
if i >= len
return ""
b := s.byteAt(i)
mapped := upper ? toUpperByte(b) : toLowerByte(b)
return byteToStr(mapped) + mapCaseFrom(s, i + 1, len, upper)
# ---- string interpolation regression tests ----
# `"{expr}"` interpolation is a compiler/codegen feature (not a Str method
# above), but Str is what it produces, so its regression coverage lives here.
fun greetForInterpolationTest(name: Str) -> Str =
"Hello, {name}!"
test "string interpolation of an int runs correctly"
x := 42
assert "{x}" == "42"
test "string interpolation of a negative int runs correctly"
x := -7
assert "{x}" == "-7"
test "string interpolation of zero runs correctly"
x := 0
assert "{x}" == "0"
test "string interpolation with surrounding text and multiple interps runs correctly"
count := 3
total := 10
assert "{count} of {total} complete" == "3 of 10 complete"
test "string interpolation of a str runs correctly"
assert greetForInterpolationTest("World") == "Hello, World!"
test "string interpolation of a bool runs correctly"
b := True
assert "is {b}" == "is True"
test "split divides on every occurrence of the separator"
parts := "a,b,c".split(",", 0)
assert parts.length() == 3
assert parts.join("|") == "a|b|c"
test "split with a positive limit leaves the remainder unsplit"
parts := "a,b,c,d".split(",", 2)
assert parts.length() == 2
assert parts.join("|") == "a|b,c,d"
test "split with no separator match returns the whole string as one element"
parts := "hello".split(",", 0)
assert parts.length() == 1
assert parts.join("|") == "hello"
test "split with an empty separator splits into individual bytes"
parts := "abc".split("", 0)
assert parts.length() == 3
assert parts.join("|") == "a|b|c"
test "split of an empty string returns a single empty element"
parts := "".split(",", 0)
assert parts.length() == 1
assert parts.join("|") == ""
test "string interpolation supports a boolean expression, not just a bare variable"
c1 := True
c2 := True
c3 := False
assert "{c1 && c2}" == "True"
assert "{c1 && c3}" == "False"
assert "{c1 || c3}" == "True"
test "string interpolation supports a method call taking a closure literal"
l := List(1, 2, 3, 4)
assert "{l.any(|x| x % 2 == 0)}" == "True"
assert "{l.any(|x| x > 100)}" == "False"
test "string interpolation supports a ternary expression"
n := 5
assert "{n > 0 ? "positive" : "non-positive"}" == "positive"
# ---- Str method regression tests ----
test "endsWith matches a real suffix and rejects a non-suffix or an over-long search"
assert "hello.plum".endsWith(".plum") == True
assert "hello.plum".endsWith(".rs") == False
assert "hi".endsWith("hello") == False
test "startsWith/endsWith/contains/indexOf agree on a shared example"
s := "the quick brown fox"
assert s.startsWith("the") == True
assert s.endsWith("fox") == True
assert s.contains("quick") == True
assert s.indexOf("brown") == 10
assert s.indexOf("missing") == -1
test "trim/trimStart/trimEnd strip only leading/trailing whitespace"
assert " hi ".trim() == "hi"
assert " hi ".trimStart() == "hi "
assert " hi ".trimEnd() == " hi"
assert "hi".trim() == "hi"
test "repeat concatenates self count times, and is empty for count <= 0"
assert "ab".repeat(3) == "ababab"
assert "ab".repeat(0) == ""
assert "ab".repeat(-1) == ""
test "padStart/padEnd/pad grow a string to at least the target length"
assert "5".padStart("0", 3) == "005"
assert "5".padEnd("0", 3) == "500"
assert "hi".pad("-", 6) == "--hi--"
assert "hi".pad("-", 5) == "-hi--"
assert "hello".pad("-", 3) == "hello"
test "truncate shortens a too-long string with a trailing ellipsis"
assert "hello world".truncate(8) == "hello..."
assert "hi".truncate(8) == "hi"
assert "hello world".truncate(2) == ".."
test "toLower/upperCase/capitalize/lowerFirst/upperFirst case-convert as expected"
assert "Hello World".toLower() == "hello world"
assert "Hello World".upperCase() == "HELLO WORLD"
assert "hello".capitalize() == "Hello"
assert "Hello".lowerFirst() == "hello"
assert "hello".upperFirst() == "Hello"
test "camelCase/snakeCase/kebabCase/startCase convert a multi-word phrase"
assert "foo bar baz".camelCase() == "fooBarBaz"
assert "foo bar baz".snakeCase() == "foo_bar_baz"
assert "foo bar baz".kebabCase() == "foo-bar-baz"
assert "foo bar baz".startCase() == "Foo Bar Baz"
test "reverse reverses by character, not by raw byte"
assert "hello".reverse() == "olleh"
test "escape/unescape round-trip HTML-sensitive characters"
assert "<a href=\"x\">&'</a>".escape() == "<a href="x">&'</a>"
assert "<a href="x">&'</a>".unescape() == "<a href=\"x\">&'</a>"