diff --git a/.luarc.json b/.luarc.json
new file mode 100644
index 0000000..fc3a6f8
--- /dev/null
+++ b/.luarc.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
+ "runtime.version": "Lua 5.5",
+ "runtime.builtin": {
+ "io": "disable",
+ "debug": "disable",
+ "package": "disable"
+ },
+ "runtime.path": ["lua/?.lua", "lua/?/init.lua"],
+ "workspace.checkThirdParty": false,
+ "workspace.ignoreDir": ["target", "tmp"]
+}
diff --git a/docs/tui.md b/docs/tui.md
index 05fb8b1..7659e56 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -11,7 +11,7 @@ answer as a plain Lua value. It is always available as the global `tui`.
local name = tui.promptText("Your name?")
local lang = tui.promptSelect("Favorite language?", "lua", { options = { "lua", "rust", "c" } })
if tui.confirm("Save results?", true) then
- print(tui.styled("saved for " .. name .. ">"))
+ print(tui.styled("saved for " .. tui.escape(name) .. ">"))
end
```
@@ -315,10 +315,11 @@ are deliberately **not** tags; they need cleanup tracking and live in the
### Escaping and invalid tags
-A backslash escapes a literal bracket: `"\\"` prints ``. Anything
-that does not parse as a tag — ``, `` (tags are
-case-sensitive), a lone `<`, a bad color — is passed through as literal text,
-never an error.
+A backslash escapes a literal bracket: `"\\"` prints ``; `"\\\\"`
+prints a single backslash. Anything that does not parse as a tag —
+``, `` (tags are case-sensitive), a lone `<`, a bad color — is
+passed through as literal text, never an error. For dynamic text, don't
+escape by hand — use [`tui.escape`](#tuiescapetext--string).
### Color detection
@@ -327,6 +328,23 @@ text: tags are still consumed, but no escape codes (colors or hyperlinks) are
emitted. Detection follows the usual conventions (`NO_COLOR`, `CLICOLOR`,
`CLICOLOR_FORCE`, tty check on stdout).
+## `tui.escape(text)` → `string`
+
+Backslash-escapes the markup metacharacters (`<`, `>`, `\`) so text from
+outside the program — user input, file contents, API responses — can be
+embedded in a `tui.styled` format string and come out exactly as it went in,
+styled only by the enclosing tags:
+
+```lua
+local city = tui.promptText("City?")
+print(tui.styled(("looking up %s…"):format(tui.escape(city))))
+```
+
+Without escaping, a user who types `` gets it rendered as a red block,
+and stray brackets can eat the format string's own tags. Only `tui.styled`
+markup needs this; block messages (`tui.warning` & co.) are plain text and
+never interpret tags.
+
## Message blocks
Ready-made status messages in the style of Symfony's console: a labeled,
@@ -349,7 +367,7 @@ tui.error("Build failed")
| `tui.caution(message, opts)` | `[CAUTION]` | white on red, `!` prefix |
| `tui.info(message, opts)` | `[INFO]` | green text |
| `tui.note(message, opts)` | `[NOTE]` | yellow text, `!` prefix |
-| `tui.comment(message, opts)` | — | plain, `//` prefix |
+| `tui.comment(message, opts)` | — | gray text |
| `tui.block(message, opts)` | — | generic: everything from opts |
- `message` — a string or an array of strings (rendered into one block,
diff --git a/lua/coroutines-demo.lua b/lua/demo/coroutines-demo.lua
similarity index 100%
rename from lua/coroutines-demo.lua
rename to lua/demo/coroutines-demo.lua
diff --git a/lua/test.lua b/lua/demo/test.lua
similarity index 100%
rename from lua/test.lua
rename to lua/demo/test.lua
diff --git a/lua/test_sqlite.lua b/lua/demo/test_sqlite.lua
similarity index 100%
rename from lua/test_sqlite.lua
rename to lua/demo/test_sqlite.lua
diff --git a/lua/tui_prompts.lua b/lua/demo/tui_prompts.lua
similarity index 96%
rename from lua/tui_prompts.lua
rename to lua/demo/tui_prompts.lua
index 49836f1..cd22495 100644
--- a/lua/tui_prompts.lua
+++ b/lua/demo/tui_prompts.lua
@@ -60,9 +60,9 @@ local ratio = tui.promptNumber("Quality (0-1)?", 0.8, { min = 0, max = 1 })
local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 })
-- promptDate opens a calendar; dates are YYYY-MM-DD strings both ways.
-local day = tui.promptDate("Release day?", os.date("%Y-%m-%d"), {
- min = os.date("%Y-01-01"),
- max = os.date("%Y-12-31"),
+local day = tui.promptDate("Release day?", os.date("%Y-%m-%d") --[[@as string]], {
+ min = os.date("%Y-01-01") --[[@as string]],
+ max = os.date("%Y-12-31") --[[@as string]],
weekStart = "monday",
})
diff --git a/lua/tui_styles.lua b/lua/demo/tui_styles.lua
similarity index 100%
rename from lua/tui_styles.lua
rename to lua/demo/tui_styles.lua
diff --git a/lua/tui_term.lua b/lua/demo/tui_term.lua
similarity index 100%
rename from lua/tui_term.lua
rename to lua/demo/tui_term.lua
diff --git a/lua/demo/weather.lua b/lua/demo/weather.lua
new file mode 100644
index 0000000..97631f7
--- /dev/null
+++ b/lua/demo/weather.lua
@@ -0,0 +1,256 @@
+-- Weather forecast demo — a small app that exercises most of the `a` stdlib:
+--
+-- tui prompts (text w/ suggestions, select, confirm), styled markup,
+-- custom presets, escape for untrusted text in markup, message &
+-- outline blocks, raw output with redraws,
+-- setTitle, setClipboard
+-- http getJSON with a timeout, non-2xx handling
+-- task.join a spinner animating *while* the request is in flight
+-- table map / ifilter / ifilterMap / min / max / mean / concat
+-- math round / clamp / scale
+-- utils try, dump
+-- log diagnostics on stderr (run with RUST_LOG=info to see them)
+--
+-- Data comes from https://wttr.in (no API key). The app prompts for a city,
+-- shows current conditions and a 3-day forecast, then lets you drill into a
+-- day's hourly detail. Esc backs out of any prompt.
+--
+-- Run with: cargo run -- lua/weather.lua
+
+----------------------------------------------------------------------
+-- Styling: custom presets keep the markup below readable
+----------------------------------------------------------------------
+tui.addPreset("accent", "cyan;b")
+tui.addPreset("dim", "gray")
+tui.addPreset("freezing", "#7ecbff")
+tui.addPreset("chilly", "#b0e0ff")
+tui.addPreset("mild", "green")
+tui.addPreset("warm", "yellow")
+tui.addPreset("hot", "#ff5f2e;b")
+
+-- Pick a preset name for a temperature (thresholds are °C).
+local function tempPreset(celsius)
+ if celsius <= 0 then return "freezing"
+ elseif celsius <= 10 then return "chilly"
+ elseif celsius <= 22 then return "mild"
+ elseif celsius <= 30 then return "warm"
+ else return "hot" end
+end
+
+-- "17°C>" — a temperature with its color; `shown` is the display
+-- value (may be °F), `celsius` drives the color choice.
+local function fmtTemp(shown, celsius, suffix)
+ local p = tempPreset(celsius)
+ return ("<%s>%d%s%s>"):format(p, math.round(shown), suffix, p)
+end
+
+-- A fixed-width bar: `lo..hi` is the full scale, `a..b` the filled span.
+-- math.scale maps temperatures onto columns; clamp=true guards outliers.
+local function spanBar(lo, hi, a, b, width)
+ local from = math.round(math.scale(a, lo, hi, 1, width, true))
+ local to = math.round(math.scale(b, lo, hi, 1, width, true))
+ from, to = math.clamp(from, 1, width), math.clamp(to, 1, width)
+ return ("·"):rep(from - 1) .. ("█"):rep(to - from + 1) .. ("·"):rep(width - to)
+end
+
+-- A 0-100% meter, colored by how full it is.
+local function pctBar(pct, width)
+ local filled = math.round(math.scale(pct, 0, 100, 0, width, true))
+ local style = pct >= 60 and "accent" or "dim"
+ return ("<%s>%s%s>%s %3d%%"):format(
+ style, ("▪"):rep(filled), style, ("·"):rep(width - filled), pct)
+end
+
+----------------------------------------------------------------------
+-- Fetching: task.join runs the HTTP request and a spinner concurrently
+----------------------------------------------------------------------
+local FRAMES = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }
+
+local function fetchWeather(city)
+ local url = "https://wttr.in/" .. city:gsub(" ", "+") .. "?format=j1"
+ log.info("GET", url)
+
+ -- The city is user input headed for styled markup: escape it so a name
+ -- like "" (or a stray backslash) can't derail the tags around it.
+ local cityLabel = tui.escape(city)
+
+ local data, err
+ local done = false
+ task.join(
+ function()
+ -- utils.try traps transport errors (DNS, TLS, timeout) instead
+ -- of aborting; an HTTP error status is not an error and is
+ -- reported through resp.status / resp.ok.
+ local resp
+ resp, err = utils.try(function()
+ return http.get(url, { timeout = 20 })
+ end)
+ if resp and not resp.ok then
+ err = ("wttr.in answered HTTP %d — probably not a place it knows"):format(resp.status)
+ elseif resp then
+ data, err = utils.try(resp.json)
+ end
+ done = true
+ end,
+ function()
+ -- The spinner keeps animating because http.getJSON suspends
+ -- instead of blocking. redraws in place.
+ local i = 1
+ while not done do
+ tui.raw(tui.styled(("%s asking wttr.in about %s…")
+ :format(FRAMES[i], cityLabel)))
+ i = i % #FRAMES + 1
+ os.sleep(0.08)
+ end
+ tui.raw(tui.styled(""))
+ end
+ )
+ return data, err
+end
+
+----------------------------------------------------------------------
+-- Rendering
+----------------------------------------------------------------------
+local BAR_W = 24
+
+local function dayLabel(isoDate)
+ local y, m, d = isoDate:match("(%d+)-(%d+)-(%d+)")
+ return os.date("%a %b %e", os.time({ year = y, month = m, day = d, hour = 12 }))
+end
+
+local function showCurrent(data, T, W)
+ local cur = data.current_condition[1]
+ local area = data.nearest_area[1]
+ local place = ("%s, %s"):format(area.areaName[1].value, area.country[1].value)
+ log.debug("nearest_area:", utils.dump(area))
+
+ tui.block((" %s (%s, %s)"):format(place, area.latitude, area.longitude),
+ { style = "black;on-cyan", padding = true })
+
+ local t, feels = tonumber(cur["temp_" .. T]), tonumber(cur["FeelsLike" .. T])
+ local tC = tonumber(cur.temp_C)
+ -- weatherDesc is free text from the API — escape it like any input.
+ print(tui.styled((" %s %s (feels like %s)")
+ :format(fmtTemp(t, tC, "°" .. T), tui.escape(cur.weatherDesc[1].value),
+ fmtTemp(feels, tonumber(cur.FeelsLikeC), "°" .. T))))
+ print(tui.styled((" wind %s %s %s · pressure %s hPa · UV %s")
+ :format(cur["windspeed" .. W], W == "Kmph" and "km/h" or "mph",
+ cur.winddir16Point, cur.pressure, cur.uvIndex)))
+ print(tui.styled(" humidity " .. pctBar(tonumber(cur.humidity), BAR_W)))
+ print()
+end
+
+local function showForecast(data, T)
+ -- Every hourly temperature across all days — table.map preserves the
+ -- sequence, table.min/max aggregate it — fixes one scale for the bars.
+ local allTemps = {}
+ for _, day in ipairs(data.weather) do
+ for _, h in ipairs(day.hourly) do
+ table.insert(allTemps, tonumber(h["temp" .. T]))
+ end
+ end
+ local lo, hi = table.min(allTemps), table.max(allTemps)
+
+ print(tui.styled((" 3-day forecast (scale %d°%s … %d°%s)"):format(lo, T, hi, T)))
+ for _, day in ipairs(data.weather) do
+ local temps = table.map(day.hourly, function(h) return tonumber(h["temp" .. T]) end)
+ local rain = math.round(table.mean(
+ table.map(day.hourly, function(h) return tonumber(h.chanceofrain) end)) or 0)
+ local dMin, dMax = table.min(temps), table.max(temps)
+ print(tui.styled((" %-10s %s … %s <%s>%s> rain %2d%% ☀ %s → %s")
+ :format(dayLabel(day.date),
+ fmtTemp(dMin, tonumber(day.mintempC), "°"),
+ fmtTemp(dMax, tonumber(day.maxtempC), "°"),
+ tempPreset(tonumber(day.maxtempC)), spanBar(lo, hi, dMin, dMax, BAR_W),
+ rain, day.astronomy[1].sunrise, day.astronomy[1].sunset)))
+ end
+ print()
+end
+
+local function showHourly(day, T, W)
+ print(tui.styled(("\n %s, hour by hour"):format(dayLabel(day.date))))
+ for _, h in ipairs(day.hourly) do
+ local time = ("%04d"):format(tonumber(h.time)):sub(1, 2) .. ":00"
+ print(tui.styled((" %s %s %s wind %3s %-24s")
+ :format(time,
+ fmtTemp(tonumber(h["temp" .. T]), tonumber(h.tempC), "°"),
+ pctBar(tonumber(h.chanceofrain), 10),
+ h["windspeed" .. W], tui.escape(h.weatherDesc[1].value))))
+ end
+
+ -- table.ifilterMap: keep only the rainy hours, mapped to "HH:00".
+ local rainy = table.ifilterMap(day.hourly, function(h)
+ if tonumber(h.chanceofrain) >= 60 then
+ return ("%04d"):format(tonumber(h.time)):sub(1, 2) .. ":00"
+ end
+ end)
+ if #rainy > 0 then
+ tui.warning("Rain is likely around " .. table.concat(rainy, ", ") .. " — pack an umbrella.")
+ else
+ tui.success("No serious rain expected that day.")
+ end
+end
+
+----------------------------------------------------------------------
+-- Main loop
+----------------------------------------------------------------------
+tui.outlineInfo("Weather forecast, courtesy of wttr.in.\nEsc backs out of any prompt; Ctrl-C quits.",
+ { title = "a-weather" })
+
+local units = tui.promptSelect("Units?", "metric", {
+ options = { "metric", "imperial" },
+ help = "°C & km/h, or °F & mph",
+})
+if units == nil then return end
+local T = units == "metric" and "C" or "F" -- temp field suffix in wttr JSON
+local W = units == "metric" and "Kmph" or "Miles" -- wind field suffix
+
+while true do
+ local city = tui.promptText("City?", "Prague", {
+ placeholder = "any city name wttr.in knows",
+ suggestions = { "Prague", "Brno", "Berlin", "London", "Tokyo", "Reykjavik", "Dubai" },
+ })
+ if city == nil or city == "" then
+ tui.comment("Nothing to look up — bye.")
+ return
+ end
+
+ tui.setTitle("weather: " .. city) -- restored automatically on exit
+
+ local data, err = fetchWeather(city)
+ if not data then
+ tui.error({ "Weather lookup failed.", tostring(err) })
+ elseif not data.current_condition then
+ -- wttr answers 200 with an error payload for unknown places
+ tui.warning(("wttr.in has no forecast for %q — try another spelling."):format(city))
+ else
+ print()
+ showCurrent(data, T, W)
+ showForecast(data, T)
+
+ -- Drill into one day; options are built from the payload itself.
+ local labels = table.map(data.weather, function(d) return dayLabel(d.date) end)
+ local pick = tui.promptSelect("Hourly detail for which day?", labels[1], {
+ options = labels,
+ help = "Esc to skip",
+ })
+ if pick ~= nil then
+ local _, idx = table.find(labels, function(l) return l == pick end)
+ showHourly(data.weather[idx], T, W)
+ end
+
+ -- One-line summary to the clipboard (OSC 52), if the user wants it.
+ local cur = data.current_condition[1]
+ local summary = ("%s: %s°%s, %s"):format(city, cur["temp_" .. T], T, cur.weatherDesc[1].value)
+ if tui.confirm("Copy summary to clipboard?", false, { help = summary }) then
+ tui.setClipboard(summary)
+ tui.info("Copied: " .. summary)
+ end
+ end
+
+ print()
+ if not tui.confirm("Look up another city?", true) then
+ tui.comment("Done. (Terminal title resets on exit.)")
+ return
+ end
+end
diff --git a/lua/stdlib/http.lua b/lua/stdlib/http.lua
index e131744..3c0f791 100644
--- a/lua/stdlib/http.lua
+++ b/lua/stdlib/http.lua
@@ -13,6 +13,12 @@ end
-- Wrap the stateless http.request so responses carry resp.json().
local _request = http.request
+
+--- Send a request with an explicit method (any verb, e.g. "GET", "OPTIONS").
+--- @param method string
+--- @param url string
+--- @param opts http.RequestOpts|nil
+--- @return http.Response
http.request = function(method, url, opts)
return wrap_resp(_request(method, url, opts))
end
@@ -24,11 +30,21 @@ for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do
end
end
+--- GET and parse the body as JSON in one step.
+--- @param url string
+--- @param opts http.RequestOpts|nil
+--- @return any body: The parsed JSON body
+--- @return http.Response resp: The full response
function http.getJSON(url, opts)
local resp = http.get(url, opts)
return resp.json(), resp
end
+--- POST with a JSON body — equivalent to setting opts.json = body.
+--- @param url string
+--- @param body any
+--- @param opts http.RequestOpts|nil
+--- @return http.Response
function http.postJSON(url, body, opts)
opts = opts or {}
opts.json = body
@@ -77,6 +93,11 @@ end
-- Wrap the Rust session constructor to install the metatable.
local _session = http.session
+
+--- Create a session with its own cookie jar. With a path, the jar is
+--- preloaded from that JSONL file.
+--- @param path string|nil
+--- @return http.Session
http.session = function(path)
return setmetatable(_session(path), session_mt)
end
diff --git a/lua/stdlib/math.lua b/lua/stdlib/math.lua
index 4e96241..8edfa0c 100644
--- a/lua/stdlib/math.lua
+++ b/lua/stdlib/math.lua
@@ -137,7 +137,7 @@ end
--- @param inMax number: The maximum of the input range
--- @param outMin number: The minimum of the output range
--- @param outMax number: The maximum of the output range
---- @param clamp boolean: If true, clamp the result to [outMin, outMax]. Default is false.
+--- @param clamp boolean|nil: If true, clamp the result to [outMin, outMax]. Default is false.
--- @return number: The scaled value
function math.scale(value, inMin, inMax, outMin, outMax, clamp)
if type(value) ~= "number" then
diff --git a/lua/stubs/README.md b/lua/stubs/README.md
new file mode 100644
index 0000000..873aa56
--- /dev/null
+++ b/lua/stubs/README.md
@@ -0,0 +1,29 @@
+# Lua stubs for the `a` runtime environment
+
+`---@meta` type definitions of the **Rust-native** parts of the `a` stdlib, for
+[lua-language-server](https://github.com/LuaLS/lua-language-server) (completion,
+hover docs, static checking). Definitions only — these files are never executed
+and are not part of the runtime.
+
+The **Lua-implemented** parts of the stdlib need no stubs: `lua/stdlib/*.lua`
+are the actual sources and the language server reads them directly. The split:
+
+| Stub here | Covers |
+|----------------|---------------------------------------------------------------|
+| `tui.lua` | the whole `tui` module (fully native) |
+| `http.lua` | `http` requests, response shape, sessions |
+| `sqlite.lua` | `sqlite.connect` and the connection methods |
+| `log.lua` | `log.trace` … `log.error` |
+| `task.lua` | `task.join` |
+| `utils.lua` | native half: `NULL`, `isNull`, `dump`, `toJSON`, `fromJSON` (`try`/`tryn` live in `lua/stdlib/utils.lua`) |
+| `os.lua` | sandbox additions `os.microtime`, `os.sleep` |
+| `require.lua` | the sandboxed `require` (the stock `package` library is disabled in `.luarc.json`) |
+
+The sandbox itself is mirrored in the repo-root `.luarc.json`: `runtime.builtin`
+disables `io`, `debug` and `package`, which do not exist in scripts.
+lua-language-server cannot hide *individual* fields, so removed functions that
+live inside kept libraries (`os.execute`, `os.remove`, `os.exit`, `load`,
+`loadfile`, `dofile`, `string.dump`) still autocomplete — they raise at runtime,
+do not use them.
+
+Keep these files in sync with the real API; the prose reference is `docs/*.md`.
diff --git a/lua/stubs/http.lua b/lua/stubs/http.lua
new file mode 100644
index 0000000..c442ce1
--- /dev/null
+++ b/lua/stubs/http.lua
@@ -0,0 +1,133 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stubs for the `http` module: the global table, the response/opts/session
+-- classes, and the get/post/… shorthands (lua/stdlib/http.lua assigns those
+-- dynamically in a loop, invisible to the language server). request, getJSON,
+-- postJSON and session are NOT stubbed — they are annotated at their real
+-- definitions in lua/stdlib/http.lua. Reference: docs/http.md
+--
+-- A non-2xx status is NOT an error (see resp.ok/resp.status); only failing to
+-- get a response at all (DNS, connection, TLS, timeout) raises.
+
+http = {}
+
+---@class http.Response
+---@field status integer HTTP status code, e.g. 200
+---@field ok boolean true when status is 2xx
+---@field headers table response headers, keyed by lowercase name
+---@field body string raw response body
+---@field json fun(): any parse body as JSON (JSON null becomes utils.NULL); raises on invalid JSON
+
+---@class http.Auth
+---@field username string
+---@field password string
+---@field scheme "basic"|"digest"? default "basic"
+
+---@class http.RequestOpts
+---@field headers table? extra request headers
+---@field body string? raw request body (set Content-Type yourself)
+---@field json any? serialized to JSON; sets Content-Type: application/json
+---@field form table? URL-encoded; sets application/x-www-form-urlencoded
+---@field cookies table? cookies for this request
+---@field timeout number? per-request timeout in seconds (default 30)
+---@field auth http.Auth? basic or digest credentials
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function http.get(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function http.post(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function http.put(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function http.patch(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function http.delete(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function http.head(url, opts) end
+
+----------------------------------------------------------------------
+-- Sessions: a cookie jar carried across requests
+----------------------------------------------------------------------
+
+---@class http.Session
+local Session = {}
+
+---@param method string
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:request(method, url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:get(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:post(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:put(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:patch(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:delete(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:head(url, opts) end
+
+---@param url string
+---@param opts http.RequestOpts?
+---@return any body
+---@return http.Response resp
+function Session:getJSON(url, opts) end
+
+---@param url string
+---@param body any
+---@param opts http.RequestOpts?
+---@return http.Response
+function Session:postJSON(url, body, opts) end
+
+---The jar as a nested table, {domain = {name = value}}.
+---@return table>
+function Session:cookies() end
+
+---Empty the in-memory jar.
+function Session:clearCookies() end
+
+---Write the jar to a JSONL file (one cookie per line).
+---@param path string
+function Session:save(path) end
+
+---Merge cookies from a JSONL file into the jar (matching names overwritten).
+---@param path string
+function Session:load(path) end
diff --git a/lua/stubs/log.lua b/lua/stubs/log.lua
new file mode 100644
index 0000000..c5086b6
--- /dev/null
+++ b/lua/stubs/log.lua
@@ -0,0 +1,31 @@
+---@meta
+-- Type stubs for the native `log` module: level-tagged diagnostics on stderr.
+-- Visibility is controlled by RUST_LOG (default: warn and error only).
+-- Arguments are stringified like print and joined with tabs; use
+-- utils.dump(t) to log a table's contents. Reference: docs/log.md
+
+log = {}
+
+---Very fine-grained, step-by-step detail. Shown with RUST_LOG=trace.
+---@param ... any
+function log.trace(...) end
+
+---Developer diagnostics. Shown with RUST_LOG=debug.
+---@param ... any
+function log.debug(...) end
+
+---Normal high-level progress. Shown with RUST_LOG=info.
+---@param ... any
+function log.info(...) end
+
+---Something unexpected but recoverable. Shown by default.
+---@param ... any
+function log.warn(...) end
+
+---Alias of log.warn.
+---@param ... any
+function log.warning(...) end
+
+---A failure worth reporting. Shown by default.
+---@param ... any
+function log.error(...) end
diff --git a/lua/stubs/os.lua b/lua/stubs/os.lua
new file mode 100644
index 0000000..513fef1
--- /dev/null
+++ b/lua/stubs/os.lua
@@ -0,0 +1,23 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stubs for the `a` additions to the sandboxed `os` table.
+--
+-- The sandbox trims `os` to its time-related functions — os.time, os.clock,
+-- os.date, os.difftime, os.getenv — plus the two additions below. The
+-- system-access functions (os.execute, os.exit, os.remove, os.rename,
+-- os.tmpname, os.setlocale) do NOT exist at runtime; lua-language-server
+-- cannot hide individual fields, so it still suggests them — do not use them.
+-- Reference: docs/os.md
+
+---Current time as a Unix timestamp in seconds, as a float with sub-second
+---precision (wall clock — may jump if the system clock is adjusted). Use the
+---difference of two readings for interval timing.
+---@return number timestamp
+function os.microtime() end
+
+---Async sleep: pause for `seconds` (a float, so 0.1 is 100 ms) by suspending
+---on the runtime instead of blocking the thread — sibling task.join tasks
+---keep running. Does not actually wait inside self-driven coroutines
+---(coroutine.wrap/resume); run those through task.join instead.
+---@param seconds number non-negative finite
+function os.sleep(seconds) end
diff --git a/lua/stubs/require.lua b/lua/stubs/require.lua
new file mode 100644
index 0000000..261495f
--- /dev/null
+++ b/lua/stubs/require.lua
@@ -0,0 +1,16 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stub for the sandboxed `require`. The stock `package` library does not
+-- exist in `a` (it is disabled in .luarc.json, which also removes the stock
+-- `require` definition — this stub replaces it). Reference: docs/require.md
+--
+-- Names are dot-separated (`require "lib.helper"` → lib/helper.lua or
+-- lib/helper/init.lua), resolved against the main script's directory and each
+-- entry of $A_INCLUDE_PATHS. Each module runs once; the result is cached.
+
+---Load a module. Returns the module's value and, unlike stock Lua's loader
+---data, the resolved file path as the second value.
+---@param modname string dot-separated module name
+---@return any module
+---@return string path the resolved file path
+function require(modname) end
diff --git a/lua/stubs/sqlite.lua b/lua/stubs/sqlite.lua
new file mode 100644
index 0000000..02b6da2
--- /dev/null
+++ b/lua/stubs/sqlite.lua
@@ -0,0 +1,71 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stubs for the `sqlite` module. The native core provides sqlite.connect
+-- and the connection methods; lua/stdlib/sqlite.lua adds the transaction
+-- helpers (typed here too, since the source defines them on a hidden method
+-- table). Reference: docs/sqlite.md
+--
+-- Failures raise Lua errors (bad SQL, unbindable type, parameter mismatch,
+-- closed connection) — trap with pcall/utils.try where needed.
+
+sqlite = {}
+
+---Parameters bind positionally (array for `?`) or by name (string keys for
+---`:name`/`@name`/`$name`); mixing the two is an error. Bind utils.NULL for
+---SQL NULL — a literal nil cannot live in a table.
+---@alias sqlite.Params any[]|table
+
+---@class sqlite.Connection
+local Connection = {}
+
+---Run a statement that changes data (INSERT, UPDATE, DELETE, CREATE, …).
+---@param sql string
+---@param params sqlite.Params?
+---@return integer changed number of rows changed
+function Connection:execute(sql, params) end
+
+---Run a SELECT and return all rows, each a table keyed by column name.
+---A NULL column is simply absent from its row table.
+---@param sql string
+---@param params sqlite.Params?
+---@return table[] rows
+function Connection:query(sql, params) end
+
+---Like query, but returns only the first row, or nil if nothing matched.
+---@param sql string
+---@param params sqlite.Params?
+---@return table? row
+function Connection:queryOne(sql, params) end
+
+---Rowid of the most recent successful INSERT on this connection.
+---@return integer
+function Connection:lastInsertRowid() end
+
+---Rows changed by the most recent INSERT/UPDATE/DELETE.
+---@return integer
+function Connection:changes() end
+
+---Release the connection eagerly (also happens on garbage collection).
+---Any further call on the connection raises.
+function Connection:close() end
+
+---Start a transaction (BEGIN).
+function Connection:begin() end
+
+---Commit the current transaction (COMMIT).
+function Connection:commit() end
+
+---Discard the current transaction (ROLLBACK).
+function Connection:rollback() end
+
+---Run fn inside a transaction: commit on success, roll back and re-raise on
+---error. fn's return values are passed through.
+---@param fn function
+---@return any ...
+function Connection:transaction(fn) end
+
+---Open a database file (created if missing), or ":memory:" for a private
+---in-memory database.
+---@param path string
+---@return sqlite.Connection
+function sqlite.connect(path) end
diff --git a/lua/stubs/task.lua b/lua/stubs/task.lua
new file mode 100644
index 0000000..fdedd50
--- /dev/null
+++ b/lua/stubs/task.lua
@@ -0,0 +1,14 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stubs for the native `task` module. Reference: docs/concurrency.md
+
+task = {}
+
+---Run each function as its own coroutine, drive them concurrently on the
+---async runtime, and return each one's first result positionally once all
+---have finished. Async stdlib calls (os.sleep, http, sqlite, prompts) inside
+---the tasks suspend and overlap — wall-clock time is the longest task, not
+---the sum. If a task raises, the first error is re-raised.
+---@param ... fun(): any
+---@return any ...
+function task.join(...) end
diff --git a/lua/stubs/tui.lua b/lua/stubs/tui.lua
new file mode 100644
index 0000000..c244b1f
--- /dev/null
+++ b/lua/stubs/tui.lua
@@ -0,0 +1,318 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stubs for the native `tui` module: interactive prompts, styled output,
+-- message blocks and terminal control. Always available as the global `tui`.
+-- Reference: docs/tui.md
+--
+-- Shared prompt behavior: Esc returns nil; Ctrl-C raises ": interrupted";
+-- prompting without a terminal raises ": not a terminal". Prompts are
+-- async — they suspend only the calling coroutine.
+
+tui = {}
+
+----------------------------------------------------------------------
+-- Prompts
+----------------------------------------------------------------------
+
+---@class tui.ConfirmOpts
+---@field help string? hint line shown below the prompt
+---@field placeholder string? dim hint shown while the input is empty
+
+---Yes/no question, answered with y/n. `default` is returned on plain Enter;
+---with nil the user must type an answer. Esc returns nil (distinct from false).
+---@param text string
+---@param default boolean?
+---@param opts tui.ConfirmOpts?
+---@return boolean? answer
+function tui.confirm(text, default, opts) end
+
+---@class tui.TextOpts
+---@field help string?
+---@field placeholder string? dim hint shown while the input is empty
+---@field default string? returned when the input is submitted empty (distinct from the pre-filled positional default)
+---@field minLen integer? length bound, enforced inline
+---@field maxLen integer? length bound, enforced inline
+---@field suggestions string[]? autocompletion candidates (substring filter, Tab completes)
+---@field pageSize integer? visible suggestion rows
+
+---One-line text input. The positional `default` is pre-filled, editable text.
+---@param text string
+---@param default string?
+---@param opts tui.TextOpts?
+---@return string? answer
+function tui.promptText(text, default, opts) end
+
+---@class tui.LongTextOpts
+---@field help string?
+---@field extension string? temp-file extension for editor syntax highlighting (default ".txt")
+---@field editor string? editor command instead of $VISUAL/$EDITOR
+
+---Multi-line input via an external editor ($VISUAL/$EDITOR) on a temp file.
+---@param text string
+---@param default string? text the editor buffer starts with
+---@param opts tui.LongTextOpts?
+---@return string? answer
+function tui.promptLongText(text, default, opts) end
+
+---@class tui.SelectOpts
+---@field options string[] the choices (required, non-empty)
+---@field help string?
+---@field multiple boolean? checkbox multi-select; result becomes string[]
+---@field pageSize integer? visible rows (default 7)
+---@field vimMode boolean? hjkl navigation
+---@field filter boolean? set false to disable type-to-filter
+---@field minSelected integer? multiple only, enforced inline
+---@field maxSelected integer? multiple only, enforced inline
+---@field allSelected boolean? multiple only: start with everything checked
+
+---Pick from a list (filterable). `default` is matched against the options by
+---value; a value not among them raises. With `opts.multiple` the default may
+---be a string or array of strings and the result is an array.
+---@param text string
+---@param default string|string[]|nil
+---@param opts tui.SelectOpts
+---@return string|string[]|nil answer
+function tui.promptSelect(text, default, opts) end
+
+---@class tui.SecretOpts
+---@field help string?
+---@field confirm boolean? ask twice and require both entries to match
+---@field display "masked"|"hidden"|"full"? echo mode (default "masked")
+---@field toggle boolean? allow Ctrl-R to reveal the input
+---@field minLen integer? enforced inline
+
+---Secret input. Deliberately has no default parameter.
+---@param text string
+---@param opts tui.SecretOpts?
+---@return string? answer
+function tui.promptSecret(text, opts) end
+
+---@class tui.NumberOpts
+---@field help string?
+---@field placeholder string?
+---@field min number? enforced inline
+---@field max number? enforced inline
+
+---Numeric input, re-prompted until it parses. `default` is returned on empty submit.
+---@param text string
+---@param default number?
+---@param opts tui.NumberOpts?
+---@return number? answer
+function tui.promptNumber(text, default, opts) end
+
+---Whole-number input, re-prompted until it parses. A fractional `default`
+---raises at call time.
+---@param text string
+---@param default integer?
+---@param opts tui.NumberOpts?
+---@return integer? answer
+function tui.promptInt(text, default, opts) end
+
+---@class tui.DateOpts
+---@field help string?
+---@field min string? earliest selectable date, "YYYY-MM-DD"
+---@field max string? latest selectable date, "YYYY-MM-DD"
+---@field weekStart string? first day of the week, e.g. "monday" (default Sunday)
+
+---Interactive calendar. Dates cross the API as "YYYY-MM-DD" strings.
+---@param text string
+---@param default string? initially selected date, "YYYY-MM-DD"
+---@param opts tui.DateOpts?
+---@return string? date
+function tui.promptDate(text, default, opts) end
+
+----------------------------------------------------------------------
+-- Styled output
+----------------------------------------------------------------------
+
+---Render HTML-like style tags (``, ``, shorthand
+---``, ``, action tags like ``) into a
+---string with ANSI escapes. Degrades to plain text when piped or NO_COLOR is
+---set. `>` closes the innermost style; invalid tags pass through literally.
+---@param markup string
+---@return string
+function tui.styled(markup) end
+
+---Backslash-escape the markup metacharacters (`<`, `>`, `\`) so untrusted
+---text — user input, API data — can be embedded in a `tui.styled` format
+---string and come out verbatim, without breaking the surrounding markup.
+---@param text string
+---@return string
+function tui.escape(text) end
+
+---Register a custom tag for `tui.styled` and block styles. The spec uses tag
+---syntax: attributes ("fg=white;bg=magenta"), shorthand ("i;bold"), or another
+---preset name. Re-registering overwrites; presets may shadow built-ins.
+---@param name string identifier-like tag name
+---@param style string
+function tui.addPreset(name, style) end
+
+----------------------------------------------------------------------
+-- Message blocks (print directly to stdout, plain-text message)
+----------------------------------------------------------------------
+
+---@class tui.BlockOpts
+---@field label string? the "[LABEL]" text
+---@field style string? tag-body syntax: preset name, spec or shorthand
+---@field prefix string? prepended to every line (default " ")
+---@field padding boolean? blank styled line above and below
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.success(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.error(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.warning(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.caution(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.info(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.note(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.comment(message, opts) end
+
+---Generic block: everything comes from opts.
+---@param message string|string[]
+---@param opts tui.BlockOpts?
+function tui.block(message, opts) end
+
+---@class tui.OutlineOpts
+---@field title string? shown in the top border
+---@field style string? border color, tag-body syntax
+---@field padding boolean? blank line above/below the content inside the box (default true)
+
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineSuccess(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineError(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineWarning(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineNote(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineInfo(message, opts) end
+
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineCaution(message, opts) end
+
+---Generic outline block.
+---@param message string|string[]
+---@param opts tui.OutlineOpts?
+function tui.outlineBlock(message, opts) end
+
+----------------------------------------------------------------------
+-- Low-level output
+----------------------------------------------------------------------
+
+---Write arguments to stdout with no newline and no separator (each converted
+---with tostring), then flush.
+---@param ... any
+function tui.raw(...) end
+
+---@class tui.ScreenInfo
+---@field width integer? terminal width in cells; nil when there is no terminal
+---@field height integer? terminal height in cells; nil when there is no terminal
+---@field tty boolean whether stdout is a terminal
+---@field colors boolean whether styled output will actually emit colors
+
+---Facts about the terminal, for scripts that render their own UI.
+---@return tui.ScreenInfo
+function tui.screenInfo() end
+
+----------------------------------------------------------------------
+-- Terminal control (no-ops without a terminal; bad arguments always raise;
+-- alternate screen / cursor / scroll region / title restored on exit)
+----------------------------------------------------------------------
+
+---Absolute cursor move, 1-based. nil for one axis keeps it.
+---@param row integer?
+---@param col integer?
+function tui.moveTo(row, col) end
+
+---Relative cursor move: positive dx right, positive dy down; nil counts as 0.
+---@param dx integer?
+---@param dy integer?
+function tui.move(dx, dy) end
+
+---Save the cursor position (one slot).
+function tui.saveCursor() end
+
+---Restore the saved cursor position.
+function tui.restoreCursor() end
+
+---Ask the terminal where the cursor is (1-based). Returns nil when there is
+---no terminal or it does not answer. Async, serialized against prompts.
+---@return integer? row
+---@return integer? col
+function tui.cursorPos() end
+
+---Hide (false) or show (true or no argument) the cursor.
+---@param visible boolean?
+function tui.showCursor(visible) end
+
+---@class tui.CursorStyleOpts
+---@field blink boolean?
+
+---Set the cursor shape; no arguments reset to the terminal default.
+---@param style "block"|"underline"|"bar"|nil
+---@param opts tui.CursorStyleOpts?
+function tui.cursorStyle(style, opts) end
+
+---Erase the current line. mode: 0/nil = whole (cursor to column 1),
+----1 = up to the cursor, 1 = cursor to end (cursor stays).
+---@param mode integer?
+function tui.clearLine(mode) end
+
+---Erase the screen. mode: 0/nil = whole (cursor home), -1 = above the cursor,
+---1 = below the cursor (cursor stays).
+---@param mode integer?
+function tui.clearScreen(mode) end
+
+---Switch to (true) or back from (false) the alternate screen buffer.
+---@param on boolean
+function tui.altScreen(on) end
+
+---Restrict scrolling to rows top..bottom (1-based, inclusive); moves the
+---cursor home. No arguments reset to the full screen.
+---@param top integer?
+---@param bottom integer?
+function tui.scrollRegion(top, bottom) end
+
+---Scroll the scroll region: positive n up, negative down. Cursor stays.
+---@param n integer
+function tui.scroll(n) end
+
+---Set the terminal window title (restored on exit). Control characters raise.
+---@param text string
+function tui.setTitle(text) end
+
+---Copy text into the system clipboard via the terminal (OSC 52). Write-only.
+---@param text string
+function tui.setClipboard(text) end
+
+---Soft-reset the terminal (DECSTR) without clearing the screen.
+function tui.reset() end
diff --git a/lua/stubs/utils.lua b/lua/stubs/utils.lua
new file mode 100644
index 0000000..f61fe4f
--- /dev/null
+++ b/lua/stubs/utils.lua
@@ -0,0 +1,40 @@
+---@meta
+---@diagnostic disable: missing-return
+-- Type stubs for the NATIVE half of the `utils` module: the JSON codec, the
+-- NULL sentinel and the pretty-printer. The Lua-implemented half (utils.try,
+-- utils.tryn) lives in lua/stdlib/utils.lua and needs no stub.
+-- Reference: docs/utils.md
+
+utils = {}
+
+---Sentinel standing for an explicit null in tables (a Lua nil would mean "key
+---absent" — assigning nil deletes the key). Produced by fromJSON for JSON
+---null; serialized back to null by toJSON and the http helpers; binds SQL
+---NULL in sqlite. Compare with utils.isNull.
+---@type userdata
+utils.NULL = nil
+
+---Serialize a Lua value to a JSON string. A table with keys exactly 1..#t
+---becomes an array, any other table an object; utils.NULL becomes null.
+---Raises on NaN/infinity, values with no JSON form, or nesting beyond 64.
+---@param value any
+---@param pretty boolean? indented multi-line output (default compact)
+---@return string
+function utils.toJSON(value, pretty) end
+
+---Parse a JSON string into a Lua value. JSON null decodes to utils.NULL (not
+---nil), so nulls inside arrays do not create gaps. Invalid JSON raises.
+---@param str string
+---@return any
+function utils.fromJSON(str) end
+
+---True if value is the utils.NULL sentinel. isNull(nil) is false.
+---@param value any
+---@return boolean
+function utils.isNull(value) end
+
+---Render any Lua value as a readable string, for logging and inspection.
+---Cycles print as ; nesting is capped at 20 levels.
+---@param value any
+---@return string
+function utils.dump(value) end
diff --git a/src/lua_tests/tui_tests.rs b/src/lua_tests/tui_tests.rs
index f4d50c1..fba1cac 100644
--- a/src/lua_tests/tui_tests.rs
+++ b/src/lua_tests/tui_tests.rs
@@ -22,6 +22,7 @@ fn test_tui_global_has_all_functions() {
"promptInt",
"promptDate",
"styled",
+ "escape",
"block",
"success",
"error",
@@ -159,6 +160,26 @@ fn test_add_preset_validation() {
}
}
+#[test]
+fn test_escape_makes_text_literal() {
+ let lua = lua();
+ // Exact escaping and round-trip bytes are pinned in stdlib/tui/markup.rs;
+ // here: escaped input stays literal, surrounding markup still works.
+ let out: String = lua
+ .load(r#"return tui.styled(tui.escape("boom\\") .. "y")"#)
+ .eval()
+ .unwrap();
+ assert!(out.contains("boom\\"), "escaped text should stay literal: {out:?}");
+ assert!(!out.contains(""), "markup after escaped text should still work: {out:?}");
+}
+
+#[test]
+fn test_escape_rejects_non_string() {
+ let lua = lua();
+ let err = lua.load(r#"return tui.escape(42)"#).eval::().unwrap_err();
+ assert!(err.to_string().contains("tui.escape: expected a string (got integer)"), "{err}");
+}
+
#[test]
fn test_styled_rejects_non_string() {
let lua = lua();
diff --git a/src/stdlib/tui/markup.rs b/src/stdlib/tui/markup.rs
index 96cec0d..5247214 100644
--- a/src/stdlib/tui/markup.rs
+++ b/src/stdlib/tui/markup.rs
@@ -24,7 +24,9 @@
//!
//! A tag that doesn't parse — unknown name, bad color, `<>`, wrong case, a
//! stray `>` — is emitted as literal text, never an error, matching
-//! Symfony's formatter. `\<` (and `\>`) escape the brackets.
+//! Symfony's formatter. `\<` (and `\>`) escape the brackets, `\\` a
+//! backslash; [`escape`] (`tui.escape`) produces exactly these so untrusted
+//! text can be embedded in a format string and come out verbatim.
//!
//! Rendering is pure: `render(input, colorize, presets)` has no I/O and
//! consults no globals, so tests can pin exact byte output. Each text segment
@@ -444,9 +446,9 @@ fn tokenize<'a>(input: &'a str, presets: &Presets) -> Vec> {
while i < bytes.len() {
match bytes[i] {
- // `\<` and `\>` escape the bracket; a backslash before anything
- // else is ordinary text (stays in the run).
- b'\\' if matches!(bytes.get(i + 1), Some(b'<') | Some(b'>')) => {
+ // `\<` and `\>` escape the bracket, `\\` a backslash; a backslash
+ // before anything else is ordinary text (stays in the run).
+ b'\\' if matches!(bytes.get(i + 1), Some(b'<') | Some(b'>') | Some(b'\\')) => {
flush(&mut events, input, run_start, i);
events.push(Event::Text(&input[i + 1..i + 2]));
i += 2;
@@ -498,6 +500,20 @@ fn tokenize<'a>(input: &'a str, presets: &Presets) -> Vec> {
events
}
+/// Backslash-escape the markup metacharacters (`<`, `>`, `\`) so `render`
+/// reproduces `text` verbatim — styled only by enclosing tags — no matter
+/// what it contains or what markup follows it. Backs `tui.escape`.
+pub(super) fn escape(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ for c in text.chars() {
+ if matches!(c, '<' | '>' | '\\') {
+ out.push('\\');
+ }
+ out.push(c);
+ }
+ out
+}
+
/// Render markup to a string. With `colorize == false` the tags are still
/// parsed and stripped and escapes resolved, but no escape sequences of any
/// kind are emitted — safe for pipes and NO_COLOR.
@@ -726,12 +742,43 @@ mod tests {
assert_eq!(r("\\x", false), "x");
assert_eq!(r("foo\\", true), ">");
- // A backslash before anything else is ordinary text.
+ // `\\` escapes a backslash, so `\\` is a backslash then a tag...
+ assert_eq!(r("a\\\\b", true), "a\\b");
+ assert_eq!(r("\\\\x>", true), "\\\x1b[1;32mx\x1b[0m");
+ // ...but a backslash before anything else is ordinary text.
assert_eq!(r("a\\b\\", true), "a\\b\\");
// Escaped bracket inside a styled span stays styled.
assert_eq!(r("\\", true), "\x1b[1;32m\x1b[0m");
}
+ #[test]
+ fn escape_round_trips() {
+ for s in [
+ "plain text",
+ "x",
+ ">",
+ "a < b > c",
+ "back\\slash",
+ "trailing\\",
+ "\\",
+ "",
+ "x>",
+ ] {
+ let escaped = escape(s);
+ // The escaped text renders as itself, in both color modes.
+ assert_eq!(r(&escaped, true), s, "round trip: {s:?}");
+ assert_eq!(r(&escaped, false), s, "plain round trip: {s:?}");
+ // Embedded in a format string it takes the enclosing style and
+ // never breaks the markup around it — even ending in a backslash.
+ assert_eq!(
+ r(&format!("{escaped}!"), true),
+ format!("\x1b[1;32m{s}\x1b[0m!"),
+ "embedded: {s:?}"
+ );
+ }
+ assert_eq!(escape(""), "");
+ }
+
#[test]
fn href() {
assert_eq!(
diff --git a/src/stdlib/tui/output.rs b/src/stdlib/tui/output.rs
index e932ebe..2785ace 100644
--- a/src/stdlib/tui/output.rs
+++ b/src/stdlib/tui/output.rs
@@ -130,7 +130,7 @@ const BLOCK_PRESETS: &[BlockPreset] = &[
BlockPreset { name: "caution", fname: "tui.caution", label: Some("CAUTION"), style: Some("fg=white;bg=red"), prefix: " ! ", padding: true },
BlockPreset { name: "info", fname: "tui.info", label: Some("INFO"), style: Some("fg=green"), prefix: " ", padding: false },
BlockPreset { name: "note", fname: "tui.note", label: Some("NOTE"), style: Some("fg=yellow"), prefix: " ! ", padding: false },
- BlockPreset { name: "comment", fname: "tui.comment", label: None, style: None, prefix: " // ", padding: false },
+ BlockPreset { name: "comment", fname: "tui.comment", label: None, style: Some("fg=gray"), prefix: " ", padding: false },
];
/// The outlined-box presets. Titles are plain words (no emoji) and are only
@@ -239,6 +239,28 @@ pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
})?,
)?;
+ // tui.escape: backslash-escape the markup metacharacters so untrusted
+ // text (user input, API data) can be embedded in a tui.styled format
+ // string and come out verbatim.
+ tui.raw_set(
+ "escape",
+ lua.create_function(|_, v: LuaValue| {
+ let s = match v {
+ LuaValue::String(s) => s,
+ other => {
+ return Err(ext_err!(
+ "tui.escape: expected a string (got {})",
+ other.type_name()
+ ))
+ }
+ };
+ let s = s
+ .to_str()
+ .map_err(|_| ext_err!("tui.escape: input must be valid UTF-8"))?;
+ Ok(markup::escape(&s))
+ })?,
+ )?;
+
// tui.addPreset: register a custom markup tag, e.g.
// addPreset("keyword", "fg=white;bg=magenta") makes …
// work in tui.styled and as a block `style` option. The spec may also