refactor: vendor vim.system

This commit is contained in:
Lewis Russell 2023-09-24 17:30:11 +01:00 committed by Lewis Russell
parent badaef04f8
commit 19654d963e
7 changed files with 420 additions and 167 deletions

View File

@ -7,6 +7,10 @@ on:
branches: [ main ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
commit_lint:
runs-on: ubuntu-latest
@ -16,6 +20,7 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
@ -40,6 +45,9 @@ jobs:
- name: Download Nvim
run: make nvim
- name: Download Busted
run: make busted
- name: Run Test
run: make test

View File

@ -51,6 +51,9 @@ LUAROCKS := luarocks --lua-version=5.1 --tree .luarocks
.luarocks/bin/busted:
$(LUAROCKS) install busted
.PHONY: busted
busted: .luarocks/bin/busted
.PHONY: test
test: $(NVIM_RUNNER) $(NVIM_TEST) .luarocks/bin/busted
eval $$($(LUAROCKS) path) && $(NVIM_RUNNER)/bin/nvim -ll test/busted/runner.lua -v \

View File

@ -3,7 +3,7 @@ local scheduler = require('gitsigns.async').scheduler
local log = require('gitsigns.debug.log')
local util = require('gitsigns.util')
local subprocess = require('gitsigns.subprocess')
local system = require('gitsigns.system').system
local gs_config = require('gitsigns.config')
local config = gs_config.config
@ -19,6 +19,9 @@ local error_once = require('gitsigns.message').error_once
local M = {}
--- @type fun(cmd: string[], opts?: SystemOpts): vim.SystemCompleted
local asystem = async.wrap(system, 3)
--- @param file string
--- @return boolean
local function in_git_dir(file)
@ -104,16 +107,15 @@ function M._set_version(version)
return
end
--- @type integer, integer, string?, string?
local _, _, stdout, stderr = async.wait(2, subprocess.run_job, {
command = 'git',
args = { '--version' },
})
--- @type vim.SystemCompleted
local obj = asystem({ 'git', '--version' })
local stdout = obj.stdout
local line = vim.split(stdout or '', '\n', { plain = true })[1]
if not line then
err("Unable to detect git version as 'git --version' failed to return anything")
eprint(stderr)
eprint(obj.stderr)
return
end
assert(type(line) == 'string', 'Unexpected output: ' .. line)
@ -122,14 +124,9 @@ function M._set_version(version)
M.version = parse_version(parts[3])
end
--- @class Gitsigns.Git.JobSpec
--- @class Gitsigns.Git.JobSpec : SystemOpts
--- @field command? string
--- @field cwd? string
--- @field writer? string[] | string
--- @field suppress_stderr? boolean
--- @field raw? boolean Do not strip trailing newlines from stdout
--- @field text? boolean Convert CRLF to LF
--- @field args? string[]
--- @field ignore_error? boolean
--- @param args string[]
--- @param spec? Gitsigns.Git.JobSpec
@ -138,39 +135,35 @@ local git_command = async.create(function(args, spec)
if not M.version then
M._set_version(config._git_version)
end
spec = spec or {}
spec.command = spec.command or 'git'
spec.args = spec.command == 'git'
and {
'--no-pager',
'--literal-pathspecs',
'-c',
'gc.auto=0', -- Disable auto-packing which emits messages to stderr
unpack(args),
}
or args
if not spec.cwd and not uv.cwd() then
spec.cwd = vim.env.HOME
local cmd = {
spec.command or 'git',
'--no-pager',
'--literal-pathspecs',
'-c',
'gc.auto=0', -- Disable auto-packing which emits messages to stderr
unpack(args),
}
if spec.text == nil then
spec.text = true
end
--- @type integer, integer, string?, string?
local _, _, stdout, stderr = async.wait(2, subprocess.run_job, spec)
--- @type vim.SystemCompleted
local obj = asystem(cmd, spec)
local stdout = obj.stdout
local stderr = obj.stderr
if not spec.suppress_stderr then
if stderr then
local cmd_str = table.concat({ spec.command, unpack(args) }, ' ')
log.eprintf("Received stderr when running command\n'%s':\n%s", cmd_str, stderr)
end
end
if stdout and spec.text then
stdout = stdout:gsub('\r\n', '\n')
if not spec.ignore_error and obj.code > 0 then
local cmd_str = table.concat(cmd, ' ')
log.eprintf("Received exit code %d when running command\n'%s':\n%s", obj.code, cmd_str, stderr)
end
local stdout_lines = vim.split(stdout or '', '\n', { plain = true })
if not spec.raw then
if spec.text then
-- If stdout ends with a newline, then remove the final empty string after
-- the split
if stdout_lines[#stdout_lines] == '' then
@ -185,6 +178,10 @@ local git_command = async.create(function(args, spec)
end
end
if stderr == '' then
stderr = nil
end
return stdout_lines, stderr
end, 2)
@ -205,6 +202,9 @@ function M.diff(file_cmp, file_buf, indent_heuristic, diff_algo)
'--unified=0',
file_cmp,
file_buf,
}, {
-- git-diff implies --exit-code
ignore_error = true,
})
end
@ -220,7 +220,7 @@ local function process_abbrev_head(gitdir, head_str, path, cmd)
if head_str == 'HEAD' then
local short_sha = git_command({ 'rev-parse', '--short', 'HEAD' }, {
command = cmd or 'git',
suppress_stderr = true,
ignore_error = true,
cwd = path,
})[1] or ''
if log.debug_mode and short_sha ~= '' then
@ -243,7 +243,9 @@ local cygpath_convert ---@type fun(path: string): string
if has_cygpath then
cygpath_convert = function(path)
return git_command({ '-aw', path }, { command = 'cygpath' })[1]
--- @type vim.SystemCompleted
local obj = asystem({ 'cygpath', '-aw', path })
return obj.stdout
end
end
@ -298,8 +300,8 @@ function M.get_repo_info(path, cmd, gitdir, toplevel)
})
local results = git_command(args, {
command = cmd or 'git',
suppress_stderr = true,
command = cmd,
ignore_error = true,
cwd = toplevel or path,
})
@ -375,7 +377,7 @@ end
--- @param encoding? string
--- @return string[] stdout, string? stderr
function Repo:get_show_text(object, encoding)
local stdout, stderr = self:command({ 'show', object }, { raw = true, suppress_stderr = true })
local stdout, stderr = self:command({ 'show', object }, { text = false, ignore_error = true })
if encoding and encoding ~= 'utf-8' and iconv_supported(encoding) then
for i, l in ipairs(stdout) do
@ -398,7 +400,7 @@ end
function Repo.new(dir, gitdir, toplevel)
local self = setmetatable({}, { __index = Repo })
self.username = git_command({ 'config', 'user.name' })[1]
self.username = git_command({ 'config', 'user.name' }, { ignore_error = true })[1]
local info = M.get_repo_info(dir, nil, gitdir, toplevel)
for k, v in
pairs(info --[[@as table<string,any>]])
@ -479,10 +481,10 @@ function Obj:file_info(file, silent)
'--exclude-standard',
'--eol',
file or self.file,
}, { suppress_stderr = true })
}, { ignore_error = true })
if stderr and not silent then
-- Suppress_stderr for the cases when we run:
-- ignore_error for the cases when we run:
-- git ls-files --others exists/nonexist
if not stderr:match('^warning: could not open directory .*: No such file or directory') then
log.eprint(stderr)
@ -637,7 +639,7 @@ function Obj:run_blame(lines, lnum, ignore_whitespace)
vim.list_extend(args, { '--ignore-revs-file', ignore_file })
end
local results, stderr = self:command(args, { writer = lines, suppress_stderr = true })
local results, stderr = self:command(args, { stdin = lines, ignore_error = true })
if stderr then
error_once('Error running git-blame: ' .. stderr)
return
@ -763,7 +765,7 @@ function Obj:stage_lines(lines)
'--path',
self.relpath,
'--stdin',
}, { writer = lines })
}, { stdin = lines })
local new_object = stdout[1]
@ -797,7 +799,7 @@ function Obj.stage_hunks(self, hunks, invert)
'--unidiff-zero',
'-',
}, {
writer = patch,
stdin = patch,
})
end

View File

@ -13,7 +13,7 @@ local log = require('gitsigns.debug.log')
local dprint = log.dprint
local dprintf = log.dprintf
local subprocess = require('gitsigns.subprocess')
local system = require('gitsigns.system')
local util = require('gitsigns.util')
local run_diff = require('gitsigns.diff')
@ -512,7 +512,7 @@ M.update = throttle_by_id(function(bufnr)
update_cnt = update_cnt + 1
dprintf('updates: %s, jobs: %s', update_cnt, subprocess.job_cnt)
dprintf('updates: %s, jobs: %s', update_cnt, system.job_cnt)
end, true)
--- @param bufnr integer

View File

@ -1,116 +0,0 @@
local log = require('gitsigns.debug.log')
local uv = vim.loop
local M = {}
M.job_cnt = 0
--- @class Gitsigns.JobSpec
--- @field command string
--- @field args string[]
--- @field cwd string
--- @field writer string[] | string
--- @param ... uv.uv_pipe_t
local function try_close(...)
for i = 1, select('#', ...) do
local pipe = select(i, ...)
if pipe and not pipe:is_closing() then
pipe:close()
end
end
end
--- @param pipe uv.uv_pipe_t
--- @param x string[]|string
local function handle_writer(pipe, x)
if type(x) == 'table' then
for i, v in ipairs(x) do
pipe:write(v)
if i ~= #x then
pipe:write('\n')
else
pipe:write('\n', function()
try_close(pipe)
end)
end
end
elseif x then
-- write is string
pipe:write(x, function()
try_close(pipe)
end)
end
end
--- @param pipe uv.uv_pipe_t
--- @param output string[]
local function handle_reader(pipe, output)
pipe:read_start(function(err, data)
if err then
log.eprint(err)
end
if data then
output[#output + 1] = data
else
try_close(pipe)
end
end)
end
--- @param obj Gitsigns.JobSpec
--- @param callback fun(_: integer, _: integer, _: string?, _: string?)
function M.run_job(obj, callback)
local __FUNC__ = 'run_job'
if log.debug_mode then
local cmd = obj.command .. ' ' .. table.concat(obj.args, ' ')
log.dprint(cmd)
end
local stdout_data = {}
local stderr_data = {}
local stdout = assert(uv.new_pipe(false))
local stderr = assert(uv.new_pipe(false))
local stdin --- @type uv.uv_pipe_t?
if obj.writer then
stdin = assert(uv.new_pipe(false))
end
--- @type uv.uv_process_t?, integer|string
local handle, _pid
--- @diagnostic disable-next-line:missing-fields
handle, _pid = uv.spawn(obj.command, {
args = obj.args,
stdio = { stdin, stdout, stderr },
cwd = obj.cwd,
}, function(code, signal)
if handle then
handle:close()
end
stdout:read_stop()
stderr:read_stop()
try_close(stdin, stdout, stderr)
local stdout_result = #stdout_data > 0 and table.concat(stdout_data) or nil
local stderr_result = #stderr_data > 0 and table.concat(stderr_data) or nil
callback(code, signal, stdout_result, stderr_result)
end)
if not handle then
try_close(stdin, stdout, stderr)
error(debug.traceback('Failed to spawn process: ' .. vim.inspect(obj)))
end
handle_reader(stdout, stdout_data)
handle_reader(stderr, stderr_data)
if stdin then
handle_writer(stdin, obj.writer)
end
M.job_cnt = M.job_cnt + 1
end
return M

22
lua/gitsigns/system.lua Normal file
View File

@ -0,0 +1,22 @@
local log = require('gitsigns.debug.log')
local M = {
job_cnt = 0,
}
local system = vim.system or require('gitsigns.system.compat')
--- @param cmd string[]
--- @param opts SystemOpts
--- @param on_exit fun(obj: vim.SystemCompleted)
--- @return vim.SystemObj
function M.system(cmd, opts, on_exit)
local __FUNC__ = 'run_job'
if log.debug_mode then
log.dprint(table.concat(cmd, ' '))
end
M.job_cnt = M.job_cnt + 1
return system(cmd, opts, on_exit)
end
return M

View File

@ -0,0 +1,334 @@
local uv = vim.loop
--- @param handle uv.uv_handle_t?
local function close_handle(handle)
if handle and not handle:is_closing() then
handle:close()
end
end
--- @type vim.SystemSig
local SIG = {
HUP = 1, -- Hangup
INT = 2, -- Interrupt from keyboard
KILL = 9, -- Kill signal
TERM = 15, -- Termination signal
-- STOP = 17,19,23 -- Stop the process
}
--- @param state vim.SystemState
local function close_handles(state)
close_handle(state.handle)
close_handle(state.stdin)
close_handle(state.stdout)
close_handle(state.stderr)
close_handle(state.timer)
end
--- @class Pckr.SystemObj : vim.SystemObj
--- @field private _state vim.SystemState
local SystemObj = {}
--- @param state vim.SystemState
--- @return vim.SystemObj
local function new_systemobj(state)
return setmetatable({
pid = state.pid,
_state = state,
}, { __index = SystemObj })
end
--- @param signal integer|string
function SystemObj:kill(signal)
self._state.handle:kill(signal)
end
--- @package
--- @param signal? vim.SystemSig
function SystemObj:_timeout(signal)
self._state.done = 'timeout'
self:kill(signal or SIG.TERM)
end
local MAX_TIMEOUT = 2 ^ 31
--- @param timeout? integer
--- @return vim.SystemCompleted
function SystemObj:wait(timeout)
local state = self._state
local done = vim.wait(timeout or state.timeout or MAX_TIMEOUT, function()
return state.result ~= nil
end)
if not done then
-- Send sigkill since this cannot be caught
self:_timeout(SIG.KILL)
vim.wait(timeout or state.timeout or MAX_TIMEOUT, function()
return state.result ~= nil
end)
end
return state.result
end
--- @param data string[]|string|nil
function SystemObj:write(data)
local stdin = self._state.stdin
if not stdin then
error('stdin has not been opened on this object')
end
if type(data) == 'table' then
for _, v in ipairs(data) do
stdin:write(v)
stdin:write('\n')
end
elseif type(data) == 'string' then
stdin:write(data)
elseif data == nil then
-- Shutdown the write side of the duplex stream and then close the pipe.
-- Note shutdown will wait for all the pending write requests to complete
-- TODO(lewis6991): apparently shutdown doesn't behave this way.
-- (https://github.com/neovim/neovim/pull/17620#discussion_r820775616)
stdin:write('', function()
stdin:shutdown(function()
if stdin then
stdin:close()
end
end)
end)
end
end
--- @return boolean
function SystemObj:is_closing()
local handle = self._state.handle
return handle == nil or handle:is_closing() or false
end
--- @param output fun(err:string?, data: string?)|false
--- @return uv.uv_stream_t?
--- @return fun(err:string?, data: string?)? Handler
local function setup_output(output)
if output == nil then
return assert(uv.new_pipe(false)), nil
end
if type(output) == 'function' then
return assert(uv.new_pipe(false)), output
end
assert(output == false)
return nil, nil
end
--- @param input string|string[]|true|nil
--- @return uv.uv_stream_t?
--- @return string|string[]?
local function setup_input(input)
if not input then
return
end
local towrite --- @type string|string[]?
if type(input) == 'string' or type(input) == 'table' then
towrite = input
end
return assert(uv.new_pipe(false)), towrite
end
local environ = vim.fn.environ()
environ['NVIM'] = vim.v.servername
environ['NVIM_LISTEN_ADDRESS'] = nil
--- uv.spawn will completely overwrite the environment
--- when we just want to modify the existing one, so
--- make sure to prepopulate it with the current env.
--- @param env? table<string,string|number>
--- @param clear_env? boolean
--- @return string[]?
local function setup_env(env, clear_env)
if clear_env then
return env
end
--- @type table<string,string|number>
env = vim.tbl_extend('force', environ, env or {})
local renv = {} --- @type string[]
for k, v in pairs(env) do
renv[#renv + 1] = string.format('%s=%s', k, tostring(v))
end
return renv
end
--- @param stream uv.uv_stream_t
--- @param text? boolean
--- @param bucket string[]
--- @return fun(err: string?, data: string?)
local function default_handler(stream, text, bucket)
return function(err, data)
if err then
error(err)
end
if data ~= nil then
if text then
bucket[#bucket + 1] = data:gsub('\r\n', '\n')
else
bucket[#bucket + 1] = data
end
else
stream:read_stop()
stream:close()
end
end
end
--- @param cmd string
--- @param opts uv.spawn.options
--- @param on_exit fun(code: integer, signal: integer)
--- @param on_error fun()
--- @return uv.uv_process_t, integer
local function spawn(cmd, opts, on_exit, on_error)
local handle, pid_or_err = uv.spawn(cmd, opts, on_exit)
if not handle then
on_error()
error(pid_or_err)
end
return handle, pid_or_err --[[@as integer]]
end
--- @param timeout integer
--- @param cb fun()
--- @return uv.uv_timer_t
local function timer_oneshot(timeout, cb)
local timer = assert(uv.new_timer())
timer:start(timeout, 0, function()
timer:stop()
timer:close()
cb()
end)
return timer
end
--- @param state vim.SystemState
--- @param code integer
--- @param signal integer
--- @param on_exit fun(result: vim.SystemCompleted)?
local function _on_exit(state, code, signal, on_exit)
close_handles(state)
local check = assert(uv.new_check())
check:start(function()
for _, pipe in pairs({ state.stdin, state.stdout, state.stderr }) do
if not pipe:is_closing() then
return
end
end
check:stop()
check:close()
if state.done == nil then
state.done = true
end
if (code == 0 or code == 1) and state.done == 'timeout' then
-- Unix: code == 0
-- Windows: code == 1
code = 124
end
local stdout_data = state.stdout_data
local stderr_data = state.stderr_data
state.result = {
code = code,
signal = signal,
stdout = stdout_data and table.concat(stdout_data) or nil,
stderr = stderr_data and table.concat(stderr_data) or nil,
}
if on_exit then
on_exit(state.result)
end
end)
end
--- Run a system command
---
--- @param cmd string[]
--- @param opts? SystemOpts
--- @param on_exit? fun(out: vim.SystemCompleted)
--- @return vim.SystemObj
local function system(cmd, opts, on_exit)
local __FUNC__ = 'run_job'
vim.validate({
cmd = { cmd, 'table' },
opts = { opts, 'table', true },
on_exit = { on_exit, 'function', true },
})
opts = opts or {}
local stdout, stdout_handler = setup_output(opts.stdout)
local stderr, stderr_handler = setup_output(opts.stderr)
local stdin, towrite = setup_input(opts.stdin)
--- @type vim.SystemState
local state = {
done = false,
cmd = cmd,
timeout = opts.timeout,
stdin = stdin,
stdout = stdout,
stderr = stderr,
}
--- @diagnostic disable-next-line:missing-fields
state.handle, state.pid = spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { stdin, stdout, stderr },
cwd = opts.cwd,
--- @diagnostic disable-next-line:assign-type-mismatch
env = setup_env(opts.env, opts.clear_env),
detached = opts.detach,
hide = true,
}, function(code, signal)
_on_exit(state, code, signal, on_exit)
end, function()
close_handles(state)
end)
if stdout then
state.stdout_data = {}
stdout:read_start(stdout_handler or default_handler(stdout, opts.text, state.stdout_data))
end
if stderr then
state.stderr_data = {}
stderr:read_start(stderr_handler or default_handler(stderr, opts.text, state.stderr_data))
end
local obj = new_systemobj(state)
if towrite then
obj:write(towrite)
obj:write(nil) -- close the stream
end
if opts.timeout then
state.timer = timer_oneshot(opts.timeout, function()
if state.handle and state.handle:is_active() then
obj:_timeout()
end
end)
end
return obj
end
return system