mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 02:36:07 +00:00
Migrate command execution to structured argv lists across the plugin, replacing shell-string command construction with `vim.system` argv execution. The runner API now exposes argv-based execution as the default path:
- `runner.execute_command` -> `runner.execute`
- `runner.execute_command_async` -> `runner.execute_async`
- `runner.execute_command_raw` -> `runner.execute_raw`
- `runner.execute_command_raw_async` -> `runner.execute_raw_async`
- `runner.execute_command_sync` was removed; use `runner.execute` and handle the result directly
BREAKING CHANGES:
- `cmd.new({ args = ... })` now requires `args` as `string[]` argv tokens, not a single string.
- `cmd.log({ ... })` now uses `raw_flags` as `string[]` for raw passthrough flags; `raw = "..."` is no longer supported.
- Direct users of `jj.core.runner` must migrate from the old `execute_command*` string helpers to the new argv-based `execute*` helpers.
536 lines
16 KiB
Lua
536 lines
16 KiB
Lua
--- @class jj.file
|
|
local M = {}
|
|
|
|
local runner = require("jj.core.runner")
|
|
local buffer = require("jj.core.buffer")
|
|
local utils = require("jj.utils")
|
|
local parser = require("jj.core.parser")
|
|
local jj_args = require("jj.core.args")
|
|
|
|
--- @class jj.file.read_target_opts
|
|
--- @field rev? string Revision to read the file from
|
|
--- @field path? string Path to the file (`%`, absolute, or repository-relative)
|
|
|
|
--- @class jj.file.open_target_opts
|
|
--- @field rev? string Revision to open the file from
|
|
--- @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 read succeeded
|
|
--- @return jj.file.enc used_enc The encoding used to decode the content
|
|
--- @return boolean absent True when the path does not exist in `rev` (e.g. a
|
|
--- file added since `rev`).
|
|
local function get_file_content(rev, path, enc)
|
|
local cmd = {
|
|
"jj",
|
|
"file",
|
|
"show",
|
|
"-r",
|
|
rev,
|
|
jj_args.fileset(path),
|
|
}
|
|
local raw, ok, stderr = runner.execute_raw(cmd, nil, true)
|
|
if not ok or not raw then
|
|
local absent = stderr ~= nil and stderr:find("No such path", 1, true) ~= nil
|
|
return {}, false, false, enc or { fenc = "", bomb = false, ff = "unix" }, absent
|
|
end
|
|
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, false
|
|
end
|
|
return lines,
|
|
had_eol, --[[@as boolean]]
|
|
true,
|
|
used_enc,
|
|
false
|
|
end
|
|
M.get_file_content = get_file_content
|
|
|
|
--- Reads a target file revision into the current buffer (undoable).
|
|
--- @param opts? jj.file.read_target_opts
|
|
function M.read_target(opts)
|
|
local revision = opts and opts.rev or "@"
|
|
local raw_path = opts and opts.path or "%"
|
|
local path, normalize_err = utils.normalize_relative_path(raw_path)
|
|
if not path then
|
|
utils.notify(normalize_err or "Could not normalize path", vim.log.levels.ERROR)
|
|
return
|
|
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 = {
|
|
"jj",
|
|
"file",
|
|
"show",
|
|
"-r",
|
|
revision,
|
|
jj_args.fileset(path),
|
|
}
|
|
runner.execute_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
|
|
|
|
--- Write buffer content back into a jj revision, bypassing the working copy.
|
|
--- @param buf integer
|
|
--- @param change_id string
|
|
--- @param rel_path string Repository-relative path of the file
|
|
--- @param force boolean Whether to bypass the immutability check (`:w!`)
|
|
local function write_revision_file(buf, change_id, rel_path, force)
|
|
if utils.is_change_immutable(change_id) then
|
|
if not force then
|
|
utils.notify("Cannot write to immutable revision: " .. change_id, vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
end
|
|
|
|
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, "wb")
|
|
if not cf then
|
|
utils.notify("Failed to create temp file", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
cf:write(new_content)
|
|
cf:close()
|
|
|
|
-- Configure `cp` as the diffedit tool inline via --config.
|
|
-- jj expands $right to the directory it populates with <rev>'s content,
|
|
-- so the parent directory for rel_path already exists there.
|
|
local prog_config = 'merge-tools.jj-nvim-write.program="cp"'
|
|
-- json_encode produces valid TOML inline arrays.
|
|
local args_config = "merge-tools.jj-nvim-write.edit-args=" .. vim.fn.json_encode({ tmp, "$right/" .. rel_path })
|
|
|
|
local cmd = {
|
|
"jj",
|
|
"diffedit",
|
|
"--from",
|
|
"root()",
|
|
"--to",
|
|
change_id,
|
|
"--config",
|
|
prog_config,
|
|
"--config",
|
|
args_config,
|
|
"--tool",
|
|
"jj-nvim-write",
|
|
"--",
|
|
jj_args.fileset(rel_path),
|
|
}
|
|
local _, ok = runner.execute(cmd, "jj: failed to edit revision")
|
|
|
|
os.remove(tmp)
|
|
|
|
if not ok then
|
|
return
|
|
end
|
|
vim.bo[buf].modified = false
|
|
utils.notify(string.format("Written to revision %s", change_id))
|
|
end
|
|
M.write_revision_file = write_revision_file
|
|
|
|
--- Opens a target file revision in a new buffer.
|
|
--- @param opts jj.file.open_target_opts
|
|
function M.open_target(opts)
|
|
local revision = opts.rev or "@"
|
|
local raw_path = opts.path or "%"
|
|
local path, normalize_err = utils.normalize_relative_path(raw_path)
|
|
if not path then
|
|
utils.notify(normalize_err or "Could not normalize path", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
local cmd = {
|
|
"jj",
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
revision,
|
|
"-T",
|
|
'change_id ++ "\n"',
|
|
"--quiet",
|
|
}
|
|
local raw_ids, ok = runner.execute(cmd, "jj: failed to resolve revision", nil, true)
|
|
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)
|
|
return
|
|
end
|
|
local change_id = ids[1]
|
|
|
|
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 ft = vim.filetype.match({ filename = path })
|
|
|
|
local buf, _ = buffer.create({
|
|
name = string.format("jj://%s/%s", change_id, path),
|
|
split = opts.split or "current",
|
|
modifiable = true,
|
|
buftype = "acwrite",
|
|
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
|
|
vim.bo[buf].undolevels = -1
|
|
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
|
vim.bo[buf].undolevels = ul
|
|
vim.bo[buf].eol = had_eol
|
|
|
|
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
|
buffer = buf,
|
|
callback = function()
|
|
write_revision_file(buf, change_id, path, vim.v.cmdbang == 1)
|
|
end,
|
|
})
|
|
|
|
vim.bo[buf].modified = false
|
|
vim.bo[buf].modifiable = not utils.is_change_immutable(change_id)
|
|
end
|
|
|
|
--- Complete `<rev>:<file>` arguments for file commands.
|
|
--- @param arglead string
|
|
--- @return string[]
|
|
local function complete_target(arglead)
|
|
local rev, file_prefix = arglead:match("^([^:]+):(.*)$")
|
|
if not rev then
|
|
return {}
|
|
end
|
|
|
|
local cmd = {
|
|
"jj",
|
|
"file",
|
|
"list",
|
|
"-r",
|
|
rev,
|
|
}
|
|
local out, ok = runner.execute(cmd, nil, nil, true)
|
|
if not ok or not out then
|
|
return {}
|
|
end
|
|
|
|
local items = {}
|
|
local seen = {}
|
|
for line in out:gmatch("[^\r\n]+") do
|
|
local file = vim.trim(line)
|
|
if file ~= "" and vim.startswith(file, file_prefix) then
|
|
local candidate = rev .. ":" .. file
|
|
if not seen[candidate] then
|
|
table.insert(items, candidate)
|
|
seen[candidate] = true
|
|
end
|
|
end
|
|
end
|
|
return items
|
|
end
|
|
|
|
function M.register_command()
|
|
-- Allow :e on jj:// buffers to reload their content.
|
|
vim.api.nvim_create_autocmd("BufReadCmd", {
|
|
pattern = "jj://*",
|
|
nested = true,
|
|
callback = function()
|
|
local name = vim.api.nvim_buf_get_name(0)
|
|
local change_id, path = utils.parse_jj_uri(name)
|
|
if not change_id or not path then
|
|
return
|
|
end
|
|
-- Keep reloads of an added-file diff buffer empty rather than erroring.
|
|
local lines, had_eol, ok_read, used_enc, absent = get_file_content(change_id, path)
|
|
if not ok_read and not absent 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
|
|
vim.bo[buf].modified = false
|
|
vim.bo[buf].modifiable = not utils.is_change_immutable(change_id)
|
|
vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_user_command("Jread", function(opts)
|
|
local parsed = parser.parse_file_module_input(opts.args)
|
|
M.read_target({
|
|
rev = parsed and parsed.rev,
|
|
path = parsed and parsed.path,
|
|
})
|
|
end, {
|
|
desc = "Read a jj file revision into the current buffer",
|
|
nargs = "?",
|
|
complete = function(arglead, _, _)
|
|
return complete_target(arglead)
|
|
end,
|
|
})
|
|
|
|
local function create_open_command(name, split, desc)
|
|
vim.api.nvim_create_user_command(name, function(opts)
|
|
local parsed = parser.parse_file_module_input(opts.args)
|
|
M.open_target({
|
|
rev = parsed and parsed.rev,
|
|
path = parsed and parsed.path,
|
|
split = split,
|
|
})
|
|
end, {
|
|
desc = desc,
|
|
nargs = "?",
|
|
complete = function(arglead, _, _)
|
|
return complete_target(arglead)
|
|
end,
|
|
})
|
|
end
|
|
|
|
create_open_command("Jedit", "current", "Open a jj file revision in the current window")
|
|
create_open_command("Jtabedit", "tab", "Open a jj file revision in a new tab")
|
|
create_open_command("Jsplit", "horizontal", "Open a jj file revision in a horizontal split")
|
|
create_open_command("Jvsplit", "vertical", "Open a jj file revision in a vertical split")
|
|
end
|
|
|
|
return M
|