From b39d79ef0431146d2b0e15c5c587d264011a83c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Mon, 6 Jul 2026 22:42:50 +0200 Subject: [PATCH] fix up lua lib and language server config, add stubs, move demos --- .luarc.json | 12 ++ docs/tui.md | 30 ++- lua/{ => demo}/coroutines-demo.lua | 0 lua/{ => demo}/test.lua | 0 lua/{ => demo}/test_sqlite.lua | 0 lua/{ => demo}/tui_prompts.lua | 6 +- lua/{ => demo}/tui_styles.lua | 0 lua/{ => demo}/tui_term.lua | 0 lua/demo/weather.lua | 256 +++++++++++++++++++++++ lua/stdlib/http.lua | 21 ++ lua/stdlib/math.lua | 2 +- lua/stubs/README.md | 29 +++ lua/stubs/http.lua | 133 ++++++++++++ lua/stubs/log.lua | 31 +++ lua/stubs/os.lua | 23 +++ lua/stubs/require.lua | 16 ++ lua/stubs/sqlite.lua | 71 +++++++ lua/stubs/task.lua | 14 ++ lua/stubs/tui.lua | 318 +++++++++++++++++++++++++++++ lua/stubs/utils.lua | 40 ++++ src/lua_tests/tui_tests.rs | 21 ++ src/stdlib/tui/markup.rs | 57 +++++- src/stdlib/tui/output.rs | 24 ++- 23 files changed, 1088 insertions(+), 16 deletions(-) create mode 100644 .luarc.json rename lua/{ => demo}/coroutines-demo.lua (100%) rename lua/{ => demo}/test.lua (100%) rename lua/{ => demo}/test_sqlite.lua (100%) rename lua/{ => demo}/tui_prompts.lua (96%) rename lua/{ => demo}/tui_styles.lua (100%) rename lua/{ => demo}/tui_term.lua (100%) create mode 100644 lua/demo/weather.lua create mode 100644 lua/stubs/README.md create mode 100644 lua/stubs/http.lua create mode 100644 lua/stubs/log.lua create mode 100644 lua/stubs/os.lua create mode 100644 lua/stubs/require.lua create mode 100644 lua/stubs/sqlite.lua create mode 100644 lua/stubs/task.lua create mode 100644 lua/stubs/tui.lua create mode 100644 lua/stubs/utils.lua 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"):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 %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