From 41da454b74eca2b2ba89cd5da23f522dd2e00b3a Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:09:09 -0400 Subject: [PATCH 01/10] fix: Use lux for demo script --- demo/demo.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 demo/demo.lua diff --git a/demo/demo.lua b/demo/demo.lua old mode 100644 new mode 100755 index c626260..565bbe4 --- a/demo/demo.lua +++ b/demo/demo.lua @@ -1,4 +1,4 @@ -#!/usr/bin/env lua +#!/usr/bin/env -S lx lua --- Demo script for Roda terminal spinner library --- Run this script to see all features in action From 49c95542d510275176d0948270a7771b40deb231 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:09:33 -0400 Subject: [PATCH 02/10] feat: Make it async --- lua/roda/ansi.lua | 2 +- lua/roda/init.lua | 419 +++++++++++++++++++++++++++------------------- lua/roda/util.lua | 28 ++++ lux.toml | 3 +- 4 files changed, 276 insertions(+), 176 deletions(-) create mode 100644 lua/roda/util.lua diff --git a/lua/roda/ansi.lua b/lua/roda/ansi.lua index 86b16a0..50e30da 100644 --- a/lua/roda/ansi.lua +++ b/lua/roda/ansi.lua @@ -1,4 +1,4 @@ ---- roda/ansi.lua - ANSI escape code utilities +--- roda/ansi.lua - ANSI escape code --- @module roda.ansi --- @author TJ Kolleh --- @license EUPL-1.2 diff --git a/lua/roda/init.lua b/lua/roda/init.lua index b1447e3..6bb7285 100644 --- a/lua/roda/init.lua +++ b/lua/roda/init.lua @@ -1,15 +1,47 @@ --- roda.lua - Elegant terminal spinner for Lua ---- Roda (Portuguese for "wheel") - inspired by sindresorhus/ora +--- Roda (Portuguese for "wheel") --- --- @module roda --- @author TJ Kolleh --- @license EUPL-1.2 -local system = require("system") +local uv = require("luv") +local fp = require("roda.util") local ansi = require("roda.ansi") local spinners = require("roda.spinners") local symbols = require("roda.symbols") +local with_safe_timer = fp.bracket(function() + return uv.new_timer() +end, function(timer) + if timer and not timer:is_closing() then + timer:stop() + timer:close() + end +end) + +local make_process_bracket = function(command, args) + return fp.bracket(function() -- acquire command pipes and handles + return { + stdout = uv.new_pipe(false), + stderr = uv.new_pipe(false), + command = command, + args = args or {}, + handle = nil, + } + end, function(rslt) -- release pipes and handles + if rslt.stdout and not rslt.stdout:is_closing() then + rslt.stdout:close() + end + if rslt.stderr and not rslt.stderr:is_closing() then + rslt.stderr:close() + end + if rslt.handle and not rslt.handle:is_closing() then + rslt.handle:close() + end + end) +end + local M = {} -- Re-export submodules for advanced usage @@ -36,7 +68,6 @@ M.default_spinner = spinners.default ---@field private _indent number ---@field private _is_spinning boolean ---@field private _frame_index number ----@field private _last_frame_time number local Spinner = {} Spinner.__index = Spinner @@ -44,34 +75,77 @@ Spinner.__index = Spinner ---@param opts string|table|nil Options table or text string ---@return Spinner Spinner instance function M.new(opts) - if type(opts) == "string" then - opts = { text = opts } - end - opts = opts or {} - - local spinner_def = opts.spinner - if type(spinner_def) == "string" then - spinner_def = spinners[spinner_def] or spinners[M.default_spinner] - elseif type(spinner_def) ~= "table" then - spinner_def = spinners[M.default_spinner] - end - - local self = setmetatable({}, Spinner) - - self._text = opts.text or "" - self._prefix_text = opts.prefixText or "" - self._suffix_text = opts.suffixText or "" - self._color = opts.color == nil and "cyan" or opts.color - self._spinner = spinner_def - self._interval = opts.interval or spinner_def.interval or 100 - self._stream = opts.stream or io.stderr - self._hide_cursor = opts.hideCursor ~= false - self._indent = opts.indent or 0 - self._is_spinning = false - self._frame_index = 1 - self._last_frame_time = 0 - - return self + if type(opts) == "string" then + opts = { text = opts } + end + opts = opts or {} + + local spinner_def = opts.spinner + if type(spinner_def) == "string" then + spinner_def = spinners[spinner_def] or spinners[M.default_spinner] + elseif type(spinner_def) ~= "table" then + spinner_def = spinners[M.default_spinner] + end + + local self = setmetatable({}, Spinner) + + self._text = opts.text or "" + self._prefix_text = opts.prefixText or "" + self._suffix_text = opts.suffixText or "" + self._color = opts.color == nil and "cyan" or opts.color + self._spinner = spinner_def + self._interval = opts.interval or spinner_def.interval or 100 + self._stream = opts.stream or io.stderr + self._hide_cursor = opts.hideCursor ~= false + self._indent = opts.indent or 0 + self._is_spinning = false + self._frame_index = 1 + + return self +end + +--- Execute process asynchronously while spinning +---@param command string +---@param args table|nil +---@return function Thunk that expects an on_complete callback +function Spinner:execute(command, args) + local with_cmd = make_process_bracket(command, args) + local run_cmd = function(rslt, done_process) + local output = {} + + -- read the stdout stream (async) + uv.read_start(rslt.stdout, function(err, data) + if data then + table.insert(output, data) + end + end) + + -- evaluate the non-blocking process + rslt.handle = uv.spawn(rslt.command, { + args = rslt.args, + stdio = { nil, rslt.stdout, rslt.stderr }, + }, function(exit_code) + if exit_code == 0 then + self:succeed() + else + self:fail() + end + done_process(exit_code, table.concat(output)) + end) + end + + return function(on_complete) + with_safe_timer( + -- Use the timer with process + function(timer, done_timer) + self:start() + timer:start(0, self._interval, function() + self:render() + end) + with_cmd(run_cmd)(done_timer) + end + )(on_complete) + end end ------------------------------------------------------------------------------- @@ -81,46 +155,46 @@ end --- Get the current text ---@return string function Spinner:getText() - return self._text + return self._text end --- Set the text ---@param text string|nil Text to display ---@return Spinner self function Spinner:setText(text) - self._text = text or "" - if self._is_spinning then - self:render() - end - return self + self._text = text or "" + if self._is_spinning then + self:render() + end + return self end --- Get prefix text ---@return string function Spinner:getPrefixText() - return self._prefix_text + return self._prefix_text end --- Set prefix text ---@param text string|nil Text before spinner ---@return Spinner self function Spinner:setPrefixText(text) - self._prefix_text = text or "" - return self + self._prefix_text = text or "" + return self end --- Get suffix text ---@return string function Spinner:getSuffixText() - return self._suffix_text + return self._suffix_text end --- Set suffix text ---@param text string|nil Text after spinner text ---@return Spinner self function Spinner:setSuffixText(text) - self._suffix_text = text or "" - return self + self._suffix_text = text or "" + return self end ------------------------------------------------------------------------------- @@ -130,15 +204,15 @@ end --- Get spinner color ---@return string|boolean function Spinner:getColor() - return self._color + return self._color end --- Set spinner color ---@param color string|boolean Color name or false to disable ---@return Spinner self function Spinner:setColor(color) - self._color = color - return self + self._color = color + return self end ------------------------------------------------------------------------------- @@ -148,14 +222,14 @@ end --- Check if spinner is currently spinning ---@return boolean function Spinner:isSpinning() - return self._is_spinning + return self._is_spinning end --- Get current frame character ---@return string function Spinner:frame() - local frames = self._spinner.frames - return frames[self._frame_index] + local frames = self._spinner.frames + return frames[self._frame_index] end ------------------------------------------------------------------------------- @@ -165,51 +239,45 @@ end --- Clear the current line ---@return Spinner self function Spinner:clear() - self._stream:write(ansi.clear_line .. ansi.move_to_col_1) - self._stream:flush() - return self + self._stream:write(ansi.clear_line .. ansi.move_to_col_1) + self._stream:flush() + return self end --- Render the current frame ---@return Spinner self function Spinner:render() - if not self._is_spinning then - return self - end - - local now = system.gettime() - local elapsed_ms = (now - self._last_frame_time) * 1000 - - if elapsed_ms >= self._interval then - self._frame_index = self._frame_index + 1 - if self._frame_index > #self._spinner.frames then - self._frame_index = 1 - end - self._last_frame_time = now - end - - local indent = string.rep(" ", self._indent) - local frame = self:frame() - local color_code = ansi.get_color(self._color) - local prefix = self._prefix_text ~= "" and (self._prefix_text .. " ") or "" - local suffix = self._suffix_text ~= "" and (" " .. self._suffix_text) or "" - - local line = string.format( - "%s%s%s%s%s %s%s%s", - ansi.clear_line .. ansi.move_to_col_1, - indent, - prefix, - color_code, - frame, - ansi.reset, - self._text, - suffix - ) - - self._stream:write(line) - self._stream:flush() - - return self + if not self._is_spinning then + return self + end + + self._frame_index = self._frame_index + 1 + if self._frame_index > #self._spinner.frames then + self._frame_index = 1 + end + + local indent = string.rep(" ", self._indent) + local frame = self:frame() + local color_code = ansi.get_color(self._color) + local prefix = self._prefix_text ~= "" and (self._prefix_text .. " ") or "" + local suffix = self._suffix_text ~= "" and (" " .. self._suffix_text) or "" + + local line = string.format( + "%s%s%s%s%s %s%s%s", + ansi.clear_line .. ansi.move_to_col_1, + indent, + prefix, + color_code, + frame, + ansi.reset, + self._text, + suffix + ) + + self._stream:write(line) + self._stream:flush() + + return self end ------------------------------------------------------------------------------- @@ -220,131 +288,130 @@ end ---@param text string|nil Optional text to set ---@return Spinner self function Spinner:start(text) - if text then - self._text = text - end + if text then + self._text = text + end - if self._is_spinning then - return self - end + if self._is_spinning then + return self + end - self._is_spinning = true - self._frame_index = 1 - self._last_frame_time = system.gettime() + self._is_spinning = true + self._frame_index = 1 - if self._hide_cursor then - self._stream:write(ansi.hide_cursor) - self._stream:flush() - end + if self._hide_cursor then + self._stream:write(ansi.hide_cursor) + self._stream:flush() + end - self:render() + self:render() - return self + return self end --- Stop the spinner and clear ---@return Spinner self function Spinner:stop() - if not self._is_spinning then - return self - end + if not self._is_spinning then + return self + end - self._is_spinning = false - self:clear() + self._is_spinning = false + self:clear() - if self._hide_cursor then - self._stream:write(ansi.show_cursor) - self._stream:flush() - end + if self._hide_cursor then + self._stream:write(ansi.show_cursor) + self._stream:flush() + end - return self + return self end --- Stop the spinner and persist with a symbol and text ---@param opts table|nil Options: symbol, text, prefixText, suffixText ---@return Spinner self function Spinner:stopAndPersist(opts) - opts = opts or {} + opts = opts or {} - self._is_spinning = false + self._is_spinning = false - local symbol = opts.symbol or " " - local text = opts.text or self._text - local prefix = opts.prefixText or self._prefix_text - local suffix = opts.suffixText or self._suffix_text - local indent = string.rep(" ", self._indent) + local symbol = opts.symbol or " " + local text = opts.text or self._text + local prefix = opts.prefixText or self._prefix_text + local suffix = opts.suffixText or self._suffix_text + local indent = string.rep(" ", self._indent) - local prefix_str = prefix ~= "" and (prefix .. " ") or "" - local suffix_str = suffix ~= "" and (" " .. suffix) or "" + local prefix_str = prefix ~= "" and (prefix .. " ") or "" + local suffix_str = suffix ~= "" and (" " .. suffix) or "" - local line = string.format( - "%s%s%s%s %s%s\n", - ansi.clear_line .. ansi.move_to_col_1, - indent, - prefix_str, - symbol, - text, - suffix_str - ) + local line = string.format( + "%s%s%s%s %s%s\n", + ansi.clear_line .. ansi.move_to_col_1, + indent, + prefix_str, + symbol, + text, + suffix_str + ) - self._stream:write(line) + self._stream:write(line) - if self._hide_cursor then - self._stream:write(ansi.show_cursor) - end + if self._hide_cursor then + self._stream:write(ansi.show_cursor) + end - self._stream:flush() + self._stream:flush() - return self + return self end --- Stop with success symbol (green checkmark) ---@param text string|nil Optional text override ---@return Spinner self function Spinner:succeed(text) - return self:stopAndPersist({ - symbol = ansi.colors.green .. symbols.succeed .. ansi.reset, - text = text, - }) + return self:stopAndPersist({ + symbol = ansi.colors.green .. symbols.succeed .. ansi.reset, + text = text, + }) end --- Stop with failure symbol (red X) ---@param text string|nil Optional text override ---@return Spinner self function Spinner:fail(text) - return self:stopAndPersist({ - symbol = ansi.colors.red .. symbols.fail .. ansi.reset, - text = text, - }) + return self:stopAndPersist({ + symbol = ansi.colors.red .. symbols.fail .. ansi.reset, + text = text, + }) end --- Stop with warning symbol (yellow warning) ---@param text string|nil Optional text override ---@return Spinner self function Spinner:warn(text) - return self:stopAndPersist({ - symbol = ansi.colors.yellow .. symbols.warn .. ansi.reset, - text = text, - }) + return self:stopAndPersist({ + symbol = ansi.colors.yellow .. symbols.warn .. ansi.reset, + text = text, + }) end --- Stop with info symbol (blue info) ---@param text string|nil Optional text override ---@return Spinner self function Spinner:info(text) - return self:stopAndPersist({ - symbol = ansi.colors.blue .. symbols.info .. ansi.reset, - text = text, - }) + return self:stopAndPersist({ + symbol = ansi.colors.blue .. symbols.info .. ansi.reset, + text = text, + }) end --- Spin once (call in a loop for animation) ---@return Spinner self function Spinner:spin() - if self._is_spinning then - self:render() - end - return self + if self._is_spinning then + self:render() + end + return self end ------------------------------------------------------------------------------- @@ -355,7 +422,7 @@ end ---@param opts string|table Options or text ---@return Spinner Spinner instance function M.roda(opts) - return M.new(opts) + return M.new(opts) end --- Wrap a function execution with a spinner @@ -363,18 +430,18 @@ end ---@return any Result of the function, or nil on error ---@return string|nil Error message if failed function M.promise(opts) - local spinner = M.new(opts) - spinner:start() - - local success, result = pcall(opts.fn) - - if success then - spinner:succeed(opts.successText) - return result - else - spinner:fail(opts.failText or tostring(result)) - return nil, result - end + local spinner = M.new(opts) + spinner:start() + + local success, result = pcall(opts.fn) + + if success then + spinner:succeed(opts.successText) + return result + else + spinner:fail(opts.failText or tostring(result)) + return nil, result + end end ------------------------------------------------------------------------------- @@ -382,9 +449,13 @@ end ------------------------------------------------------------------------------- setmetatable(M, { - __call = function(_, opts) - return M.new(opts) - end, + __call = function(_, opts) + return M.new(opts) + end, }) +function M.run() + uv.run("default") +end + return M diff --git a/lua/roda/util.lua b/lua/roda/util.lua new file mode 100644 index 0000000..217c8ea --- /dev/null +++ b/lua/roda/util.lua @@ -0,0 +1,28 @@ +--- roda/util.lua +--- @module roda.util +--- @author TJ Kolleh +--- @license EUPL-1.2 + +local M = {} + +--- Bracket pattern - Derived from Haskell's `Control.Exception.bracket`. +--- This uses Continuation-Passing Style (CPS) to handle async operations. +--- +--- Creates a reuasble bracket for asynchronous resource lifecycles. +function M.bracket(acquire, release) + return function(use) + return function(on_complete) + local resource = acquire() + use( + resource, + -- Release the resource and trigger the final continuation + function(...) + release(resource) + if on_complete then on_complete(...) end + end + ) + end + end +end + +return M diff --git a/lux.toml b/lux.toml index ac2e341..da4237e 100644 --- a/lux.toml +++ b/lux.toml @@ -1,5 +1,5 @@ package = "roda" -version = "0.1.0" +version = "1.0.5" lua = ">=5.1" [description] @@ -22,6 +22,7 @@ dev = "git+https://github.com/tkolleh/roda.lua.git" [dependencies] luasystem = ">=0.4.0" +luv = ">= 1.44.2" [test_dependencies] busted = ">=2.0" From be79d8aabc4e83372bc295f0b8290c6dd20a5fe8 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:27:49 -0400 Subject: [PATCH 03/10] build: add lefthook configuration --- lefthook.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 lefthook.yml diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..201c547 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,11 @@ +pre-commit: + commands: + format: + glob: "*.lua" + run: lx fmt {staged_files} + stage_fixed: true + +pre-push: + commands: + test: + run: lx test From 444052f508d823a85ea67df299416d3e5e0f9da9 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:27:49 -0400 Subject: [PATCH 04/10] docs: simplify readme and add async usage --- README.md | 272 ++++++++---------------------------------------------- 1 file changed, 39 insertions(+), 233 deletions(-) diff --git a/README.md b/README.md index 390aad4..484d613 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![LuaRocks](https://img.shields.io/luarocks/v/tkolleh/roda)](https://luarocks.org/modules/tkolleh/roda) [![License: EUPL 1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://opensource.org/licenses/EUPL-1.2) -**Roda** (Portuguese for "wheel") is a pure Lua terminal spinner library +**Roda** (Portuguese for "wheel") is a pure Lua terminal spinner library. ## Features @@ -17,8 +17,9 @@ - **Terminal states** - succeed, fail, warn, info with symbols - **Dynamic text updates** - change text while spinning - **Highly configurable** - intervals, colors, prefixes, suffixes -- **Minimal dependencies** - only requires `luasystem` +- **Minimal dependencies** - only requires `luasystem` and `luv` - **Lua 5.1+ compatible** - works with Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT +- **Asynchronous** - non-blocking execution using `luv` ## Installation @@ -34,14 +35,6 @@ lx add roda luarocks install roda ``` -### Manual Installation - -Clone the repository and add to your `package.path`: - -```lua -package.path = "/path/to/roda.lua/lua/?.lua;/path/to/roda.lua/lua/?/init.lua;" .. package.path -``` - ## Quick Start ```lua @@ -63,6 +56,26 @@ spinner:start() spinner:succeed() ``` +## Async Command Execution + +Roda supports running child processes asynchronously without blocking the Lua runtime. + +```lua +local roda = require("roda") + +local spinner = roda("Installing dependencies...") + +-- Execute a command asynchronously +spinner:execute("npm", {"install"})(function(exit_code, output) + if exit_code == 0 then + print("\nOutput:\n" .. output) + end +end) + +-- Run the event loop to wait for async tasks +roda.run() +``` + ## API Reference ### `roda(opts)` / `roda.new(opts)` @@ -87,229 +100,22 @@ Create a new spinner instance. ### Instance Methods -#### `:start(text?)` - -Start the spinner. Optionally set new text. - -```lua -spinner:start() -spinner:start("New loading text") -``` - -#### `:stop()` - -Stop and clear the spinner from the terminal. - -#### `:succeed(text?)` - -Stop with green checkmark. - -```lua -spinner:succeed("Completed!") -``` - -#### `:fail(text?)` - -Stop with red X. - -```lua -spinner:fail("Failed to connect") -``` - -#### `:warn(text?)` - -Stop with yellow warning. - -```lua -spinner:warn("Deprecated API used") -``` - -#### `:info(text?)` - -Stop with blue info. - -```lua -spinner:info("Using cached data") -``` - -#### `:spin()` - -Render next frame. Call this in a loop for manual animation control. - -```lua -while working do - spinner:spin() - system.sleep(0.08) -end -``` - -#### `:setText(text)` - -Update spinner text while spinning. - -```lua -spinner:setText("Processing item 5/10") -``` - -#### `:setColor(color)` - -Change spinner color. - -```lua -spinner:setColor("yellow") -``` - -#### `:isSpinning()` - -Check if spinner is currently active. - -```lua -if spinner:isSpinning() then - -- still working -end -``` - -#### `:stopAndPersist(opts)` - -Stop with custom symbol and text. - -```lua -spinner:stopAndPersist({ - symbol = "->", - text = "Skipped", -}) -``` - -### Available Spinners - -| Name | Interval | -| ------ | ---------- | -| `dots` | 80ms | -| `dots2` | 80ms | -| `dots3` | 80ms | -| `line` | 130ms | -| `line2` | 100ms | -| `pipe` | 100ms | -| `simpleDots` | 400ms | -| `star` | 70ms | -| `arc` | 100ms | -| `circle` | 120ms | -| `bounce` | 120ms | -| `bouncingBar` | 80ms | -| `arrow` | 100ms | -| `growVertical` | 120ms | -| `growHorizontal` | 120ms | -| `aesthetic` | 80ms | - - -### Available Colors - -`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray` - -Set to `false` to disable coloring. - -## Advanced Usage - -### Async Command Execution - -```lua -local roda = require("roda") -local system = require("system") - -local function exec_with_spinner(cmd, text) - local spinner = roda(text):start() - - local tmpfile = os.tmpname() - local exitfile = os.tmpname() - os.execute(string.format("(%s) > %s 2>&1; echo $? > %s &", cmd, tmpfile, exitfile)) - - local exit_code = nil - while exit_code == nil do - spinner:spin() - local ef = io.open(exitfile, "r") - if ef then - local content = ef:read("*a") - ef:close() - if content:match("%d+") then - exit_code = tonumber(content:match("%d+")) - end - end - system.sleep(0.08) - end - - local f = io.open(tmpfile, "r") - local output = f and f:read("*a") or "" - if f then f:close() end - os.remove(tmpfile) - os.remove(exitfile) - - if exit_code == 0 then - spinner:succeed(text) - else - spinner:fail(text) - end - - return output, exit_code == 0 -end - --- Usage -local output, success = exec_with_spinner("npm install", "Installing dependencies") -``` - -### Custom Spinners - -```lua -local spinner = roda({ - text = "Moon phases", - spinner = { - interval = 100, - frames = { "moon1", "moon2", "moon3", "moon4", "moon5", "moon6", "moon7", "moon8" }, - }, -}) -spinner:start() -``` - -### Promise-style Wrapping - -```lua -local result = roda.promise({ - text = "Fetching data...", - successText = "Data fetched!", - failText = "Failed to fetch data", - fn = function() - -- your work here - return fetch_data() - end, -}) -``` - -### Progress Updates - -```lua -local spinner = roda("Processing..."):start() -for i = 1, total do - spinner:setText(string.format("Processing [%d/%d]", i, total)) - spinner:spin() - process_item(i) - system.sleep(0.02) -end -spinner:succeed(string.format("Processed %d items", total)) -``` - -## Compatibility - -- Lua 5.1, 5.2, 5.3, 5.4 -- LuaJIT 2.0, 2.1 -- Requires a terminal that supports ANSI escape codes - -## Related Projects - -- [sindresorhus/ora](https://github.com/sindresorhus/ora) - An inspirational Node.js implementation -- [cli-spinners](https://github.com/sindresorhus/cli-spinners) - Spinner frame definitions - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines, including how to debug with Neovim DAP. +- `:start(text?)` - Start the spinner. Optionally set new text. +- `:stop()` - Stop and clear the spinner from the terminal. +- `:succeed(text?)` - Stop with green checkmark. +- `:fail(text?)` - Stop with red X. +- `:warn(text?)` - Stop with yellow warning. +- `:info(text?)` - Stop with blue info. +- `:spin()` - Render next frame. Call this in a loop for manual animation control. +- `:setText(text)` - Update spinner text while spinning. +- `:setColor(color)` - Change spinner color. +- `:isSpinning()` - Check if spinner is currently active. +- `:stopAndPersist(opts)` - Stop with custom symbol and text. +- `:execute(command, args)` - Execute a child process asynchronously while spinning. Returns a Thunk that expects an `on_complete` callback. + +### Module Methods + +- `roda.run()` - Run the libuv event loop. Blocks until all async tasks finish. ## License From f2ac843702a7302a2674f9d69958ba7187fd33f5 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:27:49 -0400 Subject: [PATCH 05/10] docs(util): add proper comments to bracket function --- lua/roda/util.lua | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/lua/roda/util.lua b/lua/roda/util.lua index 217c8ea..5b35fa1 100644 --- a/lua/roda/util.lua +++ b/lua/roda/util.lua @@ -5,24 +5,29 @@ local M = {} ---- Bracket pattern - Derived from Haskell's `Control.Exception.bracket`. +--- Bracket pattern - Derived from Haskell's `Control.Exception.bracket`. --- This uses Continuation-Passing Style (CPS) to handle async operations. --- ---- Creates a reuasble bracket for asynchronous resource lifecycles. +--- Creates a reusable bracket for asynchronous resource lifecycles. +--- @param acquire function Function to acquire the resource +--- @param release function Function to release the resource +--- @return function A function that takes a `use` function and returns a continuation function M.bracket(acquire, release) - return function(use) - return function(on_complete) - local resource = acquire() - use( - resource, - -- Release the resource and trigger the final continuation - function(...) - release(resource) - if on_complete then on_complete(...) end - end - ) - end - end + return function(use) + return function(on_complete) + local resource = acquire() + use( + resource, + -- Release the resource and trigger the final continuation + function(...) + release(resource) + if on_complete then + on_complete(...) + end + end + ) + end + end end return M From b8ac74aee2227c38ad685da980f5ce6c81996c87 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:27:50 -0400 Subject: [PATCH 06/10] docs(init): add proper comments to functions --- lua/roda/init.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/roda/init.lua b/lua/roda/init.lua index 6bb7285..85e0052 100644 --- a/lua/roda/init.lua +++ b/lua/roda/init.lua @@ -11,6 +11,8 @@ local ansi = require("roda.ansi") local spinners = require("roda.spinners") local symbols = require("roda.symbols") +--- Bracket for safely acquiring and releasing a libuv timer +--- @type function local with_safe_timer = fp.bracket(function() return uv.new_timer() end, function(timer) @@ -20,6 +22,10 @@ end, function(timer) end end) +--- Bracket for safely acquiring and releasing child process pipes and handles +--- @param command string The command to execute +--- @param args table|nil Arguments for the command +--- @return function A bracket function for the process local make_process_bracket = function(command, args) return fp.bracket(function() -- acquire command pipes and handles return { @@ -454,6 +460,8 @@ setmetatable(M, { end, }) +--- Run the libuv event loop. Blocks until all async tasks finish. +--- @return nil function M.run() uv.run("default") end From e4a9fd9402abc5f49c55e61f4a073db212d48d2c Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:27:50 -0400 Subject: [PATCH 07/10] test: add simple async test --- spec/simple_spec.lua | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 spec/simple_spec.lua diff --git a/spec/simple_spec.lua b/spec/simple_spec.lua new file mode 100644 index 0000000..5b734d5 --- /dev/null +++ b/spec/simple_spec.lua @@ -0,0 +1,5 @@ +---@diagnostic disable: undefined-global +local roda = require("roda") + +roda.new("Sleeping..."):execute("sleep", { "2" })(print) +roda.run() From 0c22b458e907d768ecf6376a6336b087b378b4b8 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:30:00 -0400 Subject: [PATCH 08/10] fix: ignore unused err argument in uv.read_start callback --- lua/roda/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/roda/init.lua b/lua/roda/init.lua index 85e0052..dedd750 100644 --- a/lua/roda/init.lua +++ b/lua/roda/init.lua @@ -120,7 +120,7 @@ function Spinner:execute(command, args) local output = {} -- read the stdout stream (async) - uv.read_start(rslt.stdout, function(err, data) + uv.read_start(rslt.stdout, function(_, data) if data then table.insert(output, data) end From fb76879e3c2bdc46ac49951c0994d33026f24224 Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:31:31 -0400 Subject: [PATCH 09/10] build: add linter to pre-commit hook --- lefthook.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lefthook.yml b/lefthook.yml index 201c547..446408c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,6 +4,9 @@ pre-commit: glob: "*.lua" run: lx fmt {staged_files} stage_fixed: true + lint: + glob: "*.lua" + run: lx lint {staged_files} pre-push: commands: From bc911bab6cc8b7459ceb70bb900a9d58a57d933e Mon Sep 17 00:00:00 2001 From: TJ Kolleh Date: Sat, 14 Mar 2026 05:33:25 -0400 Subject: [PATCH 10/10] Delete spec/simple_spec.lua --- spec/simple_spec.lua | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 spec/simple_spec.lua diff --git a/spec/simple_spec.lua b/spec/simple_spec.lua deleted file mode 100644 index 5b734d5..0000000 --- a/spec/simple_spec.lua +++ /dev/null @@ -1,5 +0,0 @@ ----@diagnostic disable: undefined-global -local roda = require("roda") - -roda.new("Sleeping..."):execute("sleep", { "2" })(print) -roda.run()