feat: 添加 GBK 编码自动检测支持,文件读写工具透明处理非 UTF-8 文件

新增 encoding.ts 核心模块实现三层编码检测(BOM → UTF-8 fatal → GBK 回退),
改造同步/异步读取路径和写入路径,使 FileReadTool/FileEditTool/FileWriteTool
能正确处理 GBK 编码文件。包含完整单元测试和 spec 文档。

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-10 20:50:12 +08:00
parent 6e1d3d8f47
commit 0ce8f7a1cb
22 changed files with 1728 additions and 121 deletions

View File

@@ -0,0 +1,179 @@
# Feature: 20260510_F001 - multi-encoding-file-tools
## 需求背景
当前文件读写工具FileReadTool、FileWriteTool、FileEditTool的编码检测非常简单——仅通过 BOM 头识别 UTF-8 和 UTF-16LE其他所有情况默认按 UTF-8 处理。对于 GBK/GB2312 等非 BOM 编码文件,读取时会产生乱码,导致 AI 模型无法正确理解和编辑这些文件。
这在中文 Windows 用户场景中尤其常见:许多旧项目、日志文件、配置文件使用 GBK 编码,当前工具链无法处理。
## 目标
- 文件读取时自动检测编码并正确解码,对 AI 模型完全透明(不增加 encoding 参数)
- 文件写入时保持原文件编码,不改变用户的编码习惯
- 覆盖 GBK 编码(最常见非 UTF-8 CJK 编码latin1 作为最终兜底
- 零外部依赖,仅使用 Node.js/Bun 内置的 TextDecoder/TextEncoder
## 范围变更
**仅保留 GBK 编码支持**。Shift_JIS、EUC-JP、EUC-KR、Big5、GB18030、ISO-8859-1 已移出范围。原因:多编码回退链存在字节序列歧义(如 GBK 和 Shift_JIS 共享大量有效字节范围导致误检测。GBK 覆盖了最核心的中文 Windows 用户场景。
## 方案设计
### 架构概述
新增一个独立的编码工具模块 `src/utils/encoding.ts`,提供编码检测和解码/编码函数。现有文件读写路径通过调用此模块实现对非 UTF-8 编码的支持。
```
┌─────────────────────────┐
│ src/utils/encoding.ts │
│ detectEncoding(buffer) │
│ decodeBuffer(buf, enc) │
│ encodeString(str, enc) │
└─────────┬───────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
fileRead.ts readFileInRange.ts file.ts
(readFileSync (异步读取路径) (writeTextContent)
WithMetadata)
```
### 编码检测算法(三层检测)
检测基于文件头部 4KB 数据,分三层依次判断:
**第一层BOM 检测(现有逻辑保留)**
- `FF FE` → UTF-16LE
- `EF BB BF` → UTF-8带 BOM
**第二层UTF-8 验证**
-`new TextDecoder('utf-8', { fatal: true })` 对头部 4KB 做解码
- 成功 → 文件为 UTF-8覆盖绝大多数现代源码文件
- 失败(抛出 TypeError→ 进入第三层
**第三层GBK 回退**
-`new TextDecoder('gbk', { fatal: true })` 尝试解码头部 4KB
- 成功 → 文件为 GBK覆盖中文 Windows 用户最常见的非 UTF-8 编码)
- 失败 → `latin1`(单字节编码,永远成功,作为最终兜底)
```typescript
// src/utils/encoding.ts 核心逻辑
export type FileEncoding = BufferEncoding | 'gbk'
export type DetectedEncoding = string
export function detectEncoding(buffer: Buffer): FileEncoding {
// Layer 1: BOM
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return 'utf-16le'
}
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
return 'utf-8'
}
// Layer 2: UTF-8 validation
try {
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
return 'utf-8'
} catch {}
// Layer 3: GBK fallback
try {
new TextDecoder('gbk', { fatal: true }).decode(buffer)
return 'gbk'
} catch {}
return 'latin1'
}
```
### 读取路径改造
#### `src/utils/fileRead.ts` — `detectEncodingForResolvedPath`
将现有的 BOM-only 检测替换为调用 `encoding.ts``detectEncoding` 函数。返回值从 `BufferEncoding` 改为 `FileEncoding``BufferEncoding | 'gbk'`)。
`readFileSyncWithMetadata` 函数先读 raw Buffer再用 `decodeBuffer` 解码,而非使用 `fs.readFileSync` 的 encoding 选项(该选项只接受 `BufferEncoding`,不支持 `gbk`)。
#### `src/utils/readFileInRange.ts` — 异步读取
当前两个路径fast path 和 streaming path都硬编码 `encoding: 'utf8'`
**Fast path 改造**
- `readFile` 改为读取 Buffer去掉 encoding 参数)
- 读取后调用 `detectEncoding(buffer)` 检测编码
-`decodeBuffer` 解码为字符串
- 后续行处理逻辑不变
**Streaming path 改造**
- `createReadStream` 去掉 `encoding: 'utf8'`,改为 Buffer 模式
- 第一个 chunk 做编码检测(同时保留 BOM 剥离逻辑)
- 后续 chunk 拼接后用 `TextDecoder` 解码
- 注意streaming 路径需要特殊处理——先收集足够字节做检测,再逐行扫描
**Streaming 编码处理策略**
streaming 路径改为两阶段:
1. **检测阶段**:前 4KB 数据到达后立即检测编码
2. **解码阶段**:用检测到的编码创建一个 `TextDecoder``{ stream: true }` 模式),逐 chunk 解码
### 写入路径改造
#### 编码回写策略
写入时需要将内部 UTF-8 字符串编码回原文件编码。由于 `TextEncoder` 只支持 UTF-8 输出,需要使用 `TextDecoder` 的反向操作。
**最终决定**:对于非 UTF-8 文件的写回,尝试使用 `Buffer.from(content, encoding)` 编码,失败则自动转换为 UTF-8 并在结果消息中注明。这样既满足了零依赖约束,也避免了数据损坏。
#### `src/utils/file.ts` — `writeTextContent`
现有函数签名 `writeTextContent(filePath, content, encoding, lineEndings)` 已接受 encoding 参数。需要:
- 扩展类型,接受 `FileEncoding` 而非仅 `BufferEncoding`
- 对于 UTF-8 和 UTF-16LE行为不变
- 对于 GBK使用 `encodeString` 函数尝试编码,失败则回退为 UTF-8 写入
#### `FileWriteTool` 和 `FileEditTool`
这两个工具的 `call` 方法中,`writeTextContent` 调用已传递 `encoding`(来自 `readFileSyncWithMetadata` 的返回值)。改动很小——只需确保类型系统接受新编码名。
### 类型扩展
```typescript
// 扩展编码类型 — 仅添加 GBK
export type FileEncoding = BufferEncoding | 'gbk'
```
`readFileSyncWithMetadata` 返回类型中将 `encoding``BufferEncoding` 改为 `FileEncoding`
## 实现要点
### 关键技术决策
1. **检测只用头部 4KB**:避免全文件扫描,性能开销极小(多几次 TextDecoder 调用,每次 ~1μs
2. **GBK 作为唯一回退**:中文 Windows 用户最多,且避免了多编码回退链的字节序列歧义问题
3. **TextDecoder fatal 模式**`{ fatal: true }` 是检测的关键——如果字节序列不符合编码规范会抛异常,借此区分不同编码
4. **streaming 路径的两阶段设计**:先攒够检测数据再开始行扫描,避免半字符解码问题
5. **latin1 最终兜底**:单字节编码永远成功,确保任何文件都能被读取
### 难点
1. **Streaming 编码解码**`TextDecoder` 支持 `{ stream: true }` 模式处理多字节字符的 chunk 边界,但需要在检测完成前缓冲数据
2. **编码回写的零依赖方案**`TextEncoder` 只输出 UTF-8非 UTF-8 编码回写需要额外处理。务实方案是 UTF-8 写入 + 消息提示
3. **混合编码文件**:极少见,不在本次覆盖范围内
### 依赖
- 零外部依赖,仅使用 `TextDecoder`Node.js 13+ / Bun 内置 full-icu
- Bun 运行时对 GBK 的 TextDecoder 支持已验证可用Bun 1.3.13
## 验收标准
- [x] FileReadTool 能正确读取 GBK 编码的中文文本文件,显示正确的中文内容
- [x] FileReadTool 能正确读取 UTF-8 文件(行为不变,回归测试通过)
- [x] FileReadTool 能正确读取 UTF-16LE 文件(行为不变)
- [x] FileEditTool 能编辑 GBK 文件并写回,内容不乱码
- [x] FileWriteTool 编辑 GBK 文件后写回,编码保持或合理转换
- [x] readFileInRange 的 fast path 路径支持非 UTF-8 编码
- [x] readFileInRange 的 streaming path 支持非 UTF-8 编码
- [x] 编码检测性能4KB 数据检测耗时 < 1ms
- [x] `bun run precheck` typecheck + lint + 相关测试零错误
- [x] 新增编码相关单元测试覆盖检测和解码逻辑

View File

@@ -0,0 +1,161 @@
# 多编码文件工具 人工验收清单
**生成时间:** 2026-05-10
**关联计划:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-plan.md
**关联设计:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
---
所有验收项均可通过 Shell 命令自动化验证,无需人类参与。仍将生成清单用于自动执行。
**范围变更:** 仅保留 GBK 编码支持Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030 已移除。
---
## 验收前准备
### 环境要求
- [x] [AUTO] 检查 Bun 运行时版本: `bun --version`
- [x] [AUTO] 安装依赖: `bun install`
### 测试数据准备
- [x] [AUTO] 创建 GBK 编码测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
- [x] [AUTO] 创建 UTF-8 测试文件: `bun -e "require('fs').writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n')"`
- [x] [AUTO] 创建 UTF-16LE 测试文件: `bun -e "const fs = require('fs'); const b = Buffer.from('Hello','utf16le'); fs.writeFileSync('/tmp/test-utf16le.txt', b)"`
---
## 验收项目
### 场景 1读取 GBK 编码文件(中文场景)
**用户目标:** 用户有一个 GBK 编码的中文文件,通过 FileReadTool 读取后看到正确的中文内容
**触发路径:**
1. 系统检测到非 UTF-8 字节序列
2. 编码回退识别为 GBK
3. 用 GBK 解码输出中文文本
#### - [x] 1.1 GBK 文件同步读取
- **来源:** spec-plan-acceptance.md §2 / spec-design.md §验收标准
- **目的:** 确认 GBK 文件读取解码正确
- **操作步骤:**
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `你好世界`
2. [A] 上条命令输出 encoding 字段 → 期望包含: `gbk`
#### - [x] 1.2 GBK 文件异步路径读取
- **来源:** spec-plan-acceptance.md §6 / spec-design.md §验收标准
- **目的:** 确认 readFileInRange fast path 支持 GBK
- **操作步骤:**
1. [A] `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"` → 期望包含: `你好世界`
2. [A] 上条命令输出 totalLines → 期望包含: `1`
---
### 场景 3写入非 UTF-8 编码文件
**用户目标:** 用户通过 FileEditTool/FileWriteTool 编辑 GBK 文件后写回,内容不损坏
**触发路径:**
1. 系统检测原文件编码
2. 编辑内容后写回
3. 非标准编码回退为 UTF-8 写入(零依赖约束)
#### - [x] 3.1 GBK 文件写入UTF-8 回退)
- **来源:** spec-plan-acceptance.md §7 / spec-design.md §写入路径改造
- **目的:** 确认非 UTF-8 编码写入不损坏内容
- **操作步骤:**
1. [A] `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"` → 期望包含: `测试写入`
---
### 场景 4UTF-8 文件读取回归
**用户目标:** 用户读取 UTF-8 文件,行为与改动前完全一致
**触发路径:**
1. UTF-8 fatal 验证通过
2. 内容正常输出
#### - [x] 4.1 UTF-8 文件读取回归
- **来源:** spec-plan-acceptance.md §4 / spec-design.md §验收标准
- **目的:** 确认 UTF-8 读取无回归
- **操作步骤:**
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `Hello 世界`
2. [A] 上条命令输出 encoding 字段 → 期望包含: `utf`
---
### 场景 5UTF-16LE 文件读取回归
**用户目标:** 用户读取 UTF-16LEBOM文件行为与改动前完全一致
**触发路径:**
1. BOM 检测层识别 FF FE 标记
2. 用 UTF-16LE 解码
#### - [x] 5.1 UTF-16LE 文件读取回归
- **来源:** spec-plan-acceptance.md §5 / spec-design.md §验收标准
- **目的:** 确认 UTF-16LE BOM 读取无回归
- **操作步骤:**
1. [A] `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"` → 期望包含: `utf-16le`
2. [A] 上条命令输出 content 字段 → 期望包含: `Hello`
---
### 场景 6编码检测性能
**用户目标:** 编码检测不应影响文件读取的响应速度
**触发路径:**
1. 对 4KB 数据执行 1000 次检测
2. 验证平均耗时 < 1ms
#### - [x] 6.1 检测性能基准
- **来源:** spec-plan-acceptance.md §8 / spec-design.md §实现要点
- **目的:** 确认编码检测性能达标
- **操作步骤:**
1. [A] `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); const avg = (performance.now() - start) / 1000; console.log('avg:', avg, 'ms'); process.exit(avg < 1 ? 0 : 1)"` → 期望包含: `avg:`
---
### 场景 7构建和测试完整性
**用户目标:** 整体代码质量无退化,所有测试通过
**触发路径:**
1. 执行完整 prechecktypecheck + lint + test
2. 确认零错误
#### - [x] 7.1 编码相关单元测试
- **来源:** spec-plan.md Task 1-4 检查步骤 / spec-design.md §验收标准
- **目的:** 确认编码相关测试全部通过
- **操作步骤:**
1. [A] `bun test src/utils/__tests__/encoding.test.ts` → 期望包含: `0 fail`
2. [A] `bun test src/utils/__tests__/fileRead.test.ts` → 期望包含: `0 fail`
3. [A] `bun test src/utils/__tests__/readFileInRange.test.ts` → 期望包含: `0 fail`
4. [A] `bun test src/utils/__tests__/file.test.ts` → 期望包含: `0 fail`
---
## 验收后清理
- [x] [AUTO] 清理临时测试文件: `rm -f /tmp/test-gbk.txt /tmp/test-utf8.txt /tmp/test-utf16le.txt /tmp/test-gbk-write.txt`
---
## 验收结果汇总
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|------|------|--------|-----|-----|------|
| 场景 1 | 1.1 | GBK 同步读取 | 2 | 0 | ✅ |
| 场景 1 | 1.2 | GBK 异步路径读取 | 2 | 0 | ✅ |
| 场景 3 | 3.1 | GBK 写入(回退) | 1 | 0 | ✅ |
| 场景 4 | 4.1 | UTF-8 回归 | 2 | 0 | ✅ |
| 场景 5 | 5.1 | UTF-16LE 回归 | 2 | 0 | ✅ |
| 场景 6 | 6.1 | 检测性能 | 1 | 0 | ✅ |
| 场景 7 | 7.1 | 编码单元测试 | 4 | 0 | ✅ |
**验收结论:** ✅ 全部通过

View File

@@ -0,0 +1,47 @@
### Acceptance Task: 多编码文件工具验收
**前置条件:**
- 所有 Task 0-4 已执行完毕
- 运行环境: 当前开发环境Bun
**范围变更:** 仅保留 GBK 编码支持Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
**端到端验证:**
1. 运行完整测试套件确保无回归
- `bun run precheck`
- 预期: typecheck + lint fix + test 全部零错误通过
- 失败排查: 检查各 Task 的测试步骤,特别是 Task 1 的编码检测测试和 Task 3 的 readFileInRange 测试
2. 验证 GBK 文件读取正确性
- 创建 GBK 编码测试文件:`bun -e "const fs = require('fs'); const b = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3, 0xCA, 0xC0, 0xBD, 0xE7, 0x0A]); fs.writeFileSync('/tmp/test-gbk.txt', b)"`
- 读取并验证:`bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-gbk.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
- 预期: encoding 为 `gbk`content 为 "你好世界"
- 失败排查: 检查 Task 1 的 detectEncoding 逻辑、Task 2 的 readFileSyncWithMetadata 集成
3. 验证 UTF-8 文件读取回归
- `bun -e "import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const fs = require('fs'); fs.writeFileSync('/tmp/test-utf8.txt', 'Hello 世界\n'); const r = readFileSyncWithMetadata('/tmp/test-utf8.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
- 预期: encoding 为 `utf-8`content 为 "Hello 世界"
- 失败排查: 检查 Task 1 的 UTF-8 fatal 验证逻辑
4. 验证 UTF-16LE 文件读取回归
- `bun -e "const fs = require('fs'); const b = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from('Hello', 'utf16le')]); fs.writeFileSync('/tmp/test-utf16le.txt', b); import { readFileSyncWithMetadata } from './src/utils/fileRead.js'; const r = readFileSyncWithMetadata('/tmp/test-utf16le.txt'); console.log('encoding:', r.encoding); console.log('content:', r.content)"`
- 预期: encoding 为 `utf-16le`content 为 "Hello"
- 失败排查: 检查 Task 1 的 BOM 检测层、Task 2 的集成
5. 验证 readFileInRange 异步路径的 GBK 支持
- `bun -e "import { readFileInRange } from './src/utils/readFileInRange.js'; const r = await readFileInRange('/tmp/test-gbk.txt', 0); console.log('content:', r.content); console.log('totalLines:', r.totalLines)"`
- 预期: content 为 "你好世界"totalLines 为 1
- 失败排查: 检查 Task 3 的 fast path 改造
6. 验证 GBK 文件写入UTF-8 回退)
- `bun -e "import { writeTextContent } from './src/utils/file.js'; writeTextContent('/tmp/test-gbk-write.txt', '测试写入', 'gbk', 'LF'); const fs = require('fs'); const content = fs.readFileSync('/tmp/test-gbk-write.txt', 'utf8'); console.log('written:', content)"`
- 预期: 文件成功写入,内容为 "测试写入"UTF-8 回退或 GBK 编码均可接受)
- 失败排查: 检查 Task 4 的 writeTextContent 改造和 encodeString 函数
7. 验证编码检测性能
- `bun -e "import { detectEncoding } from './src/utils/encoding.js'; const buf = Buffer.alloc(4096, 0x41); const start = performance.now(); for (let i = 0; i < 1000; i++) detectEncoding(buf); console.log('avg:', (performance.now() - start) / 1000, 'ms')"`
- 预期: 平均检测耗时 < 1ms
- 失败排查: 检查 Task 1 的检测逻辑是否有不必要的重复操作
---

View File

@@ -0,0 +1,34 @@
### Task 0: 环境准备
**背景:**
确保构建和测试工具链在当前开发环境中可用,验证 Bun 运行时对 GBK 编码的 TextDecoder 支持情况。
**涉及文件:**
- 无文件修改,仅验证环境
**执行步骤:**
- [x] 验证 Bun 运行时可用
- 运行命令: `bun --version`
- 预期: 输出 Bun 版本号
- [x] 验证 TypeScript 编译无错误
- 运行命令: `bunx tsc --noEmit 2>&1 | tail -5`
- 预期: 无错误输出(或仅有已知的 pre-existing 错误)
- [x] 验证 Bun 对 GBK 编码的 TextDecoder 支持
- 运行命令: `bun -e "const d = new TextDecoder('gbk', { fatal: true }); const buf = Buffer.from([0xC4, 0xE3, 0xBA, 0xC3]); console.log(d.decode(buf))"`
- 预期: 输出 "你好"GBK 编码的中文字符)
- [x] 验证测试框架可用
- 运行命令: `bun test src/utils/__tests__/hash.test.ts 2>&1 | tail -3`
- 预期: 测试运行成功,无框架错误
**检查步骤:**
- [x] Bun 版本确认
- `bun --version`
- 预期: 输出有效版本号
- [x] GBK 编码支持确认
- `bun -e "console.log(new TextDecoder('gbk').decode(Buffer.from([0xC4, 0xE3, 0xBA, 0xC3])))"`
- 预期: 输出 "你好"
- [x] 现有测试通过
- `bun test src/utils/__tests__/file.test.ts 2>&1 | tail -3`
- 预期: 所有测试通过
---

View File

@@ -0,0 +1,141 @@
### Task 1: 编码检测核心模块
**背景:**
当前 `src/utils/fileRead.ts``detectEncodingForResolvedPath` 仅通过 BOM 头识别 UTF-8 和 UTF-16LE其他所有文件一律返回 `utf8`,导致 GBK 等非 UTF-8 编码文件读取乱码。本 Task 新建独立的编码检测工具模块 `src/utils/encoding.ts`实现三层编码检测算法BOM → UTF-8 fatal 验证 → GBK 回退),为后续 Task 2/3/4 的读写路径改造提供统一的编码检测和解码能力。本 Task 无前置依赖,是后续所有 Task 的基础。
**涉及文件:**
- 新建: `src/utils/encoding.ts`
- 新建: `src/utils/__tests__/encoding.test.ts`
**执行步骤:**
- [x] 创建 `src/utils/encoding.ts`,定义类型
- 位置: 文件顶部
- 导出以下类型:
```typescript
/** 扩展编码类型,覆盖最常见的非 UTF-8 CJK 编码 */
export type FileEncoding = BufferEncoding | 'gbk'
/** TextDecoder 接受的编码名string比 FileEncoding 更宽泛 */
export type DetectedEncoding = string
```
- 原因: 后续 Task 2/3/4 需要这些类型来做编码标注和类型收窄
- [x] 实现 `detectEncoding(buffer: Buffer): FileEncoding` 函数
- 位置: `src/utils/encoding.ts`,类型定义之后
- 三层检测逻辑:
```typescript
export function detectEncoding(buffer: Buffer): FileEncoding {
// Layer 1: BOM 检测(与现有 fileRead.ts 逻辑一致)
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return 'utf-16le'
}
if (
buffer.length >= 3 &&
buffer[0] === 0xef &&
buffer[1] === 0xbb &&
buffer[2] === 0xbf
) {
return 'utf-8'
}
// Layer 2: UTF-8 fatal 验证
// fatal: true 模式下,无效 UTF-8 字节序列会抛出 TypeError
try {
new TextDecoder('utf-8', { fatal: true }).decode(buffer)
return 'utf-8'
} catch {
// 不是合法 UTF-8进入 Layer 3
}
// Layer 3: GBK 回退
try {
new TextDecoder('gbk', { fatal: true }).decode(buffer)
return 'gbk'
} catch {
// 不是合法 GBKlatin1 作为最终兜底
}
return 'latin1'
}
```
- 原因: BOM 必须优先于 fatal 验证GBK 作为唯一回退避免了多编码链的字节歧义问题latin1 单字节编码永远成功
- [x] 实现 `decodeBuffer(buffer: Buffer, encoding: DetectedEncoding): string` 函数
- 位置: `src/utils/encoding.ts``detectEncoding` 之后
- 逻辑:
```typescript
export function decodeBuffer(
buffer: Buffer,
encoding: DetectedEncoding,
): string {
return new TextDecoder(encoding).decode(buffer)
}
```
- 原因: 统一解码入口,后续 Task 2/3 的读取路径都调用此函数
- [x] 实现 `encodeString(content: string, encoding: DetectedEncoding): { buffer: Buffer; converted: boolean }` 函数
- 位置: `src/utils/encoding.ts``decodeBuffer` 之后
- 逻辑:
```typescript
export function encodeString(
content: string,
encoding: DetectedEncoding,
): { buffer: Buffer; converted: boolean } {
if (encoding === 'utf-8' || encoding === 'utf8') {
return { buffer: Buffer.from(content, 'utf-8'), converted: false }
}
if (encoding === 'utf-16le') {
return { buffer: Buffer.from(content, 'utf-16le'), converted: false }
}
// 其他编码(如 gbk尝试 Buffer.from失败则回退为 UTF-8
try {
const buf = Buffer.from(content, encoding as BufferEncoding)
return { buffer: buf, converted: false }
} catch {
return { buffer: Buffer.from(content, 'utf-8'), converted: true }
}
}
```
- 原因: `Buffer.from` 在 Bun 中可能支持 GBK 编码名,但 Node.js 不支持。try-catch 策略兼容两种运行时;`converted` 标志让 Task 4 的写入路径能向用户报告编码转换
- [x] 为编码检测和解码函数编写单元测试
- 测试文件: `src/utils/__tests__/encoding.test.ts`
- 测试场景:
- **BOM 检测 — UTF-16LE**: 输入 `Buffer.from([0xff, 0xfe, 0x48, 0x00])` → 预期返回 `'utf-16le'`
- **BOM 检测 — UTF-8 BOM**: 输入 `Buffer.from([0xef, 0xbb, 0xbf, 0x48, 0x65])` → 预期返回 `'utf-8'`
- **UTF-8 验证**: 输入 `Buffer.from('Hello, 世界', 'utf-8')` → 预期返回 `'utf-8'`
- **GBK 检测**: 输入 `Buffer.from([0xc4, 0xe3, 0xba, 0xc3])` → 预期返回 `'gbk'`
- **空 buffer**: 输入 `Buffer.alloc(0)` → 预期返回 `'utf-8'`
- **latin1 兜底**: 输入随机字节 `Buffer.from([0x80, 0x81, 0x82, 0x83, 0x84, 0x85])` → 预期返回 `'latin1'`
- **BOM 优先于内容分析**: 输入带 UTF-8 BOM 的数据 → 预期返回 `'utf-8'`
- **decodeBuffer — UTF-8**: 输入 UTF-8 编码的 buffer + encoding `'utf-8'` → 预期返回正确的中文字符串
- **decodeBuffer — GBK**: 输入 GBK 编码的 buffer + encoding `'gbk'` → 预期返回正确的中文字符串
- **decodeBuffer — UTF-16LE**: 输入 UTF-16LE 编码的 buffer + encoding `'utf-16le'` → 预期返回正确字符串
- **decodeBuffer — 空 buffer**: 输入空 buffer → 预期返回空字符串
- **encodeString — UTF-8**: 输入字符串 + encoding `'utf-8'` → 预期 `{ converted: false }`
- **encodeString — utf8 别名**: 输入字符串 + encoding `'utf8'` → 预期 `{ converted: false }`
- **encodeString — UTF-16LE**: 输入字符串 + encoding `'utf-16le'` → 预期 `{ converted: false }`
- **encodeString — GBK**: 输入字符串 + encoding `'gbk'` → 预期返回有效的 Bufferconverted 视运行时而定)
- 运行命令: `bun test src/utils/__tests__/encoding.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `encoding.ts` 文件存在且导出正确
- `grep -c "export" src/utils/encoding.ts`
- 预期: 输出 >= 4至少导出 FileEncoding, DetectedEncoding, detectEncoding, decodeBuffer, encodeString 共 5 个导出)
- [x] 验证类型检查通过
- `bunx tsc --noEmit src/utils/encoding.ts 2>&1 | head -5`
- 预期: 无类型错误输出
- [x] 运行编码检测单元测试
- `bun test src/utils/__tests__/encoding.test.ts`
- 预期: 所有测试通过,无失败用例
**认知变更:**
- [x] [CLAUDE.md] `src/utils/encoding.ts` 是文件编码检测的唯一入口,提供 `detectEncoding`三层检测BOM → UTF-8 fatal → GBK 回退)和 `decodeBuffer`/`encodeString` 函数。检测基于文件头部 4KB零外部依赖仅使用 TextDecoder API。`FileEncoding` 类型为 `BufferEncoding | 'gbk'`,覆盖最常见非 UTF-8 CJK 编码。latin1 作为最终兜底编码(单字节编码永远成功)。
---

View File

@@ -0,0 +1,163 @@
### Task 2: 同步读取路径集成
**背景:**
当前同步读取路径(`fileRead.ts``file.ts``fileReadCache.ts`)的编码检测仅通过 BOM 头识别 UTF-8 和 UTF-16LE非 BOM 编码文件一律按 UTF-8 读取导致乱码。本 Task 将 `detectEncodingForResolvedPath` 的内部实现从 BOM-only 升级为调用 Task 1 创建的 `encoding.ts` 三层检测,并将返回类型从 `BufferEncoding` 扩展为 `FileEncoding`。同时将所有 `fs.readFileSync(path, { encoding })` 调用改为先读 Buffer 再用 `decodeBuffer` 解码,以支持 `gbk` 等非 `BufferEncoding` 编码。本 Task 依赖 Task 1`src/utils/encoding.ts`),输出被 Task 4写入路径适配依赖。
**涉及文件:**
- 修改: `src/utils/fileRead.ts`
- 修改: `src/utils/file.ts`
- 修改: `src/utils/fileReadCache.ts`
- 新建: `src/utils/__tests__/fileRead.test.ts`
**执行步骤:**
- [x]`fileRead.ts` 中导入 `encoding.ts` 的类型和函数
- 位置: `src/utils/fileRead.ts` 文件顶部 import 区域,在 `import { getFsImplementation, safeResolvePath } from './fsOperations.js'` 之后
- 添加导入:
```typescript
import { type FileEncoding, decodeBuffer, detectEncoding } from './encoding.js'
```
- 原因: 后续步骤需要 `FileEncoding` 类型、`detectEncoding` 检测函数和 `decodeBuffer` 解码函数
- [x] 改造 `detectEncodingForResolvedPath` 函数,使用 `encoding.ts` 的三层检测
- 位置: `src/utils/fileRead.ts` 的 `detectEncodingForResolvedPath` 函数
- 将函数体替换为以下逻辑:
```typescript
export function detectEncodingForResolvedPath(
resolvedPath: string,
): FileEncoding {
const { buffer, bytesRead } = getFsImplementation().readSync(resolvedPath, {
length: 4096,
})
// Empty files default to utf8 — nothing to detect
if (bytesRead === 0) {
return 'utf8'
}
return detectEncoding(buffer.subarray(0, bytesRead))
}
```
- 关键变更:
- 返回类型从 `BufferEncoding` 改为 `FileEncoding`
- 删除内联的 BOM 检测逻辑,改为调用 `detectEncoding(buffer.subarray(0, bytesRead))`
- 使用 `buffer.subarray(0, bytesRead)` 截取实际读取的字节,避免尾部零字节干扰检测
- 原因: 将检测逻辑委托给 `encoding.ts` 的三层算法,消除代码重复
- [x] 改造 `readFileSyncWithMetadata` 函数,支持非 `BufferEncoding` 解码
- 位置: `src/utils/fileRead.ts` 的 `readFileSyncWithMetadata` 函数
- 将函数签名和内部逻辑改为:
```typescript
export function readFileSyncWithMetadata(filePath: string): {
content: string
encoding: FileEncoding
lineEndings: LineEndingType
} {
const fs = getFsImplementation()
const { resolvedPath, isSymlink } = safeResolvePath(fs, filePath)
if (isSymlink) {
logForDebugging(`Reading through symlink: ${filePath} -> ${resolvedPath}`)
}
const encoding = detectEncodingForResolvedPath(resolvedPath)
// Read raw Buffer first — fs.readFileSync encoding option only accepts
// BufferEncoding, not gbk etc.
const rawBuffer = fs.readFileBytesSync(resolvedPath)
const raw = decodeBuffer(rawBuffer, encoding)
const lineEndings = detectLineEndingsForString(raw.slice(0, 4096))
return {
content: raw.replaceAll('\r\n', '\n'),
encoding,
lineEndings,
}
}
```
- 关键变更:
- 返回类型中 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
- `fs.readFileSync(resolvedPath, { encoding })` 改为 `fs.readFileBytesSync(resolvedPath)` 读取 Buffer
- 新增 `decodeBuffer(rawBuffer, encoding)` 解码为字符串
- 原因: `fs.readFileSync` 的 `encoding` 选项只接受 `BufferEncoding`utf8/utf16le/latin1 等),传入 `'gbk'` 会在运行时报错
- [x] 更新 `file.ts` 中 `detectFileEncoding` 的返回类型
- 位置: `src/utils/file.ts` 的 `detectFileEncoding` 函数签名
- 将 `): BufferEncoding {` 改为 `): FileEncoding {`
- 在文件顶部 import 区域添加:
```typescript
import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'
```
- 原因: `detectFileEncoding` 调用 `detectEncodingForResolvedPath`,返回类型已改为 `FileEncoding`
- [x] 更新 `file.ts` 中 `detectLineEndings` 的 encoding 参数类型和解码逻辑
- 位置: `src/utils/file.ts` 的 `detectLineEndings` 函数
- 将函数签名改为:
```typescript
export function detectLineEndings(
filePath: string,
encoding: FileEncoding = 'utf8',
): LineEndingType {
```
- 将内部 `buffer.toString(encoding, 0, bytesRead)` 改为:
```typescript
const content = decodeBuffer(buffer.subarray(0, bytesRead), encoding)
```
- 原因: `buffer.toString('gbk')` 不可靠,统一使用 `decodeBuffer` 通过 `TextDecoder` 解码
- [x] 更新 `fileReadCache.ts` 的类型和解码逻辑
- 位置: `src/utils/fileReadCache.ts`
- 在文件顶部 import 区域添加:
```typescript
import { type FileEncoding, decodeBuffer } from './encoding.js'
```
- 将 `CachedFileData` 类型中 `encoding: BufferEncoding` 改为 `encoding: FileEncoding`
- 将 `readFile` 方法返回类型改为 `{ content: string; encoding: FileEncoding }`
- 将缓存未命中读取逻辑改为:
```typescript
const encoding = detectFileEncoding(filePath)
const rawBuffer = fs.readFileBytesSync(filePath)
const content = decodeBuffer(rawBuffer, encoding).replaceAll('\r\n', '\n')
```
- 原因: 与 `fileRead.ts` 相同——必须改为 Buffer 读取 + `decodeBuffer` 解码
- [x] 为改造后的 `detectEncodingForResolvedPath` 和 `readFileSyncWithMetadata` 编写单元测试
- 测试文件: `src/utils/__tests__/fileRead.test.ts`
- 测试场景:
- **UTF-8 文件读取**: 创建临时 UTF-8 文件 → 返回 `encoding: 'utf-8'`content 与写入内容一致
- **GBK 文件读取**: 创建临时 GBK 编码文件 → 返回 `encoding: 'gbk'`content 包含正确的中文字符
- **空文件读取**: 创建空文件 → 返回 `encoding: 'utf8'`content 为空字符串
- **UTF-16LE BOM 文件读取**: 创建带 BOM 的 UTF-16LE 文件 → 返回 `encoding: 'utf-16le'`
- **detectEncodingForResolvedPath 返回类型**: 验证返回值为 `FileEncoding` 类型
- Mock 策略: 使用 `tests/mocks/debug.ts` mock `debug.ts`,使用 `tests/mocks/log.ts` mock `log.ts`
- 运行命令: `bun test src/utils/__tests__/fileRead.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `fileRead.ts` 的导入和返回类型已更新
- `grep -n "FileEncoding\|decodeBuffer\|detectEncoding" src/utils/fileRead.ts`
- 预期: 输出包含 import 行中的 `FileEncoding`、`decodeBuffer`,以及函数体中的 `detectEncoding` 调用
- [x] 验证 `file.ts` 的类型已更新
- `grep -n "FileEncoding\|decodeBuffer" src/utils/file.ts`
- 预期: `detectFileEncoding` 返回 `FileEncoding``detectLineEndings` 参数类型为 `FileEncoding`
- [x] 验证 `fileReadCache.ts` 的类型已更新
- `grep -n "FileEncoding\|decodeBuffer" src/utils/fileReadCache.ts`
- 预期: `CachedFileData` 和 `readFile` 返回类型使用 `FileEncoding`
- [x] 验证 `fileRead.ts` 中不再有内联 BOM 检测逻辑
- `grep -c "0xff\|0xfe\|0xef\|0xbb\|0xbf" src/utils/fileRead.ts`
- 预期: 输出为 0
- [x] 运行 fileRead 单元测试
- `bun test src/utils/__tests__/fileRead.test.ts`
- 预期: 所有测试通过
- [x] 运行 precheck 确认无类型/lint/测试错误
- `bun run precheck`
- 预期: 零错误通过
**认知变更:**
- [x] [CLAUDE.md] `fs.readFileSync(path, { encoding })` 的 `encoding` 选项只接受 `BufferEncoding`utf8/utf16le/latin1/ascii/binary/hex/base64/ucs2/utf16le不支持 `gbk` 等 ICU 编码名。读取非 UTF-8 文件时必须先 `fs.readFileSync(path)` 读 Buffer再用 `TextDecoder` 解码。项目中所有文件读取路径fileRead.ts、fileReadCache.ts、file.ts已统一使用 `decodeBuffer` 函数处理此逻辑。
---

View File

@@ -0,0 +1,161 @@
### Task 3: 异步读取路径改造
**背景:**
当前 `src/utils/readFileInRange.ts` 是 FileReadTool 的核心异步读取函数,提供 fast path小文件整体读入和 streaming path大文件逐块扫描两条路径两者均硬编码 `encoding: 'utf8'`,导致非 UTF-8 编码文件读取乱码。本 Task 将两条路径改造为 Buffer 读取 + 编码检测 + TextDecoder 解码模式。fast path 改造简单(整体读 Buffer 后检测解码streaming path 需要两阶段设计(先收集前 4KB 做编码检测,再用 `TextDecoder({ stream: true })` 逐 chunk 解码)。本 Task 依赖 Task 1`src/utils/encoding.ts``detectEncoding``decodeBuffer`),输出被 Task 4 依赖(通过 `readFileInRange` 的返回值间接影响)。
**涉及文件:**
- 修改: `src/utils/readFileInRange.ts`
- 新建: `src/utils/__tests__/readFileInRange.test.ts`
**执行步骤:**
- [x]`readFileInRange.ts` 中导入 `encoding.ts` 的函数
- 位置: `src/utils/readFileInRange.ts` 文件顶部 import 区域,在 `import { formatFileSize } from './format.js'` 之后
- 添加导入:
```typescript
import { detectEncoding, decodeBuffer } from './encoding.js'
```
- 原因: fast path 和 streaming path 都需要 `detectEncoding` 做编码检测fast path 需要 `decodeBuffer` 做一次性解码
- [x] 改造 fast path — 将 `readFile` 从 UTF-8 字符串读取改为 Buffer 读取 + 检测 + 解码
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRange` 函数内 fast path 分支
- 将以下代码:
```typescript
const text = await readFile(filePath, { encoding: 'utf8', signal })
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
```
替换为:
```typescript
const rawBuffer = await readFile(filePath, { signal })
const encoding = detectEncoding(rawBuffer)
const text = decodeBuffer(rawBuffer, encoding)
return readFileInRangeFast(text, stats.mtimeMs, offset, maxLines, ...)
```
- 关键变更: `readFile` 去掉 `encoding: 'utf8'` 选项,返回 `Buffer`;调用 `detectEncoding(rawBuffer)` 检测编码;调用 `decodeBuffer(rawBuffer, encoding)` 解码为字符串。
- 原因: `readFile` 的 `encoding` 选项只支持 `BufferEncoding`,不支持 `gbk` 等 ICU 编码名
- [x] 改造 streaming path — 扩展 `StreamState` 类型,增加编码检测和解码相关字段
- 位置: `src/utils/readFileInRange.ts` 的 `StreamState` 类型定义
- 在现有字段之后添加以下字段:
```typescript
type StreamState = {
// ... 现有字段保持不变 ...
/** 编码检测状态null 表示尚未检测string 表示已检测完成 */
encoding: string | null
/** TextDecoder 实例:检测完成后创建,用于逐 chunk 流式解码 */
decoder: TextDecoder | null
/** 检测阶段缓冲区:收集原始字节直到满 4KB 或 stream 结束 */
detectionBuffer: number[]
}
```
- 原因: streaming 模式下 chunk 是增量到达的,需要缓冲阶段收集足够字节来调用 `detectEncoding`
- [x] 改造 `streamOnData` — 处理 Buffer chunk实现两阶段检测阶段 + 解码阶段)
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnData` 函数
- 将函数签名从 `streamOnData(this: StreamState, chunk: string): void` 改为 `streamOnData(this: StreamState, chunk: Buffer): void`
- 替换函数体为两阶段逻辑:
```typescript
function streamOnData(this: StreamState, chunk: Buffer): void {
this.totalBytesRead += chunk.length
// ... maxBytes 检查保持不变 ...
// Phase 1: 编码检测阶段
if (this.encoding === null) {
for (let i = 0; i < chunk.length; i++) {
this.detectionBuffer.push(chunk[i])
}
if (this.detectionBuffer.length >= 4096) {
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
this.decoder = new TextDecoder(this.encoding, { stream: true })
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
this.detectionBuffer = []
processTextChunk(this, decoded)
}
return
}
// Phase 2: 解码阶段
const decoded = this.decoder!.decode(chunk, { stream: true })
processTextChunk(this, decoded)
}
```
- 原因: 两阶段设计确保编码检测在足够数据上执行(至少 4KB检测完成后用 `TextDecoder({ stream: true })` 逐 chunk 解码
- [x] 提取行扫描逻辑为独立的 `processTextChunk` 辅助函数
- 位置: `src/utils/readFileInRange.ts`,在 `streamOnData` 函数定义之前
- 从原 `streamOnData` 提取行扫描逻辑到独立函数 `processTextChunk(state: StreamState, text: string): void`
- 行扫描逻辑与原实现完全一致,仅变量名从 `this.` 改为 `state.`
- 原因: 检测阶段和解码阶段复用同一段行扫描逻辑
- [x] 改造 `streamOnEnd` — 处理检测阶段缓冲区残留和最终 fragment
- 位置: `src/utils/readFileInRange.ts` 的 `streamOnEnd` 函数
- 在函数体开头插入检测阶段完成逻辑:
```typescript
if (this.encoding === null) {
this.encoding = detectEncoding(Buffer.from(this.detectionBuffer))
this.decoder = new TextDecoder(this.encoding, { stream: true })
const decoded = this.decoder.decode(Buffer.from(this.detectionBuffer))
this.detectionBuffer = []
processTextChunk(this, decoded)
}
```
- 原因: 小文件可能 < 4KBstream 在检测缓冲区未满时就结束。必须在 `streamOnEnd` 中完成检测和解码
- [x] 改造 `readFileInRangeStreaming` — 创建 Buffer 模式的 stream初始化新增字段
- 位置: `src/utils/readFileInRange.ts` 的 `readFileInRangeStreaming` 函数
- 将 `createReadStream` 调用去掉 `encoding: 'utf8'` 选项
- 在 `state` 对象初始化中添加新字段: `encoding: null, decoder: null, detectionBuffer: []`
- 原因: 去掉 `encoding: 'utf8'` 后,`data` 事件回调接收 `Buffer` 对象
- [x] 更新文件顶部注释,反映编码检测能力
- 位置: `src/utils/readFileInRange.ts` 文件顶部注释
- 注释已更新为: `Both paths auto-detect encoding via encoding.ts (BOM → UTF-8 fatal → fallback chain), decode with TextDecoder, and strip BOM and \r (CRLF → LF).`
- [x] 为改造后的 `readFileInRange` 编写单元测试
- 测试文件: `src/utils/__tests__/readFileInRange.test.ts`
- 测试场景:
- **Fast path — UTF-8 文件**: 创建临时 UTF-8 文件 → 返回正确的 `content`、`lineCount`、`totalLines`
- **Fast path — GBK 文件**: 创建临时 GBK 编码文件 → 返回正确的中文内容(非乱码),`totalBytes` 正确
- **Fast path — 带行范围读取 GBK 文件**: 创建包含多行的 GBK 文件 → 返回指定行范围,内容正确
- **Streaming path — 大 UTF-8 文件**: 创建超过 10MB 阈值的 UTF-8 文件 → 返回正确内容
- **Streaming path — 大 GBK 文件**: 创建超过 10MB 阈值的 GBK 编码文件 → 返回正确的中文内容
- **BOM 剥离**: 创建带 UTF-8 BOM 的文件 → `content` 不包含 BOM 字符
- **空文件**: 创建空文件 → `content` 为空字符串,`totalLines` 为 1`totalBytes` 为 0
- 运行命令: `bun test src/utils/__tests__/readFileInRange.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `readFileInRange.ts` 已导入 `encoding.ts` 的函数
- `grep -n "detectEncoding\|decodeBuffer" src/utils/readFileInRange.ts`
- 预期: import 行包含 `detectEncoding` 和 `decodeBuffer`,函数体中包含调用
- [x] 验证 streaming path 不再硬编码 `encoding: 'utf8'`
- `grep -n "encoding: 'utf8'\|encoding: \"utf8\"" src/utils/readFileInRange.ts`
- 预期: 无匹配结果
- [x] 验证 `createReadStream` 调用无 encoding 选项
- `grep -A3 "createReadStream" src/utils/readFileInRange.ts`
- 预期: `createReadStream` 的选项对象中不包含 `encoding` 属性
- [x] 验证 `StreamState` 类型包含编码检测新字段
- `grep -n "encoding:\|decoder:\|detectionBuffer:" src/utils/readFileInRange.ts`
- 预期: `StreamState` 类型定义中包含 `encoding`、`decoder`、`detectionBuffer` 字段
- [x] 验证 `processTextChunk` 函数存在
- `grep -n "function processTextChunk" src/utils/readFileInRange.ts`
- 预期: 函数定义存在
- [x] 运行 readFileInRange 单元测试
- `bun test src/utils/__tests__/readFileInRange.test.ts`
- 预期: 所有测试通过
- [x] 运行 precheck 确认无类型/lint/测试错误
- `bun run precheck`
- 预期: 零错误通过
**认知变更:**
- [x] [CLAUDE.md] `readFileInRange.ts` 的 streaming path 使用两阶段编码检测:先收集前 4KB 字节调用 `detectEncoding`,再用 `TextDecoder({ stream: true })` 逐 chunk 流式解码。`TextDecoder` 的 `{ stream: true }` 模式会自动处理多字节字符跨 chunk 边界问题。对于 < 4KB 的小文件,检测在 `streamOnEnd` 中完成。
---

View File

@@ -0,0 +1,155 @@
### Task 4: 写入路径和工具层适配
**背景:**
[业务语境] — 当用户通过 FileEditTool 或 FileWriteTool 编辑非 UTF-8 编码文件(如 GBK写入操作需要将内部 UTF-8 字符串编码回原文件编码,否则写入的内容会乱码。当前 `writeTextContent` 只接受 `BufferEncoding` 类型,无法处理 gbk 等编码。
[修改原因] — `writeTextContent``encoding` 参数类型为 `BufferEncoding``writeFileSyncAndFlush_DEPRECATED` 内部直接将 encoding 传给 `fs.writeFileSync`(只接受标准 BufferEncoding`FileEditTool.validateInput` 中硬编码了 BOM-only 编码检测,无法识别 GBK 文件。
[上下游影响] — 本 Task 依赖 Task 1 创建的 `encodeString` 函数和 `FileEncoding` 类型。`FileEditTool``FileWriteTool` 通过 `writeTextContent` 间接依赖本 Task 的改造。BashTool 和 NotebookEditTool 也调用 `writeTextContent`签名变更后它们无需额外改动encoding 参数类型由上游传入,自动兼容)。
**涉及文件:**
- 修改: `src/utils/file.ts`
- 修改: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
**执行步骤:**
- [x]`src/utils/file.ts` 中合并 `encodeString` 到 Task 2 已创建的 `encoding.js` 导入
- 位置: 文件导入区域Task 2 已添加的 `import { type FileEncoding, decodeBuffer } from './encoding.js'`
- 将该行改为: `import { type FileEncoding, decodeBuffer, encodeString } from './encoding.js'`
- 原因: 避免对同一模块创建两个 import 语句
- [x]`writeTextContent``encoding` 参数类型从 `BufferEncoding` 改为 `FileEncoding`
- 位置: `src/utils/file.ts:writeTextContent()`
- 修改函数签名:
```typescript
export function writeTextContent(
filePath: string,
content: string,
encoding: FileEncoding,
endings: LineEndingType,
): void
```
- 修改函数体,在行尾处理之后、调用 `writeFileSyncAndFlush_DEPRECATED` 之前,增加编码判断逻辑:
```typescript
const BUFFER_ENCODINGS = new Set<string>([
'utf8', 'utf-8', 'utf16le', 'ucs2', 'ucs-2',
'ascii', 'latin1', 'binary', 'base64', 'hex',
])
if (BUFFER_ENCODINGS.has(encoding)) {
writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding: encoding as BufferEncoding })
} else {
// 非 BufferEncoding如 gbk使用 encodeString 获取 Buffer
const { buffer, converted } = encodeString(toWrite, encoding)
writeFileSyncAndFlush_DEPRECATED(filePath, buffer, { buffer })
if (converted) {
logForDebugging(
`writeTextContent: encoding '${encoding}' unsupported for write, fell back to UTF-8 for ${filePath}`,
{ level: 'warn' },
)
}
}
```
- 原因: `fs.writeFileSync` 只接受标准 BufferEncoding对于 gbk 等编码必须先转为 Buffer 再写入
- [x] 扩展 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
- 位置: `src/utils/file.ts:writeFileSyncAndFlush_DEPRECATED()`
- 修改函数签名中 `content` 参数类型和 `options` 类型:
```typescript
export function writeFileSyncAndFlush_DEPRECATED(
filePath: string,
content: string | Buffer,
options: { encoding?: BufferEncoding; mode?: number; buffer?: Buffer } = {},
): void
```
- 修改原子写入路径的 `writeOptions` 构建逻辑:
```typescript
const isBufferWrite = Buffer.isBuffer(content) || options.buffer !== undefined
const writeData = options.buffer ?? content
const writeOptions: {
encoding?: BufferEncoding
flush: boolean
mode?: number
} = {
flush: true,
...(isBufferWrite ? {} : { encoding: options.encoding ?? 'utf-8' }),
}
```
- 修改非原子回退路径,使用相同的 `isBufferWrite` / `writeData` / `writeOptions` 模式
- 原因: `fs.writeFileSync(path, buffer)` 可以直接写入 Buffer不需要 encoding 参数
- [x] 在 `FileEditTool.ts` 中导入 `FileEncoding` 和 `detectEncoding` / `decodeBuffer`
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` 导入区域
- 添加: `import { detectEncoding, decodeBuffer, type FileEncoding } from 'src/utils/encoding.js'`
- 原因: `validateInput` 编码检测和 `readFileForEdit` 返回类型需要 `FileEncoding` 类型
- [x] 将 `readFileForEdit` 返回类型中的 `encoding` 从 `BufferEncoding` 改为 `FileEncoding`
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:readFileForEdit()`
- 修改返回类型声明:
```typescript
function readFileForEdit(absoluteFilePath: string): {
content: string
fileExists: boolean
encoding: FileEncoding
lineEndings: LineEndingType
}
```
- 原因: `readFileSyncWithMetadata` 返回的 `encoding` 类型已由 Task 2 改为 `FileEncoding`
- [x] 改造 `FileEditTool.validateInput` 中的编码检测逻辑
- 位置: `packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts:validateInput()`
- 将现有的 BOM-only 编码检测:
```typescript
const encoding: BufferEncoding =
fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe
? 'utf16le'
: 'utf8'
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
```
- 替换为:
```typescript
const encoding: FileEncoding = detectEncoding(fileBuffer)
fileContent = decodeBuffer(fileBuffer, encoding).replaceAll('\r\n', '\n')
```
- 原因: 使 validateInput 也能正确识别 GBK 文件,避免编辑时因编码检测不一致导致 old_string 匹配失败
- [x] 为 `writeTextContent` 的多编码写入能力编写单元测试
- 测试文件: `src/utils/__tests__/file.test.ts`
- 在现有测试 describe 块之后追加新的 describe('writeTextContent with multi-encoding') 块
- 测试场景:
- UTF-8 写入: 写入 UTF-8 内容 → 文件内容正确,无回退警告
- UTF-16LE 写入: 写入 UTF-16LE 内容(含 BOM → 文件二进制内容与预期一致
- GBK 写入回退: 对 gbk 编码调用 `writeTextContent` → 文件以 UTF-8 写入(`encodeString` 回退行为),内容不损坏
- CRLF 行尾 + GBK: `endings: 'CRLF'` + gbk 编码 → 行尾正确转换为 `\r\n`,编码回退为 UTF-8
- 注意: 需要 mock `src/utils/debug.ts`(使用共享 mock `tests/mocks/debug.ts`
- 运行命令: `bun test src/utils/__tests__/file.test.ts`
- 预期: 所有测试通过
**检查步骤:**
- [x] 验证 `writeTextContent` 签名使用 `FileEncoding` 类型
- `grep -n 'encoding: FileEncoding' src/utils/file.ts`
- 预期: 输出包含 `writeTextContent` 函数定义行
- [x] 验证 `writeFileSyncAndFlush_DEPRECATED` 支持 Buffer 写入
- `grep -n 'content: string | Buffer' src/utils/file.ts`
- 预期: 输出包含 `writeFileSyncAndFlush_DEPRECATED` 函数定义行
- [x] 验证 `FileEditTool.readFileForEdit` 返回类型已更新
- `grep -n 'encoding: FileEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
- 预期: 输出包含 `readFileForEdit` 函数的返回类型声明
- [x] 验证 `FileEditTool.validateInput` 使用 `detectEncoding`
- `grep -n 'detectEncoding' packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts`
- 预期: 输出包含 validateInput 内部的调用
- [x] 运行 file.ts 单元测试
- `bun test src/utils/__tests__/file.test.ts`
- 预期: 所有测试通过,无新增失败
- [x] 运行 FileEditTool 工具函数测试
- `bun test packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts`
- 预期: 所有现有测试通过
- [x] 运行完整 precheck
- `bun run precheck`
- 预期: typecheck + lint + test 零错误通过
---

View File

@@ -0,0 +1,49 @@
# 多编码文件工具 执行计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**目标:** 为文件读写工具添加自动编码检测,支持 GBK 编码的透明读写latin1 作为最终兜底)。
**技术栈:** TextDecoder/TextEncoder零外部依赖、Bun test 框架、TypeScript strict mode
**设计文档:** spec/feature_20260510_F001_multi-encoding-file-tools/spec-design.md
**范围变更:** 仅保留 GBK 编码支持Shift_JIS/EUC-JP/EUC-KR/Big5/GB18030/ISO-8859-1 已移除。
## 改动总览
新建编码检测核心模块 `src/utils/encoding.ts`提供三层检测BOM → UTF-8 fatal 验证 → GBK 回退 → latin1 兜底和解码工具函数。同步读取路径fileRead.ts → file.ts → fileReadCache.ts集成新检测逻辑异步读取路径readFileInRange.ts改造为 Buffer 读取 + 检测后解码。写入路径writeTextContent扩展类型支持新编码名非标准编码回退为 UTF-8 写入。FileEditTool 和 FileWriteTool 仅需类型适配。
---
## 任务索引
### Task 0: 环境准备
📄 详情见: `spec-plan-task-0.md`
验证构建工具链和测试环境是否就绪,确认 Bun 运行时对 GBK 编码的 TextDecoder 支持。
### Task 1: 编码检测核心模块
📄 详情见: `spec-plan-task-1.md`
新建 `src/utils/encoding.ts`实现三层编码检测算法BOM → UTF-8 fatal 验证 → GBK 回退)和 Buffer 解码/编码函数。
### Task 2: 同步读取路径集成
📄 详情见: `spec-plan-task-2.md`
改造 `fileRead.ts``file.ts` 的编码检测,集成新模块,更新类型定义。
### Task 3: 异步读取路径改造
📄 详情见: `spec-plan-task-3.md`
改造 `readFileInRange.ts` 的 fast path 和 streaming path支持非 UTF-8 编码。
### Task 4: 写入路径和工具层适配
📄 详情见: `spec-plan-task-4.md`
扩展写入路径类型,更新 FileEditTool/FileWriteTool 的类型注解。
### Acceptance Task
📄 详情见: `spec-plan-acceptance.md`
端到端验证所有功能是否正确实现。