functionalscript 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/fs/asserts/module.f.d.ts +1 -1
  3. package/fs/asserts/module.f.js +4 -1
  4. package/fs/bnf/data/module.f.d.ts +0 -103
  5. package/fs/bnf/data/module.f.js +10 -246
  6. package/fs/bnf/data/proof.f.d.ts +0 -10
  7. package/fs/bnf/data/proof.f.js +2 -642
  8. package/fs/bnf/descent/module.f.d.ts +56 -0
  9. package/fs/bnf/descent/module.f.js +119 -0
  10. package/fs/bnf/descent/proof.f.d.ts +5 -0
  11. package/fs/bnf/descent/proof.f.js +296 -0
  12. package/fs/bnf/ll1/module.f.d.ts +72 -0
  13. package/fs/bnf/ll1/module.f.js +153 -0
  14. package/fs/bnf/ll1/proof.f.d.ts +9 -0
  15. package/fs/bnf/ll1/proof.f.js +358 -0
  16. package/fs/cas/cli/module.f.d.ts +6 -0
  17. package/fs/cas/cli/module.f.js +56 -0
  18. package/fs/cas/cli/proof.f.d.ts +14 -0
  19. package/fs/cas/cli/proof.f.js +149 -0
  20. package/fs/cas/mcp/module.f.d.ts +9 -17
  21. package/fs/cas/mcp/module.f.js +116 -84
  22. package/fs/cas/mcp/proof.f.d.ts +5 -1
  23. package/fs/cas/mcp/proof.f.js +74 -32
  24. package/fs/cas/module.f.d.ts +35 -15
  25. package/fs/cas/module.f.js +168 -131
  26. package/fs/cas/proof.f.d.ts +16 -13
  27. package/fs/cas/proof.f.js +255 -121
  28. package/fs/ci/config/module.f.d.ts +14 -14
  29. package/fs/ci/config/module.f.js +14 -14
  30. package/fs/djs/tokenizer-new/module.f.js +1 -1
  31. package/fs/djs/tokenizer-new/proof.f.d.ts +2 -0
  32. package/fs/djs/tokenizer-new/proof.f.js +357 -229
  33. package/fs/effects/list/module.f.d.ts +31 -0
  34. package/fs/effects/list/module.f.js +16 -0
  35. package/fs/effects/module.f.d.ts +11 -5
  36. package/fs/effects/module.f.js +16 -6
  37. package/fs/effects/node/module.f.d.ts +29 -3
  38. package/fs/effects/node/module.f.js +35 -1
  39. package/fs/effects/node/module.js +32 -18
  40. package/fs/effects/node/virtual/module.f.js +57 -0
  41. package/fs/effects/proof.f.d.ts +4 -0
  42. package/fs/effects/proof.f.js +18 -1
  43. package/fs/fjs/module.f.js +2 -8
  44. package/fs/html/module.f.js +6 -8
  45. package/fs/mcp/module.f.d.ts +1 -1
  46. package/fs/mime/module.f.d.ts +101 -5
  47. package/fs/mime/module.f.js +159 -6
  48. package/fs/mime/proof.f.d.ts +25 -0
  49. package/fs/mime/proof.f.js +193 -3
  50. package/fs/text/code_point/module.f.d.ts +39 -2
  51. package/fs/text/code_point/module.f.js +74 -2
  52. package/fs/text/code_point/proof.f.d.ts +8 -0
  53. package/fs/text/code_point/proof.f.js +72 -0
  54. package/fs/text/utf16/module.f.js +4 -26
  55. package/fs/text/utf8/module.f.d.ts +27 -0
  56. package/fs/text/utf8/module.f.js +8 -14
  57. package/fs/types/function/operator/module.f.d.ts +26 -0
  58. package/fs/types/list/module.f.d.ts +3 -0
  59. package/fs/types/result/module.d.ts +2 -4
  60. package/fs/types/result/module.js +9 -3
  61. package/package.json +3 -3
  62. package/issues/demo/data/data.f.js +0 -12
  63. package/issues/demo/data/shared.f.js +0 -3
  64. package/issues/demo/fs/app.js +0 -4
  65. package/issues/demo/fs/math.f.js +0 -4
  66. package/issues/demo/sample/proof.f.js +0 -13
  67. /package/{issues → todo}/031-json.f.d.ts +0 -0
  68. /package/{issues → todo}/031-json.f.js +0 -0
  69. /package/{issues → todo}/proof.f.d.ts +0 -0
  70. /package/{issues → todo}/proof.f.js +0 -0
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Magic-byte MIME type detection.
3
3
  *
4
- * A pure table lookup over the leading bytes of a `Vec`: no I/O, no
5
- * dependencies beyond `fs/types/bit_vec`. `detect` returns a MIME type string
6
- * for the container formats whose signatures it knows, or `null` for anything
7
- * else — text, unknown binary, or a `Vec` too short to match.
4
+ * `detect` is a pure table lookup over the leading bytes of a `Vec`: it returns a
5
+ * MIME type string for the container formats whose signatures it knows, or `null`
6
+ * for anything else text, unknown binary, or a `Vec` too short to match.
7
+ *
8
+ * Beside it, `detectStream` is the **streaming counterpart**: a byte-accepting
9
+ * state machine (length × magic-byte eliminator × UTF-8 validity DFA) that derives
10
+ * `{ length, mime_type, type }` by folding a CAS read stream in O(1) space, without
11
+ * ever buffering the blob into a single `maxLength`-bounded `Vec`. See the README
12
+ * for the factored design.
8
13
  *
9
14
  * The CAS store is type-agnostic and keeps raw bytes only, so type is never
10
15
  * stored; it is recovered on read by sniffing the content. Callers decide what
@@ -27,7 +32,12 @@
27
32
  *
28
33
  * @module
29
34
  */
30
- import { msb, fromSentinel, length } from "../types/bit_vec/module.f.js";
35
+ import { msb, fromSentinel, length, u8List } from "../types/bit_vec/module.f.js";
36
+ import { iterable } from "../types/list/module.f.js";
37
+ import { pure } from "../effects/module.f.js";
38
+ import { ok, error } from "../types/result/module.f.js";
39
+ import { isValidCodePoint, isTextCodePoint } from "../text/code_point/module.f.js";
40
+ import { utf8ByteToCodePointOp } from "../text/utf8/module.f.js";
31
41
  const { startsWith, removeFront } = msb;
32
42
  // Each signature is written as a hex literal whose leading `1` nibble is a
33
43
  // sentinel marking the start of the byte run (so leading zero bytes survive)
@@ -61,7 +71,7 @@ const isWebp = (bytes) => length(bytes) >= 96n
61
71
  /**
62
72
  * Detects the MIME type of `bytes` from its leading magic-byte signature.
63
73
  *
64
- * @returns the MIME type string for a recognised format, or `null` when the
74
+ * @returns the MIME type string for a recognized format, or `null` when the
65
75
  * leading bytes match no known signature (including any `Vec` shorter than
66
76
  * the signature it might otherwise match).
67
77
  */
@@ -76,3 +86,146 @@ export const detect = (bytes) => {
76
86
  }
77
87
  return null;
78
88
  };
89
+ // The streaming counterpart of `table`/`isWebp`: the same signatures expressed as
90
+ // byte patterns the eliminator can consume one byte at a time. WebP's gap is the
91
+ // only wildcard run.
92
+ const signatures = [
93
+ { pattern: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mime: 'image/png' },
94
+ { pattern: [0xff, 0xd8, 0xff], mime: 'image/jpeg' },
95
+ { pattern: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mime: 'image/gif' },
96
+ { pattern: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mime: 'image/gif' },
97
+ { pattern: [0x25, 0x50, 0x44, 0x46, 0x2d], mime: 'application/pdf' },
98
+ { pattern: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip' },
99
+ { pattern: [0x50, 0x4b, 0x05, 0x06], mime: 'application/zip' },
100
+ { pattern: [0x50, 0x4b, 0x07, 0x08], mime: 'application/zip' },
101
+ {
102
+ pattern: [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50],
103
+ mime: 'image/webp',
104
+ },
105
+ ];
106
+ const magicInit = { tag: 'scan', pos: 0, viable: signatures };
107
+ const magicStep = (m, byte) => {
108
+ if (m.tag !== 'scan') {
109
+ return m;
110
+ }
111
+ const { pos } = m;
112
+ const viable = m.viable.filter(s => {
113
+ const p = s.pattern[pos];
114
+ return p === null || p === byte;
115
+ });
116
+ for (const s of viable) {
117
+ if (s.pattern.length === pos + 1) {
118
+ return { tag: 'matched', mime: s.mime };
119
+ }
120
+ }
121
+ return viable.length === 0 ? { tag: 'dead' } : { tag: 'scan', pos: pos + 1, viable };
122
+ };
123
+ const magicMime = (m) => m.tag === 'matched' ? m.mime : null;
124
+ const utf8Init = { st: null, valid: true, text: true };
125
+ const utf8Step = (u, byte) => {
126
+ if (!u.valid) {
127
+ return u;
128
+ }
129
+ const [cps, st] = utf8ByteToCodePointOp(byte, u.st);
130
+ let text = u.text;
131
+ for (const cp of cps) {
132
+ if (!isValidCodePoint(cp)) {
133
+ return { st, valid: false, text };
134
+ }
135
+ if (!isTextCodePoint(cp)) {
136
+ text = false;
137
+ }
138
+ }
139
+ return { st, valid: true, text };
140
+ };
141
+ const utf8Valid = (u) => u.valid && u.st === null;
142
+ // A blob is text only when it is whole-blob-valid UTF-8 *and* every decoded code
143
+ // point is a text code point (no NUL/other controls).
144
+ const utf8Text = (u) => utf8Valid(u) && u.text;
145
+ /** The initial detector state `q₀`. */
146
+ export const detectInit = {
147
+ length: 0n,
148
+ magic: magicInit,
149
+ utf8: utf8Init,
150
+ };
151
+ // The outcome can no longer change — `push` may stop decoding and only count
152
+ // length — once `finish` is pinned down. A magic `matched` pins it on its own
153
+ // (`finish` returns the detected mime and ignores the utf8 verdict), so we must
154
+ // not wait for utf8 to go invalid (it may stay valid forever, e.g. an ASCII PDF).
155
+ // A magic `dead` leaves text-vs-octet open, so it settles only once utf8 can no
156
+ // longer be text — either invalid or a control byte seen (both absorbing); `scan`
157
+ // is never settled.
158
+ const isSettled = (magic, utf8) => {
159
+ switch (magic.tag) {
160
+ case 'matched': return true;
161
+ case 'dead': return !utf8.valid || !utf8.text;
162
+ case 'scan': return false;
163
+ }
164
+ };
165
+ /**
166
+ * Folds one `Vec` chunk into the detector state (`δ` over a whole chunk). Length
167
+ * always advances by the chunk's bit length; per-byte iteration stops as soon as
168
+ * the verdict is fixed (see {@link isSettled}), so large blobs — including large
169
+ * magic-matched ones — cost ≈ length counting.
170
+ */
171
+ export const push = (s) => (chunk) => {
172
+ const bits = length(chunk);
173
+ let magic = s.magic;
174
+ let utf8 = s.utf8;
175
+ if (!isSettled(magic, utf8)) {
176
+ for (const byte of iterable(u8List(msb)(chunk))) {
177
+ magic = magicStep(magic, byte);
178
+ utf8 = utf8Step(utf8, byte);
179
+ if (isSettled(magic, utf8)) {
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ return { length: s.length + bits, magic, utf8 };
185
+ };
186
+ /**
187
+ * Reads the answer off the final state (`λ`). Reproduces the three-way result of
188
+ * the pure path: magic hit → `base64` + detected mime; else whole-blob-valid UTF-8
189
+ * that is also all-text (byte-aligned, no invalidity, no control bytes) → `text` +
190
+ * `text/plain`; else → `base64` + `application/octet-stream`. A valid-but-control
191
+ * blob (NUL, other controls) is well-formed UTF-8 yet falls through to the binary
192
+ * branch.
193
+ */
194
+ export const finish = (s) => {
195
+ const byteLength = s.length >> 3n;
196
+ const mime = magicMime(s.magic);
197
+ if (mime !== null) {
198
+ return { length: byteLength, mime_type: mime, type: 'base64' };
199
+ }
200
+ if (utf8Text(s.utf8) && (s.length & 7n) === 0n) {
201
+ return { length: byteLength, mime_type: 'text/plain', type: 'text' };
202
+ }
203
+ return { length: byteLength, mime_type: 'application/octet-stream', type: 'base64' };
204
+ };
205
+ /**
206
+ * Classifies a whole `Vec` with the same state machine as {@link detectStream}.
207
+ * The single-buffer counterpart for callers that already hold the bytes (the
208
+ * `cas_get` `content: true` path materializes the blob anyway): both paths read
209
+ * the three-way `{ length, mime_type, type }` verdict from one machine instead of
210
+ * re-deriving it from `detect` + a separate UTF-8 check.
211
+ */
212
+ export const detectVec = (bytes) => finish(push(detectInit)(bytes));
213
+ /**
214
+ * Folds a CAS read stream through {@link push} and reads {@link finish} at EOF,
215
+ * deriving `cas_get` metadata without ever materializing the blob. A read `error`
216
+ * item short-circuits into the `IoResult` error.
217
+ */
218
+ export const detectStream = (stream) => {
219
+ const loop = (s) => (l) => l.step((node) => {
220
+ if (node === undefined) {
221
+ return pure(ok(finish(s)));
222
+ }
223
+ const { first, tail } = node;
224
+ const [t, v] = first;
225
+ if (t === 'error') {
226
+ return pure(error(v));
227
+ }
228
+ return loop(push(s)(v))(tail);
229
+ });
230
+ return loop(detectInit)(stream);
231
+ };
@@ -12,4 +12,29 @@ export declare const proof: {
12
12
  textIsNull: () => void;
13
13
  shortIsNull: () => void;
14
14
  emptyIsNull: () => void;
15
+ stream: {
16
+ text: () => void;
17
+ png: () => void;
18
+ octetStream: () => void;
19
+ nul: () => void;
20
+ controlByte: () => void;
21
+ c1Control: () => void;
22
+ whitespaceControlsStayText: () => void;
23
+ magicAcrossChunks: () => void;
24
+ webp: () => void;
25
+ utf8AcrossChunks: () => void;
26
+ validUntilTrailingInvalidByte: () => void;
27
+ truncatedSequence: () => void;
28
+ largeMultiChunkBlob: () => void;
29
+ pdfThenLargeTextTail: () => void;
30
+ pdfThenBinaryTail: () => void;
31
+ readErrorSurfaces: () => void;
32
+ empty: () => void;
33
+ };
34
+ vec: {
35
+ text: () => void;
36
+ png: () => void;
37
+ octetStream: () => void;
38
+ nul: () => void;
39
+ };
15
40
  };
@@ -1,9 +1,31 @@
1
- import { assertEq } from "../asserts/module.f.js";
2
- import { msb, u8ListToVec, empty } from "../types/bit_vec/module.f.js";
3
- import { detect } from "./module.f.js";
1
+ import { assert, assertEq } from "../asserts/module.f.js";
2
+ import { msb, u8ListToVec, vec8, repeat, empty } from "../types/bit_vec/module.f.js";
3
+ import { decode } from "../effects/module.f.js";
4
+ import { nonEmpty, empty as emptyList } from "../effects/list/module.f.js";
5
+ import { ok } from "../types/result/module.f.js";
6
+ import { detect, detectStream, detectVec } from "./module.f.js";
4
7
  // Builds a big-endian `Vec` from a list of byte values — mirrors how the CAS
5
8
  // store would hold the leading bytes of a stored blob.
6
9
  const bytes = (...b) => u8ListToVec(msb)(b);
10
+ // ── Streaming detector helpers ──────────────────────────────────────────────────
11
+ // Evaluates a fully pure effect (no operations) to its result.
12
+ const runPure = (e) => {
13
+ const d = decode(e);
14
+ if (!d.done) {
15
+ throw 'effect is not pure';
16
+ }
17
+ return d.result;
18
+ };
19
+ // Builds a CAS-style read stream from a sequence of ok(chunk) items.
20
+ const stream = (...chunks) => chunks.reduceRight((tail, c) => nonEmpty(ok(c), tail), emptyList());
21
+ // Runs the streaming detector over the given chunks and unwraps the metadata.
22
+ const detectChunks = (...chunks) => {
23
+ const r = runPure(detectStream(stream(...chunks)));
24
+ if (r[0] === 'error') {
25
+ throw r[1];
26
+ }
27
+ return r[1];
28
+ };
7
29
  export const proof = {
8
30
  png: () => assertEq(detect(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)), 'image/png'),
9
31
  jpeg: () => assertEq(detect(bytes(0xff, 0xd8, 0xff, 0xe0)), 'image/jpeg'),
@@ -27,4 +49,172 @@ export const proof = {
27
49
  // A prefix shorter than any signature falls through to null, not a partial match.
28
50
  shortIsNull: () => assertEq(detect(bytes(0x89, 0x50)), null),
29
51
  emptyIsNull: () => assertEq(detect(empty), null),
52
+ // ── Streaming detector (detectStream) ───────────────────────────────────────
53
+ stream: {
54
+ // Plain UTF-8 text classifies as text/plain with the correct byte length.
55
+ text: () => {
56
+ const m = detectChunks(bytes(0x68, 0x65, 0x6c, 0x6c, 0x6f)); // "hello"
57
+ assertEq(m.type, 'text');
58
+ assertEq(m.mime_type, 'text/plain');
59
+ assertEq(m.length, 5n);
60
+ },
61
+ // A magic-byte signature classifies as base64 with the detected mime type.
62
+ png: () => {
63
+ const m = detectChunks(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01));
64
+ assertEq(m.type, 'base64');
65
+ assertEq(m.mime_type, 'image/png');
66
+ assertEq(m.length, 10n);
67
+ },
68
+ // Unknown binary (no signature, not UTF-8) falls back to octet-stream/base64.
69
+ octetStream: () => {
70
+ const m = detectChunks(bytes(0xff, 0xfe, 0x00, 0x01));
71
+ assertEq(m.type, 'base64');
72
+ assertEq(m.mime_type, 'application/octet-stream');
73
+ assertEq(m.length, 4n);
74
+ },
75
+ // A NUL-byte blob is valid single-byte UTF-8 (all U+0000) but is binary:
76
+ // NUL is the sharpest binary marker, so it classifies as octet-stream.
77
+ nul: () => {
78
+ const m = detectChunks(bytes(0x00, 0x00, 0x00));
79
+ assertEq(m.type, 'base64');
80
+ assertEq(m.mime_type, 'application/octet-stream');
81
+ assertEq(m.length, 3n);
82
+ },
83
+ // A control byte embedded in otherwise-valid ASCII (here ESC) is still
84
+ // binary — text/plain must not imply terminal escapes.
85
+ controlByte: () => {
86
+ const m = detectChunks(bytes(0x68, 0x69, 0x1b, 0x5b, 0x30, 0x6d)); // "hi\x1b[0m"
87
+ assertEq(m.type, 'base64');
88
+ assertEq(m.mime_type, 'application/octet-stream');
89
+ assertEq(m.length, 6n);
90
+ },
91
+ // A C1 control arrives as 2-byte UTF-8 (C2 85 = U+0085, NEL); invisible at
92
+ // the byte level, it is caught at the code-point level and reads as binary.
93
+ c1Control: () => {
94
+ const m = detectChunks(bytes(0x41, 0xc2, 0x85)); // "A" + U+0085
95
+ assertEq(m.type, 'base64');
96
+ assertEq(m.mime_type, 'application/octet-stream');
97
+ assertEq(m.length, 3n);
98
+ },
99
+ // The text whitespace controls (TAB, LF, VT, FF, CR) are legitimate in text.
100
+ whitespaceControlsStayText: () => {
101
+ const m = detectChunks(bytes(0x61, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x62)); // "a\t\n\v\f\rb"
102
+ assertEq(m.type, 'text');
103
+ assertEq(m.mime_type, 'text/plain');
104
+ assertEq(m.length, 7n);
105
+ },
106
+ // Magic-byte detection threads across a chunk boundary mid-signature.
107
+ magicAcrossChunks: () => {
108
+ const m = detectChunks(bytes(0x89, 0x50, 0x4e), // first 3 bytes of the PNG signature
109
+ bytes(0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00)); // the rest, in a second chunk
110
+ assertEq(m.type, 'base64');
111
+ assertEq(m.mime_type, 'image/png');
112
+ assertEq(m.length, 9n);
113
+ },
114
+ // WebP's wildcard size gap is handled in the streaming form too.
115
+ webp: () => {
116
+ const m = detectChunks(bytes(0x52, 0x49, 0x46, 0x46), // "RIFF"
117
+ bytes(0x1a, 0x00, 0x00, 0x00), // 4-byte size (wildcards)
118
+ bytes(0x57, 0x45, 0x42, 0x50)); // "WEBP"
119
+ assertEq(m.type, 'base64');
120
+ assertEq(m.mime_type, 'image/webp');
121
+ assertEq(m.length, 12n);
122
+ },
123
+ // A multi-byte UTF-8 sequence split across chunks stays valid text.
124
+ utf8AcrossChunks: () => {
125
+ const m = detectChunks(bytes(0xc3), bytes(0xa9)); // "é" = C3 A9
126
+ assertEq(m.type, 'text');
127
+ assertEq(m.mime_type, 'text/plain');
128
+ assertEq(m.length, 2n);
129
+ },
130
+ // The decisive case: valid UTF-8 for every chunk but the last, which adds a
131
+ // lone invalid byte. A leading-bytes buffer would wrongly say "text"; the
132
+ // streaming validator sees the whole blob and classifies it as base64.
133
+ validUntilTrailingInvalidByte: () => {
134
+ const m = detectChunks(bytes(0x68, 0x65, 0x6c, 0x6c, 0x6f), // "hello" — valid UTF-8
135
+ bytes(0xff)); // trailing invalid byte
136
+ assertEq(m.type, 'base64');
137
+ assertEq(m.mime_type, 'application/octet-stream');
138
+ assertEq(m.length, 6n);
139
+ },
140
+ // A truncated multi-byte sequence at EOF is invalid UTF-8, hence base64.
141
+ truncatedSequence: () => {
142
+ const m = detectChunks(bytes(0xc3)); // lead byte with no continuation
143
+ assertEq(m.type, 'base64');
144
+ assertEq(m.mime_type, 'application/octet-stream');
145
+ assertEq(m.length, 1n);
146
+ },
147
+ // A blob larger than `maxLength` (1,048,576 bits) split across chunks — the
148
+ // exact case where `collectRead` would error — returns correct metadata.
149
+ largeMultiChunkBlob: () => {
150
+ const a = repeat(70000n)(vec8(0x61n)); // 70,000 bytes of 'a' (560,000 bits)
151
+ const m = detectChunks(a, a); // 140,000 bytes = 1,120,000 bits > maxLength
152
+ assertEq(m.type, 'text');
153
+ assertEq(m.mime_type, 'text/plain');
154
+ assertEq(m.length, 140000n);
155
+ },
156
+ // A magic match settles the verdict on its own: a large magic-matched blob
157
+ // whose tail stays valid UTF-8 (an ASCII PDF) classifies by the signature.
158
+ // `push` stops decoding the tail once `magic` is matched — `finish` ignores
159
+ // the utf8 verdict here — so this is the magic-matched fast path.
160
+ pdfThenLargeTextTail: () => {
161
+ const header = bytes(0x25, 0x50, 0x44, 0x46, 0x2d); // "%PDF-"
162
+ const tail = repeat(70000n)(vec8(0x61n)); // valid ASCII, never invalidates utf8
163
+ const m = detectChunks(header, tail);
164
+ assertEq(m.type, 'base64');
165
+ assertEq(m.mime_type, 'application/pdf');
166
+ assertEq(m.length, 70005n);
167
+ },
168
+ // A magic match wins regardless of the tail: a binary (non-UTF-8) tail
169
+ // after the signature is still classified by the signature.
170
+ pdfThenBinaryTail: () => {
171
+ const header = bytes(0x25, 0x50, 0x44, 0x46, 0x2d); // "%PDF-"
172
+ const m = detectChunks(header, bytes(0xff, 0xfe, 0x00));
173
+ assertEq(m.type, 'base64');
174
+ assertEq(m.mime_type, 'application/pdf');
175
+ assertEq(m.length, 8n);
176
+ },
177
+ // A read `error` item short-circuits into the IoResult error.
178
+ readErrorSurfaces: () => {
179
+ const errStream = nonEmpty(['error', 'boom'], emptyList());
180
+ const r = runPure(detectStream(errStream));
181
+ assert(r[0] === 'error');
182
+ },
183
+ // Empty stream: zero-length text/plain.
184
+ empty: () => {
185
+ const m = detectChunks();
186
+ assertEq(m.type, 'text');
187
+ assertEq(m.length, 0n);
188
+ },
189
+ },
190
+ // ── Single-Vec detector (detectVec) ─────────────────────────────────────────
191
+ // The whole-Vec form used by the `cas_get` content:true path; same machine,
192
+ // same three-way verdict as the streaming form.
193
+ vec: {
194
+ text: () => {
195
+ const m = detectVec(bytes(0x68, 0x65, 0x6c, 0x6c, 0x6f)); // "hello"
196
+ assertEq(m.type, 'text');
197
+ assertEq(m.mime_type, 'text/plain');
198
+ assertEq(m.length, 5n);
199
+ },
200
+ png: () => {
201
+ const m = detectVec(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01));
202
+ assertEq(m.type, 'base64');
203
+ assertEq(m.mime_type, 'image/png');
204
+ assertEq(m.length, 10n);
205
+ },
206
+ octetStream: () => {
207
+ const m = detectVec(bytes(0xff, 0xfe, 0x00, 0x01));
208
+ assertEq(m.type, 'base64');
209
+ assertEq(m.mime_type, 'application/octet-stream');
210
+ assertEq(m.length, 4n);
211
+ },
212
+ // Valid UTF-8 NUL run is binary in the single-Vec path too.
213
+ nul: () => {
214
+ const m = detectVec(bytes(0x00, 0x00, 0x00));
215
+ assertEq(m.type, 'base64');
216
+ assertEq(m.mime_type, 'application/octet-stream');
217
+ assertEq(m.length, 3n);
218
+ },
219
+ },
30
220
  };
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Shared Unicode code-point contract for the UTF-8 and UTF-16 decoders: the
3
- * error-tag mask used to flag invalid sequences, and a streaming `decoder`
3
+ * error-tag mask used to flag invalid sequences, the streaming `decoder`
4
4
  * factory that wraps a per-unit step and an end-of-input step into a single
5
- * `List`-to-`List` conversion.
5
+ * `List`-to-`List` conversion, and the code-point classification predicates
6
+ * (BMP / surrogate / supplementary-plane / overall validity) that both codecs
7
+ * share.
6
8
  *
7
9
  * @module
8
10
  */
@@ -26,3 +28,38 @@ export declare const errorMask = 2147483648;
26
28
  * @returns A function converting a list of code units into a list of code points.
27
29
  */
28
30
  export declare const decoder: <Unit, S, Cp>(byteOp: StateScan<Unit, S | null, List<Cp>>, eofOp: (state: S | null) => readonly [List<Cp>, S | null]) => (input: List<Unit>) => List<Cp>;
31
+ /**
32
+ * Checks whether the 16-bit word (U16) is a high surrogate.
33
+ * Range: 0xD800 - 0xDBFF.
34
+ */
35
+ export declare const isHighSurrogate: (i: number) => boolean;
36
+ /**
37
+ * Checks whether the 16-bit word (U16) is a low surrogate.
38
+ * Range: 0xDC00 - 0xDFFF.
39
+ */
40
+ export declare const isLowSurrogate: (i: number) => boolean;
41
+ /**
42
+ * Checks whether the code point is in the BMP range.
43
+ * BMP is the main Unicode plane that covers code points 0x0000 - 0xFFFF, except
44
+ * for the range of surrogates.
45
+ */
46
+ export declare const isBmpCodePoint: (codePoint: number) => boolean;
47
+ /**
48
+ * Checks whether the code point belongs to a supplementary (additional) Unicode
49
+ * plane. Supplementary planes cover code points from 0x010000 to 0x10FFFF.
50
+ */
51
+ export declare const isSupplementaryPlane: (i: number) => boolean;
52
+ /**
53
+ * Checks whether the code point is a valid scalar value: within the assignable
54
+ * Unicode range (0x0000 - 0x10FFFF) and not a surrogate.
55
+ */
56
+ export declare const isValidCodePoint: (c: number) => boolean;
57
+ /**
58
+ * Checks whether the code point is a *text* code point — distinct from
59
+ * {@link isValidCodePoint}, which gates well-formedness for decoding. A code
60
+ * point is text unless it is a control character: the controls are 0x0000 -
61
+ * 0x001F, 0x007F (DEL), and 0x0080 - 0x009F (C1), minus the whitespace block
62
+ * 0x0009 - 0x000D (TAB, LF, VT, FF, CR), which is legitimate in text. Every
63
+ * code point at or above 0x0020 that is not DEL or a C1 control is text.
64
+ */
65
+ export declare const isTextCodePoint: (c: number) => boolean;
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Shared Unicode code-point contract for the UTF-8 and UTF-16 decoders: the
3
- * error-tag mask used to flag invalid sequences, and a streaming `decoder`
3
+ * error-tag mask used to flag invalid sequences, the streaming `decoder`
4
4
  * factory that wraps a per-unit step and an end-of-input step into a single
5
- * `List`-to-`List` conversion.
5
+ * `List`-to-`List` conversion, and the code-point classification predicates
6
+ * (BMP / surrogate / supplementary-plane / overall validity) that both codecs
7
+ * share.
6
8
  *
7
9
  * @module
8
10
  */
9
11
  import { flat, stateScan } from "../../types/list/module.f.js";
12
+ import { contains } from "../../types/range/module.f.js";
10
13
  /**
11
14
  * Error mask used to tag invalid code points or encoding errors. A decoded
12
15
  * value with this bit set represents a malformed unit rather than a valid
@@ -29,3 +32,72 @@ export const decoder = (byteOp, eofOp) => {
29
32
  const run = stateScan(op)(null);
30
33
  return input => flat(run(flat([input, [null]])));
31
34
  };
35
+ /**
36
+ * Unicode code-point classification boundaries. The surrogate block
37
+ * (`0xD800`–`0xDFFF`) splits into a high half (`0xD800`–`0xDBFF`) and a low half
38
+ * (`0xDC00`–`0xDFFF`); the BMP ends at `0xFFFF` and `maxCodePoint` (`0x10FFFF`)
39
+ * is the largest assignable code point. Every predicate below is derived from
40
+ * these constants so the surrogate bounds and the maximum appear exactly once.
41
+ */
42
+ const surrogateMin = 0xd800;
43
+ const lowSurrogateMin = 0xdc00;
44
+ const surrogateMax = 0xdfff;
45
+ const bmpMax = 0xffff;
46
+ const maxCodePoint = 0x10_ffff;
47
+ /**
48
+ * Checks whether the 16-bit word (U16) is a high surrogate.
49
+ * Range: 0xD800 - 0xDBFF.
50
+ */
51
+ export const isHighSurrogate = contains([surrogateMin, lowSurrogateMin - 1]);
52
+ /**
53
+ * Checks whether the 16-bit word (U16) is a low surrogate.
54
+ * Range: 0xDC00 - 0xDFFF.
55
+ */
56
+ export const isLowSurrogate = contains([lowSurrogateMin, surrogateMax]);
57
+ /**
58
+ * Ranges of code points for the lower (Low) and higher (High) parts of the BMP
59
+ * (Basic Multilingual Plane), i.e. the BMP with the surrogate block removed.
60
+ */
61
+ const lowBmp = contains([0x0000, surrogateMin - 1]);
62
+ const highBmp = contains([surrogateMax + 1, bmpMax]);
63
+ /**
64
+ * Checks whether the code point is in the BMP range.
65
+ * BMP is the main Unicode plane that covers code points 0x0000 - 0xFFFF, except
66
+ * for the range of surrogates.
67
+ */
68
+ export const isBmpCodePoint = (codePoint) => lowBmp(codePoint) || highBmp(codePoint);
69
+ /**
70
+ * Checks whether the code point belongs to a supplementary (additional) Unicode
71
+ * plane. Supplementary planes cover code points from 0x010000 to 0x10FFFF.
72
+ */
73
+ export const isSupplementaryPlane = contains([bmpMax + 1, maxCodePoint]);
74
+ /**
75
+ * The full assignable code-point range and the surrogate block, used to gate
76
+ * overall validity below.
77
+ */
78
+ const validRange = contains([0, maxCodePoint]);
79
+ const isSurrogate = contains([surrogateMin, surrogateMax]);
80
+ /**
81
+ * Checks whether the code point is a valid scalar value: within the assignable
82
+ * Unicode range (0x0000 - 0x10FFFF) and not a surrogate.
83
+ */
84
+ export const isValidCodePoint = (c) => validRange(c) && !isSurrogate(c);
85
+ /**
86
+ * The control-character blocks and the whitespace exception used to separate
87
+ * *text* code points from binary control bytes. C0 controls span 0x0000 -
88
+ * 0x001F; 0x007F (DEL) and the C1 controls (0x0080 - 0x009F) form one
89
+ * contiguous run; the whitespace block 0x0009 - 0x000D (TAB, LF, VT, FF, CR) is
90
+ * the only control range legitimate in text.
91
+ */
92
+ const c0Control = contains([0x00, 0x1f]);
93
+ const delAndC1Control = contains([0x7f, 0x9f]);
94
+ const textWhitespace = contains([0x09, 0x0d]);
95
+ /**
96
+ * Checks whether the code point is a *text* code point — distinct from
97
+ * {@link isValidCodePoint}, which gates well-formedness for decoding. A code
98
+ * point is text unless it is a control character: the controls are 0x0000 -
99
+ * 0x001F, 0x007F (DEL), and 0x0080 - 0x009F (C1), minus the whitespace block
100
+ * 0x0009 - 0x000D (TAB, LF, VT, FF, CR), which is legitimate in text. Every
101
+ * code point at or above 0x0020 that is not DEL or a C1 control is text.
102
+ */
103
+ export const isTextCodePoint = (c) => textWhitespace(c) || !(c0Control(c) || delAndC1Control(c));
@@ -0,0 +1,8 @@
1
+ export declare const proof: {
2
+ isHighSurrogate: (() => void)[];
3
+ isLowSurrogate: (() => void)[];
4
+ isBmpCodePoint: (() => void)[];
5
+ isSupplementaryPlane: (() => void)[];
6
+ isValidCodePoint: (() => void)[];
7
+ isTextCodePoint: (() => void)[];
8
+ };
@@ -0,0 +1,72 @@
1
+ import { isBmpCodePoint, isHighSurrogate, isLowSurrogate, isSupplementaryPlane, isTextCodePoint, isValidCodePoint, } from "./module.f.js";
2
+ const check = (actual, expected) => {
3
+ if (actual !== expected) {
4
+ throw `${actual} !== ${expected}`;
5
+ }
6
+ };
7
+ export const proof = {
8
+ isHighSurrogate: [
9
+ () => check(isHighSurrogate(0xd800), true),
10
+ () => check(isHighSurrogate(0xdbff), true),
11
+ () => check(isHighSurrogate(0xd7ff), false),
12
+ () => check(isHighSurrogate(0xdc00), false),
13
+ ],
14
+ isLowSurrogate: [
15
+ () => check(isLowSurrogate(0xdc00), true),
16
+ () => check(isLowSurrogate(0xdfff), true),
17
+ () => check(isLowSurrogate(0xdbff), false),
18
+ () => check(isLowSurrogate(0xe000), false),
19
+ ],
20
+ isBmpCodePoint: [
21
+ // lowBmp branch true
22
+ () => check(isBmpCodePoint(0x0000), true),
23
+ () => check(isBmpCodePoint(0xd7ff), true),
24
+ // lowBmp false, highBmp true
25
+ () => check(isBmpCodePoint(0xe000), true),
26
+ () => check(isBmpCodePoint(0xffff), true),
27
+ // both false: surrogate and supplementary
28
+ () => check(isBmpCodePoint(0xd800), false),
29
+ () => check(isBmpCodePoint(0x10000), false),
30
+ ],
31
+ isSupplementaryPlane: [
32
+ () => check(isSupplementaryPlane(0x10000), true),
33
+ () => check(isSupplementaryPlane(0x10ffff), true),
34
+ () => check(isSupplementaryPlane(0xffff), false),
35
+ () => check(isSupplementaryPlane(0x110000), false),
36
+ ],
37
+ isValidCodePoint: [
38
+ // in range, not surrogate
39
+ () => check(isValidCodePoint(0x0000), true),
40
+ () => check(isValidCodePoint(0x10ffff), true),
41
+ // in range, surrogate -> invalid
42
+ () => check(isValidCodePoint(0xd800), false),
43
+ () => check(isValidCodePoint(0xdfff), false),
44
+ // out of range -> validRange short-circuits false
45
+ () => check(isValidCodePoint(-1), false),
46
+ () => check(isValidCodePoint(0x110000), false),
47
+ ],
48
+ isTextCodePoint: [
49
+ // C0 controls are binary...
50
+ () => check(isTextCodePoint(0x00), false), // NUL
51
+ () => check(isTextCodePoint(0x08), false), // BS
52
+ () => check(isTextCodePoint(0x1b), false), // ESC
53
+ () => check(isTextCodePoint(0x1f), false), // US
54
+ // ...except the whitespace block 0x09 - 0x0D
55
+ () => check(isTextCodePoint(0x09), true), // TAB
56
+ () => check(isTextCodePoint(0x0a), true), // LF
57
+ () => check(isTextCodePoint(0x0b), true), // VT
58
+ () => check(isTextCodePoint(0x0c), true), // FF
59
+ () => check(isTextCodePoint(0x0d), true), // CR
60
+ // printable ASCII is text
61
+ () => check(isTextCodePoint(0x20), true), // space
62
+ () => check(isTextCodePoint(0x41), true), // 'A'
63
+ () => check(isTextCodePoint(0x7e), true), // '~'
64
+ // DEL and the C1 controls are binary
65
+ () => check(isTextCodePoint(0x7f), false), // DEL
66
+ () => check(isTextCodePoint(0x80), false), // C1 start
67
+ () => check(isTextCodePoint(0x9f), false), // C1 end
68
+ // above C1 is text again
69
+ () => check(isTextCodePoint(0xa0), true), // NBSP
70
+ () => check(isTextCodePoint(0x10ffff), true),
71
+ ],
72
+ };