Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 57f07a8

Browse files
committed
feat: add support for VSCode file encodings
- Add iconv-lite dependency for encoding conversion - Create src/utils/fileEncoding.ts utility that: - Gets file encoding from VSCode settings (files.encoding) - Provides readFileWithEncoding() and writeFileWithEncoding() functions - Includes fallback to UTF-8 if encoding conversion fails - Update DiffViewProvider.ts to use encoding-aware file operations - Update ApplyPatchTool.ts to use encoding-aware file operations - Add comprehensive tests for the encoding utility Fixes #11002
1 parent e7965d9 commit 57f07a8

6 files changed

Lines changed: 542 additions & 7 deletions

File tree

pnpm-lock.yaml

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/tools/ApplyPatchTool.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
55

66
import { getReadablePath } from "../../utils/path"
77
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
8+
import { readFileWithEncoding, writeFileWithEncoding } from "../../utils/fileEncoding"
89
import { Task } from "../task/Task"
910
import { formatResponse } from "../prompts/responses"
1011
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
@@ -59,7 +60,8 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
5960
// Process each hunk
6061
const readFile = async (filePath: string): Promise<string> => {
6162
const absolutePath = path.resolve(task.cwd, filePath)
62-
return await fs.readFile(absolutePath, "utf8")
63+
const { content } = await readFileWithEncoding(absolutePath)
64+
return content
6365
}
6466

6567
let changes: ApplyPatchFileChange[]
@@ -387,10 +389,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
387389
writeDelayMs,
388390
)
389391
} else {
390-
// Write to new path and delete old file
392+
// Write to new path and delete old file with proper encoding
391393
const parentDir = path.dirname(moveAbsolutePath)
392394
await fs.mkdir(parentDir, { recursive: true })
393-
await fs.writeFile(moveAbsolutePath, newContent, "utf8")
395+
await writeFileWithEncoding(moveAbsolutePath, newContent)
394396
}
395397

396398
// Delete the original file

src/integrations/editor/DiffViewProvider.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import delay from "delay"
88
import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
99

1010
import { createDirectoriesForFile } from "../../utils/fs"
11+
import { readFileWithEncoding, writeFileWithEncoding } from "../../utils/fileEncoding"
1112
import { arePathsEqual, getReadablePath } from "../../utils/path"
1213
import { formatResponse } from "../../core/prompts/responses"
1314
import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics"
@@ -67,7 +68,8 @@ export class DiffViewProvider {
6768
this.preDiagnostics = vscode.languages.getDiagnostics()
6869

6970
if (fileExists) {
70-
this.originalContent = await fs.readFile(absolutePath, "utf-8")
71+
const { content } = await readFileWithEncoding(absolutePath)
72+
this.originalContent = content
7173
} else {
7274
this.originalContent = ""
7375
}
@@ -651,9 +653,9 @@ export class DiffViewProvider {
651653
// Get diagnostics before editing the file
652654
this.preDiagnostics = vscode.languages.getDiagnostics()
653655

654-
// Write the content directly to the file
656+
// Write the content directly to the file with proper encoding
655657
await createDirectoriesForFile(absolutePath)
656-
await fs.writeFile(absolutePath, content, "utf-8")
658+
await writeFileWithEncoding(absolutePath, content)
657659

658660
// Open the document to ensure diagnostics are loaded
659661
// When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory

src/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@
479479
"fastest-levenshtein": "^1.0.16",
480480
"fzf": "^0.5.2",
481481
"get-folder-size": "^5.0.0",
482+
"iconv-lite": "^0.6.3",
482483
"global-agent": "^3.0.0",
483484
"google-auth-library": "^9.15.1",
484485
"gray-matter": "^4.0.3",
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import * as vscode from "vscode"
2+
import * as fs from "fs/promises"
3+
import * as iconv from "iconv-lite"
4+
import {
5+
getFileEncoding,
6+
normalizeEncoding,
7+
isEncodingSupported,
8+
readFileWithEncoding,
9+
writeFileWithEncoding,
10+
} from "../fileEncoding"
11+
12+
// Mock vscode module
13+
vi.mock("vscode", () => ({
14+
workspace: {
15+
getConfiguration: vi.fn(),
16+
},
17+
Uri: {
18+
file: vi.fn((path: string) => ({ fsPath: path })),
19+
},
20+
}))
21+
22+
// Mock fs/promises module
23+
vi.mock("fs/promises", () => ({
24+
default: {
25+
readFile: vi.fn(),
26+
writeFile: vi.fn(),
27+
},
28+
readFile: vi.fn(),
29+
writeFile: vi.fn(),
30+
}))
31+
32+
// Mock iconv-lite module
33+
vi.mock("iconv-lite", () => ({
34+
default: {
35+
encodingExists: vi.fn(),
36+
decode: vi.fn(),
37+
encode: vi.fn(),
38+
},
39+
encodingExists: vi.fn(),
40+
decode: vi.fn(),
41+
encode: vi.fn(),
42+
}))
43+
44+
describe("fileEncoding", () => {
45+
const mockedVscode = vi.mocked(vscode)
46+
const mockedFs = vi.mocked(fs)
47+
const mockedIconv = vi.mocked(iconv)
48+
49+
beforeEach(() => {
50+
vi.clearAllMocks()
51+
})
52+
53+
describe("getFileEncoding", () => {
54+
it("should return the configured encoding from VSCode settings", () => {
55+
const mockConfig = {
56+
get: vi.fn().mockReturnValue("cp852"),
57+
}
58+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
59+
60+
const encoding = getFileEncoding("/path/to/file.txt")
61+
62+
expect(mockedVscode.Uri.file).toHaveBeenCalledWith("/path/to/file.txt")
63+
expect(mockedVscode.workspace.getConfiguration).toHaveBeenCalledWith("files", {
64+
fsPath: "/path/to/file.txt",
65+
})
66+
expect(mockConfig.get).toHaveBeenCalledWith("encoding", "utf8")
67+
expect(encoding).toBe("cp852")
68+
})
69+
70+
it("should return utf8 as default if no encoding is configured", () => {
71+
const mockConfig = {
72+
get: vi.fn().mockReturnValue("utf8"),
73+
}
74+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
75+
76+
const encoding = getFileEncoding("/path/to/file.txt")
77+
78+
expect(encoding).toBe("utf8")
79+
})
80+
})
81+
82+
describe("normalizeEncoding", () => {
83+
it("should normalize utf-8 to utf8", () => {
84+
expect(normalizeEncoding("utf-8")).toBe("utf8")
85+
expect(normalizeEncoding("UTF-8")).toBe("utf8")
86+
})
87+
88+
it("should normalize windows code pages", () => {
89+
expect(normalizeEncoding("windows1252")).toBe("windows1252")
90+
expect(normalizeEncoding("windows-1252")).toBe("windows1252")
91+
})
92+
93+
it("should normalize DOS code pages", () => {
94+
expect(normalizeEncoding("cp852")).toBe("cp852")
95+
expect(normalizeEncoding("CP852")).toBe("cp852")
96+
})
97+
98+
it("should normalize ISO encodings", () => {
99+
expect(normalizeEncoding("iso88591")).toBe("iso88591")
100+
expect(normalizeEncoding("iso-8859-1")).toBe("iso88591")
101+
})
102+
103+
it("should return the original encoding if not in the map", () => {
104+
expect(normalizeEncoding("unknown-encoding")).toBe("unknown-encoding")
105+
})
106+
})
107+
108+
describe("isEncodingSupported", () => {
109+
it("should return true for supported encodings", () => {
110+
mockedIconv.encodingExists = vi.fn().mockReturnValue(true)
111+
112+
expect(isEncodingSupported("utf8")).toBe(true)
113+
expect(mockedIconv.encodingExists).toHaveBeenCalledWith("utf8")
114+
})
115+
116+
it("should return false for unsupported encodings", () => {
117+
mockedIconv.encodingExists = vi.fn().mockReturnValue(false)
118+
119+
expect(isEncodingSupported("unknown")).toBe(false)
120+
expect(mockedIconv.encodingExists).toHaveBeenCalledWith("unknown")
121+
})
122+
})
123+
124+
describe("readFileWithEncoding", () => {
125+
beforeEach(() => {
126+
const mockConfig = {
127+
get: vi.fn().mockReturnValue("utf8"),
128+
}
129+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
130+
})
131+
132+
it("should read file with UTF-8 encoding directly", async () => {
133+
const mockBuffer = Buffer.from("Hello World", "utf8")
134+
mockedFs.readFile = vi.fn().mockResolvedValue(mockBuffer)
135+
136+
const result = await readFileWithEncoding("/path/to/file.txt")
137+
138+
expect(result.content).toBe("Hello World")
139+
expect(result.encoding).toBe("utf8")
140+
expect(result.usedFallback).toBe(false)
141+
})
142+
143+
it("should read file with CP852 encoding", async () => {
144+
const mockConfig = {
145+
get: vi.fn().mockReturnValue("cp852"),
146+
}
147+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
148+
149+
const mockBuffer = Buffer.from([0x8d, 0x8f, 0xa7]) // Some CP852 bytes
150+
mockedFs.readFile = vi.fn().mockResolvedValue(mockBuffer)
151+
mockedIconv.encodingExists = vi.fn().mockReturnValue(true)
152+
mockedIconv.decode = vi.fn().mockReturnValue("čćž")
153+
154+
const result = await readFileWithEncoding("/path/to/file.txt")
155+
156+
expect(mockedIconv.decode).toHaveBeenCalledWith(mockBuffer, "cp852")
157+
expect(result.content).toBe("čćž")
158+
expect(result.encoding).toBe("cp852")
159+
expect(result.usedFallback).toBe(false)
160+
})
161+
162+
it("should fall back to UTF-8 if encoding is not supported", async () => {
163+
const mockConfig = {
164+
get: vi.fn().mockReturnValue("unsupported-encoding"),
165+
}
166+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
167+
168+
const mockBuffer = Buffer.from("Hello World", "utf8")
169+
mockedFs.readFile = vi.fn().mockResolvedValue(mockBuffer)
170+
mockedIconv.encodingExists = vi.fn().mockReturnValue(false)
171+
172+
const result = await readFileWithEncoding("/path/to/file.txt")
173+
174+
expect(result.content).toBe("Hello World")
175+
expect(result.encoding).toBe("unsupported-encoding")
176+
expect(result.usedFallback).toBe(true)
177+
})
178+
179+
it("should fall back to UTF-8 if decoding fails", async () => {
180+
const mockConfig = {
181+
get: vi.fn().mockReturnValue("cp852"),
182+
}
183+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
184+
185+
const mockBuffer = Buffer.from("Hello World", "utf8")
186+
mockedFs.readFile = vi.fn().mockResolvedValue(mockBuffer)
187+
mockedIconv.encodingExists = vi.fn().mockReturnValue(true)
188+
mockedIconv.decode = vi.fn().mockImplementation(() => {
189+
throw new Error("Decoding failed")
190+
})
191+
192+
const result = await readFileWithEncoding("/path/to/file.txt")
193+
194+
expect(result.content).toBe("Hello World")
195+
expect(result.encoding).toBe("cp852")
196+
expect(result.usedFallback).toBe(true)
197+
})
198+
})
199+
200+
describe("writeFileWithEncoding", () => {
201+
beforeEach(() => {
202+
const mockConfig = {
203+
get: vi.fn().mockReturnValue("utf8"),
204+
}
205+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
206+
})
207+
208+
it("should write file with UTF-8 encoding directly", async () => {
209+
mockedFs.writeFile = vi.fn().mockResolvedValue(undefined)
210+
211+
const result = await writeFileWithEncoding("/path/to/file.txt", "Hello World")
212+
213+
expect(mockedFs.writeFile).toHaveBeenCalledWith("/path/to/file.txt", "Hello World", "utf8")
214+
expect(result.encoding).toBe("utf8")
215+
expect(result.usedFallback).toBe(false)
216+
})
217+
218+
it("should write file with CP852 encoding", async () => {
219+
const mockConfig = {
220+
get: vi.fn().mockReturnValue("cp852"),
221+
}
222+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
223+
224+
const mockBuffer = Buffer.from([0x8d, 0x8f, 0xa7])
225+
mockedIconv.encodingExists = vi.fn().mockReturnValue(true)
226+
mockedIconv.encode = vi.fn().mockReturnValue(mockBuffer)
227+
mockedFs.writeFile = vi.fn().mockResolvedValue(undefined)
228+
229+
const result = await writeFileWithEncoding("/path/to/file.txt", "čćž")
230+
231+
expect(mockedIconv.encode).toHaveBeenCalledWith("čćž", "cp852")
232+
expect(mockedFs.writeFile).toHaveBeenCalledWith("/path/to/file.txt", mockBuffer)
233+
expect(result.encoding).toBe("cp852")
234+
expect(result.usedFallback).toBe(false)
235+
})
236+
237+
it("should fall back to UTF-8 if encoding is not supported", async () => {
238+
const mockConfig = {
239+
get: vi.fn().mockReturnValue("unsupported-encoding"),
240+
}
241+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
242+
243+
mockedIconv.encodingExists = vi.fn().mockReturnValue(false)
244+
mockedFs.writeFile = vi.fn().mockResolvedValue(undefined)
245+
246+
const result = await writeFileWithEncoding("/path/to/file.txt", "Hello World")
247+
248+
expect(mockedFs.writeFile).toHaveBeenCalledWith("/path/to/file.txt", "Hello World", "utf8")
249+
expect(result.encoding).toBe("unsupported-encoding")
250+
expect(result.usedFallback).toBe(true)
251+
})
252+
253+
it("should fall back to UTF-8 if encoding fails", async () => {
254+
const mockConfig = {
255+
get: vi.fn().mockReturnValue("cp852"),
256+
}
257+
mockedVscode.workspace.getConfiguration = vi.fn().mockReturnValue(mockConfig)
258+
259+
mockedIconv.encodingExists = vi.fn().mockReturnValue(true)
260+
mockedIconv.encode = vi.fn().mockImplementation(() => {
261+
throw new Error("Encoding failed")
262+
})
263+
mockedFs.writeFile = vi.fn().mockResolvedValue(undefined)
264+
265+
const result = await writeFileWithEncoding("/path/to/file.txt", "Hello World")
266+
267+
expect(mockedFs.writeFile).toHaveBeenCalledWith("/path/to/file.txt", "Hello World", "utf8")
268+
expect(result.encoding).toBe("cp852")
269+
expect(result.usedFallback).toBe(true)
270+
})
271+
})
272+
})

0 commit comments

Comments
 (0)