src/str.bend

ezsys@0.1.0.0 · 495 lines · 45 definitions

1# ezsys/src/str: text helpers that Python's str gives a service for free:
2# strip with Python's whitespace, replace, split and find on a text
3# separator, lower and upper case for ASCII, Latin-1, Latin Extended-A,
4# Greek and Cyrillic, padding and truncation by code point, and the numeric
5# order of digit strings. Pure; every function walks its text once, left to
6# right, one code point a step.
7import Base
8
9# --- whitespace ---
10
11# Python's whitespace (str.isspace, str.strip with no argument): the code
12# points 9 to 13, 28 to 32, 133, 160, 5760, 8192 to 8202, 8232, 8233, 8239,
13# 8287 and 12288
14def text.spaces() -> List<&2, U32>:
15  [9, 10, 11, 12, 13, 28, 29, 30, 31, 32, 133, 160, 5760, 8192, 8193, 8194,
16    8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287,
17    12288]
18
19# whether a character is Python whitespace
20def text.space(chr: Char) -> Bool:
21  match chr:
22    case Chr{+x}:
23      List.contains(~U32, ~U32.is_eq, text.spaces(), x)
24
25# a character at the start: dropped when it is whitespace, and the rest
26# stripped too; else the text is kept from it
27def text.lstrip.if(
28  sp: Bool,
29  hd: Char,
30  tl: String,
31  more: Unit -> String
32) -> String:
33  match sp:
34    case True{}:
35      more(Unit{})
36    case False{}:
37      SCon{hd, tl}
38
39# the text with its leading whitespace removed
40def text.lstrip(txt: String) -> String:
41  match txt:
42    case SNil{}:
43      SNil{}
44    case SCon{+h, +t}:
45      text.lstrip.if(text.space(h), h, t, _u => text.lstrip(t))
46
47# a lone last character: gone when it is whitespace
48def text.rstrip.lone(sp: Bool, hd: Char) -> String:
49  match sp:
50    case True{}:
51      SNil{}
52    case False{}:
53      SCon{hd, SNil{}}
54
55# a character before the stripped rest: when nothing is left after it, it
56# goes too if it is whitespace
57def text.rstrip.put(+hd: Char, rule: String) -> String:
58  match rule:
59    case SNil{}:
60      text.rstrip.lone(text.space(hd), hd)
61    case SCon{a, b}:
62      SCon{hd, SCon{a, b}}
63
64# the text with its trailing whitespace removed
65def text.rstrip(txt: String) -> String:
66  match txt:
67    case SNil{}:
68      SNil{}
69    case SCon{+h, t}:
70      text.rstrip.put(h, text.rstrip(t))
71
72# the text with whitespace removed from both ends
73def text.strip(txt: String) -> String:
74  text.rstrip(text.lstrip(txt))
75
76# --- replace ---
77
78# how many characters of a separator follow its first
79def text.after(sep: String) -> Nat:
80  match sep:
81    case SNil{}:
82      0n
83    case SCon{_h, t}:
84      String.length(t)
85
86# a nonempty `old` at this character, or not: write `new` and pass over
87# the rest of `old`, or write the character
88def text.replace.at(
89  hit: Bool,
90  hd: Char,
91  +new: String,
92  +left: Nat,
93  next: Nat -> String
94) -> String:
95  match hit:
96    case True{}:
97      new ++ next(left)
98    case False{}:
99      SCon{hd, next(0n)}
100
101# the scan for a nonempty `old`; `skip` characters of a found `old` are
102# still to pass over
103def text.replace.go(
104  txt: String,
105  skip: Nat,
106  +old: String,
107  +new: String
108) -> String:
109  match txt skip:
110    case SNil{} _:
111      SNil{}
112    case SCon{_h, t} 1n+k:
113      text.replace.go(t, k, old, new)
114    case SCon{+h, +t} 0n:
115      text.replace.at(String.starts_with(SCon{h, t}, old), h, new,
116        text.after(old),
117        k => text.replace.go(t, k, old, new))
118
119# an empty `old`: `new` after every character
120def text.replace.gaps(txt: String, +new: String) -> String:
121  match txt:
122    case SNil{}:
123      SNil{}
124    case SCon{h, t}:
125      SCon{h, new ++ text.replace.gaps(t, new)}
126
127# every `old` replaced by `new`, left to right, never overlapping; an empty
128# `old` puts `new` before every character and at the end
129def text.replace(txt: String, +old: String, +new: String) -> String:
130  match old:
131    case SNil{}:
132      new ++ text.replace.gaps(txt, new)
133    case SCon{oh, ot}:
134      text.replace.go(txt, 0n, SCon{oh, ot}, new)
135
136# --- split ---
137
138# a character on the front of the first piece
139def text.split.push(hd: Char, ps: List<&2, String>) -> List<&2, String>:
140  match ps:
141    case Nil{}:
142      [SCon{hd, SNil{}}]
143    case p <> rest:
144      SCon{hd, p} <> rest
145
146# a separator at this character ends a piece; else the character starts it
147def text.split.at(
148  hit: Bool,
149  hd: Char,
150  +left: Nat,
151  next: Nat -> List<&2, String>
152) -> List<&2, String>:
153  match hit:
154    case True{}:
155      SNil{} <> next(left)
156    case False{}:
157      text.split.push(hd, next(0n))
158
159# the scan for a nonempty separator; `skip` characters of a found separator
160# are still to pass over
161def text.split.go(txt: String, skip: Nat, +sep: String) -> List<&2, String>:
162  match txt skip:
163    case SNil{} _:
164      [SNil{}]
165    case SCon{_h, t} 1n+k:
166      text.split.go(t, k, sep)
167    case SCon{+h, +t} 0n:
168      text.split.at(String.starts_with(SCon{h, t}, sep), h,
169        text.after(sep),
170        k => text.split.go(t, k, sep))
171
172# the pieces between the separators, in order; an empty separator splits
173# nothing
174def text.split(txt: String, +sep: String) -> List<&2, String>:
175  match sep:
176    case SNil{}:
177      [txt]
178    case SCon{sh, st}:
179      text.split.go(txt, 0n, SCon{sh, st})
180
181# --- find ---
182
183# a place one character further on
184def text.find.inc(got: Maybe<&2, U32>) -> Maybe<&2, U32>:
185  match got:
186    case None{}:
187      None{}
188    case Some{+i}:
189      Some{(i + 1 : U32)}
190
191# the needle here, or further on
192def text.find.at(here: Bool, rest: Unit -> Maybe<&2, U32>) -> Maybe<&2, U32>:
193  match here:
194    case True{}:
195      Some{0}
196    case False{}:
197      text.find.inc(rest(Unit{}))
198
199# at the end of the text only an empty needle is found
200def text.find.end(needle: String) -> Maybe<&2, U32>:
201  match needle:
202    case SNil{}:
203      Some{0}
204    case SCon{_h, _t}:
205      None{}
206
207# the index, in code points, of the first place the needle starts
208def text.find(txt: String, +needle: String) -> Maybe<&2, U32>:
209  match txt:
210    case SNil{}:
211      text.find.end(needle)
212    case SCon{+h, +t}:
213      text.find.at(String.starts_with(SCon{h, t}, needle),
214        _u => text.find(t, needle))
215
216# --- case ---
217
218# a run of code points `lo` to `hi`, every `step`-th one from `lo`, mapped
219# in order onto the code points from `to`
220type Rule is Data:
221  Rule{lo: U32, hi: U32, step: U32, to: U32}
222
223# a code point that maps to more than one
224type Multi is Data:
225  Multi{from: U32, to: String}
226
227# whether a rule maps the code point
228def text.case.covers(rule: Rule, +cp: U32) -> Bool:
229  match rule:
230    case Rule{+lo, +hi, +step, _to}:
231      Bool.and(Bool.and(U32.is_ge(cp, lo), U32.is_le(cp, hi)),
232        U32.is_eq(((cp - lo) % step : U32), 0))
233
234# where a rule maps a code point it covers
235def text.case.target(rule: Rule, +cp: U32) -> U32:
236  match rule:
237    case Rule{+lo, _hi, _step, +to}:
238      (to + (cp - lo) : U32)
239
240# the first rule's mapping, or the rest's
241def text.case.pick(
242  hit: Bool,
243  +rule: Rule,
244  +cp: U32,
245  rest: Unit -> U32
246) -> U32:
247  match hit:
248    case True{}:
249      text.case.target(rule, cp)
250    case False{}:
251      rest(Unit{})
252
253# the code point the first rule that covers it maps it to; itself when none
254# does
255def text.case.apply(rules: List<&2, Rule>, +cp: U32) -> U32:
256  match rules:
257    case Nil{}:
258      cp
259    case +r <> rest:
260      text.case.pick(text.case.covers(r, cp), r, cp,
261        _u => text.case.apply(rest, cp))
262
263# this entry's text, or the rest's
264def text.case.found(
265  hit: Bool,
266  to: String,
267  rest: Unit -> Maybe<&2, String>
268) -> Maybe<&2, String>:
269  match hit:
270    case True{}:
271      Some{to}
272    case False{}:
273      rest(Unit{})
274
275# the text a code point that maps to more than one maps to
276def text.case.multi(ms: List<&2, Multi>, +cp: U32) -> Maybe<&2, String>:
277  match ms:
278    case Nil{}:
279      None{}
280    case Multi{+from, +to} <> rest:
281      text.case.found(U32.is_eq(from, cp), to,
282        _u => text.case.multi(rest, cp))
283
284# a multiple mapping's text, or the rules' one code point
285def text.case.one(
286  got: Maybe<&2, String>,
287  +cp: U32,
288  rules: List<&2, Rule>
289) -> String:
290  match got:
291    case Some{s}:
292      s
293    case None{}:
294      SCon{Chr{text.case.apply(rules, cp)}, SNil{}}
295
296# a code point mapped by a table: its multiple mapping, else the rules
297def text.case.of(
298  ms: List<&2, Multi>,
299  rules: List<&2, Rule>,
300  +cp: U32
301) -> String:
302  text.case.one(text.case.multi(ms, cp), cp, rules)
303
304# Unicode 13's lowercase mappings (Python's str.lower) of U+0000 to U+017F
305# and U+0370 to U+04FF that are one code point
306def text.lower.rules() -> List<&2, Rule>:
307  [
308    Rule{65, 90, 1, 97},
309    Rule{192, 214, 1, 224},
310    Rule{216, 222, 1, 248},
311    Rule{256, 302, 2, 257},
312    Rule{306, 310, 2, 307},
313    Rule{313, 327, 2, 314},
314    Rule{330, 374, 2, 331},
315    Rule{376, 376, 1, 255},
316    Rule{377, 381, 2, 378},
317    Rule{880, 882, 2, 881},
318    Rule{886, 886, 1, 887},
319    Rule{895, 895, 1, 1011},
320    Rule{902, 902, 1, 940},
321    Rule{904, 906, 1, 941},
322    Rule{908, 908, 1, 972},
323    Rule{910, 911, 1, 973},
324    Rule{913, 929, 1, 945},
325    Rule{931, 939, 1, 963},
326    Rule{975, 975, 1, 983},
327    Rule{984, 1006, 2, 985},
328    Rule{1012, 1012, 1, 952},
329    Rule{1015, 1015, 1, 1016},
330    Rule{1017, 1017, 1, 1010},
331    Rule{1018, 1018, 1, 1019},
332    Rule{1021, 1023, 1, 891},
333    Rule{1024, 1039, 1, 1104},
334    Rule{1040, 1071, 1, 1072},
335    Rule{1120, 1152, 2, 1121},
336    Rule{1162, 1214, 2, 1163},
337    Rule{1216, 1216, 1, 1231},
338    Rule{1217, 1229, 2, 1218},
339    Rule{1232, 1278, 2, 1233}
340  ]
341
342# the lowercase mapping of U+0130 is two code points
343def text.lower.multi() -> List<&2, Multi>:
344  [Multi{304, "i\u{307}"}]
345
346# Unicode 13's uppercase mappings (Python's str.upper) of U+0000 to U+017F
347# and U+0370 to U+04FF that are one code point
348def text.upper.rules() -> List<&2, Rule>:
349  [
350    Rule{97, 122, 1, 65},
351    Rule{181, 181, 1, 924},
352    Rule{224, 246, 1, 192},
353    Rule{248, 254, 1, 216},
354    Rule{255, 255, 1, 376},
355    Rule{257, 303, 2, 256},
356    Rule{305, 305, 1, 73},
357    Rule{307, 311, 2, 306},
358    Rule{314, 328, 2, 313},
359    Rule{331, 375, 2, 330},
360    Rule{378, 382, 2, 377},
361    Rule{383, 383, 1, 83},
362    Rule{881, 883, 2, 880},
363    Rule{887, 887, 1, 886},
364    Rule{891, 893, 1, 1021},
365    Rule{940, 940, 1, 902},
366    Rule{941, 943, 1, 904},
367    Rule{945, 961, 1, 913},
368    Rule{962, 962, 1, 931},
369    Rule{963, 971, 1, 931},
370    Rule{972, 972, 1, 908},
371    Rule{973, 974, 1, 910},
372    Rule{976, 976, 1, 914},
373    Rule{977, 977, 1, 920},
374    Rule{981, 981, 1, 934},
375    Rule{982, 982, 1, 928},
376    Rule{983, 983, 1, 975},
377    Rule{985, 1007, 2, 984},
378    Rule{1008, 1008, 1, 922},
379    Rule{1009, 1009, 1, 929},
380    Rule{1010, 1010, 1, 1017},
381    Rule{1011, 1011, 1, 895},
382    Rule{1013, 1013, 1, 917},
383    Rule{1016, 1016, 1, 1015},
384    Rule{1019, 1019, 1, 1018},
385    Rule{1072, 1103, 1, 1040},
386    Rule{1104, 1119, 1, 1024},
387    Rule{1121, 1153, 2, 1120},
388    Rule{1163, 1215, 2, 1162},
389    Rule{1218, 1230, 2, 1217},
390    Rule{1231, 1231, 1, 1216},
391    Rule{1233, 1279, 2, 1232}
392  ]
393
394# the uppercase mappings of U+00DF, U+0149, U+0390 and U+03B0 are more than
395# one code point
396def text.upper.multi() -> List<&2, Multi>:
397  [
398    Multi{223, "SS"},
399    Multi{329, "\u{2BC}N"},
400    Multi{912, "\u{399}\u{308}\u{301}"},
401    Multi{944, "\u{3A5}\u{308}\u{301}"}
402  ]
403
404# one character in lowercase
405def text.lower.char(chr: Char) -> String:
406  match chr:
407    case Chr{+x}:
408      text.case.of(text.lower.multi(), text.lower.rules(), x)
409
410# one character in uppercase
411def text.upper.char(chr: Char) -> String:
412  match chr:
413    case Chr{+x}:
414      text.case.of(text.upper.multi(), text.upper.rules(), x)
415
416# the text in lowercase, character by character
417def text.lower(txt: String) -> String:
418  match txt:
419    case SNil{}:
420      SNil{}
421    case SCon{h, t}:
422      text.lower.char(h) ++ text.lower(t)
423
424# the text in uppercase, character by character
425def text.upper(txt: String) -> String:
426  match txt:
427    case SNil{}:
428      SNil{}
429    case SCon{h, t}:
430      text.upper.char(h) ++ text.upper(t)
431
432# --- width ---
433
434# the text with spaces after it up to `n` code points; a longer text is
435# kept whole
436def text.pad_right(txt: String, width: Nat) -> String:
437  match txt width:
438    case SNil{} k:
439      String.repeat(" ", k)
440    case SCon{h, t} 0n:
441      SCon{h, t}
442    case SCon{h, t} 1n+k:
443      SCon{h, text.pad_right(t, k)}
444
445# the first `n` code points of the text, or all of it when it is shorter
446def text.truncate(txt: String, width: Nat) -> String:
447  match txt width:
448    case SNil{} _:
449      SNil{}
450    case SCon{_h, _t} 0n:
451      SNil{}
452    case SCon{h, t} 1n+k:
453      SCon{h, text.truncate(t, k)}
454
455# --- digit strings ---
456
457# a leading '0' is dropped, and the zeros after it
458def text.digits.zero.if(
459  zero: Bool,
460  hd: Char,
461  tl: String,
462  more: Unit -> String
463) -> String:
464  match zero:
465    case True{}:
466      more(Unit{})
467    case False{}:
468      SCon{hd, tl}
469
470# the text with its leading '0's removed
471def text.digits.trim(txt: String) -> String:
472  match txt:
473    case SNil{}:
474      SNil{}
475    case SCon{+h, +t}:
476      text.digits.zero.if(Char.is_eq(h, '0'), h, t,
477        _u => text.digits.trim(t))
478
479# the longer text is the greater; texts of one length compare code point
480# by code point
481def text.digits.by(chr: Cmp, +lhs: String, +rhs: String) -> Cmp:
482  match chr:
483    case LT{}:
484      LT{}
485    case EQ{}:
486      String.order(lhs, rhs)
487    case GT{}:
488      GT{}
489
490# two digit strings in numeric order: leading zeros dropped, then the
491# longer is the greater, and two of one length compare digit by digit
492def text.digits.cmp(lhs: String, rhs: String) -> Cmp:
493  +x = text.digits.trim(lhs)
494  +y = text.digits.trim(rhs)
495  text.digits.by(Nat.cmp(String.length(x), String.length(y)), x, y)