From ec35dc911b1aaa3ebb392841f07f074a40ae4992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sat, 27 Jun 2026 20:52:43 +0200 Subject: [PATCH] docs --- docs/README.md | 24 +++++ docs/log.md | 86 ++++++++++++++++++ docs/math.md | 106 ++++++++++++++++++++++ docs/os.md | 73 +++++++++++++++ docs/table.md | 239 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/utils.md | 179 ++++++++++++++++++++++++++++++++++++ 6 files changed, 707 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/log.md create mode 100644 docs/math.md create mode 100644 docs/os.md create mode 100644 docs/table.md create mode 100644 docs/utils.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..28665ef --- /dev/null +++ b/docs/README.md @@ -0,0 +1,24 @@ +# `a` standard library + +Reference docs for the library `a` adds on top of Lua 5.5. Every module below is +a **global** — there is no `require`; they are present in every script. + +## Modules + +| Module | What it covers | +|--------|----------------| +| [`utils`](utils.md) | JSON encode/decode, the `NULL` marker, error-trapping (`try`/`tryn`), value `dump`. | +| [`math`](math.md) | Additions to stock `math`: `round`, `clamp`, `isFinite`, `sign`, `scale`. | +| [`table`](table.md) | Additions to stock `table`: map/filter/reduce, merge, deep-copy, aggregates, lookups, read-only proxy. | +| [`os`](os.md) | Sandbox additions: `microtime` (sub-second clock) and async `sleep`. | +| [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. | +| [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. | +| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. | + +## Concepts + +| Page | What it covers | +|------|----------------| +| [Concurrency](concurrency.md) | How async calls suspend, and running work in parallel with `task.join`. | + +See the top-level [README](../README.md) for what `a` is and how to build it. diff --git a/docs/log.md b/docs/log.md new file mode 100644 index 0000000..f5d1d87 --- /dev/null +++ b/docs/log.md @@ -0,0 +1,86 @@ +# `log` + +The `log` global is for diagnostic output that is separate from a script's +results. Where `print` writes to standard output, `log` emits **level-tagged** +messages whose visibility is controlled at runtime by an environment variable — +so the same script can run quietly in production and verbosely while you debug, +with no code change. It is always available as the global `log`. + +```lua +log.info("starting run", os.time()) +log.warn("retrying after error") +log.error("giving up") +``` + +## Levels + +There is one function per severity level: + +| Function | Level | Use for | +|----------------|---------|--------------------------------------------------| +| `log.trace` | TRACE | Very fine-grained, step-by-step detail. | +| `log.debug` | DEBUG | Developer diagnostics. | +| `log.info` | INFO | Normal high-level progress. | +| `log.warn` | WARN | Something unexpected but recoverable. | +| `log.error` | ERROR | A failure worth reporting. | + +`log.warning` is an alias for `log.warn`. + +Each takes any number of arguments, converts each with `tostring`, and joins them +with a **tab**, just like `print`: + +```lua +log.info("user", userId, "logged in") --> user 42 logged in +``` + +To log a table's structure, render it first with +[`utils.dump`](utils.md#utilsdumpvalue): + +```lua +log.debug("request opts:", utils.dump(opts)) +``` + +## Controlling what is shown + +Log output goes to **standard error**, and which levels are shown is set by the +`RUST_LOG` environment variable. By default only `WARN` and `ERROR` appear: + +```sh +a script.lua # default: warnings and errors only +RUST_LOG=info a script.lua # info and above +RUST_LOG=debug a script.lua # debug and above +RUST_LOG=trace a script.lua # everything +``` + +A level shows itself and everything more severe — `RUST_LOG=info` includes +`info`, `warn`, and `error` but not `debug` or `trace`. Messages below the active +level are discarded cheaply, so leaving `log.debug` calls in place costs almost +nothing when they are not enabled. + +## `log` vs. `print` + +| | `print` | `log` | +|---|---|---| +| Stream | stdout | stderr | +| Always shown? | yes | only at/above the active level | +| Tagged with level? | no | yes | + +Use `print` for a script's actual output — the thing a caller pipes or captures. +Use `log` for the running commentary about *how* it is going, which you want to +turn up or down without editing the script. + +```lua +local rows = con:query("SELECT * FROM users") +log.info("fetched", #rows, "rows") -- diagnostic; hidden unless RUST_LOG=info +for _, r in ipairs(rows) do + print(r.name) -- the actual result +end +``` + +## Notes + +- **Always loaded.** No `require`; `log` is a global in every script. +- **Quiet by default.** With no `RUST_LOG` set, only `warn`/`error` are emitted, + so `info`/`debug`/`trace` are silent until you opt in. +- **Arguments are stringified like `print`.** Multiple arguments are joined with + tabs; tables print as their address, so use `utils.dump` for contents. diff --git a/docs/math.md b/docs/math.md new file mode 100644 index 0000000..3543ef1 --- /dev/null +++ b/docs/math.md @@ -0,0 +1,106 @@ +# `math` + +`a` keeps Lua's standard `math` library intact and adds a handful of functions +that come up constantly in everyday scripting: rounding, clamping, sign, finiteness, +and linear rescaling. The additions live on the same global `math` table, so +`math.floor` and `math.round` sit side by side. + +```lua +print(math.round(3.14159, 2)) --> 3.14 +print(math.clamp(120, 0, 100)) --> 100 +print(math.scale(0.5, 0, 1, 0, 255)) --> 127.5 +``` + +Everything here errors on a non-number argument rather than coercing it, so a +stray `nil` or string surfaces at the call site instead of producing a silent +`NaN`. + +## `math.round(value [, places])` + +Round to the nearest integer, or to `places` decimal places. Rounding is **half +away from zero** (so `0.5` → `1`, `-0.5` → `-1`), computed exactly — it does not +suffer the `floor(x + 0.5)` error that mis-rounds values just under `.5`. + +The return *type* depends on `places`: + +- `places` omitted or `0` → an **integer**, +- `places > 0` → a **float** rounded to that many decimals. + +```lua +math.round(2.5) --> 3 (integer) +math.round(-2.5) --> -3 +math.round(3.14159, 2) --> 3.14 (float) +math.round(7) --> 7 (integers pass through) +``` + +`places` must be a non-negative integer. A non-finite `value` (`NaN`/infinity) or +a result outside Lua's integer range is an error. + +## `math.clamp(value, min, max)` + +Constrain `value` to the interval `[min, max]`. Either bound may be `nil` to +leave that side open: + +```lua +math.clamp(15, 0, 10) --> 10 +math.clamp(-3, 0, 10) --> 0 +math.clamp(5, 0, 10) --> 5 +math.clamp(-3, 0, nil) --> 0 (no upper bound) +math.clamp(99, nil, 10) --> 10 (no lower bound) +``` + +It is an error for `min` to exceed `max`. + +## `math.isFinite(value)` + +Return `true` if `value` is a number that is neither `NaN` nor infinite. Anything +that is not a number returns `false` (it does not error), making it a safe guard +before arithmetic: + +```lua +math.isFinite(1.5) --> true +math.isFinite(1/0) --> false (infinity) +math.isFinite(0/0) --> false (NaN) +math.isFinite("x") --> false +``` + +## `math.sign(value)` + +Return the sign of a number as `-1`, `0`, or `1`. Zero (and `NaN`) yield `0`. + +```lua +math.sign(-42) --> -1 +math.sign(0) --> 0 +math.sign(3.5) --> 1 +``` + +## `math.scale(value, inMin, inMax, outMin, outMax [, clamp])` + +Linearly remap `value` from the input range `[inMin, inMax]` onto the output +range `[outMin, outMax]`. With `clamp = true`, the result is held within the +output range for inputs that fall outside the input range. + +```lua +-- map a 0–1023 ADC reading to a 0–100 percentage +math.scale(512, 0, 1023, 0, 100) --> ~50.05 + +-- ranges may descend, and you can invert +math.scale(0, 0, 100, 100, 0) --> 100 + +-- clamp keeps out-of-range inputs inside the output band +math.scale(2.0, 0, 1, 0, 255) --> 510 (unclamped) +math.scale(2.0, 0, 1, 0, 255, true) --> 255 (clamped) +``` + +The input range cannot be empty — `inMin == inMax` is an error (it would divide +by zero). + +## Notes + +- **Additions, not replacements.** All of stock Lua's `math` — `floor`, `ceil`, + `abs`, `min`, `max`, `sqrt`, `huge`, `pi`, `tointeger`, `type`, … — is still + there unchanged. +- **`round` is the only type-changing one.** It returns an integer for + `places == 0` and a float otherwise; the rest return whatever fits naturally. +- **Aggregates over tables live in [`table`](table.md).** For the min/max/mean/sum + of a collection, see `table.min`, `table.max`, `table.mean`, `table.sum`. diff --git a/docs/os.md b/docs/os.md new file mode 100644 index 0000000..0a7a38c --- /dev/null +++ b/docs/os.md @@ -0,0 +1,73 @@ +# `os` + +`a` runs scripts in a sandbox, so the standard `os` table is trimmed to its +safe, time-related functions — `os.time`, `os.clock`, `os.date`, +`os.difftime`, `os.getenv` — while the parts that touch the system +(`os.execute`, `os.remove`, `os.exit`, …) are removed. To that trimmed table `a` +adds two functions: a high-resolution clock and an async sleep. + +```lua +local t0 = os.microtime() +os.sleep(0.25) +print(string.format("waited %.3fs", os.microtime() - t0)) --> waited ~0.250s +``` + +## `os.microtime()` + +Return the current time as a Unix timestamp in **seconds, as a float** with +sub-second precision — unlike `os.time`, which is whole seconds only. It is the +right tool for measuring elapsed time: + +```lua +local start = os.microtime() +doSomeWork() +local elapsed = os.microtime() - start +log.info(string.format("took %.1f ms", elapsed * 1000)) +``` + +It reads the system wall clock, so it tracks real time (and can jump if the clock +is adjusted); for interval timing the difference of two readings is what you +want. + +## `os.sleep(seconds)` + +Pause for `seconds` (a float, so `os.sleep(0.1)` is 100 ms). This is an **async** +sleep: it suspends the script on the runtime instead of blocking the OS thread, +so concurrent work keeps running while it waits. + +```lua +os.sleep(1) -- one second +os.sleep(0.05) -- 50 milliseconds +``` + +`seconds` must be a non-negative finite number. + +Because the sleep suspends rather than blocks, sibling tasks started with +[`task.join`](concurrency.md) make progress during the wait — that is what lets +several sleeps (or requests, or queries) overlap: + +```lua +-- finishes in ~0.3s, not 0.6s — the sleeps overlap +task.join( + function() os.sleep(0.3) end, + function() os.sleep(0.2) end, + function() os.sleep(0.1) end +) +``` + +One caveat applies to coroutines you drive yourself with `coroutine.wrap` / +`coroutine.resume`: there, `os.sleep` does not actually wait. See +[Self-driven coroutines](concurrency.md#self-driven-coroutines) for the full +explanation — the rule is to run anything that needs to sleep through +`task.join`. + +## Notes + +- **The sandbox keeps the time functions.** `os.time`, `os.clock`, `os.date`, + `os.difftime`, and `os.getenv` work as in stock Lua; system-mutating functions + are not present. +- **`microtime` for durations, `time` for timestamps.** Use `os.microtime` when + you need sub-second precision or are timing an interval; `os.time` when whole + seconds suffice. +- **`sleep` is cooperative.** It yields to the runtime, so it is cheap to sleep + inside concurrent tasks — see [Concurrency](concurrency.md). diff --git a/docs/table.md b/docs/table.md new file mode 100644 index 0000000..cad19a6 --- /dev/null +++ b/docs/table.md @@ -0,0 +1,239 @@ +# `table` + +`a` keeps Lua's standard `table` library (`insert`, `remove`, `concat`, `sort`, +`pack`, `unpack`) and adds the higher-order and collection helpers stock Lua +leaves out: map/filter/reduce, merge and deep-copy, aggregates, lookups, and a +read-only proxy. The additions live on the same global `table`. + +```lua +local nums = {1, 2, 3, 4, 5} +local evens = table.ifilter(nums, function(n) return n % 2 == 0 end) +local doubled = table.map(nums, function(n) return n * 2 end) +print(table.sum(nums), table.max(nums)) --> 15 5 +``` + +## Sequences vs. general tables + +Lua tables are both arrays and maps, so most helpers come in two flavours. The +distinction runs through the whole module: + +- **Plain name** (`map`, `filter`, `reduce`) iterates with `pairs` — it visits + **every** key, preserves keys in the result, and the order is unspecified. +- **`i`-prefixed name** (`imap`-style: `ifilter`, `ireduce`, …) iterates with + `ipairs` over the sequence part `1..#t` **in order**, and produces a fresh + gapless sequence. + +Reach for the `i` variants when you have an array and want array semantics +(order preserved, no gaps); use the plain ones for maps or when keys matter. + +## Transforming + +### `table.map(tbl, fn)` + +Apply `fn(value)` to every value, **preserving keys**. Order unspecified. + +```lua +table.map({a = 1, b = 2}, function(v) return v * 10 end) --> {a = 10, b = 20} +``` + +### `table.filter(tbl, predicate)` + +Keep the entries where `predicate(value)` is truthy, **preserving keys**. On a +sequence this may leave gaps — use `ifilter` to avoid them. + +```lua +table.filter({a = 1, b = 2, c = 3}, function(v) return v > 1 end) --> {b = 2, c = 3} +``` + +### `table.ifilter(tbl, predicate)` + +Keep the sequence elements matching `predicate`, producing a new **gapless** +sequence in order. + +```lua +table.ifilter({1, 2, 3, 4}, function(v) return v % 2 == 0 end) --> {2, 4} +``` + +### `table.filterMap(tbl, fn)` + +Map and filter in one pass: apply `fn(value)`, and **drop entries where `fn` +returns `nil`**. Keys are preserved (may leave gaps on a sequence). + +```lua +-- keep and square only the even numbers +table.filterMap({a = 1, b = 2, c = 4}, function(v) + if v % 2 == 0 then return v * v end +end) --> {b = 4, c = 16} +``` + +### `table.ifilterMap(tbl, fn)` + +The sequence version of `filterMap`: map over `1..#t` in order, drop `nil` +results, and return a **gapless** sequence. + +```lua +table.ifilterMap({1, 2, 3, 4}, function(v) + if v % 2 == 0 then return v * 10 end +end) --> {20, 40} +``` + +## Reducing + +### `table.reduce(tbl, fn, init)` + +Fold every value into a single accumulator: `acc = fn(acc, value)`, starting from +`init`. Iterates with `pairs`, so order is unspecified — use it for +order-independent folds (sum, product, building a set). + +```lua +table.reduce({1, 2, 3}, function(acc, v) return acc + v end, 0) --> 6 +``` + +### `table.ireduce(tbl, fn, init)` + +Like `reduce` but folds the sequence part **in order**, for when order matters +(e.g. concatenation, left-to-right composition). + +```lua +table.ireduce({"a", "b", "c"}, function(acc, v) return acc .. v end, "") --> "abc" +``` + +## Aggregates + +These walk all values with `pairs`, so they work on sequences and maps alike. +Each returns `nil` for an empty table (except `sum`, which returns `0`), and a +`NaN` value anywhere propagates to the result. + +| Function | Result | +|--------------------|-----------------------------------------| +| `table.min(tbl)` | smallest value, or `nil` if empty | +| `table.max(tbl)` | largest value, or `nil` if empty | +| `table.mean(tbl)` | arithmetic mean, or `nil` if empty | +| `table.sum(tbl)` | sum of values, `0` if empty | + +```lua +local t = {4, 8, 15, 16, 23, 42} +print(table.min(t), table.max(t)) --> 4 42 +print(table.sum(t), table.mean(t)) --> 108 18.0 +``` + +## Querying + +### `table.keys(tbl)` / `table.values(tbl)` + +Return an array of all keys, or all values. Order is unspecified. + +```lua +table.keys({a = 1, b = 2}) --> {"a", "b"} (some order) +table.values({a = 1, b = 2}) --> {1, 2} (matching order) +``` + +### `table.contains(tbl, value)` + +Return `true` if any value equals `value` (compared with `==`). + +```lua +table.contains({"red", "green"}, "green") --> true +``` + +### `table.find(tbl, predicate)` + +Return the **first** value matching `predicate`, together with its key: +`(value, key)`, or `(nil, nil)` if none match. Because a matching value could +itself be `false`, test the returned **key** against `nil` to distinguish "found +`false`" from "not found". + +```lua +local v, k = table.find({10, 20, 30}, function(x) return x > 15 end) +print(v, k) --> 20 2 +``` + +### `table.isEmpty(tbl)` + +Return `true` if the table has no keys at all (faster and more correct than +`#tbl == 0`, which only inspects the sequence part). + +```lua +table.isEmpty({}) --> true +table.isEmpty({x = 1}) --> false +``` + +## Combining and copying + +### `table.merge(...)` + +Merge any number of tables into a **new** table (a shallow merge). Sequence parts +are **appended** in argument order; other keys are copied, with later arguments +overwriting earlier ones on a collision. Not recursive — for nested merges use +`deepMerge`. + +```lua +table.merge({1, 2}, {3, 4}) --> {1, 2, 3, 4} (sequences concatenate) +table.merge({a = 1, b = 2}, {b = 9}) --> {a = 1, b = 9} (later wins) +``` + +### `table.deepMerge(...)` + +Like `merge`, but where the same key holds a table on both sides, those tables +are merged **recursively**. The result shares no tables with the inputs — +everything is deep-copied — so mutating the result never touches the originals. + +```lua +table.deepMerge( + { db = { host = "localhost", port = 5432 } }, + { db = { port = 5433 } } +) --> { db = { host = "localhost", port = 5433 } } +``` + +### `table.deepCopy(value)` + +Return a deep copy of a table, recursively copying nested tables. Metatables are +**not** copied. Non-table values pass through unchanged. + +```lua +local original = { list = {1, 2, 3} } +local copy = table.deepCopy(original) +copy.list[1] = 99 +print(original.list[1]) --> 1 (unaffected) +``` + +`deepMerge` and `deepCopy` cap recursion at 100 levels and error beyond that, so +a cyclic table raises rather than looping forever. + +### `table.reverse(tbl)` + +Return a new array with the sequence elements in reverse order. + +```lua +table.reverse({1, 2, 3}) --> {3, 2, 1} +``` + +## Protecting + +### `table.readonly(tbl [, name])` + +Return a read-only proxy of `tbl`: reads pass through to the underlying table, +writes raise an error. The optional `name` is included in that error message. +Useful for exposing a constant namespace that callers must not mutate. + +```lua +local config = table.readonly({ retries = 3 }, "config") +print(config.retries) --> 3 +config.retries = 5 --> error: Attempt to update a read-only table config +``` + +**Caveat:** the proxy is an empty shell that forwards reads through a metatable, +so `pairs`, `next`, and `#` see **no** contents — it is meant for guarding API +tables you read by known key, not for iterable data. + +## Notes + +- **Additions, not replacements.** Stock `table.insert`, `remove`, `concat`, + `sort`, `pack`, `unpack` are all still present. +- **Map vs. sequence is the recurring choice.** Plain functions preserve keys via + `pairs` (unspecified order); `i`-prefixed ones produce gapless sequences via + `ipairs` (in order). Pick by whether you have an array or a map. +- **Most helpers return new tables.** `map`, `filter*`, `merge*`, `deepCopy`, + `reverse`, `keys`, `values` never mutate their input. +- **Type-checked.** Passing a non-table where a table is expected raises a clear + error naming the offending function. diff --git a/docs/utils.md b/docs/utils.md new file mode 100644 index 0000000..3179f1c --- /dev/null +++ b/docs/utils.md @@ -0,0 +1,179 @@ +# `utils` + +The `utils` module is a grab-bag of helpers that the rest of the standard +library leans on: JSON encoding and decoding, a `null` marker for round-tripping +JSON and SQL, error-trapping wrappers, and a value pretty-printer. It is always +available as the global `utils`. + +```lua +local data = utils.fromJSON('{"name":"Alice","age":30}') +print(data.name, data.age) --> Alice 30 + +print(utils.toJSON({1, 2, 3})) --> [1,2,3] +print(utils.dump({a = 1, b = {2, 3}})) --> { a = 1, b = { 2, 3 } } +``` + +## JSON + +### `utils.toJSON(value [, pretty])` + +Serialize a Lua value to a JSON string. Pass `pretty = true` for indented, +multi-line output; the default is compact. + +```lua +utils.toJSON({ name = "test", count = 5 }) --> {"count":5,"name":"test"} +utils.toJSON({ name = "test" }, true) --> pretty-printed over several lines +``` + +How values are converted: + +| Lua | JSON | +|-----------------------------|-------------------| +| `nil` | `null` | +| `utils.NULL` | `null` | +| `boolean` | `true` / `false` | +| integer | number | +| float | number | +| `string` | string | +| table (sequence) | array | +| table (other) | object | + +A table is encoded as a **JSON array** when its keys are exactly the integers +`1..#t` with no gaps, and as a **JSON object** otherwise. Object keys must be +strings or integers (an integer key becomes its decimal string); any other key +type is an error. + +These cases raise an error rather than producing invalid JSON: + +- `NaN` or infinity (JSON has no representation for them), +- a value that has no JSON form (function, coroutine, userdata other than + `utils.NULL`), +- nesting deeper than 64 levels. + +### `utils.fromJSON(string)` + +Parse a JSON string into a Lua value. The inverse of `toJSON`: + +| JSON | Lua | +|-------------------|----------------| +| `null` | `utils.NULL` | +| `true` / `false` | `boolean` | +| integer number | integer | +| fractional number | float | +| string | `string` | +| array | sequence table | +| object | table | + +Invalid JSON raises an error. Note that JSON `null` decodes to +[`utils.NULL`](#utilsnull), **not** Lua `nil` — so a `null` inside an array does +not create a gap in the sequence. + +```lua +local arr = utils.fromJSON('[1, null, 3]') +print(#arr) --> 3 +print(utils.isNull(arr[2])) --> true +``` + +## `null` + +JSON and SQL both have a `null`/`NULL` value distinct from "absent". Lua's `nil` +cannot fill that role: a table cannot *store* `nil` (assigning `nil` deletes the +key), so a literal `nil` can never survive inside a table to reach a JSON array +slot or a SQL parameter. `utils.NULL` is a sentinel that can. + +### `utils.NULL` + +A unique marker standing for an explicit null. Store it in a table where you mean +"null, not missing": + +```lua +http.postJSON(url, { nickname = utils.NULL }) -- sends {"nickname":null} +con:execute("UPDATE u SET nickname = ?", { utils.NULL }) -- binds SQL NULL +``` + +It is the value `fromJSON` produces for JSON `null`, the value `toJSON` and the +`http`/`sqlite` modules turn back into `null`/`NULL`. Comparable by identity, so +`value == utils.NULL` works, but prefer `utils.isNull` for clarity. + +### `utils.isNull(value)` + +Return `true` if `value` is `utils.NULL`. Everything else, including Lua `nil`, +returns `false`. + +```lua +utils.isNull(utils.NULL) --> true +utils.isNull(nil) --> false +utils.isNull(0) --> false +``` + +## Error handling + +### `utils.try(fn [, ...])` + +Call `fn` and trap any error, returning `(result, nil)` on success or +`(nil, err)` on failure — a more ergonomic `pcall` for the common +single-return-value case. Extra arguments are forwarded to `fn`. + +```lua +local data, err = utils.try(function() + return utils.fromJSON(input) +end) +if not data then + log.error("bad JSON: " .. tostring(err)) + return +end +-- use data +``` + +If `fn` errors with a `nil` value, `err` is the string `"unspecified error"`, so +a falsy first return reliably means failure. + +### `utils.tryn(n, fn [, ...])` + +Like `try`, but for a function that returns several values. `n` is how many +values `fn` returns on success. On success it returns those `n` values followed +by a trailing `nil` (the error slot); on failure it returns `n` `nil`s followed +by the error. + +```lua +-- fn returns two values +local x, y, err = utils.tryn(2, function() + return parsePoint(s) -- returns x, y +end) +if err then + log.error("parse failed: " .. tostring(err)) +else + print(x, y) +end +``` + +`n` must be an integer between 0 and 64. Use `try` when `fn` returns at most one +value; reach for `tryn` only when you genuinely need to capture several. + +## Debugging + +### `utils.dump(value)` + +Render any Lua value as a readable string — handy for logging and quick +inspection. Strings are quoted, sequences print as `{ a, b, c }`, other tables as +`{ key = value, ... }`, and `utils.NULL` prints as `null`. Cycles are shown as +`` rather than looping forever, and nesting is capped at 20 levels. + +```lua +print(utils.dump({ id = 1, tags = {"a", "b"}, parent = utils.NULL })) +--> { id = 1, parent = null, tags = { "a", "b" } } +``` + +This is for human eyes, not machine parsing — use `toJSON` when you need output +you can read back. + +## Notes + +- **`utils` is always loaded.** No `require`; the global is present in every + script. +- **`NULL` bridges three worlds.** The same marker represents null for `toJSON`, + `fromJSON`, the `http` JSON helpers, and SQL `NULL` binding in `sqlite`, so a + null value can round-trip through any of them. +- **`toJSON`/`fromJSON` are not symmetric for `nil`.** `nil` *encodes* to `null`, + but `null` *decodes* to `utils.NULL`. This is deliberate, so decoding never + silently drops array elements.