Repository navigation
[Bug] lsp_format_document rewrites non-UTF-8 bytes as U+FFFD, and read_file labels such files UTF-8 (follow-up to #1854) #1867
Description
Description
Follow-up to #1854. While reviewing the fix for it (#1866), the automated reviewer found two more places that mishandle a file that isn't valid UTF-8, such as Latin-1, Windows-1252 or Shift-JIS. I reproduced both. #1866 doesn't cover either, because #1854 was about string_replace and diff_edit.
lsp_format_documentrewrites every non-UTF-8 byte as U+FFFD. It reads the file as UTF-8, applies the language server's edits, and writes the whole file back as UTF-8, the same waystring_replacedid. So formatting a Latin-1 file changes bytes the formatter never touched, and the tool reports "Formatted … (1 edit applied)".read_filewithmetadata_onlyreportsEncoding: UTF-8for a file that isn't UTF-8. It decides the encoding by tryingreadFile(absPath, 'utf-8'), which never fails: Node replaces invalid bytes with U+FFFD instead of throwing. So the label is alwaysUTF-8for a regular file, which tells the model a Latin-1 file is safe to treat as UTF-8.
This is narrower than #1854. The formatter only runs when the model calls lsp_format_document, and language servers only start in the interactive TUI: --plain run and ACP never call getLSPManager() with the server config. It also needs a language server with formatting support for that file type. Still, it's the same silent damage, and the user asked for formatting, not for a change of encoding.
Environment
- OS: macOS (Darwin 25.6.0, x86_64)
- Node version: 22.23.3
- Nanocoder version: main at
c72945d3, v1.31.0, and the fix(tools): refuse to edit a file that isn't valid UTF-8 (#1854) #1866 branch - Provider: a local OpenAI-compatible test server with scripted responses
- Model: n/a (scripted)
- Language server: a minimal stdio test server that advertises document formatting and returns one edit (
old→newon line 2), configured throughlspServers
Steps to Reproduce
Formatter (interactive TUI):
- In a scratch project, create a Latin-1 file
printf 'caf\351\nold\n' > legacy.legacy. Line 1 iscaféin Latin-1, a singleE9byte. - Configure a language server for the
legacyextension inlspServers. Any server withdocumentFormattingProviderworks. Mine returns one edit replacingoldwithnewon line 2. - Start
nanocoder --mode auto-accept, trust the folder, and have the model calllsp_format_documentonlegacy.legacy. - Compare the bytes:
xxd -p legacy.legacy.
Encoding label:
- With the same Latin-1 file, have the model call
read_filewith{"path": "legacy.legacy", "metadata_only": true}. I usednanocoder --trust-directory --mode auto-accept --plain run. - Check the
Encoding:line.
Expected Behavior
lsp_format_documentleaves bytes outside the edits unchanged, or refuses a file that isn't valid UTF-8 the waystring_replaceanddiff_editdo with fix(tools): refuse to edit a file that isn't valid UTF-8 (#1854) #1866.read_filemetadata doesn't label a file that isn't valid UTF-8 asUTF-8.
Actual Behavior
| Case | v1.31.0 | main c72945d3 |
#1866 branch |
|---|---|---|---|
format a Latin-1 file: 63 61 66 e9 0a 6f 6c 64 0a |
63 61 66 ef bf bd 0a 6e 65 77 0a, "Formatted legacy.legacy (1 edit applied)" |
same | same |
format the same text saved as UTF-8 (control): 63 61 66 c3 a9 0a 6f 6c 64 0a |
63 61 66 c3 a9 0a 6e 65 77 0a |
same | same |
read_file metadata_only on the Latin-1 file |
Encoding: UTF-8 |
same | same |
In the formatter case, line 1 was never part of the edit, but its E9 byte became EF BF BD. The language server saw initialize, didOpen, formatting, then didChange with the damaged text.
Logs/Screenshots
What I think is going on:
formatFileWithLspreads withreadFile(absPath, 'utf-8')and writes withwriteFile(absPath, formatted, 'utf-8')(source/tools/lsp-format-document.tsx:292and:299).- The metadata path sets
let encoding = 'UTF-8'and only changes it ifreadFile(absPath, 'utf-8')throws, which it never does for invalid UTF-8 (source/tools/read-file.tsx:81-92).
Additional Context
Proposed fix (happy to adjust): once #1866 is in, both can reuse what it adds.
- Formatter: refuse a file that isn't valid UTF-8 with the same
validateEditableEncoding()error before asking the language server for edits.formatFileWithLspcould read the file through the file cache (which now recordsnotUtf8), or checkbuffer.isUtf8on its own read. - Label: use the cache's
notUtf8flag, already in scope in that block, to report something likeNot valid UTF-8 (legacy encoding?)instead ofUTF-8.
For tests: formatting a Latin-1 file is refused and the bytes stay the same; a UTF-8 file with non-ASCII text still formats; and metadata_only on a Latin-1 file doesn't say UTF-8.
- I have searched existing issues to ensure this is not a duplicate
- I can reproduce this issue consistently
- This issue occurs with the latest version of nanocoder
@addyCooks could you take a look when you have a moment? I'd like to work on this one too, so please assign it to me if that works for you.
Activity
Parth-Jadhav-2004 commentedon Oct 21, 2026
Can I take this? I'd read the file as a Buffer and validate it with a fatal TextDecoder, so
lsp_format_documentrefuses non-UTF-8 files like string_replace does after #1866, andread_filemetadata stops labelling them UTF-8. I'll add Latin-1 fixture tests for both and reuse the #1866 helper if that lands first.addyCooks commentedon Oct 27, 2026
Thanks @diyaavirmani, confirmed on current main. lsp_format_document reads and writes with 'utf-8' (lsp-format-document.tsx:292/299), and the metadata path in read_file only changes the label if readFile throws, which it never does for invalid UTF-8. I reproduced both at unit level: a Latin-1 file formatted to 63 61 66 ef bf bd 0a 6e 65 77 0a with "1 edit applied", and metadata_only printed "Encoding: UTF-8".
Your plan makes sense: once #1866 is in, refuse a non-UTF-8 file in the formatter with the same error, and use the notUtf8 flag for the label. Please add the three tests you listed, and wait for #1866 to merge first.
Go ahead and open a PR. I'll assign this to you.
@Parth-Jadhav-2004 thanks for the offer, but the reporter asked first, so this one is taken.