fix(encoding): preserve UTF-16/32 content in jj file reads and diffs (#122)

Use raw command output for jj file reads, add encoding-aware decode/encode
handling, preserve buffer encoding settings across revision buffers, and add
tests for BOMs, endianness, and roundtrip behavior.
This commit is contained in:
Lars Hansen
2026-06-16 18:35:27 +02:00
committed by GitHub
parent 33179e2f15
commit 6409ab0a66
5 changed files with 361 additions and 36 deletions
+54 -8
View File
@@ -1,6 +1,13 @@
--- @class jj.core.runner
local M = {}
local function error_notify(msg, error_prefix, silent)
local error_message = error_prefix and string.format("%s: %s", error_prefix, msg) or msg
if not silent then
vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" })
end
end
--- Execute a system command and return output with error handling
--- @param cmd string The command to execute
--- @param error_prefix string|nil Optional error message prefix
@@ -18,10 +25,7 @@ function M.execute_command(cmd, error_prefix, input, silent)
vim.fn.delete(stderr_file)
local error_output = table.concat(stderr_lines, "\n")
local msg = error_output ~= "" and error_output or output
local error_message = error_prefix and string.format("%s: %s", error_prefix, msg) or msg
if not silent then
vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" })
end
error_notify(msg, error_prefix, silent)
return nil, false
end
@@ -29,6 +33,24 @@ function M.execute_command(cmd, error_prefix, input, silent)
return output, success
end
--- Execute a system command and return its raw stdout bytes.
--- Skips replacement of NUL bytes with SOH (0x01),
--- which corrupts UTF-16 and other binary-ish content.
--- @param cmd string The command to execute
--- @param error_prefix string|nil Optional error message prefix
--- @param silent boolean|nil Optional to silent the notification
--- @return string|nil output Raw stdout bytes, or nil if failed
--- @return boolean success Whether the command succeeded
function M.execute_command_raw(cmd, error_prefix, silent)
local result = vim.system({ "sh", "-c", cmd }):wait()
if result.code ~= 0 then
local msg = result.stderr ~= "" and result.stderr or result.stdout or ""
error_notify(msg, error_prefix, silent)
return nil, false
end
return result.stdout or "", true
end
--- Execute a system command synchronously and call success callback.
--- @param cmd string The command to execute
--- @param on_success function|nil Callback on success, receives output as parameter
@@ -74,10 +96,7 @@ function M.execute_command_async(cmd, on_success, error_prefix, input, silent, o
else
local error_output = table.concat(stderr_lines, "\n")
local msg = error_output ~= "" and error_output or output
local error_message = error_prefix and string.format("%s: %s", error_prefix, msg) or msg
if not silent then
vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" })
end
error_notify(msg, error_prefix, silent)
if on_error then
on_error(msg)
end
@@ -92,4 +111,31 @@ function M.execute_command_async(cmd, on_success, error_prefix, input, silent, o
end
end
--- Execute a system command asynchronously and receive raw stdout bytes.
--- Skips replacement of NUL bytes with SOH (0x01).
--- @param cmd string The command to execute
--- @param on_success function|nil Callback on success, receives raw stdout bytes
--- @param error_prefix string|nil Optional error message prefix
--- @param silent boolean|nil Optional to silent the notification
--- @param on_error function|nil Callback on error, receives the error message
function M.execute_command_raw_async(cmd, on_success, error_prefix, silent, on_error)
vim.system(
{ "sh", "-c", cmd },
{},
vim.schedule_wrap(function(res)
if res.code == 0 then
if on_success then
on_success(res.stdout or "")
end
else
local msg = res.stderr ~= "" and res.stderr or res.stdout or ""
error_notify(msg, error_prefix, silent)
if on_error then
on_error(msg)
end
end
end)
)
end
return M
+5 -1
View File
@@ -168,7 +168,11 @@ diff.register_backend("codediff", {
return
end
local base_lines, base_had_eol, ok_read = file.get_file_content(revset, path)
-- Borrow the current buffer's encoding settings so the revision
-- side is decoded in the same way as the current file.
-- Assumes the encoding didn't change between revisions.
local enc = file.get_buf_encoding(0)
local base_lines, base_had_eol, ok_read = file.get_file_content(revset, path, enc)
if not ok_read then
utils.notify(string.format("Could not read `%s` from `%s` for CodeDiff", path, revset), vim.log.levels.ERROR)
return
+12 -5
View File
@@ -8,12 +8,13 @@ local file = require("jj.file")
--- Open a writable buffer for a specific revision of a file.
--- @param rev string The revision
--- @param path string The file path (absolute or repo-relative)
local function open_revision(rev, path)
--- @param enc? jj.file.enc Encoding settings for the file content
local function open_revision(rev, path, enc)
local raw_ids, ok = runner.execute_command(
string.format([[jj log --no-graph -r %s -T 'change_id ++ "\n"' --quiet]], vim.fn.shellescape(rev)),
"jj: failed to resolve revision"
)
if not ok then return end
if not ok or not raw_ids then return end
local ids = vim.split(vim.trim(raw_ids), "\n", { trimempty = true })
if #ids ~= 1 then
utils.notify(string.format("Revision '%s' is ambiguous", rev), vim.log.levels.ERROR)
@@ -27,7 +28,7 @@ local function open_revision(rev, path)
return
end
local lines, had_eol, ok_read = file.get_file_content(change_id, rel_path)
local lines, had_eol, ok_read, used_enc = file.get_file_content(change_id, rel_path, enc)
if not ok_read then
utils.notify(string.format("Could not read `%s` from `%s`", rel_path, change_id), vim.log.levels.ERROR)
return
@@ -36,6 +37,7 @@ local function open_revision(rev, path)
local buf = vim.api.nvim_create_buf(false, true)
local buf_name = string.format("jj://%s/%s", change_id, rel_path)
file.set_buf_encoding(buf, used_enc)
vim.api.nvim_buf_set_name(buf, buf_name)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].eol = had_eol
@@ -54,7 +56,7 @@ local function open_revision(rev, path)
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
file.write_revision_file(buf, change_id, rel_path)
file.write_revision_file(buf, change_id, rel_path, vim.v.cmdbang == 1)
end,
})
@@ -85,6 +87,11 @@ diff.register_backend("native", {
local path = opts.path or jj_path or buf_name
local layout = opts.layout or "vertical"
-- Borrow the current buffer's encoding settings so the revision
-- side is decoded in the same way as the current file.
-- Assumes the encoding didn't change between revisions.
local enc = file.get_buf_encoding(prev_buf)
local split_fun = layout == "horizontal" and vim.cmd.split or vim.cmd.vsplit
local orig_win = vim.api.nvim_get_current_win()
@@ -95,7 +102,7 @@ diff.register_backend("native", {
-- Set up diff: current buffer on right, revision on left
vim.cmd.diffthis()
split_fun({ mods = { split = "aboveleft" } })
open_revision(rev, path)
open_revision(rev, path, enc)
vim.cmd.diffthis()
local rev_buf = vim.api.nvim_get_current_buf()
+235 -22
View File
@@ -15,29 +15,227 @@ local parser = require("jj.core.parser")
--- @field path? string Path to the file (`%`, absolute, or repository-relative)
--- @field split? "horizontal"|"vertical"|"tab"|"current" Open in split direction (default: "current")
--- Encoding settings mirroring the corresponding buffer options.
--- @class jj.file.enc
--- @field fenc string 'fileencoding', "" means utf-8/internal
--- @field bomb boolean 'bomb'
--- @field ff "unix"|"dos"|"mac" 'fileformat'
--- @param buf integer
--- @return jj.file.enc
function M.get_buf_encoding(buf)
return {
fenc = vim.bo[buf].fileencoding,
bomb = vim.bo[buf].bomb,
ff = vim.bo[buf].fileformat,
}
end
--- Apply encoding settings to a buffer's options
--- @param buf integer
--- @param enc jj.file.enc
function M.set_buf_encoding(buf, enc)
vim.bo[buf].fileencoding = enc.fenc
vim.bo[buf].bomb = enc.bomb
vim.bo[buf].fileformat = enc.ff
end
local UTF8_BOM = "\239\187\191"
--- Reverse the byte order of every `width`-byte code unit (le <-> be).
--- NOTE: We swap manually because neovim does not reliably distinguish unicode
--- endianness. See https://github.com/neovim/neovim/issues/40262.
--- @param s string Byte string whose length is a multiple of `width`
--- @param width 2|4
--- @return string
local function swap_units(s, width)
if width == 2 then
return (s:gsub("(.)(.)", "%2%1"))
end
return (s:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
end
--- Byte-order mark for a fixed-width Unicode encoding.
--- @param width 2|4
--- @param order "le"|"be"
--- @return string
local function bom(width, order)
local le = width == 2 and "\255\254" or "\255\254\0\0"
return order == "le" and le or swap_units(le, width)
end
--- Identify the multi-byte unicode family and its byte order.
--- @param fenc string A 'fileencoding' value
--- @return 2|4|nil width Byte width of a code unit
--- @return "le"|"be"|nil order
local function unicode_width(fenc)
local f = fenc:lower():gsub("%-", "")
if f == "utf16le" or f == "ucs2le" then return 2, "le" end
if f == "utf32le" or f == "ucs4le" then return 4, "le" end
if f == "utf16be" or f == "ucs2be" or f == "utf16" or f == "ucs2" or f == "unicode" then
return 2, "be"
end
if f == "utf32be" or f == "ucs4be" or f == "utf32" or f == "ucs4" then
return 4, "be"
end
return nil
end
--- Decode raw file bytes into UTF-8 lines according to `enc`.
--- When `enc` is omitted, it is auto-detected from the raw bytes.
--- @param raw string
--- @param enc? jj.file.enc
--- @return string[]|nil lines nil on conversion failure
--- @return boolean|string had_eol Whether the content had a trailing
--- newline; an error message when `lines` is nil
--- @return jj.file.enc enc The encoding used (detected or passed in)
local function decode(raw, enc)
local auto_detected = false
if not enc then
auto_detected = true
enc = { fenc = "", bomb = false, ff = "unix" }
-- Use the canonical neovim 'fileencoding' names.
if raw:sub(1, 4) == bom(4, "le") then
enc.fenc = "ucs-4le"
enc.bomb = true
elseif raw:sub(1, 4) == bom(4, "be") then
enc.fenc = "ucs-4"
enc.bomb = true
elseif raw:sub(1, 2) == bom(2, "le") then
enc.fenc = "utf-16le"
enc.bomb = true
elseif raw:sub(1, 2) == bom(2, "be") then
enc.fenc = "utf-16"
enc.bomb = true
elseif raw:sub(1, #UTF8_BOM) == UTF8_BOM then
enc.bomb = true
end
end
local width, order = unicode_width(enc.fenc)
if width then
-- BOM is authoritative for endianness; strip it before converting.
local head = raw:sub(1, width)
if head == bom(width, "le") then
enc.bomb, order, raw = true, "le", raw:sub(width + 1)
elseif head == bom(width, "be") then
enc.bomb, order, raw = true, "be", raw:sub(width + 1)
end
if order == "be" then
raw = swap_units(raw, width)
end
local converted = vim.iconv(raw, width == 2 and "utf-16le" or "utf-32le", "utf-8")
if not converted then
return nil, string.format("Could not convert content from '%s' to utf-8", enc.fenc), enc
end
raw = converted
elseif enc.fenc ~= "" and enc.fenc ~= "utf-8" then
local converted = vim.iconv(raw, enc.fenc, "utf-8")
if not converted then
return nil, string.format("Could not convert content from '%s' to utf-8", enc.fenc), enc
end
raw = converted
elseif enc.bomb then
-- UTF-8 with BOM.
if raw:sub(1, #UTF8_BOM) == UTF8_BOM then
raw = raw:sub(#UTF8_BOM + 1)
end
end
if auto_detected then
if raw:find("\r\n", 1, true) then
enc.ff = "dos"
elseif raw:find("\r", 1, true) then
enc.ff = "mac"
end
end
if enc.ff == "dos" then
raw = raw:gsub("\r\n", "\n")
elseif enc.ff == "mac" then
raw = raw:gsub("\r", "\n")
end
local had_eol = raw:sub(-1) == "\n"
local lines = vim.split(raw, "\n", { plain = true, trimempty = false })
if had_eol then
table.remove(lines, #lines)
end
return lines, had_eol, enc
end
--- Serialize UTF-8 lines back into raw file bytes.
--- @param lines string[]
--- @param eol boolean Whether to append a trailing newline
--- @param enc jj.file.enc
--- @return string|nil content nil on conversion failure
--- @return string|nil err Error message when content is nil
local function encode(lines, eol, enc)
local text = table.concat(lines, "\n")
if eol then
text = text .. "\n"
end
if enc.ff == "dos" then
text = text:gsub("\n", "\r\n")
elseif enc.ff == "mac" then
text = text:gsub("\n", "\r")
end
local width, order = unicode_width(enc.fenc)
if width then
-- Always serialise via the little-endian variant,
-- see https://github.com/neovim/neovim/issues/40262.
local converted = vim.iconv(text, "utf-8", width == 2 and "utf-16le" or "utf-32le")
if not converted then
return nil, string.format("Could not convert content from utf-8 to '%s'", enc.fenc)
end
if order == "be" then
converted = swap_units(converted, width)
end
if enc.bomb then
converted = bom(width, order --[[@as string]]) .. converted
end
return converted
end
if enc.fenc ~= "" and enc.fenc ~= "utf-8" then
local converted = vim.iconv(text, "utf-8", enc.fenc)
if not converted then
return nil, string.format("Could not convert content from utf-8 to '%s'", enc.fenc)
end
text = converted
elseif enc.bomb then
text = UTF8_BOM .. text
end
return text
end
-- Exposed for unit tests (tests/run_tests.lua); not part of the public API.
M._decode = decode
M._encode = encode
--- Fetch file content from jj synchronously.
--- Returns lines with blank lines preserved; trailing empty line removed.
--- @param rev string The revision (change ID or other revset)
--- @param path string Cwd-relative path
--- @param enc? jj.file.enc Encoding to interpret the content with
--- (default: auto-detected from content)
--- @return string[] lines
--- @return boolean had_eol Whether the content had a trailing newline
--- @return boolean ok Whether the command succeeded
local function get_file_content(rev, path)
local content, ok = runner.execute_command(
--- @return jj.file.enc used_enc The encoding used to decode the content
local function get_file_content(rev, path, enc)
local raw, ok = runner.execute_command_raw(
string.format("jj file show -r %s %s", vim.fn.shellescape(rev), vim.fn.shellescape(path)),
nil,
nil,
true
)
if not ok or not content then
return {}, false, false
if not ok or not raw then
return {}, false, false, enc or { fenc = "", bomb = false, ff = "unix" }
end
local lines = vim.split(content, "\n", { plain = true, trimempty = false })
local had_eol = #lines > 0 and lines[#lines] == ""
if had_eol then
table.remove(lines, #lines)
local lines, had_eol, used_enc = decode(raw, enc)
if not lines then
utils.notify(had_eol --[[@as string]], vim.log.levels.ERROR)
return {}, false, false, used_enc
end
return lines, had_eol, true
return lines, had_eol --[[@as boolean]], true, used_enc
end
M.get_file_content = get_file_content
@@ -53,17 +251,28 @@ function M.read_target(opts)
end
local buf = vim.api.nvim_get_current_buf()
-- Borrow the buffer's existing encoding if it already has one set;
-- otherwise auto-detect from the raw bytes.
local enc = nil
local buf_fenc = vim.bo[buf].fileencoding
if buf_fenc ~= "" then
enc = M.get_buf_encoding(buf)
end
local cmd = string.format("jj file show -r %s %s", vim.fn.shellescape(revision), vim.fn.shellescape(path))
runner.execute_command_async(cmd, function(out)
local lines = vim.split(out, "\n", { plain = true, trimempty = false })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines, #lines)
runner.execute_command_raw_async(cmd, function(raw)
local lines, had_eol, used_enc = decode(raw, enc)
if not lines then
utils.notify(had_eol --[[@as string]], vim.log.levels.ERROR)
return
end
if vim.bo[buf].modifiable == false then
utils.notify("Current buffer is not modifiable", vim.log.levels.ERROR)
return
end
M.set_buf_encoding(buf, used_enc)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].eol = had_eol --[[@as boolean]]
vim.bo[buf].modified = true
end, string.format("Could not read `%s` from `%s`", path, revision))
end
@@ -81,13 +290,15 @@ local function write_revision_file(buf, change_id, rel_path, force)
end
end
local new_content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n")
if vim.bo[buf].eol then
new_content = new_content .. "\n"
local new_content, enc_err =
encode(vim.api.nvim_buf_get_lines(buf, 0, -1, false), vim.bo[buf].eol, M.get_buf_encoding(buf))
if not new_content then
utils.notify(enc_err or "Could not encode buffer content", vim.log.levels.ERROR)
return
end
local tmp = vim.fn.tempname()
local cf = io.open(tmp, "w")
local cf = io.open(tmp, "wb")
if not cf then
utils.notify("Failed to create temp file", vim.log.levels.ERROR)
return
@@ -139,7 +350,7 @@ function M.open_target(opts)
nil,
true
)
if not ok then return end
if not ok or not raw_ids then return end
local ids = vim.split(vim.trim(raw_ids), "\n", { trimempty = true })
if #ids ~= 1 then
utils.notify(string.format("Revision '%s' is ambiguous", revision), vim.log.levels.ERROR)
@@ -147,7 +358,7 @@ function M.open_target(opts)
end
local change_id = ids[1]
local lines, had_eol, ok_read = get_file_content(change_id, path)
local lines, had_eol, ok_read, used_enc = get_file_content(change_id, path)
if not ok_read then
utils.notify(string.format("Could not read `%s` from `%s`", path, change_id), vim.log.levels.ERROR)
return
@@ -162,6 +373,7 @@ function M.open_target(opts)
bufhidden = "wipe",
filetype = ft,
})
M.set_buf_encoding(buf, used_enc)
vim.bo[buf].buflisted = true
vim.bo[buf].swapfile = false
local ul = vim.bo[buf].undolevels
@@ -223,13 +435,14 @@ function M.register_command()
callback = function()
local name = vim.api.nvim_buf_get_name(0)
local change_id, path = utils.parse_jj_uri(name)
if not change_id then return end
local lines, had_eol, ok_read = get_file_content(change_id, path)
if not change_id or not path then return end
local lines, had_eol, ok_read, used_enc = get_file_content(change_id, path)
if not ok_read then
utils.notify(string.format("Could not read `%s` from `%s`", path, change_id), vim.log.levels.ERROR)
return
end
local buf = vim.api.nvim_get_current_buf()
M.set_buf_encoding(buf, used_enc)
vim.bo[buf].modifiable = true
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].eol = had_eol
+55
View File
@@ -498,6 +498,61 @@ run_test("parse_bookmark_names: handles whitespace only", function()
assert_table_equals({}, utils.parse_bookmark_names(" "))
end)
print("\n=== Running file encoding tests ===\n")
local jj_file = require("jj.file")
--- Hex-dump a string so assertion failures on binary data are readable.
local function hex(s)
return (s:gsub(".", function(c)
return string.format("%02x ", string.byte(c))
end))
end
run_test("decode: utf-16le with BOM and dos endings", function()
-- "a\r\nü\r\n" in utf-16le with BOM
local raw = "\255\254a\0\r\0\n\0\252\0\r\0\n\0"
local lines, had_eol, enc = jj_file._decode(raw)
assert_table_equals({ "a", "\195\188" }, lines) -- "ü" in utf-8
assert_equals(true, had_eol)
assert_equals("utf-16le", enc.fenc)
assert_equals(true, enc.bomb)
assert_equals("dos", enc.ff)
for _, line in ipairs(lines) do
assert_is_nil(line:find("%z"), "line contains a NUL byte")
assert_is_nil(line:find("\1"), "line contains a SOH byte")
end
end)
run_test("decode: honors explicit enc for BOM-less utf-16le", function()
local lines = jj_file._decode("h\0i\0", { fenc = "utf-16le", bomb = false, ff = "unix" })
assert_table_equals({ "hi" }, lines)
end)
run_test("decode: roundtrip reproduces the bytes exactly", function()
local fixtures = {
["utf-16le bom"] = "\255\254h\0i\0\n\0\252\0",
["utf-16be bom"] = "\254\255\0h\0i\0\n\0\252",
["utf-16le bom + dos"] = "\255\254a\0\r\0\n\0b\0\r\0\n\0",
["utf-8 bom"] = "\239\187\191hello\nworld\n",
["plain dos"] = "a\r\nb\r\n",
["mac"] = "a\rb\r",
["no trailing newline"] = "a\nb",
["empty file"] = "",
}
for name, raw in pairs(fixtures) do
local lines, had_eol, enc = jj_file._decode(raw)
if lines == nil then
error(string.format("%s: decode failed: %s", name, tostring(had_eol)))
end
local encoded, err = jj_file._encode(lines, had_eol, enc)
if encoded == nil then
error(string.format("%s: encode failed: %s", name, tostring(err)))
end
assert_equals(hex(raw), hex(encoded), name)
end
end)
-- Print summary
print(string.format("\n=== Test Summary ==="))
print(string.format("Passed: %d", tests_passed))