diff --git a/.github/workflows/bump-commons.yml b/.github/workflows/bump-commons.yml index e1d6bde1..79dc442a 100644 --- a/.github/workflows/bump-commons.yml +++ b/.github/workflows/bump-commons.yml @@ -19,10 +19,10 @@ jobs: git clone --depth=1 https://github.com/linrongbin16/commons.nvim.git ~/.commons.nvim rm -rf ./lua/gitlinker/commons mkdir -p ./lua/gitlinker/commons - cp -rf ~/.commons.nvim/lua/commons/*.lua ./lua/gitlinker/commons - cp ~/.commons.nvim/version.txt ./lua/gitlinker/commons/version.txt + cp -rf ~/.commons.nvim/lua/commons ./lua/gitlinker cd ./lua/gitlinker/commons find . -type f -name '*.lua' -exec sed -i 's/require("commons/require("gitlinker.commons/g' {} \; + find . -type f -name '*.lua' -exec sed -i "s/require('commons/require('gitlinker.commons/g" {} \; - uses: stefanzweifel/git-auto-commit-action@v5 if: ${{ github.ref != 'refs/heads/master' }} with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97a85791..807a26c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,6 @@ jobs: selene --config selene.toml ./lua unit_test: name: Unit Test - needs: - - lint strategy: matrix: nvim_version: [stable, nightly] diff --git a/.luarc.json b/.luarc.json index 44964069..c040b727 100644 --- a/.luarc.json +++ b/.luarc.json @@ -7,7 +7,10 @@ "jit", "utf8" ], - "diagnostics.disable": [], + "diagnostics.disable": [ + "luadoc-miss-symbol", + "missing-return" + ], "runtime.version": "LuaJIT", "workspace.checkThirdParty": "Disable" } diff --git a/README.md b/README.md index 101feee5..ff19a3ac 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@

Neovim -commons.nvim luarocks ci.yml

diff --git a/lua/gitlinker.lua b/lua/gitlinker.lua index 81437d18..ed34d690 100644 --- a/lua/gitlinker.lua +++ b/lua/gitlinker.lua @@ -1,10 +1,9 @@ local tbl = require("gitlinker.commons.tbl") local str = require("gitlinker.commons.str") local num = require("gitlinker.commons.num") -local LogLevels = require("gitlinker.commons.logging").LogLevels -local logging = require("gitlinker.commons.logging") +local log = require("gitlinker.commons.log") +local async = require("gitlinker.commons.async") -local async = require("gitlinker.async") local configs = require("gitlinker.configs") local range = require("gitlinker.range") local linker = require("gitlinker.linker") @@ -20,8 +19,6 @@ local function _url_template_engine(lk, template) return nil end - local logger = logging.get("gitlinker") - --- @alias gitlinker.UrlTemplateExpr {plain:boolean,body:string} --- @type gitlinker.UrlTemplateExpr[] local exprs = {} @@ -36,7 +33,7 @@ local function _url_template_engine(lk, template) end table.insert(exprs, { plain = true, body = string.sub(template, i, open_pos - 1) }) local close_pos = str.find(template, CLOSE_BRACE, open_pos + string.len(OPEN_BRACE)) - logger:ensure( + log.ensure( type(close_pos) == "number" and close_pos > open_pos, string.format( "failed to evaluate url template(%s) at pos %d", @@ -82,7 +79,7 @@ local function _url_template_engine(lk, template) DEFAULT_BRANCH = str.not_empty(lk.default_branch) and lk.default_branch or "", CURRENT_BRANCH = str.not_empty(lk.current_branch) and lk.current_branch or "", }) - logger:debug( + log.debug( string.format( "|_url_template_engine| exp:%s, lk:%s, evaluated:%s", vim.inspect(exp.body), @@ -107,8 +104,7 @@ local function _worker(lk, p, r) elseif type(r) == "string" then return _url_template_engine(lk, r) else - local logger = logging.get("gitlinker") - logger:ensure( + log.ensure( false, string.format("unsupported router %s on pattern %s", vim.inspect(r), vim.inspect(p)) ) @@ -121,17 +117,16 @@ end --- @param lk gitlinker.Linker --- @return string? local function _router(router_type, lk) - local logger = logging.get("gitlinker") local confs = configs.get() - logger:ensure( + log.ensure( type(confs._routers[router_type]) == "table", string.format("unknown router type %s!", vim.inspect(router_type)) ) - logger:ensure( + log.ensure( type(confs._routers[router_type].list_routers) == "table", string.format("invalid router type %s! 'list_routers' missing.", vim.inspect(router_type)) ) - logger:ensure( + log.ensure( type(confs._routers[router_type].map_routers) == "table", string.format("invalid router type %s! 'map_routers' missing.", vim.inspect(router_type)) ) @@ -181,7 +176,7 @@ local function _router(router_type, lk) end end end - logger:ensure( + log.ensure( false, string.format("%s not support, please bind it in 'router'!", vim.inspect(lk.host)) ) @@ -201,9 +196,9 @@ local function _blame(lk) end --- @param opts {action:gitlinker.Action|boolean,router:gitlinker.Router,lstart:integer,lend:integer,message:boolean?,highlight_duration:integer?,remote:string?,file:string?,rev:string?} +--- @return string? local _link = function(opts) local confs = configs.get() - local logger = logging.get("gitlinker") -- logger.debug("[link] merged opts: %s", vim.inspect(opts)) local lk = linker.make_linker(opts.remote, opts.file, opts.rev) @@ -221,7 +216,8 @@ local _link = function(opts) lk.rev = opts.rev end - async.scheduler() + async.await(1, vim.schedule) + local ok, url = pcall(opts.router, lk, true) -- logger:debug( -- "|link| ok:%s, url:%s, router:%s", @@ -229,7 +225,7 @@ local _link = function(opts) -- vim.inspect(url), -- vim.inspect(opts.router) -- ) - logger:ensure( + log.ensure( ok and str.not_empty(url), string.format( "fatal: failed to generate permanent url from remote (%s): %s", @@ -255,7 +251,7 @@ local _link = function(opts) if type(opts.message) == "boolean" then message = opts.message end - logger:debug( + log.debug( string.format( "|_link| message:%s, opts:%s, confs:%s", vim.inspect(message), @@ -272,8 +268,11 @@ local _link = function(opts) return url end ---- @type fun(opts:{action:gitlinker.Action?,router:gitlinker.Router,lstart:integer,lend:integer,remote:string?,file:string?,rev:string?}):string? -local _void_link = async.void(_link) +--- @param opts {action:gitlinker.Action?,router:gitlinker.Router,lstart:integer,lend:integer,remote:string?,file:string?,rev:string?} +--- @return string? +local _void_link = function(opts) + return async.run(_link, opts) +end --- @param args string? --- @return {router_type:string,remote:string?,file:string?,rev:string?} @@ -309,12 +308,12 @@ local function setup(opts) local confs = configs.setup(opts) -- logger - logging.setup({ + log.setup({ name = "gitlinker", - level = confs.debug and LogLevels.DEBUG or LogLevels.INFO, - console_log = confs.console_log, - file_log = confs.file_log, - file_log_name = "gitlinker.log", + level = confs.debug and vim.log.levels.DEBUG or vim.log.levels.INFO, + use_console = confs.console_log, + use_file = confs.file_log, + file_name = "gitlinker.log", }) -- command diff --git a/lua/gitlinker/async.lua b/lua/gitlinker/async.lua deleted file mode 100644 index 59d939f3..00000000 --- a/lua/gitlinker/async.lua +++ /dev/null @@ -1,223 +0,0 @@ ----@diagnostic disable: luadoc-miss-module-name, undefined-doc-name ---- Small async library for Neovim plugins ---- @module async --- Store all the async threads in a weak table so we don't prevent them from --- being garbage collected -local handles = setmetatable({}, { __mode = "k" }) -local M = {} --- Note: coroutine.running() was changed between Lua 5.1 and 5.2: --- - 5.1: Returns the running coroutine, or nil when called by the main thread. --- - 5.2: Returns the running coroutine plus a boolean, true when the running --- coroutine is the main one. --- --- For LuaJIT, 5.2 behaviour is enabled with LUAJIT_ENABLE_LUA52COMPAT --- --- We need to handle both. ---- Returns whether the current execution context is async. ---- ---- @treturn boolean? -function M.running() - local current = coroutine.running() - if current and handles[current] then - return true - end -end -local function is_Async_T(handle) - if - handle - and type(handle) == "table" - and vim.is_callable(handle.cancel) - and vim.is_callable(handle.is_cancelled) - then - return true - end -end -local Async_T = {} --- Analogous to uv.close -function Async_T:cancel(cb) - -- Cancel anything running on the event loop - if self._current and not self._current:is_cancelled() then - self._current:cancel(cb) - end -end -function Async_T.new(co) - local handle = setmetatable({}, { __index = Async_T }) - handles[co] = handle - return handle -end --- Analogous to uv.is_closing -function Async_T:is_cancelled() - return self._current and self._current:is_cancelled() -end ---- Run a function in an async context. ---- @tparam function func ---- @tparam function callback ---- @tparam any ... Arguments for func ---- @treturn async_t Handle -function M.run(func, callback, ...) - local co = coroutine.create(func) - local handle = Async_T.new(co) - local function step(...) - local ret = { coroutine.resume(co, ...) } - local ok = ret[1] - if not ok then - local err = ret[2] - error( - string.format("The coroutine failed with this message:\n%s\n%s", err, debug.traceback(co)) - ) - end - if coroutine.status(co) == "dead" then - if callback then - callback(unpack(ret, 4, table.maxn(ret))) - end - return - end - local nargs, fn = ret[2], ret[3] - local args = { select(4, unpack(ret)) } - assert(type(fn) == "function", "type error :: expected func") - args[nargs] = step - local r = fn(unpack(args, 1, nargs)) - if is_Async_T(r) then - handle._current = r - end - end - step(...) - return handle -end -local function wait(argc, func, ...) - -- Always run the wrapped functions in xpcall and re-raise the error in the - -- coroutine. This makes pcall work as normal. - local function pfunc(...) - local args = { ... } - local cb = args[argc] - args[argc] = function(...) - cb(true, ...) - end - xpcall(func, function(err) - cb(false, err, debug.traceback()) - end, unpack(args, 1, argc)) - end - local ret = { coroutine.yield(argc, pfunc, ...) } - local ok = ret[1] - if not ok then - local _, err, traceback = unpack(ret) - error(string.format("Wrapped function failed: %s\n%s", err, traceback)) - end - return unpack(ret, 2, table.maxn(ret)) -end ---- Wait on a callback style function ---- ---- @tparam integer? argc The number of arguments of func. ---- @tparam function func callback style function to execute ---- @tparam any ... Arguments for func -function M.wait(...) - if type(select(1, ...)) == "number" then - return wait(...) - end - -- Assume argc is equal to the number of passed arguments. - return wait(select("#", ...) - 1, ...) -end ---- Use this to create a function which executes in an async context but ---- called from a non-async context. Inherently this cannot return anything ---- since it is non-blocking ---- @tparam function func ---- @tparam number argc The number of arguments of func. Defaults to 0 ---- @tparam boolean strict Error when called in non-async context ---- @treturn function(...):async_t -function M.create(func, argc, strict) - argc = argc or 0 - return function(...) - if M.running() then - if strict then - error("This function must run in a non-async context") - end - return func(...) - end - local callback = select(argc + 1, ...) - return M.run(func, callback, unpack({ ... }, 1, argc)) - end -end ---- Create a function which executes in an async context but ---- called from a non-async context. ---- @tparam function func ---- @tparam boolean strict Error when called in non-async context -function M.void(func, strict) - return function(...) - if M.running() then - if strict then - error("This function must run in a non-async context") - end - return func(...) - end - return M.run(func, nil, ...) - end -end ---- Creates an async function with a callback style function. ---- ---- @tparam function func A callback style function to be converted. The last argument must be the callback. ---- @tparam integer argc The number of arguments of func. Must be included. ---- @tparam boolean strict Error when called in non-async context ---- @treturn function Returns an async function -function M.wrap(func, argc, strict) - return function(...) - if not M.running() then - if strict then - error("This function must run in an async context") - end - return func(...) - end - return M.wait(argc, func, ...) - end -end ---- Run a collection of async functions (`thunks`) concurrently and return when ---- all have finished. ---- @tparam function[] thunks ---- @tparam integer n Max number of thunks to run concurrently ---- @tparam function interrupt_check Function to abort thunks between calls -function M.join(thunks, n, interrupt_check) - local function run(finish) - if #thunks == 0 then - return finish() - end - local remaining = { select(n + 1, unpack(thunks)) } - local to_go = #thunks - local ret = {} - local function cb(...) - ret[#ret + 1] = { ... } - to_go = to_go - 1 - if to_go == 0 then - finish(ret) - elseif not interrupt_check or not interrupt_check() then - if #remaining > 0 then - local next_task = table.remove(remaining) - next_task(cb) - end - end - end - for i = 1, math.min(n, #thunks) do - thunks[i](cb) - end - end - if not M.running() then - return run - end - return M.wait(1, false, run) -end ---- Partially applying arguments to an async function ---- @tparam function fn ---- @param ... arguments to apply to `fn` -function M.curry(fn, ...) - local args = { ... } - local nargs = select("#", ...) - return function(...) - local other = { ... } - for i = 1, select("#", ...) do - args[nargs + i] = other[i] - end - fn(unpack(args)) - end -end ---- An async function that when called will yield to the Neovim scheduler to be ---- able to call the neovim API. -M.scheduler = M.wrap(vim.schedule, 1, false) -return M diff --git a/lua/gitlinker/commons/async.lua b/lua/gitlinker/commons/async.lua index aa1743c9..9185c5e3 100644 --- a/lua/gitlinker/commons/async.lua +++ b/lua/gitlinker/commons/async.lua @@ -1,95 +1,1772 @@ --- Copied from: +---@diagnostic disable +local util = require('gitlinker.commons.async._util') -local co = coroutine +local pack_len = util.pack_len +local unpack_len = util.unpack_len +local is_callable = util.is_callable +local gc_fun = util.gc_fun -local async_thread = { - threads = {}, -} +--- This module implements an asynchronous programming library for Neovim, +--- centered around the principle of **Structured Concurrency**. This design +--- makes concurrent programs easier to reason about, more reliable, and less +--- prone to resource leaks. +--- +--- ### Core Philosophy: Structured Concurrency +--- +--- Every async operation happens within a "concurrency scope", which is represented +--- by a [vim.async.Task] object created with `vim.async.run()`. This creates a +--- parent-child relationship between tasks, with the following guarantees: +--- +--- 1. **Task Lifetime:** A parent task's scope cannot end until all of its +--- child tasks have completed. The parent *implicitly waits* for its children, +--- preventing orphaned or "fire-and-forget" tasks. +--- +--- 2. **Error Propagation:** If a child task fails with an error, the error is +--- propagated up to its parent. +--- +--- 3. **Cancellation Propagation:** If a parent task is cancelled (e.g., via +--- `:close()`), the cancellation is propagated down to all of its children. +--- +--- This model ensures that all concurrent tasks form a clean, hierarchical tree, +--- and control flow is always well-defined. +--- +--- ### Stackful vs. Stackless Coroutines (Green Threads) +--- +--- A key architectural feature of `async.nvim` is that it is built on Lua's +--- native **stackful coroutines**. This provides a significant advantage over the +--- `async/await` implementations in many other popular languages, though it's +--- important to clarify its role in the "function coloring" problem. +--- +--- - **Stackful (Lua, Go):** A stackful coroutine has its own dedicated call +--- stack, much like a traditional OS thread (and are often called "green threads" +--- or "virtual threads"). This allows a coroutine to be suspended from deep +--- within a nested function call. When using `async.nvim`, `vim.async.run()` +--- serves as the explicit entry point to an asynchronous context (similar to +--- Go's `go` keyword). However, *within* that `async.run()` context, +--- intermediate synchronous helper functions do *not* need to be specially +--- marked. This means if function `A` calls `B` calls `C`, and `C` performs +--- an `await`, `A` and `B` can remain regular Lua functions as long as they are +--- called from within an `async.run()` context. This significantly reduces the +--- viral spread of "coloring". +--- +--- - **Stackless (JavaScript, Python, Swift, C#, Kotlin):** Most languages +--- implement `async/await` with stackless coroutines. A function that can +--- be suspended must be explicitly marked with a keyword (like `async` or +--- `suspend`). This requirement is "viral"—any function that calls an `async` +--- function must itself be marked `async`, and so on up the call stack. This +--- is the typical "function coloring" problem. +--- +--- Because Lua provides stackful coroutines, `async.nvim` allows you to `await` +--- from deeply nested synchronous functions *within an async context* without +--- "coloring" those intermediate callers. This makes concurrent code less +--- intrusive and easier to integrate with existing synchronous code, despite +--- `async.run()` providing an explicit boundary for the async operations. +--- +--- ### Key Features +--- +--- - **Task Scopes:** Create a new concurrency scope with `vim.async.run()`. +--- The returned [vim.async.Task] object acts as the parent for any other +--- tasks started within its function. +--- +--- - **Awaiting:** Suspend execution and wait for an operation to complete using +--- `vim.async.await()`. This can be used on other tasks or on callback-based +--- functions. +--- +--- - **Callback Wrapping:** Convert traditional callback-based functions into +--- modern async functions with `vim.async.wrap()`. +--- +--- - **Concurrency Utilities:** `await_all`, `await_any`, and `iter` provide +--- powerful tools for managing groups of tasks. +--- +--- - **Synchronization Primitives:** `event`, `queue`, and `semaphore` are +--- available for more complex coordination patterns. +--- +--- ### Example +--- +--- ```lua +--- -- Create an async version of vim.system +--- local system = vim.async.wrap(3, vim.system) +--- +--- -- vim.async.run() creates a parent task scope. +--- local parent_task = vim.async.run(function() +--- -- These child tasks are launched within the parent's scope. +--- local ls_task = system({ 'ls', '-l' }) +--- local date_task = system({ 'date' }) +--- +--- -- The parent task will not complete until both ls_task and +--- -- date_task have finished, even without an explicit 'await'. +--- end) +--- +--- -- Wait for the parent and all its children to complete. +--- parent_task:wait() +--- ``` +--- +--- ### Structured Concurrency and Task Scopes +--- +--- Every call to `vim.async.run(fn)` creates a new [vim.async.Task] that establishes +--- a concurrency scope. Any other tasks started inside `fn` become children of this +--- task. +--- +--- ```lua +--- -- t1 is a top-level task with no parent. +--- local t1 = async.run(function() vim.async.sleep(50) end) +--- +--- local main = async.run(function() +--- -- 'child' is created within main's scope, so 'main' is its parent. +--- local child = async.run(function() vim.async.sleep(100) end) +--- +--- -- Because 'main' is the parent, it implicitly waits for 'child' +--- -- to complete before it can complete itself. +--- +--- -- Cancellation is also propagated down the tree. +--- -- Calling main:close() will also call child:close(). +--- +--- -- t1 created outside of the main async context. +--- -- It has no parent, so 'main' does not implicitly wait for it. +--- async.await(t1) +--- end) +--- +--- -- This will wait for ~100ms, as 'main' must wait for 'child'. +--- main:wait() +--- ``` +--- +--- If a parent task finishes with an error, it will immediately cancel all of its +--- running child tasks. If it finishes normally, it implicitly waits for them to +--- complete normally. +--- +--- ### Comparison with Python's Trio +--- +--- The design of `async.nvim` is heavily inspired by Python's `trio` library, +--- and it implements the same core philosophy of **Structured Concurrency**. +--- Both libraries guarantee that all tasks are run in a hierarchy, preventing +--- leaked or "orphaned" tasks and ensuring that cancellation and errors +--- propagate predictably. +--- +--- Trio uses an explicit `nursery` object. To spawn child tasks, you must +--- create a nursery scope (e.g., `async with trio.open_nursery() as nursery:`), +--- and the nursery block defines the lifetime of the child tasks. +--- +--- async.nvim unifies the concepts of a task and a concurrency scope. +--- The [vim.async.Task] object returned by `vim.async.run()` *is* the scope. +--- In essence, `async.nvim` provides the same safety and clarity as `trio` but +--- adapts the concepts idiomatically for Lua and Neovim. +--- +--- ### Comparison with JavaScript's Promises +--- +--- JavaScript's `async/await` model with Promises is fundamentally **unstructured**. +--- While tools like `Promise.all` can coordinate multiple promises, the language +--- provides no built-in "scope" that automatically manages child tasks. +--- +--- An `async` function call in JavaScript returns a Promise +--- that runs independently. If it is not explicitly awaited, it can easily +--- become an "orphaned" task. +--- +--- Cancellation is manual and opt-in via the `AbortController` +--- and `AbortSignal` pattern. It does not automatically propagate from a parent +--- scope to child operations. +--- +--- `async.nvim`'s structured model contrasts with this by providing automatic +--- cleanup and cancellation, preventing common issues like resource leaks from +--- forgotten background tasks. +--- +--- ### Comparison with Swift Concurrency +--- +--- Swift's concurrency model maps closely to `async.nvim`. +--- +--- Swift's `TaskGroup` is analogous to the concurrency scope +--- created by `vim.async.run()`. The group's scope cannot exit until all +--- child tasks added to it are complete. +--- +--- In both Swift and `async.nvim`, cancelling a parent task +--- automatically propagates a cancellation notice down to all of its children. +--- +--- ### Comparison with Kotlin Coroutines +--- +--- Kotlin's Coroutine framework is another system built on **Structured Concurrency**, +--- and it shares a nearly identical philosophy with `async.nvim`. +--- +--- In Kotlin, a `coroutineScope` function creates a new +--- scope. The scope is guaranteed not to complete until all coroutines +--- launched within it have also completed. This is conceptually the same as +--- the scope created by `vim.async.run()`. +--- +--- Like `async.nvim`, cancellation and errors +--- propagate automatically through the task hierarchy. Cancelling a parent scope +--- cancels its children, and an exception in a child will cancel the parent. +--- +--- ### Comparison with Go Goroutines +--- +--- Go's concurrency model, while powerful, is fundamentally **unstructured**. +--- Launching a `go` routine is a "fire-and-forget" operation with no implicit +--- parent-child relationship. +--- +--- Programmers must manually track groups of goroutines, +--- typically using a `sync.WaitGroup` to ensure they all complete before +--- proceeding. +--- +--- Cancellation and deadlines are handled by +--- explicitly passing a `context` object through the entire call stack. There +--- is no automatic propagation of cancellation or errors up or down a task tree. +--- +--- This contrasts with `async.nvim`, where the structured concurrency model +--- automates the lifetime, cancellation, and error management that must be +--- handled explicitly in Go. +--- +--- @class vim.async +local M = {} -local function threadtostring(x) - if jit then - return string.format('%p', x) - else - return tostring(x):match('thread: (.*)') - end -end +-- Use max 32-bit signed int value to avoid overflow on 32-bit systems. +-- Do not use `math.huge` as it is not interpreted as a positive integer on all +-- platforms. +local MAX_TIMEOUT = 2 ^ 31 - 1 -function async_thread.running() - local thread = co.running() - local id = threadtostring(thread) - return async_thread.threads[id] -end +--- Weak table to keep track of running tasks +--- @type table?> +local threads = setmetatable({}, { __mode = 'k' }) -function async_thread.create(fn) - local thread = co.create(fn) - local id = threadtostring(thread) - async_thread.threads[id] = true - return thread +--- Returns the currently running task. +--- @return vim.async.Task? +local function running() + local task = threads[coroutine.running()] + if task and not task:completed() then + return task + end end -function async_thread.finished(x) - if co.status(x) == 'dead' then - local id = threadtostring(x) - async_thread.threads[id] = nil - return true - end - return false +--- Internal marker used to identify that a yielded value is an asynchronous yielding. +local yield_marker = {} +local resume_marker = {} +local complete_marker = {} + +local resume_error = 'Unexpected coroutine.resume()' +local yield_error = 'Unexpected coroutine.yield()' + +--- Checks the arguments of a `coroutine.resume`. +--- This is used to ensure that a resume is expected. +--- @generic T +--- @param marker any +--- @param err? any +--- @param ... T... +--- @return T... +local function check_yield(marker, err, ...) + if marker ~= resume_marker then + local task = assert(running(), 'Not in async context') + task:_raise(resume_error) + -- Return an error to the caller. This will also leave the task in a dead + -- and unfinshed state + error(resume_error, 0) + elseif err then + error(err, 0) + end + return ... end ---- @param async_fn function ---- @param ... any -local function execute(async_fn, ...) - local thread = async_thread.create(async_fn) +--- @class vim.async.Closable +--- @field close fun(self, callback?: fun()) +--- @field is_closing? fun(self): boolean + +--- Tasks are used to run coroutines in event loops. If a coroutine needs to +--- wait on the event loop, the Task suspends the execution of the coroutine and +--- waits for event loop to restart it. +--- +--- Use the [vim.async.run()] to create Tasks. +--- +--- To close a running Task use the `close()` method. Calling it will cause the +--- Task to throw a "closed" error in the wrapped coroutine. +--- +--- Note a Task can be waited on via more than one waiter. +--- +--- @class vim.async.Task: vim.async.Closable +--- @field package _thread thread +--- @field package _future vim.async.Future +--- @field package _closing boolean +--- +--- Reference to parent to handle attaching/detaching. +--- @field package _parent? vim.async.Task +--- @field package _parent_children_idx? integer +--- +--- Name of the task +--- @field name? string +--- +--- Mark task for internal use. Used for awaiting children tasks on complete. +--- @field package _internal? string +--- +--- The source line that created this task, used for inspect(). +--- @field package _caller? string +--- +--- Maintain children as an array to preserve closure order. +--- @field package _children table?> +--- +--- Pointer to last child in children +--- @field package _children_idx integer +--- +--- Tasks can await other async functions (task of callback functions) +--- when we are waiting on a child, we store the handle to it here so we can +--- close it. +--- @field package _awaiting? vim.async.Task|vim.async.Closable +local Task = {} + +do --- Task + Task.__index = Task + --- @package + --- @param func function + --- @param opts? vim.async.run.Opts + --- @return vim.async.Task + function Task._new(func, opts) + local thread = coroutine.create(function(marker, ...) + check_yield(marker) + return func(...) + end) + + opts = opts or {} + + local self = setmetatable({ + name = opts.name, + _internal = opts._internal, + _closing = false, + _is_completing = false, + _thread = thread, + _future = M._future(), + _children = {}, + _children_idx = 0, + }, Task) + + threads[thread] = self + + if not (opts and opts.detached) then + self:_attach(running()) + end + + return self + end + + -- --- @return boolean + -- function Task:closed() + -- return self._future._err == 'closed' + -- end + + --- @package + function Task:_unwait(cb) + return self._future:_remove_cb(cb) + end + + --- Returns whether the Task has completed. + --- @return boolean + function Task:completed() + return self._future:completed() + end + + --- Add a callback to be run when the Task has completed. + --- + --- - If a timeout or `nil` is provided, the Task will synchronously wait for the + --- task to complete for the given time in milliseconds. + --- + --- ```lua + --- local result = task:wait(10) -- wait for 10ms or else error + --- + --- local result = task:wait() -- wait indefinitely + --- ``` + --- + --- - If a function is provided, it will be called when the Task has completed + --- with the arguments: + --- - (`err: string`) - if the Task completed with an error. + --- - (`nil`, `...:any`) - the results of the Task if it completed successfully. + --- + --- + --- If the Task is already done when this method is called, the callback is + --- called immediately with the results. + --- @param callback_or_timeout integer|fun(err?: any, ...: R...)? + --- @overload fun(timeout?: integer): R... + function Task:wait(callback_or_timeout) + if is_callable(callback_or_timeout) then + self._future:wait(callback_or_timeout) + return + end + + if + not vim.wait(callback_or_timeout or MAX_TIMEOUT, function() + return self:completed() + end) + then + error('timeout', 2) + end + + local res = pack_len(self._future:result()) + + assert(self:status() == 'completed' or res[2] == yield_error) + + if not res[1] then + error(res[2], 2) + end + + return unpack_len(res, 2) + end + + --- Protected-call version of `wait()`. + --- + --- Does not throw an error if the task fails or times out. Instead, returns + --- the status and the results. + --- @param timeout integer? + --- @return boolean, R... + function Task:pwait(timeout) + vim.validate('timeout', timeout, 'number', true) + return pcall(self.wait, self, timeout) + end + + --- @package + --- @param parent? vim.async.Task + function Task:_attach(parent) + if parent then + -- Attach to parent + parent._children_idx = parent._children_idx + 1 + parent._children[parent._children_idx] = self + + -- Keep track of the parent and this tasks index so we can detach + self._parent = parent + self._parent_children_idx = parent._children_idx + end + end + + --- Detach a task from its parent. + --- + --- The task becomes a top-level task. + --- @return vim.async.Task + function Task:detach() + if self._parent then + self._parent._children[self._parent_children_idx] = nil + self._parent = nil + self._parent_children_idx = nil + end + return self + end + + --- Get the traceback of a task when it is not active. + --- Will also get the traceback of nested tasks. + --- + --- @param msg? string + --- @param level? integer + --- @return string traceback + function Task:traceback(msg, level) + level = level or 0 + + local thread = ('[%s] '):format(self._thread) + + local awaiting = self._awaiting + if getmetatable(awaiting) == Task then + --- @cast awaiting vim.async.Task + msg = awaiting:traceback(msg, level + 1) + end + + local tblvl = getmetatable(awaiting) == Task and 2 or nil + msg = (tostring(msg) or '') + .. debug.traceback(self._thread, '', tblvl):gsub('\n\t', '\n\t' .. thread) + + if level == 0 then + --- @type string + msg = msg + :gsub('\nstack traceback:\n', '\nSTACK TRACEBACK:\n', 1) + :gsub('\nstack traceback:\n', '\n') + :gsub('\nSTACK TRACEBACK:\n', '\nstack traceback:\n', 1) + end + + return msg + end + + --- If a task completes with an error, raise the error + --- @return vim.async.Task self + function Task:raise_on_error() + self:wait(function(err) + if err then + error(self:traceback(err), 0) + end + end) + return self + end + + --- @package + --- @param err any + function Task:_raise(err) + if self:status() == 'running' then + -- TODO(lewis6991): is there a better way to do this? + vim.schedule(function() + self:_resume(err) + end) + else + self:_resume(err) + end + end + + --- Close the task and all of its children. + --- If callback is provided it will run asynchronously, + --- else it will run synchronously. + --- + --- @param callback? fun() + function Task:close(callback) + if not self:completed() and not self._closing and not self._is_completing then + self._closing = true + self:_raise('closed') + end + if callback then + self:wait(function() + callback() + end) + end + end + + --- Complete a task with the given values, cancelling any remaining work. + --- + --- This marks the task as successfully completed and notifies any waiters with + --- the provided values. It also initiates the cancellation of all + --- running child tasks. + --- + --- A primary use case is for "race" scenarios. A child task can acquire a + --- reference to its parent task and call `complete()` on it. This signals + --- that the overall goal of the parent scope has been met, which immediately + --- triggers the cancellation of all sibling tasks. + --- + --- This provides a built-in pattern for "first-to-finish" logic, such as + --- querying multiple data sources and taking the first response. + --- + --- @param ... any The values to complete the task with. + function Task:complete(...) + if self:completed() or self._closing or self._is_completing then + error('Task is already completing or completed', 2) + end + self._is_completing = true + self:_raise({ complete_marker, pack_len(...) }) + end + + --- Checks if an object is closable, i.e., has a `close` method. + --- @param obj any + --- @return boolean + --- @return_cast obj vim.async.Closable + local function is_closable(obj) + local ty = type(obj) + return (ty == 'table' or ty == 'userdata') and is_callable(obj.close) + end + + do -- Task:_resume() + --- @private + --- @param stat boolean + --- @param ...R... result + function Task:_finalize0(stat, ...) + -- TODO(lewis6991): should we collect all errors? + for _, child in pairs(self._children) do + if not stat then + child:close() + end + -- If child fails then it will resume the parent with an error + -- which is handled below + pcall(M.await, child) + end + + local parent = self._parent + self:detach() - local function step(...) - local ret = { co.resume(thread, ...) } - local stat, err_or_fn, nargs = unpack(ret) + threads[self._thread] = nil if not stat then - error(string.format("The coroutine failed with this message: %s\n%s", - err_or_fn, debug.traceback(thread))) + local err = ... + if type(err) == 'table' and err[1] == complete_marker then + self._future:complete(nil, unpack_len(err[2])) + else + local err_msg = err or 'unknown error' + self._future:complete(err_msg) + if parent and not self._closing then + parent:_raise('child error: ' .. tostring(err_msg)) + end + end + else + self._future:complete(nil, ...) end + end - if async_thread.finished(thread) then - return + --- @private + --- Should only be called in Task:_resume_co() + --- @param stat boolean + --- @param ...R... result + function Task:_finalize(stat, ...) + -- Only run self._finalize0() directly if there are children, otherwise + -- this will cause infinite recursion: + -- M.run() -> task:_resume() -> resume_co() -> complete_task() -> M.run() + if next(self._children) ~= nil then + -- TODO(lewis6991): should this be detached? + M.run({ _internal = true, name = 'await_children' }, self._finalize0, self, stat, ...) + else + self:_finalize0(stat, ...) end + end - assert(type(err_or_fn) == "function", "The 1st parameter must be a lua function") + --- @param thread thread + --- @param on_finish fun(stat: boolean, ...:any) + --- @param stat boolean + --- @return fun(callback: fun(...:any...): vim.async.Closable?)? + local function handle_co_resume(thread, on_finish, stat, ...) + if coroutine.status(thread) == 'dead' then + on_finish(stat, ...) + return + end + + local marker, fn = ... + + if marker ~= yield_marker or not is_callable(fn) then + on_finish(false, yield_error) + return + end + + return fn + end + + --- @param awaitable fun(callback: fun(...:any...): vim.async.Closable?) + --- @param on_defer fun(err?:any, ...:any) + --- @return any[]? next_args + --- @return vim.async.Closable? closable + local function handle_awaitable(awaitable, on_defer) + local ok, closable_or_err + local settled = false + local next_args --- @type any[]? + ok, closable_or_err = pcall(awaitable, function(...) + if settled then + -- error here? + return + end + settled = true + + if ok == nil then + next_args = pack_len(...) + else + on_defer(...) + end + end) + + if not ok then + return pack_len(closable_or_err) + elseif is_closable(closable_or_err) then + return next_args, closable_or_err + else + return next_args + end + end + + --- @param task vim.async.Task + --- @param awaiting vim.async.Task|vim.async.Closable + --- @return boolean + local function can_close_awaiting(task, awaiting) + if getmetatable(awaiting) ~= Task then + return true + end + + for _, child in pairs(task._children) do + if child == awaiting then + return true + end + end + + return false + end + + --- Handle closing an awaitable if needed + --- @param task vim.async.Task + --- @param awaiting vim.async.Closable? + --- @param on_continue fun() + --- @return boolean should_return + --- @return {[integer]: any, n: integer}? new_args + local function handle_close_awaiting(task, awaiting, on_continue) + if not awaiting or not can_close_awaiting(task, awaiting) then + return false, nil + end + + -- Check if the awaitable is already closing (if it has an is_closing method) + local already_closing = false + if type(awaiting.is_closing) == 'function' then + already_closing = awaiting:is_closing() + end + + if already_closing then + -- Already closing, just continue without calling close + task._awaiting = nil + on_continue() + return true, nil + end - local ret_fn = err_or_fn - local args = { select(4, unpack(ret)) } - args[nargs] = step - ret_fn(unpack(args, 1, nargs --[[@as integer]])) - end + -- We must close the closable child before we resume to ensure + -- all resources are collected. + --- @diagnostic disable-next-line: param-type-not-match + local close_ok, close_err = pcall(awaiting.close, awaiting, function() + task._awaiting = nil + on_continue() + end) - step(...) + if close_ok then + -- will call on_continue in close callback + return true, nil + end + + -- Close failed (synchronously) raise error + return false, pack_len(close_err) + end + + --- @package + --- @param ... any the first argument is the error, except for when the coroutine begins + function Task:_resume(...) + --- @type {[integer]: any, n: integer}? + local args = pack_len(...) + + -- Run this block in a while loop to run non-deferred continuations + -- without a new stack frame. + while args do + -- TODO(lewis6991): Add a test that handles awaiting in the non-deferred + -- continuation + if self._is_completing and select(1, ...) == 'closed' then + return + end + + local should_return, close_err_args = handle_close_awaiting(self, self._awaiting, function() + self:_resume(unpack_len(args)) + end) + if should_return then + return + end + + args = close_err_args or args + + -- Check the coroutine is still alive before trying to resume it + if coroutine.status(self._thread) == 'dead' then + -- Can only happen if coroutine.resume() is called outside of this + -- function. When that happens check_yield() will error the coroutine + -- which puts it in the 'dead' state. + self:_finalize(false, (...)) + return + end + + -- Level-triggered cancellation: if the task is closing and the coroutine + -- completed successfully (e.g., after pcall caught the cancellation and + -- then did another await), override the success with "closed" error. + -- This ensures cancellations persist across pcall catches. + local awaitable = handle_co_resume(self._thread, function(stat2, ...) + if self._closing and stat2 then + self:_finalize(false, 'closed') + else + self:_finalize(stat2, ...) + end + end, coroutine.resume(self._thread, resume_marker, unpack_len(args))) + + if not awaitable then + return + end + + args, self._awaiting = handle_awaitable(awaitable, function(...) + if not self:completed() then + self:_resume(...) + end + end) + end + end + end + + --- @package + function Task:_log(...) + print(tostring(self._thread), ...) + end + + --- Returns the status of the task: + --- - 'running' : task is running (that is, is called `status()`). + --- - 'normal' : task is active but not running (e.g. it is starting + --- another task). + --- - 'awaiting' : if the task is awaiting another task either directly via + --- `await()` or waiting for all children to complete. + --- - 'completed' : task and all it's children have completed + --- @return 'running'|'awaiting'|'normal'|'scheduled'|'completed' + function Task:status() + local co_status = coroutine.status(self._thread) + if co_status == 'dead' then + return self:completed() and 'completed' or 'awaiting' + elseif co_status == 'suspended' then + return 'awaiting' + elseif co_status == 'normal' then + -- TODO(lewis6991): This state is a bit ambiguous. If all tasks + -- are started from the main thread, then we can remove this state. + -- Though it still may be possible if the user resumes a non-task + -- coroutine. + return 'normal' + end + assert(co_status == 'running') + return 'running' + end end -local M = {} +do --- M.run + --- @class vim.async.run.Opts + --- @field name? string + --- @field detached? boolean + --- @field package _internal? boolean + + --- @package + --- @generic T, R + --- @param opts? vim.async.run.Opts + --- @param func async fun(...:T...): R... Function to run in an async context + --- @param ... T... Arguments to pass to the function + --- @return vim.async.Task + local function run(opts, func, ...) + vim.validate('opts', opts, 'table', true) + vim.validate('func', func, 'callable') + -- TODO(lewis6991): add task names + local task = Task._new(func, opts) + local info = debug.getinfo(2, 'Sl') + if info and info.currentline then + task._caller = ('%s:%d'):format(info.source, info.currentline) + end + task:_resume(...) + return task + end ---- @param func function + --- Run a function in an async context, asynchronously. + --- + --- Returns an [vim.async.Task] object which can be used to wait or await the result + --- of the function. + --- + --- Examples: + --- ```lua + --- -- Run a uv function and wait for it + --- local stat = vim.async.run(function() + --- return vim.async.await(2, vim.uv.fs_stat, 'foo.txt') + --- end):wait() + --- + --- -- Since uv functions have sync versions, this is the same as: + --- local stat = vim.fs_stat('foo.txt') + --- ``` + --- @generic T, R + --- @param func async fun(...:T...): R... + --- @return vim.async.Task + --- @overload fun(name: string, func: async fun(...:T...), ...: T...): vim.async.Task + --- @overload fun(opts: vim.async.run.Opts, func: async fun(...:T...), ...: T...): vim.async.Task + function M.run(func, ...) + if type(func) == 'string' then + return run({ name = func }, ...) + elseif type(func) == 'table' then + return run(func, ...) + elseif is_callable(func) then + return run(nil, func, ...) + end + error('Invalid arguments') + end +end + +do --- M.await() + --- @generic T, R + --- @param argc integer + --- @param fun fun(...: T..., callback: fun(...: R...)) + --- @param ... T... func arguments + --- @return fun(callback: fun(...: R...)) + local function norm_cb_fun(argc, fun, ...) + local args = pack_len(...) + + --- @param callback fun(...:any) + --- @return any? + return function(callback) + args[argc] = function(...) + callback(nil, ...) + end + args.n = math.max(args.n, argc) + return fun(unpack_len(args)) + end + end + + --- Asynchronous blocking wait + --- + --- Example: + --- ```lua + --- local task = vim.async.run(function() + --- return 1, 'a' + --- end) + --- + --- local task_fun = vim.async.async(function(arg) + --- return 2, 'b', arg + --- end) + --- + --- vim.async.run(function() + --- do -- await a callback function + --- vim.async.await(1, vim.schedule) + --- end + --- + --- do -- await a callback function (if function only has a callback argument) + --- vim.async.await(vim.schedule) + --- end + --- + --- do -- await a task (new async context) + --- local n, s = vim.async.await(task) + --- assert(n == 1 and s == 'a') + --- end + --- + --- end) + --- ``` + --- @async + --- @generic T, R + --- @param ... any see overloads + --- @overload async fun(func: (fun(callback: fun(...:R...)): vim.async.Closable?)): R... + --- @overload async fun(argc: integer, func: (fun(...:T..., callback: fun(...:R...)): vim.async.Closable?), ...:T...): R... + --- @overload async fun(task: vim.async.Task): R... + function M.await(...) + local task = running() + assert(task, 'Not in async context') + + -- TODO(lewis6991): needs test coverage. Happens when a task pcalls an await + if task._closing then + error('closed', 0) + end + + local arg1 = select(1, ...) + + local fn --- @type fun(...:R...): vim.async.Closable? + if type(arg1) == 'number' then + fn = norm_cb_fun(...) + elseif type(arg1) == 'function' then + fn = norm_cb_fun(1, arg1) + elseif getmetatable(arg1) == Task then + fn = function(callback) + --- @cast arg1 vim.async.Task + arg1:wait(callback) + return arg1 + end + else + error('Invalid arguments, expected Task or (argc, func) got: ' .. vim.inspect(arg1), 2) + end + + return check_yield(coroutine.yield(yield_marker, fn)) + end +end + +--- Returns true if the current task has been closed. +--- +--- Can be used in an async function to do cleanup when a task is closing. +--- @return boolean +function M.is_closing() + local task = running() + return task and task._closing or false +end + +--- Protected call for async functions that propagates child task errors. +--- +--- Similar to Lua's built-in `pcall`, but with special handling for child task +--- errors. This function will: +--- - Catch regular errors (return `false, err`) +--- - Propagate child task errors (re-throw them) +--- - Propagate cancellation errors (re-throw them) +--- +--- This is useful when you want to handle regular errors locally, but allow +--- child task failures to bubble up to the parent task, maintaining the +--- structured concurrency guarantees. +--- +--- Note: This function uses `xpcall` with a custom error handler to capture the +--- full stack trace before the stack is unwound. When re-throwing child errors +--- or cancellation errors, the traceback is preserved. Regular errors are +--- caught and returned with their error messages as usual. +--- +--- Example: +--- ```lua +--- vim.async.run(function() +--- local child = vim.async.run(function() +--- vim.async.sleep(10) +--- error('CHILD_FAILED') +--- end) +--- +--- -- Regular pcall would catch the child error +--- local ok1, err1 = pcall(function() +--- vim.async.sleep(100) +--- end) +--- -- ok1 = false, err1 = 'child error: CHILD_FAILED' +--- +--- -- async.pcall propagates child errors +--- local ok2, err2 = vim.async.pcall(function() +--- error('REGULAR_ERROR') +--- end) +--- -- ok2 = false, err2 = 'REGULAR_ERROR' +--- +--- -- But child errors propagate: +--- vim.async.pcall(function() +--- vim.async.sleep(100) +--- end) +--- -- This will error with 'child error: CHILD_FAILED' +--- end) +--- ``` +--- +--- @async +--- @generic T +--- @param fn async fun(): T... +--- @return boolean ok +--- @return any|T... err_or_result +function M.pcall(fn) + vim.validate('fn', fn, 'callable') + + local captured_traceback + local results = pack_len(xpcall(fn, function(err) + -- Error handler runs before stack is unwound + -- Capture the full traceback here + captured_traceback = debug.traceback(err, 2) + + -- Check if this is a child error or cancellation + if type(err) == 'string' then + if err:match('^child error: ') or err == 'closed' then + -- For child errors/cancellations, return the traceback so it can be re-thrown + return captured_traceback + end + end + + -- For regular errors, just return the error message + return err + end)) + + local ok = results[1] + + if not ok then + local err = results[2] + -- If this is a child error or cancellation, re-throw with the full traceback + if err == captured_traceback then + -- This is a child error or cancellation - re-throw it + error(err, 0) + end + end + + return unpack_len(results) +end + +--- Creates an async function from a callback style function. +--- +--- `func` can optionally return an object with a close method to clean up +--- resources. Note this method will be called when the task finishes or +--- interrupted. +--- +--- Example: +--- +--- ```lua +--- --- Note the callback argument is not present in the return function +--- --- @type async fun(timeout: integer) +--- local sleep = vim.async.wrap(2, function(timeout, callback) +--- local timer = vim.uv.new_timer() +--- timer:start(timeout * 1000, 0, callback) +--- -- uv_timer_t provides a close method so timer will be +--- -- cleaned up when this function finishes +--- return timer +--- end) +--- +--- vim.async.run(function() +--- print('hello') +--- sleep(2) +--- print('world') +--- end) +--- ``` +--- +--- @generic T, R --- @param argc integer ---- @return function -M.wrap = function(func, argc) - return function(...) - if not async_thread.running() then - return func(...) +--- @param func fun(...: T..., callback: fun(...: R...)): vim.async.Closable? +--- @return async fun(...:T...): R... +function M.wrap(argc, func) + vim.validate('argc', argc, 'number') + vim.validate('func', func, 'callable') + --- @async + return function(...) + return M.await(argc, func, ...) + end +end + +do --- M.iter(), M.await_all(), M.await_any() + --- @async + --- @generic R + --- @param tasks vim.async.Task[] A list of tasks to wait for and iterate over. + --- @return async fun(): (integer?, any?, ...R) iterator that yields the index, error, and results of each task. + local function iter(tasks) + vim.validate('tasks', tasks, 'table') + + -- TODO(lewis6991): do not return err, instead raise any errors as they occur + assert(running(), 'Not in async context') + + local remaining = #tasks + local queue = M._queue() + + -- Keep track of the callbacks so we can remove them when the iterator + -- is garbage collected. + --- @type table,function> + local task_cbs = setmetatable({}, { __mode = 'v' }) + + -- Wait on all the tasks. Keep references to the task futures and wait + -- callbacks so we can remove them when the iterator is garbage collected. + for i, task in ipairs(tasks) do + local function cb(err, ...) + remaining = remaining - 1 + queue:put_nowait(pack_len(err, i, ...)) + if remaining == 0 then + queue:put_nowait() + end + end + + task_cbs[task] = cb + task:wait(cb) + end + + --- @async + return gc_fun(function() + local r = queue:get() + if r then + local err = r[1] + if err then + -- -- Note: if the task was a child, then an error should have already been + -- -- raised in _complete_task(). This should only trigger to detached tasks. + -- assert(assert(tasks[r[2]])._parent == nil) + error(('iter error[index:%d]: %s'):format(r[2], r[1]), 3) + end + return unpack_len(r, 2) + end + end, function() + for t, tcb in pairs(task_cbs) do + t:_unwait(tcb) + end + end) + end + + --- Waits for multiple tasks to finish and iterates over their results. + --- + --- This function allows you to run multiple asynchronous tasks concurrently and + --- process their results as they complete. It returns an iterator function that + --- yields the index of the task, any error encountered, and the results of the + --- task. + --- + --- If a task completes with an error, the error is returned as the second + --- value. Otherwise, the results of the task are returned as subsequent values. + --- + --- Example: + --- ```lua + --- local task1 = vim.async.run(function() + --- return 1, 'a' + --- end) + --- + --- local task2 = vim.async.run(function() + --- return 2, 'b' + --- end) + --- + --- local task3 = vim.async.run(function() + --- error('task3 error') + --- end) + --- + --- vim.async.run(function() + --- for i, err, r1, r2 in vim.async.iter({task1, task2, task3}) do + --- print(i, err, r1, r2) + --- end + --- end) + --- ``` + --- + --- Prints: + --- ``` + --- 1 nil 1 'a' + --- 2 nil 2 'b' + --- 3 'task3 error' nil nil + --- ``` + --- + --- @async + --- @generic R + --- @param tasks vim.async.Task[] A list of tasks to wait for and iterate over. + --- @return async fun(): (integer?, any?, ...R) iterator that yields the index, error, and results of each task. + function M.iter(tasks) + return iter(tasks) + end + + --- Wait for all tasks to finish and return their results. + --- + --- Example: + --- ```lua + --- local task1 = vim.async.run(function() + --- return 1, 'a' + --- end) + --- + --- local task2 = vim.async.run(function() + --- return 1, 'a' + --- end) + --- + --- local task3 = vim.async.run(function() + --- error('task3 error') + --- end) + --- + --- vim.async.run(function() + --- local results = vim.async.await_all({task1, task2, task3}) + --- print(vim.inspect(results)) + --- end) + --- ``` + --- + --- Prints: + --- ``` + --- { + --- [1] = { nil, 1, 'a' }, + --- [2] = { nil, 2, 'b' }, + --- [3] = { 'task2 error' }, + --- } + --- ``` + --- @async + --- @param tasks vim.async.Task[] + --- @return table + function M.await_all(tasks) + assert(running(), 'Not in async context') + local itr = iter(tasks) + local results = {} --- @type table + + local function collect(i, ...) + if i then + results[i] = pack_len(...) + end + return i ~= nil + end + + while collect(itr()) do + end + + return results + end + + --- Wait for the first task to complete and return its result. + --- + --- Example: + --- ```lua + --- local task1 = vim.async.run(function() + --- vim.async.sleep(100) + --- return 1, 'a' + --- end) + --- + --- local task2 = vim.async.run(function() + --- return 2, 'b' + --- end) + --- + --- vim.async.run(function() + --- local i, err, r1, r2 = vim.async.await_any({task1, task2}) + --- assert(i == 2) + --- assert(err == nil) + --- assert(r1 == 2) + --- assert(r2 == 'b') + --- end) + --- ``` + --- @async + --- @param tasks vim.async.Task[] + --- @return integer? index + --- @return any? err + --- @return any ... results + function M.await_any(tasks) + return iter(tasks)() + end +end + +--- Asynchronously sleep for a given duration. +--- +--- Blocks the current task for the given duration, but does not block the main +--- thread. +--- @async +--- @param duration integer ms +function M.sleep(duration) + vim.validate('duration', duration, 'number') + M.await(1, function(callback) + -- TODO(lewis6991): should return the result of defer_fn here. + vim.defer_fn(callback, duration) + end) +end + +--- Run a task with a timeout. +--- +--- If the task does not complete within the specified duration, it is closed +--- and an error is thrown. +--- @async +--- @generic R +--- @param duration integer Timeout duration in milliseconds +--- @param task vim.async.Task +--- @return R +function M.timeout(duration, task) + vim.validate('duration', duration, 'number') + vim.validate('task', task, 'table') + local timer = M.run(M.await, function(callback) + local t = assert(vim.uv.new_timer()) + t:start(duration, 0, callback) + return t + end) + if M.await_any({ task, timer }) == 2 then + -- Timer completed first, close the task + task:close() + error('timeout') + end + return M.await(task) +end + +do --- M._future() + --- Future objects are used to bridge low-level callback-based code with + --- high-level async/await code. + --- @class vim.async.Future + --- @field private _callbacks table + --- @field private _callback_pos integer + --- Error result of the task is an error occurs. + --- Must use `await` to get the result. + --- @field package _err? any + --- + --- Result of the task. + --- Must use `await` to get the result. + --- @field private _result? R[] + local Future = {} + Future.__index = Future + + --- Return `true` if the Future is completed. + --- @return boolean + function Future:completed() + return (self._err or self._result) ~= nil + end + + --- Return the result of the Future. + --- + --- If the Future is done and has a result set by the `complete()` method, the + --- result is returned. + --- + --- If the Future’s result isn’t yet available, this method raises a + --- "Future has not completed" error. + --- @return boolean stat true if the Future completed successfully, false otherwise. + --- @return any ... error or result + function Future:result() + if not self:completed() then + error('Future has not completed', 2) + end + if self._err then + return false, self._err + else + return true, unpack_len(self._result) + end + end + + --- Add a callback to be run when the Future is done. + --- + --- The callback is called with the arguments: + --- - (`err: string`) - if the Future completed with an error. + --- - (`nil`, `...:any`) - the results of the Future if it completed successfully. + --- + --- If the Future is already done when this method is called, the callback is + --- called immediately with the results. + --- @param callback fun(err?: any, ...: any) + function Future:wait(callback) + if self:completed() then + -- Already completed or closed + callback(self._err, unpack_len(self._result)) + else + self._callbacks[self._callback_pos] = callback + self._callback_pos = self._callback_pos + 1 + end + end + + --- Mark the Future as complete and set its result. + --- + --- If an error is provided, the Future is marked as failed. Otherwise, it is + --- marked as successful with the provided result. + --- + --- This will trigger any callbacks that are waiting on the Future. + --- @param err? any + --- @param ... any result + function Future:complete(err, ...) + if err ~= nil then + self._err = err + else + self._result = pack_len(...) + end + + local errs = {} --- @type string[] + -- Need to use pairs to avoid gaps caused by removed callbacks + for _, cb in pairs(self._callbacks) do + local ok, cb_err = pcall(cb, err, ...) + if not ok then + errs[#errs + 1] = cb_err + end + end + + if #errs > 0 then + error(table.concat(errs, '\n'), 0) + end + end + + --- @package + --- Removes a callback from the Future. + --- @param cb fun(err?: any, ...: any) + function Future:_remove_cb(cb) + for j, fcb in pairs(self._callbacks) do + if fcb == cb then + self._callbacks[j] = nil + break end - return co.yield(func, argc, ...) - end + end + end + + --- @package + --- Create a new future. + --- + --- A Future is a low-level awaitable that is not intended to be used in + --- application-level code. + --- @return vim.async.Future + function M._future() + return setmetatable({ + _callbacks = {}, + _callback_pos = 1, + }, Future) + end end ---- @param func function ---- @return function -M.void = function(func) - return function(...) - if async_thread.running() then - return func(...) +do --- M._event() + --- An event can be used to notify multiple tasks that some event has + --- happened. An Event object manages an internal flag that can be set to true + --- with the `set()` method and reset to `false` with the `clear()` method. + --- The `wait()` method blocks until the flag is set to `true`. The flag is + --- set to `false` initially. + --- @class vim.async.Event + --- @field private _is_set boolean + --- @field private _waiters function[] + local Event = {} + Event.__index = Event + + --- Set the event. + --- + --- All tasks waiting for event to be set will be immediately awakened. + --- + --- If `max_woken` is provided, only up to `max_woken` waiters will be woken. + --- The event will be reset to `false` if there are more waiters remaining. + --- @param max_woken? integer + function Event:set(max_woken) + if self._is_set then + return + end + self._is_set = true + local waiters = self._waiters + local waiters_to_notify = {} --- @type function[] + max_woken = max_woken or #waiters + while #waiters > 0 and #waiters_to_notify < max_woken do + waiters_to_notify[#waiters_to_notify + 1] = table.remove(waiters, 1) + end + if #waiters > 0 then + self._is_set = false + end + for _, waiter in ipairs(waiters_to_notify) do + waiter() + end + end + + --- Wait until the event is set. + --- + --- If the event is set, return immediately. Otherwise block until another + --- task calls set(). + --- @async + function Event:wait() + M.await(function(callback) + if self._is_set then + callback() + else + table.insert(self._waiters, callback) end - execute(func, ...) - end + end) + end + + --- Clear (unset) the event. + --- + --- Tasks awaiting on wait() will now block until the set() method is called + --- again. + function Event:clear() + self._is_set = false + end + + --- @package + --- Create a new event. + --- + --- An event can signal to multiple listeners to resume execution + --- The event can be set from a non-async context. + --- + --- ```lua + --- local event = vim.async._event() + --- + --- local worker = vim.async.run(function() + --- vim.async.sleep(1000) + --- event.set() + --- end) + --- + --- local listeners = { + --- vim.async.run(function() + --- event:wait() + --- print("First listener notified") + --- end), + --- vim.async.run(function() + --- event:wait() + --- print("Second listener notified") + --- end), + --- } + --- ``` + --- @return vim.async.Event + function M._event() + return setmetatable({ + _waiters = {}, + _is_set = false, + }, Event) + end end -M.schedule = M.wrap(vim.schedule, 1) +do --- M._queue() + --- @class vim.async.Queue + --- @field private _non_empty vim.async.Event + --- @field package _non_full vim.async.Event + --- @field private _max_size? integer + --- @field private _items R[] + --- @field private _right_i integer + --- @field private _left_i integer + local Queue = {} + Queue.__index = Queue + + --- Returns the number of items in the queue. + --- @return integer + function Queue:size() + return self._right_i - self._left_i + end + + --- Returns the maximum number of items in the queue. + --- @return integer? + function Queue:max_size() + return self._max_size + end + + --- Put an item into the queue. + --- + --- If the queue is full, wait until a free slot is available. + --- @async + --- @param value any + function Queue:put(value) + self._non_full:wait() + self:put_nowait(value) + end + + --- Get an item from the queue. + --- + --- If the queue is empty, wait until an item is available. + --- @async + --- @return any + function Queue:get() + self._non_empty:wait() + return self:get_nowait() + end + + --- Get an item from the queue without blocking. + --- + --- If the queue is empty, raise an error. + --- @return any + function Queue:get_nowait() + if self:size() == 0 then + error('Queue is empty', 2) + end + -- TODO(lewis6991): For a long_running queue, _left_i might overflow. + self._left_i = self._left_i + 1 + local item = self._items[self._left_i] + self._items[self._left_i] = nil + if self._left_i == self._right_i then + self._non_empty:clear() + end + self._non_full:set(1) + return item + end + + --- Put an item into the queue without blocking. + --- If no free slot is immediately available, raise "Queue is full" error. + --- @param value any + function Queue:put_nowait(value) + if self:size() == self:max_size() then + error('Queue is full', 2) + end + self._right_i = self._right_i + 1 + self._items[self._right_i] = value + self._non_empty:set(1) + if self:size() == self.max_size then + self._non_full:clear() + end + end + + --- @package + --- Create a new FIFO queue with async support. + --- ```lua + --- local queue = vim.async._queue() + --- + --- local producer = vim.async.run(function() + --- for i = 1, 10 do + --- vim.async.sleep(100) + --- queue:put(i) + --- end + --- queue:put(nil) + --- end) + --- + --- vim.async.run(function() + --- while true do + --- local value = queue:get() + --- if value == nil then + --- break + --- end + --- print(value) + --- end + --- print("Done") + --- end) + --- ``` + --- @param max_size? integer The maximum number of items in the queue, defaults to no limit + --- @return vim.async.Queue + function M._queue(max_size) + local self = setmetatable({ + _items = {}, + _left_i = 0, + _right_i = 0, + _max_size = max_size, + _non_empty = M._event(), + _non_full = M._event(), + }, Queue) + + self._non_full:set() + + return self + end +end + +do --- M.semaphore() + --- A semaphore manages an internal counter which is decremented by each + --- `acquire()` call and incremented by each `release()` call. The counter can + --- never go below zero; when `acquire()` finds that it is zero, it blocks, + --- waiting until some task calls `release()`. + --- + --- The preferred way to use a Semaphore is with the `with()` method, which + --- automatically acquires and releases the semaphore around a function call. + --- @class vim.async.Semaphore + --- @field private _permits integer + --- @field private _max_permits integer + --- @field package _event vim.async.Event + local Semaphore = {} + Semaphore.__index = Semaphore + + --- Executes a function within the semaphore. + --- + --- This acquires the semaphore before running the function and releases it + --- after the function completes, even if it errors. + --- @async + --- @generic R + --- @param fn async fun(): R... # Function to execute within the semaphore's context. + --- @return R... # Result(s) of the executed function. + function Semaphore:with(fn) + self:acquire() + local r = pack_len(pcall(fn)) + self:release() + local stat = r[1] + if not stat then + local err = r[2] + error(err) + end + return unpack_len(r, 2) + end + + --- Acquire a semaphore. + --- + --- If the internal counter is greater than zero, decrement it by `1` and + --- return immediately. If it is `0`, wait until a `release()` is called. + --- @async + function Semaphore:acquire() + self._event:wait() + self._permits = self._permits - 1 + assert(self._permits >= 0, 'Semaphore value is negative') + if self._permits == 0 then + self._event:clear() + end + end + + --- Release a semaphore. + --- + --- Increments the internal counter by `1`. Can wake + --- up a task waiting to acquire the semaphore. + function Semaphore:release() + if self._permits >= self._max_permits then + error('Semaphore value is greater than max permits', 2) + end + self._permits = self._permits + 1 + self._event:set(1) + end + + --- Create an async semaphore that allows up to a given number of acquisitions. + --- + --- ```lua + --- vim.async.run(function() + --- local semaphore = vim.async.semaphore(2) + --- + --- local tasks = {} + --- + --- local value = 0 + --- for i = 1, 10 do + --- tasks[i] = vim.async.run(function() + --- semaphore:with(function() + --- value = value + 1 + --- vim.async.sleep(10) + --- print(value) -- Never more than 2 + --- value = value - 1 + --- end) + --- end) + --- end + --- + --- vim.async.await_all(tasks) + --- assert(value <= 2) + --- end) + --- ``` + --- @param permits? integer (default: 1) + --- @return vim.async.Semaphore + function M.semaphore(permits) + vim.validate('permits', permits, 'number', true) + permits = permits or 1 + local obj = setmetatable({ + _max_permits = permits, + _permits = permits, + _event = M._event(), + }, Semaphore) + obj._event:set() + return obj + end +end + +do --- M._inspect_tree() + --- @private + --- @param parent? vim.async.Task + --- @param prefix? string + --- @return string[] + local function inspect(parent, prefix) + local tasks = {} --- @type table?> + if parent then + for _, task in pairs(parent._children) do + if not task._internal then + tasks[#tasks + 1] = task + end + end + else + -- Gather for all detached tasks + for _, task in pairs(threads) do + if not task._parent and not task._internal then + tasks[#tasks + 1] = task + end + end + end + + local r = {} --- @type string[] + for i, task in ipairs(tasks) do + local last = i == #tasks + r[#r + 1] = ('%s%s%s%s [%s]'):format( + prefix or '', + parent and (last and '└─ ' or '├─ ') or '', + task.name or '', + task._caller, + task:status() + ) + local child_prefix = (prefix or '') .. (parent and (last and ' ' or '│ ') or '') + vim.list_extend(r, inspect(task, child_prefix)) + end + return r + end + + --- Inspect the current async task tree. + --- + --- Returns a string representation of the task tree, showing the names and + --- statuses of each task. + --- @return string + function M._inspect_tree() + -- Inspired by https://docs.python.org/3.14/whatsnew/3.14.html#asyncio-introspection-capabilities + return table.concat(inspect(), '\n') + end +end return M diff --git a/lua/gitlinker/commons/async/_util.lua b/lua/gitlinker/commons/async/_util.lua new file mode 100644 index 00000000..b03bfa53 --- /dev/null +++ b/lua/gitlinker/commons/async/_util.lua @@ -0,0 +1,41 @@ +---@diagnostic disable +local M = {} + +--- @param ... any +--- @return {[integer]: any, n: integer} +function M.pack_len(...) + return { n = select('#', ...), ... } +end + +--- like unpack() but use the length set by F.pack_len if present +--- @param t? { [integer]: any, n?: integer } +--- @param first? integer +--- @return any... +function M.unpack_len(t, first) + if t then + return unpack(t, first or 1, t.n or table.maxn(t)) + end +end + +--- @return_cast obj function +function M.is_callable(obj) + return vim.is_callable(obj) +end + +--- Create a function that runs a function when it is garbage collected. +--- @generic F : function +--- @param f F +--- @param gc fun() +--- @return F +function M.gc_fun(f, gc) + local proxy = newproxy(true) + local proxy_mt = getmetatable(proxy) + proxy_mt.__gc = gc + proxy_mt.__call = function(_, ...) + return f(...) + end + + return proxy +end + +return M diff --git a/lua/gitlinker/commons/async/misc.lua b/lua/gitlinker/commons/async/misc.lua new file mode 100644 index 00000000..de5a6405 --- /dev/null +++ b/lua/gitlinker/commons/async/misc.lua @@ -0,0 +1,92 @@ +---@diagnostic disable +local async = require('gitlinker.commons.async') + +-- Examples of functions built on top of async.lua + +local M = {} + +--- Like async.join, but with a limit on the number of concurrent tasks. +--- @async +--- @param max_jobs integer +--- @param funs (async fun())[] +function M.join_n_1(max_jobs, funs) + if #funs == 0 then + return + end + + max_jobs = math.min(max_jobs, #funs) + + local running = {} --- @type async.Task[] + + -- Start the first batch of tasks + for i = 1, max_jobs do + running[i] = assert(funs[i])() + end + + -- As tasks finish, add new ones + for i = max_jobs + 1, #funs do + local finished = async.await_any(running) + --- @cast finished -? + running[finished] = async.run(assert(funs[i])) + end + + -- Wait for all tasks to finish + async.await_all(running) +end + +--- Like async.join, but with a limit on the number of concurrent tasks. +--- (different implementation and doesn't use `async.await_any()`) +--- @async +--- @param max_jobs integer +--- @param funs (async fun())[] +function M.join_n_2(max_jobs, funs) + if #funs == 0 then + return + end + + max_jobs = math.min(max_jobs, #funs) + + --- @type (async fun())[] + local remaining = { select(max_jobs + 1, unpack(funs)) } + local to_go = #funs + + async.await(1, function(finish) + local function cb() + to_go = to_go - 1 + if to_go == 0 then + finish() + elseif #remaining > 0 then + local next_task = table.remove(remaining) + async.run(next_task):await(cb) + end + end + + for i = 1, max_jobs do + async.run(assert(funs[i])):await(cb) + end + end) +end + +--- Like async.join, but with a limit on the number of concurrent tasks. +--- @async +--- @param max_jobs integer +--- @param funs (async fun())[] +function M.join_n_3(max_jobs, funs) + if #funs == 0 then + return + end + + local semaphore = async.semaphore(max_jobs) + + local tasks = {} --- @type async.Task[] + + for _, fun in ipairs(funs) do + tasks[#tasks + 1] = async.run(function() + semaphore:with(fun) + end) + end + + async.await_all(tasks) +end + +return M diff --git a/lua/gitlinker/commons/fio.lua b/lua/gitlinker/commons/fio.lua index 1d2bf72a..8118a865 100644 --- a/lua/gitlinker/commons/fio.lua +++ b/lua/gitlinker/commons/fio.lua @@ -2,406 +2,69 @@ local uv = vim.uv or vim.loop local M = {} --- FileLineReader { - ---- @class commons.FileLineReader ---- @field filename string file name. ---- @field handler integer file handle. ---- @field filesize integer file size in bytes. ---- @field offset integer current read position. ---- @field batchsize integer chunk size for each read operation running internally. ---- @field buffer string? internal data buffer. -local FileLineReader = {} - ---- @param filename string ---- @param batchsize integer? ---- @return commons.FileLineReader? -function FileLineReader:open(filename, batchsize) - local handler = uv.fs_open(filename, "r", 438) --[[@as integer]] - if type(handler) ~= "number" then - error( - string.format( - "|commons.fio - FileLineReader:open| failed to fs_open file: %s", - vim.inspect(filename) - ) - ) - return nil - end - local fstat = uv.fs_fstat(handler) --[[@as table]] - if type(fstat) ~= "table" then - error( - string.format( - "|commons.fio - FileLineReader:open| failed to fs_fstat file: %s", - vim.inspect(filename) - ) - ) - uv.fs_close(handler) - return nil - end - - local o = { - filename = filename, - handler = handler, - filesize = fstat.size, - offset = 0, - batchsize = batchsize or 4096, - buffer = nil, - } - setmetatable(o, self) - self.__index = self - return o -end - ---- @private ---- @return integer -function FileLineReader:_read_chunk() - local chunksize = (self.filesize >= self.offset + self.batchsize) and self.batchsize - or (self.filesize - self.offset) - if chunksize <= 0 then - return 0 - end - local data, --[[@as string?]] - read_err, - read_name = - uv.fs_read(self.handler, chunksize, self.offset) - if read_err then - error( - string.format( - "|commons.fio - FileLineReader:_read_chunk| failed to fs_read file: %s, read_error:%s, read_name:%s", - vim.inspect(self.filename), - vim.inspect(read_err), - vim.inspect(read_name) - ) - ) - return -1 - end - -- append to buffer - self.buffer = self.buffer and (self.buffer .. data) or data --[[@as string]] - self.offset = self.offset + #data - return #data -end - ---- @return boolean -function FileLineReader:has_next() - self:_read_chunk() - return self.buffer ~= nil and string.len(self.buffer) > 0 -end - ---- @return string? -function FileLineReader:next() - --- @return string? - local function impl() - local str = require("gitlinker.commons.str") - if self.buffer == nil then - return nil - end - self.buffer = self.buffer:gsub("\r\n", "\n") - local nextpos = str.find(self.buffer, "\n") - if nextpos then - local line = self.buffer:sub(1, nextpos - 1) - self.buffer = self.buffer:sub(nextpos + 1) - return line - else - return nil - end - end - - repeat - local nextline = impl() - if nextline then - return nextline - end - until self:_read_chunk() <= 0 - - local nextline = impl() - if nextline then - return nextline - else - local buf = self.buffer - self.buffer = nil - return buf - end -end - --- Close the file reader. -function FileLineReader:close() - if self.handler then - uv.fs_close(self.handler) - self.handler = nil - end -end - -M.FileLineReader = FileLineReader - --- FileLineReader } - --- CachedFileReader { - ---- @class commons.CachedFileReader ---- @field filename string ---- @field cache string? -local CachedFileReader = {} - --- @param filename string ---- @return commons.CachedFileReader -function CachedFileReader:open(filename) - local o = { - filename = filename, - cache = nil, - } - setmetatable(o, self) - self.__index = self - return o -end - ---- @param opts {trim:boolean?}? --- @return string? -function CachedFileReader:read(opts) - opts = opts or {} - opts.trim = type(opts.trim) == "boolean" and opts.trim or false - - if self.cache == nil then - self.cache = M.readfile(self.filename) - end - if self.cache == nil then - return self.cache - end - return opts.trim and vim.trim(self.cache) or self.cache -end - ---- @return string? -function CachedFileReader:reset() - local saved = self.cache - self.cache = nil - return saved -end - -M.CachedFileReader = CachedFileReader - --- CachedFileReader } - ---- @param filename string ---- @param opts {trim:boolean?}? ---- @return string? -M.readfile = function(filename, opts) - opts = opts or { trim = false } - opts.trim = type(opts.trim) == "boolean" and opts.trim or false - +M.readfile = function(filename) local f = io.open(filename, "r") if f == nil then return nil end local content = f:read("*a") f:close() - return opts.trim and vim.trim(content) or content + return content end --- @alias commons.AsyncReadFileOnComplete fun(data:string?):any --- @alias commons.AsyncReadFileOnError fun(step:string?,err:string?):any --- @param filename string ---- @param opts {on_complete:commons.AsyncReadFileOnComplete,on_error:commons.AsyncReadFileOnError?,trim:boolean?} +--- @param opts {on_complete:commons.AsyncReadFileOnComplete,on_error:commons.AsyncReadFileOnError?} M.asyncreadfile = function(filename, opts) assert(type(opts) == "table") assert(type(opts.on_complete) == "function") - opts.trim = type(opts.trim) == "boolean" and opts.trim or false if type(opts.on_error) ~= "function" then - opts.on_error = function(step1, err1) + opts.on_error = function(step, err) error( string.format( "failed to read file(%s), filename:%s, error:%s", - vim.inspect(step1), + vim.inspect(step), vim.inspect(filename), - vim.inspect(err1) + vim.inspect(err) ) ) end end - local open_result, open_err = uv.fs_open(filename, "r", 438, function(open_complete_err, fd) - if open_complete_err then - opts.on_error("fs_open complete", open_complete_err) + uv.fs_open(filename, "r", 438, function(on_open_err, fd) + if on_open_err then + opts.on_error("fs_open", on_open_err) return end - uv.fs_fstat(fd --[[@as integer]], function(fstat_complete_err, stat) - if fstat_complete_err then - opts.on_error("fs_fstat complete", fstat_complete_err) + uv.fs_fstat(fd --[[@as integer]], function(on_fstat_err, stat) + if on_fstat_err then + opts.on_error("fs_fstat", on_fstat_err) return end if not stat then - opts.on_error("fs_fstat returns nil", fstat_complete_err) + opts.on_error("fs_fstat", "fs_fstat returns nil") return end - uv.fs_read(fd --[[@as integer]], stat.size, 0, function(read_complete_err, data) - if read_complete_err then - opts.on_error("fs_read complete", read_complete_err) + uv.fs_read(fd --[[@as integer]], stat.size, 0, function(on_read_err, data) + if on_read_err then + opts.on_error("fs_read", on_read_err) return end - uv.fs_close(fd --[[@as integer]], function(close_complete_err) - if opts.trim and type(data) == "string" then - local trimmed_data = vim.trim(data) - opts.on_complete(trimmed_data) - else - opts.on_complete(data) - end - if close_complete_err then - opts.on_error("fs_close complete", close_complete_err) + uv.fs_close(fd --[[@as integer]], function(on_close_err) + opts.on_complete(data) + if on_close_err then + opts.on_error("fs_close", on_close_err) end end) end) end) end) - if open_result == nil then - opts.on_error("fs_open", open_err) - end end ---- @param filename string ---- @return string[]|nil -M.readlines = function(filename) - local ok, reader = pcall(M.FileLineReader.open, M.FileLineReader, filename) --[[@as commons.FileLineReader]] - if not ok or reader == nil then - return nil - end - local results = {} - while reader:has_next() do - table.insert(results, reader:next()) - end - reader:close() - return results -end - ---- @alias commons.AsyncReadLinesOnLine fun(line:string):any ---- @alias commons.AsyncReadLinesOnComplete fun(bytes:integer):any ---- @alias commons.AsyncReadLinesOnError fun(step:string?,err:string?):any ---- @param filename string ---- @param opts {on_line:commons.AsyncReadLinesOnLine,on_complete:commons.AsyncReadLinesOnComplete,on_error:commons.AsyncReadLinesOnError?,batchsize:integer?} -M.asyncreadlines = function(filename, opts) - assert(type(opts) == "table") - assert(type(opts.on_line) == "function") - local batchsize = opts.batchsize or 4096 - - if type(opts.on_error) ~= "function" then - opts.on_error = function(step1, err1) - error( - string.format( - "failed to async read file by lines(%s), filename:%s, error:%s", - vim.inspect(step1), - vim.inspect(filename), - vim.inspect(err1) - ) - ) - end - end - - local open_result, open_err = uv.fs_open(filename, "r", 438, function(open_complete_err, fd) - if open_complete_err then - opts.on_error("fs_open complete", open_complete_err) - return - end - local fstat_result, fstat_err = uv.fs_fstat( - fd --[[@as integer]], - function(fstat_complete_err, stat) - if fstat_complete_err then - opts.on_error("fs_fstat complete", fstat_complete_err) - return - end - if stat == nil then - opts.on_error("fs_fstat returns nil", fstat_complete_err) - return - end - - local fsize = stat.size - local offset = 0 - local buffer = nil - - local function _process(buf, fn_line_processor) - local str = require("gitlinker.commons.str") - - local i = 1 - while i <= #buf do - local newline_pos = str.find(buf, "\n", i) - if not newline_pos then - break - end - local line = buf:sub(i, newline_pos - 1) - fn_line_processor(line) - i = newline_pos + 1 - end - return i - end - - local function _chunk_read() - local read_result, read_err = uv.fs_read( - fd --[[@as integer]], - batchsize, - offset, - function(read_complete_err, data) - if read_complete_err then - opts.on_error("fs_read complete", read_complete_err) - return - end - - if data then - offset = offset + #data - - buffer = buffer and (buffer .. data) or data --[[@as string]] - buffer = buffer:gsub("\r\n", "\n") - local pos = _process(buffer, opts.on_line) - -- truncate the processed lines if still exists any - buffer = pos <= #buffer and buffer:sub(pos, #buffer) or nil - else - -- no more data - - -- if buffer still has not been processed - if buffer then - local pos = _process(buffer, opts.on_line) - buffer = pos <= #buffer and buffer:sub(pos, #buffer) or nil - - -- process all the left buffer till the end of file - if buffer then - opts.on_line(buffer) - end - end - - -- close file - local close_result, close_err = uv.fs_close( - fd --[[@as integer]], - function(close_complete_err) - if close_complete_err then - opts.on_error("fs_close complete", close_complete_err) - end - if type(opts.on_complete) == "function" then - opts.on_complete(fsize) - end - end - ) - if close_result == nil then - opts.on_error("fs_close", close_err) - end - end - end - ) - if read_result == nil then - opts.on_error("fs_read", read_err) - end - end - - _chunk_read() - end - ) - - if fstat_result == nil then - opts.on_error("fs_fstat", fstat_err) - end - end) - if open_result == nil then - opts.on_error("fs_open", open_err) - end -end - --- AsyncFileLineReader } - --- @param filename string file name. --- @param content string file content. --- @return integer returns `0` if success, returns `-1` if failed. @@ -415,65 +78,49 @@ M.writefile = function(filename, content) return 0 end +--- @alias commons.AsyncWriteFileOnComplete fun(bytes:integer?):any +--- @alias commons.AsyncWriteFileOnError fun(step:string?,err:string?):any --- @param filename string file name. --- @param content string file content. ---- @param on_complete fun(bytes:integer?):any callback on write complete. ---- 1. `bytes`: written data bytes. -M.asyncwritefile = function(filename, content, on_complete) - uv.fs_open(filename, "w", 438, function(open_err, fd) - if open_err then +--- @param opts {on_complete:commons.AsyncWriteFileOnComplete,on_error:commons.AsyncWriteFileOnError?} +M.asyncwritefile = function(filename, content, opts) + assert(type(opts) == "table") + assert(type(opts.on_complete) == "function") + + if type(opts.on_error) ~= "function" then + opts.on_error = function(step, err) error( - string.format("failed to open(w) file %s: %s", vim.inspect(filename), vim.inspect(open_err)) + string.format( + "failed to write file(%s), filename:%s, error:%s", + vim.inspect(step), + vim.inspect(filename), + vim.inspect(err) + ) ) + end + end + + uv.fs_open(filename, "w", 438, function(on_open_err, fd) + if on_open_err then + opts.on_error("fs_open", on_open_err) return end ---@diagnostic disable-next-line: param-type-mismatch - uv.fs_write(fd, content, nil, function(write_err, bytes) - if write_err then - error( - string.format( - "failed to write file %s: %s", - vim.inspect(filename), - vim.inspect(write_err) - ) - ) + uv.fs_write(fd, content, nil, function(on_write_err, bytes) + if on_write_err then + opts.on_error("fs_write", on_write_err) return end ---@diagnostic disable-next-line: param-type-mismatch - uv.fs_close(fd, function(close_err) - if close_err then - error( - string.format( - "failed to close(w) file %s: %s", - vim.inspect(filename), - vim.inspect(close_err) - ) - ) + uv.fs_close(fd, function(on_close_err) + if on_close_err then + opts.on_error("fs_close", on_close_err) return end - if type(on_complete) == "function" then - on_complete(bytes) - end + opts.on_complete(bytes) end) end) end) end ---- @param filename string file name. ---- @param lines string[] content lines. ---- @return integer returns `0` if success, returns `-1` if failed. -M.writelines = function(filename, lines) - local f = io.open(filename, "w") - if not f then - return -1 - end - assert(type(lines) == "table") - for _, line in ipairs(lines) do - assert(type(line) == "string") - f:write(line .. "\n") - end - f:close() - return 0 -end - return M diff --git a/lua/gitlinker/commons/log.lua b/lua/gitlinker/commons/log.lua new file mode 100644 index 00000000..f32a2592 --- /dev/null +++ b/lua/gitlinker/commons/log.lua @@ -0,0 +1,145 @@ +local uv = vim.uv or vim.loop +local IS_WINDOWS = vim.fn.has("win32") > 0 or vim.fn.has("win64") > 0 +local PATH_SEPARATOR = IS_WINDOWS and "\\" or "/" + +-- :lua print(vim.inspect(vim.log.levels)) +local LogLevels = vim.log.levels + +local LogLevelNames = { + [0] = "TRACE", + [1] = "DEBUG", + [2] = "INFO", + [3] = "WARN", + [4] = "ERROR", + [5] = "OFF", +} + +local LogHighlights = { + [0] = "Comment", + [1] = "Comment", + [2] = "None", + [3] = "WarningMsg", + [4] = "ErrorMsg", + [5] = "ErrorMsg", +} + +local LogConfigs = { + name = nil, + level = LogLevels.INFO, + use_console = true, + use_file = false, + file_name = nil, +} + +--- @param opts {name: string, level: integer?, use_console: boolean?, use_file: boolean?, file_name: string?}? +local function setup(opts) + opts = opts or {} + local level = opts.level or LogLevels.INFO + local use_console + if type(opts.use_console) == "boolean" then + use_console = opts.use_console + else + use_console = true + end + local use_file = type(opts.use_file) == "boolean" and opts.use_file or false + local name = opts.name + local file_name = opts.file_name + + LogConfigs.name = name + LogConfigs.level = level + LogConfigs.use_console = use_console + LogConfigs.use_file = use_file + + -- For Windows: $env:USERPROFILE\AppData\Local\nvim-data\${file_name}.log + -- For *NIX: ~/.local/share/nvim/${file_name}.log + LogConfigs.file_name = string.format("%s%s%s", vim.fn.stdpath("data"), PATH_SEPARATOR, file_name) +end + +--- @param level integer +--- @param msg string +local function log(level, msg) + if level < LogConfigs.level then + return + end + + local msg_lines = vim.split(msg, "\n", { plain = true, trimempty = true }) + if LogConfigs.use_console and level >= LogLevels.INFO then + local msg_chunks = {} + for _, line in ipairs(msg_lines) do + if type(line) == "string" and string.len(line) > 0 then + table.insert(msg_chunks, { + string.format("[%s] %s", LogConfigs.name, line), + LogHighlights[level], + }) + end + end + vim.schedule(function() + vim.api.nvim_echo(msg_chunks, false, {}) + end) + end + if LogConfigs.use_file then + local fp = io.open(LogConfigs.file_name, "a") + if fp then + for _, line in ipairs(msg_lines) do + local secs, ms = uv.gettimeofday() + fp:write( + string.format( + "%s.%03d [%s]: %s\n", + os.date("%Y-%m-%d %H:%M:%S", secs), + math.floor(ms / 1000), + LogLevelNames[level], + line + ) + ) + end + fp:close() + end + end +end + +--- @param s string +local function debug(s) + log(LogLevels.DEBUG, s) +end + +--- @param s string +local function info(s) + log(LogLevels.INFO, s) +end + +--- @param s string +local function warn(s) + log(LogLevels.WARN, s) +end + +--- @param s string +local function err(s) + log(LogLevels.ERROR, s) +end + +--- @param s string +local function throw(s) + log(LogLevels.ERROR, s) + error(s) +end + +--- @param cond boolean +--- @param s string +local function ensure(cond, s) + if not cond then + throw(s) + end +end + +local M = { + setup = setup, + debug = debug, + log = log, + info = info, + warn = warn, + err = err, + throw = throw, + ensure = ensure, +} + +return M diff --git a/lua/gitlinker/commons/logging.lua b/lua/gitlinker/commons/logging.lua deleted file mode 100644 index 309869b8..00000000 --- a/lua/gitlinker/commons/logging.lua +++ /dev/null @@ -1,645 +0,0 @@ -local IS_WINDOWS = vim.fn.has("win32") > 0 or vim.fn.has("win64") > 0 -local uv = vim.uv or vim.loop - -local M = {} - --- see: `lua print(vim.inspect(vim.log.levels))` ---- @enum commons.LogLevels -local LogLevels = { - TRACE = 0, - DEBUG = 1, - INFO = 2, - WARN = 3, - ERROR = 4, - OFF = 5, -} - ---- @enum commons.LogLevelNames -local LogLevelNames = { - [0] = "TRACE", - [1] = "DEBUG", - [2] = "INFO", - [3] = "WARN", - [4] = "ERROR", - [5] = "OFF", -} - -M.LogLevels = LogLevels -M.LogLevelNames = LogLevelNames - -local LogHighlights = { - [0] = "Comment", - [1] = "Comment", - [2] = "None", - [3] = "WarningMsg", - [4] = "ErrorMsg", - [5] = "ErrorMsg", -} - --- Formatter { - ---- @class commons.logging.Formatter ---- @field fmt string ---- @field datefmt string ---- @field msecsfmt string -local Formatter = {} - ---- @param fmt string ---- @param opts {datefmt:string?,msecsfmt:string?}? ---- @return commons.logging.Formatter -function Formatter:new(fmt, opts) - assert(type(fmt) == "string") - - opts = opts or { datefmt = "%Y-%m-%d %H:%M:%S", msecsfmt = "%06d" } - opts.datefmt = type(opts.datefmt) == "string" and opts.datefmt or "%Y-%m-%d %H:%M:%S" - opts.msecsfmt = type(opts.msecsfmt) == "string" and opts.msecsfmt or "%06d" - - local o = { - fmt = fmt, - datefmt = opts.datefmt, - msecsfmt = opts.msecsfmt, - } - setmetatable(o, self) - self.__index = self - return o -end - -local FORMATTING_TAGS = { - LEVEL_NO = "%(levelno)s", - LEVEL_NAME = "%(levelname)s", - MESSAGE = "%(message)s", - ASCTIME = "%(asctime)s", - MSECS = "%(msecs)d", - NAME = "%(name)s", - PROCESS = "%(process)d", - FILE_NAME = "%(filename)s", - LINE_NO = "%(lineno)d", - FUNC_NAME = "%(funcName)s", -} - ---- @param meta table ---- @return string -function Formatter:format(meta) - local str = require("gitlinker.commons.str") - - local n = string.len(self.fmt) - - local function make_detect(tag) - local function impl(idx) - if idx - 1 >= 1 and string.sub(self.fmt, idx - 1, idx) == "%%" then - return false - end - - local endpos = idx + string.len(FORMATTING_TAGS[tag]) - 1 - if endpos > n then - return false - end - - return str.startswith(string.sub(self.fmt, idx, endpos), FORMATTING_TAGS[tag]) - end - return impl - end - - local tags = { - "LEVEL_NO", - "LEVEL_NAME", - "MESSAGE", - "ASCTIME", - "MSECS", - "NAME", - "PROCESS", - "FILE_NAME", - "LINE_NO", - "FUNC_NAME", - } - - local builder = {} - local i = 1 - local tmp = "" - - while i <= n do - local hit = false - for _, tag in ipairs(tags) do - local is_tag = make_detect(tag) - if is_tag(i) then - if string.len(tmp) > 0 then - table.insert(builder, tmp) - tmp = "" - end - i = i + string.len(FORMATTING_TAGS[tag]) - hit = true - if tag == "ASCTIME" then - table.insert(builder, os.date(self.datefmt, meta.SECONDS)) - elseif tag == "MSECS" then - table.insert(builder, string.format(self.msecsfmt, meta.MSECS)) - elseif meta[tag] ~= nil then - table.insert(builder, tostring(meta[tag])) - end - break - end - end - - if not hit then - tmp = tmp .. string.sub(self.fmt, i, i) - i = i + 1 - end - end - - return table.concat(builder, "") -end - -M.Formatter = Formatter - --- Formatter } - --- Handler { - ---- @class commons.logging.Handler -local Handler = {} - ---- @param meta commons.logging._MetaInfo -function Handler:write(meta) - assert(false) -end - --- ConsoleHandler { - ---- @class commons.logging.ConsoleHandler : commons.logging.Handler ---- @field formatter commons.logging.Formatter -local ConsoleHandler = {} - ---- @param formatter commons.logging.Formatter? ---- @return commons.logging.ConsoleHandler -function ConsoleHandler:new(formatter) - if formatter == nil then - formatter = Formatter:new("[%(name)s] %(message)s") - end - - local o = { - formatter = formatter, - } - setmetatable(o, self) - self.__index = self - return o -end - ---- @param meta commons.logging._MetaInfo -function ConsoleHandler:write(meta) - if meta.LEVEL_NO < LogLevels.INFO then - return - end - - local msg_lines = vim.split(meta.MESSAGE, "\n", { plain = true }) - for _, line in ipairs(msg_lines) do - local chunks = {} - local line_meta = vim.tbl_deep_extend("force", vim.deepcopy(meta), { MESSAGE = line }) - local record = self.formatter:format(line_meta) - table.insert(chunks, { - record, - LogHighlights[line_meta.LEVEL_NO], - }) - vim.schedule(function() - vim.api.nvim_echo(chunks, false, {}) - end) - end -end - -M.ConsoleHandler = ConsoleHandler - --- ConsoleHandler } - --- FileHandler { - ---- @class commons.logging.FileHandler : commons.logging.Handler ---- @field formatter commons.logging.Formatter ---- @field filepath string ---- @field filemode "a"|"w" ---- @field filehandle any -local FileHandler = {} - ---- @param filepath string ---- @param filemode "a"|"w"|nil ---- @param formatter commons.logging.Formatter? ---- @return commons.logging.FileHandler -function FileHandler:new(filepath, filemode, formatter) - assert(type(filepath) == "string") - assert(filemode == "a" or filemode == "w" or filemode == nil) - - if formatter == nil then - formatter = - Formatter:new("%(asctime)s,%(msecs)d [%(filename)s:%(lineno)d] %(levelname)s: %(message)s") - end - - filemode = filemode ~= nil and string.lower(filemode) or "a" - local filehandle = nil - - if filemode == "w" then - filehandle = io.open(filepath, "w") - assert(filehandle ~= nil, string.format("failed to open file:%s", vim.inspect(filepath))) - end - - local o = { - formatter = formatter, - filepath = filepath, - filemode = filemode, - filehandle = filehandle, - } - setmetatable(o, self) - self.__index = self - return o -end - -function FileHandler:close() - if self.filemode == "w" or self.filehandle ~= nil then - self.filehandle:close() - self.filehandle = nil - end -end - ---- @param meta commons.logging._MetaInfo -function FileHandler:write(meta) - local fp = nil - - if self.filemode == "w" then - assert( - self.filehandle ~= nil, - string.format("failed to write file log:%s", vim.inspect(self.filepath)) - ) - fp = self.filehandle - elseif self.filemode == "a" then - fp = io.open(self.filepath, "a") - end - - if fp then - local record = self.formatter:format(meta) - fp:write(string.format("%s\n", record)) - end - - if self.filemode == "a" and fp ~= nil then - fp:close() - end -end - -M.FileHandler = FileHandler - --- FileHandler } - --- Handler } - --- Logger { - ---- @class commons.logging.Logger ---- @field name string ---- @field level commons.LogLevels ---- @field handlers commons.logging.Handler[] -local Logger = {} - ---- @param name string ---- @param level commons.LogLevels ---- @return commons.logging.Logger -function Logger:new(name, level) - assert(type(name) == "string") - assert(type(level) == "number" and LogLevelNames[level] ~= nil) - - local o = { - name = name, - level = level, - handlers = {}, - } - setmetatable(o, self) - self.__index = self - return o -end - ---- @param handler commons.logging.Handler -function Logger:add_handler(handler) - assert(type(handler) == "table") - table.insert(self.handlers, handler) -end - ---- @param dbg debuginfo? ---- @param lvl integer ---- @param msg string -function Logger:_log(dbg, lvl, msg) - assert(type(lvl) == "number" and LogLevelNames[lvl] ~= nil) - - if lvl < self.level then - return - end - - for _, handler in ipairs(self.handlers) do - local secs, millis = uv.gettimeofday() - --- @alias commons.logging._MetaInfo {LEVEL_NO:commons.LogLevels,LEVEL_NAME:commons.LogLevelNames,MESSAGE:string,SECONDS:integer,MILLISECONDS:integer,FILE_NAME:string,LINE_NO:integer,FUNC_NAME:string} - local meta_info = { - LEVEL_NO = lvl, - LEVEL_NAME = LogLevelNames[lvl], - MESSAGE = msg, - SECONDS = secs, - MSECS = millis, - NAME = self.name, - PROCESS = uv.os_getpid(), - FILE_NAME = dbg ~= nil and (dbg.source or dbg.short_src) or nil, - LINE_NO = dbg ~= nil and (dbg.currentline or dbg.linedefined) or nil, - FUNC_NAME = dbg ~= nil and (dbg.func or dbg.what) or nil, - } - handler:write(meta_info) - end -end - ---- @param level integer|string ---- @param msg string -function Logger:log(level, msg) - if type(level) == "string" then - assert(LogLevels[string.upper(level)] ~= nil) - level = LogLevels[string.upper(level)] - end - assert(type(level) == "number" and LogHighlights[level] ~= nil) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, level, msg) -end - ---- @param msg string -function Logger:debug(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, LogLevels.DEBUG, msg) -end - ---- @param msg string -function Logger:info(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, LogLevels.INFO, msg) -end - ---- @param msg string -function Logger:warn(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, LogLevels.WARN, msg) -end - ---- @param msg string -function Logger:err(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, LogLevels.ERROR, msg) -end - ---- @param msg string -function Logger:throw(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, LogLevels.ERROR, msg) - error(msg) -end - ---- @param cond any ---- @param msg string -function Logger:ensure(cond, msg) - if not cond then - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - self:_log(dbg, LogLevels.ERROR, msg) - end - assert(cond, msg) -end - -M.Logger = Logger - --- Logger } - ---- @type table -local NAMESPACE = {} - ---- @alias commons.LoggingConfigs {name:string,level:(commons.LogLevels|string)?,console_log:boolean?,file_log:boolean?,file_log_name:string?,file_log_dir:string?,file_log_mode:"a"|"w"|nil} ---- @type commons.LoggingConfigs -local Defaults = { - --- @type string - name = nil, - level = LogLevels.INFO, - console_log = true, - file_log = false, - file_log_name = nil, - file_log_dir = vim.fn.stdpath("data") --[[@as string]], - file_log_mode = "a", -} - ---- @param opts commons.LoggingConfigs -M.setup = function(opts) - local conf = vim.tbl_deep_extend("force", vim.deepcopy(Defaults), opts or {}) - if type(conf.level) == "string" then - assert(LogLevels[string.upper(conf.level)] ~= nil) - conf.level = LogLevels[string.upper(conf.level)] - end - assert(type(conf.level) == "number" and LogHighlights[conf.level] ~= nil) - - local console_handler = ConsoleHandler:new() - local logger = Logger:new(conf.name, conf.level --[[@as commons.LogLevels]]) - logger:add_handler(console_handler) - - if conf.file_log then - assert(type(conf.file_log_name) == "string") - local SEPARATOR = IS_WINDOWS and "\\" or "/" - local filepath = string.format( - "%s%s", - type(conf.file_log_dir) == "string" and (conf.file_log_dir .. SEPARATOR) or "", - conf.file_log_name - ) - local file_handler = FileHandler:new(filepath, conf.file_log_mode or "a") - logger:add_handler(file_handler) - end - - M.add(logger) -end - ---- @param name string ---- @return boolean -M.has = function(name) - assert(type(name) == "string") - return NAMESPACE[name] ~= nil -end - ---- @param name string ---- @return commons.logging.Logger -M.get = function(name) - assert(type(name) == "string") - return NAMESPACE[name] -end - ---- @param logger commons.logging.Logger -M.add = function(logger) - assert(type(logger) == "table") - assert((type(logger.name) == "string" and string.len(logger.name) > 0) or logger.name ~= nil) - assert(NAMESPACE[logger.name] == nil) - NAMESPACE[logger.name] = logger -end - -local ROOT = "root" - ---- @param level integer|string ---- @param msg string -M.log = function(level, msg) - if type(level) == "string" then - assert(LogLevels[string.upper(level)] ~= nil) - level = LogLevels[string.upper(level)] - end - assert(type(level) == "number" and LogHighlights[level] ~= nil) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - local logger = M.get(ROOT) - assert(logger ~= nil) - logger:_log(dbg, level, msg) -end - ---- @param msg string -M.debug = function(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - local logger = M.get(ROOT) - assert(logger ~= nil) - logger:_log(dbg, LogLevels.DEBUG, msg) -end - ---- @param msg string -M.info = function(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - local logger = M.get(ROOT) - assert(logger ~= nil) - logger:_log(dbg, LogLevels.INFO, msg) -end - ---- @param msg string -M.warn = function(msg) - local dbg = debug.getinfo(2, "nfSl") - local logger = M.get(ROOT) - assert(logger ~= nil) - logger:_log(dbg, LogLevels.WARN, msg) -end - ---- @param msg string -M.err = function(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - local logger = M.get(ROOT) - assert(logger ~= nil) - logger:_log(dbg, LogLevels.ERROR, msg) -end - ---- @param msg string -M.throw = function(msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - local logger = M.get(ROOT) - assert(logger ~= nil) - logger:_log(dbg, LogLevels.ERROR, msg) - error(msg) -end - ---- @param cond any ---- @param msg string -M.ensure = function(cond, msg) - local dbglvl = 2 - local dbg = nil - while true do - dbg = debug.getinfo(dbglvl, "nfSl") - if not dbg or dbg.what ~= "C" then - break - end - dbglvl = dbglvl + 1 - end - local logger = M.get(ROOT) - assert(logger ~= nil) - if not cond then - logger:_log(dbg, LogLevels.ERROR, msg) - end - assert(cond, msg) -end - -return M diff --git a/lua/gitlinker/commons/num.lua b/lua/gitlinker/commons/num.lua index 1c076873..f7d668d9 100644 --- a/lua/gitlinker/commons/num.lua +++ b/lua/gitlinker/commons/num.lua @@ -79,78 +79,67 @@ M.le = function(a, b, rel_tol, abs_tol) return M.lt(a, b, rel_tol, abs_tol) or M.eq(a, b, rel_tol, abs_tol) end +--- @param a integer +--- @param b integer +--- @return integer +M.mod = function(a, b) + return math.floor(math.fmod(a, b)) +end + --- @param value number --- @param left number? lower bound, by default INT32_MIN --- @param right number? upper bound, by default INT32_MAX --- @return number -M.bound = function(value, left, right) +M.clamp = function(value, left, right) assert(type(value) == "number") assert(type(left) == "number" or left == nil) assert(type(right) == "number" or right == nil) return math.min(math.max(left or M.INT32_MIN, value), right or M.INT32_MAX) end -local IncrementalId = 0 - ---- @return integer -M.auto_incremental_id = function() - if IncrementalId >= M.INT32_MAX then - IncrementalId = 1 - else - IncrementalId = IncrementalId + 1 - end - return IncrementalId -end - ---- @param a integer ---- @param b integer ---- @return integer -M.mod = function(a, b) - return math.floor(math.fmod(a, b)) -end - --- @param f fun(v:any):number --- @param a any --- @param ... any --- @return integer, integer -M.max = function(f, a, ...) +M.min = function(f, a, ...) assert( type(f) == "function", string.format("first param 'f' must be unary-function returns number value:%s", vim.inspect(f)) ) - local maximal_item = a - local maximal_value = f(a) - local maximal_index = 1 + local minimal_item = a + local minimal_value = f(a) + local minimal_index = 1 for i, o in ipairs({ ... }) do - if f(o) > maximal_value then - maximal_item = o - maximal_index = i + if f(o) < minimal_value then + minimal_item = o + minimal_index = i end end - return maximal_item, maximal_index + return minimal_item, minimal_index end --- @param f fun(v:any):number --- @param a any --- @param ... any --- @return integer, integer -M.min = function(f, a, ...) +M.max = function(f, a, ...) assert( type(f) == "function", string.format("first param 'f' must be unary-function returns number value:%s", vim.inspect(f)) ) - local minimal_item = a - local minimal_value = f(a) - local minimal_index = 1 + local maximal_item = a + local maximal_value = f(a) + local maximal_index = 1 for i, o in ipairs({ ... }) do - if f(o) < minimal_value then - minimal_item = o - minimal_index = i + if f(o) > maximal_value then + maximal_item = o + maximal_index = i end end - return minimal_item, minimal_index + return maximal_item, maximal_index end +-- Drop-in 32-bit random replacement of `math.random`. --- @param m integer? --- @param n integer? --- @return number @@ -180,24 +169,4 @@ M.random = function(m, n) end end ---- @param l any[]|string ---- @return any[]|string -M.shuffle = function(l) - assert(type(l) == "table") - local n = #l - - local new_l = {} - for i = 1, n do - table.insert(new_l, l[i]) - end - - for i = n, 1, -1 do - local j = M.random(n) - local tmp = new_l[j] - new_l[j] = new_l[i] - new_l[i] = tmp - end - return new_l -end - return M diff --git a/lua/gitlinker/commons/path.lua b/lua/gitlinker/commons/path.lua index b002b1b5..f476d041 100644 --- a/lua/gitlinker/commons/path.lua +++ b/lua/gitlinker/commons/path.lua @@ -59,13 +59,9 @@ M.islink = function(p) end --- @param p string ---- @param opts {double_backslash:boolean?}? --- @return string M._normalize_slash = function(p, opts) assert(type(p) == "string") - opts = opts or { double_backslash = false } - opts.double_backslash = type(opts.double_backslash) == "boolean" and opts.double_backslash - or false -- '\\\\' => '\\' local function _double_backslash(s) @@ -84,9 +80,7 @@ M._normalize_slash = function(p, opts) end local result = vim.trim(p) - if opts.double_backslash then - result = _double_backslash(result) - end + result = _double_backslash(result) result = _single_backslash(result) return result end @@ -121,13 +115,11 @@ M.resolve = function(p) end --- @param p string ---- @param opts {double_backslash:boolean?,expand:boolean?,resolve:boolean?}? +--- @param opts {expand:boolean?,resolve:boolean?}? --- @return string M.normalize = function(p, opts) assert(type(p) == "string") - opts = opts or { double_backslash = false, expand = false, resolve = false } - opts.double_backslash = type(opts.double_backslash) == "boolean" and opts.double_backslash - or false + opts = opts or { expand = false, resolve = false } opts.expand = type(opts.expand) == "boolean" and opts.expand or false opts.resolve = type(opts.resolve) == "boolean" and opts.resolve or false @@ -164,59 +156,4 @@ M.normalize = function(p, opts) return M._normalize_slash(result, opts) end ---- @param ... any ---- @return string -M.join = function(...) - return table.concat({ ... }, M.SEPARATOR) -end - ---- @param p string? ---- @return string -M.reduce2home = function(p) - return vim.fn.fnamemodify(p or vim.fn.getcwd(), ":~") --[[@as string]] -end - ---- @param p string? ---- @return string -M.reduce = function(p) - return vim.fn.fnamemodify(p or vim.fn.getcwd(), ":~:.") --[[@as string]] -end - ---- @param p string? ---- @return string -M.shorten = function(p) - return vim.fn.pathshorten(M.reduce(p)) --[[@as string]] -end - ---- @return string -M.pipename = function() - if IS_WINDOWS then - local function uuid() - local secs, ms = vim.loop.gettimeofday() - return table.concat({ - string.format("%x", vim.loop.os_getpid()), - string.format("%x", secs), - string.format("%x", ms), - }, "-") - end - return string.format([[\\.\pipe\nvim-pipe-%s]], uuid()) - else - return vim.fn.tempname() --[[@as string]] - end -end - ---- @param p string? ---- @return string? -M.parent = function(p) - p = p or vim.fn.getcwd() - - local str = require("gitlinker.commons.str") - if str.endswith(p, "/") or str.endswith(p, "\\") then - p = string.sub(p, 1, #p - 1) - end - - local result = vim.fn.fnamemodify(p, ":h") - return string.len(result) < string.len(p) and result or nil -end - return M diff --git a/lua/gitlinker/commons/platform.lua b/lua/gitlinker/commons/platform.lua index 8c2ca636..a588f47d 100644 --- a/lua/gitlinker/commons/platform.lua +++ b/lua/gitlinker/commons/platform.lua @@ -5,7 +5,7 @@ local os_name = uv.os_uname().sysname local os_name_valid = type(os_name) == "string" and string.len(os_name) > 0 M.OS_NAME = os_name -M.IS_WINDOWS = os_name_valid and os_name:match("Windows") ~= nil +M.IS_WINDOWS = vim.fn.has("win32") > 0 or vim.fn.has("win64") > 0 M.IS_MAC = os_name_valid and os_name:match("Darwin") ~= nil M.IS_BSD = vim.fn.has("bsd") > 0 M.IS_LINUX = os_name_valid and os_name:match("Linux") ~= nil diff --git a/lua/gitlinker/commons/spawn.lua b/lua/gitlinker/commons/spawn.lua deleted file mode 100644 index c8516f2e..00000000 --- a/lua/gitlinker/commons/spawn.lua +++ /dev/null @@ -1,203 +0,0 @@ -local M = {} - ---- @alias commons.SpawnOnLine fun(line:string):any ---- @alias commons.SpawnOnExit fun(result:{exitcode:integer?,signal:integer?}?):nil ---- @alias commons.SpawnOpts {on_stdout:commons.SpawnOnLine,on_stderr:commons.SpawnOnLine?,[string]:any} ---- @alias commons.SpawnJob {obj:vim.SystemObj,opts:commons.SpawnOpts,on_exit:commons.SpawnOnExit?} - ---- @param cmd string[] ---- @param opts commons.SpawnOpts? ---- @param on_exit commons.SpawnOnExit? ---- @return commons.SpawnJob -local function _impl(cmd, opts, on_exit) - opts = opts or {} - - if opts.text == nil then - opts.text = true - end - if type(opts.on_stderr) ~= "function" then - opts.on_stderr = function() end - end - - assert(type(opts.on_stdout) == "function", "Spawn job must have 'on_stdout' function in 'opts'") - assert(type(opts.on_stderr) == "function", "Spawn job must have 'on_stderr' function in 'opts'") - assert(type(on_exit) == "function" or on_exit == nil) - - --- @param buffer string - --- @param fn_line_processor commons.SpawnOnLine - --- @return integer - local function _process(buffer, fn_line_processor) - local str = require("gitlinker.commons.str") - - local i = 1 - while i <= #buffer do - local newline_pos = str.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 - - local stdout_buffer = nil - - --- @param err string? - --- @param data string? - local function _handle_stdout(err, data) - if err then - error( - string.format( - "failed to read stdout on cmd:%s, error:%s", - vim.inspect(cmd), - vim.inspect(err) - ) - ) - return - end - - if data then - -- append data to buffer - stdout_buffer = stdout_buffer and (stdout_buffer .. data) or data - -- search buffer and process each line - local i = _process(stdout_buffer, opts.on_stdout) - -- truncate the processed lines if still exists any - stdout_buffer = i <= #stdout_buffer and stdout_buffer:sub(i, #stdout_buffer) or nil - elseif stdout_buffer then - -- foreach the data_buffer and find every line - local i = _process(stdout_buffer, opts.on_stdout) - if i <= #stdout_buffer then - local line = stdout_buffer:sub(i, #stdout_buffer) - opts.on_stdout(line) - stdout_buffer = nil - end - end - end - - local stderr_buffer = nil - - --- @param err string? - --- @param data string? - local function _handle_stderr(err, data) - if err then - error( - string.format( - "failed to read stderr on cmd:%s, error:%s", - vim.inspect(cmd), - vim.inspect(err) - ) - ) - return - end - - if data then - stderr_buffer = stderr_buffer and (stderr_buffer .. data) or data - local i = _process(stderr_buffer, opts.on_stderr) - stderr_buffer = i <= #stderr_buffer and stderr_buffer:sub(i, #stderr_buffer) or nil - elseif stderr_buffer then - local i = _process(stderr_buffer, opts.on_stderr) - if i <= #stderr_buffer then - local line = stderr_buffer:sub(i, #stderr_buffer) - opts.on_stderr(line) - stderr_buffer = nil - end - end - end - - --- @param completed vim.SystemCompleted - local function _handle_exit(completed) - assert(type(on_exit) == "function") - on_exit({ exitcode = completed.code, signal = completed.signal }) - end - - local obj - if type(on_exit) == "function" then - obj = vim.system(cmd, { - cwd = opts.cwd, - env = opts.env, - clear_env = opts.clear_env, - ---@diagnostic disable-next-line: assign-type-mismatch - stdin = opts.stdin, - stdout = _handle_stdout, - stderr = _handle_stderr, - text = opts.text, - timeout = opts.timeout, - detach = opts.detach, - }, _handle_exit) - else - obj = vim.system(cmd, { - cwd = opts.cwd, - env = opts.env, - clear_env = opts.clear_env, - ---@diagnostic disable-next-line: assign-type-mismatch - stdin = opts.stdin, - stdout = _handle_stdout, - stderr = _handle_stderr, - text = opts.text, - timeout = opts.timeout, - detach = opts.detach, - }) - end - - return { obj = obj, opts = opts, on_exit = on_exit } -end - ---- @param cmd string[] ---- @param opts commons.SpawnOpts? ---- @param on_exit commons.SpawnOnExit ---- @return commons.SpawnJob -M.detached = function(cmd, opts, on_exit) - opts = opts or {} - - assert( - type(opts.on_stdout) == "function", - "Detached spawn job must have 'on_stdout' function in 'opts'" - ) - assert(opts.on_exit == nil, "Detached spawn job cannot have 'on_exit' function in 'opts'") - assert( - type(on_exit) == "function", - "Detached spawn job must have 'on_exit' function in 3rd parameter" - ) - - return _impl(cmd, opts, on_exit) -end - ---- @param cmd string[] ---- @param opts commons.SpawnOpts? ---- @return commons.SpawnJob -M.waitable = function(cmd, opts) - opts = opts or {} - - assert( - type(opts.on_stdout) == "function", - "Waitable spawn job must have 'on_stdout' function in 'opts'" - ) - assert(opts.on_exit == nil, "Waitable spawn job cannot have 'on_exit' function in 'opts'") - - return _impl(cmd, opts) -end - ---- @param job commons.SpawnJob ---- @param timeout integer? ---- @return {exitcode:integer?,signal:integer?} -M.wait = function(job, timeout) - assert(type(job) == "table", "Spawn job must be a 'commons.SpawnJob' object") - assert(job.obj ~= nil, "Spawn job must be a 'commons.SpawnJob' object") - assert(type(job.opts) == "table", "Spawn job must be a 'commons.SpawnJob' object") - assert( - job.on_exit == nil, - "Detached spawn job cannot 'wait' for its exit, it already has 'on_exit' in 3rd parameter for its exit" - ) - - local completed - if type(timeout) == "number" and timeout >= 0 then - completed = job.obj:wait(timeout) --[[@as vim.SystemCompleted]] - else - completed = job.obj:wait() --[[@as vim.SystemCompleted]] - end - return { exitcode = completed.code, signal = completed.signal } -end - -return M diff --git a/lua/gitlinker/commons/str.lua b/lua/gitlinker/commons/str.lua index 0512ae9d..26c8fe94 100644 --- a/lua/gitlinker/commons/str.lua +++ b/lua/gitlinker/commons/str.lua @@ -269,7 +269,6 @@ M.setchar = function(s, pos, ch) assert(string_len(ch) == 1) local n = string_len(s) - pos = require("gitlinker.commons.tbl").list_index(pos, n) local buffer = "" if pos > 1 then diff --git a/lua/gitlinker/commons/tbl.lua b/lua/gitlinker/commons/tbl.lua index c91ef17a..82acef5b 100644 --- a/lua/gitlinker/commons/tbl.lua +++ b/lua/gitlinker/commons/tbl.lua @@ -12,25 +12,6 @@ M.tbl_not_empty = function(t) return type(t) == "table" and not vim.tbl_isempty(t) end ---- @param t any? ---- @param ... any ---- @return any -M.tbl_get = function(t, ...) - local args = { ... } - if #args == 0 then - return t - end - local e = t --[[@as table]] - for _, k in ipairs(args) do - if type(e) == "table" and e[k] ~= nil then - e = e[k] - else - return nil - end - end - return e -end - --- @param t any[] --- @param v any --- @param compare (fun(a:any, b:any):boolean)|nil @@ -63,14 +44,6 @@ M.list_not_empty = function(l) return type(l) == "table" and #l > 0 end ---- @param i integer ---- @param n integer ---- @return integer -M.list_index = function(i, n) - assert(n >= 0) - return i > 0 and i or (n + i + 1) -end - --- @param l any[] --- @param v any --- @param compare (fun(a:any, b:any):boolean)|nil @@ -91,644 +64,4 @@ M.list_contains = function(l, v, compare) return false end ---- @class commons.List ---- @field _data any[] -local List = {} - ---- @param l any[] ---- @return commons.List -function List:move(l) - assert(type(l) == "table") - - local o = { _data = l } - setmetatable(o, self) - self.__index = self - return o -end - ---- @param l any[] ---- @return commons.List -function List:copy(l) - assert(type(l) == "table") - - local new_l = {} - for i, v in ipairs(l) do - table.insert(new_l, v) - end - return List:move(new_l) -end - ---- @param ... any ---- @return commons.List -function List:of(...) - return List:move({ ... }) -end - ---- @return any[] -function List:data() - return self._data -end - ---- @return integer -function List:length() - return #self._data -end - ---- @return boolean -function List:empty() - return #self._data == 0 -end - ---- @param index integer ---- @return any -function List:at(index) - local normalized_index = M.list_index(index, self:length()) - return self._data[normalized_index] -end - ---- @return any -function List:first() - return self:at(1) -end - ---- @return any -function List:last() - return self:at(self:length()) -end - ---- @param other commons.List ---- @return commons.List -function List:concat(other) - assert(M.is_list(other)) - local l = {} - for i, v in ipairs(self._data) do - table.insert(l, v) - end - for i, v in ipairs(other._data) do - table.insert(l, v) - end - return List:move(l) -end - ---- @param separator string? ---- @return string -function List:join(separator) - separator = separator or " " - return table.concat(self._data, separator) -end - ---- @param f fun(value:any, index:integer):boolean ---- @return boolean -function List:every(f) - assert(type(f) == "function") - for i, v in ipairs(self._data) do - if not f(v, i) then - return false - end - end - return true -end - ---- @param f fun(value:any, index:integer):boolean ---- @return boolean -function List:some(f) - assert(type(f) == "function") - for i, v in ipairs(self._data) do - if f(v, i) then - return true - end - end - return false -end - ---- @param f fun(value:any, index:integer):boolean ---- @return boolean -function List:none(f) - assert(type(f) == "function") - for i, v in ipairs(self._data) do - if f(v, i) then - return false - end - end - return true -end - ---- @param f fun(value:any, index:integer):boolean ---- @return commons.List -function List:filter(f) - assert(type(f) == "function") - local l = {} - for i, v in ipairs(self._data) do - if f(v, i) then - table.insert(l, v) - end - end - return List:move(l) -end - ---- @param f fun(value:any, index:integer):boolean ---- @return any?, integer -function List:find(f) - assert(type(f) == "function") - for i, v in ipairs(self._data) do - if f(v, i) then - return v, i - end - end - return nil, -1 -end - ---- @param f fun(value:any, index:integer):boolean ---- @return any?, integer -function List:findLast(f) - assert(type(f) == "function") - local n = self:length() - - for i = n, 1, -1 do - local v = self._data[i] - if f(v, i) then - return v, i - end - end - return nil, -1 -end - ---- @param value any ---- @param start integer? ---- @param comparator (fun(a:any,b:any):boolean)|nil ---- @return integer? -function List:indexOf(value, start, comparator) - assert(type(comparator) == "function" or comparator == nil) - start = start or 1 - local n = self:length() - - for i = start, n do - local v = self._data[i] - if type(comparator) == "function" then - if comparator(v, value) then - return i - end - else - if v == value then - return i - end - end - end - - return -1 -end - ---- @param value any ---- @param rstart integer? ---- @param comparator (fun(a:any,b:any):boolean)|nil ---- @return integer? -function List:lastIndexOf(value, rstart, comparator) - assert(type(comparator) == "function" or comparator == nil) - local n = self:length() - rstart = rstart or n - - for i = rstart, 1, -1 do - local v = self._data[i] - if type(comparator) == "function" then - if comparator(v, value) then - return i - end - else - if v == value then - return i - end - end - end - - return -1 -end - ---- @param f fun(value:any, index:integer):nil -function List:forEach(f) - assert(type(f) == "function") - for i, v in ipairs(self._data) do - f(v, i) - end -end - ---- @param value any ---- @param start integer? ---- @param comparator (fun(a:any,b:any):boolean)|nil ---- @return boolean -function List:includes(value, start, comparator) - return self:indexOf(value, start, comparator) >= 1 -end - ---- @param f fun(value:any,index:integer):any ---- @return commons.List -function List:map(f) - assert(type(f) == "function") - local l = {} - for i, v in ipairs(self._data) do - table.insert(l, f(v, i)) - end - return List:move(l) -end - ---- @return any?, boolean -function List:pop() - if self:empty() then - return nil, false - end - return table.remove(self._data, self:length()), true -end - ---- @param ... any -function List:push(...) - for i, v in ipairs({ ... }) do - table.insert(self._data, v) - end -end - ---- @return any?, boolean -function List:shift() - if self:empty() then - return nil, false - end - return table.remove(self._data, 1), true -end - ---- @param ... any -function List:unshift(...) - for i, v in ipairs({ ... }) do - table.insert(self._data, 1, v) - end -end - ---- @param f fun(accumulator:any,value:any,index:integer):any ---- @param initialValue any? ---- @return any -function List:reduce(f, initialValue) - assert(type(f) == "function") - - if self:empty() then - return initialValue - end - - local startIndex = initialValue and 1 or 2 - local accumulator = initialValue or self._data[1] - local n = self:length() - local i = startIndex - while i <= n do - accumulator = f(accumulator, self._data[i], i) - i = i + 1 - end - return accumulator -end - ---- @param f fun(accumulator:any,value:any,index:integer):any ---- @param initialValue any? ---- @return any -function List:reduceRight(f, initialValue) - assert(type(f) == "function") - - if self:empty() then - return initialValue - end - - local n = self:length() - local startIndex = initialValue and n or self:length() - 1 - local accumulator = initialValue or self._data[n] - - local i = startIndex - while i >= 1 do - accumulator = f(accumulator, self._data[i], i) - i = i - 1 - end - return accumulator -end - ---- @return commons.List -function List:reverse() - if self:empty() then - return List:move({}) - end - - local l = {} - local i = self:length() - while i >= 1 do - table.insert(l, self._data[i]) - i = i - 1 - end - return List:move(l) -end - ---- @param startIndex integer? ---- @param endIndex integer? ---- @return commons.List -function List:slice(startIndex, endIndex) - assert(type(startIndex) == "number" or startIndex == nil) - assert(type(endIndex) == "number" or endIndex == nil) - - local n = self:length() - startIndex = startIndex or 1 - endIndex = endIndex or n - - local l = {} - for i = startIndex, endIndex do - if i >= 1 and i <= n then - table.insert(l, self._data[i]) - end - end - return List:move(l) -end - ---- @param comparator (fun(a:any,b:any):boolean)|nil ---- @return commons.List -function List:sort(comparator) - local l = {} - for i, v in ipairs(self._data) do - table.insert(l, v) - end - table.sort(l, comparator) - return List:move(l) -end - -M.List = List - ---- @param o any ---- @return boolean -M.is_list = function(o) - return type(o) == "table" and o.__index == List and getmetatable(o) == List -end - ---- @class commons.HashMap ---- @field _data table -local HashMap = {} - ---- @param t table ---- @return commons.HashMap -function HashMap:move(t) - assert(type(t) == "table") - - local o = { _data = t } - setmetatable(o, self) - self.__index = self - return o -end - ---- @param t table ---- @return commons.HashMap -function HashMap:copy(t) - assert(type(t) == "table") - - local new_t = {} - for k, v in pairs(t) do - new_t[k] = v - end - return HashMap:move(new_t) -end - ---- @param ... {[1]:any,[2]:any} ---- @return commons.HashMap -function HashMap:of(...) - local t = {} - local s = 0 - for i, v in ipairs({ ... }) do - t[v[1]] = v[2] - s = s + 1 - end - local o = { _data = t } - setmetatable(o, self) - self.__index = self - return o -end - ---- @return table -function HashMap:data() - return self._data -end - ---- @return integer -function HashMap:size() - local s = 0 - for _, _ in pairs(self._data) do - s = s + 1 - end - return s -end - ---- @return boolean -function HashMap:empty() - return next(self._data) == nil -end - ---- @param key any ---- @param value any -function HashMap:set(key, value) - self._data[key] = value -end - ---- @param key any ---- @return any? -function HashMap:unset(key) - local old = self._data[key] - self._data[key] = nil - return old -end - ---- @param ... any ---- @return any -function HashMap:get(...) - return M.tbl_get(self._data, ...) -end - ---- @param key any ---- @return boolean -function HashMap:hasKey(key) - return self._data[key] ~= nil -end - ---- @param value any ---- @param comparator (fun(a:any, b:any):boolean)|nil ---- @return boolean -function HashMap:hasValue(value, comparator) - for k, v in pairs(self._data) do - if type(comparator) == "function" and comparator(v, value) then - return true - elseif v == value then - return true - end - end - return false -end - ---- @param other commons.HashMap ---- @return commons.HashMap -function HashMap:merge(other) - assert(M.is_hashmap(other)) - local t = {} - for k, v in pairs(self._data) do - t[k] = v - end - for k, v in pairs(other._data) do - t[k] = v - end - return HashMap:move(t) -end - ---- @param f fun(key:any, value:any):boolean ---- @return boolean -function HashMap:every(f) - assert(type(f) == "function") - for k, v in pairs(self._data) do - if not f(k, v) then - return false - end - end - return true -end - ---- @param f fun(key:any, value:any):boolean ---- @return boolean -function HashMap:some(f) - assert(type(f) == "function") - for k, v in pairs(self._data) do - if f(k, v) then - return true - end - end - return false -end - ---- @param f fun(key:any, value:any):boolean ---- @return boolean -function HashMap:none(f) - assert(type(f) == "function") - for k, v in pairs(self._data) do - if f(k, v) then - return false - end - end - return true -end - ---- @param f fun(key:any, value:any):boolean ---- @return commons.HashMap -function HashMap:filter(f) - assert(type(f) == "function") - local t = {} - for k, v in pairs(self._data) do - if f(k, v) then - t[k] = v - end - end - return HashMap:move(t) -end - ---- @param f fun(key:any, value:any):boolean ---- @return any, any -function HashMap:find(f) - assert(type(f) == "function") - for k, v in pairs(self._data) do - if f(k, v) then - return k, v - end - end - return nil, nil -end - ---- @param f fun(key:any,value:any):nil -function HashMap:forEach(f) - assert(type(f) == "function") - - for k, v in pairs(self._data) do - f(k, v) - end -end - ---- @param iterator any? ---- @return any, any -function HashMap:next(iterator) - return next(self._data, iterator) -end - ---- @return commons.HashMap -function HashMap:invert() - local t = {} - for k, v in pairs(self._data) do - t[v] = k - end - return HashMap:move(t) -end - ---- @param f fun(key:any, value:any):any ---- @return commons.HashMap -function HashMap:mapKeys(f) - assert(type(f) == "function") - local t = {} - for k, v in pairs(self._data) do - t[f(k, v)] = v - end - return HashMap:move(t) -end - ---- @param f fun(key:any, value:any):any ---- @return commons.HashMap -function HashMap:mapValues(f) - assert(type(f) == "function") - local t = {} - for k, v in pairs(self._data) do - t[k] = f(k, v) - end - return HashMap:move(t) -end - ---- @return any[] -function HashMap:keys() - local keys = {} - for k, _ in pairs(self._data) do - table.insert(keys, k) - end - return keys -end - ---- @return any[] -function HashMap:values() - local values = {} - for _, v in pairs(self._data) do - table.insert(values, v) - end - return values -end - ---- @return {[1]:any,[2]:any}[] -function HashMap:entries() - local p = {} - for k, v in pairs(self._data) do - table.insert(p, { k, v }) - end - return p -end - ---- @param f fun(accumulator:any,key:any,value:any):any ---- @param initialValue any ---- @return any -function HashMap:reduce(f, initialValue) - assert(type(f) == "function") - - if self:empty() then - return initialValue - end - - local accumulator = initialValue - for k, v in pairs(self._data) do - accumulator = f(accumulator, k, v) - end - return accumulator -end - -M.HashMap = HashMap - ---- @param o any? ---- @return boolean -M.is_hashmap = function(o) - return type(o) == "table" and o.__index == HashMap and getmetatable(o) == HashMap -end - return M diff --git a/lua/gitlinker/commons/uv.lua b/lua/gitlinker/commons/uv.lua deleted file mode 100644 index 8a70d613..00000000 --- a/lua/gitlinker/commons/uv.lua +++ /dev/null @@ -1 +0,0 @@ -return vim.uv or vim.loop diff --git a/lua/gitlinker/commons/version.lua b/lua/gitlinker/commons/version.lua deleted file mode 100644 index e44c0490..00000000 --- a/lua/gitlinker/commons/version.lua +++ /dev/null @@ -1,50 +0,0 @@ -local M = {} - -M.HAS_VIM_VERSION_EQ = type(vim.version) == "table" and vim.is_callable(vim.version.eq) -M.HAS_VIM_VERSION_GT = type(vim.version) == "table" and vim.is_callable(vim.version.gt) -M.HAS_VIM_VERSION_GE = type(vim.version) == "table" and vim.is_callable(vim.version.ge) -M.HAS_VIM_VERSION_LT = type(vim.version) == "table" and vim.is_callable(vim.version.lt) -M.HAS_VIM_VERSION_LE = type(vim.version) == "table" and vim.is_callable(vim.version.le) - ---- @param l integer[] ---- @return string -M.to_string = function(l) - assert(type(l) == "table") - local builder = {} - for _, v in ipairs(l) do - table.insert(builder, tostring(v)) - end - return table.concat(builder, ".") -end - ---- @param s string ---- @return integer[] -M.to_list = function(s) - assert(type(s) == "string") - local splits = vim.split(s, ".", { plain = true }) - local result = {} - for _, v in ipairs(splits) do - table.insert(result, tonumber(v)) - end - return result -end - ---- @param ver string|integer[] ---- @return boolean -M.lt = function(ver) - if type(ver) == "string" then - ver = M.to_list(ver) - end - return vim.version.lt(vim.version(), ver) -end - ---- @param ver string|integer[] ---- @return boolean -M.ge = function(ver) - if type(ver) == "string" then - ver = M.to_list(ver) - end - return vim.version.gt(vim.version(), ver) or vim.version.eq(vim.version(), ver) -end - -return M diff --git a/lua/gitlinker/commons/version.txt b/lua/gitlinker/commons/version.txt deleted file mode 100644 index 008c39a4..00000000 --- a/lua/gitlinker/commons/version.txt +++ /dev/null @@ -1 +0,0 @@ -27.0.0 diff --git a/lua/gitlinker/git.lua b/lua/gitlinker/git.lua index 6a288744..41fa05f8 100644 --- a/lua/gitlinker/git.lua +++ b/lua/gitlinker/git.lua @@ -1,9 +1,7 @@ -local logging = require("gitlinker.commons.logging") -local spawn = require("gitlinker.commons.spawn") -local uv = require("gitlinker.commons.uv") +local log = require("gitlinker.commons.log") local str = require("gitlinker.commons.str") - -local async = require("gitlinker.async") +local async = require("gitlinker.commons.async") +local uv = vim.uv or vim.loop --- @class gitlinker.CmdResult --- @field stdout string[] @@ -33,44 +31,46 @@ end --- @param default string function CmdResult:print_err(default) - local logger = logging.get("gitlinker") if self:has_err() then for _, e in ipairs(self.stderr) do - logger:err(e) + log.err(e) end else - logger:err("fatal: " .. default) + log.err("fatal: " .. default) end end ---- NOTE: async functions can't have optional parameters so wrap it into another function without '_' -local _run_cmd = async.wrap(function(args, cwd, callback) - local result = CmdResult:new() - local logger = logging.get("gitlinker") - logger:debug(string.format("|_run_cmd| args:%s, cwd:%s", vim.inspect(args), vim.inspect(cwd))) +--- @param args string[] +--- @param cwd string? +--- @param callback fun(gitlinker.CmdResult):any +local function _run_cmd_async(args, cwd, callback) + log.debug(string.format("|_run_cmd_async| args:%s, cwd:%s", vim.inspect(args), vim.inspect(cwd))) + + --- @param completed vim.SystemCompleted + local function on_exit(completed) + local result = CmdResult:new() + if str.not_blank(completed.stdout) then + result.stdout = str.split(str.trim(completed.stdout), "\n", { trimempty = true }) + end + if str.not_blank(completed.stderr) then + result.stderr = str.split(str.trim(completed.stderr), "\n", { trimempty = true }) + end + log.debug(string.format("|_run_cmd_async| result:%s", vim.inspect(result))) + callback(result) + end - spawn.detached(args, { + vim.system(args, { cwd = cwd, - on_stdout = function(line) - if type(line) == "string" then - table.insert(result.stdout, line) - end - end, - on_stderr = function(line) - if type(line) == "string" then - table.insert(result.stderr, line) - end - end, - }, function() - logger:debug(string.format("|_run_cmd| result:%s", vim.inspect(result))) - callback(result) - end) -end, 3) + text = true, + }, on_exit) +end -- wrap the git command to do the right thing always --- @package --- @type fun(args:string[], cwd:string?): gitlinker.CmdResult local function run_cmd(args, cwd) + --- @type fun(args:string[], cwd:string?): gitlinker.CmdResult + local _run_cmd = async.wrap(3, _run_cmd_async) return _run_cmd(args, cwd or uv.cwd()) end @@ -274,7 +274,6 @@ end --- @param cwd string? --- @return string? local function get_closest_remote_compatible_rev(remote, cwd) - local logger = logging.get("gitlinker") assert(remote, "remote cannot be nil") -- try upstream branch HEAD (a.k.a @{u}) @@ -331,7 +330,7 @@ local function get_closest_remote_compatible_rev(remote, cwd) return remote_rev end - logger:err("fatal: failed to get closest revision in that exists in remote: " .. remote) + log.err("fatal: failed to get closest revision in that exists in remote: " .. remote) return nil end @@ -383,7 +382,6 @@ end --- @param cwd string? --- @return string? local function _select_remotes(remotes, cwd) - local logger = logging.get("gitlinker") -- local result = run_select(remotes) local formatted_remotes = { "Please select remote index:" } @@ -392,12 +390,13 @@ local function _select_remotes(remotes, cwd) table.insert(formatted_remotes, string.format("%d. %s (%s)", i, remote, remote_url)) end - async.scheduler() + async.await(1, vim.schedule) + local result = vim.fn.inputlist(formatted_remotes) -- logger:debug(string.format("inputlist:%s(%s)", vim.inspect(result), vim.inspect(type(result)))) if type(result) ~= "number" or result < 1 or result > #remotes then - logger:err("fatal: user cancelled multiple git remotes") + log.err("fatal: user cancelled multiple git remotes") return nil end @@ -407,17 +406,16 @@ local function _select_remotes(remotes, cwd) end end - logger:err("fatal: user cancelled multiple git remotes, please select an index") + log.err("fatal: user cancelled multiple git remotes, please select an index") return nil end --- @param cwd string? --- @return string? local function get_branch_remote(cwd) - local logger = logging.get("gitlinker") -- origin/upstream local remotes = _get_remote(cwd) - logger:debug(string.format("git remotes:%s", vim.inspect(remotes))) + log.debug(string.format("git remotes:%s", vim.inspect(remotes))) if not remotes then return nil end @@ -443,7 +441,7 @@ local function get_branch_remote(cwd) upstream_branch:match("^(" .. upstream_branch_allowed_chars .. ")%/") if not remote_from_upstream_branch then - logger:err("fatal: cannot parse remote name from remote branch: " .. upstream_branch) + log.err("fatal: cannot parse remote name from remote branch: " .. upstream_branch) return nil end @@ -453,7 +451,7 @@ local function get_branch_remote(cwd) end end - logger:err( + log.err( string.format( "fatal: parsed remote '%s' from remote branch '%s' is not a valid remote", remote_from_upstream_branch, @@ -467,13 +465,12 @@ end --- @param cwd string? --- @return string? local function get_default_branch(remote, cwd) - local logger = logging.get("gitlinker") local args = { "git", "rev-parse", "--abbrev-ref", string.format("%s/HEAD", remote) } local result = run_cmd(args, cwd) if type(result.stdout) ~= "table" or #result.stdout == 0 then return nil end - logger:debug( + log.debug( string.format( "|get_default_branch| running %s: %s", vim.inspect(args), @@ -487,13 +484,12 @@ end --- @param cwd string? --- @return string? local function get_current_branch(cwd) - local logger = logging.get("gitlinker") local args = { "git", "rev-parse", "--abbrev-ref", "HEAD" } local result = run_cmd(args, cwd) if type(result.stdout) ~= "table" or #result.stdout == 0 then return nil end - logger:debug( + log.debug( string.format( "|get_current_branch| running %s: %s", vim.inspect(args), diff --git a/lua/gitlinker/linker.lua b/lua/gitlinker/linker.lua index e5a4b81a..656eb0f7 100644 --- a/lua/gitlinker/linker.lua +++ b/lua/gitlinker/linker.lua @@ -1,17 +1,16 @@ -local logging = require("gitlinker.commons.logging") +local log = require("gitlinker.commons.log") local str = require("gitlinker.commons.str") +local async = require("gitlinker.commons.async") -local async = require("gitlinker.async") local git = require("gitlinker.git") local path = require("gitlinker.path") local giturlparser = require("gitlinker.giturlparser") --- @return string? local function _get_buf_dir() - local logger = logging.get("gitlinker") local buf_path = vim.api.nvim_buf_get_name(0) local buf_dir = vim.fn.fnamemodify(buf_path, ":p:h") - logger:debug( + log.debug( string.format( "|_get_buf_dir| buf_path:%s, buf_dir:%s", vim.inspect(buf_path), @@ -30,7 +29,6 @@ end --- @param rev string? --- @return gitlinker.Linker? local function make_linker(remote, file, rev) - local logger = logging.get("gitlinker") local cwd = _get_buf_dir() local file_provided = str.not_empty(file) @@ -55,7 +53,7 @@ local function make_linker(remote, file, rev) end local parsed_url, parsed_err = giturlparser.parse(remote_url) --[[@as table, string?]] - logger:debug( + log.debug( string.format( "|make_linker| remote:%s, parsed_url:%s, parsed_err:%s", vim.inspect(remote), @@ -63,7 +61,7 @@ local function make_linker(remote, file, rev) vim.inspect(parsed_err) ) ) - logger:ensure( + log.ensure( parsed_url ~= nil, string.format( "failed to parse git remote url:%s, error:%s", @@ -89,7 +87,7 @@ local function make_linker(remote, file, rev) end -- logger.debug("|linker - Linker:make| rev:%s", vim.inspect(rev)) - async.scheduler() + async.await(1, vim.schedule) if not file_provided then local buf_path_on_root = path.buffer_relpath(root) --[[@as string]] @@ -114,7 +112,7 @@ local function make_linker(remote, file, rev) -- vim.inspect(file_in_rev_result) -- ) - async.scheduler() + async.await(1, vim.schedule) local file_changed = false if not file_provided then @@ -151,7 +149,7 @@ local function make_linker(remote, file, rev) current_branch = current_branch, } - logger:debug(string.format("|make_linker| o:%s", vim.inspect(o))) + log.debug(string.format("|make_linker| o:%s", vim.inspect(o))) return o end diff --git a/lua/gitlinker/range.lua b/lua/gitlinker/range.lua index 26d9a8be..1355df24 100644 --- a/lua/gitlinker/range.lua +++ b/lua/gitlinker/range.lua @@ -1,4 +1,4 @@ -local logging = require("gitlinker.commons.logging") +local log = require("gitlinker.commons.log") --- @param m string --- @return boolean @@ -13,7 +13,7 @@ end --- @return gitlinker.Range local function make_range() local m = vim.fn.mode() - logging.get("gitlinker"):debug(string.format("|make_range| mode:%s", vim.inspect(m))) + log.debug(string.format("|make_range| mode:%s", vim.inspect(m))) local l1 = nil local l2 = nil if _is_visual_mode(m) then diff --git a/lua/gitlinker/routers.lua b/lua/gitlinker/routers.lua index eacea782..f3061024 100644 --- a/lua/gitlinker/routers.lua +++ b/lua/gitlinker/routers.lua @@ -1,5 +1,5 @@ local str = require("gitlinker.commons.str") -local logging = require("gitlinker.commons.logging") +local log = require("gitlinker.commons.log") local range = require("gitlinker.range") @@ -114,9 +114,7 @@ end --- @param lk gitlinker.Linker --- @return string local function samba_browse(lk) - local logger = logging.get("gitlinker") - - logger:debug(string.format("|samba_browse| lk:%s", vim.inspect(lk))) + log.debug(string.format("|samba_browse| lk:%s", vim.inspect(lk))) local builder = "https://git.samba.org/?p=" -- org builder = builder .. (string.len(lk.org) > 0 and string.format("%s/", lk.org) or "") diff --git a/spec/gitlinker/gitlinker/git_spec.lua b/spec/gitlinker/gitlinker/git_spec.lua index e3da6ba1..6801ef09 100644 --- a/spec/gitlinker/gitlinker/git_spec.lua +++ b/spec/gitlinker/gitlinker/git_spec.lua @@ -11,7 +11,7 @@ describe("gitlinker.git", function() vim.cmd([[ edit lua/gitlinker.lua ]]) end) - local async = require("gitlinker.async") + local async = require("gitlinker.commons.async") local git = require("gitlinker.git") local path = require("gitlinker.path") local gitlinker = require("gitlinker") diff --git a/spec/gitlinker/gitlinker/linker_spec.lua b/spec/gitlinker/gitlinker/linker_spec.lua index 240243eb..e3a3cd61 100644 --- a/spec/gitlinker/gitlinker/linker_spec.lua +++ b/spec/gitlinker/gitlinker/linker_spec.lua @@ -13,7 +13,7 @@ describe("gitlinker.linker", function() vim.cmd([[ edit lua/gitlinker.lua ]]) end) - local async = require("gitlinker.async") + local async = require("gitlinker.commons.async") local github_actions = os.getenv("GITHUB_ACTIONS") == "true" local linker = require("gitlinker.linker") describe("[make_linker]", function()