plum

#treesitter#compiler#wasm

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
Files changed (1) hide show
  1. 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
- runeLengthFrom(self, 0, self.length(), 0)
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
- runeAtFrom(self, 0, charIndex)
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
- return {sub + self}.padStart(sub, count)
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
- return {self + sub}.padEnd(sub, count)
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 self + self.repeat(count - 1)
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, pattern: Regex, sub: Str) -> Str =
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
- todo
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, pattern: Regex) -> Str =
231
+ fun search(self, sub: Str) -> Int =
184
- todo
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 self.get(s) + self.sub(s + 1, en)
282
+ return bytesToStr(self.data.data, s, en - s)
235
283
 
236
284
  fun toLower(self) -> Str =
237
- mapCaseFrom(self, 0, self.length(), false)
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
- reverseFrom(self, 0)
300
+ return bytesToStr(out, 0, n)
244
301
 
245
302
  fun camelCase(self) -> Str =
246
- caseConvertFrom(self, 0, self.length(), "", 2, 0, 0)
303
+ caseConvert(self, "", 2)
247
304
 
248
305
  fun snakeCase(self) -> Str =
249
- caseConvertFrom(self, 0, self.length(), "_", 1, 0, 0)
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
- caseConvertFrom(self, 0, self.length(), "-", 1, 0, 0)
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
- mapCaseFrom(self, 0, self.length(), true)
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
- caseConvertFrom(self, 0, self.length(), " ", 4, 0, 0)
333
+ caseConvert(self, " ", 4)
277
334
 
335
+ # Strips Latin-1 Supplement and Latin Extended-A diacritics to their ASCII
278
- # Diacritic stripping needs a Unicode decomposition table (`é` -> `e`, ...) —
336
+ # base letters (`é` -> `e`, `ß` -> `ss`, `æ` -> `ae`, `Ł` -> `L`). Other
279
- # far more than the ASCII byte-array `Str` primitives here support. Left as a
337
+ # characters (including `×`/`÷` and everything outside U+00C0..U+017F)
280
- # known gap rather than a wrong "ASCII-only" approximation.
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
- todo
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
- escapeFrom(self, 0, self.length())
368
+ escapeHtml(self)
286
369
 
287
370
  fun escapeRegExp(self) -> Str =
288
- escapeRegExpFrom(self, 0, self.length())
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
- trimEndFrom(self, self.length())
391
+ self.sub(0, trimEndIndex(self))
320
392
 
321
393
  fun trimStart(self) -> Str =
322
- trimStartFrom(self, 0, self.length())
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
- unescapeFrom(self, 0, self.length())
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
- caseConvertFrom(self, 0, self.length(), " ", 0, 0, 0)
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, `caseConvertFrom` (backing `words`/
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 trimStartFrom(s: Str, i: Int, len: Int) -> Str =
488
+ fun trimStartIndex(s: Str) -> Int =
432
- if i >= len
433
- return ""
434
- b := s.byteAt(i)
489
+ n := s.length()
490
+ i := 0
435
- if b != 32 && b != 9 && b != 10 && b != 13
491
+ while i < n && isTrimByte(s.byteAt(i))
492
+ i = i + 1
436
- return s.sub(i, len)
493
+ return i
494
+
437
- return trimStartFrom(s, i + 1, len)
495
+ # Exclusive end index of `s` with trailing whitespace dropped.
438
-
439
- fun trimEndFrom(s: Str, len: Int) -> Str =
496
+ fun trimEndIndex(s: Str) -> Int =
440
- if len <= 0
441
- return ""
442
- b := s.byteAt(len - 1)
497
+ n := s.length()
443
- if b != 32 && b != 9 && b != 10 && b != 13
498
+ while n > 0 && isTrimByte(s.byteAt(n - 1))
499
+ n = n - 1
444
- return s.sub(0, len)
500
+ return n
445
- return trimEndFrom(s, len - 1)
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 escapeChar(s: Str, i: Int) -> Str =
502
+ fun htmlEntity(b: Int) -> Str =
453
- match s.byteAt(i)
503
+ match b
454
504
  38 => "&amp;"
455
505
  60 => "&lt;"
456
506
  62 => "&gt;"
457
507
  34 => "&quot;"
458
508
  39 => "&#39;"
459
- _ => s.get(i)
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 escapeRegExpFrom(s: Str, i: Int, len: Int) -> Str =
529
+ fun escapeRegExpStr(s: Str) -> Str =
530
+ n := s.length()
531
+ buf := Buffer()
532
+ i := 0
466
- if i >= len
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
- return ""
539
+ return buf.toStr()
540
+
468
- piece := if isRegExpMetaByte(s.byteAt(i)) then "\\" + s.get(i) else s.get(i)
541
+ # Length of the HTML entity starting at `i` (and writes its decoded byte
469
- return piece + escapeRegExpFrom(s, i + 1, len)
542
+ # into `buf`), or 0 if none starts there.
470
-
471
- fun unescapeFrom(s: Str, i: Int, len: Int) -> Str =
543
+ fun unescapeEntityAt(s: Str, i: Int, buf: Buffer) -> Int =
472
- if i >= len
473
- return ""
474
544
  if matchesAt(s, "&amp;", i, 5)
475
- return "&" + unescapeFrom(s, i + 5, len)
545
+ buf.writeByte(Byte(38))
546
+ return 5
476
547
  if matchesAt(s, "&lt;", i, 4)
477
- return "<" + unescapeFrom(s, i + 4, len)
548
+ buf.writeByte(Byte(60))
549
+ return 4
478
550
  if matchesAt(s, "&gt;", i, 4)
479
- return ">" + unescapeFrom(s, i + 4, len)
551
+ buf.writeByte(Byte(62))
552
+ return 4
480
553
  if matchesAt(s, "&quot;", i, 6)
481
- return "\"" + unescapeFrom(s, i + 6, len)
554
+ buf.writeByte(Byte(34))
555
+ return 6
482
556
  if matchesAt(s, "&#39;", i, 5)
557
+ buf.writeByte(Byte(39))
558
+ return 5
559
+ return 0
560
+
483
- return "'" + unescapeFrom(s, i + 5, len)
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
- return s.get(i) + unescapeFrom(s, i + 1, len)
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`/`upperCase`/
575
+ # Shared walker behind `words`/`camelCase`/`snakeCase`/`kebabCase`/`startCase`:
487
- # `toLower`/`startCase`: splits on non-word bytes and on a lower->upper "camel"
576
+ # splits on non-word bytes and on a lower->upper "camel" transition, joining
488
- # transition, joining the surviving words with `sep` and case-mapping each
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` are NOT built on this — they map every byte 1:1
580
+ # (`toLower`/`upperCase` use `mapCase` — they map every byte 1:1 including
493
- # including non-word bytes like spaces/punctuation, which this function drops.)
581
+ # non-word bytes like spaces/punctuation, which this function drops.)
494
- fun caseConvertFrom(s: Str, i: Int, len: Int, sep: Str, mode: Int, word_index: Int, prev_byte: Int) -> Str =
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
- if i >= len
588
+ while i < n
496
- return ""
497
- b := s.byteAt(i)
589
+ b := s.byteAt(i)
498
- if !isWordByte(b)
590
+ if !isWordByte(b)
499
- return caseConvertFrom(s, i + 1, len, sep, mode, word_index, 0)
591
+ prev_byte = 0
592
+ else
500
- is_word_start := prev_byte == 0 || isCamelBoundary(prev_byte, b)
593
+ is_word_start := prev_byte == 0 || isCamelBoundary(prev_byte, b)
594
+ if is_word_start
501
- word_index_next := if is_word_start then word_index + 1 else word_index
595
+ word_index = word_index + 1
502
- prefix := if {is_word_start && word_index > 0} then sep else ""
596
+ if word_index > 1
597
+ buf.write(sep)
503
- out_byte := caseConvertByte(b, mode, is_word_start, word_index_next)
598
+ buf.writeByte(Byte(caseConvertByte(b, mode, is_word_start, word_index)))
599
+ prev_byte = b
600
+ i = i + 1
504
- return prefix + byteToStr(out_byte) + caseConvertFrom(s, i + 1, len, sep, mode, word_index_next, b)
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. This is what
516
- # `toLower`/`upperCase` need but `caseConvertFrom` above (word-boundary
517
- # splitting/rejoining) doesn't provide.
518
- fun mapCaseFrom(s: Str, i: Int, len: Int, upper: Bool) -> Str =
519
- if i >= len
520
- return ""
521
- b := s.byteAt(i)
522
- mapped := if upper then toUpperByte(b) else toLowerByte(b)
523
- return byteToStr(mapped) + mapCaseFrom(s, i + 1, len, upper)
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 日本"