Files
scriptc/tests/corpus/1423-text-codec.ts
Chris Tate b94c88e31c feat: support static non-UTF-8 TextDecoder labels (#111)
* feat: support static non-UTF-8 TextDecoder labels

- Recognize static WHATWG labels and aliases during builtin lowering.
- Add feature-gated portable decoders generated against the pinned Node runtime.
- Cover legacy encodings, malformed input, and unsupported dynamic labels.

* fix: match Node legacy decoder recovery

* fix TextDecoder edge-case parity

* fix(runtime): match Node malformed text decoding

* fix TextDecoder recovery edge cases
2026-08-09 23:15:51 -05:00

207 lines
12 KiB
TypeScript

// The WHATWG encoder pair, composed and same-scope const store-then-call:
// utf-8 round trips, BOM stripping, and maximal-subpart replacement,
// byte-compared against Node.
const enc = new TextEncoder().encode("héllo😀");
console.log(enc.length, enc[0], enc[1], enc[2], enc[6]);
console.log(new TextDecoder().decode(enc));
// A leading BOM strips (Buffer.toString("utf8") keeps it — the one
// behavioral difference between the two decodes).
console.log(new TextDecoder().decode(new Uint8Array([0xef, 0xbb, 0xbf, 0x41, 0x42])));
console.log(new TextDecoder().decode(new Uint8Array([0xef, 0xbb, 0xbf])).length);
console.log(Buffer.from([0xef, 0xbb, 0xbf, 0x41]).toString("utf8").length);
// Invalid sequences replace per maximal subpart, exactly like Node.
console.log(JSON.stringify(new TextDecoder().decode(new Uint8Array([0x41, 0xff, 0x42]))));
console.log(JSON.stringify(new TextDecoder().decode(new Uint8Array([0xf0, 0x9f, 0x98]))));
// Zero-argument decode is "" per spec; the explicit utf-8 label works.
console.log(new TextDecoder().decode().length);
console.log(new TextDecoder("utf-8").decode(Buffer.from("hi", "utf8")));
// Round trip through both, Buffer input included.
const rt = new TextDecoder().decode(new TextEncoder().encode("round ✓ trip"));
console.log(rt === "round ✓ trip", rt);
console.log(new TextEncoder().encode("").length);
// The ordinary stored-instance idiom is compile-time alias plumbing: the
// default constructors have no effects, and supported calls resolve back
// through a stable const without materializing the codec object.
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const stored = encoder.encode("stored héllo 😀");
console.log(stored.length, decoder.decode(stored));
console.log(encoder.encode("again").length, decoder.decode(Buffer.from("twice", "utf8")));
// An explicit literal utf-8 label is equally effect-free. Function-local
// bindings take the same rewrite within their own execution scope.
function localRoundTrip(s: string): string {
const labelledDecoder = new TextDecoder("utf-8");
const localEncoder = new TextEncoder();
return labelledDecoder.decode(localEncoder.encode(s));
}
console.log(localRoundTrip("local ✓"));
// Every WHATWG legacy family lowers when the label is static. The generated
// single-byte tables are each touched here; JSON escaping keeps C0/C1 and
// undefined-byte replacements visible in the differential output.
const singleByteSamples = [
new TextDecoder("ibm866").decode(new Uint8Array([0x80])),
new TextDecoder("iso-8859-2").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-3").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-4").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-5").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-6").decode(new Uint8Array([0xac])),
new TextDecoder("iso-8859-7").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-8").decode(new Uint8Array([0xe0])),
new TextDecoder("iso-8859-8-i").decode(new Uint8Array([0xe1])),
new TextDecoder("iso-8859-10").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-13").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-14").decode(new Uint8Array([0xa1])),
new TextDecoder("iso-8859-15").decode(new Uint8Array([0xa4])),
new TextDecoder("iso-8859-16").decode(new Uint8Array([0xa1])),
new TextDecoder("koi8-r").decode(new Uint8Array([0xe1])),
new TextDecoder("koi8-u").decode(new Uint8Array([0xa4])),
new TextDecoder("macintosh").decode(new Uint8Array([0xdb])),
new TextDecoder("windows-874").decode(new Uint8Array([0xa1])),
new TextDecoder("windows-1250").decode(new Uint8Array([0x8a])),
new TextDecoder("windows-1251").decode(new Uint8Array([0xc0])),
new TextDecoder("windows-1252").decode(new Uint8Array([0x80])),
new TextDecoder("windows-1253").decode(new Uint8Array([0xc1])),
new TextDecoder("windows-1254").decode(new Uint8Array([0xd0])),
new TextDecoder("windows-1255").decode(new Uint8Array([0xe0])),
new TextDecoder("windows-1256").decode(new Uint8Array([0xc7])),
new TextDecoder("windows-1257").decode(new Uint8Array([0xc0])),
new TextDecoder("windows-1258").decode(new Uint8Array([0xd5])),
new TextDecoder("x-mac-cyrillic").decode(new Uint8Array([0x80])),
];
console.log(JSON.stringify(singleByteSamples));
// Labels trim ASCII whitespace and fold ASCII case; latin1/ascii are the
// Encoding Standard's windows-1252 aliases, not Buffer's byte-for-byte forms.
const latinDecoder = new TextDecoder(" \tLaTiN1\r\n");
console.log(latinDecoder.decode(new Uint8Array([0x41, 0x80, 0x92, 0x42])));
console.log(new TextDecoder("ASCII").decode(new Uint8Array([0x80])));
console.log(JSON.stringify(new TextDecoder("x-user-defined").decode(new Uint8Array([0x41, 0x80, 0xff]))));
// UTF-16 has TextDecoder-specific BOM stripping and replacement for lone
// surrogates / odd trailing bytes (Buffer.toString("utf16le") differs).
console.log(new TextDecoder("utf-16le").decode(new Uint8Array([0xff, 0xfe, 0x61, 0x00, 0x3d, 0xd8, 0x00, 0xde])));
console.log(new TextDecoder("unicodefffe").decode(new Uint8Array([0xfe, 0xff, 0x00, 0x61, 0xd8, 0x3d, 0xde, 0x00])));
console.log(JSON.stringify(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xd8, 0x61, 0x00, 0xff]))));
console.log(JSON.stringify(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xd8, 0x12]))));
console.log(JSON.stringify(new TextDecoder("utf-16be").decode(new Uint8Array([0xd8, 0x00, 0x12]))));
// The multibyte families: two- and four-byte GB, traditional Chinese,
// Japanese stateful/stateless forms, and Korean.
console.log(new TextDecoder("GBK").decode(new Uint8Array([0xd6, 0xd0, 0xce, 0xc4])));
console.log(new TextDecoder("gb18030").decode(new Uint8Array([0x90, 0x30, 0x81, 0x30])));
console.log(new TextDecoder("big5").decode(new Uint8Array([0xa4, 0xa4, 0xa4, 0xe5])));
console.log(new TextDecoder("euc-jp").decode(new Uint8Array([0xc6, 0xfc, 0xcb, 0xdc, 0xb8, 0xec])));
console.log(new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x46, 0x7c, 0x4b, 0x5c, 0x38, 0x6c, 0x1b, 0x28, 0x42])));
console.log(new TextDecoder("shift_jis").decode(new Uint8Array([0x93, 0xfa, 0x96, 0x7b, 0x8c, 0xea])));
console.log(new TextDecoder("euc-kr").decode(new Uint8Array([0xc7, 0xd1, 0xb1, 0xdb, 0xbe, 0xee])));
// Invalid/truncated sequences pin replacement and byte reprocessing.
console.log(JSON.stringify(new TextDecoder("gb18030").decode(new Uint8Array([0x81, 0x30, 0x41]))));
console.log(JSON.stringify(new TextDecoder("big5").decode(new Uint8Array([0x81, 0x20, 0xff]))));
console.log(JSON.stringify(new TextDecoder("euc-jp").decode(new Uint8Array([0xa1, 0x20, 0x8f]))));
console.log(JSON.stringify(new TextDecoder("shift_jis").decode(new Uint8Array([0x81, 0x20, 0x81]))));
console.log(JSON.stringify(new TextDecoder("euc-kr").decode(new Uint8Array([0xa1, 0x20, 0xa1]))));
// Recovery rules vary by converter: some invalid bytes are consumed, some
// return to the input queue, and ICU recognizes a few historical extensions.
const recoverySamples = [
new TextDecoder("gb18030").decode(new Uint8Array([0x81, 0xff])),
new TextDecoder("gb18030").decode(new Uint8Array([0x84, 0x31, 0xa4, 0x37])),
new TextDecoder("big5").decode(new Uint8Array([0x81, 0xff])),
new TextDecoder("euc-jp").decode(new Uint8Array([0xa1, 0xff])),
new TextDecoder("euc-jp").decode(new Uint8Array([0x8e, 0xe0])),
new TextDecoder("euc-jp").decode(new Uint8Array([0x8f, 0xea, 0x00])),
new TextDecoder("euc-kr").decode(new Uint8Array([0xa1, 0xff])),
new TextDecoder("euc-kr").decode(new Uint8Array([0x8e, 0xa1])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x41])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x4f])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x28, 0x48, 0x5c, 0x7e])),
// In JIS mode ICU groups adjacent invalid payload bytes, but SO/SI begin
// standalone errors; a grouped CR/LF does not perform the line reset.
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x00, 0x00])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x21, 0x0e])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x00, 0x0a, 0x41])),
// Malformed escapes restore their payload in the active JIS state;
// consecutive designations toggle ICU's replacement flag.
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x1b, 0x24, 0x21])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x1b, 0x24, 0x42, 0x1b, 0x24, 0x42])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x26, 0x40, 0x1b, 0x24, 0x42, 0x46, 0x7c])),
// Four-byte designation prefixes stay pending until their final byte;
// unknown finals restore the payload while known finals are one error.
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x28, 0x00])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x28, 0x41])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x25, 0x2f, 0x42])),
// ICU's JIS-1990 announcer enters lead state even without a following
// designation; the next two payload bytes therefore form a JIS pair.
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x26, 0x40, 0x21, 0x5a])),
// ICU emits line separators in JIS/Katakana states and resumes in ASCII.
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x24, 0x42, 0x46, 0x7c, 0x0a, 0x41])),
new TextDecoder("iso-2022-jp").decode(new Uint8Array([0x1b, 0x28, 0x49, 0x21, 0x0d, 0x41])),
];
console.log(JSON.stringify(recoverySamples));
// A compact deterministic byte sweep exercises malformed-sequence recovery,
// truncated leads, state resets between decode() calls, and sparse table
// holes. JSON is the byte-for-byte oracle artifact.
const decoderVectors: number[][] = [];
let decoderSeed = 0x6d2b79f5;
for (let i = 0; i < 96; i++) {
const vector: number[] = [];
for (let j = 0; j < i % 7; j++) {
decoderSeed ^= decoderSeed << 13;
decoderSeed ^= decoderSeed >>> 17;
decoderSeed ^= decoderSeed << 5;
decoderSeed >>>= 0;
vector.push(decoderSeed >>> 24);
}
decoderVectors.push(vector);
}
const gbSweepDecoder = new TextDecoder("gb18030");
const gbSweep: string[] = [];
for (const vector of decoderVectors) gbSweep.push(gbSweepDecoder.decode(new Uint8Array(vector)));
console.log(JSON.stringify(gbSweep));
const big5SweepDecoder = new TextDecoder("big5");
const big5Sweep: string[] = [];
for (const vector of decoderVectors) big5Sweep.push(big5SweepDecoder.decode(new Uint8Array(vector)));
console.log(JSON.stringify(big5Sweep));
const eucJpSweepDecoder = new TextDecoder("euc-jp");
const eucJpSweep: string[] = [];
for (const vector of decoderVectors) eucJpSweep.push(eucJpSweepDecoder.decode(new Uint8Array(vector)));
console.log(JSON.stringify(eucJpSweep));
const isoJpSweepDecoder = new TextDecoder("iso-2022-jp");
const isoJpSweep: string[] = [];
for (const vector of decoderVectors) isoJpSweep.push(isoJpSweepDecoder.decode(new Uint8Array(vector)));
console.log(JSON.stringify(isoJpSweep));
const shiftJisSweepDecoder = new TextDecoder("shift_jis");
const shiftJisSweep: string[] = [];
for (const vector of decoderVectors) shiftJisSweep.push(shiftJisSweepDecoder.decode(new Uint8Array(vector)));
console.log(JSON.stringify(shiftJisSweep));
const eucKrSweepDecoder = new TextDecoder("euc-kr");
const eucKrSweep: string[] = [];
for (const vector of decoderVectors) eucKrSweep.push(eucKrSweepDecoder.decode(new Uint8Array(vector)));
console.log(JSON.stringify(eucKrSweep));
// A switch clause is still one straight-line execution region: the
// declaration dominates the call when both live in that clause.
const codecCase = 1;
switch (codecCase) {
case 1:
const caseEncoder = new TextEncoder();
console.log(caseEncoder.encode("same clause").length);
break;
}