diff --git a/.luacov b/.luacov new file mode 100644 index 0000000..ea7f402 --- /dev/null +++ b/.luacov @@ -0,0 +1,4 @@ +modules = { + ["gitlinker"] = "lua/gitlinker.lua", + ["gitlinker.*"] = "lua", +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..e938135 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + threshold: 90% + patch: + default: + threshold: 90% diff --git a/lua/gitlinker.lua b/lua/gitlinker.lua index 011a9e2..3e6bcd6 100644 --- a/lua/gitlinker.lua +++ b/lua/gitlinker.lua @@ -2,10 +2,11 @@ local git = require("gitlinker.git") local util = require("gitlinker.util") local logger = require("gitlinker.logger") ---- @alias Configs table ---- @type Configs +--- @alias Options table +--- @type Options local Defaults = { -- print message(git host url) in command line + -- --- @type boolean message = true, @@ -24,8 +25,8 @@ local Defaults = { }, }, - -- regex pattern based rules - --- @type table[] + -- pattern based rules + --- @type {[1]:table,[2]:table} pattern_rules = { { ["^git@github%.([_%.%-%w]+):([%.%-%w]+)/([_%.%-%w]+)%.git$"] = "https://github.%1/%2/%3/blob/", @@ -88,10 +89,10 @@ local Defaults = { file_log = false, } ---- @type Configs +--- @type Options local Configs = {} ---- @param option Configs? +--- @param option Options? local function setup(option) Configs = vim.tbl_deep_extend("force", Defaults, option or {}) @@ -112,7 +113,7 @@ local function setup(option) end -- key mapping - if key_mappings then + if type(key_mappings) == "table" then for k, v in pairs(key_mappings) do local opt = { noremap = true, @@ -162,8 +163,8 @@ end local function make_link_data(range) --- @type JobResult local root_result = git.get_root() - if not git.result_has_out(root_result) then - git.result_print_err(root_result, "not in a git repository") + if not root_result:has_out() then + root_result:print_err("not in a git repository") return nil end logger.debug( @@ -185,9 +186,8 @@ local function make_link_data(range) --- @type JobResult local remote_url_result = git.get_remote_url(remote) - if not git.result_has_out(remote_url_result) then - git.result_print_err( - remote_url_result, + if not remote_url_result:has_out() then + remote_url_result:print_err( "failed to get remote url by remote '" .. remote .. "'" ) return nil @@ -221,9 +221,8 @@ local function make_link_data(range) --- @type JobResult local file_in_rev_result = git.is_file_in_rev(buf_path_on_root, rev) - if git.result_has_err(file_in_rev_result) then - git.result_print_err( - file_in_rev_result, + if file_in_rev_result:has_err() then + file_in_rev_result:print_err( "'" .. buf_path_on_root .. "' does not exist in remote '" diff --git a/lua/gitlinker/git.lua b/lua/gitlinker/git.lua index ca23e4a..ff8a022 100644 --- a/lua/gitlinker/git.lua +++ b/lua/gitlinker/git.lua @@ -1,31 +1,38 @@ local logger = require("gitlinker.logger") +local spawn = require("gitlinker.spawn") --- @class JobResult --- @field stdout string[] --- @field stderr string[] +local JobResult = {} + +--- @return JobResult +function JobResult:new() + local o = { + stdout = {}, + stderr = {}, + } + setmetatable(o, self) + self.__index = self + return o +end ---- @param result JobResult --- @return boolean -local function result_has_out(result) - return result["stdout"] - and type(result["stdout"]) == "table" - and #result["stdout"] > 0 +function JobResult:has_out() + return type(self.stdout) == "table" and #self.stdout > 0 end ---- @param result JobResult --- @return boolean -local function result_has_err(result) - return result["stderr"] ~= nil - and type(result["stderr"]) == "table" - and #result["stderr"] > 0 +function JobResult:has_err() + return type(self.stderr) == "table" and #self.stderr > 0 end ---- @param result JobResult ---- @param default string|nil ---- @return nil -local function result_print_err(result, default) - if result_has_err(result) and #result.stderr > 0 then - logger.err("%s", result.stderr[1]) +--- @param default string +function JobResult:print_err(default) + if self:has_err() then + for _, e in ipairs(self.stderr) do + logger.err("%s", e) + end else logger.err("fatal: %s", default) end @@ -37,51 +44,19 @@ end --- @param cwd string|nil --- @return JobResult local function cmd(args, cwd) - local result = { stdout = {}, stderr = {} } - local job = vim.fn.jobstart(args, { - cwd = cwd, - on_stdout = function(chanid, data, name) - logger.debug( - "|cmd.on_stdout| args(%s):%s, cwd(%s):%s, chanid(%s):%s, data(%s):%s, name(%s):%s", - vim.inspect(type(args)), - vim.inspect(args), - vim.inspect(type(cwd)), - vim.inspect(cwd), - vim.inspect(type(chanid)), - vim.inspect(chanid), - vim.inspect(type(data)), - vim.inspect(data), - vim.inspect(type(name)), - vim.inspect(name) - ) - for _, line in ipairs(data) do - if string.len(line) > 0 then - table.insert(result.stdout, line) - end - end - end, - on_stderr = function(chanid, data, name) - logger.debug( - "|cmd.on_stderr| args(%s):%s, cwd(%s):%s, chanid(%s):%s, data(%s):%s, name(%s):%s", - vim.inspect(type(args)), - vim.inspect(args), - vim.inspect(type(cwd)), - vim.inspect(cwd), - vim.inspect(type(chanid)), - vim.inspect(chanid), - vim.inspect(type(data)), - vim.inspect(data), - vim.inspect(type(name)), - vim.inspect(name) - ) - for _, line in ipairs(data) do - if string.len(line) > 0 then - table.insert(result.stderr, line) - end - end - end, - }) - vim.fn.jobwait({ job }) + local result = JobResult:new() + + local sp = spawn.Spawn:make(args, function(line) + if type(line) == "string" then + table.insert(result.stdout, line) + end + end, function(line) + if type(line) == "string" then + table.insert(result.stderr, line) + end + end) --[[@as Spawn]] + sp:run() + logger.debug( "|cmd| args(%s):%s, cwd(%s):%s, result(%s):%s", vim.inspect(type(args)), @@ -123,7 +98,7 @@ end --- @package --- @param revspec string|nil ---- @return string|nil +--- @return string? local function get_rev(revspec) local result = cmd({ "git", "rev-parse", revspec }) logger.debug( @@ -133,7 +108,7 @@ local function get_rev(revspec) vim.inspect(type(result)), vim.inspect(result) ) - return result_has_out(result) and result.stdout[1] or nil + return result:has_out() and result.stdout[1] or nil end --- @package @@ -182,7 +157,7 @@ local function has_file_changed(file, rev) vim.inspect(type(result)), vim.inspect(result) ) - return result_has_out(result) + return result:has_out() end --- @package @@ -271,14 +246,14 @@ local function get_root() return result end ---- @return string|nil +--- @return string? local function get_branch_remote() -- origin/upstream --- @type JobResult local remote_result = get_remote() if type(remote_result.stdout) ~= "table" or #remote_result.stdout == 0 then - result_print_err(remote_result, "git repository has no remote") + remote_result:print_err("git repository has no remote") return nil end @@ -289,8 +264,8 @@ local function get_branch_remote() -- origin/linrongbin16/add-rule2 --- @type JobResult local upstream_branch_result = get_rev_name("@{u}") - if not result_has_out(upstream_branch_result) then - result_print_err(upstream_branch_result, "git branch has no remote") + if not upstream_branch_result:has_out() then + upstream_branch_result:print_err("git branch has no remote") return nil end @@ -324,11 +299,7 @@ local function get_branch_remote() return nil end ---- @type table local M = { - result_has_out = result_has_out, - result_has_err = result_has_err, - result_print_err = result_print_err, get_root = get_root, get_remote_url = get_remote_url, is_file_in_rev = is_file_in_rev, diff --git a/lua/gitlinker/spawn.lua b/lua/gitlinker/spawn.lua new file mode 100644 index 0000000..0d8a4b8 --- /dev/null +++ b/lua/gitlinker/spawn.lua @@ -0,0 +1,260 @@ +-- port from: https://github.com/linrongbin16/fzfx.nvim/main/lua/fzfx/utils.lua + +--- @param s string +--- @param t string +--- @param start integer? +--- @return integer? +local function string_find(s, t, start) + -- start = start or 1 + -- local result = vim.fn.stridx(s, t, start - 1) + -- return result >= 0 and (result + 1) or nil + + start = start or 1 + for i = start, #s do + local match = true + for j = 1, #t do + if i + j - 1 > #s then + match = false + break + end + local a = string.byte(s, i + j - 1) + local b = string.byte(t, j) + if a ~= b then + match = false + break + end + end + if match then + return i + end + end + return nil +end + +--- @param filename string +--- @param opts {trim:boolean?}|nil +--- @return string? +local function readfile(filename, opts) + opts = opts or { trim = true } + opts.trim = opts.trim == nil and true or opts.trim + + local f = io.open(filename, "r") + if f == nil then + return nil + end + local content = vim.trim(f:read("*a")) + f:close() + return content +end + +--- @param filename string +--- @return string[]? +local function readlines(filename) + local results = {} + for line in io.lines(filename) do + table.insert(results, line) + end + return results +end + +--- @alias SpawnLineConsumer fun(line:string):any +--- @class Spawn +--- @field cmds string[] +--- @field fn_out_line_consumer SpawnLineConsumer +--- @field fn_err_line_consumer SpawnLineConsumer +--- @field out_pipe uv_pipe_t +--- @field err_pipe uv_pipe_t +--- @field out_buffer string? +--- @field err_buffer string? +--- @field process_handle uv_process_t? +--- @field process_id integer|string|nil +--- @field _close_count integer +--- @field result {code:integer?,signal:integer?}? +local Spawn = {} + +--- @param line string +local function dummy_stderr_line_consumer(line) + -- if type(line) == "string" then + -- io.write(string.format("AsyncSpawn:_on_stderr:%s", vim.inspect(line))) + -- error(string.format("AsyncSpawn:_on_stderr:%s", vim.inspect(line))) + -- end +end + +--- @param cmds string[] +--- @param fn_out_line_consumer SpawnLineConsumer +--- @param fn_err_line_consumer SpawnLineConsumer? +--- @return Spawn? +function Spawn:make(cmds, fn_out_line_consumer, fn_err_line_consumer) + local out_pipe = vim.loop.new_pipe(false) --[[@as uv_pipe_t]] + local err_pipe = vim.loop.new_pipe(false) --[[@as uv_pipe_t]] + if not out_pipe or not err_pipe then + return nil + end + + local o = { + cmds = cmds, + fn_out_line_consumer = fn_out_line_consumer, + fn_err_line_consumer = fn_err_line_consumer + or dummy_stderr_line_consumer, + out_pipe = out_pipe, + err_pipe = err_pipe, + out_buffer = nil, + err_buffer = nil, + process_handle = nil, + process_id = nil, + _close_count = 0, + result = nil, + } + setmetatable(o, self) + self.__index = self + return o +end + +--- @param buffer string +--- @param fn_line_processor SpawnLineConsumer +--- @return integer +function Spawn:_consume_line(buffer, fn_line_processor) + local i = 1 + while i <= #buffer do + local newline_pos = string_find(buffer, "\n", i) + if not newline_pos then + break + end + local line = buffer:sub(i, newline_pos - 1) + fn_line_processor(line) + i = newline_pos + 1 + end + return i +end + +--- @param handle uv_handle_t +function Spawn:_close_handle(handle) + if handle and not handle:is_closing() then + handle:close(function() + self._close_count = self._close_count + 1 + if self._close_count >= 3 then + vim.loop.stop() + end + end) + end +end + +--- @param err string? +--- @param data string? +--- @return nil +function Spawn:_on_stdout(err, data) + if err then + self.out_pipe:read_stop() + self:_close_handle(self.out_pipe) + return + end + + if data then + -- append data to data_buffer + self.out_buffer = self.out_buffer and (self.out_buffer .. data) or data + self.out_buffer = self.out_buffer:gsub("\r\n", "\n") + -- foreach the data_buffer and find every line + local i = self:_consume_line(self.out_buffer, self.fn_out_line_consumer) + -- truncate the printed lines if found any + self.out_buffer = i <= #self.out_buffer + and self.out_buffer:sub(i, #self.out_buffer) + or nil + else + if self.out_buffer then + -- foreach the data_buffer and find every line + local i = + self:_consume_line(self.out_buffer, self.fn_out_line_consumer) + if i <= #self.out_buffer then + local line = self.out_buffer:sub(i, #self.out_buffer) + self.fn_out_line_consumer(line) + self.out_buffer = nil + end + end + self.out_pipe:read_stop() + self:_close_handle(self.out_pipe) + end +end + +--- @param err string? +--- @param data string? +--- @return nil +function Spawn:_on_stderr(err, data) + if err then + io.write( + string.format( + "AsyncSpawn:_on_stderr, err:%s, data:%s", + vim.inspect(err), + vim.inspect(data) + ) + ) + error( + string.format( + "AsyncSpawn:_on_stderr, err:%s, data:%s", + vim.inspect(err), + vim.inspect(data) + ) + ) + self.err_pipe:read_stop() + self:_close_handle(self.err_pipe) + return + end + + if data then + -- append data to data_buffer + self.err_buffer = self.err_buffer and (self.err_buffer .. data) or data + self.err_buffer = self.err_buffer:gsub("\r\n", "\n") + -- foreach the data_buffer and find every line + local i = self:_consume_line(self.err_buffer, self.fn_err_line_consumer) + -- truncate the printed lines if found any + self.err_buffer = i <= #self.err_buffer + and self.err_buffer:sub(i, #self.err_buffer) + or nil + else + if self.err_buffer then + -- foreach the data_buffer and find every line + local i = + self:_consume_line(self.err_buffer, self.fn_err_line_consumer) + if i <= #self.err_buffer then + local line = self.err_buffer:sub(i, #self.err_buffer) + self.fn_err_line_consumer(line) + self.err_buffer = nil + end + end + self.err_pipe:read_stop() + self:_close_handle(self.err_pipe) + end +end + +function Spawn:run() + self.process_handle, self.process_id = vim.loop.spawn(self.cmds[1], { + args = vim.list_slice(self.cmds, 2), + stdio = { nil, self.out_pipe, self.err_pipe }, + hide = true, + -- verbatim = true, + }, function(code, signal) + self.result = { code = code, signal = signal } + self:_close_handle(self.process_handle) + end) + + self.out_pipe:read_start(function(err, data) + self:_on_stdout(err, data) + end) + self.err_pipe:read_start(function(err, data) + self:_on_stderr(err, data) + end) + vim.loop.run() + + local max_timeout = 2 ^ 31 + vim.wait(max_timeout, function() + return self._close_count == 3 + end) +end + +local M = { + string_find = string_find, + readfile = readfile, + readlines = readlines, + Spawn = Spawn, +} + +return M diff --git a/test/spawn_spec.lua b/test/spawn_spec.lua new file mode 100644 index 0000000..3d843c1 --- /dev/null +++ b/test/spawn_spec.lua @@ -0,0 +1,230 @@ +local cwd = vim.fn.getcwd() + +describe("spawn", function() + local assert_eq = assert.is_equal + local assert_true = assert.is_true + local assert_false = assert.is_false + + before_each(function() + vim.api.nvim_command("cd " .. cwd) + end) + + local spawn = require("gitlinker.spawn") + + describe("[string_find]", function() + it("found", function() + assert_eq(spawn.string_find("abcdefg", "a"), 1) + assert_eq(spawn.string_find("abcdefg", "a", 1), 1) + assert_eq(spawn.string_find("abcdefg", "g"), 7) + assert_eq(spawn.string_find("abcdefg", "g", 1), 7) + assert_eq(spawn.string_find("abcdefg", "g", 7), 7) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--"), 6) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--", 1), 6) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--", 2), 6) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--", 3), 6) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--", 6), 6) + assert_eq(spawn.string_find("fzfx -w -- -g *.lua", "--"), 9) + assert_eq(spawn.string_find("fzfx -w -- -g *.lua", "--", 1), 9) + assert_eq(spawn.string_find("fzfx -w -- -g *.lua", "--", 2), 9) + assert_eq(spawn.string_find("fzfx -w ---g *.lua", "--", 8), 9) + assert_eq(spawn.string_find("fzfx -w ---g *.lua", "--", 9), 9) + end) + it("not found", function() + assert_eq(spawn.string_find("abcdefg", "a", 2), nil) + assert_eq(spawn.string_find("abcdefg", "a", 7), nil) + assert_eq(spawn.string_find("abcdefg", "g", 8), nil) + assert_eq(spawn.string_find("abcdefg", "g", 9), nil) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--", 7), nil) + assert_eq(spawn.string_find("fzfx -- -w -g *.lua", "--", 8), nil) + assert_eq(spawn.string_find("fzfx -w -- -g *.lua", "--", 10), nil) + assert_eq(spawn.string_find("fzfx -w -- -g *.lua", "--", 11), nil) + assert_eq(spawn.string_find("fzfx -w ---g *.lua", "--", 11), nil) + assert_eq(spawn.string_find("fzfx -w ---g *.lua", "--", 12), nil) + assert_eq(spawn.string_find("", "--"), nil) + assert_eq(spawn.string_find("", "--", 1), nil) + assert_eq(spawn.string_find("-", "--"), nil) + assert_eq(spawn.string_find("--", "---", 1), nil) + end) + end) + describe("[Spawn]", function() + it("open", function() + local sp = spawn.Spawn:make({ "cat", "README.md" }, function() end) --[[@as Spawn]] + assert_eq(type(sp), "table") + assert_eq(type(sp.cmds), "table") + assert_eq(#sp.cmds, 2) + assert_eq(sp.cmds[1], "cat") + assert_eq(sp.cmds[2], "README.md") + assert_eq(type(sp.out_pipe), "userdata") + assert_eq(type(sp.err_pipe), "userdata") + end) + it("consume line", function() + local content = spawn.readfile("README.md") --[[@as string]] + local lines = spawn.readlines("README.md") --[[@as table]] + + local i = 1 + local function process_line(line) + print(string.format("actual:[%d]%s\n", i, line)) + print(string.format("expect:[%d]%s\n", i, lines[i])) + assert_eq(type(line), "string") + assert_eq(line, lines[i]) + i = i + 1 + end + local sp = spawn.Spawn:make({ "cat", "README.md" }, process_line) --[[@as Spawn]] + local pos = sp:_consume_line(content, process_line) + if pos <= #content then + local line = content:sub(pos, #content) + process_line(line) + end + end) + it("stdout on newline", function() + local content = spawn.readfile("README.md") --[[@as string]] + local lines = spawn.readlines("README.md") --[[@as table]] + + local i = 1 + local function process_line(line) + -- print(string.format("[%d]%s\n", i, line)) + assert_eq(type(line), "string") + assert_eq(line, lines[i]) + i = i + 1 + end + local sp = spawn.Spawn:make({ "cat", "README.md" }, process_line) --[[@as Spawn]] + local content_splits = + vim.split(content, "\n", { plain = true, trimempty = false }) + for j, splits in ipairs(content_splits) do + sp:_on_stdout(nil, splits) + if j < #content_splits then + sp:_on_stdout(nil, "\n") + end + end + sp:_on_stdout(nil, nil) + assert_true(sp.out_pipe:is_closing()) + end) + it("stdout on whitespace", function() + local content = spawn.readfile("README.md") --[[@as string]] + local lines = spawn.readlines("README.md") --[[@as table]] + + local i = 1 + local function process_line(line) + -- print(string.format("[%d]%s\n", i, line)) + assert_eq(type(line), "string") + assert_eq(line, lines[i]) + i = i + 1 + end + local sp = spawn.Spawn:make({ "cat", "README.md" }, process_line) --[[@as Spawn]] + local content_splits = + vim.split(content, " ", { plain = true, trimempty = false }) + for j, splits in ipairs(content_splits) do + sp:_on_stdout(nil, splits) + if j < #content_splits then + sp:_on_stdout(nil, " ") + end + end + sp:_on_stdout(nil, nil) + assert_true(sp.out_pipe:is_closing()) + end) + for delimiter_i = 0, 25 do + -- lower case: a + local lower_char = string.char(97 + delimiter_i) + it(string.format("stdout on %s", lower_char), function() + local content = spawn.readfile("README.md") --[[@as string]] + local lines = spawn.readlines("README.md") --[[@as table]] + + local i = 1 + local function process_line(line) + -- print(string.format("[%d]%s\n", i, line)) + assert_eq(type(line), "string") + assert_eq(line, lines[i]) + i = i + 1 + end + local sp = + spawn.Spawn:make({ "cat", "README.md" }, process_line) --[[@as Spawn]] + local content_splits = vim.split( + content, + lower_char, + { plain = true, trimempty = false } + ) + for j, splits in ipairs(content_splits) do + sp:_on_stdout(nil, splits) + if j < #content_splits then + sp:_on_stdout(nil, lower_char) + end + end + sp:_on_stdout(nil, nil) + assert_true(sp.out_pipe:is_closing()) + end) + -- upper case: A + local upper_char = string.char(65 + delimiter_i) + it(string.format("stdout on %s", upper_char), function() + local content = spawn.readfile("README.md") --[[@as string]] + local lines = spawn.readlines("README.md") --[[@as table]] + + local i = 1 + local function process_line(line) + -- print(string.format("[%d]%s\n", i, line)) + assert_eq(type(line), "string") + assert_eq(line, lines[i]) + i = i + 1 + end + local sp = + spawn.Spawn:make({ "cat", "README.md" }, process_line) --[[@as Spawn]] + local content_splits = vim.split( + content, + upper_char, + { plain = true, trimempty = false } + ) + for j, splits in ipairs(content_splits) do + sp:_on_stdout(nil, splits) + if j < #content_splits then + sp:_on_stdout(nil, upper_char) + end + end + sp:_on_stdout(nil, nil) + assert_true(sp.out_pipe:is_closing()) + end) + end + it("stderr", function() + local sp = spawn.Spawn:make({ "cat", "README.md" }, function() end) --[[@as Spawn]] + sp:_on_stderr(nil, nil) + assert_true(sp.err_pipe:is_closing()) + end) + it("iterate on README.md", function() + local lines = spawn.readlines("README.md") --[[@as table]] + + local i = 1 + local function process_line(line) + print(string.format("[%d]%s\n", i, line)) + assert_eq(type(line), "string") + assert_eq(lines[i], line) + i = i + 1 + end + + local sp = spawn.Spawn:make({ "cat", "README.md" }, process_line) --[[@as Spawn]] + sp:run() + end) + it("iterate on lua/gitlinker.lua", function() + local lines = spawn.readlines("lua/gitlinker.lua") --[[@as table]] + + local i = 1 + local function process_line(line) + print(string.format("[%d]%s\n", i, line)) + assert_eq(type(line), "string") + assert_eq(lines[i], line) + i = i + 1 + end + + local sp = + spawn.Spawn:make({ "cat", "lua/gitlinker.lua" }, process_line) --[[@as Spawn]] + sp:run() + end) + it("close handle", function() + local sp = spawn.Spawn:make( + { "cat", "lua/gitlinker.lua" }, + function() end + ) --[[@as Spawn]] + sp:run() + assert_true(sp.process_handle ~= nil) + sp:_close_handle(sp.process_handle) + assert_true(sp.process_handle:is_closing()) + end) + end) +end)