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
141de54 1
module std
5f5d99d 2
b7071c9 3
import std/List
b7071c9 4
import std/Buffer
73b5e55 5
import std/Bool
ca5fd6f 6
import std/Number
2ec05c8 7
141de54 8
# Any type that can be converted to a str needs to implement this trait
bf629a2 9
trait ToStr =
5f5d99d 10
  toStr() -> Str
5f5d99d 11
73e4ff1 12
# A Str is an immutable byte sequence, backed by a real `Buffer` — a genuine
73e4ff1 13
# `type`/`data` field, not a compiler special case: `Str` is an ordinary
73e4ff1 14
# wasm-gc struct wrapping a `Buffer` (itself a wasm-gc struct wrapping a raw
73e4ff1 15
# `[]Byte`), the same generic struct-of-fields codegen every other class
73e4ff1 16
# gets. Only `byteToStr` (build a length-1 `Str` from a raw byte) and the
73e4ff1 17
# string-literal/concatenation/equality/int-to-string runtime helpers still
73e4ff1 18
# need to know that shape directly (see `plum-wasm-codegen`'s
73e4ff1 19
# `compileIntrinsicFnBody`/`registerStringConcatHelper`/etc) — everything
73e4ff1 20
# else, including `length`/`byteAt` below, is expressed by just calling
73e4ff1 21
# through to `Buffer`.
73e4ff1 22
#
73e4ff1 23
# `Str` values are never actually MUTATED in place — a `Buffer` is mutable,
73e4ff1 24
# but nothing here ever reassigns an existing `Str`'s `data` field or writes
73e4ff1 25
# into its `Buffer` after construction; building a "changed" string always
73e4ff1 26
# means constructing a brand new `Str`/`Buffer` pair (`Buffer.write`'s own
73e4ff1 27
# callers are always building up a FRESH buffer before wrapping it, never
73e4ff1 28
# mutating an already-published `Str`'s backing storage).
5f2f962 29
enum Str(Comparable, ToStr, Readable, Writable) =
5f2f962 30
  | Str(data: Buffer)
5f5d99d 31
73e4ff1 32
  # Number of bytes in the string — `self.data` (a `Buffer`) already tracks
73e4ff1 33
  # this, so unlike `byteAt`/`byteToStr` this needs no compiler intrinsic of
73e4ff1 34
  # its own.
a271f34 35
  fun length(self) -> Int =
73e4ff1 36
    self.data.length()
a271f34 37
73e4ff1 38
  # The raw byte value (0-255) at index `i`. Traps if `i` is out of range —
73e4ff1 39
  # `self.data.data` (the `Buffer`'s own `[]Byte`) is a compiler intrinsic
73e4ff1 40
  # (see `libs/std/bytes.plum`'s `ByteSlice.get`), converted from `Byte` to
73e4ff1 41
  # a plain `Int` since that's this method's established contract.
a271f34 42
  fun byteAt(self, i: Int) -> Int =
73e4ff1 43
    self.data.data.get(i).toInt()
a271f34 44
ea9b8c1 45
  # FNV-1a over the raw UTF-8 bytes — used by `Map[K: Hashable, V]` (see
ea9b8c1 46
  # `map.plum`) to bucket `Str` keys. Not cryptographic, just a well-known,
ea9b8c1 47
  # decently-distributed general-purpose hash; wraps silently on overflow
ea9b8c1 48
  # (ordinary 64-bit multiplication), which is fine — only the bit pattern
ea9b8c1 49
  # matters, not the numeric value itself.
ea9b8c1 50
  fun hash(self) -> Int =
ea9b8c1 51
    h := 0xcbf29ce484222325
ea9b8c1 52
    i := 0
ea9b8c1 53
    while i < self.length()
ea9b8c1 54
      h = {h ^ self.byteAt(i)} * 0x100000001b3
ea9b8c1 55
      i = i + 1
ea9b8c1 56
    return h
ea9b8c1 57
a271f34 58
  # The single-character substring at index `i`. (Was declared to return a
a271f34 59
  # `Char` type, but `Char` was never actually defined anywhere in `libs/std` —
a271f34 60
  # returning a length-1 `Str` instead makes it immediately usable with every
a271f34 61
  # other `Str` method, e.g. concatenation, with no conversion step.)
a271f34 62
  fun get(self, i: Int) -> Str =
a271f34 63
    byteToStr(self.byteAt(i))
a271f34 64
a271f34 65
  # `Str` is a byte array (UTF-8 encoded), NOT a sequence of Unicode
a271f34 66
  # characters — `length`/`byteAt`/`get`/`sub`/`slice` all operate on raw
a271f34 67
  # bytes, so indexing into (or slicing) a string containing non-ASCII text
a271f34 68
  # with them can land in the MIDDLE of a multi-byte character. This is the
a271f34 69
  # same design real languages with an efficient string type use (Go, Rust:
a271f34 70
  # a byte buffer, with separate codepoint-aware iteration built on top) —
a271f34 71
  # a `List` of codepoints/bytes would need a heap-allocated node PER
a271f34 72
  # CHARACTER with only sequential access, several orders of magnitude
a271f34 73
  # slower for something as basic as string length, and `List` is currently
a271f34 74
  # broken for any generic-with-args field regardless (see
a271f34 75
  # `plum_generic_field_type_gap`). These three methods are that
a271f34 76
  # "codepoint-aware iteration built on top" layer:
a271f34 77
  #
a271f34 78
  # How many bytes the UTF-8 character STARTING at byte index `i` occupies
a271f34 79
  # (1-4), decoded from its leading byte's high bits. Passing the byte index
a271f34 80
  # of a CONTINUATION byte (the 2nd/3rd/4th byte of a multi-byte character)
a271f34 81
  # is a misuse this can't detect and returns a meaningless answer.
a271f34 82
  fun codePointByteLength(self, i: Int) -> Int =
a271f34 83
    b := self.byteAt(i)
a271f34 84
    if b < 0x80
a271f34 85
      return 1
a271f34 86
    if b & 0xE0 == 0xC0
a271f34 87
      return 2
a271f34 88
    if b & 0xF0 == 0xE0
a271f34 89
      return 3
a271f34 90
    return 4
a271f34 91
a271f34 92
  # Decodes the single Unicode codepoint (as its raw integer value) starting
a271f34 93
  # at byte index `i`.
a271f34 94
  fun codePointAt(self, i: Int) -> Int =
a271f34 95
    n := self.codePointByteLength(i)
a271f34 96
    b0 := self.byteAt(i)
a271f34 97
    if n == 1
a271f34 98
      return b0
a271f34 99
    b1 := self.byteAt(i + 1)
a271f34 100
    if n == 2
a271f34 101
      return {b0 & 0x1F} << 6 | {b1 & 0x3F}
a271f34 102
    b2 := self.byteAt(i + 2)
a271f34 103
    if n == 3
a271f34 104
      return {b0 & 0x0F} << 12 | {b1 & 0x3F} << 6 | {b2 & 0x3F}
a271f34 105
    b3 := self.byteAt(i + 3)
a271f34 106
    return {b0 & 0x07} << 18 | {b1 & 0x3F} << 12 | {b2 & 0x3F} << 6 | {b3 & 0x3F}
a271f34 107
a271f34 108
  # Number of Unicode CHARACTERS (codepoints) — as opposed to `length`,
a271f34 109
  # which counts bytes and only agrees with this for pure-ASCII text.
a271f34 110
  fun runeLength(self) -> Int =
a271f34 111
    runeLengthFrom(self, 0, self.length(), 0)
a271f34 112
a271f34 113
  # The `charIndex`-th Unicode character, re-encoded as its own (1-4 byte)
a271f34 114
  # `Str`. Unlike `get` (which indexes by BYTE and can split a character in
a271f34 115
  # half), this always returns a complete character.
a271f34 116
  fun runeAt(self, charIndex: Int) -> Str =
a271f34 117
    runeAtFrom(self, 0, charIndex)
5f5d99d 118
58b01fc 119
  fun contains(self, search: Str) -> Bool =
a271f34 120
    self.indexOf(search) >= 0
5f5d99d 121
58b01fc 122
  fun indexOf(self, sub: Str) -> Int =
a271f34 123
    n := self.length()
a271f34 124
    m := sub.length()
a271f34 125
    if m == 0
a271f34 126
      return 0
a271f34 127
    i := 0
a271f34 128
    while i <= n - m
a271f34 129
      if matchesAt(self, sub, i, m)
a271f34 130
        return i
a271f34 131
      i = i + 1
a271f34 132
    return -1
5f5d99d 133
58b01fc 134
  fun test(self, pattern: Regex) -> Bool =
58b01fc 135
    todo
5f5d99d 136
58b01fc 137
  fun startsWith(self, search: Str) -> Bool =
a271f34 138
    matchesAt(self, search, 0, search.length())
5f5d99d 139
bf629a2 140
  fun endsWith(self, search: Str) -> Bool =
bf629a2 141
    matchesAt(self, search, self.length() - search.length(), search.length())
bf629a2 142
58b01fc 143
  fun concat(self, other: Str) -> Str =
58b01fc 144
    self + other
5f5d99d 145
58b01fc 146
  fun toStr(self) -> Str =
58b01fc 147
    self
5f5d99d 148
58b01fc 149
  fun matchPattern(self, pattern: Regex) -> List =
58b01fc 150
    todo
5f5d99d 151
58b01fc 152
  fun matchAll(self, pattern: Regex) -> List =
58b01fc 153
    todo
5f5d99d 154
a271f34 155
  # Pads the start of `self` with repeated copies of `sub` until its length is
a271f34 156
  # at least `count`. If `sub` doesn't evenly divide the needed amount, the
a271f34 157
  # final length may overshoot `count` by up to `sub.length() - 1`.
58b01fc 158
  fun padStart(self, sub: Str, count: Int) -> Str =
a271f34 159
    if self.length() >= count || sub.length() == 0
a271f34 160
      return self
a271f34 161
    return {sub + self}.padStart(sub, count)
5f5d99d 162
58b01fc 163
  fun padEnd(self, sub: Str, count: Int) -> Str =
a271f34 164
    if self.length() >= count || sub.length() == 0
a271f34 165
      return self
a271f34 166
    return {self + sub}.padEnd(sub, count)
5f5d99d 167
58b01fc 168
  fun repeat(self, count: Int) -> Str =
a271f34 169
    if count <= 0
a271f34 170
      return ""
a271f34 171
    return self + self.repeat(count - 1)
5f5d99d 172
58b01fc 173
  fun replace(self, pattern: Regex, sub: Str) -> Str =
58b01fc 174
    todo
5f5d99d 175
58b01fc 176
  fun replaceAll(self, pattern: Regex, sub: Str) -> Str =
58b01fc 177
    todo
5f5d99d 178
58b01fc 179
  fun search(self, pattern: Regex) -> Str =
58b01fc 180
    todo
5f5d99d 181
a271f34 182
  # JS-`String.prototype.slice`-style: a negative `start`/`e` counts from the
a271f34 183
  # end. Clamped to a valid range; `e` is exclusive.
58b01fc 184
  fun slice(self, start: Int, e: Int) -> Str =
a271f34 185
    len := self.length()
a271f34 186
    s := start < 0 ? len + start : start
a271f34 187
    en := e < 0 ? len + e : e
a271f34 188
    return self.sub(s, en)
a271f34 189
2ec05c8 190
  # Splits on every occurrence of `separator` (byte-exact, not regex).
2ec05c8 191
  # `limit <= 0` means unlimited; `limit > 0` caps the RESULT COUNT at
2ec05c8 192
  # `limit`, with the last element holding everything left unsplit — Go's
2ec05c8 193
  # `strings.SplitN` semantics, not JS's (which instead discards anything
2ec05c8 194
  # past the limit). An empty `separator` splits into individual bytes.
2ec05c8 195
  fun split(self, separator: Str, limit: Int) -> List[Str] =
2ec05c8 196
    result := List(head: None, tail: None, size: 0)
2ec05c8 197
    len := self.length()
2ec05c8 198
    sep_len := separator.length()
2ec05c8 199
    if sep_len == 0
2ec05c8 200
      j := 0
2ec05c8 201
      while j < len
2ec05c8 202
        result.add(self.get(j))
2ec05c8 203
        j = j + 1
2ec05c8 204
      return result
2ec05c8 205
    start := 0
2ec05c8 206
    i := 0
2ec05c8 207
    count := 1
2ec05c8 208
    while i <= len - sep_len
2ec05c8 209
      if limit > 0 && count >= limit
2ec05c8 210
        break
2ec05c8 211
      if matchesAt(self, separator, i, sep_len)
2ec05c8 212
        result.add(self.sub(start, i))
2ec05c8 213
        start = i + sep_len
2ec05c8 214
        i = start
2ec05c8 215
        count = count + 1
2ec05c8 216
      else
2ec05c8 217
        i = i + 1
2ec05c8 218
    result.add(self.sub(start, len))
2ec05c8 219
    return result
5f5d99d 220
a271f34 221
  # Plain substring, `start`/`e` clamped into `[0, length]`; `e` is exclusive.
a271f34 222
  # Unlike `slice`, negative indices are just clamped to 0, not counted from
a271f34 223
  # the end.
58b01fc 224
  fun sub(self, start: Int, e: Int) -> Str =
a271f34 225
    len := self.length()
a271f34 226
    s := start < 0 ? 0 : start
a271f34 227
    en := e > len ? len : e
a271f34 228
    if s >= en
a271f34 229
      return ""
a271f34 230
    return self.get(s) + self.sub(s + 1, en)
5f5d99d 231
58b01fc 232
  fun toLower(self) -> Str =
a271f34 233
    mapCaseFrom(self, 0, self.length(), False)
5f5d99d 234
a271f34 235
  # Reverses a Str BY CHARACTER (codepoint), not by byte — reversing raw
a271f34 236
  # UTF-8 bytes would scramble every multi-byte character's own byte order
a271f34 237
  # along with the string's, corrupting any non-ASCII text.
58b01fc 238
  fun reverse(self) -> Str =
a271f34 239
    reverseFrom(self, 0)
5f5d99d 240
58b01fc 241
  fun camelCase(self) -> Str =
a271f34 242
    caseConvertFrom(self, 0, self.length(), "", 2, 0, 0)
5f5d99d 243
58b01fc 244
  fun snakeCase(self) -> Str =
a271f34 245
    caseConvertFrom(self, 0, self.length(), "_", 1, 0, 0)
5f5d99d 246
58b01fc 247
  fun capitalize(self) -> Str =
a271f34 248
    if self.length() == 0
a271f34 249
      return self
a271f34 250
    return self.get(0).upperCase() + self.sub(1, self.length()).toLower()
5f5d99d 251
58b01fc 252
  fun kebabCase(self) -> Str =
a271f34 253
    caseConvertFrom(self, 0, self.length(), "-", 1, 0, 0)
5f5d99d 254
58b01fc 255
  fun lowerCase(self) -> Str =
a271f34 256
    self.toLower()
5f5d99d 257
58b01fc 258
  fun lowerFirst(self) -> Str =
a271f34 259
    if self.length() == 0
a271f34 260
      return self
a271f34 261
    return self.get(0).toLower() + self.sub(1, self.length())
5f5d99d 262
58b01fc 263
  fun upperCase(self) -> Str =
a271f34 264
    mapCaseFrom(self, 0, self.length(), True)
5f5d99d 265
58b01fc 266
  fun upperFirst(self) -> Str =
a271f34 267
    if self.length() == 0
a271f34 268
      return self
a271f34 269
    return self.get(0).upperCase() + self.sub(1, self.length())
5f5d99d 270
58b01fc 271
  fun startCase(self) -> Str =
a271f34 272
    caseConvertFrom(self, 0, self.length(), " ", 4, 0, 0)
5f5d99d 273
a271f34 274
  # Diacritic stripping needs a Unicode decomposition table (`é` -> `e`, ...) —
a271f34 275
  # far more than the ASCII byte-array `Str` primitives here support. Left as a
a271f34 276
  # known gap rather than a wrong "ASCII-only" approximation.
58b01fc 277
  fun deburr(self) -> Str =
58b01fc 278
    todo
5f5d99d 279
58b01fc 280
  fun escape(self) -> Str =
a271f34 281
    escapeFrom(self, 0, self.length())
5f5d99d 282
58b01fc 283
  fun escapeRegExp(self) -> Str =
a271f34 284
    escapeRegExpFrom(self, 0, self.length())
5f5d99d 285
bf629a2 286
  # Pads BOTH sides of `self` with repeated copies of `sub` until its length
bf629a2 287
  # is at least `count`, split as evenly as possible between the two sides
bf629a2 288
  # (the left side gets any odd byte of the remainder, matching lodash's
bf629a2 289
  # `_.pad`). Inherits `padStart`/`padEnd`'s own overshoot behavior when
bf629a2 290
  # `sub` doesn't evenly divide the needed amount on either side.
bf629a2 291
  fun pad(self, sub: Str, count: Int) -> Str =
bf629a2 292
    len := self.length()
bf629a2 293
    if len >= count || sub.length() == 0
bf629a2 294
      return self
bf629a2 295
    total := count - len
bf629a2 296
    left := total / 2
bf629a2 297
    right := total - left
bf629a2 298
    return self.padStart(sub, len + left).padEnd(sub, len + left + right)
bf629a2 299
bf629a2 300
  # `template` (lodash's `_.template`) compiles a string containing
bf629a2 301
  # `<%= expr %>`-style placeholders into a REUSABLE function that renders it
bf629a2 302
  # against different data each call — that needs the ability to compile and
bf629a2 303
  # run Plum source AT RUNTIME (from a `Str` value, not a `.plum` file), which
bf629a2 304
  # the language has no way to do (no `eval`, no dynamic codegen from within
bf629a2 305
  # a running program). Left as a known gap rather than a fundamentally
bf629a2 306
  # different, simpler feature (e.g. a one-shot find/replace) under the same
bf629a2 307
  # name.
58b01fc 308
  fun template(self) -> Str =
58b01fc 309
    todo
5f5d99d 310
58b01fc 311
  fun trim(self) -> Str =
a271f34 312
    self.trimStart().trimEnd()
5f5d99d 313
58b01fc 314
  fun trimEnd(self) -> Str =
a271f34 315
    trimEndFrom(self, self.length())
5f5d99d 316
58b01fc 317
  fun trimStart(self) -> Str =
a271f34 318
    trimStartFrom(self, 0, self.length())
5f5d99d 319
bf629a2 320
  # Truncates `self` to at most `length` bytes (INCLUDING the "..." suffix,
bf629a2 321
  # lodash's default `omission` string) if it's longer than `length`; returns
bf629a2 322
  # `self` unchanged otherwise. `length` too small to fit the full "..."
bf629a2 323
  # itself just returns as many of its leading bytes as fit.
bf629a2 324
  fun truncate(self, length: Int) -> Str =
bf629a2 325
    if self.length() <= length
bf629a2 326
      return self
bf629a2 327
    omission := "..."
bf629a2 328
    if length <= omission.length()
bf629a2 329
      return omission.sub(0, length)
bf629a2 330
    return self.sub(0, length - omission.length()) + omission
5f5d99d 331
58b01fc 332
  fun unescape(self) -> Str =
a271f34 333
    unescapeFrom(self, 0, self.length())
5f5d99d 334
a271f34 335
  # Space-joined, un-cased word boundaries (splits on whitespace/punctuation
a271f34 336
  # AND on a lower->upper "camel" transition, e.g. "fooBar baz" -> "foo Bar baz").
58b01fc 337
  fun words(self) -> Str =
a271f34 338
    caseConvertFrom(self, 0, self.length(), " ", 0, 0, 0)
a271f34 339
a271f34 340
# A new 1-byte Str holding `b`'s low 8 bits — a compiler intrinsic (there's no
a271f34 341
# way to build a new array from Plum source), the counterpart to `byteAt`.
a271f34 342
fun byteToStr(b: Int) -> Str =
a271f34 343
  todo
a271f34 344
a271f34 345
fun isAsciiUpper(b: Int) -> Bool =
a271f34 346
  b >= 65 && b <= 90 # 'A'..'Z'
a271f34 347
a271f34 348
fun isAsciiLower(b: Int) -> Bool =
a271f34 349
  b >= 97 && b <= 122 # 'a'..'z'
a271f34 350
a271f34 351
fun isAsciiDigit(b: Int) -> Bool =
a271f34 352
  b >= 48 && b <= 57 # '0'..'9'
a271f34 353
a271f34 354
# Byte 0x80+ is either a UTF-8 continuation byte or the lead byte of a
a271f34 355
# multi-byte character — never an ASCII separator — so it's treated as
a271f34 356
# "part of a word" here. Without this, `caseConvertFrom` (backing `words`/
a271f34 357
# `camelCase`/`snakeCase`/`kebabCase`/`startCase`) would treat every
a271f34 358
# non-ASCII character as a word BOUNDARY, shredding any text that isn't
a271f34 359
# pure ASCII into a separate "word" per byte.
a271f34 360
fun isWordByte(b: Int) -> Bool =
a271f34 361
  isAsciiUpper(b) || isAsciiLower(b) || isAsciiDigit(b) || b >= 0x80
a271f34 362
a271f34 363
fun toLowerByte(b: Int) -> Int =
a271f34 364
  isAsciiUpper(b) ? b + 32 : b
a271f34 365
a271f34 366
fun toUpperByte(b: Int) -> Int =
a271f34 367
  isAsciiLower(b) ? b - 32 : b
a271f34 368
a271f34 369
fun isCamelBoundary(prev_byte: Int, cur_byte: Int) -> Bool =
a271f34 370
  isAsciiLower(prev_byte) && isAsciiUpper(cur_byte)
a271f34 371
a271f34 372
# True if `s.byteAt(offset)..offset+subLen` equals `sub` exactly (and is in
a271f34 373
# bounds — out-of-range always reports no match rather than trapping, so every
a271f34 374
# caller can test candidate positions/lengths freely).
a271f34 375
fun matchesAt(s: Str, sub: Str, offset: Int, sub_len: Int) -> Bool =
a271f34 376
  if offset < 0 || offset + sub_len > s.length()
a271f34 377
    return False
a271f34 378
  j := 0
a271f34 379
  while j < sub_len
a271f34 380
    if s.byteAt(offset + j) != sub.byteAt(j)
a271f34 381
      return False
a271f34 382
    j = j + 1
a271f34 383
  return True
a271f34 384
a271f34 385
fun reverseFrom(s: Str, i: Int) -> Str =
a271f34 386
  if i >= s.length()
a271f34 387
    return ""
a271f34 388
  n := s.codePointByteLength(i)
a271f34 389
  return reverseFrom(s, i + n) + s.sub(i, i + n)
a271f34 390
a271f34 391
fun runeLengthFrom(s: Str, i: Int, len: Int, count: Int) -> Int =
a271f34 392
  if i >= len
a271f34 393
    return count
a271f34 394
  n := s.codePointByteLength(i)
a271f34 395
  return runeLengthFrom(s, i + n, len, count + 1)
a271f34 396
a271f34 397
fun runeAtFrom(s: Str, byte_index: Int, chars_remaining: Int) -> Str =
a271f34 398
  n := s.codePointByteLength(byte_index)
a271f34 399
  if chars_remaining == 0
a271f34 400
    return s.sub(byte_index, byte_index + n)
a271f34 401
  return runeAtFrom(s, byte_index + n, chars_remaining - 1)
a271f34 402
a271f34 403
# Encodes a single Unicode codepoint (its raw integer value) as its own
a271f34 404
# UTF-8 `Str` (1-4 bytes) — the reverse of `Str.codePointAt`. `b0`/`b1`/`b2`
a271f34 405
# are declared ONCE up front and reassigned per branch (rather than
a271f34 406
# redeclared with `:=` in each `if`) — `if` bodies share one flat scope with
a271f34 407
# the rest of the function here (there's no per-branch isolation), so a
a271f34 408
# second `:=` for the same name would be a compile error.
a271f34 409
fun codePointToStr(cp: Int) -> Str =
a271f34 410
  if cp < 0x80
a271f34 411
    return byteToStr(cp)
a271f34 412
  b0 := 0xC0 | {cp >> 6}
a271f34 413
  b1 := 0x80 | {cp & 0x3F}
a271f34 414
  if cp < 0x800
a271f34 415
    return byteToStr(b0) + byteToStr(b1)
a271f34 416
  b0 = 0xE0 | {cp >> 12}
a271f34 417
  b1 = 0x80 | {{cp >> 6} & 0x3F}
a271f34 418
  b2 := 0x80 | {cp & 0x3F}
a271f34 419
  if cp < 0x10000
a271f34 420
    return byteToStr(b0) + byteToStr(b1) + byteToStr(b2)
a271f34 421
  b0 = 0xF0 | {cp >> 18}
a271f34 422
  b1 = 0x80 | {{cp >> 12} & 0x3F}
a271f34 423
  b2 = 0x80 | {{cp >> 6} & 0x3F}
a271f34 424
  b3 := 0x80 | {cp & 0x3F}
a271f34 425
  return byteToStr(b0) + byteToStr(b1) + byteToStr(b2) + byteToStr(b3)
a271f34 426
a271f34 427
fun trimStartFrom(s: Str, i: Int, len: Int) -> Str =
a271f34 428
  if i >= len
a271f34 429
    return ""
a271f34 430
  b := s.byteAt(i)
a271f34 431
  if b != 32 && b != 9 && b != 10 && b != 13
a271f34 432
    return s.sub(i, len)
a271f34 433
  return trimStartFrom(s, i + 1, len)
a271f34 434
a271f34 435
fun trimEndFrom(s: Str, len: Int) -> Str =
a271f34 436
  if len <= 0
a271f34 437
    return ""
a271f34 438
  b := s.byteAt(len - 1)
a271f34 439
  if b != 32 && b != 9 && b != 10 && b != 13
a271f34 440
    return s.sub(0, len)
a271f34 441
  return trimEndFrom(s, len - 1)
a271f34 442
a271f34 443
fun escapeFrom(s: Str, i: Int, len: Int) -> Str =
a271f34 444
  if i >= len
a271f34 445
    return ""
a271f34 446
  return escapeChar(s, i) + escapeFrom(s, i + 1, len)
a271f34 447
a271f34 448
fun escapeChar(s: Str, i: Int) -> Str =
a271f34 449
  match s.byteAt(i)
a271f34 450
    38 => "&amp;"
a271f34 451
    60 => "&lt;"
a271f34 452
    62 => "&gt;"
a271f34 453
    34 => "&quot;"
a271f34 454
    39 => "&#39;"
a271f34 455
    _ => s.get(i)
a271f34 456
a271f34 457
fun isRegExpMetaByte(b: Int) -> Bool =
a271f34 458
  b == 94 || b == 36 || b == 92 || b == 46 || b == 42 || b == 43 || b == 63
a271f34 459
    || b == 40 || b == 41 || b == 91 || b == 93 || b == 123 || b == 125 || b == 124
a271f34 460
a271f34 461
fun escapeRegExpFrom(s: Str, i: Int, len: Int) -> Str =
a271f34 462
  if i >= len
a271f34 463
    return ""
a271f34 464
  piece := isRegExpMetaByte(s.byteAt(i)) ? "\\" + s.get(i) : s.get(i)
a271f34 465
  return piece + escapeRegExpFrom(s, i + 1, len)
a271f34 466
a271f34 467
fun unescapeFrom(s: Str, i: Int, len: Int) -> Str =
a271f34 468
  if i >= len
a271f34 469
    return ""
a271f34 470
  if matchesAt(s, "&amp;", i, 5)
a271f34 471
    return "&" + unescapeFrom(s, i + 5, len)
a271f34 472
  if matchesAt(s, "&lt;", i, 4)
a271f34 473
    return "<" + unescapeFrom(s, i + 4, len)
a271f34 474
  if matchesAt(s, "&gt;", i, 4)
a271f34 475
    return ">" + unescapeFrom(s, i + 4, len)
a271f34 476
  if matchesAt(s, "&quot;", i, 6)
a271f34 477
    return "\"" + unescapeFrom(s, i + 6, len)
a271f34 478
  if matchesAt(s, "&#39;", i, 5)
a271f34 479
    return "'" + unescapeFrom(s, i + 5, len)
a271f34 480
  return s.get(i) + unescapeFrom(s, i + 1, len)
a271f34 481
a271f34 482
# Shared walker behind `words`/`camelCase`/`snakeCase`/`kebabCase`/`upperCase`/
a271f34 483
# `toLower`/`startCase`: splits on non-word bytes and on a lower->upper "camel"
a271f34 484
# transition, joining the surviving words with `sep` and case-mapping each
a271f34 485
# byte per `mode`:
a271f34 486
#   0 = leave case as-is (words)                        1 = lowercase everything (snake/kebab)
a271f34 487
#   2 = camelCase (word 1 lowercase, rest Capitalized)  4 = Capitalize every word (startCase)
a271f34 488
# (`toLower`/`upperCase` are NOT built on this — they map every byte 1:1
a271f34 489
# including non-word bytes like spaces/punctuation, which this function drops.)
a271f34 490
fun caseConvertFrom(s: Str, i: Int, len: Int, sep: Str, mode: Int, word_index: Int, prev_byte: Int) -> Str =
a271f34 491
  if i >= len
a271f34 492
    return ""
a271f34 493
  b := s.byteAt(i)
a271f34 494
  if !isWordByte(b)
a271f34 495
    return caseConvertFrom(s, i + 1, len, sep, mode, word_index, 0)
a271f34 496
  is_word_start := prev_byte == 0 || isCamelBoundary(prev_byte, b)
a271f34 497
  word_index_next := is_word_start ? word_index + 1 : word_index
a271f34 498
  prefix := {is_word_start && word_index > 0} ? sep : ""
a271f34 499
  out_byte := caseConvertByte(b, mode, is_word_start, word_index_next)
a271f34 500
  return prefix + byteToStr(out_byte) + caseConvertFrom(s, i + 1, len, sep, mode, word_index_next, b)
a271f34 501
a271f34 502
fun caseConvertByte(b: Int, mode: Int, is_word_start: Bool, word_index: Int) -> Int =
a271f34 503
  match mode
a271f34 504
    0 => b
a271f34 505
    1 => toLowerByte(b)
a271f34 506
    2 => word_index == 1 ? toLowerByte(b) : {is_word_start ? toUpperByte(b) : toLowerByte(b)}
a271f34 507
    4 => is_word_start ? toUpperByte(b) : toLowerByte(b)
a271f34 508
    _ => b
a271f34 509
a271f34 510
# Simple 1:1 byte map — every byte (including non-word bytes like spaces and
a271f34 511
# punctuation) is preserved; only letters are case-mapped. This is what
a271f34 512
# `toLower`/`upperCase` need but `caseConvertFrom` above (word-boundary
a271f34 513
# splitting/rejoining) doesn't provide.
a271f34 514
fun mapCaseFrom(s: Str, i: Int, len: Int, upper: Bool) -> Str =
a271f34 515
  if i >= len
a271f34 516
    return ""
a271f34 517
  b := s.byteAt(i)
a271f34 518
  mapped := upper ? toUpperByte(b) : toLowerByte(b)
a271f34 519
  return byteToStr(mapped) + mapCaseFrom(s, i + 1, len, upper)
a43f3af 520
a43f3af 521
# ---- string interpolation regression tests ----
a43f3af 522
# `"{expr}"` interpolation is a compiler/codegen feature (not a Str method
a43f3af 523
# above), but Str is what it produces, so its regression coverage lives here.
a43f3af 524
a43f3af 525
fun greetForInterpolationTest(name: Str) -> Str =
a43f3af 526
  "Hello, {name}!"
a43f3af 527
a43f3af 528
test "string interpolation of an int runs correctly"
a43f3af 529
  x := 42
a9a0147 530
  assert "{x}" == "42"
a43f3af 531
a43f3af 532
test "string interpolation of a negative int runs correctly"
a43f3af 533
  x := -7
a9a0147 534
  assert "{x}" == "-7"
a43f3af 535
a43f3af 536
test "string interpolation of zero runs correctly"
a43f3af 537
  x := 0
a9a0147 538
  assert "{x}" == "0"
a43f3af 539
a43f3af 540
test "string interpolation with surrounding text and multiple interps runs correctly"
a43f3af 541
  count := 3
a43f3af 542
  total := 10
a9a0147 543
  assert "{count} of {total} complete" == "3 of 10 complete"
a43f3af 544
a43f3af 545
test "string interpolation of a str runs correctly"
a9a0147 546
  assert greetForInterpolationTest("World") == "Hello, World!"
a43f3af 547
a43f3af 548
test "string interpolation of a bool runs correctly"
a43f3af 549
  b := True
a9a0147 550
  assert "is {b}" == "is True"
2ec05c8 551
2ec05c8 552
test "split divides on every occurrence of the separator"
2ec05c8 553
  parts := "a,b,c".split(",", 0)
a9a0147 554
  assert parts.length() == 3
a9a0147 555
  assert parts.join("|") == "a|b|c"
2ec05c8 556
2ec05c8 557
test "split with a positive limit leaves the remainder unsplit"
2ec05c8 558
  parts := "a,b,c,d".split(",", 2)
a9a0147 559
  assert parts.length() == 2
a9a0147 560
  assert parts.join("|") == "a|b,c,d"
2ec05c8 561
2ec05c8 562
test "split with no separator match returns the whole string as one element"
2ec05c8 563
  parts := "hello".split(",", 0)
a9a0147 564
  assert parts.length() == 1
a9a0147 565
  assert parts.join("|") == "hello"
2ec05c8 566
2ec05c8 567
test "split with an empty separator splits into individual bytes"
2ec05c8 568
  parts := "abc".split("", 0)
a9a0147 569
  assert parts.length() == 3
a9a0147 570
  assert parts.join("|") == "a|b|c"
2ec05c8 571
2ec05c8 572
test "split of an empty string returns a single empty element"
2ec05c8 573
  parts := "".split(",", 0)
a9a0147 574
  assert parts.length() == 1
a9a0147 575
  assert parts.join("|") == ""
2ec05c8 576
2ec05c8 577
test "string interpolation supports a boolean expression, not just a bare variable"
2ec05c8 578
  c1 := True
2ec05c8 579
  c2 := True
2ec05c8 580
  c3 := False
a9a0147 581
  assert "{c1 && c2}" == "True"
a9a0147 582
  assert "{c1 && c3}" == "False"
a9a0147 583
  assert "{c1 || c3}" == "True"
2ec05c8 584
2ec05c8 585
test "string interpolation supports a method call taking a closure literal"
4a2384c 586
  l := List(1, 2, 3, 4)
a9a0147 587
  assert "{l.any(|x| x % 2 == 0)}" == "True"
a9a0147 588
  assert "{l.any(|x| x > 100)}" == "False"
2ec05c8 589
2ec05c8 590
test "string interpolation supports a ternary expression"
2ec05c8 591
  n := 5
a9a0147 592
  assert "{n > 0 ? "positive" : "non-positive"}" == "positive"
bf629a2 593
bf629a2 594
# ---- Str method regression tests ----
bf629a2 595
bf629a2 596
test "endsWith matches a real suffix and rejects a non-suffix or an over-long search"
a9a0147 597
  assert "hello.plum".endsWith(".plum") == True
a9a0147 598
  assert "hello.plum".endsWith(".rs") == False
a9a0147 599
  assert "hi".endsWith("hello") == False
bf629a2 600
bf629a2 601
test "startsWith/endsWith/contains/indexOf agree on a shared example"
bf629a2 602
  s := "the quick brown fox"
a9a0147 603
  assert s.startsWith("the") == True
a9a0147 604
  assert s.endsWith("fox") == True
a9a0147 605
  assert s.contains("quick") == True
a9a0147 606
  assert s.indexOf("brown") == 10
a9a0147 607
  assert s.indexOf("missing") == -1
bf629a2 608
bf629a2 609
test "trim/trimStart/trimEnd strip only leading/trailing whitespace"
a9a0147 610
  assert "  hi  ".trim() == "hi"
a9a0147 611
  assert "  hi  ".trimStart() == "hi  "
a9a0147 612
  assert "  hi  ".trimEnd() == "  hi"
a9a0147 613
  assert "hi".trim() == "hi"
bf629a2 614
bf629a2 615
test "repeat concatenates self count times, and is empty for count <= 0"
a9a0147 616
  assert "ab".repeat(3) == "ababab"
a9a0147 617
  assert "ab".repeat(0) == ""
a9a0147 618
  assert "ab".repeat(-1) == ""
bf629a2 619
bf629a2 620
test "padStart/padEnd/pad grow a string to at least the target length"
a9a0147 621
  assert "5".padStart("0", 3) == "005"
a9a0147 622
  assert "5".padEnd("0", 3) == "500"
a9a0147 623
  assert "hi".pad("-", 6) == "--hi--"
a9a0147 624
  assert "hi".pad("-", 5) == "-hi--"
a9a0147 625
  assert "hello".pad("-", 3) == "hello"
bf629a2 626
bf629a2 627
test "truncate shortens a too-long string with a trailing ellipsis"
a9a0147 628
  assert "hello world".truncate(8) == "hello..."
a9a0147 629
  assert "hi".truncate(8) == "hi"
a9a0147 630
  assert "hello world".truncate(2) == ".."
bf629a2 631
bf629a2 632
test "toLower/upperCase/capitalize/lowerFirst/upperFirst case-convert as expected"
a9a0147 633
  assert "Hello World".toLower() == "hello world"
a9a0147 634
  assert "Hello World".upperCase() == "HELLO WORLD"
a9a0147 635
  assert "hello".capitalize() == "Hello"
a9a0147 636
  assert "Hello".lowerFirst() == "hello"
a9a0147 637
  assert "hello".upperFirst() == "Hello"
bf629a2 638
bf629a2 639
test "camelCase/snakeCase/kebabCase/startCase convert a multi-word phrase"
a9a0147 640
  assert "foo bar baz".camelCase() == "fooBarBaz"
a9a0147 641
  assert "foo bar baz".snakeCase() == "foo_bar_baz"
a9a0147 642
  assert "foo bar baz".kebabCase() == "foo-bar-baz"
a9a0147 643
  assert "foo bar baz".startCase() == "Foo Bar Baz"
bf629a2 644
bf629a2 645
test "reverse reverses by character, not by raw byte"
a9a0147 646
  assert "hello".reverse() == "olleh"
bf629a2 647
bf629a2 648
test "escape/unescape round-trip HTML-sensitive characters"
a9a0147 649
  assert "<a href=\"x\">&'</a>".escape() == "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
a9a0147 650
  assert "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;".unescape() == "<a href=\"x\">&'</a>"