1import { Option } from "effect"
2import { pipe } from "effect/Function"
3import * as S from "effect/String"
4import { assert, describe, it } from "vitest"
5import { assertFalse, assertNone, assertSome, assertTrue, deepStrictEqual, strictEqual } from "./utils/assert.ts"
6
7describe("String", () => {
8 describe("String (constructor)", () => {
9 it("converts values to strings", () => {
10 strictEqual(S.String(123), "123")
11 strictEqual(S.String(true), "true")
12 strictEqual(S.String(null), "null")
13 })
14 })
15
16 describe("isString", () => {
17 it("returns true for strings", () => {
18 assertTrue(S.isString("hello"))
19 assertTrue(S.isString(""))
20 })
21
22 it("returns false for non-strings", () => {
23 assertFalse(S.isString(1))
24 assertFalse(S.isString(null))
25 assertFalse(S.isString(undefined))
26 assertFalse(S.isString(true))
27 assertFalse(S.isString({}))
28 })
29 })
30
31 describe("Order", () => {
32 it("compares strings lexicographically", () => {
33 strictEqual(S.Order("apple", "banana"), -1)
34 strictEqual(S.Order("banana", "apple"), 1)
35 strictEqual(S.Order("apple", "apple"), 0)
36 })
37
38 it("compares empty strings", () => {
39 strictEqual(S.Order("", ""), 0)
40 strictEqual(S.Order("", "a"), -1)
41 strictEqual(S.Order("a", ""), 1)
42 })
43 })
44
45 describe("Equivalence", () => {
46 it("returns true for equal strings", () => {
47 assertTrue(S.Equivalence("a", "a"))
48 })
49
50 it("returns false for different strings", () => {
51 assertFalse(S.Equivalence("a", "b"))
52 })
53 })
54
55 describe("empty", () => {
56 it("is the empty string", () => {
57 strictEqual(S.empty, "")
58 })
59 })
60
61 describe("concat", () => {
62 it("data-first", () => {
63 strictEqual(S.concat("hello", " world"), "hello world")
64 })
65
66 it("data-last (pipeable)", () => {
67 strictEqual(pipe("hello", S.concat(" world")), "hello world")
68 })
69
70 it("concatenates empty strings", () => {
71 strictEqual(S.concat("", ""), "")
72 strictEqual(S.concat("a", ""), "a")
73 strictEqual(S.concat("", "b"), "b")
74 })
75 })
76
77 describe("toUpperCase", () => {
78 it("converts to uppercase", () => {
79 strictEqual(S.toUpperCase("hello"), "HELLO")
80 })
81
82 it("handles empty string", () => {
83 strictEqual(S.toUpperCase(""), "")
84 })
85
86 it("pipeable", () => {
87 strictEqual(pipe("abc", S.toUpperCase), "ABC")
88 })
89 })
90
91 describe("toLowerCase", () => {
92 it("converts to lowercase", () => {
93 strictEqual(S.toLowerCase("HELLO"), "hello")
94 })
95
96 it("handles empty string", () => {
97 strictEqual(S.toLowerCase(""), "")
98 })
99 })
100
101 describe("capitalize", () => {
102 it("capitalizes first character", () => {
103 strictEqual(S.capitalize("hello"), "Hello")
104 })
105
106 it("handles single character", () => {
107 strictEqual(S.capitalize("a"), "A")
108 })
109
110 it("handles empty string", () => {
111 strictEqual(S.capitalize(""), "")
112 })
113
114 it("does not change already capitalized", () => {
115 strictEqual(S.capitalize("Hello"), "Hello")
116 })
117 })
118
119 describe("uncapitalize", () => {
120 it("uncapitalizes first character", () => {
121 strictEqual(S.uncapitalize("Hello"), "hello")
122 })
123
124 it("handles single character", () => {
125 strictEqual(S.uncapitalize("A"), "a")
126 })
127
128 it("handles empty string", () => {
129 strictEqual(S.uncapitalize(""), "")
130 })
131
132 it("preserves rest of string", () => {
133 strictEqual(S.uncapitalize("ABC"), "aBC")
134 })
135 })
136
137 describe("replace", () => {
138 it("replaces first occurrence", () => {
139 strictEqual(pipe("abab", S.replace("b", "c")), "acab")
140 })
141
142 it("replaces with regex", () => {
143 strictEqual(pipe("abc", S.replace(/b/, "d")), "adc")
144 })
145
146 it("returns unchanged if no match", () => {
147 strictEqual(pipe("abc", S.replace("z", "x")), "abc")
148 })
149 })
150
151 describe("trim", () => {
152 it("trims whitespace from both ends", () => {
153 strictEqual(S.trim(" hello "), "hello")
154 })
155
156 it("trims tabs and newlines", () => {
157 strictEqual(S.trim("\t\nhello\t\n"), "hello")
158 })
159
160 it("handles empty string", () => {
161 strictEqual(S.trim(""), "")
162 })
163 })
164
165 describe("trimStart", () => {
166 it("trims leading whitespace", () => {
167 strictEqual(S.trimStart(" hello "), "hello ")
168 })
169
170 it("handles empty string", () => {
171 strictEqual(S.trimStart(""), "")
172 })
173 })
174
175 describe("trimEnd", () => {
176 it("trims trailing whitespace", () => {
177 strictEqual(S.trimEnd(" hello "), " hello")
178 })
179
180 it("handles empty string", () => {
181 strictEqual(S.trimEnd(""), "")
182 })
183 })
184
185 describe("slice", () => {
186 it("extracts a section", () => {
187 strictEqual(pipe("abcd", S.slice(1, 3)), "bc")
188 })
189
190 it("handles negative indices", () => {
191 strictEqual(pipe("hello", S.slice(-3)), "llo")
192 })
193
194 it("no arguments returns full string", () => {
195 strictEqual(pipe("hello", S.slice()), "hello")
196 })
197 })
198
199 describe("isEmpty", () => {
200 it("returns true for empty string", () => {
201 assertTrue(S.isEmpty(""))
202 })
203
204 it("returns false for non-empty string", () => {
205 assertFalse(S.isEmpty("a"))
206 assertFalse(S.isEmpty(" "))
207 })
208 })
209
210 describe("isNonEmpty", () => {
211 it("returns true for non-empty string", () => {
212 assertTrue(S.isNonEmpty("a"))
213 })
214
215 it("returns false for empty string", () => {
216 assertFalse(S.isNonEmpty(""))
217 })
218 })
219
220 describe("length", () => {
221 it("returns string length", () => {
222 strictEqual(S.length("abc"), 3)
223 })
224
225 it("returns 0 for empty string", () => {
226 strictEqual(S.length(""), 0)
227 })
228 })
229
230 describe("split", () => {
231 it("splits by string separator (data-last)", () => {
232 deepStrictEqual(pipe("a,b,c", S.split(",")), ["a", "b", "c"])
233 })
234
235 it("splits by string separator (data-first)", () => {
236 deepStrictEqual(S.split("hello,world", ","), ["hello", "world"])
237 })
238
239 it("splits by empty string", () => {
240 deepStrictEqual(pipe("abc", S.split("")), ["a", "b", "c"])
241 })
242
243 it("splits empty string by empty string", () => {
244 deepStrictEqual(pipe("", S.split("")), [""])
245 })
246
247 it("splits by regex", () => {
248 deepStrictEqual(pipe("a1b2c", S.split(/\d/)), ["a", "b", "c"])
249 })
250 })
251
252 describe("includes", () => {
253 it("returns true when substring found", () => {
254 assertTrue(pipe("hello world", S.includes("world")))
255 })
256
257 it("returns false when substring not found", () => {
258 assertFalse(pipe("hello world", S.includes("foo")))
259 })
260
261 it("starts searching at the given position", () => {
262 assertFalse(pipe("hello", S.includes("hel", 1)))
263 assertTrue(pipe("hello", S.includes("ell", 1)))
264 })
265 })
266
267 describe("startsWith", () => {
268 it("returns true when string starts with search", () => {
269 assertTrue(pipe("hello world", S.startsWith("hello")))
270 })
271
272 it("returns false when string does not start with search", () => {
273 assertFalse(pipe("hello world", S.startsWith("world")))
274 })
275
276 it("checks the prefix at the given position", () => {
277 assertTrue(pipe("hello world", S.startsWith("world", 6)))
278 })
279 })
280
281 describe("endsWith", () => {
282 it("returns true when string ends with search", () => {
283 assertTrue(pipe("hello world", S.endsWith("world")))
284 })
285
286 it("returns false when string does not end with search", () => {
287 assertFalse(pipe("hello world", S.endsWith("hello")))
288 })
289
290 it("checks the suffix at the given end position", () => {
291 assertTrue(pipe("hello world", S.endsWith("hello", 5)))
292 })
293 })
294
295 describe("charCodeAt", () => {
296 it("returns char code at index (data-first)", () => {
297 assertSome(S.charCodeAt("abc", 1), 98)
298 })
299
300 it("returns char code at index (data-last)", () => {
301 assertSome(pipe("abc", S.charCodeAt(0)), 97)
302 })
303
304 it("returns none for out of bounds", () => {
305 assertNone(S.charCodeAt("abc", 10))
306 })
307 })
308
309 describe("substring", () => {
310 it("extracts characters between indices", () => {
311 strictEqual(pipe("abcd", S.substring(1, 3)), "bc")
312 })
313
314 it("extracts from start to end", () => {
315 strictEqual(pipe("abcd", S.substring(1)), "bcd")
316 })
317 })
318
319 describe("at", () => {
320 it("returns character at index", () => {
321 assertSome(pipe("abc", S.at(1)), "b")
322 })
323
324 it("returns none for out of bounds", () => {
325 assertNone(pipe("abc", S.at(10)))
326 })
327 })
328
329 describe("charAt", () => {
330 it("returns character at index (data-first)", () => {
331 assertSome(S.charAt("abc", 1), "b")
332 })
333
334 it("returns character at index (data-last)", () => {
335 assertSome(pipe("abc", S.charAt(1)), "b")
336 })
337
338 it("returns none for out of bounds", () => {
339 assertNone(S.charAt("abc", 10))
340 })
341 })
342
343 describe("codePointAt", () => {
344 it("returns code point at index", () => {
345 assertSome(pipe("abc", S.codePointAt(1)), 98)
346 })
347
348 it("returns none for out of bounds", () => {
349 assertNone(pipe("abc", S.codePointAt(10)))
350 })
351
352 it("handles surrogate pairs", () => {
353 assertSome(pipe("𝟘", S.codePointAt(0)), 120792)
354 })
355 })
356
357 describe("indexOf", () => {
358 it("returns index of first occurrence", () => {
359 assertSome(pipe("abbbc", S.indexOf("b")), 1)
360 })
361
362 it("returns none when not found", () => {
363 assertNone(pipe("abbbc", S.indexOf("z")))
364 })
365 })
366
367 describe("lastIndexOf", () => {
368 it("returns index of last occurrence", () => {
369 assertSome(pipe("abbbc", S.lastIndexOf("b")), 3)
370 })
371
372 it("returns none when not found", () => {
373 assertNone(pipe("abbbc", S.lastIndexOf("z")))
374 })
375 })
376
377 describe("localeCompare", () => {
378 it("returns ordering", () => {
379 strictEqual(pipe("a", S.localeCompare("b")), -1)
380 strictEqual(pipe("b", S.localeCompare("a")), 1)
381 strictEqual(pipe("a", S.localeCompare("a")), 0)
382 })
383 })
384
385 describe("match", () => {
386 it("returns matched text and native match metadata", () => {
387 const result = pipe("hello", S.match(/l+/))
388 assert(Option.isSome(result))
389 strictEqual(result.value[0], "ll")
390 })
391
392 it("returns none when the pattern does not match", () => {
393 assertNone(pipe("hello", S.match(/x/)))
394 })
395 })
396
397 describe("matchAll", () => {
398 it("returns all matches", () => {
399 const result = Array.from(pipe("hello world", S.matchAll(/l/g)))
400 strictEqual(result.length, 3)
401 })
402 })
403
404 describe("normalize", () => {
405 it("normalizes with different forms", () => {
406 const str = "\u1E9B\u0323"
407 strictEqual(pipe(str, S.normalize("NFD")), "\u017F\u0323\u0307")
408 strictEqual(pipe(str, S.normalize("NFKC")), "\u1E69")
409 strictEqual(pipe(str, S.normalize("NFKD")), "\u0073\u0323\u0307")
410 })
411 })
412
413 describe("padEnd", () => {
414 it("pads from end with spaces", () => {
415 strictEqual(pipe("a", S.padEnd(5)), "a ")
416 })
417
418 it("pads from end with fill string", () => {
419 strictEqual(pipe("a", S.padEnd(5, "_")), "a____")
420 })
421
422 it("does not truncate if already longer", () => {
423 strictEqual(pipe("hello", S.padEnd(3)), "hello")
424 })
425 })
426
427 describe("padStart", () => {
428 it("pads from start with spaces", () => {
429 strictEqual(pipe("a", S.padStart(5)), " a")
430 })
431
432 it("pads from start with fill string", () => {
433 strictEqual(pipe("a", S.padStart(5, "_")), "____a")
434 })
435
436 it("does not truncate if already longer", () => {
437 strictEqual(pipe("hello", S.padStart(3)), "hello")
438 })
439 })
440
441 describe("repeat", () => {
442 it("repeats the string", () => {
443 strictEqual(pipe("ab", S.repeat(3)), "ababab")
444 strictEqual(pipe("ab", S.repeat(1.5)), "ab")
445 })
446
447 it("returns an empty string for non-positive or NaN counts", () => {
448 strictEqual(pipe("ab", S.repeat(0)), "")
449 strictEqual(pipe("ab", S.repeat(-1)), "")
450 strictEqual(pipe("ab", S.repeat(Number.NaN)), "")
451 })
452 })
453
454 describe("replaceAll", () => {
455 it("replaces all occurrences with string", () => {
456 strictEqual(pipe("ababb", S.replaceAll("b", "c")), "acacc")
457 })
458
459 it("replaces all occurrences with regex", () => {
460 strictEqual(pipe("ababb", S.replaceAll(/b/g, "c")), "acacc")
461 })
462 })
463
464 describe("search", () => {
465 it("returns index on match (data-first)", () => {
466 assertSome(S.search("ababb", "b"), 1)
467 })
468
469 it("returns index on match (data-last)", () => {
470 assertSome(pipe("ababb", S.search("b")), 1)
471 })
472
473 it("returns none when not found", () => {
474 assertNone(S.search("ababb", "d"))
475 })
476
477 it("works with regex", () => {
478 assertSome(S.search("ababb", /bb/), 3)
479 })
480 })
481
482 describe("toLocaleLowerCase", () => {
483 it("converts to locale lowercase", () => {
484 strictEqual(pipe("ABC", S.toLocaleLowerCase()), "abc")
485 })
486 })
487
488 describe("toLocaleUpperCase", () => {
489 it("converts to locale uppercase", () => {
490 strictEqual(pipe("abc", S.toLocaleUpperCase()), "ABC")
491 })
492 })
493
494 describe("takeLeft", () => {
495 it("takes n characters from the start (data-first)", () => {
496 strictEqual(S.takeLeft("Hello World", 5), "Hello")
497 strictEqual(S.takeLeft("abc", 1.5), "a")
498 })
499
500 it("takes n characters from the start (data-last)", () => {
501 strictEqual(pipe("Hello World", S.takeLeft(5)), "Hello")
502 })
503
504 it("returns whole string if n > length", () => {
505 strictEqual(S.takeLeft("abc", 10), "abc")
506 })
507
508 it("returns an empty string for non-positive or NaN counts", () => {
509 strictEqual(S.takeLeft("abc", 0), "")
510 strictEqual(S.takeLeft("abc", -1), "")
511 strictEqual(S.takeLeft("abc", Number.NaN), "")
512 })
513 })
514
515 describe("takeRight", () => {
516 it("takes n characters from the end (data-first)", () => {
517 strictEqual(S.takeRight("Hello World", 5), "World")
518 strictEqual(S.takeRight("abc", 1.5), "c")
519 })
520
521 it("takes n characters from the end (data-last)", () => {
522 strictEqual(pipe("Hello World", S.takeRight(5)), "World")
523 })
524
525 it("returns whole string if n > length", () => {
526 strictEqual(S.takeRight("abc", 10), "abc")
527 })
528
529 it("returns an empty string for non-positive or NaN counts", () => {
530 strictEqual(S.takeRight("abc", 0), "")
531 strictEqual(S.takeRight("abc", -1), "")
532 strictEqual(S.takeRight("abc", Number.NaN), "")
533 })
534 })
535
536 describe("linesIterator", () => {
537 it("yields lines without separators", () => {
538 deepStrictEqual(Array.from(S.linesIterator("hello\nworld\n")), ["hello", "world"])
539 })
540
541 it("handles CRLF", () => {
542 deepStrictEqual(Array.from(S.linesIterator("a\r\nb\r\n")), ["a", "b"])
543 })
544
545 it("handles single line without newline", () => {
546 deepStrictEqual(Array.from(S.linesIterator("hello")), ["hello"])
547 })
548
549 it("handles empty string", () => {
550 deepStrictEqual(Array.from(S.linesIterator("")), [])
551 })
552 })
553
554 describe("linesWithSeparators", () => {
555 it("yields lines with separators", () => {
556 deepStrictEqual(Array.from(S.linesWithSeparators("hello\nworld\n")), ["hello\n", "world\n"])
557 })
558
559 it("handles CRLF", () => {
560 deepStrictEqual(Array.from(S.linesWithSeparators("a\r\nb\r\n")), ["a\r\n", "b\r\n"])
561 })
562
563 it("handles line without trailing newline", () => {
564 deepStrictEqual(Array.from(S.linesWithSeparators("hello")), ["hello"])
565 })
566 })
567
568 describe("stripMargin", () => {
569 it("strips | margin", () => {
570 strictEqual(S.stripMargin(" |hello\n |world"), "hello\nworld")
571 })
572 })
573
574 describe("stripMarginWith", () => {
575 it("strips custom margin character (data-first)", () => {
576 strictEqual(S.stripMarginWith(" #hello\n #world", "#"), "hello\nworld")
577 })
578
579 it("strips custom margin character (data-last)", () => {
580 strictEqual(pipe(" #hello\n #world", S.stripMarginWith("#")), "hello\nworld")
581 })
582
583 it("leaves lines without margin unchanged", () => {
584 strictEqual(S.stripMarginWith("no margin\n |yes", "|"), "no margin\nyes")
585 })
586 })
587
588 describe("snakeToCamel", () => {
589 it("converts snake_case to camelCase", () => {
590 strictEqual(S.snakeToCamel("hello_world"), "helloWorld")
591 strictEqual(S.snakeToCamel("foo_bar_baz"), "fooBarBaz")
592 })
593
594 it("handles single word", () => {
595 strictEqual(S.snakeToCamel("hello"), "hello")
596 })
597
598 it("handles an empty string", () => {
599 strictEqual(S.snakeToCamel(""), "")
600 })
601 })
602
603 describe("snakeToPascal", () => {
604 it("converts snake_case to PascalCase", () => {
605 strictEqual(S.snakeToPascal("hello_world"), "HelloWorld")
606 strictEqual(S.snakeToPascal("foo_bar_baz"), "FooBarBaz")
607 })
608
609 it("handles an empty string", () => {
610 strictEqual(S.snakeToPascal(""), "")
611 })
612 })
613
614 describe("snakeToKebab", () => {
615 it("converts snake_case to kebab-case", () => {
616 strictEqual(S.snakeToKebab("hello_world"), "hello-world")
617 strictEqual(S.snakeToKebab("foo_bar_baz"), "foo-bar-baz")
618 })
619 })
620
621 describe("camelToSnake", () => {
622 it("converts camelCase to snake_case", () => {
623 strictEqual(S.camelToSnake("helloWorld"), "hello_world")
624 strictEqual(S.camelToSnake("fooBarBaz"), "foo_bar_baz")
625 })
626 })
627
628 describe("pascalToSnake", () => {
629 it("converts PascalCase to snake_case", () => {
630 strictEqual(S.pascalToSnake("HelloWorld"), "hello_world")
631 strictEqual(S.pascalToSnake("FooBarBaz"), "foo_bar_baz")
632 })
633 })
634
635 describe("kebabToSnake", () => {
636 it("converts kebab-case to snake_case", () => {
637 strictEqual(S.kebabToSnake("hello-world"), "hello_world")
638 strictEqual(S.kebabToSnake("foo-bar-baz"), "foo_bar_baz")
639 })
640 })
641
642 describe("noCase", () => {
643 it("converts to space-separated lowercase by default", () => {
644 strictEqual(S.noCase("helloWorld"), "hello world")
645 strictEqual(S.noCase("HelloWorld"), "hello world")
646 })
647
648 it("data-last with options", () => {
649 strictEqual(pipe("helloWorld", S.noCase({ delimiter: "-" })), "hello-world")
650 })
651
652 it("uses a custom split regular expression", () => {
653 strictEqual(S.noCase("ab", { splitRegExp: /([a])([b])/g }), "a b")
654 })
655
656 it("uses custom split regular expressions", () => {
657 strictEqual(S.noCase("abc", { splitRegExp: [/([a])([b])/g, /([b])([c])/g] }), "a b c")
658 })
659
660 it("uses a custom strip regular expression", () => {
661 strictEqual(S.noCase("a_b-c", { stripRegExp: /_/g }), "a b-c")
662 })
663
664 it("uses custom strip regular expressions", () => {
665 strictEqual(S.noCase("a_b-c", { stripRegExp: [/_/g, /-/g] }), "a b c")
666 })
667
668 it("handles underscores and hyphens", () => {
669 strictEqual(S.noCase("hello_world"), "hello world")
670 strictEqual(S.noCase("hello-world"), "hello world")
671 })
672
673 it("splits digit-letter boundaries", () => {
674 strictEqual(S.noCase("field2value"), "field 2 value")
675 strictEqual(S.noCase("field2Value"), "field 2 value")
676 })
677 })
678
679 describe("pascalCase", () => {
680 it("converts to PascalCase", () => {
681 strictEqual(S.pascalCase("hello world"), "HelloWorld")
682 strictEqual(S.pascalCase("hello_world"), "HelloWorld")
683 strictEqual(S.pascalCase("helloWorld"), "HelloWorld")
684 })
685
686 it("does not prefix numeric segments with underscores", () => {
687 strictEqual(S.pascalCase("foo 2 bar"), "Foo2Bar")
688 strictEqual(S.pascalCase("api-v2 xml"), "ApiV2Xml")
689 })
690 })
691
692 describe("camelCase", () => {
693 it("converts to camelCase", () => {
694 strictEqual(S.camelCase("hello world"), "helloWorld")
695 strictEqual(S.camelCase("hello_world"), "helloWorld")
696 strictEqual(S.camelCase("HelloWorld"), "helloWorld")
697 })
698
699 it("does not prefix numeric segments with underscores", () => {
700 strictEqual(S.camelCase("foo 2 bar"), "foo2Bar")
701 strictEqual(S.camelCase("api-v2 xml"), "apiV2Xml")
702 })
703 })
704
705 describe("constantCase", () => {
706 it("converts to CONSTANT_CASE", () => {
707 strictEqual(S.constantCase("hello world"), "HELLO_WORLD")
708 strictEqual(S.constantCase("helloWorld"), "HELLO_WORLD")
709 strictEqual(S.constantCase("api-v2 xml"), "API_V_2_XML")
710 })
711 })
712
713 describe("configCase", () => {
714 it("converts to CONFIG_CASE", () => {
715 strictEqual(S.configCase("hello world"), "HELLO_WORLD")
716 strictEqual(S.configCase("helloWorld"), "HELLO_WORLD")
717 strictEqual(S.configCase("api-v2 xml"), "API_V2_XML")
718 strictEqual(S.configCase("field2Value"), "FIELD2_VALUE")
719 })
720 })
721
722 describe("kebabCase", () => {
723 it("converts to kebab-case", () => {
724 strictEqual(S.kebabCase("hello world"), "hello-world")
725 strictEqual(S.kebabCase("helloWorld"), "hello-world")
726 })
727 })
728
729 describe("snakeCase", () => {
730 it("converts to snake_case", () => {
731 strictEqual(S.snakeCase("hello world"), "hello_world")
732 strictEqual(S.snakeCase("helloWorld"), "hello_world")
733 })
734 })
735
736 describe("ReducerConcat", () => {
737 it("combines strings", () => {
738 strictEqual(S.ReducerConcat.combine("a", "b"), "ab")
739 })
740
741 it("identity with initialValue", () => {
742 strictEqual(S.ReducerConcat.combine("a", S.ReducerConcat.initialValue), "a")
743 strictEqual(S.ReducerConcat.combine(S.ReducerConcat.initialValue, "a"), "a")
744 })
745 })
746})