plum
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 => "&" |
| a271f34 | 451 | 60 => "<" |
| a271f34 | 452 | 62 => ">" |
| a271f34 | 453 | 34 => """ |
| a271f34 | 454 | 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, "&", i, 5) |
| a271f34 | 471 | return "&" + unescapeFrom(s, i + 5, len) |
| a271f34 | 472 | if matchesAt(s, "<", i, 4) |
| a271f34 | 473 | return "<" + unescapeFrom(s, i + 4, len) |
| a271f34 | 474 | if matchesAt(s, ">", i, 4) |
| a271f34 | 475 | return ">" + unescapeFrom(s, i + 4, len) |
| a271f34 | 476 | if matchesAt(s, """, i, 6) |
| a271f34 | 477 | return "\"" + unescapeFrom(s, i + 6, len) |
| a271f34 | 478 | if matchesAt(s, "'", 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() == "<a href="x">&'</a>" |
| a9a0147 | 650 | assert "<a href="x">&'</a>".unescape() == "<a href=\"x\">&'</a>" |