plum

#treesitter#compiler#wasm

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 => "&amp;"
    60 => "&lt;"
    62 => "&gt;"
    34 => "&quot;"
    39 => "&#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, "&amp;", i, 5)
    return "&" + unescapeFrom(s, i + 5, len)
  if matchesAt(s, "&lt;", i, 4)
    return "<" + unescapeFrom(s, i + 4, len)
  if matchesAt(s, "&gt;", i, 4)
    return ">" + unescapeFrom(s, i + 4, len)
  if matchesAt(s, "&quot;", i, 6)
    return "\"" + unescapeFrom(s, i + 6, len)
  if matchesAt(s, "&#39;", 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() == "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
  assert "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;".unescape() == "<a href=\"x\">&'</a>"