plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
bfc8f08
— Peter John
2026-11-01T21:42:35+05:30
perf: Str methods linear loops; plain-string replace/replaceAll/search/deburr; drop regex stubs
- plum-std/Str.plum +335 -135
plum-std/Str.plum
CHANGED
|
@@ -4,6 +4,8 @@ import std/List
|
|
|
4
4
|
import std/Buffer
|
|
5
5
|
import std/Int
|
|
6
6
|
import std/Float
|
|
7
|
+
import std/Byte
|
|
8
|
+
import std/ByteSlice
|
|
7
9
|
|
|
8
10
|
# A Str is an immutable byte sequence, backed by a real `Buffer` in its
|
|
9
11
|
# `data` field, not a compiler special case: `Str` is an ordinary
|
|
@@ -103,13 +105,24 @@ record Str(data: Buffer) =
|
|
|
103
105
|
# Number of Unicode CHARACTERS (codepoints) — as opposed to `length`,
|
|
104
106
|
# which counts bytes and only agrees with this for pure-ASCII text.
|
|
105
107
|
fun runeLength(self) -> Int =
|
|
108
|
+
n := self.length()
|
|
109
|
+
i := 0
|
|
110
|
+
count := 0
|
|
111
|
+
while i < n
|
|
106
|
-
|
|
112
|
+
i = i + self.codePointByteLength(i)
|
|
113
|
+
count = count + 1
|
|
114
|
+
return count
|
|
107
115
|
|
|
108
116
|
# The `charIndex`-th Unicode character, re-encoded as its own (1-4 byte)
|
|
109
117
|
# `Str`. Unlike `get` (which indexes by BYTE and can split a character in
|
|
110
118
|
# half), this always returns a complete character.
|
|
111
119
|
fun runeAt(self, charIndex: Int) -> Str =
|
|
120
|
+
i := 0
|
|
112
|
-
|
|
121
|
+
k := charIndex
|
|
122
|
+
while k > 0
|
|
123
|
+
i = i + self.codePointByteLength(i)
|
|
124
|
+
k = k - 1
|
|
125
|
+
return self.sub(i, i + self.codePointByteLength(i))
|
|
113
126
|
|
|
114
127
|
fun contains(self, search: Str) -> Bool =
|
|
115
128
|
self.indexOf(search) >= 0
|
|
@@ -126,9 +139,6 @@ record Str(data: Buffer) =
|
|
|
126
139
|
i = i + 1
|
|
127
140
|
return -1
|
|
128
141
|
|
|
129
|
-
fun test(self, pattern: Regex) -> Bool =
|
|
130
|
-
todo
|
|
131
|
-
|
|
132
142
|
fun startsWith(self, search: Str) -> Bool =
|
|
133
143
|
matchesAt(self, search, 0, search.length())
|
|
134
144
|
|
|
@@ -150,38 +160,76 @@ record Str(data: Buffer) =
|
|
|
150
160
|
else
|
|
151
161
|
return error.BadBoolStr
|
|
152
162
|
|
|
153
|
-
fun matchPattern(self, pattern: Regex) -> List[Str] =
|
|
154
|
-
todo
|
|
155
|
-
|
|
156
|
-
fun matchAll(self, pattern: Regex) -> List[Str] =
|
|
157
|
-
todo
|
|
158
|
-
|
|
159
163
|
# Pads the start of `self` with repeated copies of `sub` until its length is
|
|
160
164
|
# at least `count`. If `sub` doesn't evenly divide the needed amount, the
|
|
161
165
|
# final length may overshoot `count` by up to `sub.length() - 1`.
|
|
162
166
|
fun padStart(self, sub: Str, count: Int) -> Str =
|
|
163
167
|
if self.length() >= count || sub.length() == 0
|
|
164
168
|
return self
|
|
169
|
+
buf := Buffer()
|
|
165
|
-
|
|
170
|
+
need := count - self.length()
|
|
171
|
+
written := 0
|
|
172
|
+
while written < need
|
|
173
|
+
buf.write(sub)
|
|
174
|
+
written = written + sub.length()
|
|
175
|
+
buf.write(self)
|
|
176
|
+
return buf.toStr()
|
|
166
177
|
|
|
167
178
|
fun padEnd(self, sub: Str, count: Int) -> Str =
|
|
168
179
|
if self.length() >= count || sub.length() == 0
|
|
169
180
|
return self
|
|
181
|
+
buf := Buffer()
|
|
182
|
+
buf.write(self)
|
|
183
|
+
need := count - self.length()
|
|
184
|
+
written := 0
|
|
185
|
+
while written < need
|
|
186
|
+
buf.write(sub)
|
|
170
|
-
|
|
187
|
+
written = written + sub.length()
|
|
188
|
+
return buf.toStr()
|
|
171
189
|
|
|
172
190
|
fun repeat(self, count: Int) -> Str =
|
|
173
191
|
if count <= 0
|
|
174
192
|
return ""
|
|
193
|
+
buf := Buffer()
|
|
194
|
+
i := 0
|
|
195
|
+
while i < count
|
|
196
|
+
buf.write(self)
|
|
197
|
+
i = i + 1
|
|
175
|
-
return
|
|
198
|
+
return buf.toStr()
|
|
176
|
-
|
|
177
|
-
fun replace(self, pattern: Regex, sub: Str) -> Str =
|
|
178
|
-
todo
|
|
179
199
|
|
|
200
|
+
# Replaces the FIRST occurrence of `old` with `new` (byte-exact). Returns
|
|
201
|
+
# `self` if `old` doesn't occur.
|
|
202
|
+
fun replace(self, old: Str, new: Str) -> Str =
|
|
203
|
+
idx := self.indexOf(old)
|
|
204
|
+
if idx < 0
|
|
205
|
+
return self
|
|
206
|
+
buf := Buffer()
|
|
207
|
+
buf.write(self.sub(0, idx))
|
|
208
|
+
buf.write(new)
|
|
209
|
+
buf.write(self.sub(idx + old.length(), self.length()))
|
|
210
|
+
return buf.toStr()
|
|
211
|
+
|
|
212
|
+
# Replaces every non-overlapping occurrence of `old`, scanning left to
|
|
213
|
+
# right. An empty `old` returns `self` unchanged.
|
|
180
|
-
fun replaceAll(self,
|
|
214
|
+
fun replaceAll(self, old: Str, new: Str) -> Str =
|
|
215
|
+
m := old.length()
|
|
216
|
+
if m == 0
|
|
217
|
+
return self
|
|
218
|
+
n := self.length()
|
|
219
|
+
buf := Buffer()
|
|
220
|
+
i := 0
|
|
221
|
+
while i < n
|
|
222
|
+
if matchesAt(self, old, i, m)
|
|
223
|
+
buf.write(new)
|
|
224
|
+
i = i + m
|
|
181
|
-
|
|
225
|
+
else
|
|
226
|
+
buf.writeByte(Byte(self.byteAt(i)))
|
|
227
|
+
i = i + 1
|
|
228
|
+
return buf.toStr()
|
|
182
229
|
|
|
230
|
+
# Byte index of the first occurrence of `sub`, or -1 (same as `indexOf`).
|
|
183
|
-
fun search(self,
|
|
231
|
+
fun search(self, sub: Str) -> Int =
|
|
184
|
-
|
|
232
|
+
self.indexOf(sub)
|
|
185
233
|
|
|
186
234
|
# JS-`String.prototype.slice`-style: a negative `start`/`e` counts from the
|
|
187
235
|
# end. Clamped to a valid range; `e` is exclusive.
|
|
@@ -231,22 +279,31 @@ record Str(data: Buffer) =
|
|
|
231
279
|
en := if e > len then len else e
|
|
232
280
|
if s >= en
|
|
233
281
|
return ""
|
|
234
|
-
return
|
|
282
|
+
return bytesToStr(self.data.data, s, en - s)
|
|
235
283
|
|
|
236
284
|
fun toLower(self) -> Str =
|
|
237
|
-
|
|
285
|
+
mapCase(self, false)
|
|
238
286
|
|
|
239
287
|
# Reverses a Str BY CHARACTER (codepoint), not by byte — reversing raw
|
|
240
288
|
# UTF-8 bytes would scramble every multi-byte character's own byte order
|
|
241
289
|
# along with the string's, corrupting any non-ASCII text.
|
|
242
290
|
fun reverse(self) -> Str =
|
|
291
|
+
n := self.length()
|
|
292
|
+
out := makeBytes(n)
|
|
293
|
+
i := 0
|
|
294
|
+
while i < n
|
|
295
|
+
cl := self.codePointByteLength(i)
|
|
296
|
+
if i + cl > n
|
|
297
|
+
cl = n - i
|
|
298
|
+
copyBytes(out, n - i - cl, self.data.data, i, cl)
|
|
299
|
+
i = i + cl
|
|
243
|
-
|
|
300
|
+
return bytesToStr(out, 0, n)
|
|
244
301
|
|
|
245
302
|
fun camelCase(self) -> Str =
|
|
246
|
-
|
|
303
|
+
caseConvert(self, "", 2)
|
|
247
304
|
|
|
248
305
|
fun snakeCase(self) -> Str =
|
|
249
|
-
|
|
306
|
+
caseConvert(self, "_", 1)
|
|
250
307
|
|
|
251
308
|
fun capitalize(self) -> Str =
|
|
252
309
|
if self.length() == 0
|
|
@@ -254,7 +311,7 @@ record Str(data: Buffer) =
|
|
|
254
311
|
return self.get(0).upperCase() + self.sub(1, self.length()).toLower()
|
|
255
312
|
|
|
256
313
|
fun kebabCase(self) -> Str =
|
|
257
|
-
|
|
314
|
+
caseConvert(self, "-", 1)
|
|
258
315
|
|
|
259
316
|
fun lowerCase(self) -> Str =
|
|
260
317
|
self.toLower()
|
|
@@ -265,7 +322,7 @@ record Str(data: Buffer) =
|
|
|
265
322
|
return self.get(0).toLower() + self.sub(1, self.length())
|
|
266
323
|
|
|
267
324
|
fun upperCase(self) -> Str =
|
|
268
|
-
|
|
325
|
+
mapCase(self, true)
|
|
269
326
|
|
|
270
327
|
fun upperFirst(self) -> Str =
|
|
271
328
|
if self.length() == 0
|
|
@@ -273,19 +330,45 @@ record Str(data: Buffer) =
|
|
|
273
330
|
return self.get(0).upperCase() + self.sub(1, self.length())
|
|
274
331
|
|
|
275
332
|
fun startCase(self) -> Str =
|
|
276
|
-
|
|
333
|
+
caseConvert(self, " ", 4)
|
|
277
334
|
|
|
335
|
+
# Strips Latin-1 Supplement and Latin Extended-A diacritics to their ASCII
|
|
278
|
-
#
|
|
336
|
+
# base letters (`é` -> `e`, `ß` -> `ss`, `æ` -> `ae`, `Ł` -> `L`). Other
|
|
279
|
-
#
|
|
337
|
+
# characters (including `×`/`÷` and everything outside U+00C0..U+017F)
|
|
280
|
-
#
|
|
338
|
+
# pass through unchanged. Compact table, not exhaustive Unicode.
|
|
281
339
|
fun deburr(self) -> Str =
|
|
340
|
+
n := self.length()
|
|
341
|
+
t1 := deburrLatin1()
|
|
342
|
+
t2 := deburrLatinExtA()
|
|
343
|
+
buf := Buffer()
|
|
344
|
+
i := 0
|
|
345
|
+
while i < n
|
|
346
|
+
cl := self.codePointByteLength(i)
|
|
347
|
+
if cl == 1
|
|
348
|
+
buf.writeByte(Byte(self.byteAt(i)))
|
|
282
|
-
|
|
349
|
+
else
|
|
350
|
+
cp := self.codePointAt(i)
|
|
351
|
+
idx := -1
|
|
352
|
+
tbl := t1
|
|
353
|
+
if cp >= 0xC0 && cp < 0x100
|
|
354
|
+
idx = {cp - 0xC0} * 2
|
|
355
|
+
else if cp >= 0x100 && cp < 0x180
|
|
356
|
+
idx = {cp - 0x100} * 2
|
|
357
|
+
tbl = t2
|
|
358
|
+
if idx < 0 || tbl.byteAt(idx) == 35
|
|
359
|
+
buf.write(self.sub(i, i + cl))
|
|
360
|
+
else
|
|
361
|
+
buf.writeByte(Byte(tbl.byteAt(idx)))
|
|
362
|
+
if tbl.byteAt(idx + 1) != 32
|
|
363
|
+
buf.writeByte(Byte(tbl.byteAt(idx + 1)))
|
|
364
|
+
i = i + cl
|
|
365
|
+
return buf.toStr()
|
|
283
366
|
|
|
284
367
|
fun escape(self) -> Str =
|
|
285
|
-
|
|
368
|
+
escapeHtml(self)
|
|
286
369
|
|
|
287
370
|
fun escapeRegExp(self) -> Str =
|
|
288
|
-
|
|
371
|
+
escapeRegExpStr(self)
|
|
289
372
|
|
|
290
373
|
# Pads BOTH sides of `self` with repeated copies of `sub` until its length
|
|
291
374
|
# is at least `count`, split as evenly as possible between the two sides
|
|
@@ -301,25 +384,14 @@ record Str(data: Buffer) =
|
|
|
301
384
|
right := total - left
|
|
302
385
|
return self.padStart(sub, len + left).padEnd(sub, len + left + right)
|
|
303
386
|
|
|
304
|
-
# `template` (lodash's `_.template`) compiles a string containing
|
|
305
|
-
# `<%= expr %>`-style placeholders into a REUSABLE function that renders it
|
|
306
|
-
# against different data each call — that needs the ability to compile and
|
|
307
|
-
# run Plum source AT RUNTIME (from a `Str` value, not a `.plum` file), which
|
|
308
|
-
# the language has no way to do (no `eval`, no dynamic codegen from within
|
|
309
|
-
# a running program). Left as a known gap rather than a fundamentally
|
|
310
|
-
# different, simpler feature (e.g. a one-shot find/replace) under the same
|
|
311
|
-
# name.
|
|
312
|
-
fun template(self) -> Str =
|
|
313
|
-
todo
|
|
314
|
-
|
|
315
387
|
fun trim(self) -> Str =
|
|
316
388
|
self.trimStart().trimEnd()
|
|
317
389
|
|
|
318
390
|
fun trimEnd(self) -> Str =
|
|
319
|
-
|
|
391
|
+
self.sub(0, trimEndIndex(self))
|
|
320
392
|
|
|
321
393
|
fun trimStart(self) -> Str =
|
|
322
|
-
|
|
394
|
+
self.sub(trimStartIndex(self), self.length())
|
|
323
395
|
|
|
324
396
|
# Truncates `self` to at most `length` bytes (INCLUDING the "..." suffix,
|
|
325
397
|
# lodash's default `omission` string) if it's longer than `length`; returns
|
|
@@ -334,12 +406,12 @@ record Str(data: Buffer) =
|
|
|
334
406
|
return self.sub(0, length - omission.length()) + omission
|
|
335
407
|
|
|
336
408
|
fun unescape(self) -> Str =
|
|
337
|
-
|
|
409
|
+
unescapeHtml(self)
|
|
338
410
|
|
|
339
411
|
# Space-joined, un-cased word boundaries (splits on whitespace/punctuation
|
|
340
412
|
# AND on a lower->upper "camel" transition, e.g. "fooBar baz" -> "foo Bar baz").
|
|
341
413
|
fun words(self) -> Str =
|
|
342
|
-
|
|
414
|
+
caseConvert(self, " ", 0)
|
|
343
415
|
|
|
344
416
|
# A new 1-byte Str holding `b`'s low 8 bits — a compiler intrinsic (there's no
|
|
345
417
|
# way to build a new array from Plum source), the counterpart to `byteAt`.
|
|
@@ -357,7 +429,7 @@ fun isAsciiDigit(b: Int) -> Bool =
|
|
|
357
429
|
|
|
358
430
|
# Byte 0x80+ is either a UTF-8 continuation byte or the lead byte of a
|
|
359
431
|
# multi-byte character — never an ASCII separator — so it's treated as
|
|
360
|
-
# "part of a word" here. Without this, `
|
|
432
|
+
# "part of a word" here. Without this, `caseConvert` (backing `words`/
|
|
361
433
|
# `camelCase`/`snakeCase`/`kebabCase`/`startCase`) would treat every
|
|
362
434
|
# non-ASCII character as a word BOUNDARY, shredding any text that isn't
|
|
363
435
|
# pure ASCII into a separate "word" per byte.
|
|
@@ -386,24 +458,6 @@ fun matchesAt(s: Str, sub: Str, offset: Int, sub_len: Int) -> Bool =
|
|
|
386
458
|
j = j + 1
|
|
387
459
|
return true
|
|
388
460
|
|
|
389
|
-
fun reverseFrom(s: Str, i: Int) -> Str =
|
|
390
|
-
if i >= s.length()
|
|
391
|
-
return ""
|
|
392
|
-
n := s.codePointByteLength(i)
|
|
393
|
-
return reverseFrom(s, i + n) + s.sub(i, i + n)
|
|
394
|
-
|
|
395
|
-
fun runeLengthFrom(s: Str, i: Int, len: Int, count: Int) -> Int =
|
|
396
|
-
if i >= len
|
|
397
|
-
return count
|
|
398
|
-
n := s.codePointByteLength(i)
|
|
399
|
-
return runeLengthFrom(s, i + n, len, count + 1)
|
|
400
|
-
|
|
401
|
-
fun runeAtFrom(s: Str, byte_index: Int, chars_remaining: Int) -> Str =
|
|
402
|
-
n := s.codePointByteLength(byte_index)
|
|
403
|
-
if chars_remaining == 0
|
|
404
|
-
return s.sub(byte_index, byte_index + n)
|
|
405
|
-
return runeAtFrom(s, byte_index + n, chars_remaining - 1)
|
|
406
|
-
|
|
407
461
|
# Encodes a single Unicode codepoint (its raw integer value) as its own
|
|
408
462
|
# UTF-8 `Str` (1-4 bytes) — the reverse of `Str.codePointAt`. `b0`/`b1`/`b2`
|
|
409
463
|
# are declared ONCE up front and reassigned per branch (rather than
|
|
@@ -428,80 +482,123 @@ fun codePointToStr(cp: Int) -> Str =
|
|
|
428
482
|
b3 := 0x80 | {cp & 0x3F}
|
|
429
483
|
return byteToStr(b0) + byteToStr(b1) + byteToStr(b2) + byteToStr(b3)
|
|
430
484
|
|
|
485
|
+
fun isTrimByte(b: Int) -> Bool =
|
|
486
|
+
b == 32 || b == 9 || b == 10 || b == 13
|
|
487
|
+
|
|
431
|
-
fun
|
|
488
|
+
fun trimStartIndex(s: Str) -> Int =
|
|
432
|
-
if i >= len
|
|
433
|
-
return ""
|
|
434
|
-
|
|
489
|
+
n := s.length()
|
|
490
|
+
i := 0
|
|
435
|
-
|
|
491
|
+
while i < n && isTrimByte(s.byteAt(i))
|
|
492
|
+
i = i + 1
|
|
436
|
-
|
|
493
|
+
return i
|
|
494
|
+
|
|
437
|
-
|
|
495
|
+
# Exclusive end index of `s` with trailing whitespace dropped.
|
|
438
|
-
|
|
439
|
-
fun
|
|
496
|
+
fun trimEndIndex(s: Str) -> Int =
|
|
440
|
-
if len <= 0
|
|
441
|
-
return ""
|
|
442
|
-
|
|
497
|
+
n := s.length()
|
|
443
|
-
|
|
498
|
+
while n > 0 && isTrimByte(s.byteAt(n - 1))
|
|
499
|
+
n = n - 1
|
|
444
|
-
|
|
500
|
+
return n
|
|
445
|
-
|
|
501
|
+
|
|
446
|
-
|
|
447
|
-
fun escapeFrom(s: Str, i: Int, len: Int) -> Str =
|
|
448
|
-
if i >= len
|
|
449
|
-
return ""
|
|
450
|
-
return escapeChar(s, i) + escapeFrom(s, i + 1, len)
|
|
451
|
-
|
|
452
|
-
fun
|
|
502
|
+
fun htmlEntity(b: Int) -> Str =
|
|
453
|
-
match
|
|
503
|
+
match b
|
|
454
504
|
38 => "&"
|
|
455
505
|
60 => "<"
|
|
456
506
|
62 => ">"
|
|
457
507
|
34 => """
|
|
458
508
|
39 => "'"
|
|
459
|
-
_ =>
|
|
509
|
+
_ => ""
|
|
510
|
+
|
|
511
|
+
fun escapeHtml(s: Str) -> Str =
|
|
512
|
+
n := s.length()
|
|
513
|
+
buf := Buffer()
|
|
514
|
+
i := 0
|
|
515
|
+
while i < n
|
|
516
|
+
b := s.byteAt(i)
|
|
517
|
+
ent := htmlEntity(b)
|
|
518
|
+
if ent.length() == 0
|
|
519
|
+
buf.writeByte(Byte(b))
|
|
520
|
+
else
|
|
521
|
+
buf.write(ent)
|
|
522
|
+
i = i + 1
|
|
523
|
+
return buf.toStr()
|
|
460
524
|
|
|
461
525
|
fun isRegExpMetaByte(b: Int) -> Bool =
|
|
462
526
|
b == 94 || b == 36 || b == 92 || b == 46 || b == 42 || b == 43 || b == 63
|
|
463
527
|
|| b == 40 || b == 41 || b == 91 || b == 93 || b == 123 || b == 125 || b == 124
|
|
464
528
|
|
|
465
|
-
fun
|
|
529
|
+
fun escapeRegExpStr(s: Str) -> Str =
|
|
530
|
+
n := s.length()
|
|
531
|
+
buf := Buffer()
|
|
532
|
+
i := 0
|
|
466
|
-
|
|
533
|
+
while i < n
|
|
534
|
+
b := s.byteAt(i)
|
|
535
|
+
if isRegExpMetaByte(b)
|
|
536
|
+
buf.writeByte(Byte(92))
|
|
537
|
+
buf.writeByte(Byte(b))
|
|
538
|
+
i = i + 1
|
|
467
|
-
|
|
539
|
+
return buf.toStr()
|
|
540
|
+
|
|
468
|
-
|
|
541
|
+
# Length of the HTML entity starting at `i` (and writes its decoded byte
|
|
469
|
-
|
|
542
|
+
# into `buf`), or 0 if none starts there.
|
|
470
|
-
|
|
471
|
-
fun
|
|
543
|
+
fun unescapeEntityAt(s: Str, i: Int, buf: Buffer) -> Int =
|
|
472
|
-
if i >= len
|
|
473
|
-
return ""
|
|
474
544
|
if matchesAt(s, "&", i, 5)
|
|
475
|
-
|
|
545
|
+
buf.writeByte(Byte(38))
|
|
546
|
+
return 5
|
|
476
547
|
if matchesAt(s, "<", i, 4)
|
|
477
|
-
|
|
548
|
+
buf.writeByte(Byte(60))
|
|
549
|
+
return 4
|
|
478
550
|
if matchesAt(s, ">", i, 4)
|
|
479
|
-
|
|
551
|
+
buf.writeByte(Byte(62))
|
|
552
|
+
return 4
|
|
480
553
|
if matchesAt(s, """, i, 6)
|
|
481
|
-
|
|
554
|
+
buf.writeByte(Byte(34))
|
|
555
|
+
return 6
|
|
482
556
|
if matchesAt(s, "'", i, 5)
|
|
557
|
+
buf.writeByte(Byte(39))
|
|
558
|
+
return 5
|
|
559
|
+
return 0
|
|
560
|
+
|
|
483
|
-
|
|
561
|
+
fun unescapeHtml(s: Str) -> Str =
|
|
562
|
+
n := s.length()
|
|
563
|
+
buf := Buffer()
|
|
564
|
+
i := 0
|
|
565
|
+
while i < n
|
|
566
|
+
k := 0
|
|
567
|
+
if s.byteAt(i) == 38
|
|
484
|
-
|
|
568
|
+
k = unescapeEntityAt(s, i, buf)
|
|
485
|
-
|
|
569
|
+
if k == 0
|
|
570
|
+
buf.writeByte(Byte(s.byteAt(i)))
|
|
571
|
+
k = 1
|
|
572
|
+
i = i + k
|
|
573
|
+
return buf.toStr()
|
|
574
|
+
|
|
486
|
-
# Shared walker behind `words`/`camelCase`/`snakeCase`/`kebabCase`/`
|
|
575
|
+
# Shared walker behind `words`/`camelCase`/`snakeCase`/`kebabCase`/`startCase`:
|
|
487
|
-
#
|
|
576
|
+
# splits on non-word bytes and on a lower->upper "camel" transition, joining
|
|
488
|
-
#
|
|
577
|
+
# the surviving words with `sep` and case-mapping each byte per `mode`:
|
|
489
|
-
# byte per `mode`:
|
|
490
578
|
# 0 = leave case as-is (words) 1 = lowercase everything (snake/kebab)
|
|
491
579
|
# 2 = camelCase (word 1 lowercase, rest Capitalized) 4 = Capitalize every word (startCase)
|
|
492
|
-
# (`toLower`/`upperCase`
|
|
580
|
+
# (`toLower`/`upperCase` use `mapCase` — they map every byte 1:1 including
|
|
493
|
-
#
|
|
581
|
+
# non-word bytes like spaces/punctuation, which this function drops.)
|
|
494
|
-
fun
|
|
582
|
+
fun caseConvert(s: Str, sep: Str, mode: Int) -> Str =
|
|
583
|
+
n := s.length()
|
|
584
|
+
buf := Buffer()
|
|
585
|
+
word_index := 0
|
|
586
|
+
prev_byte := 0
|
|
587
|
+
i := 0
|
|
495
|
-
|
|
588
|
+
while i < n
|
|
496
|
-
return ""
|
|
497
|
-
|
|
589
|
+
b := s.byteAt(i)
|
|
498
|
-
|
|
590
|
+
if !isWordByte(b)
|
|
499
|
-
|
|
591
|
+
prev_byte = 0
|
|
592
|
+
else
|
|
500
|
-
|
|
593
|
+
is_word_start := prev_byte == 0 || isCamelBoundary(prev_byte, b)
|
|
594
|
+
if is_word_start
|
|
501
|
-
|
|
595
|
+
word_index = word_index + 1
|
|
502
|
-
|
|
596
|
+
if word_index > 1
|
|
597
|
+
buf.write(sep)
|
|
503
|
-
|
|
598
|
+
buf.writeByte(Byte(caseConvertByte(b, mode, is_word_start, word_index)))
|
|
599
|
+
prev_byte = b
|
|
600
|
+
i = i + 1
|
|
504
|
-
return
|
|
601
|
+
return buf.toStr()
|
|
505
602
|
|
|
506
603
|
fun caseConvertByte(b: Int, mode: Int, is_word_start: Bool, word_index: Int) -> Int =
|
|
507
604
|
match mode
|
|
@@ -512,15 +609,25 @@ fun caseConvertByte(b: Int, mode: Int, is_word_start: Bool, word_index: Int) ->
|
|
|
512
609
|
_ => b
|
|
513
610
|
|
|
514
611
|
# Simple 1:1 byte map — every byte (including non-word bytes like spaces and
|
|
515
|
-
# punctuation) is preserved; only letters are case-mapped.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
612
|
+
# punctuation) is preserved; only letters are case-mapped.
|
|
613
|
+
fun mapCase(s: Str, upper: Bool) -> Str =
|
|
614
|
+
n := s.length()
|
|
615
|
+
buf := Buffer()
|
|
616
|
+
i := 0
|
|
617
|
+
while i < n
|
|
618
|
+
b := s.byteAt(i)
|
|
619
|
+
buf.writeByte(Byte(if upper then toUpperByte(b) else toLowerByte(b)))
|
|
620
|
+
i = i + 1
|
|
621
|
+
return buf.toStr()
|
|
622
|
+
|
|
623
|
+
# 2-char cells (space padded), one per codepoint U+00C0..U+00FF. `#` = keep
|
|
624
|
+
# the original character (`×`, `÷`).
|
|
625
|
+
fun deburrLatin1() -> Str =
|
|
626
|
+
"A A A A A A AEC E E E E I I I I D N O O O O O # O U U U U Y Thssa a a a a a aec e e e e i i i i d n o o o o o # o u u u u y thy "
|
|
627
|
+
|
|
628
|
+
# Same layout for U+0100..U+017F (Latin Extended-A).
|
|
629
|
+
fun deburrLatinExtA() -> Str =
|
|
630
|
+
"A a A a A a C c C c C c C c D d D d E e E e E e E e E e G g G g G g G g H h H h I i I i I i I i I i IJijJ j K k k L l L l L l L l L l N n N n N n n N n O o O o O o OEoeR r R r R r S s S s S s S s T t T t T t U u U u U u U u U u U u W w Y y Y Z z Z z Z z s "
|
|
524
631
|
|
|
525
632
|
# ---- string interpolation regression tests ----
|
|
526
633
|
# `"{expr}"` interpolation is a compiler/codegen feature (not a Str method
|
|
@@ -529,6 +636,14 @@ fun mapCaseFrom(s: Str, i: Int, len: Int, upper: Bool) -> Str =
|
|
|
529
636
|
fun greetForInterpolationTest(name: Str) -> Str =
|
|
530
637
|
"Hello, {name}!"
|
|
531
638
|
|
|
639
|
+
fun bigAscii(n: Int, b: Int) -> Str =
|
|
640
|
+
buf := Buffer()
|
|
641
|
+
i := 0
|
|
642
|
+
while i < n
|
|
643
|
+
buf.writeByte(Byte(b))
|
|
644
|
+
i = i + 1
|
|
645
|
+
return buf.toStr()
|
|
646
|
+
|
|
532
647
|
test "string interpolation of an int runs correctly"
|
|
533
648
|
x := 42
|
|
534
649
|
assert "{x}" == "42"
|
|
@@ -663,3 +778,88 @@ test "Str.parseBool accepts true/false and rejects everything else"
|
|
|
663
778
|
assert "true".parseBool() catch false
|
|
664
779
|
assert !{"false".parseBool() catch true}
|
|
665
780
|
assert !{"yes".parseBool() catch false}
|
|
781
|
+
|
|
782
|
+
# ---- large-input (linear-time, no stack growth) regression tests ----
|
|
783
|
+
|
|
784
|
+
test "sub of a 200000-byte string is a single copy"
|
|
785
|
+
s := bigAscii(200000, 97)
|
|
786
|
+
r := s.sub(10, 199990)
|
|
787
|
+
assert r.length() == 199980
|
|
788
|
+
assert s.sub(0, 200000).length() == 200000
|
|
789
|
+
assert s.slice(-5, 200000) == "aaaaa"
|
|
790
|
+
|
|
791
|
+
test "reverse of a 100000-char string"
|
|
792
|
+
buf := Buffer()
|
|
793
|
+
i := 0
|
|
794
|
+
while i < 100000
|
|
795
|
+
buf.writeByte(Byte(if i == 0 then 98 else 97))
|
|
796
|
+
i = i + 1
|
|
797
|
+
r := buf.toStr().reverse()
|
|
798
|
+
assert r.length() == 100000
|
|
799
|
+
assert r.byteAt(99999) == 98
|
|
800
|
+
assert r.byteAt(0) == 97
|
|
801
|
+
|
|
802
|
+
test "escape and unescape of a 100000-char string"
|
|
803
|
+
s := bigAscii(100000, 60)
|
|
804
|
+
e := s.escape()
|
|
805
|
+
assert e.length() == 400000
|
|
806
|
+
assert e.unescape() == s
|
|
807
|
+
|
|
808
|
+
test "camelCase of a 100000-char string"
|
|
809
|
+
buf := Buffer()
|
|
810
|
+
i := 0
|
|
811
|
+
while i < 50000
|
|
812
|
+
buf.write("ab ")
|
|
813
|
+
i = i + 1
|
|
814
|
+
r := buf.toStr().camelCase()
|
|
815
|
+
assert r.length() == 50000 * 2
|
|
816
|
+
assert r.startsWith("abAbAb")
|
|
817
|
+
|
|
818
|
+
test "runeLength of 100000 multi-byte chars"
|
|
819
|
+
buf := Buffer()
|
|
820
|
+
i := 0
|
|
821
|
+
while i < 100000
|
|
822
|
+
buf.write("é")
|
|
823
|
+
i = i + 1
|
|
824
|
+
s := buf.toStr()
|
|
825
|
+
assert s.length() == 200000
|
|
826
|
+
assert s.runeLength() == 100000
|
|
827
|
+
assert s.runeAt(99999) == "é"
|
|
828
|
+
|
|
829
|
+
test "split into 10000 parts"
|
|
830
|
+
buf := Buffer()
|
|
831
|
+
i := 0
|
|
832
|
+
while i < 10000
|
|
833
|
+
buf.write("x,")
|
|
834
|
+
i = i + 1
|
|
835
|
+
parts := buf.toStr().split(",", 0)
|
|
836
|
+
assert parts.length() == 10001
|
|
837
|
+
|
|
838
|
+
test "replace swaps only the first occurrence"
|
|
839
|
+
assert "a-b-c".replace("-", "+") == "a+b-c"
|
|
840
|
+
assert "abc".replace("x", "y") == "abc"
|
|
841
|
+
assert "abc".replace("abc", "") == ""
|
|
842
|
+
assert "héllo wörld".replace("ö", "oe") == "héllo woerld"
|
|
843
|
+
|
|
844
|
+
test "replaceAll swaps every non-overlapping occurrence"
|
|
845
|
+
assert "a-b-c".replaceAll("-", "+") == "a+b+c"
|
|
846
|
+
assert "aaa".replaceAll("aa", "b") == "ba"
|
|
847
|
+
assert "abc".replaceAll("", "x") == "abc"
|
|
848
|
+
assert "abc".replaceAll("x", "y") == "abc"
|
|
849
|
+
assert "héhé".replaceAll("é", "e") == "hehe"
|
|
850
|
+
assert "aXbXc".replaceAll("X", "--") == "a--b--c"
|
|
851
|
+
|
|
852
|
+
test "search returns the byte index or -1, like indexOf"
|
|
853
|
+
assert "the quick fox".search("quick") == 4
|
|
854
|
+
assert "the quick fox".search("slow") == -1
|
|
855
|
+
assert "abc".search("") == 0
|
|
856
|
+
|
|
857
|
+
test "deburr strips Latin-1 and Latin Extended-A diacritics"
|
|
858
|
+
assert "café".deburr() == "cafe"
|
|
859
|
+
assert "señor".deburr() == "senor"
|
|
860
|
+
assert "Straße".deburr() == "Strasse"
|
|
861
|
+
assert "Ærø".deburr() == "AEro"
|
|
862
|
+
assert "Łódź".deburr() == "Lodz"
|
|
863
|
+
assert "œuvre Ÿ ž".deburr() == "oeuvre Y z"
|
|
864
|
+
assert "plain ASCII 1+1".deburr() == "plain ASCII 1+1"
|
|
865
|
+
assert "3×4÷2 日本".deburr() == "3×4÷2 日本"
|