fix up lua lib and language server config, add stubs, move demos

master
Ondřej Hruška 2 weeks ago
parent 1a1b134ffa
commit b39d79ef04
  1. 12
      .luarc.json
  2. 30
      docs/tui.md
  3. 0
      lua/demo/coroutines-demo.lua
  4. 0
      lua/demo/test.lua
  5. 0
      lua/demo/test_sqlite.lua
  6. 6
      lua/demo/tui_prompts.lua
  7. 0
      lua/demo/tui_styles.lua
  8. 0
      lua/demo/tui_term.lua
  9. 256
      lua/demo/weather.lua
  10. 21
      lua/stdlib/http.lua
  11. 2
      lua/stdlib/math.lua
  12. 29
      lua/stubs/README.md
  13. 133
      lua/stubs/http.lua
  14. 31
      lua/stubs/log.lua
  15. 23
      lua/stubs/os.lua
  16. 16
      lua/stubs/require.lua
  17. 71
      lua/stubs/sqlite.lua
  18. 14
      lua/stubs/task.lua
  19. 318
      lua/stubs/tui.lua
  20. 40
      lua/stubs/utils.lua
  21. 21
      src/lua_tests/tui_tests.rs
  22. 57
      src/stdlib/tui/markup.rs
  23. 24
      src/stdlib/tui/output.rs

@ -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"]
}

@ -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("<info>saved</info> for <options=bold>" .. name .. "</>"))
print(tui.styled("<info>saved</info> for <options=bold>" .. 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: `"\\<info>"` prints `<info>`. Anything
that does not parse as a tag — `<unknown>`, `<Info>` (tags are
case-sensitive), a lone `<`, a bad color — is passed through as literal text,
never an error.
A backslash escapes a literal bracket: `"\\<info>"` prints `<info>`; `"\\\\"`
prints a single backslash. Anything that does not parse as a tag —
`<unknown>`, `<Info>` (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 <b>%s</b>…"):format(tui.escape(city))))
```
Without escaping, a user who types `<error>` 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,

@ -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",
})

@ -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 <clear=line> 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
-- "<mild>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><dim>%s</dim> %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 "<error>" (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. <clear=line> redraws in place.
local i = 1
while not done do
tui.raw(tui.styled(("<clear=line><accent>%s</accent> asking wttr.in about <b>%s</b>…")
:format(FRAMES[i], cityLabel)))
i = i % #FRAMES + 1
os.sleep(0.08)
end
tui.raw(tui.styled("<clear=line>"))
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 <b>%s</b> <dim>(feels like %s)</dim>")
:format(fmtTemp(t, tC, "°" .. T), tui.escape(cur.weatherDesc[1].value),
fmtTemp(feels, tonumber(cur.FeelsLikeC), "°" .. T))))
print(tui.styled((" wind <b>%s %s</b> %s · pressure <b>%s hPa</b> · UV <b>%s</b>")
: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((" <u>3-day forecast</u> <dim>(scale %d°%s … %d°%s)</dim>"):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%% <dim>☀ %s → %s</dim>")
: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 <u>%s, hour by hour</u>"):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((" <dim>%s</dim> %s %s <dim>wind %3s</dim> %-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

@ -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

@ -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

@ -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`.

@ -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<string, string> 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<string, string>? 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<string, any>? URL-encoded; sets application/x-www-form-urlencoded
---@field cookies table<string, string>? 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<string, table<string, string>>
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

@ -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

@ -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

@ -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

@ -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<string, any>
---@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

@ -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

@ -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 "<fn>: interrupted";
-- prompting without a terminal raises "<fn>: 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 (`<info>`, `<fg=#c0392b;options=bold>`, shorthand
---`<red;on-white;b>`, `<href=URL>`, action tags like `<clear=line>`) 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

@ -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 <circular>; nesting is capped at 20 levels.
---@param value any
---@return string
function utils.dump(value) end

@ -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("<error>boom</error>\\") .. "<b>y</b>")"#)
.eval()
.unwrap();
assert!(out.contains("<error>boom</error>\\"), "escaped text should stay literal: {out:?}");
assert!(!out.contains("<b>"), "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::<String>().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();

@ -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<Event<'a>> {
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<Event<'a>> {
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("\\<info>x", false), "<info>x");
assert_eq!(r("foo\\<bar", true), "foo<bar");
assert_eq!(r("\\>", true), ">");
// A backslash before anything else is ordinary text.
// `\\` escapes a backslash, so `\\<info>` is a backslash then a tag...
assert_eq!(r("a\\\\b", true), "a\\b");
assert_eq!(r("\\\\<info>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("<info>\\<x></info>", true), "\x1b[1;32m<x>\x1b[0m");
}
#[test]
fn escape_round_trips() {
for s in [
"plain text",
"<info>x</info>",
"</>",
"a < b > c",
"back\\slash",
"trailing\\",
"\\<pre-escaped>",
"<clear=screen><up=3>",
"<red;href=https://e.com>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!("<info>{escaped}</info>!"), true),
format!("\x1b[1;32m{s}\x1b[0m!"),
"embedded: {s:?}"
);
}
assert_eq!(escape(""), "");
}
#[test]
fn href() {
assert_eq!(

@ -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 <keyword>…</keyword>
// work in tui.styled and as a block `style` option. The spec may also

Loading…
Cancel
Save