File size: 8,755 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | import { Readable } from 'node:stream';
function isUtf8Continuation(byte: number): boolean {
return byte >= 0x80 && byte <= 0xbf;
}
function decodeUtf8Ignore(data: Buffer): string {
let output = '';
let i = 0;
while (i < data.length) {
const b0 = data[i];
if (b0 === undefined) break;
if (b0 <= 0x7f) {
output += String.fromCodePoint(b0);
i += 1;
continue;
}
if (b0 >= 0xc2 && b0 <= 0xdf) {
const b1 = data[i + 1];
if (b1 !== undefined && isUtf8Continuation(b1)) {
output += String.fromCodePoint(((b0 & 0x1f) << 6) | (b1 & 0x3f));
i += 2;
continue;
}
i += 1;
continue;
}
if (b0 >= 0xe0 && b0 <= 0xef) {
const b1 = data[i + 1];
const b2 = data[i + 2];
const validSecond =
b1 !== undefined &&
((b0 === 0xe0 && b1 >= 0xa0 && b1 <= 0xbf) ||
(b0 >= 0xe1 && b0 <= 0xec && isUtf8Continuation(b1)) ||
(b0 === 0xed && b1 >= 0x80 && b1 <= 0x9f) ||
(b0 >= 0xee && b0 <= 0xef && isUtf8Continuation(b1)));
if (validSecond && b2 !== undefined && isUtf8Continuation(b2)) {
output += String.fromCodePoint(((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f));
i += 3;
continue;
}
i += 1;
continue;
}
if (b0 >= 0xf0 && b0 <= 0xf4) {
const b1 = data[i + 1];
const b2 = data[i + 2];
const b3 = data[i + 3];
const validSecond =
b1 !== undefined &&
((b0 === 0xf0 && b1 >= 0x90 && b1 <= 0xbf) ||
(b0 >= 0xf1 && b0 <= 0xf3 && isUtf8Continuation(b1)) ||
(b0 === 0xf4 && b1 >= 0x80 && b1 <= 0x8f));
if (
validSecond &&
b2 !== undefined &&
b3 !== undefined &&
isUtf8Continuation(b2) &&
isUtf8Continuation(b3)
) {
output += String.fromCodePoint(
((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f),
);
i += 4;
continue;
}
i += 1;
continue;
}
i += 1;
}
return output;
}
function decodeUtf16LeIgnore(data: Buffer): string {
let output = '';
let i = 0;
while (i + 1 < data.length) {
const first = data[i];
const second = data[i + 1];
if (first === undefined || second === undefined) break;
const codeUnit = first | (second << 8);
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const lowFirst = data[i + 2];
const lowSecond = data[i + 3];
if (lowFirst !== undefined && lowSecond !== undefined) {
const low = lowFirst | (lowSecond << 8);
if (low >= 0xdc00 && low <= 0xdfff) {
const codePoint = 0x10000 + ((codeUnit - 0xd800) << 10) + (low - 0xdc00);
output += String.fromCodePoint(codePoint);
i += 4;
continue;
}
}
i += 2;
continue;
}
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
i += 2;
continue;
}
output += String.fromCodePoint(codeUnit);
i += 2;
}
return output;
}
/**
* Decode a Buffer into a string with Python-compatible `errors` handling.
*
* - `'strict'` (default): throw on invalid sequences (via TextDecoder `fatal: true`)
* - `'replace'`: substitute each invalid sequence with U+FFFD (TextDecoder default)
* - `'ignore'`: drop invalid input sequences while preserving valid U+FFFD characters
*
* Falls back to `Buffer.toString(encoding)` for encodings TextDecoder does not
* support (e.g. `hex`, `base64`, `binary`, `latin1`) — those are lossless
* byte-to-character mappings so `errors` has no effect.
* @internal
*/
export function decodeTextWithErrors(
data: Buffer,
encoding: BufferEncoding,
errors: 'strict' | 'replace' | 'ignore' = 'strict',
ignoreBOM: boolean = false,
): string {
// Map Node's BufferEncoding names to Web TextDecoder labels where the two
// diverge. Only UTF-family encodings participate in the strict/replace/
// ignore dance; the others are lossless and use Buffer.toString directly.
let webLabel: string | undefined;
// eslint-disable-next-line typescript-eslint/switch-exhaustiveness-check
switch (encoding) {
case 'utf-8':
case 'utf8':
webLabel = 'utf-8';
break;
case 'utf16le':
case 'ucs2':
case 'ucs-2':
webLabel = 'utf-16le';
break;
default:
webLabel = undefined;
}
if (webLabel === undefined) {
// Non-UTF encodings (hex/base64/latin1/binary/ascii) are lossless byte↔
// character mappings; `errors` is meaningless for them. Return raw.
return data.toString(encoding);
}
if (errors === 'strict') {
return new TextDecoder(webLabel, { fatal: true, ignoreBOM }).decode(data);
}
// 'ignore' must skip invalid input bytes/code units, not delete every
// replacement character in the decoded output. A file can contain a valid
// U+FFFD, and Python preserves it under errors="ignore".
if (errors === 'ignore') {
return webLabel === 'utf-8' ? decodeUtf8Ignore(data) : decodeUtf16LeIgnore(data);
}
// 'replace' → substitute each invalid sequence with U+FFFD (default).
return new TextDecoder(webLabel, { fatal: false, ignoreBOM }).decode(data);
}
/**
* Convert a glob pattern segment (e.g. "*.txt", "file?.log") into a RegExp.
* Mirrors Python pathlib behavior: includes dotfiles, case-sensitive by default.
* @internal
*/
export function globPatternToRegex(pattern: string, caseSensitive: boolean): RegExp {
let regex = '^';
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === undefined) break;
switch (ch) {
case '*':
regex += '[^/]*';
break;
case '?':
regex += '[^/]';
break;
case '[': {
const end = pattern.indexOf(']', i + 1);
if (end === -1) {
regex += '\\[';
} else {
// Glob character classes only use `!` for negation. A literal
// leading `^` must remain literal even though JS regex char
// classes treat it as negation in the first position.
let charClass = pattern.slice(i + 1, end);
// Escape backslashes inside the class so a trailing backslash
// does not accidentally escape the closing `]`.
charClass = charClass.replace(/\\/g, '\\\\');
if (charClass.startsWith('!')) {
charClass = '^' + charClass.slice(1);
} else if (charClass.startsWith('^')) {
charClass = '\\' + charClass;
}
regex += '[' + charClass + ']';
i = end;
}
break;
}
case '\\': {
if (i + 1 < pattern.length) {
const next = pattern.charAt(i + 1);
regex += next.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&');
// Advance past the escaped character so it is not processed
// again as a regex metacharacter. match literally.
i++;
} else {
regex += '\\\\';
}
break;
}
default:
regex += ch.replaceAll(/[{}()+.\\[\]^$|]/g, '\\$&');
}
}
regex += '$';
return new RegExp(regex, caseSensitive ? '' : 'i');
}
/**
* A Readable wrapper that preserves source backpressure while still allowing
* consumers to read buffered output after the source has ended.
* @internal
*/
export class BufferedReadable extends Readable {
private readonly _source: Readable;
private _ended: boolean = false;
constructor(source: Readable) {
// Keep a modest prefetch window so wait()-then-read still works for
// common small/medium outputs without draining unboundedly.
super({ highWaterMark: 128 * 1024 });
this._source = source;
this._source.on('data', this._onData);
this._source.on('end', this._onEnd);
this._source.on('close', this._onClose);
this._source.on('error', this._onError);
}
override _read(): void {
if (!this._ended && !this.destroyed) {
this._source.resume();
}
}
override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
this._source.off('data', this._onData);
this._source.off('end', this._onEnd);
this._source.off('close', this._onClose);
this._source.off('error', this._onError);
this._source.destroy();
callback(error);
}
private readonly _onData = (chunk: string | Uint8Array): void => {
if (!this.push(chunk)) {
this._source.pause();
}
};
private readonly _onEnd = (): void => {
this._ended = true;
this.push(null);
};
private readonly _onClose = (): void => {
if (!this._ended) {
this._ended = true;
this.push(null);
}
};
private readonly _onError = (error: Error): void => {
this.destroy(error);
};
}
|