1 # ezhttp/src/unescape: HTML character references back to the characters they
2 # stand for, the way Python's html.unescape does it (the HTML Standard's
3 # §13.2.5.72 to §13.2.5.80 character reference states, with Python's choices
4 # where the parser would report an error). Numeric references are decimal
5 # (`&`) or hexadecimal (`&`); named ones come from the HTML
6 # Standard's table (src/entities.bend), the legacy names also without their
7 # `;`. Pure. SPEC.md HTTP-REF lists what is guaranteed.
8 import Base
9 import ./entities.bend as Ents
10
11 # where the scan stands: in text, after `&`, after ``, after `` (the `x`
12 # or `X` kept), inside a number (hexadecimal or not, its value so far), or
13 # inside a name (its text so far and how many characters it has)
14 type Scan is Data:
15 Txt{}
16 Amp{}
17 Hash{}
18 HashX{x: Char}
19 Num{hex: Bool, n: U32}
20 Name{text: String, len: Nat}
21
22 # U+FFFD REPLACEMENT CHARACTER, alone
23 def fffd() -> String:
24 SCon{Chr{65533}, ""}
25
26 # --- numeric references (HTTP-REF-2) ---
27
28 # the value a number is held at once it is past U+10FFFF: any larger one
29 # reads the same (U+FFFD)
30 def cap() -> U32:
31 1114112
32
33 # a digit's value in a base (16 or 10), or 16 when it is not one
34 def digit.of(hex: Bool, +cp: U32) -> U32:
35 match hex:
36 case True{}:
37 Bool.pick(U32, Bool.and(U32.is_le(48, cp), U32.is_le(cp, 57)),
38 U32.sub(cp, 48),
39 Bool.pick(U32, Bool.and(U32.is_le(65, cp), U32.is_le(cp, 70)),
40 U32.sub(cp, 55),
41 Bool.pick(U32, Bool.and(U32.is_le(97, cp), U32.is_le(cp, 102)),
42 U32.sub(cp, 87), 16)))
43 case False{}:
44 Bool.pick(U32, Bool.and(U32.is_le(48, cp), U32.is_le(cp, 57)),
45 U32.sub(cp, 48), 16)
46
47 # a character's value as a digit in a base, or 16 when it is not one
48 def digit(hex: Bool, c: Char) -> U32:
49 digit.of(hex, Char.to_u32(c))
50
51 # a number with one more digit, held at cap() once past it
52 def grow(hex: Bool, +n: U32, +d: U32) -> U32:
53 U32.min(U32.add(U32.mul(n, Bool.pick(U32, hex, 16, 10)), d), cap())
54
55 # what 0x80 to 0x9F stand for (HTML §13.2.5.80's table: Windows-1252, the
56 # five it leaves undefined as themselves)
57 def w1252() -> List<&2, U32>:
58 [8364, 129, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338,
59 141, 381, 143, 144, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482,
60 353, 8250, 339, 157, 382, 376]
61
62 # the code point at a place in a list, 65533 past its end
63 def nth(xs: List<&2, U32>, i: Nat) -> U32:
64 match xs i:
65 case [] _:
66 65533
67 case x <> t 0n:
68 x
69 case x <> t 1n+p:
70 nth(t, p)
71
72 # a code point Python drops: C0 controls but TAB, LF, FF and CR, DEL and the
73 # C1 controls, and the noncharacters U+FDD0 to U+FDEF and U+xFFFE, U+xFFFF
74 def dropped(+n: U32) -> Bool:
75 Bool.or(Bool.and(U32.is_le(1, n), U32.is_le(n, 8)),
76 Bool.or(U32.is_eq(n, 11),
77 Bool.or(Bool.and(U32.is_le(14, n), U32.is_le(n, 31)),
78 Bool.or(Bool.and(U32.is_le(127, n), U32.is_le(n, 159)),
79 Bool.or(Bool.and(U32.is_le(64976, n), U32.is_le(n, 65007)),
80 U32.is_eq(U32.and(n, 65534), 65534))))))
81
82 # a code point in range: nothing when Python drops it, else the character
83 def code.kept(+n: U32, drop: Bool) -> String:
84 match drop:
85 case True{}:
86 ""
87 case False{}:
88 SCon{Chr{n}, ""}
89
90 # a surrogate or a number past U+10FFFF is U+FFFD
91 def code.range(+n: U32, bad: Bool) -> String:
92 match bad:
93 case True{}:
94 fffd()
95 case False{}:
96 code.kept(n, dropped(n))
97
98 # 0x80 to 0x9F as the table says
99 def code.c1(+n: U32, c1: Bool) -> String:
100 match c1:
101 case True{}:
102 SCon{Chr{nth(w1252(), U32.to_nat(U32.sub(n, 128)))}, ""}
103 case False{}:
104 code.range(n, Bool.or(Bool.and(U32.is_le(55296, n), U32.is_le(n, 57343)),
105 U32.is_lt(1114111, n)))
106
107 # 0x0D is a carriage return
108 def code.cr(+n: U32, cr: Bool) -> String:
109 match cr:
110 case True{}:
111 "\r"
112 case False{}:
113 code.c1(n, Bool.and(U32.is_le(128, n), U32.is_le(n, 159)))
114
115 # 0 is U+FFFD, else on to 0x0D
116 def code.zero(+n: U32, zero: Bool) -> String:
117 match zero:
118 case True{}:
119 fffd()
120 case False{}:
121 code.cr(n, U32.is_eq(n, 13))
122
123 # what a numeric reference stands for: 0 is U+FFFD
124 def code(+n: U32) -> String:
125 code.zero(n, U32.is_eq(n, 0))
126
127 # --- named references (HTTP-REF-3) ---
128
129 # a name's length, or 0 for none yet
130 def best.len(m: Maybe<&2, Ents.Ref>) -> Nat:
131 match m:
132 case None{}:
133 0n
134 case Some{Ents.Ref{name, first, second}}:
135 String.length(name)
136
137 # the better of the best so far and a name the text starts with: the longer
138 def best.take(best: Maybe<&2, Ents.Ref>, +r: Ents.Ref, longer: Bool)
139 -> Maybe<&2, Ents.Ref>:
140 match longer:
141 case True{}:
142 Some{r}
143 case False{}:
144 best
145
146 # a name the text starts with or not
147 def best.hit(+best: Maybe<&2, Ents.Ref>, +r: Ents.Ref, +name: String,
148 hit: Bool) -> Maybe<&2, Ents.Ref>:
149 match hit:
150 case True{}:
151 best.take(best, r, Nat.is_lt(best.len(best), String.length(name)))
152 case False{}:
153 best
154
155 # one table row against the best so far
156 def best.step(+best: Maybe<&2, Ents.Ref>, +r: Ents.Ref, +s: String)
157 -> Maybe<&2, Ents.Ref>:
158 match r:
159 case Ents.Ref{+name, first, second}:
160 best.hit(best, r, name, String.starts_with(s, name))
161
162 # the longest name in the rows that the text starts with
163 def best(rs: List<&2, Ents.Ref>, +s: String, +sofar: Maybe<&2, Ents.Ref>)
164 -> Maybe<&2, Ents.Ref>:
165 match rs:
166 case []:
167 sofar
168 case r <> t:
169 best(t, s, best.step(sofar, r, s))
170
171 # the one or two characters a row stands for
172 def chars(first: U32, +second: U32) -> String:
173 SCon{Chr{first}, Bool.pick(String, U32.is_eq(second, 0), "",
174 SCon{Chr{second}, ""})}
175
176 # a reference's text once its longest name is known: the characters, then
177 # the text after the name as written; with no name, `&` and the text
178 def named.of(m: Maybe<&2, Ents.Ref>, s: String) -> String:
179 match m:
180 case None{}:
181 SCon{'&', s}
182 case Some{Ents.Ref{+name, first, second}}:
183 chars(first, second) ++ String.drop(s, String.length(name))
184
185 # a named reference's text (after the `&`, with its `;` if it has one)
186 def named(+s: String) -> String:
187 named.of(best(Ents.table(), s, None{}), s)
188
189 # --- the scan (HTTP-REF-1) ---
190
191 # a character that may be in a name: anything but TAB, LF, FF, space, `<`,
192 # `&`, `#` and `;`
193 def name.char(+c: Char) -> Bool:
194 Bool.not(Bool.or(Char.is_eq(c, '\t'), Bool.or(Char.is_eq(c, '\n'),
195 Bool.or(Char.is_eq(c, '\u{C}'), Bool.or(Char.is_eq(c, ' '),
196 Bool.or(Char.is_eq(c, '<'), Bool.or(Char.is_eq(c, '&'),
197 Bool.or(Char.is_eq(c, '#'), Char.is_eq(c, ';')))))))))
198
199 # a character in text once it is known whether it is `&`
200 def text.of(+c: Char, k: Scan -> String, amp: Bool) -> String:
201 match amp:
202 case True{}:
203 k(Amp{})
204 case False{}:
205 SCon{c, k(Txt{})}
206
207 # a character in text: `&` may open a reference
208 def text(+c: Char, k: Scan -> String) -> String:
209 text.of(c, k, Char.is_eq(c, '&'))
210
211 # after `&`: a name character opens a name, else the `&` is text
212 def amp.name(+c: Char, k: Scan -> String, name: Bool) -> String:
213 match name:
214 case True{}:
215 k(Name{SCon{c, ""}, 1n})
216 case False{}:
217 SCon{'&', text(c, k)}
218
219 # after `&`: `#` opens a number
220 def amp(+c: Char, k: Scan -> String, hash: Bool) -> String:
221 match hash:
222 case True{}:
223 k(Hash{})
224 case False{}:
225 amp.name(c, k, name.char(c))
226
227 # after ``: `x` or `X` opens a hexadecimal number
228 def hash.x(+c: Char, k: Scan -> String, x: Bool) -> String:
229 match x:
230 case True{}:
231 k(HashX{c})
232 case False{}:
233 "" ++ text(c, k)
234
235 # after ``, once it is known whether the character is a decimal digit
236 def hash.dec(+c: Char, k: Scan -> String, +d: U32, digit: Bool) -> String:
237 match digit:
238 case True{}:
239 k(Num{False{}, d})
240 case False{}:
241 hash.x(c, k, Bool.or(Char.is_eq(c, 'x'), Char.is_eq(c, 'X')))
242
243 # after ``: a decimal digit opens a decimal number
244 def hash(+c: Char, k: Scan -> String, +d: U32) -> String:
245 hash.dec(c, k, d, U32.is_lt(d, 16))
246
247 # after ``, once it is known whether the character is a hex digit
248 def hash.hex.of(+x: Char, +c: Char, k: Scan -> String, +d: U32, digit: Bool)
249 -> String:
250 match digit:
251 case True{}:
252 k(Num{True{}, d})
253 case False{}:
254 SCon{'&', SCon{'#', SCon{x, text(c, k)}}}
255
256 # after ``: a hex digit opens the number, else `` is text
257 def hash.hex(+x: Char, +c: Char, k: Scan -> String, +d: U32) -> String:
258 hash.hex.of(x, c, k, d, U32.is_lt(d, 16))
259
260 # inside a number, a character that is not a digit: `;` ends the reference
261 # with it, anything else ends it and is text
262 def num.end(+n: U32, +c: Char, k: Scan -> String, semi: Bool) -> String:
263 match semi:
264 case True{}:
265 code(n) ++ k(Txt{})
266 case False{}:
267 code(n) ++ text(c, k)
268
269 # inside a number: a digit grows it
270 def num(+hex: Bool, +n: U32, +c: Char, k: Scan -> String, +d: U32,
271 digit: Bool) -> String:
272 match digit:
273 case True{}:
274 k(Num{hex, grow(hex, n, d)})
275 case False{}:
276 num.end(n, c, k, Char.is_eq(c, ';'))
277
278 # inside a name, a character that does not join it: the name ends there
279 def name.end(+s: String, +c: Char, k: Scan -> String) -> String:
280 named(s) ++ text(c, k)
281
282 # inside a name, a character that is not `;`: a name character joins it
283 # while it has fewer than 32
284 def name.more(+s: String, len: Nat, +c: Char, k: Scan -> String, join: Bool)
285 -> String:
286 match join:
287 case True{}:
288 k(Name{s ++ SCon{c, ""}, 1n+len})
289 case False{}:
290 name.end(s, c, k)
291
292 # inside a name: `;` ends the reference with it
293 def name(+s: String, +len: Nat, +c: Char, k: Scan -> String, semi: Bool)
294 -> String:
295 match semi:
296 case True{}:
297 named(s ++ ";") ++ k(Txt{})
298 case False{}:
299 name.more(s, len, c, k, Bool.and(name.char(c), Nat.is_lt(len, 32n)))
300
301 # one character where the scan stands
302 def step(st: Scan, +c: Char, k: Scan -> String) -> String:
303 match st:
304 case Txt{}:
305 text(c, k)
306 case Amp{}:
307 amp(c, k, Char.is_eq(c, '#'))
308 case Hash{}:
309 hash(c, k, digit(False{}, c))
310 case HashX{x}:
311 hash.hex(x, c, k, digit(True{}, c))
312 case Num{+hex, n}:
313 num(hex, n, c, k, digit(hex, c), U32.is_lt(digit(hex, c), 16))
314 case Name{s, len}:
315 name(s, len, c, k, Char.is_eq(c, ';'))
316
317 # the end of the text: a reference left open ends there, an `&`, `` or
318 # `` with nothing after it is text
319 def end(st: Scan) -> String:
320 match st:
321 case Txt{}:
322 ""
323 case Amp{}:
324 "&"
325 case Hash{}:
326 ""
327 case HashX{x}:
328 SCon{'&', SCon{'#', SCon{x, ""}}}
329 case Num{hex, n}:
330 code(n)
331 case Name{s, len}:
332 named(s)
333
334 # a text scanned from where it stands, character by character
335 def scan(s: String, st: Scan) -> String:
336 match s:
337 case SNil{}:
338 end(st)
339 case SCon{+c, t}:
340 step(st, c, st2 => scan(t, st2))
341
342 # a text with its character references replaced, as html.unescape
343 def unescape(s: String) -> String:
344 scan(s, Txt{})