| 1 | // Package encoding detects and converts file encodings for the built-in |
| 2 | // file tools. The detection cascade (BOM → strict UTF-8 → GB18030 → lossy |
| 3 | // UTF-8) mirrors v1's file-encoding.ts and keeps CJK Windows files editable |
| 4 | // without silently mangling their bytes. |
| 5 | package encoding |
| 6 | |
| 7 | import ( |
| 8 | "bytes" |
| 9 | "encoding/binary" |
| 10 | "os" |
| 11 | "unicode/utf8" |
| 12 | |
| 13 | "golang.org/x/text/encoding/simplifiedchinese" |
| 14 | "golang.org/x/text/transform" |
| 15 | ) |
| 16 | |
| 17 | // Kind identifies a detected file encoding. |
| 18 | type Kind int |
| 19 | |
| 20 | const ( |
| 21 | // UTF8 is plain UTF-8 without a BOM — the common case. |
| 22 | UTF8 Kind = iota |
| 23 | // UTF8BOM is UTF-8 with a leading BOM (EF BB BF). |
| 24 | UTF8BOM |
| 25 | // UTF16LE is UTF-16 Little-Endian with a BOM (FF FE). |
| 26 | UTF16LE |
| 27 | // UTF16BE is UTF-16 Big-Endian with a BOM (FE FF). |
| 28 | UTF16BE |
| 29 | // GB18030 is the Chinese national standard charset (superset of GBK). |
| 30 | GB18030 |
| 31 | // LossyUTF8 is not valid UTF-8 and not valid GB18030 — decoded lossily |
| 32 | // as UTF-8 with replacement characters so the model sees something. |
| 33 | LossyUTF8 |
| 34 | // UTF16LENoBOM is UTF-16 Little-Endian without a BOM — common for source |
| 35 | // files saved by Windows tools. Detected heuristically from the NUL-byte |
| 36 | // pattern; written back without a BOM to preserve the original bytes. |
| 37 | UTF16LENoBOM |
| 38 | // UTF16BENoBOM is UTF-16 Big-Endian without a BOM. |
| 39 | UTF16BENoBOM |
| 40 | ) |
| 41 | |
| 42 | var utf8BOM = []byte{0xEF, 0xBB, 0xBF} |
| 43 | |
| 44 | // Detect returns the encoding kind for the given raw file bytes. The same |
| 45 | // bytes should then be passed to Decode for conversion to UTF-8. |
| 46 | func Detect(data []byte) (Kind, []byte) { |
| 47 | switch { |
| 48 | case len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF: |
| 49 | return UTF8BOM, data |
| 50 | case len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE: |
| 51 | return UTF16LE, data |
| 52 | case len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF: |
| 53 | return UTF16BE, data |
| 54 | } |
| 55 | // BOM-less UTF-16 must be tried before utf8.Valid: its low bytes plus 0x00 |
| 56 | // high bytes are all valid UTF-8 code units, so a naive check would tag a |
| 57 | // UTF-16 source file as UTF-8 and surface the embedded NULs as garbage. |
| 58 | if k, ok := DetectUTF16NoBOM(data); ok { |
| 59 | return k, data |
| 60 | } |
| 61 | if utf8.Valid(data) { |
| 62 | return UTF8, data |
| 63 | } |
| 64 | // Try GB18030 — it is a strict superset of GBK and rejects truly |
| 65 | // invalid byte sequences, so a successful decode is a reliable signal. |
| 66 | dec := simplifiedchinese.GB18030.NewDecoder() |
| 67 | if _, _, err := transform.Bytes(dec, data); err == nil { |
| 68 | return GB18030, data |
| 69 | } |
| 70 | return LossyUTF8, data |
| 71 | } |
| 72 | |
| 73 | // DetectQuick checks only for BOM prefixes in the first few bytes. This is |
| 74 | // the fast path for peek-based binary rejection: BOM-prefixed files (UTF-16, |
| 75 | // UTF-8 BOM) skip the NUL-byte check since 0x00 is normal in UTF-16. Returns |
| 76 | // UTF8 for non-BOM content (the caller should fall through to full Detect |
| 77 | // after verifying no NUL bytes). |
| 78 | func DetectQuick(peek []byte) Kind { |
| 79 | switch { |
| 80 | case len(peek) >= 3 && peek[0] == 0xEF && peek[1] == 0xBB && peek[2] == 0xBF: |
| 81 | return UTF8BOM |
| 82 | case len(peek) >= 2 && peek[0] == 0xFF && peek[1] == 0xFE: |
| 83 | return UTF16LE |
| 84 | case len(peek) >= 2 && peek[0] == 0xFE && peek[1] == 0xFF: |
| 85 | return UTF16BE |
| 86 | } |
| 87 | return UTF8 |
| 88 | } |
| 89 | |
| 90 | // DetectUTF16NoBOM heuristically recognises BOM-less UTF-16 from the NUL-byte |
| 91 | // distribution: ASCII-range text encodes one byte of payload and one 0x00 per |
| 92 | // code unit, so the NULs cluster on odd offsets (LE) or even offsets (BE). It |
| 93 | // requires a strong skew — one parity heavily NUL, the other almost none — so |
| 94 | // genuine binary (NULs on both parities) and plain UTF-8 (no NULs) fall through. |
| 95 | func DetectUTF16NoBOM(b []byte) (Kind, bool) { |
| 96 | n := len(b) |
| 97 | if n < 16 { |
| 98 | return UTF8, false |
| 99 | } |
| 100 | n &^= 1 // examine an even-length window so parity counts are comparable |
| 101 | var evenNUL, oddNUL int |
| 102 | for i := 0; i < n; i++ { |
| 103 | if b[i] != 0 { |
| 104 | continue |
| 105 | } |
| 106 | if i%2 == 0 { |
| 107 | evenNUL++ |
| 108 | } else { |
| 109 | oddNUL++ |
| 110 | } |
| 111 | } |
| 112 | half := n / 2 |
| 113 | switch { |
| 114 | case oddNUL*10 >= half*3 && evenNUL*20 <= half: |
| 115 | return UTF16LENoBOM, true |
| 116 | case evenNUL*10 >= half*3 && oddNUL*20 <= half: |
| 117 | return UTF16BENoBOM, true |
| 118 | } |
| 119 | return UTF8, false |
| 120 | } |
| 121 | |
| 122 | // Decode converts data from the given encoding to UTF-8 bytes. |
| 123 | func Decode(data []byte, enc Kind) []byte { |
| 124 | switch enc { |
| 125 | case UTF8BOM: |
| 126 | return data[3:] |
| 127 | case UTF16LE: |
| 128 | return decodeUTF16(data[2:], binary.LittleEndian) |
| 129 | case UTF16BE: |
| 130 | return decodeUTF16(data[2:], binary.BigEndian) |
| 131 | case UTF16LENoBOM: |
| 132 | return decodeUTF16(data, binary.LittleEndian) |
| 133 | case UTF16BENoBOM: |
| 134 | return decodeUTF16(data, binary.BigEndian) |
| 135 | case GB18030: |
| 136 | out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), data) |
| 137 | if err != nil { |
| 138 | return data // should not happen after Detect, but be safe |
| 139 | } |
| 140 | return out |
| 141 | } |
| 142 | // UTF8 and LossyUTF8 both pass through — LossyUTF8 is already |
| 143 | // "best effort" and Go strings can hold arbitrary bytes. |
| 144 | return data |
| 145 | } |
| 146 | |
| 147 | // DecodeToUTF8 converts raw text-like file bytes to UTF-8 using Reasonix's |
| 148 | // shared detection cascade. It is intended for user-editable structured files |
| 149 | // (TOML, JSON, dotenv, Markdown) before handing the content to strict parsers. |
| 150 | func DecodeToUTF8(data []byte) []byte { |
| 151 | enc, raw := Detect(data) |
| 152 | return Decode(raw, enc) |
| 153 | } |
| 154 | |
| 155 | // ReadFileUTF8 reads path and decodes text-like content to UTF-8. |
| 156 | func ReadFileUTF8(path string) ([]byte, error) { |
| 157 | data, err := os.ReadFile(path) |
| 158 | if err != nil { |
| 159 | return nil, err |
| 160 | } |
| 161 | return DecodeToUTF8(data), nil |
| 162 | } |
| 163 | |
| 164 | // Decoder returns a streaming transform.Transformer for the given encoding, |
| 165 | // suitable for wrapping an io.Reader via dec.Reader(r). Returns nil for UTF-8 |
| 166 | // and LossyUTF8 (no transformation needed — the caller should read directly). |
| 167 | func Decoder(enc Kind) transform.Transformer { |
| 168 | switch enc { |
| 169 | case UTF8BOM: |
| 170 | // UTF-8 BOM just needs the 3-byte prefix stripped; the content is |
| 171 | // already valid UTF-8. Callers handle BOM stripping via Decode. |
| 172 | return nil |
| 173 | case GB18030: |
| 174 | return simplifiedchinese.GB18030.NewDecoder() |
| 175 | } |
| 176 | // UTF16LE/BE are not self-synchronising and cannot be streamed |
| 177 | // line-by-line without full-file buffering. Callers must handle |
| 178 | // them separately. UTF8 and LossyUTF8 need no transformation. |
| 179 | return nil |
| 180 | } |
| 181 | |
| 182 | // Encode converts a UTF-8 string back to the given file encoding. |
| 183 | // UTF8 and LossyUTF8 produce plain UTF-8 bytes. |
| 184 | func Encode(text string, enc Kind) []byte { |
| 185 | switch enc { |
| 186 | case UTF8BOM: |
| 187 | return append(utf8BOM, []byte(text)...) |
| 188 | case UTF16LE: |
| 189 | return encodeUTF16(text, binary.LittleEndian, true) |
| 190 | case UTF16BE: |
| 191 | return encodeUTF16(text, binary.BigEndian, true) |
| 192 | case UTF16LENoBOM: |
| 193 | return encodeUTF16(text, binary.LittleEndian, false) |
| 194 | case UTF16BENoBOM: |
| 195 | return encodeUTF16(text, binary.BigEndian, false) |
| 196 | case GB18030: |
| 197 | out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewEncoder(), []byte(text)) |
| 198 | if err != nil { |
| 199 | return []byte(text) |
| 200 | } |
| 201 | return out |
| 202 | } |
| 203 | return []byte(text) |
| 204 | } |
| 205 | |
| 206 | // decodeUTF16 converts UTF-16 bytes (BOM already stripped) to UTF-8. |
| 207 | func decodeUTF16(b []byte, order binary.ByteOrder) []byte { |
| 208 | u := make([]uint16, 0, len(b)/2) |
| 209 | for i := 0; i+1 < len(b); i += 2 { |
| 210 | u = append(u, order.Uint16(b[i:i+2])) |
| 211 | } |
| 212 | return []byte(string(utf16Decode(u))) |
| 213 | } |
| 214 | |
| 215 | // encodeUTF16 converts a UTF-8 string to UTF-16 bytes, with a BOM when withBOM. |
| 216 | func encodeUTF16(text string, order binary.ByteOrder, withBOM bool) []byte { |
| 217 | runes := []rune(text) |
| 218 | encoded := utf16Encode(runes) |
| 219 | |
| 220 | var buf bytes.Buffer |
| 221 | if withBOM { |
| 222 | var bom [2]byte |
| 223 | if order == binary.LittleEndian { |
| 224 | bom[0], bom[1] = 0xFF, 0xFE |
| 225 | } else { |
| 226 | bom[0], bom[1] = 0xFE, 0xFF |
| 227 | } |
| 228 | buf.Write(bom[:]) |
| 229 | } |
| 230 | for _, u := range encoded { |
| 231 | var b [2]byte |
| 232 | order.PutUint16(b[:], u) |
| 233 | buf.Write(b[:]) |
| 234 | } |
| 235 | return buf.Bytes() |
| 236 | } |
| 237 | |
| 238 | // utf16Decode converts UTF-16 code units to runes, handling surrogate pairs. |
| 239 | func utf16Decode(u []uint16) []rune { |
| 240 | var out []rune |
| 241 | for i := 0; i < len(u); i++ { |
| 242 | c := u[i] |
| 243 | if c >= 0xD800 && c <= 0xDBFF && i+1 < len(u) { |
| 244 | c2 := u[i+1] |
| 245 | if c2 >= 0xDC00 && c2 <= 0xDFFF { |
| 246 | out = append(out, rune(c-0xD800)<<10|rune(c2-0xDC00)+0x10000) |
| 247 | i++ |
| 248 | continue |
| 249 | } |
| 250 | } |
| 251 | out = append(out, rune(c)) |
| 252 | } |
| 253 | return out |
| 254 | } |
| 255 | |
| 256 | // utf16Encode converts runes to UTF-16 code units, producing surrogates for |
| 257 | // supplementary plane characters. |
| 258 | func utf16Encode(runes []rune) []uint16 { |
| 259 | var out []uint16 |
| 260 | for _, r := range runes { |
| 261 | if r >= 0x10000 && r <= 0x10FFFF { |
| 262 | r -= 0x10000 |
| 263 | out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF))) |
| 264 | } else { |
| 265 | out = append(out, uint16(r)) |
| 266 | } |
| 267 | } |
| 268 | return out |
| 269 | } |
| 270 |