Files
DeepSeek-Reasonix/internal/fileutil/encoding/encoding.go
T
esengine 55c307834e fix(encoding): a file is GB18030 only if a write can restore its bytes
edit_file on a UTF-8 file carrying CJK text rewrote bytes it never touched:
continuation bytes of characters far from the edit came back as 84 31 A4 37.

Detect took a nil error from the GB18030 decoder as proof the file was
GB18030. The decoder never fails: it maps every invalid sequence to U+FFFD.
So any file that was not valid UTF-8 -- one stray byte is enough -- was read
as GB18030, and the read-modify-write tools (edit_file, multi_edit,
delete_range, write_file over an existing file), checkpoint restore and the
config migrations then re-encoded every unreadable sequence as the GB18030
form of U+FFFD.

Detect now names a charset only when decoding and re-encoding restores the
input byte for byte. GB18030 is tried first, then GBK, which restores what
GB18030 cannot such as CP936's single-byte euro 0x80, so such a file stays
editable in its own encoding; anything else is LossyUTF8, whose bytes pass
through untouched. The new Kind is appended because checkpoints persist it
by value.

GBK cannot hold every character, so Encode now returns an error instead of
falling back to UTF-8, which rewrote a whole CP936 file as UTF-8 when an edit
added a character such as an emoji. A legacy charset that cannot represent a
character answers an *UnencodableError naming it, its byte offset and the
charset, with ErrUnencodable as its identity; the file tools, checkpoint
restore and the config migrations refuse the write and leave the file as it
was. Fixtures encode through MustEncode.

Readers that hold only part of a stream say so rather than inherit the
whole-file answer:
- DetectFragment runs the round trip on the prefix the decoder consumes when
  not at EOF, so a character the cut split is dropped structurally. grep's
  8 KiB peek, read_file's 256 KiB sample and the desktop's 2 MiB file preview
  use it; the preview's next page starts at the split character, and lossy
  text is shown as text rather than as binary.
- DecodeOutput is the one entry point for bytes that are only displayed.
  The producer says which ends its bound cut (Cut{Head, Tail}); UTF-8 is read
  without a character split at a cut end, and an uncut end keeps its bytes,
  since a GBK pair there is output rather than half a rune. What no charset
  restores is still read as GB18030. Hook output passes its tail cut when
  truncated.
- DetectAndDecode decodes a legacy charset once; the edit tools and
  DecodeToUTF8 use it instead of detecting and decoding separately.

Tests that fail before this change (on the base or an earlier revision of it)
and pass here:
- TestE2EEditKeepsBytesOfUTF8FileWithStrayByte: e4 b8 ad became
  e4 b8 84 31 a4 37 outside the edit (fails on origin/main-v2).
- TestDetectUTF8WithStrayByteIsNotGB18030 (fails on origin/main-v2).
- TestWritesRefuseCharacterCP936CannotHold (edit_file, multi_edit,
  write_file): nil error and the whole CP936 file rewritten as UTF-8.
- TestDecodeOutputTrimsCutUTF8Edges: UTF-8 cut at the front read as mojibake.
- TestDecodeOutputKeepsUncutCodePageEdges, TestDecodeHookOutputKeepsUncutCodePageEdges:
  GBK pairs at an uncut edge (bc db, b0 a1 0d 0a, 6f 6b 20 e4 a1,
  b0 a1 20 6f 6b) were dropped as partial UTF-8 runes.
- TestEditKeepsCP936FileWithEuroByte: an edit adding Chinese wrote UTF-8
  into a CP936 file.
- TestGrepGB18030PeekWithoutNewline, TestGrepGB18030PeekCutMidCharacter:
  no matches past a peek cut mid-character.
- TestReadFileGB18030SampleWithoutNewline: garbled text past the sample.
- TestReadFileGB18030TruncatedWithoutNewline,
  TestReadFileGB18030TruncatedMidCharacter (desktop): the 2 MiB preview of a
  GB18030 file was garbled or ended in U+FFFD.
- TestDecodeHookOutputReadsTruncatedCodePageText,
  TestDecodeHookOutputReadsCP936Euro: mojibake for cut or euro-carrying cmd.exe output.
- TestEncodeRefusesCharacterTheCharsetCannotHold, TestDetectGBKWithEuroByte,
  TestDetectFragmentDropsTheSplitCharacter, TestDecodeOutputReadsCutCodePageText:
  the API's contract.
2026-09-26 11:52:22 -07:00

262 lines
7.9 KiB
Go

// Package encoding detects and converts file encodings for the built-in
// file tools. The detection cascade (BOM → strict UTF-8 → GB18030 → GBK →
// lossy UTF-8) mirrors v1's file-encoding.ts and keeps CJK Windows files editable
// without silently mangling their bytes.
package encoding
import (
"bytes"
"encoding/binary"
"os"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// Kind identifies a detected file encoding.
type Kind int
const (
// UTF8 is plain UTF-8 without a BOM — the common case.
UTF8 Kind = iota
// UTF8BOM is UTF-8 with a leading BOM (EF BB BF).
UTF8BOM
// UTF16LE is UTF-16 Little-Endian with a BOM (FF FE).
UTF16LE
// UTF16BE is UTF-16 Big-Endian with a BOM (FE FF).
UTF16BE
// GB18030 is the Chinese national standard charset (superset of GBK).
GB18030
// LossyUTF8 is not valid UTF-8 and no charset restores it byte for byte.
// Its bytes pass through untouched, so a rewrite keeps what it did not edit.
LossyUTF8
// UTF16LENoBOM is UTF-16 Little-Endian without a BOM — common for source
// files saved by Windows tools. Detected heuristically from the NUL-byte
// pattern; written back without a BOM to preserve the original bytes.
UTF16LENoBOM
// UTF16BENoBOM is UTF-16 Big-Endian without a BOM.
UTF16BENoBOM
// GBK is CP936 where GB18030 cannot restore the bytes, such as its 0x80 euro.
// Appended: checkpoints persist a Kind by value.
GBK
)
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
// Detect returns the encoding kind for the given raw file bytes. The same
// bytes should then be passed to Decode for conversion to UTF-8.
func Detect(data []byte) (Kind, []byte) {
k, _, _ := sniff(data, true)
return k, data
}
// DetectQuick checks only for BOM prefixes in the first few bytes. This is
// the fast path for peek-based binary rejection: BOM-prefixed files (UTF-16,
// UTF-8 BOM) skip the NUL-byte check since 0x00 is normal in UTF-16. Returns
// UTF8 for non-BOM content (the caller should fall through to full Detect
// after verifying no NUL bytes).
func DetectQuick(peek []byte) Kind {
switch {
case len(peek) >= 3 && peek[0] == 0xEF && peek[1] == 0xBB && peek[2] == 0xBF:
return UTF8BOM
case len(peek) >= 2 && peek[0] == 0xFF && peek[1] == 0xFE:
return UTF16LE
case len(peek) >= 2 && peek[0] == 0xFE && peek[1] == 0xFF:
return UTF16BE
}
return UTF8
}
// DetectUTF16NoBOM heuristically recognises BOM-less UTF-16 from the NUL-byte
// distribution: ASCII-range text encodes one byte of payload and one 0x00 per
// code unit, so the NULs cluster on odd offsets (LE) or even offsets (BE). It
// requires a strong skew — one parity heavily NUL, the other almost none — so
// genuine binary (NULs on both parities) and plain UTF-8 (no NULs) fall through.
func DetectUTF16NoBOM(b []byte) (Kind, bool) {
n := len(b)
if n < 16 {
return UTF8, false
}
n &^= 1 // examine an even-length window so parity counts are comparable
var evenNUL, oddNUL int
for i := range n {
if b[i] != 0 {
continue
}
if i%2 == 0 {
evenNUL++
} else {
oddNUL++
}
}
half := n / 2
switch {
case oddNUL*10 >= half*3 && evenNUL*20 <= half:
return UTF16LENoBOM, true
case evenNUL*10 >= half*3 && oddNUL*20 <= half:
return UTF16BENoBOM, true
}
return UTF8, false
}
// Decode converts data from the given encoding to UTF-8 bytes.
func Decode(data []byte, enc Kind) []byte {
switch enc {
case UTF8BOM:
return data[3:]
case UTF16LE:
return decodeUTF16(data[2:], binary.LittleEndian)
case UTF16BE:
return decodeUTF16(data[2:], binary.BigEndian)
case UTF16LENoBOM:
return decodeUTF16(data, binary.LittleEndian)
case UTF16BENoBOM:
return decodeUTF16(data, binary.BigEndian)
case GB18030, GBK:
out, _, err := transform.Bytes(charsetOf(enc).NewDecoder(), data)
if err != nil {
return data // should not happen after Detect, but be safe
}
return out
}
// UTF8 and LossyUTF8 both pass through — LossyUTF8 is already
// "best effort" and Go strings can hold arbitrary bytes.
return data
}
// DecodeToUTF8 converts raw text-like file bytes to UTF-8 using Reasonix's
// shared detection cascade. It is intended for user-editable structured files
// (TOML, JSON, dotenv, Markdown) before handing the content to strict parsers.
func DecodeToUTF8(data []byte) []byte {
_, out := DetectAndDecode(data)
return out
}
// ReadFileUTF8 reads path and decodes text-like content to UTF-8.
func ReadFileUTF8(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return DecodeToUTF8(data), nil
}
// Decoder returns a streaming transform.Transformer for the given encoding,
// suitable for wrapping an io.Reader via dec.Reader(r). Returns nil for UTF-8
// and LossyUTF8 (no transformation needed — the caller should read directly).
func Decoder(enc Kind) transform.Transformer {
switch enc {
case UTF8BOM:
// UTF-8 BOM just needs the 3-byte prefix stripped; the content is
// already valid UTF-8. Callers handle BOM stripping via Decode.
return nil
case GB18030, GBK:
return charsetOf(enc).NewDecoder()
case UTF16LE:
return unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM).NewDecoder()
case UTF16BE:
return unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM).NewDecoder()
case UTF16LENoBOM:
return unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder()
case UTF16BENoBOM:
return unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM).NewDecoder()
}
// UTF8 and LossyUTF8 need no transformation.
return nil
}
// Encode converts a UTF-8 string back to the given file encoding. UTF8 and
// LossyUTF8 produce plain UTF-8 bytes. A legacy charset that cannot represent a
// character answers ErrUnencodable rather than switching the file to UTF-8.
func Encode(text string, enc Kind) ([]byte, error) {
switch enc {
case UTF8BOM:
return append(utf8BOM, []byte(text)...), nil
case UTF16LE:
return encodeUTF16(text, binary.LittleEndian, true), nil
case UTF16BE:
return encodeUTF16(text, binary.BigEndian, true), nil
case UTF16LENoBOM:
return encodeUTF16(text, binary.LittleEndian, false), nil
case UTF16BENoBOM:
return encodeUTF16(text, binary.BigEndian, false), nil
case GB18030, GBK:
return encodeCharset(text, enc)
}
return []byte(text), nil
}
// MustEncode is Encode for text known to be representable, such as a fixture.
func MustEncode(text string, enc Kind) []byte {
out, err := Encode(text, enc)
if err != nil {
panic(err)
}
return out
}
// decodeUTF16 converts UTF-16 bytes (BOM already stripped) to UTF-8.
func decodeUTF16(b []byte, order binary.ByteOrder) []byte {
u := make([]uint16, 0, len(b)/2)
for i := 0; i+1 < len(b); i += 2 {
u = append(u, order.Uint16(b[i:i+2]))
}
return []byte(string(utf16Decode(u)))
}
// encodeUTF16 converts a UTF-8 string to UTF-16 bytes, with a BOM when withBOM.
func encodeUTF16(text string, order binary.ByteOrder, withBOM bool) []byte {
runes := []rune(text)
encoded := utf16Encode(runes)
var buf bytes.Buffer
if withBOM {
var bom [2]byte
if order == binary.LittleEndian {
bom[0], bom[1] = 0xFF, 0xFE
} else {
bom[0], bom[1] = 0xFE, 0xFF
}
buf.Write(bom[:])
}
for _, u := range encoded {
var b [2]byte
order.PutUint16(b[:], u)
buf.Write(b[:])
}
return buf.Bytes()
}
// utf16Decode converts UTF-16 code units to runes, handling surrogate pairs.
func utf16Decode(u []uint16) []rune {
var out []rune
for i := 0; i < len(u); i++ {
c := u[i]
if c >= 0xD800 && c <= 0xDBFF && i+1 < len(u) {
c2 := u[i+1]
if c2 >= 0xDC00 && c2 <= 0xDFFF {
out = append(out, rune(c-0xD800)<<10|rune(c2-0xDC00)+0x10000)
i++
continue
}
}
out = append(out, rune(c))
}
return out
}
// utf16Encode converts runes to UTF-16 code units, producing surrogates for
// supplementary plane characters.
func utf16Encode(runes []rune) []uint16 {
var out []uint16
for _, r := range runes {
if r >= 0x10000 && r <= 0x10FFFF {
r -= 0x10000
out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF)))
} else {
out = append(out, uint16(r))
}
}
return out
}