Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

About

Ward is a Lua runtime for writing system scripts that are pleasant and safe like a real language, but still feel as direct as bash/sh for day-to-day automation.

Think of Ward as:

  • A scripting runtime (you write Lua)
  • A standard library for systems work (fs, env, process, net, term, time, etc.)
  • A “shell-like” UX for pipelines, exit codes, and stdout/stderr — without shell footguns

Project goals

Ward is designed to help you write scripts that are:

  • Readable: explicit modules, explicit options, explicit error handling
  • Composable: small primitives that chain well (process pipelines, fs + fetch, etc.)
  • Predictable: fewer implicit behaviors than shells; structured results instead of string parsing
  • Practical: excellent at orchestrating external tools (git, tar, curl-like flows, build tools)
  • Cross-platform by default: consistent APIs across Linux/macOS/Windows where feasible (in progress)

Problems Ward solves (and why not just bash/sh)

Shell scripting is powerful, but it tends to degrade quickly as complexity grows:

  • Error handling is subtle (set -e, pipelines, subshells, traps)
  • Quoting/escaping is fragile, especially with spaces/newlines and binary data
  • Data is usually “strings all the way down” (hard to validate, hard to refactor)
  • Concurrency and streaming require non-trivial patterns
  • Portability across sh/bash/dash and different OSes is painful

Ward addresses these by offering shell-like primitives with language-level structure:

  • Explicit process execution (ward.process) with structured results (ok, exit code, stdout/stderr)
  • Filesystem utilities (ward.fs) with consistent return conventions for mutating operations
  • Environment overlay (ward.env) so scripts can modify env safely without global side effects
  • Network fetch + HTTP (ward.net) that composes with ward.fs
  • Terminal UI (ward.term) for prompts, progress, and ANSI utilities

What Ward is not

Ward is intentionally not trying to become:

  • A full interactive shell replacement (job control, shell syntax, glob semantics everywhere)
  • A configuration-management/orchestration framework (inventory/targeting is out of scope for now)
  • A CPU-parallel compute runtime for Lua (Ward focuses on I/O overlap and orchestration)

Ward is a tool for writing robust scripts that run external programs and manipulate files reliably.

Core design principles you can rely on

1) Explicit modules Ward APIs live under ward.* and are imported via require. This makes scripts easy to audit and refactor.

2) Structured boundaries Where shells typically return strings and exit codes, Ward usually returns either:

  • a structured userdata (e.g., CmdResult)
  • or a result table like { ok, err } for mutating filesystem operations

3) Script-friendly errors Errors are meant to be understandable and printable. Many APIs are designed so you can adopt a “set -e” style by asserting success early and failing fast.

4) Async-capable runtime (without forcing you into a new programming model) Ward runs on an async-capable runtime. Many operations internally use async I/O, but most APIs are still called in a straightforward way. Some operations return awaitable objects when the operation must be explicitly awaited (interactive input, some timers, streaming reads, etc.).

A full explanation of awaitables, tasks, channels, and select is in the async section (see ward.async).

A quick taste: “shell replacement” style

A Ward script typically reads like “do X, assert it worked, then proceed”:

local process = require("ward.process")

process.cmd("git", "rev-parse", "--is-inside-work-tree")
  :output()
  :assert_ok("not a git repo")

local r = (process.cmd("printf", "hello\nworld\n") | process.cmd("wc", "-l")):output()
r:assert_ok()
print("lines:", r.stdout)

Importing modules

You can import either the root module or submodules:

local ward   = require("ward")
local fs     = require("ward.fs")
local path   = require("ward.fs.path")
local io     = require("ward.io")
local proc   = require("ward.process")
local crypto = require("ward.crypto")
local retry  = require("ward.helpers.retry")
local time   = require("ward.time")
local term   = require("ward.term")

Tip: Prefer importing the specific submodules you use; it keeps scripts explicit and makes the dependency surface obvious.

Conventions

Common

  • nil|string means the function returns either nil or a Lua string.
  • “bytes string” means a Lua string whose contents are raw bytes (binary-safe).
  • Paths are typically accepted as strings (and in most ward.fs APIs also as ward.fs.path objects).
  • Most option tables are optional; when omitted, defaults apply.

CLI sandbox limits (ward run, ward eval, ward repl)

Ward commands that execute Lua (ward run, ward eval, ward repl) support a configurable sandbox. The most commonly used switches are:

  • --memory-limit BYTES - maximum Lua memory usage
  • --instruction-limit N - approximate instruction budget (see notes below)
  • --timeout SECONDS - wall-clock timeout (also accepts duration strings like 500ms, 2s, 1m)
  • --threads N - Tokio worker threads

Notes:

  • Instruction limiting is intentionally coarse. Ward installs a Lua VM hook every 1024 instructions (or less if the configured limit is smaller), so a script may exceed the configured limit by up to 1023 instructions.
  • The instruction hook does not execute while awaiting Rust async operations (e.g., I/O). Long-running I/O does not consume the instruction budget.
  • It helps prevent runaway scripts (memory/time/instructions). This is not a secure sandbox for untrusted code.

CLI

Ward ships a single executable, ward, which provides a small set of commands for running Lua-based system scripts.

This page documents the CLI behavior (command syntax, argument forwarding, exit codes, sandbox options) and includes usage examples.

Common concepts

Argument forwarding (arg)

All Ward commands that execute Lua (run, eval, repl) forward trailing CLI arguments to the Lua program and expose them via the standard Lua global arg.

  • arg[0] identifies the entry being executed:
    • run: the script path
    • eval: (eval)
    • repl: (repl)
  • arg[1]..arg[n] are the forwarded arguments.

It is recommended to use -- to terminate Ward’s CLI parsing when you are forwarding arbitrary flags.

Exit codes

  • If the Lua program finishes normally, Ward exits with code 0.
  • If Lua raises an unhandled error, Ward prints the error and exits with a non-zero code.
  • If a script calls require("ward.process").exit(code), Ward exits with exactly that code.
  • In repl, Ctrl-C exits with code 130.

Sandbox options

run, eval, and repl share the same sandbox limit switches:

  • -m, --memory-limit <bytes>: memory cap for the Lua VM (bytes)
  • -i, --instruction-limit <n>: approximate instruction cap (enforced via VM hook every ~1024 instructions)
  • -t, --threads <n>: Tokio worker thread count (also used for the Lua async pool)
  • -T, --timeout <duration>: wall-clock timeout (supports seconds or duration strings like 500ms, 2s, 1m)

For details on instruction-limit behavior, see the notes in Conventions.

Logging

Ward’s runtime logging level can be configured through:

  • WARD_LOG=trace|debug|info|warn|error|fatal

ward run

Run a Lua file.

Synopsis

ward run [OPTIONS] FILE [--] [ARGS...]

Examples

Run a script and forward positional args:

ward run ./script.lua -- foo bar

Forward flags without ambiguity:

ward run ./script.lua -- --flag --other-flag=value

Shebang mode (Ward strips a leading #! line):

#!/usr/bin/env -S ward run
print("hello from ward")

ward eval

Evaluate a Lua chunk provided either via --expr or via stdin.

Synopsis

ward eval [OPTIONS] [-e|--expr LUA] [--] [ARGS...]

If --expr is omitted, ward eval reads code from stdin unless --no-stdin is provided.

Options

  • -e, --expr <lua>: Lua code to evaluate.
  • --no-stdin: do not read code from stdin when --expr is not provided.

All sandbox options described above are supported.

Examples

Evaluate a one-liner:

ward eval -e 'print("hello")'

Use forwarded arguments:

ward eval -e 'print(arg[0]); print(arg[1])' -- foo
# prints:
# (eval)
# foo

Read code from stdin:

echo 'print("from stdin")' | ward eval

Explicitly refuse stdin (useful for CI scripts that should fail fast):

ward eval --no-stdin
# error: no code provided (use --expr or pipe into stdin)

ward repl

Start an interactive Lua REPL inside the Ward runtime.

The REPL keeps a single Lua state for the entire session, so definitions persist between inputs.

Synopsis

ward repl [OPTIONS] [--] [ARGS...]

Options

  • --no-prompt: disable prompts and banner (useful when piping input).

All sandbox options described above are supported.

Exiting the REPL

You can exit in any of the following ways:

  • Press Ctrl-D (EOF)
  • Type one of: exit, quit, :q, :quit
  • Call require("ward.process").exit(code)

Ctrl-C exits with code 130.

Multi-line input

The REPL supports multi-line chunks: if the current input is syntactically incomplete, Ward switches the prompt to >> and keeps reading until the chunk is complete.

Convenience: =expr shorthand

If you enter a single line that starts with =, Ward rewrites it as print(<expr>).

Example:

> =1+2
3

Examples

Start a REPL and inspect the platform:

$ ward repl
> local p = require("ward.host.platform")
> print(p.os())
linux

Use forwarded args in a REPL session:

$ ward repl -- hello
> print(arg[0])
(repl)
> print(arg[1])
hello

Use the REPL as a non-interactive evaluator (use --no-prompt to keep output clean):

printf 'print("hi")\n' | ward repl --no-prompt

ward.async - tasks and channels

local async = require("ward.async")

Overview

Ward scripts run in an async-capable Lua runtime. Most operations that involve I/O (process execution, HTTP, timers) are implemented using async Rust internally, but can typically be called from Lua in a straightforward way because Ward drives the runtime for you.

ward.async provides user-level concurrency primitives:

  • Tasks: run Lua functions concurrently via async.spawn(...).
  • Channels: communicate between tasks with bounded queues via async.channel(...).
  • Await helpers: a single awaitable contract (:wait() / __call()) used consistently by async.await and async.select.

These primitives are intended for local concurrency (I/O overlap, worker pools, fan-out/fan-in), not for CPU-parallel Lua execution.

Awaitables contract (important)

Ward uses a single user-facing await protocol:

  • An awaitable is a userdata that implements :wait() (preferred) and may also implement __call() so it can be awaited via a().
  • async.await(awaitable) and async.select(list) operate on this protocol.
  • Task and Channel are awaitables via Task:wait() and Channel:wait().

This contract exists to keep concurrency composable: you can pass awaitables into async.select without “eagerly awaiting” them first.

Tasks

async.spawn(fn, ...) -> Task

Spawns a concurrent Lua task that runs fn(...).

local t = async.spawn(function(x)
  return x + 1, "ok"
end, 41)

Lifetime and cancellation semantics

Tasks are structured by default: if the Task handle becomes unreachable and is garbage-collected (or dropped by losing scope), the underlying task may be aborted.

If you intentionally want a “fire-and-forget” task, call t:detach() to prevent abort-on-drop. Prefer structured tasks unless you have a clear reason to detach.

Task:wait() -> ...

Waits for the task to finish and returns the function’s return values.

local n, s = t:wait()
print(n, s) -- 42  ok

This makes Task compatible with the awaitables contract and with async.select.

Errors:

  • Raises "task already joined" if wait() is called more than once.
  • Raises "cancelled" if the underlying task was aborted (for example, because the handle was dropped without detach()).

Task:cancel() -> boolean

Requests task cancellation.

  • Returns true if cancellation was requested.
  • Returns false if the task is already finished (or already cancelled).

Task:done() -> boolean

Returns true when the task has finished.

Task:detach() -> boolean

Detaches the task from structured cancellation-on-drop/GC.

  • Returns true after switching the task into detached mode.
  • After detaching, dropping the Task handle will not abort the underlying task.

Channels

async.channel(opts?) -> Channel

Creates a bounded channel.

Accepted opts:

  • nil (defaults apply)
  • a number (capacity)
  • a table: { capacity = N }

If omitted, capacity defaults to 64.

local ch = async.channel({ capacity = 16 })

Channel:send(value) -> true | nil, err

Async. Sends a value into the channel.

  • Returns true on success.
  • Returns nil, "closed" if the channel is closed.

Channel:try_send(value) -> true | nil, err

Sync. Attempts to send without waiting.

  • Returns true on success.
  • Returns nil, "full" if the buffer is full.
  • Returns nil, "closed" if the channel is closed.

Channel:wait() -> value | nil, err

Async. Receives a value from the channel.

  • Returns the value on success.
  • Returns nil, "closed" after the sender is closed and the queue is drained.

This makes Channel compatible with the awaitables contract and with async.select.

Notes:

  • Channel:wait() returns nil, "closed" only after the sender is closed and the queue is drained.
  • If your channel can carry nil, always check err to distinguish a nil message from closure.

Channel:try_recv() -> value | nil, err

Sync. Attempts to receive without waiting.

  • Returns the value on success.
  • Returns nil, "empty" if no value is available.
  • Returns nil, "closed" if the channel is disconnected.

Note: if another task is currently blocked in wait(), try_recv() may return nil, "busy" (implementation detail). Treat both "empty" and "busy" as retryable states.

Channel:close() -> true

Closes the sender side of the channel.

Important: close() does not discard queued items. Receivers can continue calling wait() until the channel is fully drained and then observe nil, "closed".

Selecting across awaitables

async.select(list) -> idx, ...

Races multiple awaitables concurrently and returns the first one that completes. list must be an array-like table of userdata awaitables. For convenience, Ward also accepts:

  • Task userdata (waited via :wait())
  • Channel userdata (waited via :wait())

The return value is:

  • idx - the 1-based index into list that completed first
  • followed by that awaitable’s return values

Note: async.select cancels the non-winning internal waiters (the race participants), but it does not automatically cancel arbitrary underlying work unless that work is itself cancellation-aware. For example, if you race a long-running task against a timeout, the task will continue running unless you explicitly cancel it (or allow it to be aborted via structured drop/GC behavior).

Example: race a task against a timeout

local async = require("ward.async")
local time = require("ward.time")

local t = async.spawn(function()
  time.sleep("200ms"):wait()
  return "task"
end)

local idx, v = async.select({ t, time.sleep("50ms") })
print("winner", idx, v)

async.await(awaitable) -> ...

Awaits a single awaitable userdata using the awaitables contract (:wait() preferred, or __call()).

This is mostly a convenience wrapper for readability and for writing higher-level helpers.

local async = require("ward.async")
local time = require("ward.time")

async.await(time.sleep("100ms"))

ward.convert

local json = require("ward.convert.json")
local yaml = require("ward.convert.yaml")
local toml = require("ward.convert.toml")
local ini  = require("ward.convert.ini")

Common patterns:

  • encode(value) -> string
  • decode(string) -> value
  • encode_async(value) -> string
  • decode_async(string) -> value

Example (JSON):

local json = require("ward.convert.json")
local s = json.encode({ a = 1, b = { true, false } })
local t = json.decode(s)

ward.convert.json

Functions:

  • json.encode(value, opts?) -> string
  • json.decode(text) -> value
  • json.encode_async(value, opts?) -> string (runs on a blocking thread)
  • json.decode_async(text) -> value (runs on a blocking thread)

opts for encode/encode_async:

  • pretty (boolean, default false) - pretty-print.
  • indent (integer, default 2) - spaces per indent level (must be > 0).

ward.convert.yaml

Functions:

  • yaml.encode(value) -> string
  • yaml.decode(text) -> value
  • yaml.encode_async(value) -> string (blocking thread)
  • yaml.decode_async(text) -> value (blocking thread)

ward.convert.toml

Functions:

  • toml.encode(value) -> string
  • toml.decode(text) -> value
  • toml.encode_async(value) -> string (blocking thread)
  • toml.decode_async(text) -> value (blocking thread)

ward.convert.ini

Functions:

  • ini.encode(table) -> string
  • ini.decode(text) -> table
  • ini.encode_async(table) -> string (blocking thread)
  • ini.decode_async(text) -> table (blocking thread)

ini.encode expects a table shaped like:

{
  [""] = { root_key = "value" },       -- optional default section
  section = { key1 = "v1", flag = true }
}

All values are stringified; booleans/numbers are accepted and converted to strings.

ward.crypto

local crypto = require("ward.crypto")

Byte-string functions (Lua strings are binary-safe):

  • crypto.sha256(bytes) -> string (hex)
  • crypto.sha1(bytes) -> string (hex)
  • crypto.md5(bytes) -> string (hex)

File functions (async, streamed):

  • crypto.sha256_file(path) -> string (hex)
  • crypto.sha1_file(path) -> string (hex)
  • crypto.md5_file(path) -> string (hex)

Examples:

local crypto = require("ward.crypto")

local digest = crypto.sha256("abc")
print("sha256(abc) =", digest)

local file_digest = crypto.sha256_file("Cargo.toml")
print("sha256(Cargo.toml) =", file_digest)

ward.env

Ward uses an environment overlay:

  • env.set / env.unset / env.clear modify Ward’s overlay only (they do not mutate the process-global OS environment).
  • Read operations (env.get / env.list / env.is_exists / env.which / env.is_in_path) resolve the effective environment: the process environment plus overlay modifications (overlay wins).
  • The overlay is applied to child processes spawned via ward.process and to the git invocations used by ward.net.fetch.git. For child processes, precedence is: process env → Ward overlay → per-command overrides (Cmd:env / Cmd:envs).

Used to inspect and modify environment/Ward variables. Mutations are applied to the current process via local Ward env variables overlay to keep it safe in async contexts.

local env = require("ward.env")

env.get(key, default?) -> string|nil

Get environment variable key. If missing (or key is empty), returns default.

local home = env.get("HOME")
local port = env.get("PORT", "8080")

env.set(key, value) -> boolean

Set an environment variable in the Ward overlay. Returns false if the key is invalid (empty, contains =, or contains \0) or the value contains \0.

env.set("FOO", "bar")

env.export(key, value?) -> boolean

Mutate the process environment (not just Ward’s overlay). This mirrors export in shells and affects concurrently running scripts in the same process, so use it sparingly.

  • value omitted / nil ⇒ removes the variable from the process environment and overlay.
  • Returns false on invalid keys.
-- Prefer env.set for isolation; use export only when you must change 
-- the process env.
env.export("PATH", "/custom/bin:" .. (env.get("PATH") or ""))

env.unset(key) -> boolean

Remove an environment variable (from the overlay). Returns false if the key is invalid.

env.unset("FOO")

env.clear() -> nil

Clears all overlay modifications (restores the effective environment back to the base process environment).

env.list() -> table

Returns a table of the effective environment (base process env with overlay applied).

Example (print all):

local t = env.list()
for k, v in pairs(t) do
  print(k, v)
end

env.is_exists(key) -> boolean

Returns true if variable exists in the effective environment.

if env.is_exists("CI") then
  print("running in CI")
end

env.hostname() -> string

Returns hostname.

env.which(name) -> string|nil

Searches PATH (and Windows PATHEXT) for an executable.

local git = env.which("git")
assert(git, "git not found")

env.is_in_path(path_or_name) -> boolean

Returns whether a candidate is reachable via PATH search.

ward.fs

local fs = require("ward.fs")

4.1 Existence and type checks

  • fs.is_exists(path) -> boolean
  • fs.is_dir(path) -> boolean
  • fs.is_file(path) -> boolean
  • fs.is_link(path) -> boolean
  • fs.is_symlink(path) -> boolean
  • fs.is_block_device(path) -> boolean (Unix)
  • fs.is_char_device(path) -> boolean (Unix)
  • fs.is_fifo(path) -> boolean (Unix)
  • fs.is_socket(path) -> boolean (Unix)
  • fs.is_executable(path) -> boolean
  • fs.is_readable(path) -> boolean
  • fs.is_writable(path) -> boolean

Notes:

  • For files, fs.is_readable/fs.is_writable check whether the file can be opened for read/write.
  • For directories, fs.is_readable checks whether the directory can be listed (read_dir), and fs.is_writable checks whether a temporary file can be created and removed inside the directory.

Example:

if fs.is_file("./build.sh") and fs.is_executable("./build.sh") then
  print("runnable")
end

Path utilities

  • fs.readlink(path) -> string|nil
  • fs.realpath(path) -> string|nil
  • fs.dirname(path) -> string
  • fs.basename(path) -> string
  • fs.join(a, b, ...) -> string

Example:

local p = fs.join("build", "out", "app.bin")
print(fs.dirname(p))
print(fs.basename(p))

fs.path` - pure path manipulation (Path userdata)

fs.path provides a Path userdata type for manipulating paths without touching the filesystem. It is useful for building paths safely and passing them to ward.fs APIs.

Constructors:

  • fs.path.new(path) -> Path
  • fs.path.cwd() -> Path
  • fs.path.join(a, b) -> Path (both arguments may be strings or Path)

Methods on Path:

  • Path:is_abs() -> boolean
  • Path:normalize() -> Path
  • Path:parts() -> table (array-like, path components)
  • Path:split() -> (dirname: string, basename: string)
  • Path:join(segment) -> Path
  • Path:dirname() -> string
  • Path:basename() -> string
  • Path:extname() -> nil|string
  • Path:stem() -> nil|string
  • Path:as_string() -> string

Interoperability:

Most ward.fs functions accept either a path string or a fs.path object.

Example:

local fs = require("ward.fs")
local path = require("ward.fs.path")

local p = path.new("build/../out/app.bin"):normalize()
assert(fs.mkdir(p:dirname(), { recursive = true, force = true }).ok)
assert(fs.write(p, "hello\n").ok)
print("wrote:", tostring(p))

Directory listing and globbing

fs.list(path, opts?) -> table

Returns an array-like table of paths.

Options (opts table):

  • recursive (boolean, default false)
  • depth (integer, default 0) - recursion depth limit; 0 means unlimited
  • dirs (boolean, default false) - include directories
  • files (boolean, default false) - include files
  • regex (string|nil) - regex filter applied to the full path string

Notes:

  • If both dirs and files are false (the default), both directories and files are included.
  • Ordering is OS-dependent (Ward does not currently sort results).

Examples:

-- list everything
for _, p in ipairs(fs.list(".")) do
  print(p)
end

-- only files, recursive, depth-limited
local files = fs.list("src", { recursive = true, depth = 3, files = true })

fs.glob(pattern) -> table

Returns an array-like table of paths matching a glob pattern.

for _, p in ipairs(fs.glob("src/**/*.rs")) do
  print(p)
end

Directories and removal

Result convention for mutating operations: most functions that change the filesystem return a table { ok, err } where ok is a boolean and err is a string (or nil on success). When ok is false, err is intended to be human-readable and suitable for printing/logging.

fs.mkdir(path, opts?) -> { ok, err }

Create directory.

Options:

  • recursive (boolean, default false)
  • mode (number, unix-only; default 0o755)
  • force (boolean, default false) - treat “already exists” as success
assert(fs.mkdir("build", { recursive = true, mode = 0o755 }).ok)

fs.rm(path, opts?) -> { ok, err }

Remove file or directory.

Options:

  • recursive (boolean, default false) - required for directories
  • force (boolean, default false) - treat missing-path as success
assert(fs.rm("build", { recursive = true, force = true }).ok)

fs.unlink(path, opts?) -> { ok, err }

Remove a file (like rm -f file).

  • fs.chmod(path, mode) -> { ok, err }
  • fs.chown(path, uid, gid) -> { ok, err } (Unix)
  • fs.rename(from, to) -> { ok, err }
  • fs.link(from, to) -> { ok, err } (hard link)
  • fs.symlink(from, to) -> { ok, err } (symbolic link)

Timestamps

fs.touch(path, opts?) -> { ok, err }

Create file if missing and update timestamps.

Options:

  • recursive (boolean, default false) - create parent directories first
assert(fs.touch("logs/app.log", { recursive = true }).ok)

File IO

fs.read(path, opts?) -> bytes string

Reads a file and returns a Lua string or raw bytes.

Options:

  • mode ("text"|"binary", default "text")
local data = fs.read("README.md")

fs.write(path, data, opts?) -> { ok, err }

Write data to a file.

Options (selected):

  • mode ("overwrite"|"append"|"prepend"|"binary", default "overwrite")
  • binary (boolean, default false) - convert data as bytes

Notes:

  • Ward does not automatically create parent directories; combine with fs.mkdir(fs.dirname(path), {recursive=true}).
assert(fs.write("out.txt", "hello\n").ok)
assert(fs.write("out.txt", "more\n", { mode = "append" }).ok)

Copy and move

  • fs.copy(from, to, opts?) -> { ok, err }
  • fs.move(from, to, opts?) -> { ok, err }

Notes:

  • fs.copy operates on regular files; it does not copy directories.
  • fs.move uses rename when possible. On cross-device moves it falls back to copy+remove for regular files; moving directories or symlinks across devices is currently unsupported.

Temporary directories

fs.tempdir(prefix?) -> string

Creates a temporary directory and returns its path.

local dir = fs.tempdir("ward-")
print("tmp:", dir)

ward.helpers

local helpers = require("ward.helpers")
local retry   = require("ward.helpers.retry")
local n = require("ward.helpers.number")
local s = require("ward.helpers.string")
local t = require("ward.helpers.table")

helpers.number

Numeric predicates and aggregates. All functions return errors if arguments have the wrong type.

Functions:

  • is_integer(n) -> boolean - true when n is finite and has no fractional component.
  • is_float(n) -> boolean - true for finite, non-integer numbers.
  • is_number(v) -> boolean - true for Lua integers or floats.
  • is_nan(v) -> boolean - true when v is a float NaN.
  • is_infinity(v) -> boolean - true when v is +/-inf.
  • is_finite(v) -> boolean - true for integers and finite floats.
  • round(n, precision) -> number - rounds n to precision decimal places.
  • ceil(n) -> number - ceiling.
  • floor(n) -> number - floor.
  • abs(n) -> number - absolute value.
  • clamp(n, min, max) -> number - clamps n into [min, max].
  • sign(n) -> integer - returns 1, 0, or -1.
  • random(min, max) -> number - uniform random between min and max (inclusive for integers).
  • avg(...) -> number - average of variadic arguments (at least one required).
  • min(...) -> number - minimum of variadic arguments (at least one required).
  • max(...) -> number - maximum of variadic arguments (at least one required).
  • sum(...) -> number - sum of variadic arguments (at least one required).

helpers.string

String utilities and regex helpers. Patterns use Rust regex syntax.

Functions:

  • trim(s) -> string - trim both ends.
  • ltrim(s) -> string - trim start.
  • rtrim(s) -> string - trim end.
  • contains(s, substr) -> boolean - substring test.
  • starts_with(s, substr) -> boolean - prefix test.
  • ends_with(s, substr) -> boolean - suffix test.
  • replace(s, substr, repl) -> string - replace first occurrence.
  • replace_all(s, substr, repl) -> string - replace all occurrences.
  • split(s, sep) -> table - splits into an array table.
  • join(sep, ...) -> string - joins variadic strings with sep.
  • to_lower(s) -> string - lowercase.
  • to_upper(s) -> string - uppercase.
  • to_title(s) -> string - title-case words.
  • to_snake(s) -> string - snake_case.
  • to_camel(s) -> string - camelCase.
  • to_kebab(s) -> string - kebab-case.
  • to_pascal(s) -> string - PascalCase.
  • to_slug(s) -> string - slug (alias of kebab-case).
  • match(s, pattern) -> table - first regex match captures as array (empty table if none).
  • match_all(s, pattern) -> table - list of capture arrays for all matches.
  • match_replace(s, pattern, repl) -> string - replace first regex match.
  • match_replace_all(s, pattern, repl) -> string - replace all regex matches.

helpers.table

Functional-style table helpers. Unless noted, sequence order is preserved and non-sequence keys are ignored. Functions that expect an array treat numeric keys from 1..n as the sequence.

Functions:

  • is_empty(tbl) -> boolean - true when table has no key/value pairs.
  • contains(tbl, value) -> boolean - true if any value equals value.
  • concat(tbl, ...) -> table - concatenates array tables.
  • merge(tbl, ...) -> table - shallow merge (later values overwrite).
  • deep_merge(tbl, ...) -> table - recursive merge for nested tables.
  • map(tbl, fn) -> table - applies fn(value, key); returns table with same keys.
  • filter(tbl, fn) -> table - keeps entries where fn(value, key) returns true.
  • reduce(tbl, fn, acc) -> any - folds with fn(acc, value, key).
  • each(tbl, fn) -> table - calls fn(value, key) for side effects; returns original table.
  • find(tbl, fn) -> any|nil - first value where fn(value, key) returns true, else nil.
  • findall(tbl, fn) -> table - array of values where predicate is true.
  • sort(tbl, fn) -> table - returns sorted copy using comparator fn(a, b) -> boolean (true when a < b).
  • reverse(tbl) -> table - reversed array copy.
  • shuffle(tbl) -> table - shuffled array copy.
  • flatten(tbl) -> table - recursively flattens nested array tables.
  • uniq(tbl) -> table - removes duplicates (first occurrence wins).
  • uniq_by(tbl, fn) -> table - uniqueness by computed key fn(value, key).
  • count(tbl, fn) -> integer - number of entries where predicate is true.
  • keys(tbl) -> table - array of keys.
  • values(tbl) -> table - array of values.
  • push(tbl, value) -> nil - append to end.
  • append(tbl, value) -> nil - alias of push.
  • pop(tbl) -> any|nil - remove and return last element (or nil).
  • shift(tbl) -> any|nil - remove and return first element (or nil).
  • prepend(tbl, value) -> nil - insert at start.
  • join(tbl, sep) -> string - stringifies sequence values with separator.

helpers.retry

helpers.retry implements an async retry loop for functions that may intermittently fail.

retry.run(fn, opts?) -> any

Calls fn() and returns its result. If fn() errors, Ward retries until success or the attempt limit is reached.

Options (opts table):

  • attempts (integer, default 3) - total attempts (minimum 1)
  • delay (duration, default 100ms) - base delay between retries; accepts strings like 50ms, 1.5s, 2m
  • max_delay (duration|nil) - optional cap on the delay; accepts the same duration strings
  • backoff (number, default 2.0) - multiplier applied to the delay after each failed attempt (minimum 1.0)
  • jitter (boolean, default false) - randomize delay to reduce thundering herd
  • jitter_ratio (number, default 0.2) - maximum relative jitter, clamped to 0..1

Example:

local retry = require("ward.helpers.retry")
local net = require("ward.net.http")

local res = retry.run(function()
    ...
end, { attempts = 5, delay = "200ms", backoff = 2.0, jitter = true })

print("ok:", res.status)

ward.host

local host = require("ward.host")
local platform = require("ward.host.platform")
local resources = require("ward.host.resources")

host.platform

Platform inspection helpers (OS, arch, etc.).

  • platform.is_windows() -> boolean
  • platform.is_macos() -> boolean
  • platform.is_linux() -> boolean
  • platform.is_bsd() -> boolean
  • platform.is_unix() -> boolean
  • platform.os() -> string (e.g., "linux")
  • platform.arch() -> string (e.g., "x86_64")
  • platform.platform() -> string ("<os>-<arch>")
  • platform.version() -> string (long OS version, best-effort)
  • platform.release() -> string (kernel version, best-effort)
  • platform.hostname() -> string
  • platform.exe_suffix() -> string (".exe" on Windows, else "")
  • platform.path_sep() -> string ("/" or "\\")
  • platform.env_sep() -> string (":" or ";")
  • platform.newline() -> string ("\r\n" on Windows, else "\n")
  • platform.endianness() -> string ("little" or "big")
  • platform.shell() -> table - array-like command for the default shell:
    • Unix: { "sh", "-lc" }
    • Windows: { "cmd", "/C" }
  • platform.info() -> table - one-shot snapshot containing the fields above plus is_windows, is_unix, is_bsd, platform, shell.

host.resources

Resource inspection (memory, CPU) for the host process.

  • resources.get() -> table returns:
    • memory.total, memory.available, memory.used, memory.free (bytes)
    • cpu.load["1m"], cpu.load["5m"], cpu.load["15m"] (load averages)
    • cpu.cores.logical (integer), cpu.cores.physical (integer|nil)
    • uptime (seconds)
    • hostname (string, best-effort)

ward.io

local io = require("ward.io")

Ward serializes reads/writes with internal mutexes so concurrent operations do not interleave unpredictably.

io.read_all(opts?) -> bytes string

Reads all remaining stdin into a string.

Optional opts:

  • max_bytes (number|integer) - if provided, fails when stdin exceeds this limit.
local s = io.read_all()

-- hard cap (1 MiB)
local s2 = io.read_all({ max_bytes = 1024 * 1024 })

io.read_line() -> byts string|nil

Reads one line from stdin.

  • Returns nil on EOF.
local line = io.read_line()
if line == nil then return end
print("got:", line)

io.read_lines() -> function

Returns an iterator-like function. Each call reads one line from stdin and returns bytes string|nil (nil on EOF).

local next_line = io.read_lines()
while true do
  local line = next_line()
  if line == nil then break end
  print(line)
end

Output

  • io.write_stdout(data) -> true
  • io.write_stderr(data) -> true
  • io.flush_stdout() -> nil
  • io.flush_stderr() -> nil
io.write_stdout("hello")
io.write_stderr("warn\n")
io.flush_stdout()

ward.ipc.unix

Fast local IPC using Unix domain sockets. Available on Unix platforms (Linux, macOS). This module integrates with Ward’s awaitable I/O model.

Connecting and listening

  • unix.connect(path, opts?) -> UnixStream

    • Connects to path and returns a stream. Options are currently reserved; unknown fields raise errors to avoid silent misconfiguration.
  • unix.listen(path, opts?) -> UnixListener

    • Default behavior removes stale socket paths: if a prior server crashed and left the path behind, Ward attempts a connection; a refused/not-found result removes the path, while a successful connection errors with “address in use”.
    • Options:
      • backlog: positive integer (default 128).
      • mode: permission bits (use 0o660 or tonumber("660", 8)).
      • owner / group: numeric uid/gid to chown after bind.
      • unlink (default true): when false, existing paths abort instead of being removed.
      • unlink_on_close (default true): unlink the path when the listener is closed/dropped (only for paths created by Ward).
      • mkdir: create parent directories before binding.

Streams

UnixStream methods (all return awaitables yielding {value}|nil, err):

  • read(n?) (default 16384 bytes), read_exact(n)
  • write(bytes) (returns bytes written), write_all(bytes)
  • shutdown() (close write half), close() (drop both halves)
  • Awaitable helpers: wait(n?) and calling the stream itself delegates to read(n?).
  • Field: stream.closed (boolean snapshot).

UnixListener:

  • accept() -> UnixStream|nil, err
  • close() -> boolean (unlinks if owned and configured).

Examples

Echo server + client:

local async = require("ward.async")
local unix = require("ward.ipc.unix")
local json = require("ward.convert.json")

local socket = "/tmp/ward-echo.sock"
local listener = assert(unix.listen(socket, {
  backlog = 8,
  mode = tonumber("660", 8),
  unlink_on_close = true,
}))

async.spawn(function()
  while true do
    local stream, err = listener:accept()
    if not stream then return print("accept failed: " .. tostring(err)) end

    async.spawn(function()
      while true do
        local data, rerr = stream:read(1024)
        if not data then return stream:close() end
        stream:write_all(data):wait()
      end
    end)
  end
end):detach()

local client = assert(unix.connect(socket):wait())
assert(client:write_all("ping"):wait())
local resp = assert(client:read(4):wait())

print(json.encode({ response = resp }))

See samples/ipc_unix_echo.lua for a complete sample including mkdir, stale cleanup, and permission settings.

ward.lifecycle

local lifecycle = require("ward.lifecycle")

Lifecycle provides:

  • Shutdown request detection (signals, cancellation)
  • Hooks you can register to run before exit

Common pattern:

local lifecycle = require("ward.lifecycle")

lifecycle.on_shutdown(function(reason)
  -- flush files, cleanup temp dirs
end)

Functions:

  • lifecycle.on(signal, fn) -> id - register a handler for a signal name/number ("HUP", "INT", "QUIT", "USR1", "USR2", "PIPE", "TERM", or a numeric code). Returns a handler id.
  • lifecycle.on_shutdown(fn) -> id - register a shutdown callback (LIFO order).
  • lifecycle.off(id) -> boolean - remove a previously registered handler.
  • lifecycle.request(code?) -> () - request shutdown; code is an optional exit code.
  • lifecycle.requested() -> boolean - whether shutdown has been requested.
  • lifecycle.code() -> integer|nil - shutdown exit code (if known).

Notes:

  • Ctrl-C / SIGINT requests shutdown by default.
  • Shutdown callbacks run once in LIFO order; failures are best-effort and do not prevent later callbacks.

ward.log

local log = require("ward.log")
log.info("hello", "world")
log.trace(...)
log.debug(...)
log.warn(...)
log.error(...)
log.fatal(...)

Ward log is intentionally minimal; use it for script-friendly logs.

ward.module

ward.module is Ward’s external Lua module manager. It downloads third-party Lua code into Ward’s data directory and makes it available to the current process by extending package.path, so you can require("<name>.<submodule>") (and other repo-local module names) directly. Downloads are stored in a content-addressed layout so multiple versions can coexist and be reused across runs.

Import

local module = require("ward.module")

Where modules live

Ward keeps external modules under an “externals” directory in the user data home:

  • Base directory: ${XDG_DATA_HOME}/ward/externals
    • If XDG_DATA_HOME is not set: ~/.local/share/ward/externals
  • Content-addressed store: ${base}/.store/<id>

The store id is derived from the normalized source URL and the selected revision (for git sources), so changing tag/branch/rev produces a different <id>.

Ward maintains a per-process binding map so that once you bind <name> to an id, future require(...) calls in that same run resolve consistently. The binding operation also injects the selected store root into package.path (idempotently), so standard Lua resolution works. Rebinding is rejected unless you pass force=true, in which case Ward also clears matching package.loaded entries and updates package.path to point at the new store root.

Naming rules

If you don’t specify a name, it is derived from the URL:

  • Basename is taken from the URL path; common suffixes are removed (.git, .lua, .zip, .tar.gz, .tgz), and query/fragment parts are ignored.
  • The resulting name is canonicalized:
    • lowercase
    • non-alphanumeric separators collapse to _
    • names starting with a digit are prefixed with _

Submodules are supported:

require("<name>.<submodule>")

(Each segment must be alphanumeric or _.)

API

module.dir() -> string

Returns the absolute path to the externals base directory.

local module = require("ward.module")
print(module.dir())

module.git(url, opts?) ->

Fetches a git repository, checks out a selected revision, and binds it as <name> for this process.

Arguments

  • url (string): git repository URL (HTTPS/SSH, etc.)
  • opts (table, optional):
    • name (string): override derived module name / binding name
    • Exactly one selector:
      • rev (string): commit hash
      • branch (string): branch name
      • tag (string): tag name
      • If none provided, defaults to “head”
    • force (boolean, default false): allow rebinding <name> to a different id (also clears cached require)
    • depth (integer): shallow clone depth
    • recursive (boolean): fetch submodules
    • timeout (number, seconds): overall fetch timeout
    • max_bytes (integer): abort if transfer exceeds this size
    • filter_blobs (boolean): request partial clone with blob filtering (when supported)

Return fields

  • ok (boolean): whether fetch/checkout succeeded
  • name (string): canonicalized name
  • require (string): require target (e.g. my_lib)
  • path / store_path (string): checkout directory path
  • id (string): content-addressed id for this URL+selector

Example

local module = require("ward.module")

local result = module.git("https://github.com/org/repo.git", {
  name = "my_lib",
  tag = "v1.2.3",
  depth = 1,
  recursive = true,
})

assert(result.ok, "git fetch failed")

-- Require a submodule from the checked out repo
local api = require(result.require .. ".api")
print("loaded", result.require, "from", result.path, "id", result.id)

module.url(url, opts?) -> { ok, name, require, path, store_path, id }

Downloads a single Lua file and binds it as <name>. The downloaded content is stored as init.lua inside the store directory for its id.

Arguments

  • url (string): HTTP(S) URL to a .lua file (or any endpoint returning Lua content)
  • opts (table, optional):
    • name (string): override derived module name
    • insecure (boolean, default false): allow invalid TLS certificates
    • follow_redirects (boolean, default true)
    • retries (integer, default 5, minimum 1)
    • retry_delay (integer, milliseconds, default 2000)
    • force (boolean, default false): allow rebinding <name> to a new id
    • timeout (number, seconds): overall request timeout
    • max_bytes (integer): abort if download exceeds this size
    • method (string): HTTP method (default GET)
    • headers (table<string, string>): additional request headers

Return fields Same shape as module.git.

Example

local module = require("ward.module")

local result = module.url("https://example.com/plugin.lua", {
  headers = { Authorization = "Bearer TOKEN" },
  method = "POST",
  retry_delay = 500,
})

assert(result.ok, "download failed")

local plugin = require(result.require)
print("plugin loaded:", plugin.version and plugin.version() or "<no version()>")

Notes and best practices

  • Prefer pinning git modules with tag or rev to get reproducible behavior.
  • Use force=true only when you intentionally want to rebind a name within a single run.
  • If you need multiple versions at once, bind them under different names (e.g. my_lib_v1, my_lib_v2) rather than forcing rebinding.

ward.net

local net   = require("ward.net")
local http  = require("ward.net.http")
local fetch = require("ward.net.fetch")

ward.net groups network-related helpers. Today it exposes two submodules:

  • ward.net.http - in-process HTTP requests via reqwest
  • ward.net.fetch - higher-level “fetch into a file/dir” helpers

ward.net.http - HTTP request primitives

Functions

All functions below are async (implemented with create_async_function). Call them normally and receive a HttpResponse userdata.

  • http.get(url, opts?) -> HttpResponse
  • http.delete(url, opts?) -> HttpResponse
  • http.options(url, opts?) -> HttpResponse
  • http.post(url, opts?) -> HttpResponse
  • http.put(url, opts?) -> HttpResponse

Options (opts table)

opts is optional. When omitted or not a table, defaults are applied.

  • query (table) - query parameters.
    • Keys are strings.
    • Values must be string, number, integer, or boolean (they are converted to strings).
  • headers (table) - header map: string -> string.
  • timeout (number) - request timeout in seconds (float accepted). Must be positive and finite.
  • follow_redirects (boolean, default true) - when enabled, redirects are followed (limited to 10).
  • allow_error (boolean, default false) -
    • false: non-2xx responses raise a runtime error.
    • true: non-2xx responses are returned as HttpResponse.

Body options (used by post and put):

  • json (any) - serializable Lua value encoded as JSON.
  • form (table) - form fields string -> string.

If both json and form are present, JSON takes precedence.

HttpResponse userdata

Returned by http.* functions.

Fields (also available via methods):

  • resp.status (integer) - HTTP status code.
  • resp.headers (table) - header map string -> string.
    • Note: duplicate header names will be overwritten in the table (last one wins).
  • resp.body (string|nil) - response body decoded as text.

Methods:

  • resp:is_ok() -> boolean - true for 2xx.
  • resp:get_status() -> integer
  • resp:get_headers() -> table
  • resp:get_body() -> string|nil

Examples:

local http = require("ward.net.http")

-- Basic GET
local r = http.get("https://example.com", { follow_redirects = true })
print(r.status)
print(r:is_ok())

-- Query + headers
local r2 = http.get("https://httpbin.org/get", {
  query = { q = "ward", page = 1, debug = true },
  headers = { ["User-Agent"] = "ward" },
  timeout = 10,
  allow_error = true,
})
print(r2.status)
print(r2:body())

-- POST JSON
local r3 = http.post("https://httpbin.org/post", {
  json = { hello = "world", n = 1 },
  headers = { ["Content-Type"] = "application/json" },
})
assert(r3:is_ok())

ward.net.fetch - fetch into a file/dir

fetch is for “download/checkout into a path” workflows that compose well with ward.fs.

fetch.url(url, opts?) -> FetchResponse

Downloads the response body as bytes into a file (streaming), then returns metadata.

Options (opts table):

  • method (string, default "GET") - one of: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
  • headers (table) - header map string -> string.
  • timeout (number) - request timeout in seconds.
  • follow_redirects (boolean, default true) - redirects are followed
  • insecure (boolean, default false) - allow invalid TLS certificates. (limited to 10).
  • into (string|nil) - destination file path.
    • If omitted, Ward creates a unique file path under the OS temp directory.
  • max_bytes (integer|number|nil) - maximum allowed response size.
    • Values <= 0 disable the limit.
    • If the limit is exceeded, Ward removes the partial file and returns ok=false with status=413 and path=nil.

FetchResponse userdata fields/methods:

  • resp.ok / resp:is_ok() -> boolean
  • resp.status / resp:get_status() -> integer - HTTP status code (or 413 if max_bytes exceeded).
  • resp.path / resp:get_path() -> string|nil - destination path.
  • resp.size / resp:get_size() -> integer - bytes written.

Example:

local fetch = require("ward.net.fetch")
local fs    = require("ward.fs")

local r = fetch.url("https://example.com/file.tar.gz", {
  into = "./downloads/file.tar.gz",
  max_bytes = 50 * 1024 * 1024,
})

if not r.ok then
  error("fetch failed: status=" .. tostring(r.status))
end

print("saved to", r.path, "bytes", r.size)
assert(fs.is_file(r.path))

fetch.git(url, opts?) -> FetchResponse

Clones a Git repository into a directory (using the external git command), then optionally checks out a revision.

Notes:

  • Requires git to be installed and discoverable in PATH (it is relies on a system git binary).
  • git stdout/stderr are suppressed; use ok/status to handle errors.

Options (opts table):

  • into (string|nil) - destination directory.
    • If omitted, Ward creates a unique directory under the OS temp directory.
  • depth (integer|nil) - shallow clone depth (must be > 0). Defaults to 1.
  • filter_blobs (boolean, default true) - when true, uses --filter=blob:none.
  • branch (string|nil) - clone a specific branch.
  • tag (string|nil) - clone a specific tag (used as --branch <tag>).
    • If both branch and tag are set, branch takes precedence.
  • recursive (boolean, default false) - when true, uses --recurse-submodules.
  • rev (string|nil) - if set, runs git checkout <rev> after cloning.
  • timeout (number|nil) - command timeout in seconds.
  • max_bytes (integer|number|nil) - maximum allowed on-disk size for the cloned directory.
    • If exceeded, Ward removes the directory and returns ok=false with status=413 and path=nil.

On success, FetchResponse.status is 0 and ok=true. On failure, status is the git exit code.

Example:

local fetch = require("ward.net.fetch")

local r = fetch.git("https://github.com/user/repo.git", {
  into = "./vendor/repo",
  depth = 1,
  rev = "v1.2.3",
  recursive = false,
  timeout = 120,
})

if not r.ok then
  error("git fetch failed: exit=" .. tostring(r.status))
end

print("checked out into", r.path, "bytes", r.size)

ward.module - external module manager

ward.module downloads third-party Lua modules into Ward’s data directory and exposes them via require("<name>") for the lifetime of the process. Downloads are content-addressed so multiple revisions can coexist and be reused across runs.

local module = require("ward.module")

Where modules live

  • Base directory: ${XDG_DATA_HOME}/ward/externals (or ~/.local/share/ward/externals if XDG_DATA_HOME is unset).
  • Content-addressed store: ${base}/.store/<id>, where <id> is sha256(normalized_url + "\n" + selector).
  • A per-process binding map ensures require("<name>") resolves to the revision selected earlier in the same run. Rebinding to a different id is rejected unless force=true, in which case the next require reloads the module.

Naming rules

  • Default name comes from the URL basename (extensions, .git, .lua, .zip, .tar.gz, .tgz, and query/fragment removed).
  • Names are canonicalized to lowercase with non-alphanumeric separators folded into _. Leading digits are prefixed with _.
  • require("<name>.<submodule>") is supported; segments must be alphanumeric or _.

module.dir() -> string

Returns the absolute path to the externals base directory.

print(module.dir())

module.git(url, opts?) -> { ok, name, require, path, store_path, id }

Clone a Git repository into the externals store and bind it for require.

Options (all optional):

  • name (string) - override derived name and <name> binding.
  • Exactly one of rev, branch, or tag selects a revision; default is head. Different selectors produce different store ids.
  • force (bool, default false) - allow rebinding an already-bound name to a new id (also clears cached required module).
  • depth (integer) - shallow clone depth (passed to ward.net.fetch.git).
  • recursive (bool) - fetch submodules (git --recurse-submodules).
  • timeout (number, seconds) - overall fetch timeout.
  • max_bytes (integer) - abort if transfer exceeds this many bytes.
  • filter_blobs (bool) - request partial clone with blob filtering when supported by the remote.

Return table:

  • ok (bool) - whether the checkout succeeded.
  • name (string) - canonicalized name.
  • require (string) - require target (<name>).
  • path / store_path (string) - final checkout directory.
  • id (string) - content-addressed id.

Example (pin to a tag and require a submodule):

local module = require("ward.module")
local json = require("ward.convert.json")

local result = module.git("https://github.com/org/repo.git", {
  name = "my_lib",
  tag = "v1.2.3",
  depth = 1,
  recursive = true,
})

assert(result.ok, "git fetch failed")
local api = require(result.require .. ".api")
print(json.encode({ id = result.id, path = result.path, ok = result.ok }))

module.url(url, opts?) -> { ok, name, require, path, store_path, id }

Download a single Lua file and bind it as <name>. The URL content is stored as init.lua inside its store directory.

Options (all optional):

  • name (string) - override derived name.
  • insecure (bool, default false) - allow invalid TLS certificates.
  • follow_redirects (bool, default true).
  • retries (integer, default 5, minimum 1).
  • retry_delay (milliseconds, default 2000) - delay between retries.
  • force (bool, default false) - rebind an existing name to the downloaded id.
  • timeout (number, seconds) - overall request timeout.
  • max_bytes (integer) - abort if download exceeds this size.
  • method (string) - HTTP method (default GET).
  • headers (table<string, string>) - additional request headers.

Return table fields match module.git.

Example (with custom headers and POST body):

local module = require("ward.module")

local result = module.url("https://example.com/plugin.lua", {
  headers = { Authorization = "Bearer TOKEN" },
  method = "POST",
  retry_delay = 500,
})

if not result.ok then error("download failed") end
local plugin = require(result.require)
print(plugin.version())

ward.process

local process = require("ward.process")

Constructors

process.cmd(program, ...args) -> Cmd

Create a command.

Arguments may be provided either as varargs or as a single array-table:

local a = process.cmd("git", "status", "--porcelain")
local b = process.cmd("git", { "status", "--porcelain" })

process.sh(script) -> Cmd

Run a shell fragment using the platform default shell:

  • Unix: sh -lc <script>
  • Windows: cmd /C <script>
local cmd = process.sh("echo $HOME")

process.exit(code?) -> (raises)

Request script termination with an exit status.

  • This does not terminate the host process immediately.
  • Ward requests shutdown, unwinds execution, runs shutdown handlers, and then the CLI exits with the given status.
  • code defaults to 0. Negative values are coerced to 1. Values above i32::MAX are clamped.

Use this for early returns from scripts that still need cleanup handlers to run.

process.shell_defaults(opts?) -> table

Set or read defaults that mimic common set -euo pipefail toggles:

  • pipefail (boolean) - when true, new commands/pipelines treat any non-zero step as failure by default.
  • timeout (number|string|nil) - pipeline-level timeout for new commands/pipelines. Accepts integers (ms) or human strings (500ms, 2s, 1m). nil clears the default.

Returns a table with the active defaults. Example:

local process = require("ward.process")

-- Enable pipefail + a 30s default timeout for all new cmds/pipelines.
process.shell_defaults({ pipefail = true, timeout = "30s" })

Process middleware (command wrappers)

Ward exposes a small, composable middleware stack that is applied to every process spawn:

  • Cmd:run() / Cmd:output() / Cmd:spawn()
  • Pipeline:run() / Pipeline:output() / Pipeline:spawn()

This is the intended core building block for wardlib wrappers (for example, sudo / doas) so users do not need to repeat a prefix in every process.cmd(...) call.

Semantics

  • Middleware is task-local (per ward.async task). When you async.spawn(...), the current middleware stack is inherited by the new task.
  • Middleware runs synchronously right before the OS process is spawned.
  • Each middleware is a Lua function mw(spec).
    • It may mutate the passed spec table and return nil, or
    • return a new spec table.

Spec contract

The spec table contains the following fields (future versions may add more):

  • spec.argv (table) - array of strings: { program, arg1, arg2, ... } (must remain non-empty)
  • spec.cwd (string|nil)
  • spec.env (table) - string-to-string map of per-command overrides

API

  • process.push_middleware(fn) -> integer - push fn to the current task’s stack (returns new depth)
  • process.pop_middleware() -> boolean - pop the last middleware (returns false if the stack is empty)

Example: wrap commands with sudo/doas (non-interactive by default)

local process = require("ward.process")

local function with_middleware(mw, body)
  process.push_middleware(mw)
  local ok, err = pcall(body)
  process.pop_middleware()
  if not ok then error(err) end
end

local function sudo_middleware(opts)
  opts = opts or {}
  local tool = opts.tool or "sudo"     -- or "doas"
  local non_interactive = opts.non_interactive ~= false

  return function(spec)
    local argv = spec.argv or {}
    if argv[1] == tool then return nil end

    local out = { tool }
    if non_interactive then
      table.insert(out, "-n")
    end
    table.insert(out, "--")
    for i = 1, #argv do table.insert(out, argv[i]) end
    spec.argv = out
    return nil
  end
end

-- If you want password prompting, pre-auth once with inherited stdio.
-- (Captured `output()` calls cannot safely prompt.)
process.cmd("sudo", "-v"):run():assert_ok("sudo -v failed")

with_middleware(sudo_middleware({ tool = "sudo" }), function()
  process.cmd("id", "-u"):run():assert_ok()
end)

CmdResult userdata (result of run/output)

Fields:

  • result.ok (boolean)
  • result.code (integer)
  • result.signal (integer|nil)
  • result.stdout (bytes string|nil)
  • result.stderr (bytes string|nil)
  • result.steps (table of integers) - per-step exit codes

Methods:

  • result:is_ok() -> boolean
  • result:assert_ok(msg?) -> () - throws a Lua error if not ok

Example:

local r = process.cmd("echo", "hi"):output()
print(r.ok, r.code)
print(r.stdout)
r:assert_ok("echo failed")

Cmd userdata

In addition to one-shot execution (run / output), Cmd supports spawning long-running processes and streaming their stdio.

Builder methods (fluent):

  • cmd:cwd(path) -> Cmd
  • cmd:env(key, value) -> Cmd
  • cmd:envs(tbl) -> Cmd (reads key/value pairs)
  • cmd:timeout(ms|duration_string|nil) -> Cmd
  • cmd:stdin(data) -> Cmd (bytes string)
  • cmd:stdin_file(path) -> Cmd
  • cmd:stdin_null() -> Cmd
  • cmd:stderr_to_stdout(true|false) -> Cmd
  • cmd:spawn(opts?) -> ProcChild
  • cmd:disown(opts?) -> table

Notes:

  • cmd:stdin(data), cmd:stdin_file(path), and cmd:stdin_null() configure stdin for run() / output() / spawn(). This is one-shot input: Ward will feed the configured input (or connect stdin to the file/null stream) and then close stdin.

  • cmd:stdin(v) accepts only a bytes string, or nil/false to reset to inherited stdin. Other values raise an error.

  • cmd:stdin_file(path) fails at spawn-time if the file cannot be opened.

  • cmd:stderr_to_stdout(true) merges stderr into stdout best-effort. Ordering may differ from shell 2>&1. In capture mode (output()), merged data is returned in stdout and stderr is nil.

  • cmd:timeout(...) accepts whole seconds (number) or human strings like "500ms", "2s", "1m". Pass nil to clear.

  • cmd:stdin_null() sets stdin to a closed stream (equivalent to shell < /dev/null).

  • cmd:pipe(other_cmd_or_pipeline) -> Pipeline

Terminal operations:

  • cmd:run() -> CmdResult - inherits stdio
  • cmd:output() -> CmdResult - captures stdout/stderr

Pipeline operator:

  • cmd1 | cmd2 produces a Pipeline (via __bor).

Example:

local r = process.cmd("git", "rev-parse", "HEAD"):output()
r:assert_ok()
print(r.stdout)

Cmd:spawn(opts?) -> ProcChild

Spawns a long-running child process and returns a ProcChild handle. This is used for:

  • Streaming stdout/stderr incrementally (lines or raw bytes)
  • Interactive processes (writing to stdin)
  • Long-running daemons/subscriptions (e.g., pw-mon, tail -f, etc.)

opts is an optional table:

  • stdin (boolean or "pipe"|"inherit"|"null")

    • true / "pipe": pipe stdin so Lua can write via ProcChild:stdin()
    • false / "inherit": inherit parent stdin
    • "null": connect stdin to a closed stream
    • Default: inferred. If you set cmd:stdin(data), Ward pipes stdin to feed the bytes; if you set cmd:stdin_null(), stdin is null; if you set cmd:stdin_file(path), Ward pre-opens the file and uses it as stdin. Otherwise stdin defaults to inherited stdin unless you request piping.
  • stdout (boolean or "pipe"|"inherit"|"null", default true)

    • true / "pipe": pipe stdout so you can stream it
    • false / "inherit": inherit parent stdout
    • "null": discard stdout
  • stderr (boolean or "pipe"|"inherit"|"null")

    • true / "pipe": pipe stderr so you can stream it
    • false / "inherit": inherit parent stderr
    • "null": discard stderr
    • Default: true when cmd:stderr_to_stdout(true) is set, otherwise false ("inherit").

Important:

  • If you call cmd:stderr_to_stdout(true), Ward merges stderr into the stdout stream (similar to 2>&1). In this case, stderr is not available separately and must be read from stdout. Ordering is best-effort and may not exactly match OS-level 2>&1 interleaving.
  • Choose either one-shot stdin via cmd:stdin(...) / cmd:stdin_file(...) / cmd:stdin_null(), or interactive stdin via spawn({ stdin = true }) + ProcChild:stdin(). Combining cmd:stdin(...) / cmd:stdin_file(...) with spawn({ stdin = true }) is invalid and raises an error.

Example (spawn + line streaming):

local p = require("ward.process")

local child = p.cmd("sh", "-lc", "printf 'a\\nb\\n' && sleep 1")
    :spawn({ stdout = true })
local out = assert(child:stdout_lines())

while true do
  local line, err = out:wait()
  if not line then break end
  print("line:", line)
end

child:wait()

Cmd:disown(opts?) -> table

Spawn a process and do not wait for it.

This is the equivalent of cmd & disown in a shell:

  • Ward starts the process and immediately returns.
  • Ward does not keep a ProcChild handle alive.
  • Ward reaps the process in the background (best-effort) to avoid zombies.

Return value:

{ pid = <integer>, pids = { <integer>, ... } }

opts is the same shape as spawn(opts?) (stdio modes), but with different defaults:

  • stdin default: "null"
  • stdout default: "null"
  • stderr default: "null"

Notes:

  • stdin = "pipe" / true is not supported for disown() (use spawn() for interactive processes).
  • If you explicitly set stdout = "pipe" or stderr = "pipe", Ward will drain those pipes in the background so the child cannot block on a full buffer.

Example (start a GUI app and continue immediately):

local p = require("ward.process")

-- Start and forget.
local info = p.cmd("firefox"):disown()
print("started pid", info.pid)

-- Continue doing other work...
p.cmd("sh", "-lc", "echo still running"):run()

ProcChild userdata

Represents a spawned child process.

Methods:

  • child:pid() -> integer
  • child:pids() -> table - array of PIDs for all stages (for pipelines)
  • child:stdin() -> ProcStdin | nil, err
    • Returns nil, "not_piped" if stdin is not piped.
  • child:stdout_lines() -> LineStream | nil, err
    • Returns nil, "not_piped" if stdout is not piped.
    • Returns nil, "mode_conflict" if stdout was already opened as bytes.
    • You may call this multiple times; each handle reads from the same underlying pipe.
  • child:stderr_lines() -> LineStream | nil, err
    • Returns nil, "not_piped" if stderr is not piped.
    • Returns nil, "merged" if stderr was merged into stdout via cmd:stderr_to_stdout(true).
    • Returns nil, "mode_conflict" if stderr was already opened as bytes.
    • You may call this multiple times; each handle reads from the same underlying pipe.
  • child:stdout_bytes() -> ByteStream | nil, err
    • Returns nil, "not_piped" if stdout is not piped.
    • Returns nil, "mode_conflict" if stdout was already opened as lines.
    • You may call this multiple times; each handle reads from the same underlying pipe.
  • child:stderr_bytes() -> ByteStream | nil, err
    • Returns nil, "not_piped" if stderr is not piped.
    • Returns nil, "merged" if stderr was merged into stdout via cmd:stderr_to_stdout(true).
    • Returns nil, "mode_conflict" if stderr was already opened as lines.
    • You may call this multiple times; each handle reads from the same underlying pipe.
  • child:kill() -> boolean (async)
  • child:wait() -> CmdResult (async)

ProcChild:wait() returns a CmdResult with:

  • ok, code, signal
  • stdout/stderr are typically nil because streaming consumption is incremental, not captured.

ProcStdin userdata (interactive stdin)

Returned by ProcChild:stdin() when stdin is piped.

Methods:

  • stdin:write(bytes_string) -> true | nil, err (async)
  • stdin:writeln(string) -> true | nil, err (async) - writes string + \\n
  • stdin:flush() -> true | nil, err (async)
  • stdin:close() -> true (async)
  • stdin:is_closed() -> boolean (sync)

Example (interactive stdin):

local p = require("ward.process")

local child = p.cmd("cat"):spawn({ stdin = true, stdout = true })
local stdin = assert(child:stdin())
local out = assert(child:stdout_lines())

stdin:writeln("hello")
stdin:writeln("world")
stdin:close()

while true do
  local line, err = out:wait()
  if not line then break end
  print("echo:", line)
end

child:wait()

LineStream userdata (line-by-line streaming)

Returned by ProcChild:stdout_lines() or ProcChild:stderr_lines().

Note: Multiple coroutines reading the same LineStream/ByteStream will compete for data (load-balancing). Avoid creating multiple readers unless that is intended.

Methods:

  • stream:wait() -> line | nil, err
    • err is "eof" when the stream ends.

This object follows Ward’s “awaitable” contract (:wait()), so it can be used with async.select(...).

ByteStream userdata (raw byte streaming)

Returned by ProcChild:stdout_bytes() or ProcChild:stderr_bytes().

Note: Multiple coroutines reading the same LineStream/ByteStream will compete for data (load-balancing). Avoid creating multiple readers unless that is intended.

Methods:

  • stream:wait(n?) -> bytes | nil, err
    • n defaults to 16384
    • returns a bytes string (binary-safe; may contain \\0)
    • err is "eof" when the stream ends.

Aliases:

  • stream:read(n?) is the same as stream:wait(n?).
  • stream(n?) calls stream:wait(n?).

This object follows Ward’s “awaitable” contract, so it can be used with async.select(...).

Example (bytes):

local p = require("ward.process")

local child = p.cmd("sh", "-lc", "printf 'A\\0B'"):spawn({ stdout = true })
local bs = assert(child:stdout_bytes())
local chunk, err = bs:wait(3)
assert(chunk, err)

print("len:", #chunk)
print("bytes:", string.byte(chunk, 1, #chunk))

child:wait()

Pipeline userdata

Builder methods:

  • pl:pipefail(true|false) -> Pipeline - if true, pipeline ok requires all steps succeed
  • pl:timeout(ms|duration_string|nil) -> Pipeline
  • pl:pipe(cmd_or_pipeline) -> Pipeline

Timeouts accept whole seconds (number) or human strings ("500ms", "2s", "1m"). Pass nil to clear.

Terminal operations:

  • pl:run() -> CmdResult
  • pl:output() -> CmdResult
  • pl:spawn(opts?) -> ProcChild
  • pl:disown(opts?) -> table

pl:spawn(opts?) starts the pipeline and returns a ProcChild for streaming. The returned child refers to the last stage in the pipeline; use child:pids() to get all stage PIDs.

pl:disown(opts?) starts the pipeline and returns the same { pid, pids } table as Cmd:disown(opts?). It is useful for fire-and-forget pipelines where you do not need streaming or interaction.

Operator:

  • pl | cmd extends the pipeline

Example: pipe + capture

local p = process.cmd("cat", "README.md") | process.cmd("wc", "-l")
local r = p:output()
r:assert_ok()
print("lines:", r.stdout)

Examples

Fan-out / fan-in

local async = require("ward.async")
local process = require("ward.process")

local workers = 4
local ch = async.channel({ capacity = 32 })

for i = 1, workers do
  async.spawn(function()
    local r = process.cmd("sh", "-lc", "echo worker=" .. i):output()
    ch:send({ i = i, ok = r.ok, out = r.stdout })
  end)
end

for _ = 1, workers do
  local msg = ch:wait()
  print(msg.i, msg.ok, msg.out)
end

ch:close()

Worker pool

See samples/async.worker_pool.lua for a complete runnable example.

Streaming example: watch a long-running command and react to new output

This pattern is common in bash/sh scripting (e.g., tail -f ... | while read ...; do ...; done). In Ward, do it with spawn() and stdout_lines():

local async = require("ward.async")
local p = require("ward.process")
local str = require("ward.helpers.string")

local NEEDLE = "PipeWire:Interface:Device"

local child = p.cmd("pw-mon", "-oap"):spawn({ stdout = true })
local out = assert(child:stdout_lines())

while true do
  local line, err = out:wait()
  if not line then break end

  -- Only reacts to *new* lines, not the initial process output snapshot.
  if str.contains(line, NEEDLE) then
    local r = p.cmd("wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"):output()
    if r.ok then
      print(str.trim(r.stdout or ""))
    end
  end
end

child:wait()

ward.template

MiniJinja

MiniJinja is a fast, Rust-native Jinja2-like template engine.

local tpl = require("ward.template.minijinja")

minijinja.render(template, context, opts?) -> string

Render a template string using the given context (Lua table).

local out = tpl.render("Hello {{ user.name }}!", {
  user = { name = "Ward" }
})
print(out) -- Hello Ward!

minijinja.render_async(template, context, opts?) -> string

Same as render, but runs the render on a blocking thread so it will not block the async runtime.

local out = tpl.render_async("{{ n }}", { n = 42 })
print(out)

minijinja.render_file(path, context, opts?) -> string

Read a file from path and render its contents as a template.

local out = tpl.render_file("./hello.tmpl", { name = "world" })
print(out)

minijinja.render_file_async(path, context, opts?) -> string

Same as render_file, but runs on a blocking thread.

Options

All functions accept an optional opts table:

  • undefined: one of "strict", "lenient", "chainable" (default: "strict")
  • trim_blocks: boolean (default: false)
  • lstrip_blocks: boolean (default: false)
  • keep_trailing_newline: boolean (default: false)
  • auto_escape: boolean (default: false)
  • loader: table configuring {% include %} / {% import %} resolution
    • paths: array of strings; additional search paths for templates

Example:

local out = tpl.render_file("./templates/main.j2", {
  title = "Hello",
  items = { "a", "b", "c" },
}, {
  undefined = "strict",
  trim_blocks = true,
  lstrip_blocks = true,
  loader = {
    paths = { "./templates" },
  },
})
print(out)

ward.term

local term = require("ward.term")

Input (returns awaitables)

All input helpers return an InputAwaitable userdata.

  • term.prompt(args) -> InputAwaitable (returns string|nil when awaited)
  • term.confirm(args) -> InputAwaitable (returns boolean when awaited)
  • term.password(args) -> InputAwaitable (returns string|nil when awaited)
  • term.choose(args) -> InputAwaitable (returns string|nil when awaited)

Awaitable methods:

  • a:wait() -> value
  • a() -> value (via __call)

term.prompt{ question, default?, trim? }

local name = term.prompt({ question = "Name", default = "guest" }):wait()
print("hello", name)

term.confirm{ question, default? }

Accepted answers: y/yes and n/no (case-insensitive). Empty input returns the default if provided.

local ok = term.confirm({ question = "Continue?", default = false }):wait()
if not ok then return end

term.password{ prompt, trim? }

Reads a line with no echo (TTY).

local secret = term.password({ prompt = "Password:" }):wait()

term.choose{ question, options, default? }

options is an array-like table.

local choice = term.choose({
  question = "Pick one",
  options = { "dev", "staging", "prod" },
  default = "dev",
}):wait()
print(choice)

Printing

  • term.print(...) -> true
  • term.println(...) -> true
  • term.eprint(...) -> true
  • term.eprintln(...) -> true

Screen control and tty

  • term.clear() -> true
  • term.isatty(stream?) -> boolean - stream can be "stdout" or "stderr"

term.ansi submodule

term.ansi is a table of ANSI escape-code strings you can concatenate into output.

Common style fields:

  • ansi.reset
  • ansi.bold, ansi.dim, ansi.italic, ansi.underline, ansi.blink, ansi.reverse, ansi.hidden, ansi.strike

Clear / cursor fields:

  • ansi.clear_line, ansi.clear_screen, ansi.home

Colors (foreground):

  • ansi.black, ansi.red, ansi.green, ansi.yellow, ansi.blue, ansi.magenta, ansi.cyan, ansi.white, ansi.default
  • ansi.bright_black, ansi.bright_red, ansi.bright_green, ansi.bright_yellow, ansi.bright_blue, ansi.bright_magenta, ansi.bright_cyan, ansi.bright_white

Colors (background):

  • ansi.bg_black, ansi.bg_red, ansi.bg_green, ansi.bg_yellow, ansi.bg_blue, ansi.bg_magenta, ansi.bg_cyan, ansi.bg_white, ansi.bg_default
  • ansi.bg_bright_black, ansi.bg_bright_red, ansi.bg_bright_green, ansi.bg_bright_yellow, ansi.bg_bright_blue, ansi.bg_bright_magenta, ansi.bg_bright_cyan, ansi.bg_bright_white

Example:

local ansi = term.ansi
term.println(ansi.bold .. ansi.green .. "OK" .. ansi.reset)

term.progress(args?) -> Progress

Create a progress renderer for TTY output.

Constructor args (table):

  • total (integer|nil)
  • message (string|nil)
  • stream ("stdout"|"stderr", default "stderr")

Progress methods (getter/setter style):

  • p:tick(delta?) -> nil - increment by delta (default 1)
  • p:value(v?) -> integer|nil - get current when called without args; set when v provided
  • p:total(t?) -> integer|nil - get total when called without args; set when t provided
  • p:message(s?) -> string|nil - get message when called without args; set when s provided
  • p:finish(final_msg?) -> true - render final line + newline (TTY only)

Example:

local p = term.progress({ total = 10, message = "Working" })
for _ = 1, 10 do
  time.sleep("150ms"):wait()
  p:tick(1)
end
p:finish("Done")

ward.time

local time = require("ward.time")

Wall clock

  • time.now() -> TimePoint
  • time.now_table() -> table - returns a table with timestamp components

Parsing

  • time.parse_rfc3339(s) -> TimePoint|nil
  • time.parse_rfc2822(s) -> TimePoint|nil
  • time.parse(s) -> TimePoint|nil - best-effort parser

Construction

  • time.from_timestamp(seconds, nanos?) -> TimePoint
  • time.utc(y, m, d, hh?, mm?, ss?, nanos?) -> TimePoint

Durations

time.duration(x) -> Duration

Accepts:

  • number: whole seconds
  • string: human readable duration (e.g. 500ms, 1.5s, 2h)
  • Duration userdata

Examples:

local d1 = time.duration(2)
local d2 = time.duration("250ms")
local d3 = time.duration("2h")

Monotonic time

  • time.instant_now() -> InstantPoint

Timers (return awaitables)

These return userdata you must call () or :wait().

  • time.sleep(duration) -> SleepAwaitable
  • time.after(duration, callback?) -> AfterAwaitable
  • time.interval(duration) -> IntervalTimer
  • time.timeout(awaitable, duration) -> TimeoutAwaitable

Examples:

-- sleep
time.sleep("200ms"):wait()

-- after
local v = time.after("1s", function() return "done" end):wait()
print(v)

-- interval
local it = time.interval("1s")
for _ = 1, 3 do
  print("tick", it())
end

-- timeout
local a = time.sleep("5s")
local ok, err = pcall(function()
  time.timeout(a, "200ms"):wait()
end)
print(ok, err)

Blocking sleep

  • time.sleep_blocking(duration) -> true

Ward shell examples

“set -e” style: assert on failures

local process = require("ward.process")

process.cmd("git", "rev-parse", "--is-inside-work-tree")
  :output()
  :assert_ok("not a git repo")

Download to temp dir and process

local fs = require("ward.fs")
local process = require("ward.process")
local term = require("ward.term")
local time = require("ward.time")

local dir = fs.tempdir("ward-")
term.println("tmp:", dir)

-- Example pipeline
local p = process.cmd("printf", "hello\nworld\n") | process.cmd("wc", "-l")
local r = p:output()
r:assert_ok()
term.println("lines:", r.stdout)

-- progress demo
local prog = term.progress({ total = 5, message = "Working" })
for _ = 1, 5 do
  time.sleep("200ms"):wait()
  prog:tick(1)
end
prog:finish("Done")

LuaRocks Notes

Ward is designed to be self-contained and predictable. For that reason, our compatibility target with LuaRocks is intentionally conservative.

What is supported

Ward works well with LuaRocks packages that are pure Lua (i.e., they install only *.lua source files and do not require any compiled native components).

To use pure-Lua rocks with Ward, install them into a LuaRocks tree (local or global) and extend package.path so Ward can locate the modules.

Example (local tree):

-- Install with:
--   luarocks --lua-version 5.4 --tree ./.rocks install <rock>

local tree = "./.rocks"
package.path = table.concat({
  tree .. "/share/lua/5.4/?.lua",
  tree .. "/share/lua/5.4/?/init.lua",
  package.path,
}, ";")

local mod = require("some_rock_module")

Notes:

  • Ward embeds Lua 5.4, so use --lua-version 5.4.
  • Using a project-local tree (e.g., ./.rocks) is recommended for reproducibility.

What is not supported (for now)

Ward does not currently provide reliable support for LuaRocks packages that include native C extensions (e.g., *.so, *.dll) or depend on external Lua C modules.

You may be able to experiment by extending package.cpath, but compatibility is not guaranteed and is not considered part of Ward’s supported surface area.

Why C-extension rocks are out of scope currently

Ward embeds Lua as part of the executable (via an embedded Lua runtime), which is a deliberate choice to keep Ward:

  • self-contained (no system Lua dependency),
  • consistent across target environments,
  • simple to install and run.

Most LuaRocks C modules are built under the assumption that they will load into a process that links against a compatible, dynamically available Lua runtime, and that their compiled binary can resolve the expected Lua symbols and ABI at runtime. When the interpreter is embedded/self-contained, those assumptions may not hold consistently across platforms, toolchains, and build modes.

Roadmap stance

Supporting pure-Lua rocks provides most of the practical value for scripting and library reuse while preserving Ward’s simplicity. If and when Ward introduces an optional build mode that links against a system-provided shared Lua runtime, C-extension LuaRocks compatibility may become a supported configuration. Until then, Ward’s LuaRocks support should be considered pure-Lua only.