Download src/im/tools/encoding.ts from uos1231234/agent-shell: direct link, hf CLI and curl.
- Browser
- Download file 6.34 kB
-
https://huggingface.co/uos1231234/agent-shell/resolve/main/src/im/tools/encoding.ts
- Command line
-
hf download hf://uos1231234/agent-shell/src/im/tools/encoding.ts
-
curl -L -o encoding.ts https://huggingface.co/uos1231234/agent-shell/resolve/main/src/im/tools/encoding.ts
6.34 kB
| // encoding.ts | |
| // | |
| // Shared text-encoding helpers for the file tools (read.ts / edit.ts). | |
| // | |
| // Files are UTF-8 by default, but Chinese Windows editors routinely write | |
| // `.txt`/`.md`/source files as GBK (a subset of GB18030). This module: | |
| // - detects whether a Buffer is valid UTF-8 or GB18030 (with a round-trip | |
| // guard so an ambiguous file is never mis-classified), | |
| // - decodes a Buffer to a UTF-8 string, | |
| // - encodes a UTF-8 string back to the original on-disk encoding. | |
| // | |
| // Safety: a legacy encoding is only ever claimed when a full decode→re-encode | |
| // reproduces the file's exact bytes. That makes an in-place edit lossless for | |
| // the untouched content and refuses ambiguous files (Latin-1, Big5, truncated | |
| // UTF-8, binary) instead of corrupting them. Mirrors the AtomCode approach in | |
| // `atomcode-capabilities/src/tools/encoding.rs`. | |
| import iconvLite from 'iconv-lite' | |
| import { extname } from 'node:path' | |
| export type FileEncoding = 'utf8' | 'gb18030' | |
| /** Text-ish extensions worth trying a GB18030 decode for when UTF-8 fails. */ | |
| const TEXT_EXTENSIONS = new Set([ | |
| '.txt', '.md', '.csv', '.tsv', '.json', | |
| '.yaml', '.yml', '.xml', '.html', '.htm', | |
| '.log', '.conf', '.ini', '.properties', | |
| ]) | |
| function hasTextExtension(filename: string): boolean { | |
| const ext = extname(filename).toLowerCase() | |
| return TEXT_EXTENSIONS.has(ext) | |
| } | |
| /** | |
| * Pure-byte UTF-8 validator. | |
| * | |
| * Node's `Buffer.isUtf8()` was only added in v23.1.0 / v22.17.0 (behind a | |
| * flag at first) and is not reliably available across the supported Node | |
| * 18+ range. This hand-rolled validator implements the exact UTF-8 byte | |
| * sequence rules from RFC 3629: | |
| * - 1-byte: 0xxxxxxx (0x00–0x7F) | |
| * - 2-byte: 110xxxxx 10xxxxxx (lead 0xC2–0xDF; excludes overlong 0xC0/0xC1) | |
| * - 3-byte: 1110xxxx 10xxxxxx 10xxxxxx (lead 0xE0–0xEF; 0xE0 requires | |
| * second byte ≥ 0xA0; 0xED requires second byte ≤ 0x9F to exclude | |
| * surrogates) | |
| * - 4-byte: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (lead 0xF0–0xF4; 0xF0 | |
| * requires second byte ≥ 0x90; 0xF4 requires second byte ≤ 0x8F to | |
| * stay within Unicode range) | |
| * | |
| * Returns `true` only if EVERY byte is part of a valid sequence. | |
| */ | |
| function isValidUtf8(buf: Buffer): boolean { | |
| let i = 0 | |
| const len = buf.length | |
| while (i < len) { | |
| const b0 = buf[i]! | |
| if (b0 <= 0x7f) { | |
| // ASCII | |
| i += 1 | |
| } else if (b0 >= 0xc2 && b0 <= 0xdf) { | |
| // 2-byte sequence | |
| if (i + 1 >= len) return false | |
| if ((buf[i + 1]! & 0xc0) !== 0x80) return false | |
| i += 2 | |
| } else if (b0 >= 0xe0 && b0 <= 0xef) { | |
| // 3-byte sequence | |
| if (i + 2 >= len) return false | |
| const b1 = buf[i + 1]! | |
| const b2 = buf[i + 2]! | |
| if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80) return false | |
| // Reject overlongs and surrogates | |
| if (b0 === 0xe0 && b1 < 0xa0) return false | |
| if (b0 === 0xed && b1 > 0x9f) return false | |
| i += 3 | |
| } else if (b0 >= 0xf0 && b0 <= 0xf4) { | |
| // 4-byte sequence | |
| if (i + 3 >= len) return false | |
| const b1 = buf[i + 1]! | |
| const b2 = buf[i + 2]! | |
| const b3 = buf[i + 3]! | |
| if ((b1 & 0xc0) !== 0x80 || (b2 & 0xc0) !== 0x80 || (b3 & 0xc0) !== 0x80) return false | |
| // Reject overlongs and out-of-range (> U+10FFFF) | |
| if (b0 === 0xf0 && b1 < 0x90) return false | |
| if (b0 === 0xf4 && b1 > 0x8f) return false | |
| i += 4 | |
| } else { | |
| // 0x80–0xBF (lone continuation), 0xC0/0xC1 (overlong 2-byte), 0xF5–0xFF (out of range) | |
| return false | |
| } | |
| } | |
| return true | |
| } | |
| /** | |
| * Detect the on-disk encoding of a buffer. | |
| * | |
| * Decision tree: | |
| * 1. Non-text extension (e.g. `.png`) → `'utf8'` (no encoding concept; | |
| * caller treats as binary, read.ts will still return the bytes decoded | |
| * as utf8 which is the historical behavior). | |
| * 2. Valid UTF-8 → `'utf8'`. | |
| * 3. Text extension + GB18030 decode + round-trip (`encode(decode(buf))` | |
| * equals original bytes) → `'gb18030'`. | |
| * 4. Anything else → `'utf8'` (fallback; read.ts will emit replacement | |
| * chars, edit.ts will refuse via the round-trip guard in | |
| * `decodeForEdit`). | |
| */ | |
| export function detectEncoding(buf: Buffer, filename: string): FileEncoding { | |
| // Gate 1: only probe text-ish files. Binary files have no encoding. | |
| if (!hasTextExtension(filename)) return 'utf8' | |
| // Gate 2: valid UTF-8 wins immediately. | |
| if (isValidUtf8(buf)) return 'utf8' | |
| // Gate 3: try GB18030 with a round-trip guard. | |
| try { | |
| const decoded = iconvLite.decode(buf, 'gb18030') | |
| const reencoded = iconvLite.encode(decoded, 'gb18030') | |
| if (Buffer.from(reencoded).equals(buf)) return 'gb18030' | |
| } catch { | |
| // decode threw — definitely not GB18030 | |
| } | |
| return 'utf8' | |
| } | |
| /** Decode a buffer to a UTF-8 string using the given encoding. */ | |
| export function decode(buf: Buffer, enc: FileEncoding): string { | |
| if (enc === 'gb18030') return iconvLite.decode(buf, 'gb18030') | |
| return buf.toString('utf8') | |
| } | |
| /** Encode a UTF-8 string back to the on-disk encoding. */ | |
| export function encode(s: string, enc: FileEncoding): Buffer { | |
| if (enc === 'gb18030') return Buffer.from(iconvLite.encode(s, 'gb18030')) | |
| return Buffer.from(s, 'utf8') | |
| } | |
| /** | |
| * Decode a file for EDITING: return its text as UTF-8 plus the encoding to | |
| * write back, or `null` if the file cannot be losslessly decoded. | |
| * | |
| * Unlike `detectEncoding` + `decode` (which always returns a string for | |
| * display, falling back to utf8 with replacement chars), this is strict: | |
| * a file that is neither valid UTF-8 nor round-trip-safe GB18030 is refused | |
| * so edit.ts never corrupts an ambiguous file. | |
| * | |
| * - Valid UTF-8 → `{ text, encoding: 'utf8' }`. | |
| * - Text extension + GB18030 round-trip → `{ text, encoding: 'gb18030' }`. | |
| * - Anything else → `null` (caller refuses the edit). | |
| */ | |
| export function decodeForEdit( | |
| buf: Buffer, | |
| filename: string, | |
| ): { text: string; encoding: FileEncoding } | null { | |
| if (isValidUtf8(buf)) { | |
| return { text: buf.toString('utf8'), encoding: 'utf8' } | |
| } | |
| if (!hasTextExtension(filename)) return null | |
| try { | |
| const decoded = iconvLite.decode(buf, 'gb18030') | |
| const reencoded = iconvLite.encode(decoded, 'gb18030') | |
| if (Buffer.from(reencoded).equals(buf)) { | |
| return { text: decoded, encoding: 'gb18030' } | |
| } | |
| } catch { | |
| // not GB18030 | |
| } | |
| return null | |
| } | |