air/text.bend source
air/text.bend on the hub · documented module
import Base# Text# ====## String helpers the rest of Air is built on. Everything here is# tail-recursive so it is safe on a request buffer; `String.append`,# `String.length` and `String.split` from Base are not.# A reusable pair of strings; `String & String` is the affine pair.def Two() -> Data: Sigma<&2, &2, String, _ => String>def fst(kv: Two()) -> String: (k, v) = kv kdef snd(kv: Two()) -> String: (k, v) = kv v# Prepends `xs` reversed onto `acc`. Tail-recursive, unlike `String.append`.def rev_onto(xs: String, acc: String) -> String: match xs: case SNil{}: acc case SCon{h, t}: rev_onto(t, SCon{h, acc})# `a ++ b` without a stack frame per character of `a`: use it when `a` may# be large, such as a request buffer.def append(a: String, b: String) -> String: rev_onto(String.reverse(a), b)def non_empty(s: String) -> Bool: Bool.not(String.is_empty(s))def head_is(s: String, +c: Char) -> Bool: match s: case SNil{}: False{} case SCon{h, t}: Char.is_eq(h, c)# Splits at the first `sep`. `hit` says whether `s` starts with `sep`.# Without a `sep`, the whole string is the first half.def split_once(s: String, +sep: Char, acc: String, hit: Bool) -> Two(): match s hit: case _ True{}: (String.reverse(acc), String.drop(s, 1n)) case SNil{} False{}: (String.reverse(acc), SNil{}) case SCon{h, +t} False{}: split_once(t, sep, SCon{h, acc}, head_is(t, sep))def split_at(+s: String, +sep: Char) -> Two(): split_once(s, sep, SNil{}, head_is(s, sep))# Splits at the first occurrence of the substring `sep`, if any.def split_seq(s: String, +sep: String, acc: String, hit: Bool) -> Maybe<&2, Two()>: match s hit: case _ True{}: Some{(String.reverse(acc), String.drop(s, String.length(sep)))} case SNil{} False{}: None{} case SCon{h, +t} False{}: split_seq(t, sep, SCon{h, acc}, String.starts_with(t, sep))def split_str(+s: String, +sep: String) -> Maybe<&2, Two()>: split_seq(s, sep, SNil{}, String.starts_with(s, sep))# Splits on every `sep`. Tail-recursive, unlike `String.split`, so it is# safe on a request head. `hit` says whether `s` starts with `sep`.def split_all.go(s: String, +sep: Char, line: String, acc: List<&2, String>, hit: Bool) -> List<&2, String>: match s hit: case SNil{} _: List.reverse(&2, String, Con{String.reverse(line), acc}) case SCon{h, +t} True{}: split_all.go(t, sep, SNil{}, Con{String.reverse(line), acc}, head_is(t, sep)) case SCon{h, +t} False{}: split_all.go(t, sep, SCon{h, line}, acc, head_is(t, sep))def split_all(+s: String, +sep: Char) -> List<&2, String>: split_all.go(s, sep, SNil{}, Nil{}, head_is(s, sep))def push_non_empty(acc: List<&2, String>, +seg: String) -> List<&2, String>: match seg: case SNil{}: acc case SCon{h, t}: Con{seg, acc}# The non-empty segments of a path: "/users//42/" gives ["users", "42"].def segments.go(s: String, line: String, acc: List<&2, String>, hit: Bool) -> List<&2, String>: match s hit: case SNil{} _: List.reverse(&2, String, push_non_empty(acc, String.reverse(line))) case SCon{h, +t} True{}: segments.go(t, SNil{}, push_non_empty(acc, String.reverse(line)), head_is(t, '/')) case SCon{h, +t} False{}: segments.go(t, SCon{h, line}, acc, head_is(t, '/'))def segments(+path: String) -> List<&2, String>: segments.go(path, SNil{}, Nil{}, head_is(path, '/'))def map_get.fin(r: Map<&2, String> & String) -> String: (m, v) = r vdef map_get(m: Map<&2, String>, key: String, d: String) -> String: map_get.fin(Map.get(String, d, m, key))def count(xs: List<&2, String>, +acc: U32) -> U32: match xs: case Nil{}: acc case Con{h, t}: count(t, U32.add(acc, 1))# Drops the empty lines a client may send between requests.def drop_crlf(+s: String) -> String: match s: case SCon{'\r', SCon{'\n', t}}: drop_crlf(t) case _: s# Whether `v`, a comma-separated header value, lists the token `tok`.def has_token.go(parts: List<&2, String>, +tok: String, found: Bool) -> Bool: match parts found: case _ True{}: True{} case Nil{} False{}: False{} case Con{h, t} False{}: has_token.go(t, tok, String.eq(String.to_lower(String.trim(h)), tok))def has_token(v: String, +tok: String) -> Bool: has_token.go(split_all(v, ','), tok, False{})# A strict decimal: digits only, no sign, no overflow. `U32.read` is# looser than that, and a Content-Length must not be.def digits.go(s: String, +acc: U32, ok: Bool) -> Maybe<&2, U32>: match s ok: case _ False{}: None{} case SNil{} True{}: Some{acc} case SCon{+h, t} True{}: digits.go( t, U32.add(U32.mul(acc, 10), U32.sub(Char.to_u32(h), 48)), Bool.and(Char.is_digit(h), U32.is_le(acc, 429496728)))def digits(+s: String) -> Maybe<&2, U32>: digits.go(s, 0, non_empty(s))# The value of a hex digit, or 16 when it is not one.def hex_val(+x: U32) -> U32: Bool.pick(U32, Bool.and(U32.is_ge(x, 48), U32.is_le(x, 57)), U32.sub(x, 48), Bool.pick(U32, Bool.and(U32.is_ge(x, 97), U32.is_le(x, 102)), U32.sub(x, 87), Bool.pick(U32, Bool.and(U32.is_ge(x, 65), U32.is_le(x, 70)), U32.sub(x, 55), 16)))def hex.go(s: String, +acc: U32, ok: Bool) -> Maybe<&2, U32>: match s ok: case _ False{}: None{} case SNil{} True{}: Some{acc} case SCon{h, t} True{}: +v = hex_val(Char.to_u32(h)) hex.go(t, U32.add(U32.mul(acc, 16), v), Bool.and(U32.is_lt(v, 16), U32.is_le(acc, 268435455)))def hex(+s: String) -> Maybe<&2, U32>: hex.go(s, 0, non_empty(s))# UTF-8 byte count, which is what Content-Length counts.def char_bytes(+x: U32) -> U32: Bool.pick(U32, (x < 128 : U32), 1, Bool.pick(U32, (x < 2048 : U32), 2, Bool.pick(U32, (x < 65536 : U32), 3, 4)))def byte_length(s: String, acc: U32) -> U32: match s: case SNil{}: acc case SCon{h, t}: byte_length(t, (acc + char_bytes(Char.to_u32(h)) : U32))# The first `n` bytes of a string and what follows them. `TakeNeed` asks# for more input; `TakeBad` means byte `n` falls inside a character.type Take is Data: Took{head: String, rest: String} TakeNeed{} TakeBad{}def take_bytes.go(t: String, done: Bool, fits: Bool, h: Char, +n: U32, +b: U32, acc: String) -> Take: match t done fits: case _ True{} _: Took{String.reverse(acc), SCon{h, t}} case _ False{} False{}: TakeBad{} case SNil{} False{} True{}: Bool.pick(Take, U32.is_eq(n, b), Took{String.reverse(SCon{h, acc}), SNil{}}, TakeNeed{}) case SCon{+h2, t2} False{} True{}: +left = U32.sub(n, b) +b2 = char_bytes(Char.to_u32(h2)) take_bytes.go(t2, U32.is_eq(left, 0), U32.is_le(b2, left), h2, left, b2, SCon{h, acc})def take_bytes(s: String, +n: U32) -> Take: match s: case SNil{}: Bool.pick(Take, U32.is_eq(n, 0), Took{SNil{}, SNil{}}, TakeNeed{}) case SCon{+h, t}: +b = char_bytes(Char.to_u32(h)) take_bytes.go(t, U32.is_eq(n, 0), U32.is_le(b, n), h, n, b, SNil{})# Percent-decoding# ----------------## `%XX` escapes are bytes, and a non-ASCII character arrives as two to# four of them, so the decoder assembles UTF-8 as it goes. A multi-byte# sequence in progress is `Open`; anything but its next continuation# byte is an error.type Utf8 is Data: Closed{} Open{need: U32, cp: U32, floor: U32}# The decoder's state between characters: the output so far, reversed,# and the UTF-8 sequence in progress. `Bad` is sticky.type Decoding is Data: Bad{} Next{acc: String, utf8: Utf8}def on_char(acc: String, st: Utf8, c: Char) -> Decoding: match st: case Closed{}: Next{SCon{c, acc}, Closed{}} case Open{need, cp, floor}: Bad{}# A lead byte opens a sequence sized by its high bits; an ASCII byte is# pushed as is. NUL, a stray continuation byte and the leads UTF-8 never# uses (C0, C1, F5 and up) are refused.def lead.pick(acc: String, +b: U32, ok: Bool, ascii: Bool, two: Bool, three: Bool, four: Bool) -> Decoding: match ok ascii two three four: case False{} _ _ _ _: Bad{} case True{} True{} _ _ _: Next{SCon{Char.from_u32(b), acc}, Closed{}} case True{} False{} True{} _ _: Next{acc, Open{1, U32.and(b, 31), 128}} case True{} False{} False{} True{} _: Next{acc, Open{2, U32.and(b, 15), 2048}} case True{} False{} False{} False{} True{}: Next{acc, Open{3, U32.and(b, 7), 65536}} case True{} False{} False{} False{} False{}: Bad{}def lead(acc: String, +b: U32, ok: Bool) -> Decoding: lead.pick(acc, b, ok, Bool.and(U32.is_ge(b, 1), U32.is_lt(b, 128)), Bool.and(U32.is_ge(b, 194), U32.is_le(b, 223)), Bool.and(U32.is_ge(b, 224), U32.is_le(b, 239)), Bool.and(U32.is_ge(b, 240), U32.is_le(b, 244)))# A continuation byte adds six bits. The last one closes the sequence,# unless the code point is overlong, a surrogate, or past U+10FFFF.def cont.pick(acc: String, +need: U32, +cp: U32, +floor: U32, valid: Bool, last: Bool, good: Bool) -> Decoding: match valid last good: case False{} _ _: Bad{} case True{} False{} _: Next{acc, Open{need, cp, floor}} case True{} True{} True{}: Next{SCon{Char.from_u32(cp), acc}, Closed{}} case True{} True{} False{}: Bad{}def cont(acc: String, +need: U32, +cp: U32, +floor: U32, +b: U32, ok: Bool) -> Decoding: +cp2 = U32.or(U32.shln(cp, 6n), U32.and(b, 63)) cont.pick(acc, U32.sub(need, 1), cp2, floor, Bool.and(ok, Bool.and(U32.is_ge(b, 128), U32.is_le(b, 191))), U32.is_eq(need, 1), Bool.and(U32.is_ge(cp2, floor), Bool.and(U32.is_le(cp2, 1114111), Bool.not(Bool.and(U32.is_ge(cp2, 55296), U32.is_le(cp2, 57343))))))def on_byte(acc: String, st: Utf8, +b: U32, ok: Bool) -> Decoding: match st: case Closed{}: lead(acc, b, ok) case Open{need, cp, floor}: cont(acc, need, cp, floor, b, ok)# One escaped byte, given as its two hex digits (16 when not a digit).def escape(acc: String, st: Utf8, +h1: U32, +h2: U32) -> Decoding: on_byte(acc, st, U32.add(U32.mul(h1, 16), h2), Bool.and(U32.is_lt(h1, 16), U32.is_lt(h2, 16)))def percent_decode.go(s: String, +plus: Bool, d: Decoding) -> Maybe<&2, String>: match s d: case _ Bad{}: None{} case SNil{} Next{acc, Closed{}}: Some{String.reverse(acc)} case SNil{} Next{acc, Open{need, cp, floor}}: None{} case SCon{'%', SCon{h1, SCon{h2, t}}} Next{acc, st}: percent_decode.go(t, plus, escape(acc, st, hex_val(Char.to_u32(h1)), hex_val(Char.to_u32(h2)))) case SCon{'%', t} Next{acc, st}: None{} case SCon{'+', t} Next{acc, st}: percent_decode.go(t, plus, on_char(acc, st, Bool.pick(Char, plus, ' ', '+'))) case SCon{c, t} Next{acc, st}: percent_decode.go(t, plus, on_char(acc, st, c))# Whether `s` has a character that decoding would change: '%', or '+'# when `plus`. A scan without allocation, so a plain segment is cheap.def needs_decode(s: String, +plus: Bool) -> Bool: match s: case SNil{}: False{} case SCon{'%', t}: True{} case SCon{'+', t}: Bool.or(plus, needs_decode(t, plus)) case SCon{c, t}: needs_decode(t, plus)def percent_decode.pick(+s: String, +plus: Bool, needed: Bool) -> Maybe<&2, String>: match needed: case True{}: percent_decode.go(s, plus, Next{SNil{}, Closed{}}) case False{}: Some{s}# Decodes `%XX` escapes into UTF-8 code points; `plus` turns '+' into a# space, as query strings want. None on a bad or truncated escape, an# escaped NUL, or an invalid, overlong or surrogate UTF-8 sequence.def percent_decode(+s: String, +plus: Bool) -> Maybe<&2, String>: percent_decode.pick(s, plus, needs_decode(s, plus))# Whether the last character is `c`. Tail-recursive and allocation-free,# unlike `String.ends_with`, which reverses the string.def last_is.go(s: String, +c: Char, prev: Bool) -> Bool: match s: case SNil{}: prev case SCon{h, t}: last_is.go(t, c, Char.is_eq(h, c))def last_is(s: String, +c: Char) -> Bool: last_is.go(s, c, False{})