Compare commits
No commits in common. 'master' and 'repl' have entirely different histories.
@ -1,12 +0,0 @@ |
||||
{ |
||||
"$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"] |
||||
} |
||||
@ -1,8 +0,0 @@ |
||||
column_width = 120 |
||||
line_endings = "Unix" |
||||
indent_type = "Spaces" |
||||
indent_width = 4 |
||||
quote_style = "AutoPreferDouble" |
||||
call_parentheses = "Input" |
||||
# never allow `if x then y end` on one line — always expand after then/do |
||||
collapse_simple_statement = "Never" |
||||
@ -1,9 +0,0 @@ |
||||
.PHONY: make update-psl |
||||
|
||||
make: |
||||
cargo build --release
|
||||
|
||||
# Refresh the vendored Public Suffix List used for cookie domain checks
|
||||
# (compiled into the binary by stdlib::http).
|
||||
update-psl: |
||||
curl -sfL https://publicsuffix.org/list/public_suffix_list.dat -o assets/public_suffix_list.dat
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,187 +0,0 @@ |
||||
# `fs` |
||||
|
||||
Whole-file filesystem access, guarded by a per-script folder permission |
||||
system. The sandbox loads no stock `io`; this module is the supported way for |
||||
a script to touch files, and every operation first passes a permission check |
||||
(see [Permissions](#permissions) below). |
||||
|
||||
The v1 surface is deliberately small — whole files in and out, directory |
||||
listing, two predicates, and an explicit permission probe: |
||||
|
||||
```lua |
||||
local text = fs.readAll("/home/me/notes.txt") |
||||
fs.writeAll("out/report.md", text .. "\n-- processed") |
||||
|
||||
for _, name in ipairs(fs.list(".")) do print(name) end |
||||
|
||||
if fs.isFile("config.json") then ... end |
||||
if fs.access("/var/data", "w") then ... end |
||||
``` |
||||
|
||||
## Functions |
||||
|
||||
### `fs.readAll(path) -> string` |
||||
|
||||
Reads the whole file into a string. Lua strings are byte strings, so binary |
||||
content round-trips exactly. Errors if `path` is not a regular file, the OS |
||||
refuses, or the permission system denies **read** on the file's directory. |
||||
|
||||
### `fs.writeAll(path, content)` |
||||
|
||||
Writes the whole file: created if missing, truncated if it exists. The |
||||
containing directory must already exist (there is no mkdir-p), and a dangling |
||||
symlink as the target is refused. Errors if the file can't be created or the |
||||
permission system denies **write** on the containing directory. |
||||
|
||||
### `fs.list(path) -> string[]` |
||||
|
||||
The names (not full paths) of a directory's entries, sorted. `.` and `..` are |
||||
not included. Errors if `path` is not a directory or **read** on it is |
||||
denied. |
||||
|
||||
### `fs.isDir(path) -> boolean`, `fs.isFile(path) -> boolean` |
||||
|
||||
`true` iff the path exists, has the right type, *and* is readable under the |
||||
permission system. These never error: a nonexistent path is `false` without |
||||
any permission prompt; an unknown permission prompts like any other read (a |
||||
denial makes the result `false`). |
||||
|
||||
### `fs.getWorkDir() -> string` |
||||
|
||||
The process's current working directory, as an absolute path. Relative paths |
||||
given to the other fs functions resolve against it, so it is the natural base |
||||
for building paths to probe with `fs.access`: |
||||
|
||||
```lua |
||||
local cwd = fs.getWorkDir() |
||||
if fs.access(cwd, "w") then |
||||
fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt" |
||||
end |
||||
``` |
||||
|
||||
Not permission-gated — it reports process state and touches no file content. |
||||
|
||||
### `fs.getScriptDir() -> string|nil` |
||||
|
||||
The directory the main script really lives in, as an absolute path with |
||||
symlinks resolved — an installed script invoked through a `~/.local/bin` |
||||
symlink gets the directory of the real file. Unlike the working directory, |
||||
this doesn't depend on where the script was invoked from, so it is the right |
||||
base for assets shipped alongside the script: |
||||
|
||||
```lua |
||||
local tmpl = fs.readAll(fs.getScriptDir() .. "/templates/report.html") |
||||
``` |
||||
|
||||
Returns `nil` in the REPL, where there is no script. Not permission-gated — |
||||
it names a place and reads no file content (accessing files under it prompts |
||||
like anywhere else). |
||||
|
||||
### `fs.access(dir, mode) -> boolean` |
||||
|
||||
Runs the permission cycle for a directory explicitly — `mode` is `"r"` or |
||||
`"w"`: |
||||
|
||||
1. a recorded grant/denial answers immediately; |
||||
2. otherwise the interactive prompt asks; |
||||
3. with no terminal to ask on, returns `false` **without** recording a |
||||
denial (a persisted "no" is always an explicit user decision). |
||||
|
||||
Use it to ask for permission up front at a natural moment instead of |
||||
mid-operation. Errors if `dir` is not an existing directory. |
||||
|
||||
## Permissions |
||||
|
||||
Permissions attach to **directories**, not files, and are recorded per |
||||
script: |
||||
|
||||
- The permission unit for `readAll`/`writeAll`/`isFile` is the file's |
||||
directory; for `list`/`isDir`/`access` it is the directory itself. Paths |
||||
are canonicalized first (symlinks resolved, relative paths against the |
||||
CWD), so two spellings of the same place share one grant. |
||||
- Grants are **recursive**: an allow or deny on a directory covers |
||||
everything below it, and the *deepest* recorded ancestor wins — deny |
||||
`~/secrets` and it stays denied even though `~` is allowed. |
||||
- Read and write are tracked separately; each is approved, denied, or not |
||||
decided yet. |
||||
|
||||
When an operation needs a decision that isn't recorded, Luna asks on the |
||||
terminal (stderr, so it shows even with stdout piped) — one keypress, no |
||||
Enter: |
||||
|
||||
``` |
||||
luna: permission request |
||||
script: /home/me/bin/deploy.lua |
||||
wants: read access to directory /home/me/project |
||||
[y] allow once [n] deny once [A] allow always [N] never allow: |
||||
``` |
||||
|
||||
- **`y` / `n`** apply for the rest of this run only. |
||||
- **`A` / `N`** are recorded in `access.json` and answer silently from then |
||||
on. (A lowercase `a` deliberately does nothing — a slip of the finger must |
||||
not persist anything.) |
||||
- **Esc / Ctrl-C** count as `n`. |
||||
- No terminal (piped stdin, cron): the operation is denied for this run and |
||||
*nothing* is recorded. |
||||
|
||||
While a prompt is waiting, `tui` output from concurrently running coroutines |
||||
(e.g. a spinner animating during the request that triggered the question) is |
||||
held back so it cannot overwrite the question, and resumes with the answer. |
||||
|
||||
### `access.json` |
||||
|
||||
Recorded answers live in `$XDG_CONFIG_HOME/luna/access.json` (usually |
||||
`~/.config/luna/access.json`), keyed by the script's canonical path — moving or |
||||
renaming a script starts it with a clean slate. Under each script, grants are |
||||
grouped by subsystem: directory grants sit under `"fs"`, network-origin grants |
||||
(see [`http`](http.md#permissions)) under `"net"`. The file is plain JSON and |
||||
safe to hand-edit; each flag is `true` (approved), `false` (denied), or `null` |
||||
(not decided yet): |
||||
|
||||
```json |
||||
{ |
||||
"version": 1, |
||||
"scripts": { |
||||
"/home/me/bin/deploy.lua": { |
||||
"fs": { |
||||
"/home/me/project": { "read": true, "write": true }, |
||||
"/etc": { "read": true, "write": false } |
||||
}, |
||||
"net": { |
||||
"https://api.example.com": { "access": true } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
Concurrent Luna processes update it safely (the write cycle holds a file |
||||
lock and replaces the file atomically). |
||||
|
||||
The **REPL** prompts the same way, but its answers — including `A`/`N` — |
||||
last only for the session; an interactive session has no script identity to |
||||
record them under. |
||||
|
||||
### `sqlite` and `http` respect the same system |
||||
|
||||
- `sqlite.connect(path)` asks one combined **read/write** question for the |
||||
database's directory (SQLite keeps WAL/journal files next to it). |
||||
Memory-class databases — `":memory:"`, `""` and their `file:` URI |
||||
spellings — touch no file and are always allowed, even under `--no-fs` / |
||||
`--sandbox`. |
||||
- A session's cookie-jar files (`http.session(path)`, `session:load`, |
||||
`session:save`) are read/write checked like any other file. |
||||
- `http` requests run the same cycle per **network origin** instead of per |
||||
directory — see [Permissions in `http`](http.md#permissions). |
||||
|
||||
## CLI flags |
||||
|
||||
| Flag | Effect | |
||||
|------|--------| |
||||
| `--allow-fs` | Skip the filesystem checks: everything granted, nothing asked or recorded. | |
||||
| `--no-fs` | Deny all filesystem access (fs module, on-disk sqlite databases, cookie-jar files). | |
||||
| `--allow-net` / `--no-net` | The same two switches for network origins (see [`http`](http.md#permissions)). | |
||||
| `--sandbox` | `--no-fs` plus `--no-net`; overrides the `--allow-*` flags. | |
||||
|
||||
Denials from a flag read `fs.readAll: filesystem access disabled by --no-fs`, |
||||
interactive/recorded ones `fs.readAll: access to /some/dir denied`. |
||||
@ -1,110 +0,0 @@ |
||||
# Modules & `require` |
||||
|
||||
Luna provides a sandboxed `require` for splitting a script into multiple files. |
||||
It follows stock Lua's conventions — dot-separated module names, one execution |
||||
per module, cached results — so editors and the Lua language server understand |
||||
the imports without special setup. |
||||
|
||||
```lua |
||||
local helper = require "lib.helper" -- lib/helper.lua |
||||
local db = require "db" -- db.lua or db/init.lua |
||||
local mod, path = require "lib.helper" -- second value: the resolved file path |
||||
``` |
||||
|
||||
## Module names |
||||
|
||||
A name is one or more dot-separated segments; each segment may contain |
||||
`A–Z a–z 0–9 _ -`. Dots map to directory separators, so `require "db.migrations"` |
||||
looks for `db/migrations.lua`. Paths (`/`, `..`), absolute names, and empty |
||||
segments are rejected — a module can only ever be loaded from inside the |
||||
search roots. |
||||
|
||||
## Search roots |
||||
|
||||
Names are resolved against a fixed list of directories, in order: |
||||
|
||||
1. **The main script's directory.** Symlinks to the script are resolved first, |
||||
so a script symlinked into `~/.local/bin` still loads modules (and its |
||||
`.env`, below) from next to the real file. In the REPL, the current working |
||||
directory is used instead. |
||||
2. **Each entry of `$LUNA_INCLUDE_PATHS`**, colon-separated like `$PATH`. |
||||
|
||||
In every root, two candidates are tried (the Lua language server's default |
||||
templates): |
||||
|
||||
``` |
||||
<root>/<name with dots as slashes>.lua |
||||
<root>/<name with dots as slashes>/init.lua |
||||
``` |
||||
|
||||
The first hit wins. If a later root (or the `init.lua` form) also matches, a |
||||
warning is logged about the shadowed file. When nothing matches, the error |
||||
lists every path that was tried, in the stock Lua format. |
||||
|
||||
## `.env` |
||||
|
||||
Before resolving anything, Luna loads a `.env` file from the main script's |
||||
real directory (the CWD in the REPL), if one exists. Variables already set in |
||||
the real environment take precedence. This is the intended place to set |
||||
`LUNA_INCLUDE_PATHS` for a project: |
||||
|
||||
``` |
||||
# myproject/.env |
||||
LUNA_INCLUDE_PATHS=/home/me/lua-libs:/opt/shared-lua |
||||
``` |
||||
|
||||
Since the script's symlink is resolved first, `myproject/main.lua` symlinked |
||||
as `~/.local/bin/mytool` still picks up `myproject/.env`. |
||||
|
||||
## Semantics |
||||
|
||||
- **A module file is a plain chunk.** Its `local`s are private to the file; it |
||||
shares globals with the rest of the program; whatever it returns is the |
||||
module's value (conventionally a table). Like stock Lua, the chunk receives |
||||
the module name and the resolved file path as `...`. |
||||
- **Executed once.** The return value is cached by the file's canonical path, |
||||
so two names or symlinks reaching the same file share one instance, and |
||||
`require` of an already-loaded module is just a table lookup. A module that |
||||
returns nothing is cached as `true`. |
||||
- **Return values.** `require` returns the module value and, for file modules, |
||||
the resolved path as a second value. |
||||
- **Built-ins.** `require "http"`, `require "utils"`, etc. return the same |
||||
table as the corresponding global, so code written for stock Lua's habits |
||||
works unchanged. |
||||
- **Cycles.** A require cycle (`a` requires `b` requires `a`) raises a |
||||
`circular require` error instead of recursing forever. |
||||
- **Errors.** If a module raises while loading, the error propagates to the |
||||
requiring code and nothing is cached — a later `require` retries the load. |
||||
- Module files may use the async stdlib (`http`, `os.sleep`, …) at top level, |
||||
and may start with a shebang and/or UTF-8 BOM, same as the main script. |
||||
|
||||
## Editor / LSP setup |
||||
|
||||
The resolution rules match the |
||||
[Lua language server](https://luals.github.io/) defaults, so a minimal |
||||
`.luarc.json` next to your project gives go-to-definition and type inference |
||||
across `require` boundaries: |
||||
|
||||
```json |
||||
{ |
||||
"runtime.version": "Lua 5.4", |
||||
"runtime.path": ["?.lua", "?/init.lua"], |
||||
"workspace.library": ["/home/me/lua-libs", "/opt/shared-lua"] |
||||
} |
||||
``` |
||||
|
||||
List the same directories in `workspace.library` as in `LUNA_INCLUDE_PATHS`; |
||||
modules under the script's own directory are found via the workspace root. |
||||
|
||||
## Example layout |
||||
|
||||
``` |
||||
myproject/ |
||||
├── main.lua -- #!/usr/bin/env luna; symlinked to ~/.local/bin/mytool |
||||
├── .env -- LUNA_INCLUDE_PATHS=... |
||||
├── .luarc.json |
||||
└── lib/ |
||||
├── helper.lua -- require "lib.helper" |
||||
└── db/ |
||||
└── init.lua -- require "lib.db" |
||||
``` |
||||
@ -1,546 +0,0 @@ |
||||
# `tui` |
||||
|
||||
The `tui` global is for simple terminal interactivity: asking the user |
||||
questions (**prompts**), producing colored, styled output (**`tui.styled`**), |
||||
and printing ready-made **message blocks** (`tui.success`, `tui.outlineError`, |
||||
…). Prompts render an interactive one-line UI on the terminal — a yes/no |
||||
question, a text field, a filterable option list, a calendar — and return the |
||||
answer as a plain Lua value. It is always available as the global `tui`. |
||||
|
||||
```lua |
||||
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>" .. tui.escape(name) .. "</>")) |
||||
end |
||||
``` |
||||
|
||||
## Prompts |
||||
|
||||
All prompts share one shape: |
||||
|
||||
```lua |
||||
tui.promptXxx(text, default, opts) |
||||
``` |
||||
|
||||
- `text` — the question shown to the user (required string). |
||||
- `default` — the prompt's default value; `nil` means none. (Exceptions: |
||||
`promptSelect` matches the default against the options *by value*; |
||||
`promptSecret` has no default parameter at all.) |
||||
- `opts` — an optional table of extended options, listed per prompt below. |
||||
Every prompt accepts `help` (a hint line shown below the prompt). Unknown |
||||
keys are an error, so typos fail loudly. |
||||
|
||||
Shared behavior: |
||||
|
||||
- **Esc returns `nil`.** Declining a prompt is a normal, checkable outcome — |
||||
this holds for `tui.confirm` too, since `false` ("answered no") is distinct |
||||
from `nil` ("didn't answer"). |
||||
- **Ctrl-C raises an error** (`tui.<fn>: interrupted`), stopping the script |
||||
unless trapped with `pcall`/`utils.try`. |
||||
- **A terminal is required.** Prompting with stdin/stdout redirected raises |
||||
`tui.<fn>: not a terminal`. |
||||
- **Prompts are async.** Like `os.sleep` or `http`, a prompt suspends only the |
||||
calling coroutine (see [Concurrency](concurrency.md)). Prompts from |
||||
concurrent `task.join` branches are serialized — one owns the terminal at a |
||||
time — but `print`/`log` output from sibling coroutines can still interleave |
||||
visually with a live prompt. |
||||
- **Validation options re-prompt inline.** Constraint options like `min`, |
||||
`maxLen` or `minSelected` are enforced by the prompt UI itself (an inline |
||||
message, then the user tries again) — they do not raise Lua errors. |
||||
Malformed *arguments* (a default that is not among the options, `min > max`, |
||||
a bad date string) raise immediately, before the terminal is touched. |
||||
|
||||
### `tui.confirm(text, default, opts)` → `boolean | nil` |
||||
|
||||
Yes/no question, answered with `y`/`n`. |
||||
|
||||
```lua |
||||
local ok = tui.confirm("Deploy to production?", false, { help = "y/N" }) |
||||
if ok == nil then return end -- Esc: user backed out |
||||
``` |
||||
|
||||
- `default` — boolean returned when the user just presses Enter; with `nil` |
||||
the user must type an answer. |
||||
- opts: `help`, `placeholder`. |
||||
|
||||
### `tui.promptText(text, default, opts)` → `string | nil` |
||||
|
||||
One-line text input. |
||||
|
||||
```lua |
||||
local host = tui.promptText("Host?", "localhost", { |
||||
placeholder = "hostname or IP", |
||||
suggestions = { "localhost", "db.internal", "cache.internal" }, |
||||
}) |
||||
``` |
||||
|
||||
- `default` — pre-filled **editable** text (the user edits it in place). |
||||
- opts: |
||||
- `placeholder` — dim hint shown while the input is empty. |
||||
- `default` — string returned when the input is submitted empty (shown as a |
||||
hint; distinct from the pre-filled positional default, and both can be |
||||
used together). |
||||
- `minLen`, `maxLen` — length bounds (characters), enforced inline. |
||||
- `suggestions` — array of strings offered as autocompletion |
||||
(case-insensitive substring match; Tab completes the highlighted one). |
||||
- `pageSize` — how many suggestions are visible at once. |
||||
|
||||
### `tui.promptLongText(text, default, opts)` → `string | nil` |
||||
|
||||
Multi-line input via an external editor: opens `$VISUAL`/`$EDITOR` (falling |
||||
back to a platform default) on a temp file and returns its contents on save. |
||||
|
||||
```lua |
||||
local body = tui.promptLongText("Release notes", "## Changes\n\n", { extension = ".md" }) |
||||
``` |
||||
|
||||
- `default` — text the editor buffer starts with. |
||||
- opts: |
||||
- `extension` — temp-file extension (drives editor syntax highlighting); |
||||
a missing leading dot is added, default `.txt`. |
||||
- `editor` — editor command to use instead of `$VISUAL`/`$EDITOR`. |
||||
|
||||
### `tui.promptSelect(text, default, opts)` → `string | string[] | nil` |
||||
|
||||
Pick from a list. The third argument is **required** — it carries the options. |
||||
The list is filterable by typing (fuzzy match); arrows or `vimMode` keys move |
||||
the cursor. |
||||
|
||||
```lua |
||||
local color = tui.promptSelect("Color?", "green", { options = { "red", "green", "blue" } }) |
||||
|
||||
local picks = tui.promptSelect("Toppings?", { "cheese" }, { |
||||
options = { "cheese", "ham", "mushrooms", "olives" }, |
||||
multiple = true, |
||||
minSelected = 1, |
||||
}) |
||||
``` |
||||
|
||||
- `default` — matched against the options **by value**; a value that is not |
||||
among the options raises an error (a stale default is a script bug). Single |
||||
mode: a string (the cursor starts there). Multiple mode: a string or an |
||||
array of strings (pre-checked). |
||||
- opts: |
||||
- `options` — non-empty array of strings (**required**). |
||||
- `multiple` — `true` switches to checkbox-style multi-select (Space |
||||
toggles, Enter submits) and the result becomes an array of the chosen |
||||
strings (possibly empty). |
||||
- `pageSize` — visible rows (default 7). |
||||
- `vimMode` — `hjkl` navigation. |
||||
- `filter` — set `false` to disable the type-to-filter input. |
||||
- multiple-only: `minSelected` / `maxSelected` (selection-count bounds, |
||||
enforced inline), `allSelected` (start with everything checked). |
||||
|
||||
### `tui.promptSecret(text, opts)` → `string | nil` |
||||
|
||||
Secret input. Deliberately has **no default parameter**. |
||||
|
||||
```lua |
||||
local secret = tui.promptSecret("API token", { confirm = true, minLen = 8 }) |
||||
``` |
||||
|
||||
- opts: |
||||
- `confirm` — ask twice and require both entries to match (default `false`). |
||||
- `display` — `"masked"` (default, `*` per character), `"hidden"` (no echo |
||||
at all), or `"full"` (plain text). |
||||
- `toggle` — allow Ctrl-R to toggle revealing the input. |
||||
- `minLen` — minimum length, enforced inline. |
||||
|
||||
### `tui.promptNumber(text, default, opts)` → `number | nil` |
||||
### `tui.promptInt(text, default, opts)` → `integer | nil` |
||||
|
||||
Numeric input; the entry is parsed and re-prompted until it is a valid number |
||||
(`promptNumber`) or whole number (`promptInt`). |
||||
|
||||
```lua |
||||
local ratio = tui.promptNumber("Ratio?", 0.5, { min = 0, max = 1 }) |
||||
local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 }) |
||||
``` |
||||
|
||||
- `default` — returned when the input is submitted empty. `promptInt` |
||||
rejects a fractional default (`1.5`) at call time. |
||||
- opts: `placeholder`, `min`, `max` (bounds enforced inline; `min > max` is a |
||||
call-time error). |
||||
|
||||
### `tui.promptDate(text, default, opts)` → `string | nil` |
||||
|
||||
Date picker: an interactive calendar navigated with the arrow keys. Dates |
||||
cross the API as `"YYYY-MM-DD"` strings in both directions. |
||||
|
||||
```lua |
||||
local day = tui.promptDate("Start date?", "2026-07-06", { |
||||
min = "2026-01-01", |
||||
max = "2026-12-31", |
||||
weekStart = "monday", |
||||
}) |
||||
``` |
||||
|
||||
- `default` — the initially selected date, `"YYYY-MM-DD"`. |
||||
- opts: |
||||
- `min`, `max` — selectable range, `"YYYY-MM-DD"`. |
||||
- `weekStart` — first day of the calendar week: a weekday name like |
||||
`"monday"` or `"sun"` (default Sunday). |
||||
|
||||
## `tui.styled(markup)` → `string` |
||||
|
||||
Renders text with HTML-like style tags into a string with ANSI escape |
||||
sequences, ready to `print`. The tag syntax follows |
||||
[Symfony console formatting](https://symfony.com/doc/current/console/coloring.html). |
||||
|
||||
```lua |
||||
print(tui.styled("<error> FAIL </error> expected <info>42</info>, see " .. |
||||
"<href=https://example.com/docs>the docs</> for <fg=#8888ff;options=bold>details</>")) |
||||
``` |
||||
|
||||
### Built-in styles |
||||
|
||||
| Tag | Rendering | |
||||
|-----|-----------| |
||||
| `<info>` | bold green text | |
||||
| `<error>` | white on red | |
||||
| `<b>` | **bold** | |
||||
| `<i>` | *italic* | |
||||
| `<u>` | underscore | |
||||
| `<s>` | ~~strikethrough~~ | |
||||
|
||||
(Symfony's `<comment>` and `<question>` are deliberately not built in — add |
||||
them with `tui.addPreset` if you want them.) |
||||
|
||||
### Inline styles |
||||
|
||||
`<fg=COLOR>`, `<bg=COLOR>` and `<options=...>` can be combined in one tag, |
||||
separated by `;`: |
||||
|
||||
```lua |
||||
tui.styled("<fg=green>ok</> <bg=yellow;options=bold>careful</> <fg=#c0392b>brick red</>") |
||||
``` |
||||
|
||||
- Colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, |
||||
`white`, `gray`, their `bright-*` variants, `default` (the terminal's own |
||||
color — as an *inner* tag it resets an inherited color), and hex |
||||
`#rrggbb` / `#rgb` (truecolor). |
||||
- Options (comma-separated): `bold`, `italic`, `underscore`, `blink`, |
||||
`reverse`, `conceal`, `strikethrough`. |
||||
- The syntax is strict: no spaces around `=` or `;`. |
||||
|
||||
### Shorthand |
||||
|
||||
Bare tokens work without the `fg=`/`options=` keys: a color names the |
||||
foreground, `on-<color>` the background, and option names apply directly. |
||||
Shorthand mixes freely with `key=value` parts and built-in names: |
||||
|
||||
```lua |
||||
tui.styled("<red;on-white;bold>alarm</> <#f0a;u>pink underline</> <info;b>bold green</>") |
||||
``` |
||||
|
||||
### Custom presets — `tui.addPreset(name, style)` |
||||
|
||||
Register your own tag name for any style spec. The spec uses the same syntax |
||||
as tags (attributes, shorthand, or another preset/built-in name); the name |
||||
must be identifier-like (start with a letter; letters, digits, `-`, `_`). |
||||
|
||||
```lua |
||||
tui.addPreset("keyword", "fg=white;bg=magenta") |
||||
tui.addPreset("em", "i;bold") |
||||
print(tui.styled("the <keyword>local</keyword> keyword is <em>important</em>")) |
||||
tui.note("see the <keyword>docs</keyword>") -- NOT styled: block text is plain |
||||
tui.block("styled block", { style = "keyword" }) -- but block styles resolve presets |
||||
``` |
||||
|
||||
Re-registering a name overwrites it, and a preset may shadow a built-in name |
||||
or shorthand token (e.g. `addPreset("info", "fg=blue")` restyles `<info>`). |
||||
|
||||
### Closing tags and nesting |
||||
|
||||
`</>` closes the innermost open style; named forms (`</info>`, |
||||
`</fg=green>`) work too and do the same thing. Styles nest — an inner tag |
||||
overrides only what it sets and inherits the rest: |
||||
|
||||
```lua |
||||
tui.styled("<error>fail in <options=bold>step 2</> of job</error>") |
||||
-- "step 2" is bold white-on-red; the rest of the sentence plain white-on-red |
||||
``` |
||||
|
||||
### Hyperlinks |
||||
|
||||
`<href=URL>` emits an [OSC-8 hyperlink](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda); |
||||
supporting terminals show clickable text, others show just the text: |
||||
|
||||
```lua |
||||
tui.styled("see <href=https://example.com>the manual</>") |
||||
``` |
||||
|
||||
### Action tags: cursor movement and clearing |
||||
|
||||
Besides styles, markup can move the cursor and clear parts of the display — |
||||
handy for single-line redraws without string-formatting escape codes by hand: |
||||
|
||||
```lua |
||||
for i = 1, 100 do |
||||
tui.raw(tui.styled(("<clear=line>progress <info>%d%%</info>"):format(i))) |
||||
-- ... work ... |
||||
end |
||||
tui.raw("\n") |
||||
``` |
||||
|
||||
| Tag | Effect | |
||||
|-----|--------| |
||||
| `<up>` `<down>` `<left>` `<right>` | move the cursor by one cell | |
||||
| `<up=3>` … | move by *n* cells | |
||||
| `<row=3;col=2>` | absolute move, 1-based; each axis works alone too | |
||||
| `<start>` | column 1 of the current line | |
||||
| `<home>` | top-left corner | |
||||
| `<clear=line>` | clear the whole line, cursor to column 1 | |
||||
| `<clear=start>` | clear to the start of the line, cursor to column 1 | |
||||
| `<clear=end>` | clear to the end of the line, cursor stays put | |
||||
| `<clear=screen>` | clear the whole screen, cursor home | |
||||
| `<clear=up>` | clear above the cursor, cursor home | |
||||
| `<clear=down>` | clear below the cursor, cursor stays put | |
||||
|
||||
Unlike style tags, action tags emit their escape sequence *in place* — they |
||||
don't enclose text and have no closing form (`</up>` is literal text). Ops |
||||
combine in one tag, applied left to right: `<up;clear=line>` moves up one |
||||
line and blanks it. Whole and to-start clears also return the cursor (column |
||||
1 or home, matching `tui.clearLine`/`tui.clearScreen`), so the redraw starts |
||||
from a known spot; the `end`/`down` variants leave it in place. |
||||
|
||||
Like all escape output, action tags are dropped when colors are off (pipes, |
||||
`NO_COLOR`) — piped output never contains control bytes. A custom preset may |
||||
shadow an action name (`addPreset("up", …)` turns `<up>` into a style tag). |
||||
|
||||
Stateful operations — the alternate screen, cursor visibility, window title — |
||||
are deliberately **not** tags; they need cleanup tracking and live in the |
||||
[terminal control functions](#terminal-control) below. |
||||
|
||||
### Escaping and invalid tags |
||||
|
||||
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 |
||||
|
||||
When output is piped or `NO_COLOR` is set, `tui.styled` returns the plain |
||||
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, |
||||
word-wrapped block, padded to the terminal width (capped at 120 columns) and |
||||
painted with a background color. They **print directly to stdout**, surrounded |
||||
by blank lines. When output is piped (or `NO_COLOR` is set) they degrade to |
||||
plain aligned text. |
||||
|
||||
```lua |
||||
tui.success("Deployment finished") |
||||
tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" }) |
||||
tui.error("Build failed") |
||||
``` |
||||
|
||||
| Function | Label | Style | |
||||
|---|---|---| |
||||
| `tui.success(message, opts)` | `[OK]` | black on green | |
||||
| `tui.error(message, opts)` | `[ERROR]` | white on red | |
||||
| `tui.warning(message, opts)` | `[WARNING]` | black on yellow | |
||||
| `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)` | — | gray text | |
||||
| `tui.block(message, opts)` | — | generic: everything from opts | |
||||
|
||||
- `message` — a string or an array of strings (rendered into one block, |
||||
separated by a blank line). Treated as **plain text**: markup tags are not |
||||
interpreted here (compose with `tui.styled` on your own lines instead), and |
||||
lines wrap by word to fit the width. |
||||
- `opts` (each overrides the preset's default): |
||||
- `label` — the `[LABEL]` text; continuation lines align under it. |
||||
- `style` — same syntax as a `styled` tag body: a preset or built-in name |
||||
(`"error"`, anything from `tui.addPreset`), a spec (`"fg=black;bg=cyan"`), |
||||
or shorthand (`"black;on-cyan"`). An invalid style is an error. |
||||
- `prefix` — string prepended to every line (default `" "`). |
||||
- `padding` — blank styled line above and below the text (only applies when |
||||
colors are on; without a background it would just be empty lines). |
||||
|
||||
### Outline blocks |
||||
|
||||
The same messages as a bordered box — colored **borders** instead of colored |
||||
backgrounds, which stays readable on any terminal color scheme: |
||||
|
||||
``` |
||||
┌─ Success ──────────────────────────┐ |
||||
│ │ |
||||
│ All tests passed │ |
||||
│ │ |
||||
└────────────────────────────────────┘ |
||||
``` |
||||
|
||||
```lua |
||||
tui.outlineSuccess("All tests passed") |
||||
tui.outlineError("Build failed", { title = "Compile error" }) |
||||
tui.outlineBlock("Anything", { title = "Stats", style = "fg=magenta" }) |
||||
``` |
||||
|
||||
| Function | Fallback title | Border style | |
||||
|---|---|---| |
||||
| `tui.outlineSuccess` | Success | green | |
||||
| `tui.outlineError` | Error | red | |
||||
| `tui.outlineWarning` | Warning | yellow | |
||||
| `tui.outlineNote` | Note | blue | |
||||
| `tui.outlineInfo` | Info | green | |
||||
| `tui.outlineCaution` | Caution | red | |
||||
| `tui.outlineBlock` | — | — (generic) | |
||||
|
||||
- `opts`: `title` (shown in the top border; overrides the preset's fallback), |
||||
`style` (border color, tag-body syntax), `padding` (blank line above/below |
||||
the content **inside** the box, default `true`). |
||||
|
||||
## Low-level output |
||||
|
||||
### `tui.raw(...)` |
||||
|
||||
Writes its arguments to stdout with **no newline and no separator** (each is |
||||
converted with `tostring`), then flushes. This is the building block for |
||||
progress-style output that redraws one line: |
||||
|
||||
```lua |
||||
for i = 1, 100 do |
||||
tui.raw("\rprocessing ", i, "/100") |
||||
-- ... work ... |
||||
end |
||||
tui.raw("\n") |
||||
``` |
||||
|
||||
### `tui.screenInfo()` → `table` |
||||
|
||||
Facts about the terminal, for scripts that render their own UI (progress |
||||
bars, right-aligned text, …): |
||||
|
||||
| Field | Meaning | |
||||
|---|---| |
||||
| `width`, `height` | terminal size in cells; `nil` when there is no terminal to measure | |
||||
| `tty` | whether stdout is a terminal (`false` when piped) | |
||||
| `colors` | whether styled output will actually emit colors (same detection as `tui.styled`) | |
||||
|
||||
## Terminal control |
||||
|
||||
Direct terminal manipulation for scripts that render their own UI: cursor |
||||
movement, clearing, the alternate screen, scroll regions, the window title |
||||
and the clipboard. These are the standard xterm-style escape sequences, |
||||
wrapped as functions. |
||||
|
||||
```lua |
||||
tui.altScreen(true) |
||||
tui.clearScreen() |
||||
tui.showCursor(false) |
||||
tui.moveTo(2, 4) |
||||
tui.raw(tui.styled("<info>fullscreen!</info>")) |
||||
os.sleep(2) |
||||
-- no explicit restore needed: the runtime cleans up on exit |
||||
``` |
||||
|
||||
Shared behavior: |
||||
|
||||
- **No-ops without a terminal.** When stdout is not a tty, every function |
||||
validates its arguments and then does nothing — piped output never |
||||
contains control bytes. This is a plain tty check (unlike color |
||||
detection): `NO_COLOR` turns off colors, not cursor control. |
||||
- **Bad arguments always raise**, tty or not — typos fail loudly in pipes |
||||
and CI too. |
||||
- **Automatic cleanup.** The runtime tracks the alternate screen, a hidden |
||||
cursor, a changed cursor style, a scroll region and a pushed window title, |
||||
and restores all of them when the script ends — on normal exit, on error |
||||
(the message prints on the normal screen), and on a crash. Scripts should |
||||
still restore what they change for mid-script tidiness, but cannot wreck |
||||
the terminal by forgetting. |
||||
|
||||
### Cursor |
||||
|
||||
- **`tui.moveTo(row, col)`** — absolute move, 1-based (row 1, column 1 is |
||||
the top-left corner). Passing `nil` for one axis keeps it: |
||||
`tui.moveTo(5)` jumps to row 5, `tui.moveTo(nil, 1)` to column 1. |
||||
- **`tui.move(dx, dy)`** — relative move: positive `dx` right, positive `dy` |
||||
down; negative values go the other way, `nil` counts as 0. The terminal |
||||
clamps at the screen edges. |
||||
- **`tui.saveCursor()` / `tui.restoreCursor()`** — save and restore the |
||||
cursor position (one slot; a save overwrites the previous one). |
||||
- **`tui.cursorPos()` → `row, col`** — ask the terminal where the cursor is |
||||
(1-based). Returns `nil` when there is no terminal or it doesn't answer. |
||||
Like the prompts this is async and serialized against them, so a |
||||
concurrent prompt can't eat the terminal's reply. |
||||
- **`tui.showCursor(visible)`** — hide (`false`) or show (`true` or no |
||||
argument) the cursor. A hidden cursor is re-shown on exit. |
||||
- **`tui.cursorStyle(style, opts)`** — set the cursor shape: `"block"`, |
||||
`"underline"` or `"bar"`, with `{ blink = true }` for the blinking |
||||
variant. No arguments reset to the terminal default (also done on exit). |
||||
|
||||
### Clearing |
||||
|
||||
- **`tui.clearLine(mode)`** / **`tui.clearScreen(mode)`** — erase the |
||||
current line / the screen. `mode` selects what: `0` or `nil` = whole, |
||||
`-1` = up to the cursor, `1` = from the cursor to the end. Whole and |
||||
to-start clears also return the cursor to a known spot (column 1 / home); |
||||
to-end clears leave it in place. |
||||
|
||||
### Alternate screen |
||||
|
||||
- **`tui.altScreen(on)`** — switch to (`true`) or back from (`false`) the |
||||
alternate screen buffer, the full-screen-app mode where the normal |
||||
scrollback stays untouched. Switching in an already-active direction is |
||||
ignored, and the runtime always switches back on exit. |
||||
|
||||
### Scrolling |
||||
|
||||
- **`tui.scrollRegion(top, bottom)`** — restrict scrolling to the given |
||||
rows (1-based, inclusive; `top < bottom`) — the basis for fixed |
||||
headers/footers. Setting a region moves the cursor home. With no arguments |
||||
the region resets to the full screen (also done on exit). |
||||
- **`tui.scroll(n)`** — scroll the scroll region: positive `n` scrolls the |
||||
content up by `n` lines, negative down, `0` does nothing. The cursor does |
||||
not move. |
||||
|
||||
### Terminal integration |
||||
|
||||
- **`tui.setTitle(text)`** — set the terminal window title. The first call |
||||
saves the current title on the terminal's title stack, and the runtime |
||||
restores it on exit. Control characters in the title are an error. |
||||
- **`tui.setClipboard(text)`** — copy `text` into the system clipboard via |
||||
the terminal (OSC 52). Write-only: reading the clipboard is disabled by |
||||
most terminals for good reasons. Works over SSH in supporting terminals; |
||||
some terminals require enabling clipboard access in their settings. |
||||
- **`tui.reset()`** — soft-reset the terminal (DECSTR): un-sticks stuck |
||||
attributes, a garbled charset, a forgotten scroll region — without |
||||
clearing the screen. The heavy-handed fixer for "my output looks insane". |
||||
|
||||
## Notes |
||||
|
||||
- **Always loaded.** No `require`; `tui` is a global in every script |
||||
(`require "tui"` also works and returns the same table). |
||||
- **Prompts need a terminal.** In pipes, cron, CI etc. every prompt raises |
||||
`not a terminal`; `tui.styled` and the blocks still work and degrade to |
||||
plain text, and the terminal-control functions become no-ops. |
||||
- **Progress bars** can be built from `tui.raw` + `tui.screenInfo` + the |
||||
`<clear=line>` action tag; a ready-made widget may come later. |
||||
- **Key events** (raw keyboard input, mouse tracking) are not exposed yet; |
||||
prompts are the supported way to read input. |
||||
@ -1,36 +0,0 @@ |
||||
#!/usr/bin/env luna |
||||
-- Showcase os.args(): the command-line arguments passed to the script. |
||||
-- |
||||
-- Try: |
||||
-- luna lua/demo/args.lua greet --name=World extra |
||||
-- luna --sandbox lua/demo/args.lua -- greet --name=World |
||||
-- or make the file executable and call it directly (the shebang works too): |
||||
-- chmod +x lua/demo/args.lua |
||||
-- ./lua/demo/args.lua greet --name=World |
||||
|
||||
local args = os.args() |
||||
|
||||
print("os.args() = " .. utils.dump(args)) |
||||
|
||||
if #args == 0 then |
||||
print("no arguments given — try: luna lua/demo/args.lua greet --name=World extra") |
||||
return |
||||
end |
||||
|
||||
-- A tiny argument parser: --key=value pairs become options, the rest positionals. |
||||
local opts, positional = {}, {} |
||||
for _, arg in ipairs(args) do |
||||
local key, value = arg:match("^%-%-([%w-]+)=(.*)$") |
||||
if key then |
||||
opts[key] = value |
||||
else |
||||
table.insert(positional, arg) |
||||
end |
||||
end |
||||
|
||||
print("options = " .. utils.dump(opts)) |
||||
print("positionals = " .. utils.dump(positional)) |
||||
|
||||
if positional[1] == "greet" then |
||||
print(("Hello, %s!"):format(opts.name or "stranger")) |
||||
end |
||||
@ -1,124 +0,0 @@ |
||||
-- The Mandelbrot set rendered as colored ASCII art, drawn row by row with |
||||
-- the tui positioning tools (alternate screen, moveTo, clearScreen), then a |
||||
-- short animated zoom into "seahorse valley". When output is piped, it |
||||
-- degrades to a single plain-text render. |
||||
-- |
||||
-- Run with: cargo run -- lua/demo/mandelbrot.lua |
||||
|
||||
-- One character + color per escape-speed bucket, slowest to fastest. |
||||
local CHARS = { ".", ":", "-", "=", "+", "*", "#", "%", "@" } |
||||
local COLORS = { |
||||
"#1e3a8a", "#1d4ed8", "#0284c7", "#0d9488", "#16a34a", |
||||
"#84cc16", "#eab308", "#f97316", "#ef4444", |
||||
} |
||||
|
||||
--- How many iterations until the point escapes, or nil if it stays bounded |
||||
--- (i.e. belongs to the set). |
||||
local function escapeTime(cx, cy, maxIter) |
||||
local x, y = 0, 0 |
||||
for i = 1, maxIter do |
||||
local x2, y2 = x * x, y * y |
||||
if x2 + y2 > 4 then |
||||
return i |
||||
end |
||||
y = 2 * x * y + cy |
||||
x = x2 - y2 + cx |
||||
end |
||||
return nil |
||||
end |
||||
|
||||
--- Render one row as a styled string, batching same-colored runs into a |
||||
--- single tag so the escape-code overhead stays small. |
||||
local function renderRow(cy, xMin, dx, cols, maxIter) |
||||
local parts, run, runColor = {}, {}, nil |
||||
local function flush() |
||||
if #run == 0 then |
||||
return |
||||
end |
||||
local text = table.concat(run) |
||||
parts[#parts + 1] = runColor and ("<%s>%s</>"):format(runColor, text) or text |
||||
run = {} |
||||
end |
||||
for col = 0, cols - 1 do |
||||
local i = escapeTime(xMin + col * dx, cy, maxIter) |
||||
local ch, color = " ", nil -- inside the set: leave the cell empty |
||||
if i then |
||||
local idx = math.clamp(math.floor(math.sqrt(i / maxIter) * #CHARS) + 1, 1, #CHARS) |
||||
ch, color = CHARS[idx], COLORS[idx] |
||||
end |
||||
if color ~= runColor then |
||||
flush() |
||||
runColor = color |
||||
end |
||||
run[#run + 1] = ch |
||||
end |
||||
flush() |
||||
return tui.styled(table.concat(parts)) |
||||
end |
||||
|
||||
--- Draw one full frame. Interactively each row is placed with tui.moveTo |
||||
--- (row 1 is a header line); piped, rows are just printed in order. |
||||
local function drawFrame(cx, cy, scale, maxIter, cols, rows, interactive) |
||||
-- Terminal cells are roughly twice as tall as they are wide, so step |
||||
-- twice as far in y to keep the set round. |
||||
local dx = scale / cols |
||||
local dy = dx * 2 |
||||
local xMin = cx - dx * cols / 2 |
||||
local yMin = cy - dy * rows / 2 |
||||
|
||||
if interactive then |
||||
tui.moveTo(1, 1) |
||||
tui.raw(tui.styled( |
||||
("<clear=line><b>Mandelbrot</b> center %.9f%+.9fi width %.2e max iter %d") |
||||
:format(cx, cy, scale, maxIter) |
||||
)) |
||||
end |
||||
for row = 1, rows do |
||||
local line = renderRow(yMin + (row - 1) * dy, xMin, dx, cols, maxIter) |
||||
if interactive then |
||||
tui.moveTo(row + 1, 1) |
||||
tui.raw(line) |
||||
else |
||||
print(line) |
||||
end |
||||
end |
||||
end |
||||
|
||||
tui.setTitle("mandelbrot") |
||||
|
||||
local info = tui.screenInfo() |
||||
local cols = info.width or 100 |
||||
local rows = (info.height or 32) - 2 -- header + one spare line for the cursor |
||||
|
||||
-- The classic full view: x roughly [-2.1, 1.1]. |
||||
local fullCx, fullCy, fullScale = -0.5, 0, 3.2 |
||||
|
||||
if not info.tty then |
||||
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, false) |
||||
return |
||||
end |
||||
|
||||
tui.altScreen(true) |
||||
tui.clearScreen() |
||||
tui.showCursor(false) |
||||
|
||||
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, true) |
||||
os.sleep(2) |
||||
|
||||
-- Zoom into "seahorse valley" between the main cardioid and the period-2 |
||||
-- bulb, cranking up the iteration budget as detail gets finer. |
||||
local cx, cy = -0.743643887, 0.131825904 |
||||
local scale = fullScale |
||||
for frame = 1, 40 do |
||||
scale = scale * 0.85 |
||||
drawFrame(cx, cy, scale, 60 + frame * 8, cols, rows, true) |
||||
os.sleep(0.03) |
||||
end |
||||
os.sleep(2) |
||||
|
||||
tui.altScreen(false) |
||||
tui.showCursor(true) |
||||
tui.success(("zoomed to a window %.2e units wide — that's about %dx magnification"):format( |
||||
scale, |
||||
math.round(fullScale / scale) |
||||
)) |
||||
@ -1,61 +0,0 @@ |
||||
-- Smoke-test for the stdlib extensions. |
||||
|
||||
print("=== math ===") |
||||
print("round(2.5) =", math.round(2.5)) -- 3 |
||||
print("round(2.567,2) =", math.round(2.567, 2)) -- 2.57 |
||||
print("clamp(10,0,5) =", math.clamp(10, 0, 5)) -- 5 |
||||
print("isFinite(1/0) =", math.isFinite(1 / 0)) -- false |
||||
print("isFinite(3.14) =", math.isFinite(3.14)) -- true |
||||
print("sign(-7) =", math.sign(-7)) -- -1 |
||||
print("scale(5,0,10,0,100) =", math.scale(5, 0, 10, 0, 100)) -- 50.0 |
||||
|
||||
print("\n=== table ===") |
||||
local t = { 3, 1, 4, 1, 5 } |
||||
print("sum =", table.sum(t)) -- 14 |
||||
print("min =", table.min(t)) -- 1 |
||||
print("max =", table.max(t)) -- 5 |
||||
print("mean =", table.mean(t)) -- 2.8 |
||||
local evens = table.ifilter(t, function(v) |
||||
return v % 2 == 0 |
||||
end) |
||||
print("evens =", table.concat(evens, ",")) -- 4 |
||||
local doubled = table.map({ 1, 2, 3 }, function(v) |
||||
return v * 2 |
||||
end) |
||||
print("doubled=", table.concat(doubled, ",")) -- 2,4,6 |
||||
print("deepCopy ok:", table.deepCopy({ x = { y = 1 } }).x.y == 1) |
||||
print("merged =", table.concat(table.merge({ 1, 2 }, { 3, 4 }), ",")) -- 1,2,3,4 |
||||
|
||||
print("\n=== utils ===") |
||||
print("dump(nil) =", utils.dump(nil)) |
||||
print("dump({1,2}) =", utils.dump({ 1, 2 })) |
||||
print("dump({x=1}) =", utils.dump({ x = 1 })) |
||||
print("isNull(NULL) =", utils.isNull(utils.NULL)) |
||||
print("isNull(nil) =", utils.isNull(nil)) |
||||
|
||||
local json = utils.toJSON({ name = "Alice", age = 30, tags = { "a", "b" } }) |
||||
print("toJSON =", json) |
||||
local parsed = utils.fromJSON(json) |
||||
print("fromJSON name=", parsed.name, "age=", parsed.age) |
||||
|
||||
local v, err = utils.try(function() |
||||
return 42 |
||||
end) |
||||
print("try ok: v=", v, "err=", err) |
||||
local v2, err2 = utils.try(function() |
||||
error("oops") |
||||
end) |
||||
print("try err: v=", v2, "err ~= nil:", err2 ~= nil) |
||||
|
||||
print("\n=== os ===") |
||||
local t1 = os.microtime() |
||||
os.sleep(0.01) |
||||
local t2 = os.microtime() |
||||
print("microtime ok:", t2 > t1) |
||||
print("clock >= 0:", os.clock() >= 0) |
||||
|
||||
print("\n=== log (set LUNA_LOG=info to see output on stderr) ===") |
||||
log.info("hello from log.info") |
||||
log.warn("hello from log.warn") |
||||
|
||||
print("\nAll checks passed.") |
||||
@ -1,107 +0,0 @@ |
||||
-- Showcase of the tui module's interactive prompts. Requires a terminal. |
||||
-- Esc returns nil from any prompt (handled below); Ctrl-C aborts the script. |
||||
-- |
||||
-- Run with: cargo run -- lua/tui_prompts.lua |
||||
|
||||
local function banner(s) |
||||
print("\n=== " .. s .. " ===") |
||||
end |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("1. confirm — yes/no") |
||||
---------------------------------------------------------------------- |
||||
local go = tui.confirm("Run the whole showcase?", true, { help = "Esc anywhere skips that prompt" }) |
||||
if go == nil then |
||||
print("(Esc pressed — nil means the user backed out, false means they answered no)") |
||||
elseif not go then |
||||
return |
||||
end |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("2. promptText — defaults, placeholder, suggestions") |
||||
---------------------------------------------------------------------- |
||||
-- The positional default is pre-filled and editable in place. |
||||
local name = tui.promptText("Your name?", "ondra") or "anonymous" |
||||
|
||||
-- suggestions: type to filter (case-insensitive substring), Tab completes. |
||||
-- opts.default (unlike the positional one) is only used on empty submit. |
||||
local editor = tui.promptText("Favorite editor?", nil, { |
||||
placeholder = "type to autocomplete", |
||||
suggestions = { "vim", "neovim", "emacs", "helix", "vscode", "zed" }, |
||||
default = "vim", |
||||
}) |
||||
|
||||
-- minLen/maxLen re-prompt inline until satisfied. |
||||
local tag = tui.promptText("Release tag? (3-10 chars)", nil, { minLen = 3, maxLen = 10 }) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("3. promptSelect — single and multiple choice") |
||||
---------------------------------------------------------------------- |
||||
-- Single: returns the chosen string; the default sets the cursor. |
||||
local lang = tui.promptSelect("Favorite language?", "lua", { |
||||
options = { "lua", "rust", "c", "python", "javascript" }, |
||||
help = "type to filter the list", |
||||
}) |
||||
|
||||
-- Multiple: space toggles, enter submits; returns an array of strings. |
||||
local toppings = tui.promptSelect("Pizza toppings?", { "cheese" }, { |
||||
options = { "cheese", "ham", "mushrooms", "olives", "pineapple" }, |
||||
multiple = true, |
||||
minSelected = 1, |
||||
pageSize = 4, |
||||
}) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("4. Numbers and dates") |
||||
---------------------------------------------------------------------- |
||||
-- promptNumber accepts any number, promptInt only whole numbers; both |
||||
-- re-prompt inline when the input doesn't parse or is out of range. |
||||
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") --[[@as string]], { |
||||
min = os.date("%Y-01-01") --[[@as string]], |
||||
max = os.date("%Y-12-31") --[[@as string]], |
||||
weekStart = "monday", |
||||
}) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("5. promptSecret — hidden input") |
||||
---------------------------------------------------------------------- |
||||
-- No default parameter on purpose. display: "masked" (default) shows *, |
||||
-- "hidden" echoes nothing, "full" shows the text; toggle allows Ctrl-R. |
||||
local secret = tui.promptSecret("API token?", { minLen = 4, toggle = true }) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("6. promptLongText — opens $EDITOR") |
||||
---------------------------------------------------------------------- |
||||
local notes |
||||
if tui.confirm("Open your editor for release notes?", false) then |
||||
notes = tui.promptLongText("Release notes", "## " .. (tag or "next") .. "\n\n- ", { extension = ".md" }) |
||||
end |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("Summary") |
||||
---------------------------------------------------------------------- |
||||
local function show(k, v) |
||||
if v == nil then |
||||
v = "<gray;i>(skipped)</>" |
||||
elseif type(v) == "table" then |
||||
v = table.concat(v, ", ") |
||||
end |
||||
print(tui.styled(" <info>" .. k .. "</info>: " .. tostring(v))) |
||||
end |
||||
|
||||
show("name", name) |
||||
show("editor", editor) |
||||
show("tag", tag) |
||||
show("language", lang) |
||||
show("toppings", toppings) |
||||
show("quality", ratio) |
||||
show("port", port) |
||||
show("release day", day) |
||||
show("token", secret and string.rep("*", #secret)) |
||||
show("notes", notes and (#notes .. " chars")) |
||||
|
||||
tui.success("Showcase finished — see lua/tui_styles.lua for the output half of the module") |
||||
@ -1,91 +0,0 @@ |
||||
-- Showcase of the tui module's output features: styled markup, message |
||||
-- blocks, outline blocks, and the raw/screenInfo building blocks. |
||||
-- Non-interactive — safe to run piped (colors degrade to plain text). |
||||
-- |
||||
-- Run with: cargo run -- lua/tui_styles.lua |
||||
|
||||
local function banner(s) |
||||
print("\n=== " .. s .. " ===") |
||||
end |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("1. tui.styled — built-in tags") |
||||
---------------------------------------------------------------------- |
||||
print(tui.styled("<info>info is bold green</info> and <error> error is white on red </error>")) |
||||
-- HTML-like shortcuts. |
||||
print(tui.styled("<b>bold</b> <i>italic</i> <u>underscore</u> <s>strikethrough</s>")) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("2. Inline styles: fg / bg / options") |
||||
---------------------------------------------------------------------- |
||||
print(tui.styled("<fg=magenta>named colors</>, <fg=bright-cyan>bright variants</>, <fg=gray>gray</>")) |
||||
print(tui.styled("<fg=#c0392b>hex truecolor #c0392b</> and <fg=#0af>short #0af</>")) |
||||
print(tui.styled("<bg=blue>background</> <options=bold>bold</> <options=italic,strikethrough>both</>")) |
||||
print(tui.styled("<fg=black;bg=green;options=bold> all combined in one tag </>")) |
||||
-- Shorthand: bare colors are the foreground, on-<color> the background, |
||||
-- option names apply directly; mixes with built-in names and key=value. |
||||
print(tui.styled("<red;on-white;bold>alarm</> <#f0a;u>pink underline</> <info;b>bold green</>")) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("3. Custom presets — tui.addPreset") |
||||
---------------------------------------------------------------------- |
||||
tui.addPreset("keyword", "fg=white;bg=magenta") |
||||
tui.addPreset("em", "i;bold") -- specs can use shorthand and reference other presets |
||||
print(tui.styled("the <keyword>local</keyword> keyword is <em>important</em>")) |
||||
tui.block("presets work as block styles too", { style = "keyword" }) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("4. Nesting, links, escaping") |
||||
---------------------------------------------------------------------- |
||||
-- Inner tags override only what they set; the rest is inherited. |
||||
print(tui.styled("<error>outer <options=bold>bold inherits the red bg</> and back</error>")) |
||||
-- </> closes the innermost style; fg=default resets to the terminal color. |
||||
print(tui.styled("<fg=red>red <fg=default>terminal default</> red again</>")) |
||||
-- OSC-8 hyperlink (clickable in supporting terminals, plain text elsewhere). |
||||
print(tui.styled("read <href=https://example.com/docs>the docs</> for details")) |
||||
-- \< escapes a bracket; unknown tags pass through literally, never error. |
||||
print(tui.styled("literal \\<info> stays, <not-a-tag> too, a < b as well")) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("5. Message blocks (Symfony style)") |
||||
---------------------------------------------------------------------- |
||||
tui.success("Deployment finished without errors") |
||||
tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" }) |
||||
tui.error("Build failed") |
||||
tui.caution("This will overwrite production data") |
||||
tui.info("14 files processed") |
||||
tui.note("Long messages word-wrap to the terminal width and continuation lines stay aligned under the label lorem ipsum dolor sit amet") |
||||
tui.comment("git push --force-with-lease") |
||||
-- The generic form: everything is configurable. |
||||
tui.block( |
||||
"Custom label, style and prefix", |
||||
{ label = "HEY", style = "fg=black;bg=cyan", prefix = " > ", padding = true } |
||||
) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("6. Outline blocks (colored borders, plain content)") |
||||
---------------------------------------------------------------------- |
||||
tui.outlineSuccess("All tests passed") |
||||
tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset |
||||
tui.outlineBlock({ "Generic box.", "Multiple messages are separated by a blank line." }, { |
||||
title = "Stats", |
||||
style = "fg=magenta", |
||||
}) |
||||
|
||||
---------------------------------------------------------------------- |
||||
banner("7. tui.raw + tui.screenInfo — progress bar building blocks") |
||||
---------------------------------------------------------------------- |
||||
local info = tui.screenInfo() |
||||
print("screen:", (info.width or "?") .. "x" .. (info.height or "?"), "tty:", info.tty, "colors:", info.colors) |
||||
|
||||
-- A minimal in-place progress bar: \r rewinds the line, tui.raw prints |
||||
-- without a newline and flushes. Width adapts to the terminal; without a |
||||
-- tty the \r redraw trick is pointless, so print the final state once. |
||||
local width = math.clamp((info.width or 80) - 20, 10, 40) |
||||
local start = info.tty and 0 or width |
||||
for i = start, width do |
||||
local bar = string.rep("#", i) .. string.rep(".", width - i) |
||||
tui.raw("\r[", bar, "] ", math.floor(i / width * 100 + 0.5), "%") |
||||
os.sleep(0.02) |
||||
end |
||||
tui.raw("\n") |
||||
@ -1,75 +0,0 @@ |
||||
-- Showcase of the tui terminal-control functions and markup action tags: |
||||
-- inline redraws, the alternate screen, absolute cursor addressing, scroll |
||||
-- regions. Needs a real terminal — piped, everything degrades to no-ops. |
||||
-- |
||||
-- Run with: cargo run -- lua/tui_term.lua |
||||
|
||||
tui.setTitle("tui terminal demo") |
||||
|
||||
---------------------------------------------------------------------- |
||||
-- 1. Inline progress: redraw one line with the <clear=line> action tag |
||||
---------------------------------------------------------------------- |
||||
for i = 1, 30 do |
||||
tui.raw(tui.styled(("<clear=line>working <info>%d/30</info> %s"):format(i, ("#"):rep(i)))) |
||||
os.sleep(0.03) |
||||
end |
||||
tui.raw(tui.styled("<clear=line>")) |
||||
tui.success("progress loop finished") |
||||
|
||||
---------------------------------------------------------------------- |
||||
-- 2. Full-screen: alternate screen + absolute cursor addressing |
||||
---------------------------------------------------------------------- |
||||
tui.altScreen(true) |
||||
tui.clearScreen() |
||||
tui.showCursor(false) |
||||
|
||||
local info = tui.screenInfo() |
||||
local w, h = info.width or 80, info.height or 24 |
||||
tui.moveTo(2, 3) |
||||
tui.raw(tui.styled(("<info;b>alternate screen</> — %dx%d cells"):format(w, h))) |
||||
|
||||
-- A little box drawn with moveTo. |
||||
for row = 4, 8 do |
||||
tui.moveTo(row, 3) |
||||
if row == 4 or row == 8 then |
||||
tui.raw("+" .. ("-"):rep(20) .. "+") |
||||
else |
||||
tui.raw("|" .. (" "):rep(20) .. "|") |
||||
end |
||||
end |
||||
tui.moveTo(6, 6) |
||||
tui.raw(tui.styled("<yellow>boxed content</>")) |
||||
|
||||
tui.moveTo(10, 3) |
||||
tui.raw("where is the cursor? ") |
||||
local row, col = tui.cursorPos() |
||||
tui.raw(tui.styled(("at <b>row %s, col %s</b>"):format(tostring(row), tostring(col)))) |
||||
|
||||
tui.moveTo(12, 3) |
||||
tui.raw("back to the normal screen in 2 seconds...") |
||||
os.sleep(2) |
||||
|
||||
tui.altScreen(false) |
||||
tui.showCursor(true) |
||||
print("back — scrollback intact.") |
||||
|
||||
---------------------------------------------------------------------- |
||||
-- 3. Scroll region: log lines scrolling under a fixed header |
||||
---------------------------------------------------------------------- |
||||
print() |
||||
print(tui.styled("<b>FIXED HEADER</b> (lines below scroll, this one stays)")) |
||||
local top = select(1, tui.cursorPos()) |
||||
if top then |
||||
tui.scrollRegion(top, top + 5) |
||||
for i = 1, 15 do |
||||
tui.moveTo(top + 5, 1) |
||||
tui.raw(("\nlog line %d"):format(i)) |
||||
os.sleep(0.1) |
||||
end |
||||
tui.scrollRegion() |
||||
tui.moveTo(top + 6, 1) |
||||
end |
||||
print("scroll region reset.") |
||||
|
||||
-- Deliberately no title restore: the runtime pops the pushed title (and |
||||
-- would also leave the alt screen / re-show the cursor) on exit. |
||||
@ -1,256 +0,0 @@ |
||||
-- Weather forecast demo — a small app that exercises most of the Luna 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 LUNA_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 |
||||
@ -1,29 +0,0 @@ |
||||
# Lua stubs for the Luna runtime environment |
||||
|
||||
`---@meta` type definitions of the **Rust-native** parts of the Luna 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`. |
||||
@ -1,64 +0,0 @@ |
||||
---@meta |
||||
---@diagnostic disable: missing-return |
||||
-- Type stubs for the `fs` module (fully native). Reference: docs/fs.md |
||||
-- |
||||
-- Every operation runs under the folder permission system: recorded grants |
||||
-- (per script, per directory, recursive) answer silently; unknown ones ask on |
||||
-- the terminal — y/n for this run, A/N recorded in access.json. Denials and |
||||
-- OS-level failures raise Lua errors; trap with pcall/utils.try where needed. |
||||
-- `--allow-fs`, `--no-fs` and `--sandbox` override the filesystem checks. |
||||
|
||||
fs = {} |
||||
|
||||
---Read a whole file into a string (byte-exact; no encoding assumed). |
||||
---Errors if the path is not a regular file or not accessible. |
||||
---@param path string |
||||
---@return string content |
||||
function fs.readAll(path) end |
||||
|
||||
---Write a whole file from a string: created if missing, truncated if not. |
||||
---The containing directory must already exist. Errors if the file can't be |
||||
---created or is not accessible. |
||||
---@param path string |
||||
---@param content string |
||||
function fs.writeAll(path, content) end |
||||
|
||||
---Names of a directory's entries (files and subdirectories), sorted. |
||||
---Errors if the path is not a directory or not accessible. |
||||
---@param path string |
||||
---@return string[] names |
||||
function fs.list(path) end |
||||
|
||||
---true iff the path exists, is a directory, and is readable under the |
||||
---permission system. Never errors; a nonexistent path is false without any |
||||
---permission prompt. |
||||
---@param path string |
||||
---@return boolean |
||||
function fs.isDir(path) end |
||||
|
||||
---true iff the path exists, is a regular file, and is readable under the |
||||
---permission system. Never errors; a nonexistent path is false without any |
||||
---permission prompt. |
||||
---@param path string |
||||
---@return boolean |
||||
function fs.isFile(path) end |
||||
|
||||
---The process's current working directory (absolute path) — what relative |
||||
---paths in the other fs functions resolve against. Not permission-gated. |
||||
---@return string dir |
||||
function fs.getWorkDir() end |
||||
|
||||
---The directory the main script really lives in (absolute path, symlinks |
||||
---resolved) — the stable base for loading assets shipped next to the script, |
||||
---independent of the CWD it was invoked from. nil in the REPL, where there |
||||
---is no script. Not permission-gated. |
||||
---@return string|nil dir |
||||
function fs.getScriptDir() end |
||||
|
||||
---Run the permission cycle for a directory explicitly: recorded answer, else |
||||
---interactive prompt, else false (nothing recorded when there is no terminal |
||||
---to ask on). Errors if the path is not an existing directory. |
||||
---@param dir string |
||||
---@param mode "r"|"w" |
||||
---@return boolean granted |
||||
function fs.access(dir, mode) end |
||||
@ -1,140 +0,0 @@ |
||||
---@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. |
||||
-- |
||||
-- Every request runs the per-origin permission system (docs/http.md): a |
||||
-- recorded grant for scheme://host[:port] answers silently, unknown origins |
||||
-- prompt on the terminal, denials raise. `--allow-net`, `--no-net` and |
||||
-- `--sandbox` override the network checks. |
||||
|
||||
http = {} |
||||
|
||||
---@class http.Response |
||||
---@field status integer HTTP status code, e.g. 200 |
||||
---@field ok boolean true when status is 2xx |
||||
---@field url string final URL after redirects |
||||
---@field headers table<string, string> response headers, keyed by lowercase name; repeats joined with ", " (except set-cookie: first value only, see setCookies) |
||||
---@field setCookies string[] every Set-Cookie header value on the final response |
||||
---@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, string|number>? URL-encoded; sets application/x-www-form-urlencoded |
||||
---@field cookies table<string, string>? cookies for this request (merged with the session jar; not combinable with a Cookie header) |
||||
---@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 unexpired cookies 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, including session cookies). |
||||
---@param path string |
||||
function Session:save(path) end |
||||
|
||||
---Load a jar file, replacing the current jar contents. |
||||
---@param path string |
||||
function Session:load(path) end |
||||
@ -1,31 +0,0 @@ |
||||
---@meta |
||||
-- Type stubs for the native `log` module: level-tagged diagnostics on stderr. |
||||
-- Visibility is controlled by LUNA_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 LUNA_LOG=trace. |
||||
---@param ... any |
||||
function log.trace(...) end |
||||
|
||||
---Developer diagnostics. Shown with LUNA_LOG=debug. |
||||
---@param ... any |
||||
function log.debug(...) end |
||||
|
||||
---Normal high-level progress. Shown with LUNA_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 |
||||
@ -1,31 +0,0 @@ |
||||
---@meta |
||||
---@diagnostic disable: missing-return |
||||
-- Type stubs for the Luna 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 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 |
||||
|
||||
---Command-line arguments passed to the script: everything after the script |
||||
---path on the `luna` command line (or after a literal `--`), including |
||||
---arguments given directly to an executable `#!/usr/bin/env luna` script. |
||||
---Returns a fresh array each call; empty in the REPL. Arguments are raw byte |
||||
---strings (no UTF-8 conversion). |
||||
---@return string[] args |
||||
function os.args() end |
||||
|
||||
---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 |
||||
@ -1,16 +0,0 @@ |
||||
---@meta |
||||
---@diagnostic disable: missing-return |
||||
-- Type stub for the sandboxed `require`. The stock `package` library does not |
||||
-- exist in Luna (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 $LUNA_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 |
||||
@ -1,72 +0,0 @@ |
||||
---@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. A file-backed database runs the fs permission system |
||||
---(one combined read/write ask for its directory); ":memory:" never does. |
||||
---@param path string |
||||
---@return sqlite.Connection |
||||
function sqlite.connect(path) end |
||||
@ -1,14 +0,0 @@ |
||||
---@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 |
||||
@ -1,318 +0,0 @@ |
||||
---@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 |
||||
@ -1,40 +0,0 @@ |
||||
---@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 |
||||
@ -0,0 +1,53 @@ |
||||
-- Smoke-test for the stdlib extensions. |
||||
|
||||
print("=== math ===") |
||||
print("round(2.5) =", math.round(2.5)) -- 3 |
||||
print("round(2.567,2) =", math.round(2.567, 2)) -- 2.57 |
||||
print("clamp(10,0,5) =", math.clamp(10, 0, 5)) -- 5 |
||||
print("isFinite(1/0) =", math.isFinite(1/0)) -- false |
||||
print("isFinite(3.14) =", math.isFinite(3.14)) -- true |
||||
print("sign(-7) =", math.sign(-7)) -- -1 |
||||
print("scale(5,0,10,0,100) =", math.scale(5, 0, 10, 0, 100)) -- 50.0 |
||||
|
||||
print("\n=== table ===") |
||||
local t = {3, 1, 4, 1, 5} |
||||
print("sum =", table.sum(t)) -- 14 |
||||
print("min =", table.min(t)) -- 1 |
||||
print("max =", table.max(t)) -- 5 |
||||
print("mean =", table.mean(t)) -- 2.8 |
||||
local evens = table.ifilter(t, function(v) return v % 2 == 0 end) |
||||
print("evens =", table.concat(evens, ",")) -- 4 |
||||
local doubled = table.map({1,2,3}, function(v) return v * 2 end) |
||||
print("doubled=", table.concat(doubled, ",")) -- 2,4,6 |
||||
print("deepCopy ok:", table.deepCopy({x={y=1}}).x.y == 1) |
||||
print("merged =", table.concat(table.merge({1,2},{3,4}), ",")) -- 1,2,3,4 |
||||
|
||||
print("\n=== utils ===") |
||||
print("dump(nil) =", utils.dump(nil)) |
||||
print("dump({1,2}) =", utils.dump({1, 2})) |
||||
print("dump({x=1}) =", utils.dump({x = 1})) |
||||
print("isNull(NULL) =", utils.isNull(utils.NULL)) |
||||
print("isNull(nil) =", utils.isNull(nil)) |
||||
|
||||
local json = utils.toJSON({name="Alice", age=30, tags={"a","b"}}) |
||||
print("toJSON =", json) |
||||
local parsed = utils.fromJSON(json) |
||||
print("fromJSON name=", parsed.name, "age=", parsed.age) |
||||
|
||||
local v, err = utils.try(function() return 42 end) |
||||
print("try ok: v=", v, "err=", err) |
||||
local v2, err2 = utils.try(function() error("oops") end) |
||||
print("try err: v=", v2, "err ~= nil:", err2 ~= nil) |
||||
|
||||
print("\n=== os ===") |
||||
local t1 = os.microtime() |
||||
os.sleep(0.01) |
||||
local t2 = os.microtime() |
||||
print("microtime ok:", t2 > t1) |
||||
print("clock >= 0:", os.clock() >= 0) |
||||
|
||||
print("\n=== log (set RUST_LOG=info to see output on stderr) ===") |
||||
log.info("hello from log.info") |
||||
log.warn("hello from log.warn") |
||||
|
||||
print("\nAll checks passed.") |
||||
@ -1,313 +0,0 @@ |
||||
//! Tests for the async parts of the stdlib: os.sleep, task.join and sqlite.
|
||||
//! These have no flowbox-rt counterpart and need a tokio runtime, so they live
|
||||
//! apart from the ported sync tests.
|
||||
|
||||
use std::time::{Duration, Instant}; |
||||
|
||||
use super::lua; |
||||
|
||||
#[tokio::test] |
||||
async fn test_os_sleep_actually_sleeps() { |
||||
let lua = lua(); |
||||
|
||||
let start = Instant::now(); |
||||
lua.load(r#"os.sleep(0.1)"#).exec_async().await.unwrap(); |
||||
let elapsed = start.elapsed(); |
||||
|
||||
assert!(elapsed >= Duration::from_millis(95), "slept only {elapsed:?}"); |
||||
assert!(elapsed < Duration::from_millis(500), "slept too long: {elapsed:?}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_os_sleep_invalid_duration() { |
||||
let lua = lua(); |
||||
|
||||
// Negative, NaN and infinite durations must raise a Lua error, not panic the host
|
||||
for src in ["os.sleep(-1)", "os.sleep(0/0)", "os.sleep(math.huge)"] { |
||||
let err = lua.load(src).exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("os.sleep"), "{src}: {err}"); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_os_sleep_rejects_huge_finite_duration() { |
||||
let lua = lua(); |
||||
|
||||
// A huge-but-finite duration must be rejected promptly, not parked forever
|
||||
// (which would read as the runtime hanging). The test itself would hang if
|
||||
// this regressed, so tokio's test timeout / the assertion guards it.
|
||||
let err = lua.load(r#"os.sleep(1e18)"#).exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("os.sleep"), "{err}"); |
||||
|
||||
// A duration within the cap is accepted (0 sleeps instantly).
|
||||
lua.load(r#"os.sleep(0)"#).exec_async().await.unwrap(); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_task_join_returns_results_positionally() { |
||||
let lua = lua(); |
||||
|
||||
let (a, b, c): (i64, String, bool) = lua |
||||
.load( |
||||
r#" |
||||
return task.join( |
||||
function() return 1 end, |
||||
function() os.sleep(0.01) return "two" end, |
||||
function() return true end |
||||
) |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!(a, 1); |
||||
assert_eq!(b, "two"); |
||||
assert!(c); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_task_join_runs_concurrently() { |
||||
let lua = lua(); |
||||
|
||||
let start = Instant::now(); |
||||
lua.load( |
||||
r#" |
||||
task.join( |
||||
function() os.sleep(0.15) end, |
||||
function() os.sleep(0.1) end, |
||||
function() os.sleep(0.05) end |
||||
) |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
let elapsed = start.elapsed(); |
||||
|
||||
// Sequential execution would take ~0.3s; overlapped, the slowest task dominates.
|
||||
assert!(elapsed >= Duration::from_millis(140), "finished too fast: {elapsed:?}"); |
||||
assert!(elapsed < Duration::from_millis(280), "tasks did not overlap: {elapsed:?}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_task_join_rejects_non_function_argument() { |
||||
let lua = lua(); |
||||
|
||||
// A non-function argument gives a prefixed, positioned error, not mlua's
|
||||
// bare "error converting Lua integer to function".
|
||||
let err = lua |
||||
.load(r#"task.join(function() end, 42)"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains("task.join: argument #2 must be a function"), "{msg}"); |
||||
assert!(msg.contains("integer"), "{msg}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_os_sleep_rejects_non_number() { |
||||
let lua = lua(); |
||||
|
||||
let err = lua.load(r#"os.sleep("soon")"#).exec_async().await.unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains("os.sleep: duration must be a number"), "{msg}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_task_join_propagates_errors() { |
||||
let lua = lua(); |
||||
|
||||
let err = lua |
||||
.load(r#"task.join(function() error("boom") end, function() return 1 end)"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("boom")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_in_memory_roundtrip() { |
||||
let lua = lua(); |
||||
|
||||
let (name, id, count): (String, i64, i64) = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") |
||||
db:execute("INSERT INTO t (name) VALUES (?)", {"alice"}) |
||||
db:execute("INSERT INTO t (name) VALUES (?)", {"bob"}) |
||||
local id = db:lastInsertRowid() |
||||
local row = db:queryOne("SELECT name FROM t WHERE id = ?", {1}) |
||||
local rows = db:query("SELECT * FROM t") |
||||
db:close() |
||||
return row.name, id, #rows |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!(name, "alice"); |
||||
assert_eq!(id, 2); |
||||
assert_eq!(count, 2); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_binds_non_utf8_as_blob() { |
||||
let lua = lua(); |
||||
|
||||
// A non-UTF-8 / NUL-containing Lua string must bind and round-trip intact
|
||||
// (as a BLOB) rather than failing the UTF-8 conversion.
|
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, data)") |
||||
local blob = "\255\0\254bin" |
||||
db:execute("INSERT INTO t (data) VALUES (?)", {blob}) |
||||
local got = db:queryOne("SELECT data FROM t WHERE id = 1").data |
||||
return got == blob |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ok); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_rejects_empty_and_multi_statements() { |
||||
let lua = lua(); |
||||
|
||||
let err = lua |
||||
.load(r#"local db = sqlite.connect(":memory:"); return db:execute(" ")"#) |
||||
.eval_async::<mlua::Value>() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("no SQL statement"), "{err}"); |
||||
|
||||
// A comment-only statement has no runnable SQL either.
|
||||
let err = lua |
||||
.load(r#"local db = sqlite.connect(":memory:"); return db:query("-- only a comment")"#) |
||||
.eval_async::<mlua::Value>() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("no SQL statement"), "{err}"); |
||||
|
||||
let err = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (x)") |
||||
return db:execute("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)") |
||||
"#, |
||||
) |
||||
.eval_async::<mlua::Value>() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("single SQL statement"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_execute_batch_runs_all_statements() { |
||||
let lua = lua(); |
||||
|
||||
let count: i64 = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:executeBatch([[ |
||||
CREATE TABLE t (x); |
||||
INSERT INTO t VALUES (1); |
||||
INSERT INTO t VALUES (2); |
||||
INSERT INTO t VALUES (3); |
||||
]]) |
||||
return db:queryOne("SELECT COUNT(*) AS n FROM t").n |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(count, 3); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_transaction_passes_through_return_values() { |
||||
let lua = lua(); |
||||
|
||||
let (a, b): (i64, String) = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (x)") |
||||
return db:transaction(function() return 7, "eight" end) |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(a, 7); |
||||
assert_eq!(b, "eight"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_transaction_does_not_mask_original_error() { |
||||
let lua = lua(); |
||||
|
||||
// If the body fails AND rollback then also fails (here: the connection is
|
||||
// closed inside the body), the caller must still see the body's error.
|
||||
let err = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (x)") |
||||
db:transaction(function() |
||||
db:close() |
||||
error("ORIGINAL ERROR") |
||||
end) |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("ORIGINAL ERROR"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_sqlite_transaction_commit_and_rollback() { |
||||
let lua = lua(); |
||||
|
||||
let (after_commit, after_rollback): (i64, i64) = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (x)") |
||||
|
||||
db:transaction(function() |
||||
db:execute("INSERT INTO t VALUES (1)") |
||||
end) |
||||
local committed = db:queryOne("SELECT COUNT(*) AS n FROM t").n |
||||
|
||||
local ok, err = pcall(function() |
||||
db:transaction(function() |
||||
db:execute("INSERT INTO t VALUES (2)") |
||||
error("nope") |
||||
end) |
||||
end) |
||||
assert(not ok) |
||||
assert(tostring(err):find("nope")) |
||||
local rolled_back = db:queryOne("SELECT COUNT(*) AS n FROM t").n |
||||
|
||||
return committed, rolled_back |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!(after_commit, 1); |
||||
// The failed transaction's insert must have been rolled back.
|
||||
assert_eq!(after_rollback, 1); |
||||
} |
||||
@ -1,226 +0,0 @@ |
||||
//! Core environment tests: basic Lua evaluation, sandbox verification, and
|
||||
//! checks that the stdlib extends rather than replaces the standard modules.
|
||||
//!
|
||||
//! Ported from flowbox-rt's fb_lua core_tests, with sandbox-verification
|
||||
//! tests added for this project's sandbox (see src/sandbox.rs).
|
||||
|
||||
use super::assert_eq_f64; |
||||
|
||||
/// Smoke test for the environment setup
|
||||
#[test] |
||||
fn test_call_lua_function() { |
||||
let lua = super::lua(); |
||||
|
||||
let chunk: mlua::Function = lua |
||||
.load( |
||||
r#" |
||||
function(a, b) |
||||
return a + b |
||||
end |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
|
||||
let res: i32 = chunk.call((10, 20)).unwrap(); |
||||
assert_eq!(res, 30); |
||||
} |
||||
|
||||
/// dofile, loadfile and load (raw bytecode loading) are removed from the sandbox.
|
||||
#[test] |
||||
fn test_sandbox_chunk_loaders_removed() { |
||||
let lua = super::lua(); |
||||
|
||||
for name in ["dofile", "loadfile", "load"] { |
||||
let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap(); |
||||
assert!(is_nil, "global `{name}` should be nil in the sandbox"); |
||||
} |
||||
} |
||||
|
||||
/// string.dump (bytecode producer, the counterpart of load) is removed.
|
||||
#[test] |
||||
fn test_sandbox_string_dump_removed() { |
||||
let lua = super::lua(); |
||||
|
||||
let is_nil: bool = lua.load(r#"return string.dump == nil"#).eval().unwrap(); |
||||
assert!(is_nil, "string.dump should be nil in the sandbox"); |
||||
} |
||||
|
||||
/// io, package and debug libraries are not loaded at all.
|
||||
#[test] |
||||
fn test_sandbox_io_package_debug_not_loaded() { |
||||
let lua = super::lua(); |
||||
|
||||
for name in ["io", "package", "debug"] { |
||||
let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap(); |
||||
assert!(is_nil, "global `{name}` should be nil in the sandbox"); |
||||
} |
||||
} |
||||
|
||||
/// collectgarbage only allows the "count" option; everything else raises
|
||||
/// an error mentioning the sandbox.
|
||||
#[test] |
||||
fn test_sandbox_collectgarbage_count_only() { |
||||
let lua = super::lua(); |
||||
|
||||
let count: f64 = lua.load(r#"return collectgarbage("count")"#).eval().unwrap(); |
||||
assert!(count > 0.0, "collectgarbage(\"count\") should report heap KB, got {count}"); |
||||
|
||||
let err = lua.load(r#"return collectgarbage()"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}"); |
||||
|
||||
let err = lua.load(r#"return collectgarbage("collect")"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}"); |
||||
} |
||||
|
||||
/// The os table only keeps the safe time functions plus our extensions.
|
||||
#[test] |
||||
fn test_sandbox_os_dangerous_functions_removed() { |
||||
let lua = super::lua(); |
||||
|
||||
let os_table = lua.globals().get::<mlua::Table>("os").unwrap(); |
||||
for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] { |
||||
assert!( |
||||
!os_table.contains_key(name).unwrap(), |
||||
"os.{name} should be removed from the sandbox" |
||||
); |
||||
} |
||||
for name in ["date", "difftime", "time", "clock", "microtime", "sleep"] { |
||||
assert!( |
||||
os_table.contains_key(name).unwrap(), |
||||
"os.{name} should be present in the sandbox" |
||||
); |
||||
} |
||||
} |
||||
|
||||
/// Verify that the math table was extended with custom functions,
|
||||
/// not replaced - standard Lua math functions must still exist.
|
||||
#[test] |
||||
fn test_math_table_extended_not_replaced() { |
||||
let lua = super::lua(); |
||||
|
||||
// Test standard math functions still work
|
||||
let result: f64 = lua.load(r#"return math.floor(3.7)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 3.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.ceil(3.2)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 4.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.abs(-5)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 5.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.sqrt(16)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 4.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.sin(0)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 0.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.cos(0)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 1.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.max(1, 5, 3)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 5.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.min(1, 5, 3)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 1.0); |
||||
|
||||
// Verify math.pi constant exists
|
||||
let result: f64 = lua.load(r#"return math.pi"#).eval().unwrap(); |
||||
assert!(result > 3.14 && result < 3.15); |
||||
|
||||
// Verify our custom functions are also present
|
||||
let result: i64 = lua.load(r#"return math.round(3.7)"#).eval().unwrap(); |
||||
assert_eq!(result, 4); |
||||
|
||||
let result: f64 = lua.load(r#"return math.round(3.1415, 2)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 3.14); |
||||
|
||||
let result: i64 = lua.load(r#"return math.clamp(50, 0, 100)"#).eval().unwrap(); |
||||
assert_eq!(result, 50); |
||||
} |
||||
|
||||
/// Verify that the table module was extended with custom functions,
|
||||
/// not replaced - standard Lua table functions must still exist.
|
||||
#[test] |
||||
fn test_table_module_extended_not_replaced() { |
||||
let lua = super::lua(); |
||||
|
||||
// Test standard table functions still work
|
||||
// table.insert
|
||||
let result: String = lua |
||||
.load( |
||||
r#" |
||||
local t = {1, 2, 3} |
||||
table.insert(t, 4) |
||||
return utils.toJSON(t) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, "[1,2,3,4]"); |
||||
|
||||
// table.remove
|
||||
let result: String = lua |
||||
.load( |
||||
r#" |
||||
local t = {1, 2, 3, 4} |
||||
table.remove(t, 2) |
||||
return utils.toJSON(t) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, "[1,3,4]"); |
||||
|
||||
// table.concat
|
||||
let result: String = lua.load(r#"return table.concat({"a", "b", "c"}, "-")"#).eval().unwrap(); |
||||
assert_eq!(result, "a-b-c"); |
||||
|
||||
// table.sort
|
||||
let result: String = lua |
||||
.load( |
||||
r#" |
||||
local t = {3, 1, 4, 1, 5, 9} |
||||
table.sort(t) |
||||
return utils.toJSON(t) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, "[1,1,3,4,5,9]"); |
||||
|
||||
// table.unpack
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local t = {10, 20, 30} |
||||
local a, b, c = table.unpack(t) |
||||
return a + b + c |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 60); |
||||
|
||||
// Verify our custom functions are also present
|
||||
let _: () = lua |
||||
.load( |
||||
r#" |
||||
local ro = table.readonly({x = 1}) |
||||
assert(ro.x == 1) |
||||
"#, |
||||
) |
||||
.exec() |
||||
.unwrap(); |
||||
|
||||
let result: String = lua |
||||
.load( |
||||
r#" |
||||
local merged = table.merge({a = 1}, {b = 2}) |
||||
return utils.toJSON(merged) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result.contains("\"a\":1") && result.contains("\"b\":2")); |
||||
} |
||||
@ -1,436 +0,0 @@ |
||||
//! Tests for the `fs` module and its permission system, exercised through
|
||||
//! Lua with policies injected via app data (the same channel `main` uses).
|
||||
//!
|
||||
//! No test here can ever reach a real terminal prompt or the real
|
||||
//! access.json: in test builds the prompt path is compiled out (scripted
|
||||
//! answers only) and the default policy carries no script identity.
|
||||
|
||||
use std::sync::Arc; |
||||
|
||||
use super::lua; |
||||
use crate::stdlib::perm::{store, Choice, Override, Policy}; |
||||
|
||||
/// The sandboxed state with an allow-everything policy.
|
||||
fn lua_allow_all() -> mlua::Lua { |
||||
let lua = lua(); |
||||
lua.set_app_data(Arc::new(Policy::allow_all())); |
||||
lua |
||||
} |
||||
|
||||
/// A policy with only the filesystem statically denied by `flag` (what
|
||||
/// `--no-fs` builds); origins stay interactive.
|
||||
fn deny_fs(flag: &'static str) -> Policy { |
||||
Policy::new(Some(Override::Deny { flag }), None, None, Default::default(), None) |
||||
} |
||||
|
||||
fn lua_with(policy: Policy) -> mlua::Lua { |
||||
let lua = lua(); |
||||
lua.set_app_data(Arc::new(policy)); |
||||
lua |
||||
} |
||||
|
||||
async fn lua_err(lua: &mlua::Lua, src: &str) -> String { |
||||
lua.load(src).exec_async().await.unwrap_err().to_string() |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn write_read_round_trip_and_truncation() { |
||||
let lua = lua_allow_all(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("data.bin"); |
||||
|
||||
// Non-UTF-8 bytes must survive; a rewrite must truncate, not append.
|
||||
let got: mlua::String = lua |
||||
.load(format!( |
||||
r#" |
||||
fs.writeAll('{p}', "long original content \255\254\0tail") |
||||
fs.writeAll('{p}', "short\255") |
||||
return fs.readAll('{p}') |
||||
"#, |
||||
p = file.display() |
||||
)) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(&*got.as_bytes(), b"short\xff"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn write_all_creates_missing_file_but_not_missing_parent() { |
||||
let lua = lua_allow_all(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
|
||||
let fresh = dir.path().join("new.txt"); |
||||
lua.load(format!("fs.writeAll('{}', 'x')", fresh.display())) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "x"); |
||||
|
||||
let orphan = dir.path().join("no/such/dir/f.txt"); |
||||
let err = lua_err(&lua, &format!("fs.writeAll('{}', 'x')", orphan.display())).await; |
||||
assert!(err.contains("fs.writeAll") && err.contains("parent directory"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn type_mismatches_error() { |
||||
let lua = lua_allow_all(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("f.txt"); |
||||
std::fs::write(&file, "x").unwrap(); |
||||
|
||||
let err = lua_err(&lua, &format!("fs.readAll('{}')", dir.path().display())).await; |
||||
assert!(err.contains("fs.readAll") && err.contains("not a regular file"), "{err}"); |
||||
|
||||
let err = lua_err(&lua, &format!("fs.writeAll('{}', 'x')", dir.path().display())).await; |
||||
assert!(err.contains("fs.writeAll") && err.contains("is a directory"), "{err}"); |
||||
|
||||
let err = lua_err(&lua, &format!("fs.list('{}')", file.display())).await; |
||||
assert!(err.contains("fs.list") && err.contains("not a directory"), "{err}"); |
||||
|
||||
let err = lua_err(&lua, "fs.readAll('/no/such/file/anywhere')").await; |
||||
assert!(err.contains("fs.readAll"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn list_returns_sorted_names() { |
||||
let lua = lua_allow_all(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
std::fs::write(dir.path().join("b.txt"), "").unwrap(); |
||||
std::fs::write(dir.path().join("a.txt"), "").unwrap(); |
||||
std::fs::create_dir(dir.path().join("sub")).unwrap(); |
||||
|
||||
let joined: String = lua |
||||
.load(format!( |
||||
"return table.concat(fs.list('{}'), ',')", |
||||
dir.path().display() |
||||
)) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(joined, "a.txt,b.txt,sub"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn predicates_check_existence_type_and_access() { |
||||
let lua = lua_allow_all(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("f.txt"); |
||||
std::fs::write(&file, "x").unwrap(); |
||||
|
||||
let (dir_is_dir, dir_is_file, file_is_dir, file_is_file, ghost_dir, ghost_file): ( |
||||
bool, |
||||
bool, |
||||
bool, |
||||
bool, |
||||
bool, |
||||
bool, |
||||
) = lua |
||||
.load(format!( |
||||
r#" |
||||
return fs.isDir('{d}'), fs.isFile('{d}'), |
||||
fs.isDir('{f}'), fs.isFile('{f}'), |
||||
fs.isDir('/no/such/path'), fs.isFile('/no/such/path') |
||||
"#, |
||||
d = dir.path().display(), |
||||
f = file.display() |
||||
)) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(dir_is_dir && !dir_is_file && !file_is_dir && file_is_file); |
||||
assert!(!ghost_dir && !ghost_file); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn access_validates_arguments() { |
||||
let lua = lua_allow_all(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
|
||||
let ok: bool = lua |
||||
.load(format!("return fs.access('{}', 'r')", dir.path().display())) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ok); |
||||
|
||||
let err = lua_err(&lua, &format!("fs.access('{}', 'rw')", dir.path().display())).await; |
||||
assert!(err.contains("fs.access") && err.contains("mode"), "{err}"); |
||||
|
||||
let err = lua_err(&lua, "fs.access('/no/such/dir', 'r')").await; |
||||
assert!(err.contains("fs.access"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn get_work_dir_returns_the_cwd_ungated() { |
||||
// Works even under the strictest policy: it reveals process state, not
|
||||
// filesystem content.
|
||||
let lua = lua_with(Policy::deny_all("--sandbox")); |
||||
let cwd: String = lua.load("return fs.getWorkDir()").eval_async().await.unwrap(); |
||||
assert_eq!(std::path::PathBuf::from(&cwd), std::env::current_dir().unwrap()); |
||||
assert!(std::path::Path::new(&cwd).is_absolute()); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn get_script_dir_reports_planted_dir_ungated_and_nil_without_one() { |
||||
// No ScriptDir in app data (REPL semantics): nil.
|
||||
let lua = lua_with(Policy::deny_all("--sandbox")); |
||||
let noscript: Option<String> = |
||||
lua.load("return fs.getScriptDir()").eval_async().await.unwrap(); |
||||
assert_eq!(noscript, None); |
||||
|
||||
// With the slot planted (as main does for a script), it comes back
|
||||
// verbatim even under the strictest policy.
|
||||
lua.set_app_data(crate::stdlib::fs::ScriptDir("/opt/tool".into())); |
||||
let dir: String = lua.load("return fs.getScriptDir()").eval_async().await.unwrap(); |
||||
assert_eq!(dir, "/opt/tool"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn deny_all_blocks_everything_without_prompting() { |
||||
let lua = lua_with(deny_fs("--no-fs")); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("f.txt"); |
||||
std::fs::write(&file, "secret").unwrap(); |
||||
|
||||
for op in [ |
||||
format!("fs.readAll('{}')", file.display()), |
||||
format!("fs.writeAll('{}', 'x')", file.display()), |
||||
format!("fs.list('{}')", dir.path().display()), |
||||
] { |
||||
let err = lua_err(&lua, &op).await; |
||||
assert!(err.contains("disabled by --no-fs"), "{op}: {err}"); |
||||
} |
||||
|
||||
let (acc, is_dir, is_file): (bool, bool, bool) = lua |
||||
.load(format!( |
||||
"return fs.access('{d}', 'r'), fs.isDir('{d}'), fs.isFile('{f}')", |
||||
d = dir.path().display(), |
||||
f = file.display() |
||||
)) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(!acc && !is_dir && !is_file); |
||||
// the file was not touched
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "secret"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn default_policy_denies_unknown_without_persisting() { |
||||
// No policy injected: the fs::install default (REPL semantics, no
|
||||
// scripted answers = nobody to ask) must deny and record nothing.
|
||||
let lua = lua(); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("f.txt"); |
||||
std::fs::write(&file, "x").unwrap(); |
||||
|
||||
let err = lua_err(&lua, &format!("fs.readAll('{}')", file.display())).await; |
||||
assert!(err.contains("fs.readAll") && err.contains("denied"), "{err}"); |
||||
|
||||
let ok: bool = lua |
||||
.load(format!("return fs.access('{}', 'r')", dir.path().display())) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(!ok); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn interactive_allow_once_grants_operations() { |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("f.txt"); |
||||
std::fs::write(&file, "content").unwrap(); |
||||
|
||||
// One AllowOnce answer covers the whole run for that directory: the
|
||||
// second read and the fs.access probe consume no further answers.
|
||||
let lua = lua_with(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
[Choice::AllowOnce], |
||||
)); |
||||
let (a, b, acc): (String, String, bool) = lua |
||||
.load(format!( |
||||
r#"return fs.readAll('{f}'), fs.readAll('{f}'), fs.access('{d}', 'r')"#, |
||||
f = file.display(), |
||||
d = dir.path().display() |
||||
)) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!((a.as_str(), b.as_str(), acc), ("content", "content", true)); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn interactive_deny_answer_blocks_and_read_grant_does_not_leak_to_write() { |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let file = dir.path().join("f.txt"); |
||||
std::fs::write(&file, "content").unwrap(); |
||||
|
||||
// First answer grants the read; the write is a separate (write-kind)
|
||||
// question answered with DenyOnce.
|
||||
let lua = lua_with(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
[Choice::AllowOnce, Choice::DenyOnce], |
||||
)); |
||||
let got: String = lua |
||||
.load(format!("return fs.readAll('{}')", file.display())) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(got, "content"); |
||||
|
||||
let err = lua_err(&lua, &format!("fs.writeAll('{}', 'nope')", file.display())).await; |
||||
assert!(err.contains("fs.writeAll") && err.contains("denied"), "{err}"); |
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "content"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn fs_access_allow_always_persists_and_is_honored_next_run() { |
||||
let data_dir = tempfile::tempdir().unwrap(); |
||||
let cfg = tempfile::tempdir().unwrap(); |
||||
let store_path = cfg.path().join("access.json"); |
||||
let script_key = "/scripts/tool.lua"; |
||||
|
||||
// Run 1: fs.access asks, the user answers "A".
|
||||
let lua = lua_with(Policy::interactive_scripted( |
||||
Some(script_key.into()), |
||||
Default::default(), |
||||
Some(store_path.clone()), |
||||
[Choice::AllowAlways], |
||||
)); |
||||
let ok: bool = lua |
||||
.load(format!("return fs.access('{}', 'w')", data_dir.path().display())) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ok); |
||||
|
||||
// The grant is on disk, keyed by script and canonical dir…
|
||||
let persisted = store::load_for_script(&store_path, script_key); |
||||
let dir_key = data_dir.path().canonicalize().unwrap(); |
||||
assert_eq!(persisted.fs[&dir_key.to_string_lossy().into_owned()].write, Some(true)); |
||||
|
||||
// …and a fresh "run" that loads it never needs a prompt, including for a
|
||||
// file inside a subdirectory.
|
||||
std::fs::create_dir(data_dir.path().join("sub")).unwrap(); |
||||
let lua = lua_with(Policy::interactive_scripted( |
||||
Some(script_key.into()), |
||||
store::load_for_script(&store_path, script_key), |
||||
Some(store_path.clone()), |
||||
[], // any prompt would deny
|
||||
)); |
||||
lua.load(format!( |
||||
"fs.writeAll('{}', 'hello')", |
||||
data_dir.path().join("sub/out.txt").display() |
||||
)) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn sqlite_memory_db_works_under_sandbox_but_file_db_is_blocked() { |
||||
let lua = lua_with(Policy::deny_all("--sandbox")); |
||||
|
||||
let n: i64 = lua |
||||
.load( |
||||
r#" |
||||
local db = sqlite.connect(":memory:") |
||||
db:execute("CREATE TABLE t (x)") |
||||
db:execute("INSERT INTO t VALUES (41)") |
||||
return db:queryOne("SELECT x + 1 AS y FROM t").y |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(n, 42); |
||||
|
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let err = lua_err( |
||||
&lua, |
||||
&format!("sqlite.connect('{}')", dir.path().join("x.db").display()), |
||||
) |
||||
.await; |
||||
assert!(err.contains("sqlite.connect") && err.contains("--sandbox"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn sqlite_file_db_asks_one_combined_question() { |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
// A single AllowOnce must cover the whole read/write open.
|
||||
let lua = lua_with(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
[Choice::AllowOnce], |
||||
)); |
||||
let n: i64 = lua |
||||
.load(format!( |
||||
r#" |
||||
local db = sqlite.connect('{}') |
||||
db:execute("CREATE TABLE t (x)") |
||||
db:execute("INSERT INTO t VALUES (1)") |
||||
return db:queryOne("SELECT COUNT(*) AS n FROM t").n |
||||
"#, |
||||
dir.path().join("x.db").display() |
||||
)) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(n, 1); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn http_requests_are_blocked_under_sandbox_before_any_io() { |
||||
let lua = lua_with(Policy::deny_all("--sandbox")); |
||||
// Unroutable address: if the gate failed, this would time out / error
|
||||
// differently; the sandbox message must come from our check.
|
||||
let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await; |
||||
assert!(err.contains("networking disabled by --sandbox"), "{err}"); |
||||
|
||||
// --no-fs alone leaves networking interactive: the denial names the
|
||||
// origin (nobody to ask here), not the flag.
|
||||
let lua = lua_with(deny_fs("--no-fs")); |
||||
let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await; |
||||
assert!( |
||||
err.contains("access to http://127.0.0.1:1 denied") && !err.contains("--no-fs"), |
||||
"{err}" |
||||
); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn cookie_jar_files_go_through_the_permission_system() { |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let jar_path = dir.path().join("jar.jsonl"); |
||||
|
||||
// Denied: save must not create the file.
|
||||
let lua = lua_with(deny_fs("--no-fs")); |
||||
let err = lua_err( |
||||
&lua, |
||||
&format!("local s = http.session() s:save('{}')", jar_path.display()), |
||||
) |
||||
.await; |
||||
assert!(err.contains("session:save") && err.contains("--no-fs"), "{err}"); |
||||
assert!(!jar_path.exists()); |
||||
|
||||
// Allowed: save/load and the http.session(path) preload round-trip.
|
||||
let lua = lua_allow_all(); |
||||
lua.load(format!( |
||||
r#" |
||||
local s = http.session() |
||||
s:save('{p}') |
||||
s:load('{p}') |
||||
local s2 = http.session('{p}') |
||||
"#, |
||||
p = jar_path.display() |
||||
)) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(jar_path.exists()); |
||||
} |
||||
@ -1,647 +0,0 @@ |
||||
//! End-to-end tests for the http module against a local `tiny_http` server:
|
||||
//! request building (form/json/headers/auth), redirect behaviour, the session
|
||||
//! cookie jar, and jar persistence. Cookie *policy* (public suffix, Secure,
|
||||
//! expiry) is unit-tested in `stdlib::http`.
|
||||
|
||||
use std::sync::{Arc, Mutex}; |
||||
use std::thread::JoinHandle; |
||||
|
||||
use super::lua as sandboxed_lua; |
||||
|
||||
/// The stdlib Lua with networking and filesystem allowed (the default
|
||||
/// test policy is deny-all), plus a `BASE` global pointing at `server`.
|
||||
fn lua_net(server: &TestServer) -> mlua::Lua { |
||||
let lua = sandboxed_lua(); |
||||
lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::allow_all())); |
||||
lua.globals().set("BASE", server.base.clone()).unwrap(); |
||||
lua |
||||
} |
||||
|
||||
/// A request as the server saw it, for asserting on from the test thread
|
||||
/// (a panic inside the server thread would be silently swallowed).
|
||||
#[derive(Clone, Debug)] |
||||
struct Captured { |
||||
method: String, |
||||
url: String, |
||||
headers: Vec<(String, String)>, |
||||
body: String, |
||||
} |
||||
|
||||
impl Captured { |
||||
fn header(&self, name: &str) -> Option<&str> { |
||||
self.headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) |
||||
} |
||||
} |
||||
|
||||
struct TestServer { |
||||
base: String, |
||||
seen: Arc<Mutex<Vec<Captured>>>, |
||||
server: Arc<tiny_http::Server>, |
||||
thread: Option<JoinHandle<()>>, |
||||
} |
||||
|
||||
impl TestServer { |
||||
/// All requests observed so far, in arrival order.
|
||||
fn requests(&self) -> Vec<Captured> { |
||||
self.seen.lock().unwrap().clone() |
||||
} |
||||
} |
||||
|
||||
impl Drop for TestServer { |
||||
fn drop(&mut self) { |
||||
self.server.unblock(); |
||||
if let Some(t) = self.thread.take() { |
||||
let _ = t.join(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Spawn a local HTTP server; `handler` maps each captured request to a
|
||||
/// response. Every request is also recorded for later assertions.
|
||||
fn serve( |
||||
handler: impl Fn(&Captured) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> |
||||
+ Send |
||||
+ Sync |
||||
+ 'static, |
||||
) -> TestServer { |
||||
let server = Arc::new(tiny_http::Server::http("127.0.0.1:0").unwrap()); |
||||
let base = format!("http://{}", server.server_addr().to_ip().unwrap()); |
||||
let seen: Arc<Mutex<Vec<Captured>>> = Arc::default(); |
||||
|
||||
let thread = { |
||||
let server = server.clone(); |
||||
let seen = seen.clone(); |
||||
std::thread::spawn(move || { |
||||
for mut req in server.incoming_requests() { |
||||
let mut body = String::new(); |
||||
req.as_reader().read_to_string(&mut body).unwrap(); |
||||
let captured = Captured { |
||||
method: req.method().to_string(), |
||||
url: req.url().to_string(), |
||||
headers: req |
||||
.headers() |
||||
.iter() |
||||
.map(|h| (h.field.to_string().to_ascii_lowercase(), h.value.to_string())) |
||||
.collect(), |
||||
body, |
||||
}; |
||||
seen.lock().unwrap().push(captured.clone()); |
||||
let _ = req.respond(handler(&captured)); |
||||
} |
||||
}) |
||||
}; |
||||
|
||||
TestServer { base, seen, server, thread: Some(thread) } |
||||
} |
||||
|
||||
fn resp( |
||||
code: u16, |
||||
headers: &[(&str, &str)], |
||||
body: &str, |
||||
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> { |
||||
let mut r = tiny_http::Response::from_data(body.as_bytes().to_vec()).with_status_code(code); |
||||
for (k, v) in headers { |
||||
r.add_header(tiny_http::Header::from_bytes(k.as_bytes(), v.as_bytes()).unwrap()); |
||||
} |
||||
r |
||||
} |
||||
|
||||
fn path_of(c: &Captured) -> &str { |
||||
c.url.split('?').next().unwrap() |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_get_response_fields() { |
||||
let server = serve(|_| resp(200, &[("X-One", "1")], "hello")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (status, ok, body, x_one, url): (u16, bool, String, String, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE) |
||||
return r.status, r.ok, r.body, r.headers["x-one"], r.url |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!((status, ok), (200, true)); |
||||
assert_eq!(body, "hello"); |
||||
assert_eq!(x_one, "1"); |
||||
assert_eq!(url, format!("{}/", server.base)); |
||||
|
||||
// The default User-Agent goes out unless overridden.
|
||||
let reqs = server.requests(); |
||||
assert!(reqs[0].header("user-agent").unwrap().starts_with("luna/"), "{reqs:?}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_form_body_is_urlencoded() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load(r#"http.post(BASE, { form = { q = "a b&c=d", plain = "x", num = 5 } })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let req = &server.requests()[0]; |
||||
assert_eq!(req.method, "POST"); |
||||
assert_eq!(req.header("content-type"), Some("application/x-www-form-urlencoded")); |
||||
// Lua table order is unspecified; compare as a set of encoded pairs.
|
||||
let mut pairs: Vec<&str> = req.body.split('&').collect(); |
||||
pairs.sort(); |
||||
assert_eq!(pairs, ["num=5", "plain=x", "q=a+b%26c%3Dd"]); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_json_body_and_resp_json() { |
||||
let server = |
||||
serve(|c| resp(200, &[("Content-Type", "application/json")], &c.body.clone())); |
||||
let lua = lua_net(&server); |
||||
|
||||
let hello: String = lua |
||||
.load(r#"return http.postJSON(BASE, { hello = "world" }).json().hello"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(hello, "world"); |
||||
|
||||
let req = &server.requests()[0]; |
||||
assert_eq!(req.header("content-type"), Some("application/json")); |
||||
assert!(super::json_eq(&req.body, r#"{"hello":"world"}"#)); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_user_content_type_wins_over_implied() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load( |
||||
r#" |
||||
http.post(BASE, { |
||||
form = { a = "1" }, |
||||
headers = { ["Content-Type"] = "text/plain" }, |
||||
}) |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let req = &server.requests()[0]; |
||||
let cts: Vec<&str> = |
||||
req.headers.iter().filter(|(k, _)| k == "content-type").map(|(_, v)| v.as_str()).collect(); |
||||
assert_eq!(cts, ["text/plain"], "exactly one Content-Type, the caller's"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_redirect_302_degrades_post_to_get() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/a" => resp(302, &[("Location", "/b")], ""), |
||||
"/b" => resp(200, &[], "final"), |
||||
p => resp(404, &[], p), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (status, body, url): (u16, String, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.post(BASE .. "/a", { json = { x = 1 } }) |
||||
return r.status, r.body, r.url |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!((status, body.as_str()), (200, "final")); |
||||
assert!(url.ends_with("/b"), "{url}"); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].method, "GET"); |
||||
assert_eq!(reqs[1].body, "", "302-degraded GET must not carry the POST body"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_redirect_307_preserves_method_and_body() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/a" => resp(307, &[("Location", "/b")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load(r#"http.post(BASE .. "/a", { body = "payload" })"#).exec_async().await.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].method, "POST"); |
||||
assert_eq!(reqs[1].body, "payload"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_too_many_redirects_errors() { |
||||
let server = serve(|_| resp(302, &[("Location", "/loop")], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let err = |
||||
lua.load(r#"http.get(BASE .. "/loop")"#).exec_async().await.unwrap_err().to_string(); |
||||
assert!(err.contains("http:") && err.contains("redirect"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_session_captures_set_cookie_on_redirect() { |
||||
// The login pattern: Set-Cookie arrives on the 302 itself, and the
|
||||
// redirected hop must already send it back.
|
||||
let server = serve(|c| match path_of(c) { |
||||
"/login" => resp(302, &[("Location", "/dash"), ("Set-Cookie", "sid=abc; Path=/")], ""), |
||||
"/dash" => resp(200, &[], ""), |
||||
p => resp(404, &[], p), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (sid, n): (String, i64) = lua |
||||
.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/login") |
||||
local jar = s:cookies() |
||||
local domains = 0 |
||||
for _ in pairs(jar) do domains = domains + 1 end |
||||
return jar["127.0.0.1"].sid, domains |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(sid, "abc"); |
||||
assert_eq!(n, 1); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[0].header("cookie"), None); |
||||
assert_eq!(reqs[1].header("cookie"), Some("sid=abc"), "jar cookie must reach /dash"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_session_clear_cookies() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/set") |
||||
s:clearCookies() |
||||
assert(next(s:cookies()) == nil, "jar must be empty") |
||||
s:get(BASE .. "/check") |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].header("cookie"), None); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_per_request_cookies_merge_with_jar() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/set" => resp(200, &[("Set-Cookie", "a=1"), ("Set-Cookie", "keep=yes")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/set") |
||||
s:get(BASE .. "/check", { cookies = { a = "2", b = "3" } }) |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
let mut got: Vec<&str> = reqs[1].header("cookie").unwrap().split("; ").collect(); |
||||
got.sort(); |
||||
// Jar cookie `keep` rides along, per-request `a` wins over the jar's.
|
||||
assert_eq!(got, ["a=2", "b=3", "keep=yes"]); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_cookies_opt_conflicts_with_cookie_header() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let err = lua |
||||
.load(r#"http.get(BASE, { cookies = { a = "1" }, headers = { Cookie = "b=2" } })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err() |
||||
.to_string(); |
||||
assert!(err.contains("not both"), "{err}"); |
||||
assert!(server.requests().is_empty(), "the request must not go out"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_session_save_load_roundtrip() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let jar_path = dir.path().join("jar.jsonl"); |
||||
lua.globals().set("JAR", jar_path.to_str().unwrap()).unwrap(); |
||||
|
||||
lua.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/set") |
||||
s:save(JAR) |
||||
|
||||
-- A fresh session preloaded from the file sends the cookie again. |
||||
local s2 = http.session(JAR) |
||||
assert(s2:cookies()["127.0.0.1"].sid == "abc", "cookie must survive save/load") |
||||
s2:get(BASE .. "/check") |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].header("cookie"), Some("sid=abc")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_basic_auth_header() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load(r#"http.get(BASE, { auth = { username = "alice", password = "secret" } })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
// base64("alice:secret")
|
||||
let auth = server.requests()[0].header("authorization").unwrap().to_string(); |
||||
assert_eq!(auth, "Basic YWxpY2U6c2VjcmV0"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_digest_auth_answers_challenge() { |
||||
let server = serve(|c| { |
||||
if c.header("authorization").is_none() { |
||||
resp( |
||||
401, |
||||
&[("WWW-Authenticate", r#"Digest realm="test", nonce="abc123", qop="auth""#)], |
||||
"", |
||||
) |
||||
} else { |
||||
resp(200, &[], "in") |
||||
} |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (ok, body): (bool, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE .. "/p", { |
||||
auth = { username = "alice", password = "secret", scheme = "digest" }, |
||||
}) |
||||
return r.ok, r.body |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ok); |
||||
assert_eq!(body, "in"); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs.len(), 2, "one challenge, one answer"); |
||||
let auth = reqs[1].header("authorization").unwrap(); |
||||
assert!(auth.starts_with("Digest "), "{auth}"); |
||||
for part in [r#"username="alice""#, r#"uri="/p""#, "response="] { |
||||
assert!(auth.contains(part), "{auth} missing {part}"); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_cross_host_redirect_strips_credentials() { |
||||
// Two servers = two hosts (the port differs). Authorization and Cookie
|
||||
// must not follow the redirect off the original host; ordinary custom
|
||||
// headers do (matching browser/curl behaviour).
|
||||
let target = serve(|_| resp(200, &[], "landed")); |
||||
let origin = { |
||||
let to = format!("{}/land", target.base); |
||||
serve(move |_| resp(302, &[("Location", &to.clone())], "")) |
||||
}; |
||||
let lua = lua_net(&origin); |
||||
|
||||
let body: String = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE .. "/go", { |
||||
auth = { username = "alice", password = "secret" }, |
||||
cookies = { sid = "abc" }, |
||||
headers = { ["X-Custom"] = "keep" }, |
||||
}) |
||||
return r.body |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(body, "landed"); |
||||
|
||||
let first = &origin.requests()[0]; |
||||
assert!(first.header("authorization").is_some()); |
||||
assert!(first.header("cookie").is_some()); |
||||
|
||||
let landed = &target.requests()[0]; |
||||
assert_eq!(landed.header("authorization"), None, "auth leaked cross-host"); |
||||
assert_eq!(landed.header("cookie"), None, "cookies leaked cross-host"); |
||||
assert_eq!(landed.header("x-custom"), Some("keep")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_multi_value_headers_and_set_cookies() { |
||||
let server = serve(|_| { |
||||
resp( |
||||
200, |
||||
&[ |
||||
("X-Multi", "one"), |
||||
("X-Multi", "two"), |
||||
("Set-Cookie", "a=1; Path=/"), |
||||
("Set-Cookie", "b=2; Path=/"), |
||||
], |
||||
"", |
||||
) |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (multi, first_sc, sc1, sc2): (String, String, String, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE) |
||||
assert(#r.setCookies == 2, "expected two Set-Cookie values") |
||||
return r.headers["x-multi"], r.headers["set-cookie"], r.setCookies[1], r.setCookies[2] |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!(multi, "one, two"); |
||||
assert_eq!(first_sc, "a=1; Path=/"); |
||||
assert_eq!((sc1.as_str(), sc2.as_str()), ("a=1; Path=/", "b=2; Path=/")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_timeout_errors() { |
||||
let server = serve(|_| { |
||||
std::thread::sleep(std::time::Duration::from_secs(2)); |
||||
resp(200, &[], "late") |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let start = std::time::Instant::now(); |
||||
let err = lua |
||||
.load(r#"http.get(BASE, { timeout = 0.1 })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err() |
||||
.to_string(); |
||||
assert!(err.contains("http:"), "{err}"); |
||||
assert!(start.elapsed() < std::time::Duration::from_secs(1), "timeout did not apply"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_invalid_url_and_method() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let err = lua.load(r#"http.get("not a url")"#).exec_async().await.unwrap_err().to_string(); |
||||
assert!(err.contains("invalid URL"), "{err}"); |
||||
|
||||
let err = lua |
||||
.load(r#"http.request("BAD METHOD", BASE)"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err() |
||||
.to_string(); |
||||
assert!(err.contains("invalid method"), "{err}"); |
||||
|
||||
assert!(server.requests().is_empty()); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Origin permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The stdlib Lua with an interactive policy fed by scripted prompt answers
|
||||
/// (and a `BASE` global), for exercising the per-origin permission gate.
|
||||
fn lua_scripted( |
||||
server: &TestServer, |
||||
script_key: Option<&str>, |
||||
store_path: Option<std::path::PathBuf>, |
||||
answers: impl IntoIterator<Item = crate::stdlib::perm::Choice>, |
||||
) -> mlua::Lua { |
||||
let lua = sandboxed_lua(); |
||||
lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::interactive_scripted( |
||||
script_key.map(String::from), |
||||
Default::default(), |
||||
store_path, |
||||
answers, |
||||
))); |
||||
lua.globals().set("BASE", server.base.clone()).unwrap(); |
||||
lua |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_origin_denied_blocks_request_before_any_io() { |
||||
let server = serve(|_| resp(200, &[], "ok")); |
||||
// No scripted answers = nobody to ask: deny.
|
||||
let lua = lua_scripted(&server, None, None, []); |
||||
|
||||
let err = lua.load("http.get(BASE .. '/x')").exec_async().await.unwrap_err().to_string(); |
||||
assert!( |
||||
err.contains(&format!("access to {} denied", server.base)), |
||||
"{err}" |
||||
); |
||||
assert!(server.requests().is_empty(), "the request must never leave the process"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_origin_allow_once_covers_the_whole_run() { |
||||
use crate::stdlib::perm::Choice; |
||||
let server = serve(|_| resp(200, &[], "ok")); |
||||
// Exactly one answer: the second request must be served from the session
|
||||
// grant, not a second prompt.
|
||||
let lua = lua_scripted(&server, None, None, [Choice::AllowOnce]); |
||||
|
||||
let (a, b): (String, String) = lua |
||||
.load("return http.get(BASE .. '/one').body, http.get(BASE .. '/two').body") |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!((a.as_str(), b.as_str()), ("ok", "ok")); |
||||
assert_eq!(server.requests().len(), 2); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_origin_allow_always_persists_under_net_key() { |
||||
use crate::stdlib::perm::{store, Choice}; |
||||
let server = serve(|_| resp(200, &[], "ok")); |
||||
let cfg = tempfile::tempdir().unwrap(); |
||||
let store_path = cfg.path().join("access.json"); |
||||
|
||||
// The URL carries credentials and a path; the recorded key must be the
|
||||
// bare origin (BASE = scheme://host:port).
|
||||
let lua = lua_scripted( |
||||
&server, |
||||
Some("/scripts/tool.lua"), |
||||
Some(store_path.clone()), |
||||
[Choice::AllowAlways], |
||||
); |
||||
let with_creds = server.base.replace("http://", "http://user:secret@"); |
||||
lua.load(format!("http.get('{with_creds}/x?q=1')")).exec_async().await.unwrap(); |
||||
|
||||
let saved = store::load_for_script(&store_path, "/scripts/tool.lua"); |
||||
assert_eq!(saved.net[&server.base].access, Some(true)); |
||||
assert_eq!(saved.net.len(), 1, "exactly one origin recorded: {:?}", saved.net); |
||||
assert!(saved.fs.is_empty()); |
||||
// and the raw JSON nests it under the script's "net" section
|
||||
let raw = std::fs::read_to_string(&store_path).unwrap(); |
||||
assert!(raw.contains("\"net\""), "{raw}"); |
||||
|
||||
// A fresh run loading the store needs no prompt (no answers scripted).
|
||||
let lua = sandboxed_lua(); |
||||
lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::interactive_scripted( |
||||
Some("/scripts/tool.lua".into()), |
||||
store::load_for_script(&store_path, "/scripts/tool.lua"), |
||||
Some(store_path), |
||||
[], |
||||
))); |
||||
lua.globals().set("BASE", server.base.clone()).unwrap(); |
||||
let body: String = lua.load("return http.get(BASE .. '/y').body").eval_async().await.unwrap(); |
||||
assert_eq!(body, "ok"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_origin_deny_answer_blocks_request() { |
||||
use crate::stdlib::perm::Choice; |
||||
let server = serve(|_| resp(200, &[], "ok")); |
||||
let lua = lua_scripted(&server, None, None, [Choice::DenyOnce]); |
||||
|
||||
let err = lua.load("http.get(BASE .. '/x')").exec_async().await.unwrap_err().to_string(); |
||||
assert!(err.contains("denied"), "{err}"); |
||||
assert!(server.requests().is_empty()); |
||||
} |
||||
@ -1,398 +0,0 @@ |
||||
//! Tests for the math stdlib extensions (lua/stdlib/math.lua).
|
||||
|
||||
use super::assert_eq_f64; |
||||
use mlua::prelude::LuaValue; |
||||
|
||||
#[test] |
||||
fn test_clamp() { |
||||
let lua = super::lua(); |
||||
|
||||
// Simple, integers
|
||||
let result: i64 = lua.load(r#"return math.clamp(3, 10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, 10); // low
|
||||
let result: i64 = lua.load(r#"return math.clamp(3, nil, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, 3); // low-unbounded
|
||||
|
||||
let result: i64 = lua.load(r#"return math.clamp(15, 10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, 15); // inside
|
||||
|
||||
let result: i64 = lua.load(r#"return math.clamp(25, 10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, 20); // high
|
||||
let result: i64 = lua.load(r#"return math.clamp(25, 10, nil)"#).eval().unwrap(); |
||||
assert_eq!(result, 25); // high-unbounded
|
||||
|
||||
let result: i64 = lua.load(r#"return math.clamp(999, nil, nil)"#).eval().unwrap(); |
||||
assert_eq!(result, 999); // unbounded
|
||||
|
||||
// Floats
|
||||
let result: f64 = lua.load(r#"return math.clamp(3.5, 10.5, 20.5)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 10.5); // low
|
||||
let result: f64 = lua.load(r#"return math.clamp(3.5, nil, 20.5)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 3.5); // low-unbounded
|
||||
|
||||
let result: f64 = lua.load(r#"return math.clamp(15.5, 10.5, 20.5)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 15.5); // inside
|
||||
|
||||
let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, 20.5)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 20.5); // high
|
||||
let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, nil)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 25.5); // high-unbounded
|
||||
|
||||
let result: f64 = lua.load(r#"return math.clamp(999.9, nil, nil)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 999.9); // unbounded
|
||||
|
||||
// Check some negative numbers
|
||||
let result: i64 = lua.load(r#"return math.clamp(3, -10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, 3); // low
|
||||
let result: i64 = lua.load(r#"return math.clamp(-15, -10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, -10); // inside
|
||||
let result: i64 = lua.load(r#"return math.clamp(-25, -10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, -10); // high
|
||||
let result: i64 = lua.load(r#"return math.clamp(25, -10, 20)"#).eval().unwrap(); |
||||
assert_eq!(result, 20); // high
|
||||
|
||||
assert!(lua.load(r#"return math.clamp(nil, 1, 2)"#).eval::<LuaValue>().is_err()); |
||||
assert!(lua.load(r#"return math.clamp(1, "1", 2)"#).eval::<LuaValue>().is_err()); |
||||
assert!(lua.load(r#"return math.clamp(1, "foo", 2)"#).eval::<LuaValue>().is_err()); |
||||
assert!(lua.load(r#"return math.clamp(1, 1, "bar")"#).eval::<LuaValue>().is_err()); |
||||
assert!(lua.load(r#"return math.clamp(1, 1, "2")"#).eval::<LuaValue>().is_err()); |
||||
|
||||
// Crossed bounds (min > max) is a programming error, not a valid interval
|
||||
let err = lua.load(r#"return math.clamp(5, 10, 0)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("greater than")); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_round() { |
||||
let lua = super::lua(); |
||||
|
||||
// Round positive numbers
|
||||
let result: i64 = lua.load(r#"return math.round(3.4)"#).eval().unwrap(); |
||||
assert_eq!(result, 3); |
||||
|
||||
let result: i64 = lua.load(r#"return math.round(3.5)"#).eval().unwrap(); |
||||
assert_eq!(result, 4); |
||||
|
||||
let result: i64 = lua.load(r#"return math.round(3.9)"#).eval().unwrap(); |
||||
assert_eq!(result, 4); |
||||
|
||||
// Round negative numbers
|
||||
let result: i64 = lua.load(r#"return math.round(-3.4)"#).eval().unwrap(); |
||||
assert_eq!(result, -3); |
||||
|
||||
let result: i64 = lua.load(r#"return math.round(-3.5)"#).eval().unwrap(); |
||||
assert_eq!(result, -4); |
||||
|
||||
// Round whole numbers (should stay the same)
|
||||
let result: i64 = lua.load(r#"return math.round(5)"#).eval().unwrap(); |
||||
assert_eq!(result, 5); |
||||
|
||||
let result: i64 = lua.load(r#"return math.round(0)"#).eval().unwrap(); |
||||
assert_eq!(result, 0); |
||||
|
||||
// Round with a non-number should error
|
||||
let err = lua.load(r#"return math.round("not a number")"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("non-number")); |
||||
|
||||
// NaN must error, not silently become 0
|
||||
let err = lua.load(r#"return math.round(0/0)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("finite")); |
||||
|
||||
// Infinity must error (out of integer range)
|
||||
assert!(lua.load(r#"return math.round(math.huge)"#).exec().is_err()); |
||||
assert!(lua.load(r#"return math.round(-math.huge)"#).exec().is_err()); |
||||
|
||||
// Out of i64 range must error
|
||||
assert!(lua.load(r#"return math.round(1e19)"#).exec().is_err()); |
||||
assert!(lua.load(r#"return math.round(-1e19)"#).exec().is_err()); |
||||
|
||||
// Half away from zero at exactly +-0.5
|
||||
let result: i64 = lua.load(r#"return math.round(0.5)"#).eval().unwrap(); |
||||
assert_eq!(result, 1); |
||||
let result: i64 = lua.load(r#"return math.round(-0.5)"#).eval().unwrap(); |
||||
assert_eq!(result, -1); |
||||
|
||||
// The naive floor(value + 0.5) trap: 0.49999999999999994 + 0.5 == 1.0 in f64,
|
||||
// but the nearest integer is 0
|
||||
let result: i64 = lua.load(r#"return math.round(0.49999999999999994)"#).eval().unwrap(); |
||||
assert_eq!(result, 0); |
||||
let result: i64 = lua.load(r#"return math.round(-0.49999999999999994)"#).eval().unwrap(); |
||||
assert_eq!(result, 0); |
||||
|
||||
// Integer-valued floats at the edge of f64 precision (2^53)
|
||||
let result: i64 = lua.load(r#"return math.round(2.0^53 - 1.0)"#).eval().unwrap(); |
||||
assert_eq!(result, 9007199254740991); |
||||
let result: i64 = lua.load(r#"return math.round(2.0^53)"#).eval().unwrap(); |
||||
assert_eq!(result, 9007199254740992); |
||||
|
||||
// -2^63 is exactly representable as a float and is a valid Lua integer...
|
||||
let result: bool = lua.load(r#"return math.round(-2.0^63) == math.mininteger"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
// ...but 2^63 is one above math.maxinteger and must error
|
||||
assert!(lua.load(r#"return math.round(2.0^63)"#).exec().is_err()); |
||||
} |
||||
|
||||
//noinspection RsApproxConstant
|
||||
#[test] |
||||
fn test_round_to_places() { |
||||
let lua = super::lua(); |
||||
|
||||
// roundn was merged into round - it must no longer exist
|
||||
let result: bool = lua.load(r#"return math.roundn == nil"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
// places = 0 behaves like the one-argument form and returns an integer
|
||||
let result: String = lua.load(r#"return math.type(math.round(3.1415, 0))"#).eval().unwrap(); |
||||
assert_eq!(result, "integer"); |
||||
let result: i64 = lua.load(r#"return math.round(3.7, 0)"#).eval().unwrap(); |
||||
assert_eq!(result, 4); |
||||
|
||||
// places > 0 rounds to N decimal places and returns a float
|
||||
let result: f64 = lua.load(r#"return math.round(3.1415, 1)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 3.1); |
||||
|
||||
let result: f64 = lua.load(r#"return math.round(3.1415, 3)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 3.142); |
||||
|
||||
let result: f64 = lua.load(r#"return math.round(2.567, 2)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 2.57); |
||||
|
||||
let result: String = lua.load(r#"return math.type(math.round(3.1415, 2))"#).eval().unwrap(); |
||||
assert_eq!(result, "float"); |
||||
|
||||
// Integer input with places > 0 returns the same value as a float
|
||||
let result: String = lua.load(r#"return math.type(math.round(5, 2))"#).eval().unwrap(); |
||||
assert_eq!(result, "float"); |
||||
let result: f64 = lua.load(r#"return math.round(5, 2)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 5.0); |
||||
|
||||
// Negative numbers round half away from zero
|
||||
let result: f64 = lua.load(r#"return math.round(-2.345, 2)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, -2.35); |
||||
|
||||
// Huge values must not overflow to infinity via the 10^places multiplier
|
||||
let result: f64 = lua.load(r#"return math.round(1.5e308, 2)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 1.5e308); |
||||
|
||||
// Huge places are a no-op (cannot change any f64), not an overflow
|
||||
let result: f64 = lua.load(r#"return math.round(12.5, 400)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 12.5); |
||||
|
||||
// Tiny values still round correctly with large places
|
||||
let result: f64 = lua.load(r#"return math.round(1.23e-300, 300)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 1.0e-300); |
||||
|
||||
// Places given as an integral float is accepted
|
||||
let result: f64 = lua.load(r#"return math.round(3.14159, 2.0)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 3.14); |
||||
|
||||
// Non-integral places must error
|
||||
assert!(lua.load(r#"return math.round(3.14, 2.5)"#).exec().is_err()); |
||||
|
||||
// Negative places must error with a clear message
|
||||
let err = lua.load(r#"return math.round(3.14, -2)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("negative")); |
||||
|
||||
// NaN must error
|
||||
let err = lua.load(r#"return math.round(0/0, 2)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("finite")); |
||||
|
||||
// Infinity must error
|
||||
assert!(lua.load(r#"return math.round(math.huge, 2)"#).exec().is_err()); |
||||
|
||||
// Non-number must error
|
||||
let err = lua.load(r#"return math.round("foo", 2)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("non-number")); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_is_finite() { |
||||
let lua = super::lua(); |
||||
|
||||
// Regular numbers are finite
|
||||
let result: bool = lua.load(r#"return math.isFinite(42)"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: bool = lua.load(r#"return math.isFinite(-3.14)"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: bool = lua.load(r#"return math.isFinite(0)"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
// Infinity is not finite
|
||||
let result: bool = lua.load(r#"return math.isFinite(math.huge)"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
let result: bool = lua.load(r#"return math.isFinite(-math.huge)"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
// NaN is not finite (0/0 produces NaN)
|
||||
let result: bool = lua.load(r#"return math.isFinite(0/0)"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
// Non-numbers return false
|
||||
let result: bool = lua.load(r#"return math.isFinite("hello")"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
let result: bool = lua.load(r#"return math.isFinite(nil)"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
let result: bool = lua.load(r#"return math.isFinite({})"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
// Very large numbers are still finite
|
||||
let result: bool = lua.load(r#"return math.isFinite(1e308)"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
// But overflow to infinity is not
|
||||
let result: bool = lua.load(r#"return math.isFinite(1e309)"#).eval().unwrap(); |
||||
assert!(!result); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_sign() { |
||||
let lua = super::lua(); |
||||
|
||||
// Positive numbers return 1
|
||||
let result: i64 = lua.load(r#"return math.sign(42)"#).eval().unwrap(); |
||||
assert_eq!(result, 1); |
||||
|
||||
let result: i64 = lua.load(r#"return math.sign(0.001)"#).eval().unwrap(); |
||||
assert_eq!(result, 1); |
||||
|
||||
// Negative numbers return -1
|
||||
let result: i64 = lua.load(r#"return math.sign(-42)"#).eval().unwrap(); |
||||
assert_eq!(result, -1); |
||||
|
||||
let result: i64 = lua.load(r#"return math.sign(-0.001)"#).eval().unwrap(); |
||||
assert_eq!(result, -1); |
||||
|
||||
// Zero returns 0
|
||||
let result: i64 = lua.load(r#"return math.sign(0)"#).eval().unwrap(); |
||||
assert_eq!(result, 0); |
||||
|
||||
// Negative zero is still zero
|
||||
let result: i64 = lua.load(r#"return math.sign(-0)"#).eval().unwrap(); |
||||
assert_eq!(result, 0); |
||||
|
||||
// Positive infinity returns 1
|
||||
let result: i64 = lua.load(r#"return math.sign(math.huge)"#).eval().unwrap(); |
||||
assert_eq!(result, 1); |
||||
|
||||
// Negative infinity returns -1
|
||||
let result: i64 = lua.load(r#"return math.sign(-math.huge)"#).eval().unwrap(); |
||||
assert_eq!(result, -1); |
||||
|
||||
// NaN returns 0 (NaN is not > 0 and not < 0)
|
||||
let result: i64 = lua.load(r#"return math.sign(0/0)"#).eval().unwrap(); |
||||
assert_eq!(result, 0); |
||||
|
||||
// Error on non-number
|
||||
let err = lua.load(r#"return math.sign("foo")"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("Non-number")); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_scale() { |
||||
let lua = super::lua(); |
||||
|
||||
// Scale 0-100 to 0-1
|
||||
let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 0, 1)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 0.5); |
||||
|
||||
// Scale 0-100 to 0-10
|
||||
let result: f64 = lua.load(r#"return math.scale(25, 0, 100, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 2.5); |
||||
|
||||
// Scale with offset ranges
|
||||
let result: f64 = lua.load(r#"return math.scale(15, 10, 20, 100, 200)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 150.0); |
||||
|
||||
// Scale to inverted range
|
||||
let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 100, 0)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 100.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 100, 0)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 0.0); |
||||
|
||||
// Value outside input range (no clamp)
|
||||
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 15.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, -5.0); |
||||
|
||||
// Value outside input range with clamp=true
|
||||
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, true)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 10.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10, true)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 0.0); |
||||
|
||||
// Clamp with inverted output range
|
||||
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 10, 0, true)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 0.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 10, 0, true)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 10.0); |
||||
|
||||
// Clamp=false (explicit) behaves same as default
|
||||
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, false)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 15.0); |
||||
|
||||
// Zero-width output range is allowed (always returns outMin)
|
||||
let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 5, 5)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 5.0); |
||||
|
||||
// Boundary values
|
||||
let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 0.0); |
||||
|
||||
let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 10.0); |
||||
|
||||
// Negative input range
|
||||
let result: f64 = lua.load(r#"return math.scale(-50, -100, 0, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 5.0); |
||||
|
||||
// Inverted input range (inMin > inMax)
|
||||
let result: f64 = lua.load(r#"return math.scale(75, 100, 0, 0, 10)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 2.5); |
||||
|
||||
// Error on zero-width input range (division by zero)
|
||||
let err = lua.load(r#"return math.scale(50, 100, 100, 0, 10)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("Input range cannot be zero")); |
||||
|
||||
// Error on non-number value
|
||||
let err = lua.load(r#"return math.scale("foo", 0, 100, 0, 10)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("Non-number")); |
||||
|
||||
// Error on non-number range
|
||||
let err = lua.load(r#"return math.scale(50, "a", 100, 0, 10)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("Non-number")); |
||||
|
||||
// Infinity input produces infinity output (no clamp)
|
||||
let result: bool = lua |
||||
.load(r#"return math.scale(math.huge, 0, 100, 0, 10) == math.huge"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
// Infinity input with clamp gets clamped
|
||||
let result: f64 = lua.load(r#"return math.scale(math.huge, 0, 100, 0, 10, true)"#).eval().unwrap(); |
||||
assert_eq_f64!(result, 10.0); |
||||
|
||||
let result: f64 = lua |
||||
.load(r#"return math.scale(-math.huge, 0, 100, 0, 10, true)"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq_f64!(result, 0.0); |
||||
|
||||
// NaN input produces NaN output
|
||||
let result: bool = lua |
||||
.load(r#"local r = math.scale(0/0, 0, 100, 0, 10); return r ~= r"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); // NaN ~= NaN is true
|
||||
} |
||||
@ -1,45 +0,0 @@ |
||||
//! Tests for the Lua stdlib, ported from flowbox-rt's fb_lua `lua_tests` and
|
||||
//! adapted to this project's sandbox and stdlib.
|
||||
|
||||
mod async_tests; |
||||
mod core_tests; |
||||
mod fs_tests; |
||||
mod http_tests; |
||||
mod math_tests; |
||||
mod os_tests; |
||||
mod require_tests; |
||||
mod table_tests; |
||||
mod tui_tests; |
||||
mod utils_tests; |
||||
|
||||
use mlua::Lua; |
||||
|
||||
/// A fresh sandboxed Lua with the full stdlib installed - the same state scripts get.
|
||||
pub(crate) fn lua() -> Lua { |
||||
crate::sandbox::create_sandboxed_lua().unwrap() |
||||
} |
||||
|
||||
/// Compare two JSON strings as parsed values (key order independent).
|
||||
#[allow(dead_code)] |
||||
pub(crate) fn json_eq(a: &str, b: &str) -> bool { |
||||
let a: serde_json::Value = serde_json::from_str(a).unwrap(); |
||||
let b: serde_json::Value = serde_json::from_str(b).unwrap(); |
||||
a == b |
||||
} |
||||
|
||||
/// Assert that two f64 values are equal within an epsilon (default `f64::EPSILON`).
|
||||
macro_rules! assert_eq_f64 { |
||||
($a:expr, $b:expr) => { |
||||
assert_eq_f64!($a, $b, f64::EPSILON) |
||||
}; |
||||
($a:expr, $b:expr, $eps:expr) => {{ |
||||
let (a, b): (f64, f64) = ($a, $b); |
||||
let eps: f64 = $eps; |
||||
assert!( |
||||
(a - b).abs() <= eps, |
||||
"assertion failed: `(left !== right)` (left: `{a:?}`, right: `{b:?}`, epsilon: `{eps:?}`, diff: `{:?}`)", |
||||
(a - b).abs() |
||||
); |
||||
}}; |
||||
} |
||||
pub(crate) use assert_eq_f64; |
||||
@ -1,212 +0,0 @@ |
||||
//! Tests for the sandboxed `os` table.
|
||||
//!
|
||||
//! Ported from flowbox-rt's fb_lua os_tests. Unlike flowbox, this project
|
||||
//! keeps the real Lua 5.5 os.date/os.time/os.difftime (flowbox used a
|
||||
//! chrono-based shim), so a few edge cases differ - see individual tests.
|
||||
//! os.sleep is async here and is covered by async_tests, not this file.
|
||||
|
||||
use std::time::{Duration, Instant}; |
||||
|
||||
#[test] |
||||
fn test_os_time_now() { |
||||
let lua = super::lua(); |
||||
|
||||
let result: i64 = lua.load(r#"return os.time()"#).eval().unwrap(); |
||||
let now = chrono::Utc::now().timestamp(); |
||||
assert!((result - now).abs() <= 5, "os.time() = {result}, expected ~{now}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_time_from_table() { |
||||
let lua = super::lua(); |
||||
|
||||
// Round trip through local time - independent of the host timezone
|
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
local t = os.time({year = 2020, month = 1, day = 15, hour = 10, min = 30, sec = 20}) |
||||
local d = os.date("*t", t) |
||||
return d.year == 2020 and d.month == 1 and d.day == 15 |
||||
and d.hour == 10 and d.min == 30 and d.sec == 20 |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(ok); |
||||
|
||||
// Out-of-range fields are normalized like mktime; hour defaults to 12
|
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
local a = os.time({year = 2020, month = 14, day = 1}) |
||||
local b = os.time({year = 2021, month = 2, day = 1}) |
||||
return a == b |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(ok); |
||||
|
||||
// The normalized fields are written back into the table
|
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
local tbl = {year = 2020, month = 14, day = 1} |
||||
os.time(tbl) |
||||
return tbl.year == 2021 and tbl.month == 2 and tbl.day == 1 |
||||
and tbl.hour == 12 and tbl.wday == 2 -- 2021-02-01 was a Monday |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(ok); |
||||
|
||||
// Missing mandatory field is an error
|
||||
let ok: bool = lua.load(r#"return (pcall(os.time, {year = 2020}))"#).eval().unwrap(); |
||||
assert!(!ok); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_date_format() { |
||||
let lua = super::lua(); |
||||
|
||||
let result: String = lua.load(r#"return os.date("!%Y-%m-%dT%H:%M:%S", 1000000000)"#).eval().unwrap(); |
||||
assert_eq!(result, "2001-09-09T01:46:40"); |
||||
|
||||
// Epoch
|
||||
let result: String = lua.load(r#"return os.date("!%Y-%m-%d %H:%M:%S", 0)"#).eval().unwrap(); |
||||
assert_eq!(result, "1970-01-01 00:00:00"); |
||||
|
||||
// Stock Lua difference: flowbox's chrono shim truncated float time values;
|
||||
// real Lua 5.5 requires an integer-representable time and raises an error.
|
||||
let ok: bool = lua.load(r#"return (pcall(os.date, "!%Y-%m-%d", 0.9))"#).eval().unwrap(); |
||||
assert!(!ok, "os.date with a fractional time value should raise in stock Lua"); |
||||
|
||||
// Default format is %c, applied to the current time
|
||||
let result: String = lua.load(r#"return os.date()"#).eval().unwrap(); |
||||
assert!(!result.is_empty()); |
||||
|
||||
// Invalid format specifier is an error, not garbage output
|
||||
let ok: bool = lua.load(r#"return (pcall(os.date, "%Q"))"#).eval().unwrap(); |
||||
assert!(!ok); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_date_table() { |
||||
let lua = super::lua(); |
||||
|
||||
// 2001-09-09T01:46:40Z was a Sunday, day 252 of the year
|
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
local d = os.date("!*t", 1000000000) |
||||
return d.year == 2001 and d.month == 9 and d.day == 9 |
||||
and d.hour == 1 and d.min == 46 and d.sec == 40 |
||||
and d.wday == 1 and d.yday == 252 |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(ok); |
||||
} |
||||
|
||||
/// os.clock is replaced with monotonic wall time since state creation.
|
||||
#[test] |
||||
fn test_os_clock() { |
||||
let start = Instant::now(); |
||||
let lua = super::lua(); |
||||
|
||||
std::thread::sleep(Duration::from_millis(30)); |
||||
|
||||
let clock: f64 = lua.load(r#"return os.clock()"#).eval().unwrap(); |
||||
assert!( |
||||
(start.elapsed().as_secs_f64() - clock).abs() < 0.1, |
||||
"Clock function works" |
||||
); |
||||
|
||||
// 200 ms elapses, so it should be reported accurately
|
||||
std::thread::sleep(Duration::from_millis(200)); |
||||
let clock2: f64 = lua.load(r#"return os.clock()"#).eval().unwrap(); |
||||
|
||||
assert!((clock2 - clock - 0.2) < 0.03, "Clock tracks time"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_difftime() { |
||||
let lua = super::lua(); |
||||
|
||||
let result: f64 = lua.load(r#"return os.difftime(10, 4)"#).eval().unwrap(); |
||||
assert_eq!(result, 6.0); |
||||
|
||||
let ok: bool = lua.load(r#"return (pcall(os.difftime, 10))"#).eval().unwrap(); |
||||
assert!(!ok); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_missing_dangerous_functions() { |
||||
let lua = super::lua(); |
||||
|
||||
let os_table = lua.globals().get::<mlua::Table>("os").unwrap(); |
||||
for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] { |
||||
assert!( |
||||
!os_table.contains_key(name).unwrap(), |
||||
"os.{name} should be removed from the sandbox" |
||||
); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_args() { |
||||
let lua = super::lua(); |
||||
|
||||
// Without ScriptArgs app data (tests, and structurally the REPL): empty.
|
||||
let n: i64 = lua.load(r#"return #os.args()"#).eval().unwrap(); |
||||
assert_eq!(n, 0); |
||||
|
||||
// main plants the parsed trailing arguments; non-UTF-8 bytes pass through.
|
||||
use std::os::unix::ffi::OsStringExt; |
||||
lua.set_app_data(crate::stdlib::os_ext::ScriptArgs(vec![ |
||||
"hello".into(), |
||||
"--flag".into(), |
||||
std::ffi::OsString::from_vec(vec![0xff, b'x']), |
||||
])); |
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
local a = os.args() |
||||
return #a == 3 and a[1] == "hello" and a[2] == "--flag" and a[3] == "\xffx" |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(ok); |
||||
|
||||
// Each call returns a fresh table — mutations do not leak into the next.
|
||||
let ok: bool = lua |
||||
.load( |
||||
r#" |
||||
os.args()[1] = "changed" |
||||
return os.args()[1] == "hello" |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(ok); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_os_microtime() { |
||||
let lua = super::lua(); |
||||
|
||||
let result1: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap(); |
||||
std::thread::sleep(Duration::from_millis(100)); |
||||
let result2: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap(); |
||||
assert!( |
||||
(result2 - result1 - 0.1).abs() < 0.03, |
||||
"os.microtime() should return fractional seconds" |
||||
); |
||||
|
||||
// It is a Unix timestamp
|
||||
let now = chrono::Utc::now().timestamp() as f64; |
||||
assert!((result2 - now).abs() <= 5.0, "os.microtime() = {result2}, expected ~{now}"); |
||||
} |
||||
@ -1,262 +0,0 @@ |
||||
//! Tests for `require`: resolution, caching, isolation and failure modes.
|
||||
//! Each test builds its own module tree under a unique temp directory and
|
||||
//! installs `require` with that directory as a search root, the same way
|
||||
//! `main.rs` does with the script's directory.
|
||||
|
||||
use std::path::PathBuf; |
||||
|
||||
use mlua::Lua; |
||||
|
||||
use super::lua; |
||||
|
||||
/// A per-test module tree; removed on drop.
|
||||
struct TempRoot(PathBuf); |
||||
|
||||
impl TempRoot { |
||||
fn new(tag: &str) -> Self { |
||||
let dir = std::env::temp_dir().join(format!("a-require-{tag}-{}", std::process::id())); |
||||
let _ = std::fs::remove_dir_all(&dir); |
||||
std::fs::create_dir_all(&dir).unwrap(); |
||||
TempRoot(dir) |
||||
} |
||||
|
||||
fn write(&self, rel: &str, contents: &str) { |
||||
let path = self.0.join(rel); |
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
||||
std::fs::write(&path, contents).unwrap(); |
||||
} |
||||
} |
||||
|
||||
impl Drop for TempRoot { |
||||
fn drop(&mut self) { |
||||
let _ = std::fs::remove_dir_all(&self.0); |
||||
} |
||||
} |
||||
|
||||
fn lua_with_roots(roots: &[&TempRoot]) -> Lua { |
||||
let lua = lua(); |
||||
let roots: Vec<PathBuf> = roots.iter().map(|r| r.0.clone()).collect(); |
||||
crate::stdlib::require::install(&lua, roots).unwrap(); |
||||
lua |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_returns_value_and_path() { |
||||
let root = TempRoot::new("value"); |
||||
root.write("mod.lua", "return { answer = 42 }"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (answer, path): (i64, String) = lua |
||||
.load(r#"local m, p = require "mod"; return m.answer, p"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(answer, 42); |
||||
assert!(path.ends_with("mod.lua"), "{path}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_caches_and_runs_once() { |
||||
let root = TempRoot::new("cache"); |
||||
root.write("counted.lua", "COUNT = (COUNT or 0) + 1\nreturn { n = COUNT }"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (same, count): (bool, i64) = lua |
||||
.load( |
||||
r#" |
||||
local a = require "counted" |
||||
local b = require "counted" |
||||
return rawequal(a, b), COUNT |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(same, "both requires must return the same table"); |
||||
assert_eq!(count, 1, "module body must run exactly once"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_dot_names_and_init_fallback() { |
||||
let root = TempRoot::new("dots"); |
||||
root.write("db/migrations.lua", r#"return "migrations""#); |
||||
root.write("pkg/init.lua", r#"return "pkg-init""#); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (a, b): (String, String) = lua |
||||
.load(r#"return require "db.migrations", require "pkg""#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(a, "migrations"); |
||||
assert_eq!(b, "pkg-init"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_locals_stay_local() { |
||||
let root = TempRoot::new("scope"); |
||||
root.write("scoped.lua", "local secret = 'hidden'\nreturn true"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let leaked: bool = lua |
||||
.load(r#"require "scoped"; return secret ~= nil"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(!leaked, "module locals must not leak into globals"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_passes_name_and_path_as_varargs() { |
||||
let root = TempRoot::new("varargs"); |
||||
root.write("who.lua", "local name, path = ...\nreturn { name = name, path = path }"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (name, path): (String, String) = lua |
||||
.load(r#"local m = require "who"; return m.name, m.path"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(name, "who"); |
||||
assert!(path.ends_with("who.lua"), "{path}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_rejects_malformed_names() { |
||||
let root = TempRoot::new("names"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
for name in ["", ".", "a..b", "../x", "a/b", "/etc/passwd", "a.b."] { |
||||
let err = lua |
||||
.load(format!("require {name:?}")) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!( |
||||
err.to_string().contains("invalid module name"), |
||||
"{name}: {err}" |
||||
); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_not_found_lists_candidates() { |
||||
let root = TempRoot::new("notfound"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let err = lua.load(r#"require "nope""#).exec_async().await.unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains("module 'nope' not found"), "{msg}"); |
||||
assert!(msg.contains("nope.lua"), "{msg}"); |
||||
assert!(msg.contains("init.lua"), "{msg}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_circular_errors() { |
||||
let root = TempRoot::new("circular"); |
||||
root.write("aaa.lua", "return require 'bbb'"); |
||||
root.write("bbb.lua", "return require 'aaa'"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let err = lua.load(r#"require "aaa""#).exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("circular require"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_nil_result_stored_as_true() { |
||||
let root = TempRoot::new("nilret"); |
||||
root.write("sideeffect.lua", "MARK = 1"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (ret, again, mark): (bool, bool, i64) = lua |
||||
.load(r#"return require "sideeffect", require "sideeffect", MARK"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ret, "a module returning nothing must yield true"); |
||||
assert!(again, "...also on the cached path"); |
||||
assert_eq!(mark, 1); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_builtins_return_the_globals() { |
||||
let root = TempRoot::new("builtins"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let all_same: bool = lua |
||||
.load( |
||||
r#" |
||||
for _, name in ipairs({"utils", "log", "sqlite", "http", "task", "math", "table", "string", "os"}) do |
||||
if not rawequal(require(name), _G[name]) then return false end |
||||
end |
||||
return true |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(all_same, "require of a built-in must return its global table"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_first_root_wins() { |
||||
let first = TempRoot::new("order1"); |
||||
let second = TempRoot::new("order2"); |
||||
first.write("dup.lua", "return 'first'"); |
||||
second.write("dup.lua", "return 'second'"); |
||||
let lua = lua_with_roots(&[&first, &second]); |
||||
|
||||
let got: String = lua.load(r#"return require "dup""#).eval_async().await.unwrap(); |
||||
assert_eq!(got, "first"); |
||||
} |
||||
|
||||
#[cfg(unix)] |
||||
#[tokio::test] |
||||
async fn test_require_symlink_resolves_to_same_module() { |
||||
let root = TempRoot::new("symlink"); |
||||
root.write("orig.lua", "COUNT = (COUNT or 0) + 1\nreturn {}"); |
||||
std::os::unix::fs::symlink(root.0.join("orig.lua"), root.0.join("alias.lua")).unwrap(); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (same, count): (bool, i64) = lua |
||||
.load(r#"return rawequal(require "orig", require "alias"), COUNT"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(same, "symlinked names must resolve to one module instance"); |
||||
assert_eq!(count, 1); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_may_use_async_stdlib() { |
||||
let root = TempRoot::new("async"); |
||||
root.write("sleepy.lua", "os.sleep(0.01)\nreturn 'awake'"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let got: String = lua.load(r#"return require "sleepy""#).eval_async().await.unwrap(); |
||||
assert_eq!(got, "awake"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_error_propagates_and_is_not_cached() { |
||||
let root = TempRoot::new("boom"); |
||||
root.write("boom.lua", "error('kaboom')"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
for _ in 0..2 { |
||||
// The second attempt must re-run the module (and fail the same way),
|
||||
// not report a bogus circular require or return a cached value.
|
||||
let err = lua.load(r#"require "boom""#).exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("kaboom"), "{err}"); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_with_shebang_and_bom() { |
||||
let root = TempRoot::new("shebang"); |
||||
root.write("sh.lua", "\u{FEFF}#!/usr/bin/env luna\nreturn 7"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let got: i64 = lua.load(r#"return require "sh""#).eval_async().await.unwrap(); |
||||
assert_eq!(got, 7); |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -1,509 +0,0 @@ |
||||
//! Tests for the tui module: `tui.styled` behavior and the prompt argument
|
||||
//! validation that fires *before* the terminal is touched (so it's
|
||||
//! deterministic in CI, tty or not). The interactive halves of the prompts
|
||||
//! are exercised manually — see docs/tui.md.
|
||||
|
||||
use super::lua; |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// module surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test] |
||||
fn test_tui_global_has_all_functions() { |
||||
let lua = lua(); |
||||
for f in [ |
||||
"confirm", |
||||
"promptText", |
||||
"promptLongText", |
||||
"promptSelect", |
||||
"promptSecret", |
||||
"promptNumber", |
||||
"promptInt", |
||||
"promptDate", |
||||
"styled", |
||||
"escape", |
||||
"block", |
||||
"success", |
||||
"error", |
||||
"warning", |
||||
"caution", |
||||
"info", |
||||
"note", |
||||
"comment", |
||||
"outlineBlock", |
||||
"outlineSuccess", |
||||
"outlineError", |
||||
"outlineWarning", |
||||
"outlineNote", |
||||
"outlineInfo", |
||||
"outlineCaution", |
||||
"raw", |
||||
"screenInfo", |
||||
"addPreset", |
||||
"moveTo", |
||||
"move", |
||||
"saveCursor", |
||||
"restoreCursor", |
||||
"cursorPos", |
||||
"showCursor", |
||||
"cursorStyle", |
||||
"clearLine", |
||||
"clearScreen", |
||||
"altScreen", |
||||
"scrollRegion", |
||||
"scroll", |
||||
"setTitle", |
||||
"setClipboard", |
||||
"reset", |
||||
] { |
||||
let is_fn: bool = lua |
||||
.load(format!("return type(tui.{f}) == 'function'")) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(is_fn, "tui.{f} is missing or not a function"); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn test_require_returns_the_global() { |
||||
let lua = lua(); |
||||
// require needs roots installed; builtins resolve without touching the fs.
|
||||
crate::stdlib::require::install(&lua, vec![]).unwrap(); |
||||
let same: bool = lua.load(r#"return require("tui") == tui"#).eval().unwrap(); |
||||
assert!(same); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tui.styled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NOTE: whether escape codes are emitted depends on the test process's
|
||||
// stdout (colored's tty/NO_COLOR detection), so these assertions only check
|
||||
// properties that hold in both color modes. Exact byte output is pinned by
|
||||
// the unit tests in stdlib/tui/markup.rs, which bypass the global detection.
|
||||
|
||||
#[test] |
||||
fn test_styled_returns_payload_text() { |
||||
let lua = lua(); |
||||
let out: String = lua |
||||
.load(r#"return tui.styled("a <info>b</info> <fg=red;options=bold>c</> d")"#) |
||||
.eval() |
||||
.unwrap(); |
||||
// Tags are consumed, payload text survives in order.
|
||||
assert!(!out.contains("<info>"), "tag not consumed: {out:?}"); |
||||
assert!(!out.contains("fg=red"), "tag not consumed: {out:?}"); |
||||
let stripped: String = out.chars().filter(|c| " abcd".contains(*c)).collect(); |
||||
assert_eq!(stripped, "a b c d"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_styled_resolves_escapes_and_keeps_invalid_tags() { |
||||
let lua = lua(); |
||||
let out: String = lua |
||||
.load(r#"return tui.styled("\\<info>x <notatag> y")"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(out.contains("<info>x"), "escape not resolved: {out:?}"); |
||||
assert!(out.contains("<notatag>"), "invalid tag should stay literal: {out:?}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_styled_shorthand_and_html_tags_are_consumed() { |
||||
let lua = lua(); |
||||
let out: String = lua |
||||
.load(r#"return tui.styled("<red;on-white;bold>a</> <b>b</b> <i>c</i> <u>d</u> <s>e</s>")"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(!out.contains('<'), "tags not consumed: {out:?}"); |
||||
let stripped: String = out.chars().filter(|c| " abcde".contains(*c)).collect(); |
||||
assert_eq!(stripped, "a b c d e"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_add_preset() { |
||||
let lua = lua(); |
||||
// A registered preset works as a styled tag and as a block style.
|
||||
let out: String = lua |
||||
.load( |
||||
r#" |
||||
tui.addPreset("keyword", "fg=white;bg=magenta") |
||||
tui.addPreset("kw2", "keyword") -- specs may reference other presets |
||||
return tui.styled("<keyword>foo</keyword> <kw2>bar</>") |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(!out.contains("<keyword>"), "preset tag not consumed: {out:?}"); |
||||
assert!(out.contains("foo") && out.contains("bar")); |
||||
lua.load(r#"tui.block("x", {style = "keyword"})"#).exec().unwrap(); |
||||
|
||||
// Presets are per Lua state: a fresh state doesn't see "keyword".
|
||||
let other = super::lua(); |
||||
let out: String = other.load(r#"return tui.styled("<keyword>foo</keyword>")"#).eval().unwrap(); |
||||
assert!(out.contains("<keyword>"), "preset leaked across states: {out:?}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_add_preset_validation() { |
||||
let lua = lua(); |
||||
for (src, expect) in [ |
||||
("tui.addPreset(1, 'fg=red')", "tui.addPreset: name must be a string (got integer)"), |
||||
("tui.addPreset('2fast', 'fg=red')", "tui.addPreset: name must start with a letter"), |
||||
("tui.addPreset('a=b', 'fg=red')", "tui.addPreset: name must start with a letter"), |
||||
("tui.addPreset('kw', 'fg=notacolor')", "tui.addPreset: invalid style 'fg=notacolor'"), |
||||
("tui.addPreset('kw', 42)", "tui.addPreset: style must be a string (got integer)"), |
||||
] { |
||||
let err = lua.load(src).exec().unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); |
||||
} |
||||
} |
||||
|
||||
#[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(); |
||||
let err = lua.load(r#"return tui.styled(42)"#).eval::<String>().unwrap_err(); |
||||
assert!(err.to_string().contains("tui.styled:"), "{err}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_styled_action_tags_are_consumed() { |
||||
let lua = lua(); |
||||
// Valid action tags are consumed in both color modes (bytes are pinned in
|
||||
// stdlib/tui/markup.rs); invalid ones stay literal.
|
||||
let out: String = lua |
||||
.load(r#"return tui.styled("a<up=3><clear=line>b <notanop=1> c")"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(!out.contains("up=3") && !out.contains("clear"), "{out:?}"); |
||||
assert!(out.contains("<notanop=1>"), "invalid tag should stay literal: {out:?}"); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prompt argument validation (fires before any terminal interaction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run a prompt call and assert it fails with a message carrying `expect`.
|
||||
async fn assert_prompt_error(src: &str, expect: &str) { |
||||
let lua = lua(); |
||||
let err = lua.load(src).exec_async().await.unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_prompt_text_arg_validation() { |
||||
assert_prompt_error("tui.confirm(42)", "tui.confirm: prompt text must be a string (got integer)") |
||||
.await; |
||||
assert_prompt_error("tui.confirm('q', 'yes')", "tui.confirm: default must be a boolean").await; |
||||
assert_prompt_error( |
||||
"tui.promptText('q', 42)", |
||||
"tui.promptText: default must be a string (got integer)", |
||||
) |
||||
.await; |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_unknown_and_mistyped_options() { |
||||
assert_prompt_error( |
||||
"tui.promptText('q', nil, {placholder='x'})", |
||||
"tui.promptText: unknown option 'placholder' (allowed: ", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.confirm('q', nil, {help=42})", |
||||
"tui.confirm: option 'help' must be a string (got integer)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptText('q', nil, {maxLen='five'})", |
||||
"tui.promptText: option 'maxLen' must be a non-negative integer", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptText('q', nil, {minLen=5, maxLen=2})", |
||||
"tui.promptText: min (5) must not be greater than max (2)", |
||||
) |
||||
.await; |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_select_validation() { |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q')", |
||||
"tui.promptSelect: opts table with 'options' is required", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', nil, {})", |
||||
"tui.promptSelect: opts table with 'options' is required", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', nil, {options={}})", |
||||
"tui.promptSelect: options must not be empty", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', nil, {options={'a', {}}})", |
||||
"tui.promptSelect: options[2] must be a string (got table)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', 'nope', {options={'a','b'}})", |
||||
"tui.promptSelect: default 'nope' is not one of the options", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', {'a'}, {options={'a','b'}})", |
||||
"tui.promptSelect: default must be a string (got table)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', {'a','nope'}, {options={'a','b'}, multiple=true})", |
||||
"tui.promptSelect: default 'nope' is not one of the options", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptSelect('q', nil, {options={'a'}, minSelected=1})", |
||||
"tui.promptSelect: option 'minSelected' requires multiple = true", |
||||
) |
||||
.await; |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_password_and_number_validation() { |
||||
assert_prompt_error( |
||||
"tui.promptSecret('q', {display='partial'})", |
||||
"tui.promptSecret: option 'display' must be one of 'masked', 'hidden', 'full' (got 'partial')", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptNumber('q', 'five')", |
||||
"tui.promptNumber: default must be a number (got string)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptNumber('q', nil, {min=10, max=1})", |
||||
"tui.promptNumber: min (10) must not be greater than max (1)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptInt('q', 1.5)", |
||||
"tui.promptInt: default must be an integer (got 1.5)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptInt('q', nil, {min=1.5})", |
||||
"tui.promptInt: option 'min' must be an integer (got 1.5)", |
||||
) |
||||
.await; |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_date_validation() { |
||||
assert_prompt_error( |
||||
"tui.promptDate('q', 'tomorrow')", |
||||
"tui.promptDate: default must be a YYYY-MM-DD string (got 'tomorrow')", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptDate('q', nil, {min='2026-02-30'})", |
||||
"tui.promptDate: option 'min' must be a YYYY-MM-DD string", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptDate('q', nil, {min='2026-06-01', max='2026-01-01'})", |
||||
"tui.promptDate: min (2026-06-01) must not be greater than max (2026-01-01)", |
||||
) |
||||
.await; |
||||
assert_prompt_error( |
||||
"tui.promptDate('q', nil, {weekStart='someday'})", |
||||
"tui.promptDate: option 'weekStart' must be a weekday name like 'monday' (got 'someday')", |
||||
) |
||||
.await; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// blocks / raw / screenInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
// The block functions print to stdout; their rendering is pinned by unit
|
||||
// tests in stdlib/tui/blocks.rs. Here we exercise argument validation and
|
||||
// the informational surface.
|
||||
|
||||
#[test] |
||||
fn test_block_validation() { |
||||
let lua = lua(); |
||||
for (src, expect) in [ |
||||
("tui.success(42)", "tui.success: message must be a string or an array of strings (got integer)"), |
||||
("tui.block({})", "tui.block: message must not be empty"), |
||||
("tui.block({'a', 1})", "tui.block: message[2] must be a string (got integer)"), |
||||
("tui.block('x', {style='notastyle'})", "tui.block: invalid style 'notastyle'"), |
||||
("tui.outlineBlock('x', {label='X'})", "tui.outlineBlock: unknown option 'label'"), |
||||
("tui.note('x', {title='X'})", "tui.note: unknown option 'title'"), |
||||
] { |
||||
let err = lua.load(src).exec().unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn test_block_functions_print_without_error() { |
||||
let lua = lua(); |
||||
// Not asserting on stdout (captured by the test harness); this pins that
|
||||
// valid calls succeed for both shapes and with option overrides.
|
||||
lua.load( |
||||
r#" |
||||
tui.success("all good") |
||||
tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true}) |
||||
tui.outlineError("boom") |
||||
tui.outlineBlock("custom", {title="Box", style="gray;bold", padding=false}) |
||||
tui.raw("progress ", 42, "\r") |
||||
"#, |
||||
) |
||||
.exec() |
||||
.unwrap(); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_screen_info_shape() { |
||||
let lua = lua(); |
||||
let (tty, colors, width_type): (bool, bool, String) = lua |
||||
.load( |
||||
r#" |
||||
local i = tui.screenInfo() |
||||
return i.tty, i.colors, type(i.width) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
// Under cargo test stdout is not a terminal; just pin the field types.
|
||||
let _ = (tty, colors); |
||||
assert!(width_type == "number" || width_type == "nil", "width: {width_type}"); |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// terminal control (tui.moveTo & co.)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument validation fires before the tty check, so these are deterministic
|
||||
// under the test harness. The emitted bytes are pinned by the unit tests in
|
||||
// stdlib/tui/term.rs and markup.rs.
|
||||
|
||||
#[test] |
||||
fn test_term_control_validation() { |
||||
let lua = lua(); |
||||
for (src, expect) in [ |
||||
("tui.moveTo()", "tui.moveTo: row and col cannot both be nil"), |
||||
("tui.moveTo(0, 1)", "tui.moveTo: row must be a positive integer (got 0)"), |
||||
("tui.moveTo(nil, 'x')", "tui.moveTo: col must be a positive integer (got string)"), |
||||
("tui.move('x')", "tui.move: dx must be an integer (got string)"), |
||||
("tui.move(1, 2.5)", "tui.move: dy must be an integer (got 2.5)"), |
||||
("tui.clearLine(2)", "tui.clearLine: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got 2"), |
||||
("tui.clearScreen(1.5)", "tui.clearScreen: mode must be"), |
||||
("tui.altScreen()", "tui.altScreen: expected a boolean (got nil)"), |
||||
("tui.altScreen(1)", "tui.altScreen: expected a boolean (got integer)"), |
||||
("tui.showCursor('yes')", "tui.showCursor: expected a boolean or nil (got string)"), |
||||
("tui.cursorStyle('blocky')", "tui.cursorStyle: style must be 'block', 'underline' or 'bar' (got 'blocky')"), |
||||
("tui.cursorStyle('bar', {blnk=true})", "tui.cursorStyle: unknown option 'blnk'"), |
||||
("tui.cursorStyle(nil, {blink=true})", "tui.cursorStyle: a style is required when 'blink' is set"), |
||||
("tui.scrollRegion(5)", "tui.scrollRegion: top and bottom must both be given"), |
||||
("tui.scrollRegion(5, 2)", "tui.scrollRegion: top (5) must be less than bottom (2)"), |
||||
("tui.scrollRegion(0, 5)", "tui.scrollRegion: top must be a positive integer (got 0)"), |
||||
("tui.scroll()", "tui.scroll: expected an integer (got nil)"), |
||||
("tui.setTitle(42)", "tui.setTitle: title must be a string (got integer)"), |
||||
("tui.setTitle('a\\nb')", "tui.setTitle: title must not contain control characters"), |
||||
("tui.setClipboard(42)", "tui.setClipboard: text must be a string (got integer)"), |
||||
] { |
||||
let err = lua.load(src).exec().unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}"); |
||||
} |
||||
} |
||||
|
||||
/// Off-tty every control function validates and then does nothing — no error,
|
||||
/// no control bytes in piped output. Skipped when the harness's stdout is a
|
||||
/// terminal (the calls would then really move the cursor around the runner).
|
||||
#[test] |
||||
fn test_term_control_off_tty_is_noop() { |
||||
use std::io::IsTerminal; |
||||
if std::io::stdout().is_terminal() { |
||||
return; |
||||
} |
||||
let lua = lua(); |
||||
lua.load( |
||||
r#" |
||||
tui.moveTo(1, 1); tui.moveTo(2); tui.moveTo(nil, 3) |
||||
tui.move(2, -1); tui.move(0, 0); tui.move() |
||||
tui.saveCursor(); tui.restoreCursor() |
||||
tui.showCursor(false); tui.showCursor() |
||||
tui.cursorStyle("bar", {blink=true}); tui.cursorStyle() |
||||
tui.clearLine(); tui.clearLine(-1); tui.clearLine(1) |
||||
tui.clearScreen(); tui.clearScreen(-1); tui.clearScreen(1) |
||||
tui.altScreen(true); tui.altScreen(false) |
||||
tui.scrollRegion(2, 10); tui.scrollRegion() |
||||
tui.scroll(3); tui.scroll(-2); tui.scroll(0) |
||||
tui.setTitle("test"); tui.setTitle("") |
||||
tui.setClipboard("clip") |
||||
tui.reset() |
||||
"#, |
||||
) |
||||
.exec() |
||||
.unwrap(); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_cursor_pos_off_tty_is_nil() { |
||||
use std::io::IsTerminal; |
||||
if std::io::stdout().is_terminal() { |
||||
return; |
||||
} |
||||
let lua = lua(); |
||||
let (row, col): (Option<u32>, Option<u32>) = |
||||
lua.load("return tui.cursorPos()").eval_async().await.unwrap(); |
||||
assert_eq!((row, col), (None, None)); |
||||
} |
||||
|
||||
/// Without a terminal, an actual prompt attempt must fail — with the
|
||||
/// function's error prefix, not a panic. The exact wording (NotTTY vs an IO
|
||||
/// error) varies by environment, so only the prefix is asserted.
|
||||
///
|
||||
/// Skipped when `cargo test` has any way to reach a terminal: the prompt
|
||||
/// would then really render (garbling the runner output with raw-mode
|
||||
/// artifacts) and hang waiting for a key. That includes redirected runs in an
|
||||
/// interactive shell — crossterm falls back to `/dev/tty` when stdin is not a
|
||||
/// terminal. Only truly terminal-less environments (CI) exercise this.
|
||||
#[tokio::test] |
||||
async fn test_prompting_off_tty_errors_with_prefix() { |
||||
use std::io::IsTerminal; |
||||
let has_tty = std::io::stdin().is_terminal() |
||||
|| std::io::stderr().is_terminal() |
||||
|| std::fs::File::open("/dev/tty").is_ok(); |
||||
if has_tty { |
||||
return; |
||||
} |
||||
let lua = lua(); |
||||
let err = lua.load("tui.confirm('proceed?')").exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("tui.confirm:"), "{err}"); |
||||
} |
||||
@ -1,813 +0,0 @@ |
||||
//! Tests for the `utils` global: NULL sentinel, isNull, toJSON/fromJSON, dump
|
||||
//! (Rust side) and try, tryn (Lua side, lua/stdlib/utils.lua).
|
||||
//!
|
||||
//! Ported from flowbox-rt fb_lua utils_tests. `utils.dump` is the flowbox Rust
|
||||
//! implementation (src/stdlib/lua_dump.rs), so the dump tests match flowbox's.
|
||||
|
||||
use super::json_eq; |
||||
|
||||
#[test] |
||||
fn test_dump() { |
||||
let lua = super::lua(); |
||||
|
||||
let dump = |code: &str| -> String { |
||||
lua.load(format!("return utils.dump({})", code)) |
||||
.eval::<String>() |
||||
.unwrap() |
||||
}; |
||||
|
||||
// nil
|
||||
assert_eq!(dump("nil"), "nil"); |
||||
|
||||
// booleans
|
||||
assert_eq!(dump("true"), "true"); |
||||
assert_eq!(dump("false"), "false"); |
||||
|
||||
// integers
|
||||
assert_eq!(dump("1"), "1"); |
||||
assert_eq!(dump("0"), "0"); |
||||
assert_eq!(dump("-42"), "-42"); |
||||
|
||||
// floats - whole numbers show .0 (Lua 5.4 tostring)
|
||||
assert_eq!(dump("1.0"), "1.0"); |
||||
assert_eq!(dump("0.0"), "0.0"); |
||||
assert_eq!(dump("-5.0"), "-5.0"); |
||||
|
||||
// floats - with fractional part
|
||||
assert_eq!(dump("1.5"), "1.5"); |
||||
assert_eq!(dump("-3.14159"), "-3.14159"); |
||||
assert_eq!(dump("0.001"), "0.001"); |
||||
|
||||
// strings are quoted and escaped JSON-style
|
||||
assert_eq!(dump("'hello'"), "\"hello\""); |
||||
assert_eq!(dump("''"), "\"\""); // empty
|
||||
assert_eq!(dump("'with spaces'"), "\"with spaces\""); |
||||
assert_eq!(dump("'special\\nchars\\ttab'"), "\"special\\nchars\\ttab\""); |
||||
|
||||
// empty table
|
||||
assert_eq!(dump("{}"), "{}"); |
||||
|
||||
// simple table with string key
|
||||
assert_eq!(dump("{foo=1}"), "{foo=1}"); |
||||
assert_eq!(dump("{_foo=1}"), "{_foo=1}"); |
||||
assert_eq!(dump("{_1=1}"), "{_1=1}"); |
||||
// a string key that does not look like an identifier is bracketed and quoted
|
||||
assert_eq!(dump("{[\"1\"]=1}"), "{[\"1\"]=1}"); |
||||
|
||||
// nested table
|
||||
assert_eq!(dump("{foo={bar=1}}"), "{foo={bar=1}}"); |
||||
|
||||
// array-style table (numeric keys)
|
||||
assert_eq!(dump("{10, 20, 30}"), "{10, 20, 30}"); |
||||
|
||||
// mixed keys - order may vary, so check by substrings
|
||||
let dumped = dump("{foo=1, [999]='a'}"); |
||||
assert!(dumped.contains("foo=1"), "got: {dumped}"); |
||||
assert!(dumped.contains("[999]=\"a\""), "got: {dumped}"); |
||||
|
||||
// a table with both an array part and hash keys dumps both
|
||||
assert_eq!(dump("{10, 20, foo='hash'}"), "{10, 20, foo=\"hash\"}"); |
||||
|
||||
// function
|
||||
assert_eq!(dump("function() end"), "<Function>"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_dump_deep_nesting() { |
||||
let lua = super::lua(); |
||||
|
||||
// Recursion limit: tables at depth > 20 are rendered as <TRUNCATED>
|
||||
// Build a table nested 25 levels deep - truncation happens at depth 21
|
||||
let deep_result: String = lua |
||||
.load( |
||||
r#" |
||||
local t = {val="leaf"} |
||||
for i = 1, 25 do |
||||
t = {nested=t} |
||||
end |
||||
return utils.dump(t) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
// 20 levels of {nested=, then the innermost table's value truncated at depth 21
|
||||
let expected = format!("{}{{nested=<TRUNCATED>}}{}", "{nested=".repeat(20), "}".repeat(20)); |
||||
assert_eq!(deep_result, expected); |
||||
|
||||
// A table just within the limit still dumps fully
|
||||
let shallow_result: String = lua |
||||
.load( |
||||
r#" |
||||
local t = {val="leaf"} |
||||
for i = 1, 5 do |
||||
t = {nested=t} |
||||
end |
||||
return utils.dump(t) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
let expected = format!("{}{{val=\"leaf\"}}{}", "{nested=".repeat(5), "}".repeat(5)); |
||||
assert_eq!(shallow_result, expected); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_dump_circular() { |
||||
let lua = super::lua(); |
||||
|
||||
// There is no cycle detection; the depth limit terminates the recursion,
|
||||
// so a self-referencing table dumps 21 levels and then truncates
|
||||
let result: String = lua |
||||
.load( |
||||
r#" |
||||
local t = {} |
||||
t.self = t |
||||
return utils.dump(t) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
let expected = format!("{}<TRUNCATED>{}", "{self=".repeat(21), "}".repeat(21)); |
||||
assert_eq!(result, expected); |
||||
|
||||
// The same table appearing twice as a sibling dumps twice
|
||||
let result: String = lua |
||||
.load( |
||||
r#" |
||||
local shared = {x = 1} |
||||
return utils.dump({shared, shared}) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, "{{x=1}, {x=1}}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_to_json() { |
||||
let lua = super::lua(); |
||||
|
||||
// Primitives
|
||||
let result: String = lua.load(r#"return utils.toJSON(nil)"#).eval().unwrap(); |
||||
assert_eq!(result, "null"); |
||||
|
||||
let result: String = lua.load(r#"return utils.toJSON(true)"#).eval().unwrap(); |
||||
assert_eq!(result, "true"); |
||||
|
||||
let result: String = lua.load(r#"return utils.toJSON(false)"#).eval().unwrap(); |
||||
assert_eq!(result, "false"); |
||||
|
||||
let result: String = lua.load(r#"return utils.toJSON(42)"#).eval().unwrap(); |
||||
assert_eq!(result, "42"); |
||||
|
||||
let result: String = lua.load(r#"return utils.toJSON(3.14)"#).eval().unwrap(); |
||||
assert_eq!(result, "3.14"); |
||||
|
||||
let result: String = lua.load(r#"return utils.toJSON("hello")"#).eval().unwrap(); |
||||
assert_eq!(result, "\"hello\""); |
||||
|
||||
// Empty table serializes as an empty object
|
||||
let result: String = lua.load(r#"return utils.toJSON({})"#).eval().unwrap(); |
||||
assert_eq!(result, "{}"); |
||||
|
||||
// Array-style table
|
||||
let result: String = lua.load(r#"return utils.toJSON({1, 2, 3})"#).eval().unwrap(); |
||||
assert_eq!(result, "[1,2,3]"); |
||||
|
||||
// Object-style table
|
||||
let result: String = lua.load(r#"return utils.toJSON({a=1})"#).eval().unwrap(); |
||||
assert_eq!(result, "{\"a\":1}"); |
||||
|
||||
// Nested structure
|
||||
let result: String = lua |
||||
.load(r#"return utils.toJSON({nested={value=42}})"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, "{\"nested\":{\"value\":42}}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_to_json_mixed_and_integer_keys() { |
||||
let lua = super::lua(); |
||||
|
||||
// A table with both a sequence part and hash keys is serialized as an
|
||||
// object; integer keys become string keys
|
||||
let result: String = lua |
||||
.load(r#"return utils.toJSON({1, 2, x = 3})"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(json_eq(&result, r#"{"1":1,"2":2,"x":3}"#), "got: {result}"); |
||||
|
||||
// Non-sequential integer keys become string object keys
|
||||
let result: String = lua |
||||
.load(r#"return utils.toJSON({a = 1, [10] = "x"})"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(json_eq(&result, r#"{"a":1,"10":"x"}"#), "got: {result}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_to_json_pretty() { |
||||
let lua = super::lua(); |
||||
|
||||
// Second argument enables pretty-printing
|
||||
let result: String = lua.load(r#"return utils.toJSON({a=1}, true)"#).eval().unwrap(); |
||||
assert_eq!(result, "{\n \"a\": 1\n}"); |
||||
|
||||
// Explicit false behaves like the default compact output
|
||||
let result: String = lua.load(r#"return utils.toJSON({a=1}, false)"#).eval().unwrap(); |
||||
assert_eq!(result, "{\"a\":1}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_to_json_unserializable_errors() { |
||||
let lua = super::lua(); |
||||
|
||||
// Unserializable values must raise an error instead of silently returning "null"
|
||||
let err = lua.load(r#"return utils.toJSON(function() end)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("toJSON"), "got: {err}"); |
||||
assert!(err.to_string().contains("function"), "got: {err}"); |
||||
|
||||
// A single bad value must not silently discard the whole structure
|
||||
let err = lua |
||||
.load(r#"return utils.toJSON({x = 1, f = function() end})"#) |
||||
.exec() |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("toJSON"), "got: {err}"); |
||||
|
||||
// Self-referencing tables cannot be serialized (caught by the depth limit)
|
||||
let err = lua |
||||
.load( |
||||
r#" |
||||
local t = {} |
||||
t.self = t |
||||
return utils.toJSON(t) |
||||
"#, |
||||
) |
||||
.exec() |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("toJSON"), "got: {err}"); |
||||
assert!(err.to_string().contains("nesting too deep"), "got: {err}"); |
||||
|
||||
// NaN and Infinity have no JSON representation
|
||||
let err = lua.load(r#"return utils.toJSON(0/0)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("NaN"), "got: {err}"); |
||||
|
||||
let err = lua.load(r#"return utils.toJSON(math.huge)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("Infinity"), "got: {err}"); |
||||
|
||||
// Only string and integer keys can become JSON object keys
|
||||
let err = lua.load(r#"return utils.toJSON({[true] = 1})"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("object key"), "got: {err}"); |
||||
|
||||
let err = lua.load(r#"return utils.toJSON({[1.5] = 1})"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("object key"), "got: {err}"); |
||||
|
||||
// An integer key that stringifies onto an existing string key must error
|
||||
// rather than silently dropping one of the two values.
|
||||
let err = lua |
||||
.load(r#"return utils.toJSON({[1] = "int", ["1"] = "str"})"#) |
||||
.exec() |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("duplicate object key"), "got: {err}"); |
||||
|
||||
// A non-UTF-8 string value gets a clear prefixed error, not mlua's bare
|
||||
// "error converting Lua string to &str".
|
||||
let err = lua.load(r#"return utils.toJSON("\255bad")"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("utils.toJSON"), "got: {err}"); |
||||
assert!(err.to_string().contains("UTF-8"), "got: {err}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_null_sentinel() { |
||||
let lua = super::lua(); |
||||
|
||||
// utils.NULL exists and is distinct from nil
|
||||
let result: bool = lua.load(r#"return utils.NULL ~= nil"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
// isNull identifies the sentinel and nothing else
|
||||
let result: bool = lua.load(r#"return utils.isNull(utils.NULL)"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
return utils.isNull(nil) or utils.isNull(0) or utils.isNull("") |
||||
or utils.isNull({}) or utils.isNull(false) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(!result); |
||||
|
||||
// JSON null deserializes to the sentinel - consistently at any nesting level
|
||||
let result: bool = lua.load(r#"return utils.isNull(utils.fromJSON("null"))"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local obj = utils.fromJSON('{"a": null, "b": 1}') |
||||
return utils.isNull(obj.a) and obj.a ~= nil and obj.b == 1 |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
// Nulls in arrays preserve the array length
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local arr = utils.fromJSON('[1, null, 3]') |
||||
return #arr == 3 and utils.isNull(arr[2]) and arr[1] == 1 and arr[3] == 3 |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
// The sentinel serializes back to JSON null
|
||||
let result: String = lua.load(r#"return utils.toJSON(utils.NULL)"#).eval().unwrap(); |
||||
assert_eq!(result, "null"); |
||||
|
||||
let result: String = lua.load(r#"return utils.toJSON({a = utils.NULL})"#).eval().unwrap(); |
||||
assert_eq!(result, "{\"a\":null}"); |
||||
|
||||
// dump renders the sentinel as NULL, bare and inside tables
|
||||
// (other light userdata dumps as <LightUserData>)
|
||||
let result: String = lua.load(r#"return utils.dump(utils.NULL)"#).eval().unwrap(); |
||||
assert_eq!(result, "NULL"); |
||||
|
||||
let result: String = lua.load(r#"return utils.dump({a = utils.NULL})"#).eval().unwrap(); |
||||
assert_eq!(result, "{a=NULL}"); |
||||
|
||||
let result: String = lua.load(r#"return utils.dump({1, utils.NULL, 3})"#).eval().unwrap(); |
||||
assert_eq!(result, "{1, NULL, 3}"); |
||||
} |
||||
|
||||
/// utils.NULL is mlua's own null sentinel (Value::NULL, a null-pointer light
|
||||
/// userdata), so nulls interoperate with mlua's serde layer in both directions.
|
||||
#[test] |
||||
fn test_null_sentinel_matches_mlua() { |
||||
use mlua::LuaSerdeExt; |
||||
|
||||
let lua = super::lua(); |
||||
|
||||
let null_val: mlua::Value = lua.load(r#"return utils.NULL"#).eval().unwrap(); |
||||
assert_eq!(null_val, mlua::Value::NULL); |
||||
|
||||
// a null produced by mlua (lua.null()) is recognized by utils.isNull
|
||||
let is_null: bool = lua |
||||
.globals() |
||||
.get::<mlua::Table>("utils") |
||||
.unwrap() |
||||
.get::<mlua::Function>("isNull") |
||||
.unwrap() |
||||
.call(lua.null()) |
||||
.unwrap(); |
||||
assert!(is_null); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_utils_table_extensible() { |
||||
let lua = super::lua(); |
||||
|
||||
// utils is a plain global table just like math and table - scripts can extend it
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
utils.myHelper = function() return 41 + 1 end |
||||
return utils.myHelper() |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 42); |
||||
|
||||
// and the built-in functions are of course still there
|
||||
let result: bool = lua.load(r#"return type(utils.try) == "function""#).eval().unwrap(); |
||||
assert!(result); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_try_tryn_arg_validation() { |
||||
let lua = super::lua(); |
||||
|
||||
// The callback must be a function, rejected with a clear message
|
||||
let err = lua.load(r#"return utils.try(5)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("not a function"), "got: {err}"); |
||||
|
||||
let err = lua.load(r#"return utils.try(nil)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("not a function"), "got: {err}"); |
||||
|
||||
let err = lua.load(r#"return utils.tryn(2, 5)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("not a function"), "got: {err}"); |
||||
|
||||
// expectedCount must be a non-negative integer
|
||||
let err = lua.load(r#"return utils.tryn(2.5, function() end)"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("integer"), "got: {err}"); |
||||
|
||||
assert!(lua.load(r#"return utils.tryn(-1, function() end)"#).exec().is_err()); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_try_error_values() { |
||||
let lua = super::lua(); |
||||
|
||||
// A thrown table is passed through as-is (pcall semantics), so scripts can
|
||||
// attach structured data to errors
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local v, err = utils.try(function() error({code = 42}) end) |
||||
return v == nil and type(err) == "table" and err.code == 42 |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
// A nil error value still yields a non-nil err, so `if err` always detects failure
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local v, err = utils.try(function() error() end) |
||||
return err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local a, err = utils.tryn(1, function() error() end) |
||||
return err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_tryn_return_counts() { |
||||
let lua = super::lua(); |
||||
|
||||
// tryn returns exactly expectedCount + 1 values (the last one is the error slot),
|
||||
// both on success and on failure
|
||||
let result: i64 = lua |
||||
.load(r#"return select('#', utils.tryn(2, function() return 1 end))"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 3); |
||||
|
||||
let result: i64 = lua |
||||
.load(r#"return select('#', utils.tryn(2, function() error("x") end))"#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 3); |
||||
|
||||
// expectedCount = 0 gives just the error slot
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local err = utils.tryn(0, function() return 1, 2 end) |
||||
return err == nil and select('#', utils.tryn(0, function() end)) == 1 |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_tryn_count_cap() { |
||||
let lua = super::lua(); |
||||
|
||||
// Excessive expected_count must error early instead of allocating huge buffers
|
||||
let err = lua |
||||
.load(r#"return utils.tryn(1000000, function() return 1 end)"#) |
||||
.exec() |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("at most"), "got: {err}"); |
||||
|
||||
let err = lua |
||||
.load(r#"return utils.tryn(65, function() return 1 end)"#) |
||||
.exec() |
||||
.unwrap_err(); |
||||
assert!(err.to_string().contains("at most"), "got: {err}"); |
||||
|
||||
// The maximum allowed count still works
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local r = {utils.tryn(64, function() return 42 end)} |
||||
return r[1] |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 42); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_from_json() { |
||||
let lua = super::lua(); |
||||
|
||||
// Primitives - JSON null becomes the utils.NULL sentinel, not nil
|
||||
lua.load(r#"assert(utils.isNull(utils.fromJSON("null")))"#).exec().unwrap(); |
||||
|
||||
let result: bool = lua.load(r#"return utils.fromJSON("true")"#).eval().unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: bool = lua.load(r#"return utils.fromJSON("false")"#).eval().unwrap(); |
||||
assert!(!result); |
||||
|
||||
let result: i64 = lua.load(r#"return utils.fromJSON("42")"#).eval().unwrap(); |
||||
assert_eq!(result, 42); |
||||
|
||||
// Whole JSON numbers arrive as Lua integers, fractional ones as floats
|
||||
let result: bool = lua |
||||
.load(r#"return math.type(utils.fromJSON("42")) == "integer""#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: f64 = lua.load(r#"return utils.fromJSON("3.14")"#).eval().unwrap(); |
||||
assert!((result - 3.14).abs() < 0.001); |
||||
|
||||
let result: bool = lua |
||||
.load(r#"return math.type(utils.fromJSON("3.14")) == "float""#) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
|
||||
let result: String = lua.load(r#"return utils.fromJSON('"hello"')"#).eval().unwrap(); |
||||
assert_eq!(result, "hello"); |
||||
|
||||
// Array
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local arr = utils.fromJSON("[1, 2, 3]") |
||||
return arr[1] + arr[2] + arr[3] |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 6); |
||||
|
||||
// Object
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local obj = utils.fromJSON('{"a": 10, "b": 20}') |
||||
return obj.a + obj.b |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 30); |
||||
|
||||
// Nested structure
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local data = utils.fromJSON('{"nested": {"value": 42}}') |
||||
return data.nested.value |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 42); |
||||
|
||||
// Invalid JSON should error, and the message names the function
|
||||
let err = lua.load(r#"return utils.fromJSON("not valid json")"#).exec().unwrap_err(); |
||||
assert!(err.to_string().contains("fromJSON"), "got: {err}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_json_roundtrip() { |
||||
let lua = super::lua(); |
||||
|
||||
// Round-trip test: Lua -> JSON -> Lua
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local original = {x = 10, y = 20, items = {1, 2, 3}} |
||||
local json = utils.toJSON(original) |
||||
local restored = utils.fromJSON(json) |
||||
return restored.x + restored.y + restored.items[1] |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 31); // 10 + 20 + 1
|
||||
|
||||
// NULL survives a Lua -> JSON -> Lua round trip
|
||||
let result: bool = lua |
||||
.load( |
||||
r#" |
||||
local restored = utils.fromJSON(utils.toJSON({a = utils.NULL})) |
||||
return utils.isNull(restored.a) |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(result); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_try() { |
||||
let lua = super::lua(); |
||||
|
||||
// Success case: returns value, nil error
|
||||
let (value, has_error): (i64, bool) = lua |
||||
.load( |
||||
r#" |
||||
local val, err = utils.try(function() return 42 end) |
||||
return val, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(value, 42); |
||||
assert!(!has_error); |
||||
|
||||
// Error case: returns nil value, error
|
||||
let (is_nil, has_error): (bool, bool) = lua |
||||
.load( |
||||
r#" |
||||
local val, err = utils.try(function() error("boom") end) |
||||
return val == nil, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(is_nil); |
||||
assert!(has_error); |
||||
|
||||
// Error message is preserved
|
||||
let contains_boom: bool = lua |
||||
.load( |
||||
r#" |
||||
local val, err = utils.try(function() error("boom") end) |
||||
return tostring(err):find("boom") ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(contains_boom); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_try_shorthand() { |
||||
let lua = super::lua(); |
||||
|
||||
// pcall-style shorthand: the function and its arguments, no closure needed
|
||||
let (value, has_error): (String, bool) = lua |
||||
.load( |
||||
r#" |
||||
local val, err = utils.try(string.rep, "ab", 3) |
||||
return val, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(value, "ababab"); |
||||
assert!(!has_error); |
||||
|
||||
// Errors thrown by the called function are captured the same way as with a closure
|
||||
let captured: bool = lua |
||||
.load( |
||||
r#" |
||||
local val, err = utils.try(error, "boom") |
||||
return val == nil and tostring(err):find("boom") ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(captured); |
||||
|
||||
// nil arguments are forwarded with the argument count preserved (pcall semantics)
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local function countArgs(...) return select('#', ...) end |
||||
local n = utils.try(countArgs, nil, nil, nil) |
||||
return n |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 3); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_tryn() { |
||||
let lua = super::lua(); |
||||
|
||||
// Success case with multiple return values
|
||||
let (a, b, c, has_error): (i64, i64, i64, bool) = lua |
||||
.load( |
||||
r#" |
||||
local a, b, c, err = utils.tryn(3, function() return 1, 2, 3 end) |
||||
return a, b, c, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!((a, b, c), (1, 2, 3)); |
||||
assert!(!has_error); |
||||
|
||||
// Error case: all values are nil, error is present
|
||||
let (all_nil, has_error): (bool, bool) = lua |
||||
.load( |
||||
r#" |
||||
local a, b, c, err = utils.tryn(3, function() error("fail") end) |
||||
return a == nil and b == nil and c == nil, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(all_nil); |
||||
assert!(has_error); |
||||
|
||||
// Fewer return values than expected: pads with nil
|
||||
let (a, b_nil, c_nil, has_error): (i64, bool, bool, bool) = lua |
||||
.load( |
||||
r#" |
||||
local a, b, c, err = utils.tryn(3, function() return 100 end) |
||||
return a, b == nil, c == nil, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(a, 100); |
||||
assert!(b_nil); |
||||
assert!(c_nil); |
||||
assert!(!has_error); |
||||
|
||||
// More return values than expected: truncates
|
||||
let (a, b, has_error): (i64, i64, bool) = lua |
||||
.load( |
||||
r#" |
||||
local a, b, err = utils.tryn(2, function() return 1, 2, 3, 4, 5 end) |
||||
return a, b, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!((a, b), (1, 2)); |
||||
assert!(!has_error); |
||||
} |
||||
|
||||
#[test] |
||||
fn test_tryn_shorthand() { |
||||
let lua = super::lua(); |
||||
|
||||
// pcall-style shorthand: arguments after the callback are passed to it
|
||||
let (q, r, has_error): (i64, i64, bool) = lua |
||||
.load( |
||||
r#" |
||||
local function divmod(a, b) return a // b, a % b end
|
||||
local q, r, err = utils.tryn(2, divmod, 17, 5) |
||||
return q, r, err ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!((q, r), (3, 2)); |
||||
assert!(!has_error); |
||||
|
||||
// Error case: arguments are passed, the error lands in the last slot
|
||||
let (all_nil, captured): (bool, bool) = lua |
||||
.load( |
||||
r#" |
||||
local function fail(msg) error("failed: " .. msg) end |
||||
local a, b, err = utils.tryn(2, fail, "badly") |
||||
return a == nil and b == nil, tostring(err):find("failed: badly") ~= nil |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert!(all_nil); |
||||
assert!(captured); |
||||
|
||||
// nil arguments are forwarded with the argument count preserved (pcall semantics)
|
||||
let result: i64 = lua |
||||
.load( |
||||
r#" |
||||
local function countArgs(...) return select('#', ...) end |
||||
local n, err = utils.tryn(1, countArgs, nil, 5, nil) |
||||
return n |
||||
"#, |
||||
) |
||||
.eval() |
||||
.unwrap(); |
||||
assert_eq!(result, 3); |
||||
} |
||||
@ -1,258 +0,0 @@ |
||||
//! `fs` — whole-file filesystem access behind the folder-based permission
|
||||
//! system ([`super::perm`]).
|
||||
//!
|
||||
//! v1 keeps the API deliberately small: `fs.readAll` / `fs.writeAll` move a
|
||||
//! whole file to/from a Lua string, `fs.list` names a directory's entries,
|
||||
//! `fs.isDir` / `fs.isFile` are existence-and-type predicates, and
|
||||
//! `fs.access(dir, "r"|"w")` runs the permission cycle explicitly.
|
||||
//!
|
||||
//! Every operation is checked against a per-script, per-directory grant:
|
||||
//! recorded answers live in `access.json`, unknown ones are asked on the
|
||||
//! terminal — `y`/`n` for this run, `A`/`N` recorded. Grants are recursive
|
||||
//! (a granted parent covers all children) and keyed by the script's
|
||||
//! canonical path; the REPL prompts the same way but never persists.
|
||||
//! `--allow-fs`, `--no-fs` and `--sandbox` statically override the
|
||||
//! interactive checks (see `main.rs`).
|
||||
//!
|
||||
//! `sqlite.connect` and http's cookie-jar files route through the same
|
||||
//! checks via [`super::perm::ensure_dir_access`].
|
||||
|
||||
use std::path::{Path, PathBuf}; |
||||
|
||||
use mlua::prelude::{LuaResult, LuaString}; |
||||
use mlua::Lua; |
||||
|
||||
use super::perm::{self, Wants}; |
||||
|
||||
/// App-data slot for the main script's real (symlink-resolved) directory.
|
||||
/// `main` plants it before running a script; absent in the REPL and in bare
|
||||
/// test states, where `fs.getScriptDir()` returns nil.
|
||||
pub(crate) struct ScriptDir(pub(crate) PathBuf); |
||||
|
||||
/// `mlua::Error::external(format!(...))` — every Lua-facing error in this
|
||||
/// module goes through this, prefixed `fs.<fn>: …`.
|
||||
macro_rules! ext_err { |
||||
($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) }; |
||||
} |
||||
|
||||
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||
let fs = lua.create_table()?; |
||||
|
||||
fs.set( |
||||
"readAll", |
||||
lua.create_async_function(|lua, path: LuaString| async move { |
||||
const F: &str = "fs.readAll"; |
||||
let path = to_path(&path); |
||||
let (dir, target) = read_target(F, &path).await?; |
||||
perm::ensure_dir_access(&lua, F, dir, Wants::READ).await?; |
||||
let bytes = tokio::fs::read(&target) |
||||
.await |
||||
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?; |
||||
lua.create_string(&bytes) |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"writeAll", |
||||
lua.create_async_function(|lua, (path, content): (LuaString, LuaString)| async move { |
||||
const F: &str = "fs.writeAll"; |
||||
let path = to_path(&path); |
||||
let (dir, target) = write_target(F, &path).await?; |
||||
perm::ensure_dir_access(&lua, F, dir, Wants::WRITE).await?; |
||||
tokio::fs::write(&target, &content.as_bytes()) |
||||
.await |
||||
.map_err(|e| ext_err!("{F}: {}: {e}", path.display())) |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"list", |
||||
lua.create_async_function(|lua, path: LuaString| async move { |
||||
const F: &str = "fs.list"; |
||||
let path = to_path(&path); |
||||
let canon = canonicalize(F, &path).await?; |
||||
let meta = metadata(F, &path, &canon).await?; |
||||
if !meta.is_dir() { |
||||
return Err(ext_err!("{F}: {}: not a directory", path.display())); |
||||
} |
||||
perm::ensure_dir_access(&lua, F, canon.clone(), Wants::READ).await?; |
||||
|
||||
let mut entries = tokio::fs::read_dir(&canon) |
||||
.await |
||||
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?; |
||||
let mut names: Vec<Vec<u8>> = Vec::new(); |
||||
while let Some(entry) = entries |
||||
.next_entry() |
||||
.await |
||||
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))? |
||||
{ |
||||
use std::os::unix::ffi::OsStrExt; |
||||
names.push(entry.file_name().as_bytes().to_vec()); |
||||
} |
||||
// read_dir order is arbitrary; sorted output is deterministic.
|
||||
names.sort_unstable(); |
||||
let list = lua.create_table_with_capacity(names.len(), 0)?; |
||||
for name in &names { |
||||
list.raw_push(lua.create_string(name)?)?; |
||||
} |
||||
Ok(list) |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"isDir", |
||||
lua.create_async_function(|lua, path: LuaString| async move { |
||||
let path = to_path(&path); |
||||
let Ok(canon) = tokio::fs::canonicalize(&path).await else { |
||||
return Ok(false); // nonexistent: false without prompting
|
||||
}; |
||||
match tokio::fs::metadata(&canon).await { |
||||
Ok(meta) if meta.is_dir() => perm::check_dir_access(&lua, canon, Wants::READ).await, |
||||
_ => Ok(false), |
||||
} |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"isFile", |
||||
lua.create_async_function(|lua, path: LuaString| async move { |
||||
let path = to_path(&path); |
||||
let Ok(canon) = tokio::fs::canonicalize(&path).await else { |
||||
return Ok(false); |
||||
}; |
||||
match tokio::fs::metadata(&canon).await { |
||||
Ok(meta) if meta.is_file() => { |
||||
perm::check_dir_access(&lua, parent_dir(&canon), Wants::READ).await |
||||
} |
||||
_ => Ok(false), |
||||
} |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"getWorkDir", |
||||
lua.create_function(|lua, ()| { |
||||
// Process state, not filesystem content — no permission check.
|
||||
// This is what relative paths in the other fs functions (and in
|
||||
// fs.access probes) resolve against.
|
||||
use std::os::unix::ffi::OsStrExt; |
||||
let cwd = std::env::current_dir() |
||||
.map_err(|e| ext_err!("fs.getWorkDir: {e}"))?; |
||||
lua.create_string(cwd.as_os_str().as_bytes()) |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"getScriptDir", |
||||
lua.create_function(|lua, ()| { |
||||
// The real (symlink-resolved) directory of the main script, set
|
||||
// by main before execution — the stable base for a script's
|
||||
// bundled assets, independent of the CWD it was invoked from.
|
||||
// Not permission-gated: it names a place, reads no content.
|
||||
match lua.app_data_ref::<ScriptDir>() { |
||||
Some(dir) => { |
||||
use std::os::unix::ffi::OsStrExt; |
||||
Ok(Some(lua.create_string(dir.0.as_os_str().as_bytes())?)) |
||||
} |
||||
None => Ok(None), // REPL: no script to have a directory
|
||||
} |
||||
})?, |
||||
)?; |
||||
|
||||
fs.set( |
||||
"access", |
||||
lua.create_async_function(|lua, (dir, mode): (LuaString, LuaString)| async move { |
||||
const F: &str = "fs.access"; |
||||
let wants = match &*mode.as_bytes() { |
||||
b"r" => Wants::READ, |
||||
b"w" => Wants::WRITE, |
||||
_ => return Err(ext_err!("{F}: mode must be \"r\" or \"w\"")), |
||||
}; |
||||
let dir = to_path(&dir); |
||||
let canon = canonicalize(F, &dir).await?; |
||||
let meta = metadata(F, &dir, &canon).await?; |
||||
if !meta.is_dir() { |
||||
return Err(ext_err!("{F}: {}: not a directory", dir.display())); |
||||
} |
||||
perm::check_dir_access(&lua, canon, wants).await |
||||
})?, |
||||
)?; |
||||
|
||||
lua.globals().raw_set("fs", fs)?; |
||||
Ok(()) |
||||
} |
||||
|
||||
/// Lua strings are byte strings; on unix so are paths.
|
||||
fn to_path(s: &LuaString) -> PathBuf { |
||||
use std::os::unix::ffi::OsStrExt; |
||||
PathBuf::from(std::ffi::OsStr::from_bytes(&s.as_bytes())) |
||||
} |
||||
|
||||
/// The permission unit for operations on a file: its containing directory.
|
||||
fn parent_dir(canon: &Path) -> PathBuf { |
||||
canon.parent().unwrap_or(Path::new("/")).to_path_buf() |
||||
} |
||||
|
||||
async fn canonicalize(fname: &str, path: &Path) -> LuaResult<PathBuf> { |
||||
tokio::fs::canonicalize(path) |
||||
.await |
||||
.map_err(|e| ext_err!("{fname}: {}: {e}", path.display())) |
||||
} |
||||
|
||||
async fn metadata(fname: &str, path: &Path, canon: &Path) -> LuaResult<std::fs::Metadata> { |
||||
tokio::fs::metadata(canon) |
||||
.await |
||||
.map_err(|e| ext_err!("{fname}: {}: {e}", path.display())) |
||||
} |
||||
|
||||
/// Resolve an existing regular file into (canonical containing dir = the
|
||||
/// permission unit, canonical file). Shared with http's cookie-jar load.
|
||||
pub(super) async fn read_target(fname: &'static str, path: &Path) -> LuaResult<(PathBuf, PathBuf)> { |
||||
let canon = canonicalize(fname, path).await?; |
||||
let meta = metadata(fname, path, &canon).await?; |
||||
if !meta.is_file() { |
||||
return Err(ext_err!("{fname}: {}: not a regular file", path.display())); |
||||
} |
||||
Ok((parent_dir(&canon), canon)) |
||||
} |
||||
|
||||
/// Resolve a write destination that may not exist yet into (canonical
|
||||
/// containing dir = the permission unit, write target). The containing
|
||||
/// directory must exist — v1 has no mkdir-p. Shared with http's cookie-jar
|
||||
/// save and sqlite's connect.
|
||||
pub(super) async fn write_target( |
||||
fname: &'static str, |
||||
path: &Path, |
||||
) -> LuaResult<(PathBuf, PathBuf)> { |
||||
match tokio::fs::canonicalize(path).await { |
||||
Ok(canon) => { |
||||
let meta = metadata(fname, path, &canon).await?; |
||||
if meta.is_dir() { |
||||
return Err(ext_err!("{fname}: {}: is a directory", path.display())); |
||||
} |
||||
Ok((parent_dir(&canon), canon)) |
||||
} |
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
||||
// A dangling symlink would make write() land at a location the
|
||||
// permission check never saw; refuse it.
|
||||
if let Ok(meta) = tokio::fs::symlink_metadata(path).await |
||||
&& meta.file_type().is_symlink() |
||||
{ |
||||
return Err(ext_err!("{fname}: {}: dangling symlink", path.display())); |
||||
} |
||||
let Some(name) = path.file_name() else { |
||||
return Err(ext_err!("{fname}: {}: not a file path", path.display())); |
||||
}; |
||||
let parent = match path.parent() { |
||||
Some(p) if !p.as_os_str().is_empty() => p, |
||||
_ => Path::new("."), |
||||
}; |
||||
let dir = tokio::fs::canonicalize(parent).await.map_err(|e| { |
||||
ext_err!("{fname}: {}: parent directory: {e}", path.display()) |
||||
})?; |
||||
let target = dir.join(name); |
||||
Ok((dir, target)) |
||||
} |
||||
Err(e) => Err(ext_err!("{fname}: {}: {e}", path.display())), |
||||
} |
||||
} |
||||
@ -1,116 +0,0 @@ |
||||
use std::borrow::Cow; |
||||
|
||||
use mlua::Value as LuaValue; |
||||
use serde_json::Value as JsonValue; |
||||
|
||||
/// Pretty-print a Lua value for humans (the backend of `utils.dump`).
|
||||
/// Ported from flowbox-rt's fb_lua lua_dump.rs, adapted to mlua 0.11.
|
||||
pub(super) fn dump(value: LuaValue) -> Cow<'static, str> { |
||||
dump_inner(value, DumpPosition::Value, 0) |
||||
} |
||||
|
||||
enum DumpPosition { |
||||
Key, |
||||
Value, |
||||
} |
||||
|
||||
fn keywrap(value: Cow<'static, str>, position: DumpPosition) -> Cow<'static, str> { |
||||
match position { |
||||
DumpPosition::Key => format!("[{value}]").into(), |
||||
DumpPosition::Value => value, |
||||
} |
||||
} |
||||
|
||||
fn dump_inner(value: LuaValue, position: DumpPosition, recursion_depth: usize) -> Cow<'static, str> { |
||||
let res: Cow<'static, str> = match value { |
||||
LuaValue::Nil => "nil".into(), |
||||
LuaValue::Boolean(v) => if v { "true" } else { "false" }.into(), |
||||
LuaValue::Integer(v) => v.to_string().into(), |
||||
LuaValue::Number(v) => { |
||||
if v.fract() == 0.0 { |
||||
// ensure decimal places are shown to indicate it is a float
|
||||
format!("{:.1}", v.trunc()).into() |
||||
} else { |
||||
v.to_string().into() |
||||
} |
||||
} |
||||
LuaValue::String(v) => { |
||||
let stringified = v.to_string_lossy().to_string(); |
||||
match position { |
||||
DumpPosition::Key => { |
||||
// return to bypass the keywrap
|
||||
return if stringified.char_indices().any(|(index, c)| { |
||||
if index == 0 { |
||||
!c.is_ascii_alphabetic() && c != '_' |
||||
} else { |
||||
!c.is_ascii_alphanumeric() && c != '_' |
||||
} |
||||
}) { |
||||
// json-serialize the string to add quotes and escapes
|
||||
format!("[{}]", serde_json::to_string(&JsonValue::String(stringified)).unwrap()).into() |
||||
} else { |
||||
// string lua key can be used as is {foo="bar"}
|
||||
stringified.into() |
||||
}; |
||||
} |
||||
DumpPosition::Value => serde_json::to_string(&JsonValue::String(stringified)).unwrap().into(), |
||||
} |
||||
} |
||||
LuaValue::Table(t) => { |
||||
if recursion_depth > 20 { |
||||
// Flow through keywrap so a table used as a key still renders as
|
||||
// `[<TRUNCATED>]`, consistent with every other key.
|
||||
return keywrap("<TRUNCATED>".into(), position); |
||||
} |
||||
let recursion_depth = recursion_depth + 1; |
||||
|
||||
let mut buf = "{".to_string(); |
||||
|
||||
let mut first = true; |
||||
let mut list_next = Some(1); |
||||
for pair in t.pairs::<LuaValue, LuaValue>() { |
||||
let Ok((k, v)) = pair else { |
||||
list_next = None; |
||||
continue; |
||||
}; |
||||
|
||||
if !first { |
||||
buf.push_str(", "); |
||||
} |
||||
first = false; |
||||
|
||||
// special case for sequential numeric keys, starting at 1
|
||||
if let Some(list_index) = list_next { |
||||
if let LuaValue::Integer(index) = k |
||||
&& index == list_index |
||||
{ |
||||
buf.push_str(&dump_inner(v, DumpPosition::Value, recursion_depth)); |
||||
list_next = Some(list_index + 1); |
||||
continue; |
||||
} |
||||
list_next = None; |
||||
} |
||||
|
||||
buf.push_str(&format!( |
||||
"{}={}", |
||||
dump_inner(k, DumpPosition::Key, recursion_depth), |
||||
dump_inner(v, DumpPosition::Value, recursion_depth) |
||||
)); |
||||
} |
||||
|
||||
buf.push('}'); |
||||
buf.into() |
||||
} |
||||
// The null pointer lightuserdata is the JSON null sentinel (utils.NULL)
|
||||
LuaValue::LightUserData(ud) if ud.0.is_null() => "NULL".into(), |
||||
// Complex objects can't be displayed
|
||||
LuaValue::LightUserData(_) => "<LightUserData>".into(), |
||||
LuaValue::Function(_) => "<Function>".into(), |
||||
LuaValue::Thread(_) => "<Thread>".into(), |
||||
LuaValue::UserData(_) => "<UserData>".into(), |
||||
LuaValue::Error(e) => format!("<Error:{e}>").into(), |
||||
other => format!("<{}>", other.type_name()).into(), |
||||
}; |
||||
|
||||
keywrap(res, position) |
||||
} |
||||
@ -1,92 +0,0 @@ |
||||
//! The per-script permission system, shared by every subsystem that reaches
|
||||
//! outside the sandbox.
|
||||
//!
|
||||
//! Two kinds of resources are guarded:
|
||||
//!
|
||||
//! - **Directories** — the `fs` module, sqlite databases, http cookie-jar
|
||||
//! files. Grants are per canonical directory with separate read/write
|
||||
//! flags, and apply recursively (the deepest recorded ancestor decides).
|
||||
//! - **Network origins** — the `http` module. Grants are per exact origin
|
||||
//! (`scheme://host[:port]`) with a single access flag.
|
||||
//!
|
||||
//! Recorded answers live in `access.json` ([`store`]), unknown ones are asked
|
||||
//! on the terminal ([`prompt`]) — `y`/`n` for this run, `A`/`N` recorded.
|
||||
//! Grants are keyed by the script's canonical path; the REPL prompts the same
|
||||
//! way but never persists. CLI flags (`--allow-fs`, `--no-fs`, `--allow-net`,
|
||||
//! `--no-net`, `--sandbox`) statically override one or both resource kinds
|
||||
//! (see `main.rs`).
|
||||
|
||||
pub(crate) mod policy; |
||||
// In test builds `Policy::check_*` never reach the real terminal prompt
|
||||
// (scripted answers only), leaving these functions deliberately unused.
|
||||
#[cfg_attr(test, allow(dead_code))] |
||||
mod prompt; |
||||
pub(crate) mod store; |
||||
|
||||
use std::path::PathBuf; |
||||
use std::sync::Arc; |
||||
|
||||
use mlua::prelude::LuaResult; |
||||
use mlua::Lua; |
||||
|
||||
pub(crate) use policy::{Override, Policy, Wants}; |
||||
#[cfg(test)] |
||||
pub(crate) use prompt::Choice; |
||||
|
||||
/// `mlua::Error::external(format!(...))` — every Lua-facing error here goes
|
||||
/// through this, prefixed with the name of the operation being denied.
|
||||
macro_rules! ext_err { |
||||
($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) }; |
||||
} |
||||
|
||||
/// Plant the default policy: REPL semantics — prompt if a terminal is there,
|
||||
/// never persist. `main` replaces it once the CLI flags and the script's
|
||||
/// canonical path are known; bare states (tests) can therefore never touch
|
||||
/// the real access.json.
|
||||
pub(super) fn install(lua: &Lua) { |
||||
lua.set_app_data(Arc::new(Policy::interactive(None, Default::default(), None))); |
||||
} |
||||
|
||||
/// The active policy; fails closed if none was installed (cannot happen —
|
||||
/// `install` plants a default).
|
||||
fn active_policy(lua: &Lua) -> Arc<Policy> { |
||||
lua.app_data_ref::<Arc<Policy>>() |
||||
.map(|p| Arc::clone(&p)) |
||||
.unwrap_or_else(|| Arc::new(Policy::deny_all("(internal: no permission policy)"))) |
||||
} |
||||
|
||||
/// Run the permission cycle for the canonical directory `dir`; a denial is a
|
||||
/// Lua error prefixed with `fname`. Shared by fs, sqlite (`Wants::READ_WRITE`
|
||||
/// on a database's directory) and http (cookie-jar files).
|
||||
pub(super) async fn ensure_dir_access( |
||||
lua: &Lua, |
||||
fname: &'static str, |
||||
dir: PathBuf, |
||||
wants: Wants, |
||||
) -> LuaResult<()> { |
||||
match active_policy(lua).check_dir(dir.clone(), wants).await? { |
||||
policy::Outcome::Granted => Ok(()), |
||||
policy::Outcome::DeniedByFlag(flag) => { |
||||
Err(ext_err!("{fname}: filesystem access disabled by {flag}")) |
||||
} |
||||
policy::Outcome::Denied => Err(ext_err!("{fname}: access to {} denied", dir.display())), |
||||
} |
||||
} |
||||
|
||||
/// Bool-returning directory permission cycle (`fs.access`, the predicates):
|
||||
/// denial is `false`, never an error.
|
||||
pub(super) async fn check_dir_access(lua: &Lua, dir: PathBuf, wants: Wants) -> LuaResult<bool> { |
||||
Ok(matches!(active_policy(lua).check_dir(dir, wants).await?, policy::Outcome::Granted)) |
||||
} |
||||
|
||||
/// Run the permission cycle for a network origin (`scheme://host[:port]`);
|
||||
/// a denial is a Lua error. Called by http before every request.
|
||||
pub(super) async fn ensure_origin_access(lua: &Lua, origin: &str) -> LuaResult<()> { |
||||
match active_policy(lua).check_origin(origin.to_string()).await? { |
||||
policy::Outcome::Granted => Ok(()), |
||||
policy::Outcome::DeniedByFlag(flag) => { |
||||
Err(ext_err!("http: networking disabled by {flag}")) |
||||
} |
||||
policy::Outcome::Denied => Err(ext_err!("http: access to {origin} denied")), |
||||
} |
||||
} |
||||
@ -1,775 +0,0 @@ |
||||
//! The permission policy — who may touch which directory or network origin.
|
||||
//!
|
||||
//! One [`Policy`] lives in mlua app data per Lua state (as `Arc<Policy>`).
|
||||
//! `perm::install` plants a session-only default; `main` replaces it once the
|
||||
//! CLI flags and the script's canonical path are known.
|
||||
//!
|
||||
//! Directory grants are recorded per canonical directory and apply
|
||||
//! recursively: the deepest recorded ancestor decides, so a deny on
|
||||
//! `~/secrets` beats an allow on `~`. Origin grants are exact-match — a grant
|
||||
//! for `https://example.com` says nothing about other hosts, subdomains,
|
||||
//! schemes or ports. Session answers (y/n, and everything in the REPL) shadow
|
||||
//! the persisted store at every depth.
|
||||
|
||||
use std::path::{Path, PathBuf}; |
||||
use std::sync::{Arc, Mutex}; |
||||
|
||||
use mlua::prelude::LuaResult; |
||||
|
||||
use super::prompt::Choice; |
||||
use super::store::{self, DirAccess, DirGrants, OriginAccess, ScriptGrants}; |
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)] |
||||
pub(crate) enum AccessKind { |
||||
Read, |
||||
Write, |
||||
} |
||||
|
||||
/// Which directions a directory operation needs; `read && write` is sqlite's
|
||||
/// combined "open this database" request, answered by a single prompt.
|
||||
#[derive(Clone, Copy, Debug)] |
||||
pub(crate) struct Wants { |
||||
pub(crate) read: bool, |
||||
pub(crate) write: bool, |
||||
} |
||||
|
||||
impl Wants { |
||||
pub(crate) const READ: Wants = Wants { read: true, write: false }; |
||||
pub(crate) const WRITE: Wants = Wants { read: false, write: true }; |
||||
pub(crate) const READ_WRITE: Wants = Wants { read: true, write: true }; |
||||
|
||||
fn kinds(self) -> impl Iterator<Item = AccessKind> { |
||||
[(self.read, AccessKind::Read), (self.write, AccessKind::Write)] |
||||
.into_iter() |
||||
.filter_map(|(wanted, kind)| wanted.then_some(kind)) |
||||
} |
||||
|
||||
fn describe(self) -> &'static str { |
||||
match (self.read, self.write) { |
||||
(true, true) => "read/write", |
||||
(true, false) => "read", |
||||
(false, true) => "write", |
||||
(false, false) => "no", |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// A CLI flag's static answer for one resource kind; interactive when absent.
|
||||
#[derive(Clone, Copy, Debug)] |
||||
pub(crate) enum Override { |
||||
/// `--allow-fs` / `--allow-net`: granted, no prompts, no persistence.
|
||||
Allow, |
||||
/// `--no-fs` / `--no-net` / `--sandbox`: denied; `flag` names the culprit
|
||||
/// for error messages.
|
||||
Deny { flag: &'static str }, |
||||
} |
||||
|
||||
pub(crate) struct Policy { |
||||
/// Static answer for directory checks; `None` = interactive.
|
||||
fs_override: Option<Override>, |
||||
/// Static answer for origin checks; `None` = interactive.
|
||||
net_override: Option<Override>, |
||||
/// Store key = the script's canonical path. `None` in the REPL (and in
|
||||
/// bare test states): prompts work, nothing is ever persisted.
|
||||
script_key: Option<String>, |
||||
/// This script's grants from access.json, snapshotted at startup.
|
||||
persisted: Mutex<ScriptGrants>, |
||||
/// Session-only answers — y/n, plus A/N in the REPL. Shadows `persisted`.
|
||||
session: Mutex<ScriptGrants>, |
||||
/// Where A/N answers are persisted; `None` = no config dir found,
|
||||
/// session-only operation.
|
||||
store_path: Option<PathBuf>, |
||||
/// Scripted prompt answers for tests, consumed front to back; an
|
||||
/// exhausted (or absent) queue behaves like "no terminal". In test
|
||||
/// builds the real terminal prompt is never reached (see `prompted`).
|
||||
#[cfg(test)] |
||||
scripted: Mutex<std::collections::VecDeque<Choice>>, |
||||
} |
||||
|
||||
/// Outcome of a permission check, kept apart from granted/denied booleans so
|
||||
/// error messages can name the CLI flag that caused a static denial.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
||||
pub(crate) enum Outcome { |
||||
Granted, |
||||
/// Denied by `--no-fs` / `--no-net` / `--sandbox`.
|
||||
DeniedByFlag(&'static str), |
||||
/// Denied by a recorded decision, an interactive answer, or the inability
|
||||
/// to ask (no terminal).
|
||||
Denied, |
||||
} |
||||
|
||||
impl Outcome { |
||||
fn from_decided(granted: bool) -> Outcome { |
||||
if granted { Outcome::Granted } else { Outcome::Denied } |
||||
} |
||||
} |
||||
|
||||
impl Policy { |
||||
pub(crate) fn new( |
||||
fs_override: Option<Override>, |
||||
net_override: Option<Override>, |
||||
script_key: Option<String>, |
||||
persisted: ScriptGrants, |
||||
store_path: Option<PathBuf>, |
||||
) -> Self { |
||||
Policy { |
||||
fs_override, |
||||
net_override, |
||||
script_key, |
||||
persisted: Mutex::new(persisted), |
||||
session: Mutex::new(ScriptGrants::default()), |
||||
store_path, |
||||
#[cfg(test)] |
||||
scripted: Mutex::new(std::collections::VecDeque::new()), |
||||
} |
||||
} |
||||
|
||||
/// Everything granted, no prompts, no persistence. Tests only — the CLI
|
||||
/// composes the equivalent from two `Override::Allow`s.
|
||||
#[cfg(test)] |
||||
pub(crate) fn allow_all() -> Self { |
||||
Self::new(Some(Override::Allow), Some(Override::Allow), None, Default::default(), None) |
||||
} |
||||
|
||||
/// Everything denied (`--sandbox`).
|
||||
pub(crate) fn deny_all(flag: &'static str) -> Self { |
||||
Self::new( |
||||
Some(Override::Deny { flag }), |
||||
Some(Override::Deny { flag }), |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
) |
||||
} |
||||
|
||||
/// Fully interactive: consult the recorded grants, prompt on unknown.
|
||||
pub(crate) fn interactive( |
||||
script_key: Option<String>, |
||||
persisted: ScriptGrants, |
||||
store_path: Option<PathBuf>, |
||||
) -> Self { |
||||
Self::new(None, None, script_key, persisted, store_path) |
||||
} |
||||
|
||||
/// An interactive policy with a queue of pre-recorded prompt answers.
|
||||
#[cfg(test)] |
||||
pub(crate) fn interactive_scripted( |
||||
script_key: Option<String>, |
||||
persisted: ScriptGrants, |
||||
store_path: Option<PathBuf>, |
||||
answers: impl IntoIterator<Item = Choice>, |
||||
) -> Self { |
||||
let policy = Self::interactive(script_key, persisted, store_path); |
||||
policy.scripted.lock().unwrap().extend(answers); |
||||
policy |
||||
} |
||||
|
||||
/// The full permission cycle for the canonical directory `dir`: recorded
|
||||
/// answer, else ask on the terminal, else deny (recording nothing — a
|
||||
/// persisted denial must be an explicit user decision).
|
||||
pub(crate) async fn check_dir( |
||||
self: Arc<Self>, |
||||
dir: PathBuf, |
||||
wants: Wants, |
||||
) -> LuaResult<Outcome> { |
||||
match self.fs_override { |
||||
Some(Override::Allow) => return Ok(Outcome::Granted), |
||||
Some(Override::Deny { flag }) => return Ok(Outcome::DeniedByFlag(flag)), |
||||
None => {} |
||||
} |
||||
|
||||
// Fast path — already decided, no prompt needed.
|
||||
if let Some(decided) = self.resolve_dir_wants(&dir, wants) { |
||||
return Ok(Outcome::from_decided(decided)); |
||||
} |
||||
self.prompted(move |policy, ask| policy.decide_dir(&dir, wants, ask)).await |
||||
} |
||||
|
||||
/// The full permission cycle for a network origin
|
||||
/// (`scheme://host[:port]`), same shape as [`check_dir`](Self::check_dir).
|
||||
pub(crate) async fn check_origin(self: Arc<Self>, origin: String) -> LuaResult<Outcome> { |
||||
match self.net_override { |
||||
Some(Override::Allow) => return Ok(Outcome::Granted), |
||||
Some(Override::Deny { flag }) => return Ok(Outcome::DeniedByFlag(flag)), |
||||
None => {} |
||||
} |
||||
|
||||
if let Some(decided) = self.resolve_origin(&origin) { |
||||
return Ok(Outcome::from_decided(decided)); |
||||
} |
||||
self.prompted(move |policy, ask| policy.decide_origin(&origin, ask)).await |
||||
} |
||||
|
||||
/// Run an ask-and-record step. In test builds the scripted answer queue
|
||||
/// stands in for the terminal — structurally, an unwitting test cannot
|
||||
/// hang `cargo test` on a keypress. In real builds the step runs on the
|
||||
/// blocking pool under the tui prompt lock, so concurrent coroutines
|
||||
/// can't fight over the terminal.
|
||||
async fn prompted( |
||||
self: Arc<Self>, |
||||
decide: impl FnOnce(&Policy, &dyn Fn(&str, &str) -> Choice) -> Outcome + Send + 'static, |
||||
) -> LuaResult<Outcome> { |
||||
#[cfg(test)] |
||||
{ |
||||
let choice = { |
||||
let mut answers = self.scripted.lock().unwrap_or_else(|e| e.into_inner()); |
||||
answers.pop_front().unwrap_or(Choice::NoTty) |
||||
}; |
||||
Ok(decide(&self, &move |_: &str, _: &str| choice)) |
||||
} |
||||
|
||||
#[cfg(not(test))] |
||||
{ |
||||
if !super::prompt::can_prompt() { |
||||
return Ok(Outcome::Denied); |
||||
} |
||||
let this = Arc::clone(&self); |
||||
tokio::task::spawn_blocking(move || { |
||||
let _guard = crate::stdlib::tui::PROMPT_LOCK |
||||
.lock() |
||||
.unwrap_or_else(|e| e.into_inner()); |
||||
decide(&this, &super::prompt::prompt_access) |
||||
}) |
||||
.await |
||||
.map_err(|e| mlua::Error::external(format!("permission prompt task failed: {e}"))) |
||||
} |
||||
} |
||||
|
||||
/// The directory ask-and-record step, run with the prompt lock held.
|
||||
/// Re-resolves first — another coroutine may have answered for this
|
||||
/// directory while we waited for the lock — then asks only for what is
|
||||
/// still unknown.
|
||||
fn decide_dir(&self, dir: &Path, wants: Wants, ask: &dyn Fn(&str, &str) -> Choice) -> Outcome { |
||||
if let Some(decided) = self.resolve_dir_wants(dir, wants) { |
||||
return Outcome::from_decided(decided); |
||||
} |
||||
let missing = Wants { |
||||
read: wants.read && self.resolve_dir_one(dir, AccessKind::Read).is_none(), |
||||
write: wants.write && self.resolve_dir_one(dir, AccessKind::Write).is_none(), |
||||
}; |
||||
|
||||
let dir_key = dir.to_string_lossy().into_owned(); |
||||
let choice = ask( |
||||
self.script_label(), |
||||
&format!("{} access to directory {dir_key}", missing.describe()), |
||||
); |
||||
let Some(value) = choice.value() else { |
||||
return Outcome::Denied; // no terminal: record nothing
|
||||
}; |
||||
|
||||
let update = DirAccess { |
||||
read: missing.read.then_some(value), |
||||
write: missing.write.then_some(value), |
||||
}; |
||||
{ |
||||
let mut session = self.session.lock().unwrap_or_else(|e| e.into_inner()); |
||||
let entry = session.fs.entry(dir_key.clone()).or_default(); |
||||
if update.read.is_some() { |
||||
entry.read = update.read; |
||||
} |
||||
if update.write.is_some() { |
||||
entry.write = update.write; |
||||
} |
||||
} |
||||
if choice.is_always() |
||||
&& let (Some(key), Some(path)) = (&self.script_key, &self.store_path) |
||||
&& let Err(e) = store::persist_fs(path, key, &dir_key, update) |
||||
{ |
||||
// The answer still holds for this run; failing to record it must
|
||||
// not fail the script's operation.
|
||||
warn_cannot_save(path, e); |
||||
} |
||||
|
||||
// `missing` now carries the fresh answer; every other wanted kind
|
||||
// already resolved to true (a false would have denied above).
|
||||
Outcome::from_decided(value) |
||||
} |
||||
|
||||
/// The origin ask-and-record step, run with the prompt lock held.
|
||||
fn decide_origin(&self, origin: &str, ask: &dyn Fn(&str, &str) -> Choice) -> Outcome { |
||||
if let Some(decided) = self.resolve_origin(origin) { |
||||
return Outcome::from_decided(decided); |
||||
} |
||||
|
||||
let choice = ask(self.script_label(), &format!("access to {origin}")); |
||||
let Some(value) = choice.value() else { |
||||
return Outcome::Denied; // no terminal: record nothing
|
||||
}; |
||||
|
||||
self.session |
||||
.lock() |
||||
.unwrap_or_else(|e| e.into_inner()) |
||||
.net |
||||
.insert(origin.to_string(), OriginAccess { access: Some(value) }); |
||||
if choice.is_always() |
||||
&& let (Some(key), Some(path)) = (&self.script_key, &self.store_path) |
||||
&& let Err(e) = store::persist_net(path, key, origin, value) |
||||
{ |
||||
warn_cannot_save(path, e); |
||||
} |
||||
|
||||
Outcome::from_decided(value) |
||||
} |
||||
|
||||
fn script_label(&self) -> &str { |
||||
self.script_key.as_deref().unwrap_or("interactive session (REPL)") |
||||
} |
||||
|
||||
/// `Some(true)` = every wanted kind granted, `Some(false)` = at least one
|
||||
/// denied, `None` = at least one undecided (and none denied).
|
||||
fn resolve_dir_wants(&self, dir: &Path, wants: Wants) -> Option<bool> { |
||||
let mut all_known = true; |
||||
for kind in wants.kinds() { |
||||
match self.resolve_dir_one(dir, kind) { |
||||
Some(false) => return Some(false), |
||||
Some(true) => {} |
||||
None => all_known = false, |
||||
} |
||||
} |
||||
all_known.then_some(true) |
||||
} |
||||
|
||||
fn resolve_dir_one(&self, dir: &Path, kind: AccessKind) -> Option<bool> { |
||||
let session = self.session.lock().unwrap_or_else(|e| e.into_inner()); |
||||
let persisted = self.persisted.lock().unwrap_or_else(|e| e.into_inner()); |
||||
resolve_dir(&session.fs, &persisted.fs, dir, kind) |
||||
} |
||||
|
||||
/// Exact-match origin lookup, session layer first.
|
||||
fn resolve_origin(&self, origin: &str) -> Option<bool> { |
||||
let session = self.session.lock().unwrap_or_else(|e| e.into_inner()); |
||||
let persisted = self.persisted.lock().unwrap_or_else(|e| e.into_inner()); |
||||
[&session.net, &persisted.net] |
||||
.into_iter() |
||||
.find_map(|layer| layer.get(origin).and_then(|a| a.access)) |
||||
} |
||||
} |
||||
|
||||
impl Choice { |
||||
/// The yes/no the choice stands for; `None` when there was nobody to ask.
|
||||
fn value(self) -> Option<bool> { |
||||
match self { |
||||
Choice::AllowOnce | Choice::AllowAlways => Some(true), |
||||
Choice::DenyOnce | Choice::DenyAlways => Some(false), |
||||
Choice::NoTty => None, |
||||
} |
||||
} |
||||
|
||||
fn is_always(self) -> bool { |
||||
matches!(self, Choice::AllowAlways | Choice::DenyAlways) |
||||
} |
||||
} |
||||
|
||||
fn warn_cannot_save(path: &Path, e: std::io::Error) { |
||||
eprintln!( |
||||
"{}: warning: cannot save permission to {}: {e}", |
||||
store::APP_NAME, |
||||
path.display() |
||||
); |
||||
} |
||||
|
||||
/// Walk from `dir` up to and including `/`. At each depth the session layer
|
||||
/// shadows the persisted one; the first decided value wins — the deepest
|
||||
/// recorded ancestor decides.
|
||||
fn resolve_dir( |
||||
session: &DirGrants, |
||||
persisted: &DirGrants, |
||||
dir: &Path, |
||||
kind: AccessKind, |
||||
) -> Option<bool> { |
||||
let mut cur = Some(dir); |
||||
while let Some(d) = cur { |
||||
let key = d.to_string_lossy(); |
||||
for layer in [session, persisted] { |
||||
if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) { |
||||
return Some(v); |
||||
} |
||||
} |
||||
cur = d.parent(); |
||||
} |
||||
None |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
fn grants(entries: &[(&str, Option<bool>, Option<bool>)]) -> ScriptGrants { |
||||
ScriptGrants { |
||||
fs: entries |
||||
.iter() |
||||
.map(|(dir, read, write)| { |
||||
(dir.to_string(), DirAccess { read: *read, write: *write }) |
||||
}) |
||||
.collect(), |
||||
..Default::default() |
||||
} |
||||
} |
||||
|
||||
fn origin_grants(entries: &[(&str, bool)]) -> ScriptGrants { |
||||
ScriptGrants { |
||||
net: entries |
||||
.iter() |
||||
.map(|(origin, allowed)| { |
||||
(origin.to_string(), OriginAccess { access: Some(*allowed) }) |
||||
}) |
||||
.collect(), |
||||
..Default::default() |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn resolve_dir_walks_ancestors() { |
||||
let persisted = grants(&[("/home/u/data", Some(true), None)]).fs; |
||||
let session = DirGrants::new(); |
||||
let r = |dir: &str| resolve_dir(&session, &persisted, Path::new(dir), AccessKind::Read); |
||||
assert_eq!(r("/home/u/data"), Some(true), "exact hit"); |
||||
assert_eq!(r("/home/u/data/sub/deep"), Some(true), "parent covers children"); |
||||
assert_eq!(r("/home/u"), None, "no upward leakage"); |
||||
assert_eq!(r("/elsewhere"), None, "unrelated dir unknown"); |
||||
} |
||||
|
||||
#[test] |
||||
fn resolve_dir_deepest_ancestor_wins() { |
||||
let persisted = grants(&[ |
||||
("/home/u", Some(true), None), |
||||
("/home/u/secrets", Some(false), None), |
||||
]) |
||||
.fs; |
||||
let session = DirGrants::new(); |
||||
let r = |dir: &str| resolve_dir(&session, &persisted, Path::new(dir), AccessKind::Read); |
||||
assert_eq!(r("/home/u/data"), Some(true)); |
||||
assert_eq!(r("/home/u/secrets"), Some(false), "deny beats shallower allow"); |
||||
assert_eq!(r("/home/u/secrets/inner"), Some(false)); |
||||
} |
||||
|
||||
#[test] |
||||
fn resolve_dir_session_shadows_persisted() { |
||||
let persisted = grants(&[("/data", Some(false), None)]).fs; |
||||
let session = grants(&[("/data", Some(true), None)]).fs; |
||||
assert_eq!( |
||||
resolve_dir(&session, &persisted, Path::new("/data/x"), AccessKind::Read), |
||||
Some(true) |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn resolve_dir_null_flags_are_skipped() { |
||||
// read is undecided at the deep entry; the shallow decided one wins.
|
||||
let persisted = grants(&[("/a", Some(true), None), ("/a/b", None, Some(false))]).fs; |
||||
let session = DirGrants::new(); |
||||
assert_eq!( |
||||
resolve_dir(&session, &persisted, Path::new("/a/b"), AccessKind::Read), |
||||
Some(true) |
||||
); |
||||
assert_eq!( |
||||
resolve_dir(&session, &persisted, Path::new("/a/b"), AccessKind::Write), |
||||
Some(false) |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn resolve_dir_root_grant_covers_everything() { |
||||
let persisted = grants(&[("/", Some(true), Some(true))]).fs; |
||||
let session = DirGrants::new(); |
||||
assert_eq!( |
||||
resolve_dir(&session, &persisted, Path::new("/any/where"), AccessKind::Write), |
||||
Some(true) |
||||
); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn static_overrides() { |
||||
let allow = Arc::new(Policy::allow_all()); |
||||
assert_eq!( |
||||
Arc::clone(&allow).check_dir("/x".into(), Wants::READ).await.unwrap(), |
||||
Outcome::Granted |
||||
); |
||||
assert_eq!( |
||||
allow.check_origin("https://example.com".into()).await.unwrap(), |
||||
Outcome::Granted |
||||
); |
||||
|
||||
let deny = Arc::new(Policy::deny_all("--sandbox")); |
||||
assert_eq!( |
||||
Arc::clone(&deny).check_dir("/x".into(), Wants::WRITE).await.unwrap(), |
||||
Outcome::DeniedByFlag("--sandbox") |
||||
); |
||||
assert_eq!( |
||||
deny.check_origin("https://example.com".into()).await.unwrap(), |
||||
Outcome::DeniedByFlag("--sandbox") |
||||
); |
||||
|
||||
// Mixed: --no-fs leaves origins interactive (here: no answers = deny,
|
||||
// but not by flag).
|
||||
let mixed = Arc::new(Policy::new( |
||||
Some(Override::Deny { flag: "--no-fs" }), |
||||
None, |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
)); |
||||
assert_eq!( |
||||
Arc::clone(&mixed).check_dir("/x".into(), Wants::READ).await.unwrap(), |
||||
Outcome::DeniedByFlag("--no-fs") |
||||
); |
||||
assert_eq!( |
||||
mixed.check_origin("https://example.com".into()).await.unwrap(), |
||||
Outcome::Denied |
||||
); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn allow_once_grants_for_the_whole_run() { |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
[Choice::AllowOnce], // exactly one answer available
|
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
// second check hits the session cache — no second answer consumed
|
||||
// (the queue is empty; consuming would mean NoTty => Denied)
|
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data/sub".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn deny_once_is_cached_for_the_run() { |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
[Choice::DenyOnce], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn allow_always_persists_and_covers_subdirs() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let store_path = tmp.path().join("access.json"); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
Default::default(), |
||||
Some(store_path.clone()), |
||||
[Choice::AllowAlways], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
|
||||
let saved = store::load_for_script(&store_path, "/s/tool.lua"); |
||||
assert_eq!(saved.fs["/data"], DirAccess { read: Some(true), write: None }); |
||||
|
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data/deep".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn deny_always_persists_false() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let store_path = tmp.path().join("access.json"); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
Default::default(), |
||||
Some(store_path.clone()), |
||||
[Choice::DenyAlways], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/secrets".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); |
||||
|
||||
let saved = store::load_for_script(&store_path, "/s/tool.lua"); |
||||
assert_eq!(saved.fs["/secrets"], DirAccess { read: None, write: Some(false) }); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn repl_always_answers_never_persist() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let store_path = tmp.path().join("access.json"); |
||||
// script_key = None (REPL): store_path present but must not be used
|
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
Some(store_path.clone()), |
||||
[Choice::AllowAlways], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
assert!(!store_path.exists(), "REPL grant must not create the store"); |
||||
// ...but it holds for the session
|
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn unknown_without_terminal_denies_and_records_nothing() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let store_path = tmp.path().join("access.json"); |
||||
// empty answer queue = "no terminal"
|
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
Default::default(), |
||||
Some(store_path.clone()), |
||||
[], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied); |
||||
assert!(!store_path.exists()); |
||||
// nothing was cached either: a persisted grant arriving later (other
|
||||
// process) would be honored — verify session stayed empty
|
||||
assert!(policy.session.lock().unwrap().fs.is_empty()); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn persisted_grants_answer_without_prompting() { |
||||
let persisted = grants(&[("/data", Some(true), Some(false))]); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
persisted, |
||||
None, |
||||
[], // no answers available: any prompt would deny
|
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data/x".into(), Wants::READ).await.unwrap(), Outcome::Granted); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/data/x".into(), Wants::WRITE).await.unwrap(), Outcome::Denied); |
||||
// combined read/write: the recorded write=false denies without asking
|
||||
let p = Arc::clone(&policy); |
||||
assert_eq!( |
||||
p.check_dir("/data/x".into(), Wants::READ_WRITE).await.unwrap(), |
||||
Outcome::Denied |
||||
); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn combined_prompt_records_only_missing_kinds() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let store_path = tmp.path().join("access.json"); |
||||
// read already granted; a READ_WRITE check must only ask (and record)
|
||||
// the missing write flag
|
||||
let persisted = grants(&[("/db", Some(true), None)]); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
persisted, |
||||
Some(store_path.clone()), |
||||
[Choice::AllowAlways], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_dir("/db".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Granted); |
||||
|
||||
let saved = store::load_for_script(&store_path, "/s/tool.lua"); |
||||
assert_eq!( |
||||
saved.fs["/db"], |
||||
DirAccess { read: None, write: Some(true) }, |
||||
"read was already known; only write gets recorded" |
||||
); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn origin_allow_once_is_cached_for_the_run() { |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
None, |
||||
Default::default(), |
||||
None, |
||||
[Choice::AllowOnce], // exactly one answer available
|
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_origin("https://example.com".into()).await.unwrap(), Outcome::Granted); |
||||
// same origin again: session cache, no answer consumed
|
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_origin("https://example.com".into()).await.unwrap(), Outcome::Granted); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn origin_grants_are_exact_match_only() { |
||||
// A grant covers exactly its origin: not another scheme, port,
|
||||
// subdomain, or a host that merely starts with it.
|
||||
let persisted = origin_grants(&[("https://example.com", true)]); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
persisted, |
||||
None, |
||||
[], // any prompt denies
|
||||
)); |
||||
for (origin, expected) in [ |
||||
("https://example.com", Outcome::Granted), |
||||
("http://example.com", Outcome::Denied), |
||||
("https://example.com:8443", Outcome::Denied), |
||||
("https://sub.example.com", Outcome::Denied), |
||||
("https://example.com.evil.io", Outcome::Denied), |
||||
] { |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_origin(origin.into()).await.unwrap(), expected, "{origin}"); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn origin_always_answers_persist_under_net() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let store_path = tmp.path().join("access.json"); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
Default::default(), |
||||
Some(store_path.clone()), |
||||
[Choice::AllowAlways, Choice::DenyAlways], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!( |
||||
p.check_origin("https://api.example.com".into()).await.unwrap(), |
||||
Outcome::Granted |
||||
); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!( |
||||
p.check_origin("http://tracker.example.com".into()).await.unwrap(), |
||||
Outcome::Denied |
||||
); |
||||
|
||||
let saved = store::load_for_script(&store_path, "/s/tool.lua"); |
||||
assert_eq!(saved.net["https://api.example.com"].access, Some(true)); |
||||
assert_eq!(saved.net["http://tracker.example.com"].access, Some(false)); |
||||
assert!(saved.fs.is_empty(), "no directory grants were involved"); |
||||
|
||||
// a fresh policy loading the store answers without prompting
|
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
store::load_for_script(&store_path, "/s/tool.lua"), |
||||
Some(store_path.clone()), |
||||
[], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!( |
||||
p.check_origin("https://api.example.com".into()).await.unwrap(), |
||||
Outcome::Granted |
||||
); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!( |
||||
p.check_origin("http://tracker.example.com".into()).await.unwrap(), |
||||
Outcome::Denied |
||||
); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn origin_and_dir_grants_do_not_cross_talk() { |
||||
// An fs grant whose key happens to look origin-ish must not answer an
|
||||
// origin check, and vice versa.
|
||||
let mut persisted = grants(&[("/data", Some(true), Some(true))]); |
||||
persisted.net.insert("https://example.com".into(), OriginAccess { access: Some(true) }); |
||||
let policy = Arc::new(Policy::interactive_scripted( |
||||
Some("/s/tool.lua".into()), |
||||
persisted, |
||||
None, |
||||
[], |
||||
)); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!(p.check_origin("/data".into()).await.unwrap(), Outcome::Denied); |
||||
let p = Arc::clone(&policy); |
||||
assert_eq!( |
||||
p.check_dir("https://example.com".into(), Wants::READ).await.unwrap(), |
||||
Outcome::Denied |
||||
); |
||||
} |
||||
} |
||||
@ -1,110 +0,0 @@ |
||||
//! The single-keypress permission prompt.
|
||||
//!
|
||||
//! Runs on the blocking pool with the tui prompt lock held (see
|
||||
//! [`super::policy::Policy`]), so it never fights an inquire prompt
|
||||
//! or a sibling permission prompt for the terminal. One keypress, no Enter:
|
||||
//! `y`/`n` answer for this run only, `A`/`N` are recorded in access.json.
|
||||
//! Deliberately, a lowercase `a` does nothing — a slip of the finger must not
|
||||
//! persist anything.
|
||||
|
||||
use std::io::{IsTerminal, Write}; |
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; |
||||
|
||||
use super::store::APP_NAME; |
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
||||
pub(crate) enum Choice { |
||||
AllowOnce, |
||||
DenyOnce, |
||||
AllowAlways, |
||||
DenyAlways, |
||||
/// No terminal to ask on (or raw mode unavailable): deny, record nothing.
|
||||
NoTty, |
||||
} |
||||
|
||||
/// Whether there is a terminal to ask on. The prompt itself goes to stderr so
|
||||
/// it shows even when the script's stdout is piped.
|
||||
pub(super) fn can_prompt() -> bool { |
||||
std::io::stdin().is_terminal() && std::io::stderr().is_terminal() |
||||
} |
||||
|
||||
/// Ask the user; `wants` describes the request, e.g. `"read access to
|
||||
/// directory /data"` or `"access to https://example.com"`.
|
||||
pub(super) fn prompt_access(script: &str, wants: &str) -> Choice { |
||||
if !can_prompt() { |
||||
return Choice::NoTty; |
||||
} |
||||
// The leading clear-line erases anything a concurrent coroutine left on
|
||||
// the current line before the prompt took the terminal (e.g. a spinner
|
||||
// frame) — further output is held back by the prompt lock, which the
|
||||
// caller already holds.
|
||||
eprint!( |
||||
"\x1b[2K\r{APP_NAME}: permission request\n \ |
||||
script: {script}\n \ |
||||
wants: {wants}\n\ |
||||
[y] allow once [n] deny once [A] allow always [N] never allow: " |
||||
); |
||||
let _ = std::io::stderr().flush(); |
||||
|
||||
let Ok(raw) = RawMode::enable() else { |
||||
eprintln!(); |
||||
return Choice::NoTty; |
||||
}; |
||||
let choice = loop { |
||||
match crossterm::event::read() { |
||||
Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => { |
||||
// In raw mode Ctrl-C is a plain key event (no SIGINT): a
|
||||
// cautious "deny once", same as Esc.
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL) { |
||||
if k.code == KeyCode::Char('c') { |
||||
break Choice::DenyOnce; |
||||
} |
||||
continue; |
||||
} |
||||
match k.code { |
||||
KeyCode::Char('y') => break Choice::AllowOnce, |
||||
KeyCode::Char('n') => break Choice::DenyOnce, |
||||
// Shifted letters arrive as the uppercase char (the SHIFT
|
||||
// modifier bit varies by terminal, so don't match on it).
|
||||
KeyCode::Char('A') => break Choice::AllowAlways, |
||||
KeyCode::Char('N') => break Choice::DenyAlways, |
||||
KeyCode::Esc => break Choice::DenyOnce, |
||||
_ => {} |
||||
} |
||||
} |
||||
Ok(_) => {} // resize, mouse, focus: ignore
|
||||
Err(_) => break Choice::DenyOnce, // fail closed
|
||||
} |
||||
}; |
||||
drop(raw); |
||||
// Echo the decision after raw mode is off (raw mode needs \r\n).
|
||||
eprintln!( |
||||
"{}", |
||||
match choice { |
||||
Choice::AllowOnce => "allow once", |
||||
Choice::DenyOnce => "deny once", |
||||
Choice::AllowAlways => "allow always", |
||||
Choice::DenyAlways => "never allow", |
||||
Choice::NoTty => "", |
||||
} |
||||
); |
||||
choice |
||||
} |
||||
|
||||
/// RAII raw-mode: a panic between enable and disable can't strand the
|
||||
/// terminal in raw mode.
|
||||
struct RawMode; |
||||
|
||||
impl RawMode { |
||||
fn enable() -> std::io::Result<RawMode> { |
||||
crossterm::terminal::enable_raw_mode()?; |
||||
Ok(RawMode) |
||||
} |
||||
} |
||||
|
||||
impl Drop for RawMode { |
||||
fn drop(&mut self) { |
||||
let _ = crossterm::terminal::disable_raw_mode(); |
||||
} |
||||
} |
||||
@ -1,432 +0,0 @@ |
||||
//! The persistent grant store — `access.json` in the user's config directory.
|
||||
//!
|
||||
//! Grants are keyed by the script's canonical path, then by subsystem section:
|
||||
//! `fs` maps canonical directories to a tri-state per direction, `net` maps
|
||||
//! network origins to a single tri-state `access` flag — `null` (not
|
||||
//! decided), `true` (approved), `false` (denied). The file is hand-editable;
|
||||
//! the persist functions keep concurrent Luna processes from losing each
|
||||
//! other's updates by holding an exclusive `flock` on a sidecar lock file
|
||||
//! across their read-merge-write cycle, and replace the store with `rename()`
|
||||
//! so readers never see a half-written file.
|
||||
|
||||
use std::collections::BTreeMap; |
||||
use std::io::Write; |
||||
use std::path::{Path, PathBuf}; |
||||
|
||||
use serde::{Deserialize, Serialize}; |
||||
|
||||
/// Config directory name under `$XDG_CONFIG_HOME`. A literal, not
|
||||
/// `CARGO_PKG_NAME`: renaming the binary silently abandoning every recorded
|
||||
/// grant should be an explicit decision made here.
|
||||
pub(crate) const APP_NAME: &str = "luna"; |
||||
|
||||
/// Tri-state access recorded for one directory. `None` = not decided yet,
|
||||
/// `Some(true)` = approved, `Some(false)` = denied — serialized as JSON
|
||||
/// `null` / `true` / `false`.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)] |
||||
pub(crate) struct DirAccess { |
||||
#[serde(default)] |
||||
pub(crate) read: Option<bool>, |
||||
#[serde(default)] |
||||
pub(crate) write: Option<bool>, |
||||
} |
||||
|
||||
impl DirAccess { |
||||
pub(crate) fn get(&self, kind: super::policy::AccessKind) -> Option<bool> { |
||||
match kind { |
||||
super::policy::AccessKind::Read => self.read, |
||||
super::policy::AccessKind::Write => self.write, |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Filesystem grants recorded for one script: canonical directory → tri-state
|
||||
/// pair. `BTreeMap` for deterministic serialization (stable diffs of
|
||||
/// access.json).
|
||||
pub(crate) type DirGrants = BTreeMap<String, DirAccess>; |
||||
|
||||
/// Tri-state access recorded for one network origin, serialized as JSON
|
||||
/// `null` / `true` / `false`.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)] |
||||
pub(crate) struct OriginAccess { |
||||
#[serde(default)] |
||||
pub(crate) access: Option<bool>, |
||||
} |
||||
|
||||
/// Network grants recorded for one script: origin (`scheme://host[:port]`)
|
||||
/// → access flag.
|
||||
pub(crate) type OriginGrants = BTreeMap<String, OriginAccess>; |
||||
|
||||
/// Everything recorded for one script, one section per subsystem. Adding a
|
||||
/// subsystem is a new field here, not a new file shape.
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Debug)] |
||||
pub(crate) struct ScriptGrants { |
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] |
||||
pub(crate) fs: DirGrants, |
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] |
||||
pub(crate) net: OriginGrants, |
||||
} |
||||
|
||||
impl ScriptGrants { |
||||
/// True when no section records anything — the whole script entry can go.
|
||||
fn is_empty(&self) -> bool { |
||||
self.fs.is_empty() && self.net.is_empty() |
||||
} |
||||
|
||||
/// Keep the file tidy: drop entries that no longer record anything.
|
||||
fn prune(&mut self) { |
||||
self.fs.retain(|_, a| a.read.is_some() || a.write.is_some()); |
||||
self.net.retain(|_, a| a.access.is_some()); |
||||
} |
||||
} |
||||
|
||||
#[derive(Serialize, Deserialize)] |
||||
pub(crate) struct AccessStore { |
||||
#[serde(default = "version_default")] |
||||
pub(crate) version: u32, |
||||
#[serde(default)] |
||||
pub(crate) scripts: BTreeMap<String, ScriptGrants>, |
||||
} |
||||
|
||||
fn version_default() -> u32 { |
||||
1 |
||||
} |
||||
|
||||
impl Default for AccessStore { |
||||
fn default() -> Self { |
||||
AccessStore { version: 1, scripts: BTreeMap::new() } |
||||
} |
||||
} |
||||
|
||||
/// `$XDG_CONFIG_HOME/luna/access.json`, falling back to
|
||||
/// `~/.config/luna/access.json`.
|
||||
/// Per the XDG spec a non-absolute `$XDG_CONFIG_HOME` is ignored. `None` when
|
||||
/// no absolute base can be found — permissions then live for the session only.
|
||||
pub(crate) fn default_store_path() -> Option<PathBuf> { |
||||
let base = std::env::var_os("XDG_CONFIG_HOME") |
||||
.map(PathBuf::from) |
||||
.filter(|p| p.is_absolute()) |
||||
.or_else(|| { |
||||
std::env::var_os("HOME") |
||||
.map(|h| PathBuf::from(h).join(".config")) |
||||
.filter(|p| p.is_absolute()) |
||||
})?; |
||||
Some(base.join(APP_NAME).join("access.json")) |
||||
} |
||||
|
||||
/// Read the whole store. Missing file → empty store; unreadable or corrupt →
|
||||
/// warn and treat as empty (a later [`persist`] moves a corrupt file aside
|
||||
/// before overwriting, preserving whatever the user had).
|
||||
fn read_store(path: &Path) -> AccessStore { |
||||
let bytes = match std::fs::read(path) { |
||||
Ok(b) => b, |
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AccessStore::default(), |
||||
Err(e) => { |
||||
log::warn!("cannot read {}: {e}", path.display()); |
||||
return AccessStore::default(); |
||||
} |
||||
}; |
||||
match serde_json::from_slice(&bytes) { |
||||
Ok(store) => store, |
||||
Err(e) => { |
||||
log::warn!("{} is corrupt ({e}); ignoring it", path.display()); |
||||
AccessStore::default() |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// The grants recorded for one script (empty if none). Startup snapshot;
|
||||
/// takes no lock — the persist functions' atomic rename guarantees a complete
|
||||
/// file.
|
||||
pub(crate) fn load_for_script(store_path: &Path, script_key: &str) -> ScriptGrants { |
||||
read_store(store_path).scripts.remove(script_key).unwrap_or_default() |
||||
} |
||||
|
||||
/// Record a directory decision: merge `update`'s decided flags (only the
|
||||
/// `Some` ones) into the `fs` entry for (`script_key`, `dir_key`) and rewrite
|
||||
/// the file.
|
||||
pub(crate) fn persist_fs( |
||||
store_path: &Path, |
||||
script_key: &str, |
||||
dir_key: &str, |
||||
update: DirAccess, |
||||
) -> std::io::Result<()> { |
||||
with_store(store_path, |store| { |
||||
let entry = store |
||||
.scripts |
||||
.entry(script_key.to_string()) |
||||
.or_default() |
||||
.fs |
||||
.entry(dir_key.to_string()) |
||||
.or_default(); |
||||
if update.read.is_some() { |
||||
entry.read = update.read; |
||||
} |
||||
if update.write.is_some() { |
||||
entry.write = update.write; |
||||
} |
||||
}) |
||||
} |
||||
|
||||
/// Record an origin decision: set the `net` access flag for
|
||||
/// (`script_key`, `origin`) and rewrite the file.
|
||||
pub(crate) fn persist_net( |
||||
store_path: &Path, |
||||
script_key: &str, |
||||
origin: &str, |
||||
allowed: bool, |
||||
) -> std::io::Result<()> { |
||||
with_store(store_path, |store| { |
||||
store |
||||
.scripts |
||||
.entry(script_key.to_string()) |
||||
.or_default() |
||||
.net |
||||
.insert(origin.to_string(), OriginAccess { access: Some(allowed) }); |
||||
}) |
||||
} |
||||
|
||||
/// The shared read-merge-write cycle behind the persist functions: run
|
||||
/// `update` on the freshly-read store, prune, and rewrite the file.
|
||||
///
|
||||
/// Holds an exclusive `flock` on `access.json.lock` across the whole
|
||||
/// read-merge-write so a concurrently running script can't lose this update.
|
||||
/// The lock file is separate from the store on purpose: the final `rename`
|
||||
/// replaces the store's inode, so a lock on the store itself would not exclude
|
||||
/// a process that opened it just before the rename.
|
||||
fn with_store(store_path: &Path, update: impl FnOnce(&mut AccessStore)) -> std::io::Result<()> { |
||||
let config_dir = store_path.parent().unwrap_or(Path::new("/")); |
||||
std::fs::create_dir_all(config_dir)?; |
||||
|
||||
let lock_file = std::fs::File::options() |
||||
.create(true) |
||||
.truncate(false) |
||||
.read(true) |
||||
.write(true) |
||||
.open(store_path.with_extension("json.lock"))?; |
||||
rustix::fs::flock(&lock_file, rustix::fs::FlockOperation::LockExclusive)?; |
||||
|
||||
// Re-read fresh under the lock: the startup snapshot (or a previous
|
||||
// in-process copy) may be stale by now.
|
||||
let mut store = match std::fs::read(store_path) { |
||||
Ok(bytes) => match serde_json::from_slice(&bytes) { |
||||
Ok(store) => store, |
||||
Err(e) => { |
||||
// Corrupt: move it aside instead of silently overwriting
|
||||
// whatever the user had in there.
|
||||
let aside = store_path.with_extension("json.corrupt"); |
||||
log::warn!( |
||||
"{} is corrupt ({e}); moving it to {}", |
||||
store_path.display(), |
||||
aside.display() |
||||
); |
||||
std::fs::rename(store_path, &aside)?; |
||||
AccessStore::default() |
||||
} |
||||
}, |
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => AccessStore::default(), |
||||
Err(e) => return Err(e), |
||||
}; |
||||
|
||||
update(&mut store); |
||||
store.scripts.retain(|_, g| { |
||||
g.prune(); |
||||
!g.is_empty() |
||||
}); |
||||
|
||||
// Write-to-temp + rename: readers see the old or the new file, never a
|
||||
// partial one, even across a crash mid-write.
|
||||
let tmp = store_path.with_extension(format!("json.tmp.{}", std::process::id())); |
||||
let mut file = std::fs::File::create(&tmp)?; |
||||
file.write_all(&serde_json::to_vec_pretty(&store)?)?; |
||||
file.write_all(b"\n")?; |
||||
file.sync_all()?; |
||||
std::fs::rename(&tmp, store_path)?; |
||||
Ok(()) |
||||
// lock_file drops here, releasing the flock.
|
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::super::policy::AccessKind; |
||||
use super::*; |
||||
|
||||
fn store_path(dir: &tempfile::TempDir) -> PathBuf { |
||||
dir.path().join("cfg").join("access.json") |
||||
} |
||||
|
||||
#[test] |
||||
fn tri_state_round_trip() { |
||||
let json = r#"{ |
||||
"version": 1, |
||||
"scripts": { |
||||
"/s/deploy.lua": { |
||||
"fs": { |
||||
"/data": { "read": true, "write": true }, |
||||
"/etc": { "read": true, "write": false }, |
||||
"/secrets": { "read": null, "write": false } |
||||
}, |
||||
"net": { |
||||
"https://api.example.com": { "access": true }, |
||||
"http://insecure.example.com:8090": { "access": false }, |
||||
"https://undecided.example.com": { "access": null } |
||||
} |
||||
} |
||||
} |
||||
}"#; |
||||
let store: AccessStore = serde_json::from_str(json).unwrap(); |
||||
let grants = &store.scripts["/s/deploy.lua"]; |
||||
assert_eq!(grants.fs["/data"], DirAccess { read: Some(true), write: Some(true) }); |
||||
assert_eq!(grants.fs["/etc"], DirAccess { read: Some(true), write: Some(false) }); |
||||
assert_eq!(grants.fs["/secrets"], DirAccess { read: None, write: Some(false) }); |
||||
assert_eq!(grants.net["https://api.example.com"].access, Some(true)); |
||||
assert_eq!(grants.net["http://insecure.example.com:8090"].access, Some(false)); |
||||
assert_eq!(grants.net["https://undecided.example.com"].access, None); |
||||
// null and absent both mean "unknown"
|
||||
let sparse: DirAccess = serde_json::from_str(r#"{ "write": true }"#).unwrap(); |
||||
assert_eq!(sparse, DirAccess { read: None, write: Some(true) }); |
||||
// and null survives serialization (the file must distinguish it)
|
||||
let out = serde_json::to_string(&grants.fs["/secrets"]).unwrap(); |
||||
assert!(out.contains("\"read\":null"), "{out}"); |
||||
// round trip
|
||||
let again: AccessStore = |
||||
serde_json::from_str(&serde_json::to_string(&store).unwrap()).unwrap(); |
||||
assert_eq!(again.scripts, store.scripts); |
||||
} |
||||
|
||||
#[test] |
||||
fn dir_access_get() { |
||||
let a = DirAccess { read: Some(true), write: Some(false) }; |
||||
assert_eq!(a.get(AccessKind::Read), Some(true)); |
||||
assert_eq!(a.get(AccessKind::Write), Some(false)); |
||||
} |
||||
|
||||
#[test] |
||||
fn persist_creates_dir_and_merges_without_clobbering() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let path = store_path(&tmp); // parent "cfg" does not exist yet
|
||||
|
||||
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }) |
||||
.unwrap(); |
||||
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: None, write: Some(false) }) |
||||
.unwrap(); |
||||
// the net section merges alongside, not instead
|
||||
persist_net(&path, "/s/a.lua", "https://api.example.com", true).unwrap(); |
||||
persist_net(&path, "/s/a.lua", "http://other.example.com:8090", false).unwrap(); |
||||
|
||||
let grants = load_for_script(&path, "/s/a.lua"); |
||||
assert_eq!(grants.fs["/data"], DirAccess { read: Some(true), write: Some(false) }); |
||||
assert_eq!(grants.net["https://api.example.com"].access, Some(true)); |
||||
assert_eq!(grants.net["http://other.example.com:8090"].access, Some(false)); |
||||
// a second script's grants are separate
|
||||
assert!(load_for_script(&path, "/s/b.lua").is_empty()); |
||||
} |
||||
|
||||
#[test] |
||||
fn persist_net_overwrites_a_previous_answer() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let path = store_path(&tmp); |
||||
persist_net(&path, "/s/a.lua", "https://api.example.com", false).unwrap(); |
||||
persist_net(&path, "/s/a.lua", "https://api.example.com", true).unwrap(); |
||||
let grants = load_for_script(&path, "/s/a.lua"); |
||||
assert_eq!(grants.net["https://api.example.com"].access, Some(true)); |
||||
assert_eq!(grants.net.len(), 1); |
||||
} |
||||
|
||||
#[test] |
||||
fn persist_prunes_undecided_entries() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let path = store_path(&tmp); |
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
||||
// Hand-written file with a null-null entry (as a user might leave it).
|
||||
std::fs::write( |
||||
&path, |
||||
r#"{ "version": 1, "scripts": { |
||||
"/s/a.lua": { |
||||
"fs": { "/dead": { "read": null, "write": null } }, |
||||
"net": { "https://dead.example.com": { "access": null } } |
||||
}, |
||||
"/s/gone.lua": { "fs": {} } |
||||
} }"#, |
||||
) |
||||
.unwrap(); |
||||
|
||||
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }) |
||||
.unwrap(); |
||||
|
||||
let store = read_store(&path); |
||||
assert!(!store.scripts["/s/a.lua"].fs.contains_key("/dead")); |
||||
assert!(store.scripts["/s/a.lua"].net.is_empty()); |
||||
assert!(!store.scripts.contains_key("/s/gone.lua")); |
||||
assert_eq!(store.scripts["/s/a.lua"].fs["/data"].read, Some(true)); |
||||
} |
||||
|
||||
#[test] |
||||
fn persist_moves_corrupt_file_aside() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let path = store_path(&tmp); |
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
||||
std::fs::write(&path, "{ not json").unwrap(); |
||||
|
||||
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None }) |
||||
.unwrap(); |
||||
|
||||
assert_eq!( |
||||
std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(), |
||||
"{ not json" |
||||
); |
||||
assert_eq!(load_for_script(&path, "/s/a.lua").fs["/data"].read, Some(true)); |
||||
} |
||||
|
||||
#[test] |
||||
fn missing_or_corrupt_reads_as_empty() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let path = store_path(&tmp); |
||||
assert!(load_for_script(&path, "/s/a.lua").is_empty()); |
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
||||
std::fs::write(&path, "]]]").unwrap(); |
||||
assert!(load_for_script(&path, "/s/a.lua").is_empty()); |
||||
} |
||||
|
||||
#[test] |
||||
fn concurrent_persists_do_not_lose_updates() { |
||||
let tmp = tempfile::tempdir().unwrap(); |
||||
let path = store_path(&tmp); |
||||
let threads: Vec<_> = (0..8) |
||||
.map(|i| { |
||||
let path = path.clone(); |
||||
std::thread::spawn(move || { |
||||
for j in 0..10 { |
||||
persist_fs( |
||||
&path, |
||||
"/s/a.lua", |
||||
&format!("/dir-{i}-{j}"), |
||||
DirAccess { read: Some(true), write: None }, |
||||
) |
||||
.unwrap(); |
||||
persist_net(&path, "/s/a.lua", &format!("https://h{i}-{j}.example"), true) |
||||
.unwrap(); |
||||
} |
||||
}) |
||||
}) |
||||
.collect(); |
||||
for t in threads { |
||||
t.join().unwrap(); |
||||
} |
||||
// every one of the 80+80 grants survived the concurrent
|
||||
// read-merge-writes, across both sections
|
||||
let grants = load_for_script(&path, "/s/a.lua"); |
||||
assert_eq!(grants.fs.len(), 80); |
||||
assert_eq!(grants.net.len(), 80); |
||||
} |
||||
|
||||
#[test] |
||||
fn default_store_path_respects_xdg_rules() { |
||||
// Can't mutate the process environment safely in a threaded test
|
||||
// runner; assert only on the shape of whatever the real env yields.
|
||||
if let Some(p) = default_store_path() { |
||||
assert!(p.is_absolute()); |
||||
assert!(p.ends_with(format!("{APP_NAME}/access.json"))); |
||||
} |
||||
} |
||||
} |
||||
@ -1,222 +0,0 @@ |
||||
//! A sandboxed `require` for loading Lua modules from files.
|
||||
//!
|
||||
//! Module names use stock Lua dot notation (`require "db.migrations"`) and are
|
||||
//! resolved against a fixed list of root directories — the main script's real
|
||||
//! directory (the CWD in the REPL) followed by the entries of
|
||||
//! `$LUNA_INCLUDE_PATHS` — through the standard `?.lua` / `?/init.lua` templates.
|
||||
//! Because a name may only contain identifier-like segments, a module can
|
||||
//! never name a file outside the roots.
|
||||
//!
|
||||
//! Each file is executed once and its return value cached by canonical path,
|
||||
//! so repeated requires — even under different names or through symlinks —
|
||||
//! return the same value.
|
||||
|
||||
use std::cell::RefCell; |
||||
use std::collections::{HashMap, HashSet}; |
||||
use std::path::PathBuf; |
||||
use std::rc::Rc; |
||||
|
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
/// Globals pre-seeded into the module cache, so `require "http"` returns the
|
||||
/// same table as the global: scripts read more portably and LSPs get an
|
||||
/// anchor for the stdlib. Covers the project stdlib plus the stock libraries
|
||||
/// the sandbox loads.
|
||||
const BUILTINS: &[&str] = &[ |
||||
"utils", |
||||
"log", |
||||
"sqlite", |
||||
"http", |
||||
"fs", |
||||
"task", |
||||
"tui", |
||||
"coroutine", |
||||
"math", |
||||
"os", |
||||
"string", |
||||
"table", |
||||
"utf8", |
||||
]; |
||||
|
||||
/// Strip a leading UTF-8 BOM and a shebang line from Lua source, as stock Lua
|
||||
/// does. The shebang's newline is kept so error messages still report correct
|
||||
/// line numbers. Used for the main script and for every required file.
|
||||
pub(crate) fn prepare_chunk(source: &[u8]) -> &[u8] { |
||||
let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(source); |
||||
if source.first() == Some(&b'#') { |
||||
let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len()); |
||||
&source[nl..] |
||||
} else { |
||||
source |
||||
} |
||||
} |
||||
|
||||
/// Install the global `require` function, searching `roots` in order.
|
||||
pub(crate) fn install(lua: &Lua, roots: Vec<PathBuf>) -> LuaResult<()> { |
||||
// Cache of loaded modules, held Lua-side so the values stay GC-anchored.
|
||||
// Built-ins are keyed by name, file modules by canonical path; the key
|
||||
// spaces cannot collide because canonical paths are absolute.
|
||||
let loaded = lua.create_table()?; |
||||
for name in BUILTINS { |
||||
let value: LuaValue = lua.globals().get(*name)?; |
||||
if !value.is_nil() { |
||||
loaded.set(*name, value)?; |
||||
} |
||||
} |
||||
|
||||
let roots = Rc::new(roots); |
||||
// Module name -> canonical path it resolved to; repeated requires of the
|
||||
// same name skip the filesystem probing.
|
||||
let resolved: Rc<RefCell<HashMap<String, String>>> = Rc::default(); |
||||
// Canonical paths of modules currently executing, to catch require cycles.
|
||||
let in_flight: Rc<RefCell<HashSet<String>>> = Rc::default(); |
||||
|
||||
let require = lua.create_async_function(move |lua, name: String| { |
||||
let loaded = loaded.clone(); |
||||
let roots = roots.clone(); |
||||
let resolved = resolved.clone(); |
||||
let in_flight = in_flight.clone(); |
||||
async move { require_impl(lua, name, loaded, roots, resolved, in_flight).await } |
||||
})?; |
||||
lua.globals().set("require", require) |
||||
} |
||||
|
||||
async fn require_impl( |
||||
lua: Lua, |
||||
name: String, |
||||
loaded: LuaTable, |
||||
roots: Rc<Vec<PathBuf>>, |
||||
resolved: Rc<RefCell<HashMap<String, String>>>, |
||||
in_flight: Rc<RefCell<HashSet<String>>>, |
||||
) -> LuaResult<(LuaValue, Option<String>)> { |
||||
// Built-in module?
|
||||
let builtin: LuaValue = loaded.get(name.as_str())?; |
||||
if !builtin.is_nil() { |
||||
return Ok((builtin, None)); |
||||
} |
||||
|
||||
// A name that already resolved to a loaded file?
|
||||
if let Some(key) = resolved.borrow().get(&name).cloned() { |
||||
let value: LuaValue = loaded.get(key.as_str())?; |
||||
if !value.is_nil() { |
||||
return Ok((value, Some(key))); |
||||
} |
||||
} |
||||
|
||||
let Some(rel) = module_relpath(&name) else { |
||||
return Err(mlua::Error::runtime(format!( |
||||
"invalid module name '{name}' (expected dot-separated segments of [A-Za-z0-9_-], like 'dir.mod')" |
||||
))); |
||||
}; |
||||
|
||||
// Probe every root so a hit shadowed by an earlier one can be warned about.
|
||||
let mut hits: Vec<PathBuf> = Vec::new(); |
||||
let mut misses: Vec<PathBuf> = Vec::new(); |
||||
for root in roots.iter() { |
||||
let base = root.join(&rel); |
||||
for candidate in [base.with_extension("lua"), base.join("init.lua")] { |
||||
match tokio::fs::metadata(&candidate).await { |
||||
Ok(meta) if meta.is_file() => hits.push(candidate), |
||||
_ => misses.push(candidate), |
||||
} |
||||
} |
||||
} |
||||
|
||||
let Some(found) = hits.first() else { |
||||
let mut msg = format!("module '{name}' not found:"); |
||||
for miss in &misses { |
||||
msg.push_str(&format!("\n\tno file '{}'", miss.display())); |
||||
} |
||||
return Err(mlua::Error::runtime(msg)); |
||||
}; |
||||
for shadowed in &hits[1..] { |
||||
log::warn!( |
||||
"require '{name}': using '{}', ignoring shadowed '{}'", |
||||
found.display(), |
||||
shadowed.display() |
||||
); |
||||
} |
||||
|
||||
let real = tokio::fs::canonicalize(found).await.map_err(|e| { |
||||
mlua::Error::runtime(format!( |
||||
"require '{name}': cannot resolve '{}': {e}", |
||||
found.display() |
||||
)) |
||||
})?; |
||||
let key = real.display().to_string(); |
||||
|
||||
// The same file may already be loaded under another name or via a symlink.
|
||||
let value: LuaValue = loaded.get(key.as_str())?; |
||||
if !value.is_nil() { |
||||
resolved.borrow_mut().insert(name, key.clone()); |
||||
return Ok((value, Some(key))); |
||||
} |
||||
|
||||
if !in_flight.borrow_mut().insert(key.clone()) { |
||||
return Err(mlua::Error::runtime(format!( |
||||
"circular require of module '{name}' ('{key}')" |
||||
))); |
||||
} |
||||
// Clears the in-flight marker even when the load errors or is cancelled,
|
||||
// so a failed module can be required again.
|
||||
let _guard = InFlightGuard { |
||||
set: in_flight.clone(), |
||||
key: key.clone(), |
||||
}; |
||||
|
||||
let source = tokio::fs::read(&real) |
||||
.await |
||||
.map_err(|e| mlua::Error::runtime(format!("require '{name}': cannot read '{key}': {e}")))?; |
||||
|
||||
let value: LuaValue = lua |
||||
.load(prepare_chunk(&source)) |
||||
// "@" marks the chunk name as a file path in error messages / tracebacks
|
||||
.set_name(format!("@{key}")) |
||||
// like stock Lua, the chunk receives the module name and file path as `...`
|
||||
.call_async((name.as_str(), key.as_str())) |
||||
.await?; |
||||
|
||||
// A module that returns nothing is cached as `true`, as stock require does.
|
||||
let value = if value.is_nil() { |
||||
LuaValue::Boolean(true) |
||||
} else { |
||||
value |
||||
}; |
||||
loaded.set(key.as_str(), value.clone())?; |
||||
resolved.borrow_mut().insert(name, key.clone()); |
||||
Ok((value, Some(key))) |
||||
} |
||||
|
||||
/// Convert a dot-separated module name into a relative path, or `None` when
|
||||
/// the name is malformed. Restricting segments to identifier-like characters
|
||||
/// is what keeps `require` inside its roots: `..`, `/` and absolute paths are
|
||||
/// unrepresentable.
|
||||
fn module_relpath(name: &str) -> Option<PathBuf> { |
||||
if name.is_empty() { |
||||
return None; |
||||
} |
||||
let mut rel = PathBuf::new(); |
||||
for segment in name.split('.') { |
||||
let ok = !segment.is_empty() |
||||
&& segment |
||||
.bytes() |
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); |
||||
if !ok { |
||||
return None; |
||||
} |
||||
rel.push(segment); |
||||
} |
||||
Some(rel) |
||||
} |
||||
|
||||
struct InFlightGuard { |
||||
set: Rc<RefCell<HashSet<String>>>, |
||||
key: String, |
||||
} |
||||
|
||||
impl Drop for InFlightGuard { |
||||
fn drop(&mut self) { |
||||
self.set.borrow_mut().remove(&self.key); |
||||
} |
||||
} |
||||
@ -1,291 +0,0 @@ |
||||
//! SymfonyStyle-like message blocks: `[LABEL]`-prefixed colored blocks
|
||||
//! (`tui.success`, `tui.error`, …) and outlined boxes (`tui.outlineBlock`
|
||||
//! and its presets).
|
||||
//!
|
||||
//! Rendering is pure — `render_block`/`render_outline_block` take the line
|
||||
//! length and a `colorize` flag and return finished lines — so unit tests pin
|
||||
//! exact output; the Lua bindings in `mod.rs` supply the real terminal width
|
||||
//! and color policy and do the printing.
|
||||
//!
|
||||
//! Message text is treated as **plain text** here (matching Symfony's blocks,
|
||||
//! which escape their input): it is word-wrapped and padded by character
|
||||
//! count, and the single block style is applied to whole lines. Rendering
|
||||
//! wrapped text that itself contains markup is not supported — compose with
|
||||
//! `tui.styled` output on your own lines instead.
|
||||
|
||||
use super::markup::{self, Style}; |
||||
|
||||
/// Blocks never grow wider than this, even on wide terminals (Symfony's
|
||||
/// MAX_LINE_LENGTH).
|
||||
pub(super) const MAX_LINE_LENGTH: usize = 120; |
||||
|
||||
pub(super) struct BlockCfg { |
||||
pub label: Option<String>, |
||||
pub style: Option<Style>, |
||||
pub prefix: String, |
||||
pub padding: bool, |
||||
} |
||||
|
||||
/// Greedy word wrap by character count. Explicit newlines are kept (an empty
|
||||
/// input line stays an empty output line), runs of spaces collapse, and words
|
||||
/// longer than the width are hard-broken.
|
||||
fn wrap(text: &str, width: usize) -> Vec<String> { |
||||
let width = width.max(1); |
||||
let mut out = Vec::new(); |
||||
for para in text.replace("\r\n", "\n").split('\n') { |
||||
let mut line = String::new(); |
||||
let mut len = 0usize; |
||||
for word in para.split_whitespace() { |
||||
let mut rest = word; |
||||
let mut rest_len = rest.chars().count(); |
||||
if len > 0 && len + 1 + rest_len > width { |
||||
out.push(std::mem::take(&mut line)); |
||||
len = 0; |
||||
} else if len > 0 { |
||||
line.push(' '); |
||||
len += 1; |
||||
} |
||||
while rest_len > width - len { |
||||
let cut = rest |
||||
.char_indices() |
||||
.nth(width - len) |
||||
.map(|(i, _)| i) |
||||
.unwrap_or(rest.len()); |
||||
line.push_str(&rest[..cut]); |
||||
out.push(std::mem::take(&mut line)); |
||||
len = 0; |
||||
rest = &rest[cut..]; |
||||
rest_len = rest.chars().count(); |
||||
} |
||||
line.push_str(rest); |
||||
len += rest_len; |
||||
} |
||||
out.push(line); |
||||
} |
||||
out |
||||
} |
||||
|
||||
/// Wrap each message to `width`, with a blank line between messages.
|
||||
fn wrap_messages(messages: &[String], width: usize) -> Vec<String> { |
||||
let mut lines = Vec::new(); |
||||
for (i, msg) in messages.iter().enumerate() { |
||||
if i > 0 { |
||||
lines.push(String::new()); |
||||
} |
||||
lines.extend(wrap(msg, width)); |
||||
} |
||||
lines |
||||
} |
||||
|
||||
/// A `[LABEL] message` block: wrapped, prefixed, continuation lines indented
|
||||
/// under the label, every line padded to `line_length` and painted with the
|
||||
/// block style. Padding (a blank styled line above and below) only applies
|
||||
/// when colorizing — without a background color it would just be empty lines
|
||||
/// (same rule as Symfony's `isDecorated()` check).
|
||||
pub(super) fn render_block( |
||||
messages: &[String], |
||||
cfg: &BlockCfg, |
||||
line_length: usize, |
||||
colorize: bool, |
||||
) -> Vec<String> { |
||||
let label = cfg.label.as_ref().map(|l| format!("[{l}] ")); |
||||
let prefix_len = cfg.prefix.chars().count(); |
||||
let indent_len = label.as_ref().map_or(0, |l| l.chars().count()); |
||||
let text_width = line_length.saturating_sub(prefix_len + indent_len).max(1); |
||||
|
||||
let mut lines = wrap_messages(messages, text_width); |
||||
let mut label_line = 0; |
||||
if cfg.padding && colorize { |
||||
lines.insert(0, String::new()); |
||||
lines.push(String::new()); |
||||
label_line = 1; |
||||
} |
||||
|
||||
lines |
||||
.iter() |
||||
.enumerate() |
||||
.map(|(i, content)| { |
||||
let mut line = String::with_capacity(line_length); |
||||
line.push_str(&cfg.prefix); |
||||
if let Some(lbl) = &label { |
||||
if i == label_line { |
||||
line.push_str(lbl); |
||||
} else { |
||||
line.extend(std::iter::repeat_n(' ', indent_len)); |
||||
} |
||||
} |
||||
line.push_str(content); |
||||
match (&cfg.style, colorize) { |
||||
(Some(style), true) => { |
||||
// Pad to full width so the background paints the whole line.
|
||||
let pad = line_length.saturating_sub(line.chars().count()); |
||||
line.extend(std::iter::repeat_n(' ', pad)); |
||||
markup::paint_str(&line, style, true) |
||||
} |
||||
_ => line.trim_end().to_string(), |
||||
} |
||||
}) |
||||
.collect() |
||||
} |
||||
|
||||
/// An outlined box: `┌─ Title ───┐` top border, `│ content │` rows, `└───┘`
|
||||
/// bottom. The style colors the **borders**; content stays plain, which reads
|
||||
/// well on any terminal color scheme (that is the point of the outline
|
||||
/// variant).
|
||||
pub(super) fn render_outline_block( |
||||
messages: &[String], |
||||
title: Option<&str>, |
||||
style: Option<&Style>, |
||||
padding: bool, |
||||
line_length: usize, |
||||
colorize: bool, |
||||
) -> Vec<String> { |
||||
// Row layout: " │ "(3) + content(line_length - 5) + " │"(2).
|
||||
let content_width = line_length.saturating_sub(5).max(1); |
||||
|
||||
let mut lines = wrap_messages(messages, content_width); |
||||
if padding { |
||||
lines.insert(0, String::new()); |
||||
lines.push(String::new()); |
||||
} |
||||
|
||||
let paint = |s: &str| match style { |
||||
Some(st) => markup::paint_str(s, st, colorize), |
||||
None => s.to_string(), |
||||
}; |
||||
|
||||
let mut out = Vec::new(); |
||||
out.push(paint(&match title { |
||||
Some(t) => format!( |
||||
" ┌─ {t} {}┐", |
||||
"─".repeat(line_length.saturating_sub(6 + t.chars().count())) |
||||
), |
||||
None => format!(" ┌{}┐", "─".repeat(line_length.saturating_sub(3))), |
||||
})); |
||||
for content in &lines { |
||||
let pad = content_width.saturating_sub(content.chars().count()); |
||||
out.push(format!( |
||||
"{}{content}{}{}", |
||||
paint(" │ "), |
||||
" ".repeat(pad), |
||||
paint(" │") |
||||
)); |
||||
} |
||||
out.push(paint(&format!(" └{}┘", "─".repeat(line_length.saturating_sub(3))))); |
||||
out |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
fn style(spec: &str) -> Style { |
||||
markup::resolve_style_spec(spec).unwrap() |
||||
} |
||||
|
||||
fn msgs(m: &[&str]) -> Vec<String> { |
||||
m.iter().map(|s| s.to_string()).collect() |
||||
} |
||||
|
||||
#[test] |
||||
fn wrap_basics() { |
||||
assert_eq!(wrap("hello world", 20), vec!["hello world"]); |
||||
assert_eq!(wrap("hello world", 6), vec!["hello", "world"]); |
||||
assert_eq!(wrap("a b c d", 3), vec!["a b", "c d"]); |
||||
// Explicit newlines and blank lines are preserved.
|
||||
assert_eq!(wrap("a\n\nb", 10), vec!["a", "", "b"]); |
||||
// Overlong words hard-break.
|
||||
assert_eq!(wrap("abcdefgh", 3), vec!["abc", "def", "gh"]); |
||||
assert_eq!(wrap("xy abcdef", 4), vec!["xy", "abcd", "ef"]); |
||||
assert_eq!(wrap("", 10), vec![""]); |
||||
} |
||||
|
||||
#[test] |
||||
fn block_plain_when_not_colorized() { |
||||
let cfg = BlockCfg { |
||||
label: Some("OK".into()), |
||||
style: Some(style("fg=black;bg=green")), |
||||
prefix: " ".into(), |
||||
padding: true, |
||||
}; |
||||
// No colors: no padding lines, no escape codes, no trailing spaces.
|
||||
let lines = render_block(&msgs(&["hello world"]), &cfg, 40, false); |
||||
assert_eq!(lines, vec![" [OK] hello world"]); |
||||
} |
||||
|
||||
#[test] |
||||
fn block_colorized_pads_and_paints_full_width() { |
||||
let cfg = BlockCfg { |
||||
label: Some("OK".into()), |
||||
style: Some(style("fg=black;bg=green")), |
||||
prefix: " ".into(), |
||||
padding: true, |
||||
}; |
||||
let lines = render_block(&msgs(&["hello world"]), &cfg, 40, true); |
||||
assert_eq!(lines.len(), 3, "padding adds a line above and below"); |
||||
for l in &lines { |
||||
assert!(l.starts_with("\x1b[30;42m") && l.ends_with("\x1b[0m"), "{l:?}"); |
||||
// Full width between the escape codes.
|
||||
let inner = l.trim_start_matches("\x1b[30;42m").trim_end_matches("\x1b[0m"); |
||||
assert_eq!(inner.chars().count(), 40, "{inner:?}"); |
||||
} |
||||
assert!(lines[1].contains("[OK] hello world")); |
||||
// Padding lines carry no text.
|
||||
assert!(!lines[0].contains("OK") && !lines[2].contains("OK")); |
||||
} |
||||
|
||||
#[test] |
||||
fn block_continuation_lines_indent_under_label() { |
||||
let cfg = BlockCfg { |
||||
label: Some("NOTE".into()), |
||||
style: None, |
||||
prefix: " ! ".into(), |
||||
padding: false, |
||||
}; |
||||
// width 20, prefix 3 + indent 7 => text width 10
|
||||
let lines = render_block(&msgs(&["aaaa bbbb cccc"]), &cfg, 20, true); |
||||
assert_eq!(lines, vec![" ! [NOTE] aaaa bbbb", " ! cccc"]); |
||||
} |
||||
|
||||
#[test] |
||||
fn block_multiple_messages_are_separated() { |
||||
let cfg = BlockCfg { label: None, style: None, prefix: " ".into(), padding: false }; |
||||
let lines = render_block(&msgs(&["one", "two"]), &cfg, 40, false); |
||||
assert_eq!(lines, vec![" one", "", " two"]); |
||||
} |
||||
|
||||
#[test] |
||||
fn outline_geometry_and_border_painting() { |
||||
let lines = render_outline_block( |
||||
&msgs(&["hi"]), |
||||
Some("Note"), |
||||
Some(&style("fg=blue")), |
||||
true, |
||||
20, |
||||
true, |
||||
); |
||||
// top, blank, content, blank, bottom
|
||||
assert_eq!(lines.len(), 5); |
||||
assert_eq!(lines[0], format!("\x1b[34m ┌─ Note ──────────┐\x1b[0m")); |
||||
assert_eq!(lines[4], format!("\x1b[34m └─────────────────┘\x1b[0m")); |
||||
// Borders painted, content plain.
|
||||
assert_eq!(lines[2], format!("\x1b[34m │ \x1b[0mhi{}\x1b[34m │\x1b[0m", " ".repeat(13))); |
||||
} |
||||
|
||||
#[test] |
||||
fn outline_without_title_or_color() { |
||||
let lines = |
||||
render_outline_block(&msgs(&["hi"]), None, Some(&style("fg=red")), false, 12, false); |
||||
assert_eq!(lines, vec![" ┌─────────┐", " │ hi │", " └─────────┘"]); |
||||
for l in &lines { |
||||
assert_eq!(l.chars().count(), 12); |
||||
assert!(!l.contains('\x1b')); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn outline_long_title_does_not_underflow() { |
||||
let lines = render_outline_block(&msgs(&["x"]), Some("A very long title"), None, false, 10, false); |
||||
assert!(lines[0].contains("A very long title")); |
||||
} |
||||
} |
||||
@ -1,886 +0,0 @@ |
||||
//! Symfony-console-style markup renderer backing `tui.styled`.
|
||||
//!
|
||||
//! Input is plain text with HTML-like tags:
|
||||
//! - built-in style names: `<info>` (bold green), `<error>` (white on red),
|
||||
//! and the HTML-like `<b>` (bold), `<i>` (italic), `<u>` (underscore),
|
||||
//! `<s>` (strikethrough);
|
||||
//! - custom presets registered with `tui.addPreset` (checked first, so they
|
||||
//! can shadow the built-ins);
|
||||
//! - attribute specs: `<fg=red;bg=blue;options=bold,underscore>`;
|
||||
//! - shorthand tokens: `<red;on-white;bold>` — a bare token is an option
|
||||
//! name, a color (foreground, including `default` and `#hex`), or
|
||||
//! `on-<color>` (background). Shorthand and `key=value` parts mix freely
|
||||
//! in one tag (`<red;href=https://…>`);
|
||||
//! - hyperlinks: `<href=https://…>text</>`;
|
||||
//! - action tags: cursor movement and clearing emitted in place —
|
||||
//! `<up>`/`<up=3>` (and `down`/`left`/`right`), absolute `<row=3;col=2>`,
|
||||
//! `<start>` (column 1), `<home>` (top-left), and
|
||||
//! `<clear=screen|up|down|line|start|end>`; ops combine in one tag
|
||||
//! (`<up;clear=line>`). See [`parse_action_spec`].
|
||||
//!
|
||||
//! Styles nest: an inner tag overrides only the attributes it sets,
|
||||
//! everything else is inherited from the enclosing style. `</>`, `</info>`
|
||||
//! and `</fg=…>` all simply pop the innermost style.
|
||||
//!
|
||||
//! 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, `\\` 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
|
||||
//! is emitted as a self-contained `\x1b[…m…\x1b[0m` run computed from the
|
||||
//! full effective style, rather than Symfony's reset-and-reapply diffing —
|
||||
//! simpler, and no stale attributes can leak between segments.
|
||||
|
||||
use std::collections::HashMap; |
||||
use std::fmt::Write; |
||||
|
||||
use colored::Color; |
||||
|
||||
/// Custom styles registered with `tui.addPreset`, stored per Lua state (as
|
||||
/// mlua app data) and consulted before the built-in tag names.
|
||||
#[derive(Default)] |
||||
pub(super) struct Presets(pub(super) HashMap<String, Style>); |
||||
|
||||
/// A color attribute in a style spec. `Inherit` (attribute not mentioned in
|
||||
/// the tag) takes the enclosing style's value when nested; `Default`
|
||||
/// (`fg=default` / `<default>` / `<on-default>`) explicitly resets to the
|
||||
/// terminal default, overriding an enclosing color.
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Default)] |
||||
enum ColorSpec { |
||||
#[default] |
||||
Inherit, |
||||
Default, |
||||
Set(Color), |
||||
} |
||||
|
||||
impl ColorSpec { |
||||
fn over(self, base: Self) -> Self { |
||||
match self { |
||||
ColorSpec::Inherit => base, |
||||
other => other, |
||||
} |
||||
} |
||||
} |
||||
|
||||
#[derive(Clone, PartialEq, Debug, Default)] |
||||
pub(super) struct Style { |
||||
fg: ColorSpec, |
||||
bg: ColorSpec, |
||||
bold: bool, |
||||
italic: bool, |
||||
underscore: bool, |
||||
blink: bool, |
||||
reverse: bool, |
||||
conceal: bool, |
||||
strikethrough: bool, |
||||
href: Option<String>, |
||||
} |
||||
|
||||
impl Style { |
||||
fn fg(color: Color) -> Self { |
||||
Style { fg: ColorSpec::Set(color), ..Default::default() } |
||||
} |
||||
|
||||
fn fg_bg(fg: Color, bg: Color) -> Self { |
||||
Style { fg: ColorSpec::Set(fg), bg: ColorSpec::Set(bg), ..Default::default() } |
||||
} |
||||
|
||||
/// The effective style of `self` nested inside `base`: attributes set here
|
||||
/// win, everything else is inherited.
|
||||
fn merge_over(&self, base: &Style) -> Style { |
||||
Style { |
||||
fg: self.fg.over(base.fg), |
||||
bg: self.bg.over(base.bg), |
||||
bold: self.bold || base.bold, |
||||
italic: self.italic || base.italic, |
||||
underscore: self.underscore || base.underscore, |
||||
blink: self.blink || base.blink, |
||||
reverse: self.reverse || base.reverse, |
||||
conceal: self.conceal || base.conceal, |
||||
strikethrough: self.strikethrough || base.strikethrough, |
||||
href: self.href.clone().or_else(|| base.href.clone()), |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// What an opening tag does. `Style` pushes onto the style stack and applies
|
||||
/// to the enclosed text. `Action` emits a self-contained escape sequence in
|
||||
/// place (cursor movement, clearing — see [`parse_action_spec`]) with no
|
||||
/// stack interaction; like all escape output it is dropped when not
|
||||
/// colorizing, so piped `tui.styled` output stays free of control bytes.
|
||||
enum Tag { |
||||
Style(Style), |
||||
Action(String), |
||||
} |
||||
|
||||
/// Resolve a style spec: a custom preset, a built-in name, or an
|
||||
/// attribute/shorthand spec, in that order. Used for tag bodies and for
|
||||
/// `style`/preset arguments outside of tag context (`tui.block`,
|
||||
/// `tui.addPreset`).
|
||||
pub(super) fn resolve_spec(spec: &str, presets: &Presets) -> Option<Style> { |
||||
if let Some(style) = presets.0.get(spec) { |
||||
return Some(style.clone()); |
||||
} |
||||
resolve_style_spec(spec) |
||||
} |
||||
|
||||
/// [`resolve_spec`] without custom presets — for compile-time style literals.
|
||||
pub(super) fn resolve_style_spec(spec: &str) -> Option<Style> { |
||||
named_style(spec).or_else(|| parse_attr_spec(spec)) |
||||
} |
||||
|
||||
/// Render one string with a single style (no tag parsing). The block helpers
|
||||
/// use this to paint fully-assembled lines without the text being
|
||||
/// re-interpreted as markup.
|
||||
pub(super) fn paint_str(text: &str, style: &Style, colorize: bool) -> String { |
||||
let mut out = String::new(); |
||||
paint(&mut out, text, Some(style), colorize); |
||||
out |
||||
} |
||||
|
||||
/// The built-in tag names (case-sensitive, like Symfony's).
|
||||
fn named_style(name: &str) -> Option<Style> { |
||||
Some(match name { |
||||
"info" => Style { bold: true, ..Style::fg(Color::Green) }, |
||||
"error" => Style::fg_bg(Color::White, Color::Red), |
||||
// HTML-like shortcuts.
|
||||
"b" => Style { bold: true, ..Default::default() }, |
||||
"i" => Style { italic: true, ..Default::default() }, |
||||
"u" => Style { underscore: true, ..Default::default() }, |
||||
"s" => Style { strikethrough: true, ..Default::default() }, |
||||
_ => return None, |
||||
}) |
||||
} |
||||
|
||||
/// Parse the inside of `<...>`. Styles win (so a custom preset can shadow an
|
||||
/// action name like `up`); a body that is neither a style nor an action spec
|
||||
/// is `None` — the whole tag (brackets included) stays literal text.
|
||||
fn resolve_tag(body: &str, presets: &Presets) -> Option<Tag> { |
||||
resolve_spec(body, presets) |
||||
.map(Tag::Style) |
||||
.or_else(|| parse_action_spec(body).map(Tag::Action)) |
||||
} |
||||
|
||||
/// Parse an action-tag body: `;`-separated cursor/erase operations, each
|
||||
/// contributing its escape sequence in order. Strict like the style parser —
|
||||
/// one unknown part rejects the whole tag. Relative moves default to 1
|
||||
/// (`<up>` == `<up=1>`); `row`/`col` are absolute and require a value.
|
||||
/// Clears match the `tui.clearLine`/`tui.clearScreen` functions: whole and
|
||||
/// to-start clears also return the cursor (column 1 / home) so the redraw
|
||||
/// starts from a known spot; the to-end variants leave it in place.
|
||||
fn parse_action_spec(body: &str) -> Option<String> { |
||||
if body.is_empty() { |
||||
return None; |
||||
} |
||||
let mut out = String::new(); |
||||
for part in body.split(';') { |
||||
match part.split_once('=') { |
||||
Some(("clear", area)) => out.push_str(match area { |
||||
"screen" => "\x1b[2J\x1b[H", |
||||
"up" => "\x1b[1J\x1b[H", |
||||
"down" => "\x1b[0J", |
||||
"line" => "\x1b[2K\r", |
||||
"start" => "\x1b[1K\r", |
||||
"end" => "\x1b[0K", |
||||
_ => return None, |
||||
}), |
||||
Some((op, count)) => { |
||||
write!(out, "\x1b[{}{}", action_count(count)?, action_final_byte(op)?).unwrap(); |
||||
} |
||||
None => match part { |
||||
"start" => out.push('\r'), |
||||
"home" => out.push_str("\x1b[H"), |
||||
"up" | "down" | "right" | "left" => { |
||||
write!(out, "\x1b[1{}", action_final_byte(part).unwrap()).unwrap(); |
||||
} |
||||
_ => return None, |
||||
}, |
||||
} |
||||
} |
||||
Some(out) |
||||
} |
||||
|
||||
/// CSI final byte of a movement op: relative CUU/CUD/CUF/CUB, absolute
|
||||
/// VPA (`row`) / CHA (`col`).
|
||||
fn action_final_byte(op: &str) -> Option<char> { |
||||
Some(match op { |
||||
"up" => 'A', |
||||
"down" => 'B', |
||||
"right" => 'C', |
||||
"left" => 'D', |
||||
"row" => 'd', |
||||
"col" => 'G', |
||||
_ => return None, |
||||
}) |
||||
} |
||||
|
||||
/// A positive decimal count/coordinate (`up=3`, `row=12`); anything else
|
||||
/// (zero, sign, empty, non-digits) rejects the tag.
|
||||
fn action_count(s: &str) -> Option<u16> { |
||||
if !s.bytes().all(|b| b.is_ascii_digit()) { |
||||
return None; |
||||
} |
||||
s.parse::<u16>().ok().filter(|&n| n > 0) |
||||
} |
||||
|
||||
/// Parse an inline spec: `;`-separated parts, each either `key=value`
|
||||
/// (keys `fg`, `bg`, `options`, `href`) or a bare shorthand token (an option
|
||||
/// name, a color for the foreground, or `on-<color>` for the background).
|
||||
/// Strict — no whitespace trimming, unknown keys/values reject the whole tag.
|
||||
fn parse_attr_spec(body: &str) -> Option<Style> { |
||||
if body.is_empty() { |
||||
return None; |
||||
} |
||||
let mut style = Style::default(); |
||||
for part in body.split(';') { |
||||
match part.split_once('=') { |
||||
Some(("fg", value)) => style.fg = parse_color(value)?, |
||||
Some(("bg", value)) => style.bg = parse_color(value)?, |
||||
Some(("options", value)) => { |
||||
for opt in value.split(',') { |
||||
apply_option(&mut style, opt)?; |
||||
} |
||||
} |
||||
Some(("href", value)) => { |
||||
if value.is_empty() { |
||||
return None; |
||||
} |
||||
style.href = Some(value.to_string()); |
||||
} |
||||
Some(_) => return None, |
||||
None => apply_shorthand(&mut style, part)?, |
||||
} |
||||
} |
||||
Some(style) |
||||
} |
||||
|
||||
fn apply_option(style: &mut Style, opt: &str) -> Option<()> { |
||||
match opt { |
||||
"bold" => style.bold = true, |
||||
"italic" => style.italic = true, |
||||
"underscore" => style.underscore = true, |
||||
"blink" => style.blink = true, |
||||
"reverse" => style.reverse = true, |
||||
"conceal" => style.conceal = true, |
||||
"strikethrough" => style.strikethrough = true, |
||||
_ => return None, |
||||
} |
||||
Some(()) |
||||
} |
||||
|
||||
/// A bare shorthand token: an option name (`bold`), a built-in style name
|
||||
/// (`b`, `info` — merged in), a foreground color (`red`, `default`, `#f0a`),
|
||||
/// or a background color (`on-red`, `on-default`).
|
||||
fn apply_shorthand(style: &mut Style, token: &str) -> Option<()> { |
||||
if apply_option(style, token).is_some() { |
||||
return Some(()); |
||||
} |
||||
if let Some(named) = named_style(token) { |
||||
*style = named.merge_over(style); |
||||
return Some(()); |
||||
} |
||||
if let Some(bg) = token.strip_prefix("on-") { |
||||
style.bg = parse_color(bg)?; |
||||
return Some(()); |
||||
} |
||||
style.fg = parse_color(token)?; |
||||
Some(()) |
||||
} |
||||
|
||||
fn parse_color(s: &str) -> Option<ColorSpec> { |
||||
Some(ColorSpec::Set(match s { |
||||
"default" => return Some(ColorSpec::Default), |
||||
"black" => Color::Black, |
||||
"red" => Color::Red, |
||||
"green" => Color::Green, |
||||
"yellow" => Color::Yellow, |
||||
"blue" => Color::Blue, |
||||
"magenta" => Color::Magenta, |
||||
"cyan" => Color::Cyan, |
||||
"white" => Color::White, |
||||
// Symfony's name for the bright-black ANSI slot.
|
||||
"gray" | "bright-black" => Color::BrightBlack, |
||||
"bright-red" => Color::BrightRed, |
||||
"bright-green" => Color::BrightGreen, |
||||
"bright-yellow" => Color::BrightYellow, |
||||
"bright-blue" => Color::BrightBlue, |
||||
"bright-magenta" => Color::BrightMagenta, |
||||
"bright-cyan" => Color::BrightCyan, |
||||
"bright-white" => Color::BrightWhite, |
||||
_ => return parse_hex_color(s), |
||||
})) |
||||
} |
||||
|
||||
/// `#rrggbb` or `#rgb` (each nibble doubled, CSS-style) → truecolor.
|
||||
fn parse_hex_color(s: &str) -> Option<ColorSpec> { |
||||
let hex = s.strip_prefix('#')?; |
||||
let nibble = |i: usize| u8::from_str_radix(&hex[i..i + 1], 16).ok(); |
||||
let (r, g, b) = match hex.len() { |
||||
3 => (nibble(0)? * 17, nibble(1)? * 17, nibble(2)? * 17), |
||||
6 => ( |
||||
u8::from_str_radix(&hex[0..2], 16).ok()?, |
||||
u8::from_str_radix(&hex[2..4], 16).ok()?, |
||||
u8::from_str_radix(&hex[4..6], 16).ok()?, |
||||
), |
||||
_ => return None, |
||||
}; |
||||
Some(ColorSpec::Set(Color::TrueColor { r, g, b })) |
||||
} |
||||
|
||||
/// SGR parameter list for the full effective style. Empty means the segment
|
||||
/// needs no escape codes at all (explicit `default` colors emit nothing —
|
||||
/// every segment is already self-terminated by `\x1b[0m`).
|
||||
fn sgr_params(style: &Style) -> Vec<String> { |
||||
fn color_params(spec: ColorSpec, base: u16, extended_intro: u16) -> Option<String> { |
||||
match spec { |
||||
ColorSpec::Inherit | ColorSpec::Default => None, |
||||
ColorSpec::Set(Color::TrueColor { r, g, b }) => { |
||||
Some(format!("{extended_intro};2;{r};{g};{b}")) |
||||
} |
||||
// Not produced by parse_color today, but the colored enum has it
|
||||
// (256-color palette); map it correctly rather than panicking.
|
||||
ColorSpec::Set(Color::AnsiColor(n)) => Some(format!("{extended_intro};5;{n}")), |
||||
ColorSpec::Set(c) => { |
||||
let n = match c { |
||||
Color::Black => 0, |
||||
Color::Red => 1, |
||||
Color::Green => 2, |
||||
Color::Yellow => 3, |
||||
Color::Blue => 4, |
||||
Color::Magenta => 5, |
||||
Color::Cyan => 6, |
||||
Color::White => 7, |
||||
Color::BrightBlack => 60, |
||||
Color::BrightRed => 61, |
||||
Color::BrightGreen => 62, |
||||
Color::BrightYellow => 63, |
||||
Color::BrightBlue => 64, |
||||
Color::BrightMagenta => 65, |
||||
Color::BrightCyan => 66, |
||||
Color::BrightWhite => 67, |
||||
Color::TrueColor { .. } | Color::AnsiColor(_) => unreachable!(), |
||||
}; |
||||
Some((base + n).to_string()) |
||||
} |
||||
} |
||||
} |
||||
|
||||
let mut params = Vec::new(); |
||||
if style.bold { |
||||
params.push("1".to_string()); |
||||
} |
||||
if style.italic { |
||||
params.push("3".to_string()); |
||||
} |
||||
if style.underscore { |
||||
params.push("4".to_string()); |
||||
} |
||||
if style.blink { |
||||
params.push("5".to_string()); |
||||
} |
||||
if style.reverse { |
||||
params.push("7".to_string()); |
||||
} |
||||
if style.conceal { |
||||
params.push("8".to_string()); |
||||
} |
||||
if style.strikethrough { |
||||
params.push("9".to_string()); |
||||
} |
||||
params.extend(color_params(style.fg, 30, 38)); |
||||
params.extend(color_params(style.bg, 40, 48)); |
||||
params |
||||
} |
||||
|
||||
/// Append `text` styled with the effective `style` (or plain when `None` /
|
||||
/// not colorizing / the style has no visible effect).
|
||||
fn paint(out: &mut String, text: &str, style: Option<&Style>, colorize: bool) { |
||||
let style = match style { |
||||
Some(s) if colorize => s, |
||||
_ => { |
||||
out.push_str(text); |
||||
return; |
||||
} |
||||
}; |
||||
if let Some(url) = &style.href { |
||||
write!(out, "\x1b]8;;{url}\x1b\\").unwrap(); |
||||
} |
||||
let params = sgr_params(style); |
||||
if params.is_empty() { |
||||
out.push_str(text); |
||||
} else { |
||||
write!(out, "\x1b[{}m{text}\x1b[0m", params.join(";")).unwrap(); |
||||
} |
||||
if style.href.is_some() { |
||||
out.push_str("\x1b]8;;\x1b\\"); |
||||
} |
||||
} |
||||
|
||||
enum Event<'a> { |
||||
Text(&'a str), |
||||
Open(Tag), |
||||
/// A valid closing tag; `raw` is kept so a stray close (empty stack) can
|
||||
/// be re-emitted literally.
|
||||
Close { |
||||
raw: &'a str, |
||||
}, |
||||
} |
||||
|
||||
/// Single pass over the input. Byte-wise scanning is safe: the only bytes
|
||||
/// acted on (`\`, `<`, `>`, `\n`) are ASCII and never occur inside a UTF-8
|
||||
/// multi-byte sequence, and all slice cuts land on those ASCII boundaries.
|
||||
fn tokenize<'a>(input: &'a str, presets: &Presets) -> Vec<Event<'a>> { |
||||
fn flush<'a>(events: &mut Vec<Event<'a>>, input: &'a str, from: usize, to: usize) { |
||||
if from < to { |
||||
events.push(Event::Text(&input[from..to])); |
||||
} |
||||
} |
||||
|
||||
let bytes = input.as_bytes(); |
||||
let mut events = Vec::new(); |
||||
let mut run_start = 0; // start of the current literal text run
|
||||
let mut i = 0; |
||||
|
||||
while i < bytes.len() { |
||||
match bytes[i] { |
||||
// `\<` 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; |
||||
run_start = i; |
||||
} |
||||
b'<' => { |
||||
// A tag body may not contain `<` or a newline; without a
|
||||
// closing `>` the bracket is literal (`a < b` stays intact).
|
||||
let mut end = None; |
||||
let mut j = i + 1; |
||||
while j < bytes.len() { |
||||
match bytes[j] { |
||||
b'>' => { |
||||
end = Some(j); |
||||
break; |
||||
} |
||||
b'<' | b'\n' => break, |
||||
_ => j += 1, |
||||
} |
||||
} |
||||
let Some(end) = end else { |
||||
i += 1; |
||||
continue; |
||||
}; |
||||
let body = &input[i + 1..end]; |
||||
let raw = &input[i..=end]; |
||||
let event = if let Some(rest) = body.strip_prefix('/') { |
||||
// `</>`, or any spec that would be a valid *style* opener.
|
||||
(rest.is_empty() || matches!(resolve_tag(rest, presets), Some(Tag::Style(_)))) |
||||
.then_some(Event::Close { raw }) |
||||
} else { |
||||
resolve_tag(body, presets).map(Event::Open) |
||||
}; |
||||
match event { |
||||
Some(ev) => { |
||||
flush(&mut events, input, run_start, i); |
||||
events.push(ev); |
||||
i = end + 1; |
||||
run_start = i; |
||||
} |
||||
// Unparseable tag: leave the whole `<...>` in the text run.
|
||||
None => i = end + 1, |
||||
} |
||||
} |
||||
_ => i += 1, |
||||
} |
||||
} |
||||
flush(&mut events, input, run_start, bytes.len()); |
||||
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.
|
||||
pub(super) fn render(input: &str, colorize: bool, presets: &Presets) -> String { |
||||
let mut out = String::with_capacity(input.len()); |
||||
// Effective (pre-merged) styles; `last()` is what the current text gets.
|
||||
let mut stack: Vec<Style> = Vec::new(); |
||||
// Adjacent text events (e.g. either side of an escaped bracket) are
|
||||
// buffered so a contiguous same-styled run becomes one SGR segment.
|
||||
let mut pending = String::new(); |
||||
|
||||
for event in tokenize(input, presets) { |
||||
if !matches!(event, Event::Text(_)) && !pending.is_empty() { |
||||
paint(&mut out, &pending, stack.last(), colorize); |
||||
pending.clear(); |
||||
} |
||||
match event { |
||||
Event::Text(t) => pending.push_str(t), |
||||
Event::Open(Tag::Style(delta)) => { |
||||
let effective = match stack.last() { |
||||
Some(base) => delta.merge_over(base), |
||||
None => delta, |
||||
}; |
||||
stack.push(effective); |
||||
} |
||||
Event::Open(Tag::Action(seq)) => { |
||||
if colorize { |
||||
out.push_str(&seq); |
||||
} |
||||
} |
||||
Event::Close { raw } => { |
||||
if stack.pop().is_none() { |
||||
out.push_str(raw); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
if !pending.is_empty() { |
||||
paint(&mut out, &pending, stack.last(), colorize); |
||||
} |
||||
out |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
/// render() without custom presets.
|
||||
fn r(input: &str, colorize: bool) -> String { |
||||
render(input, colorize, &Presets::default()) |
||||
} |
||||
|
||||
#[test] |
||||
fn plain_text_passes_through() { |
||||
assert_eq!(r("hello world", true), "hello world"); |
||||
assert_eq!(r("hello world", false), "hello world"); |
||||
} |
||||
|
||||
#[test] |
||||
fn builtin_styles() { |
||||
assert_eq!(r("<info>x</info>", true), "\x1b[1;32mx\x1b[0m"); |
||||
assert_eq!(r("<error>x</>", true), "\x1b[37;41mx\x1b[0m"); |
||||
// Symfony's <comment> and <question> are deliberately NOT built in
|
||||
// (register with tui.addPreset if wanted) — they stay literal.
|
||||
assert_eq!(r("<comment>x</comment>", true), "<comment>x</comment>"); |
||||
assert_eq!(r("<question>x</>", true), "<question>x</>"); |
||||
} |
||||
|
||||
#[test] |
||||
fn html_like_builtins() { |
||||
assert_eq!(r("<b>x</b>", true), "\x1b[1mx\x1b[0m"); |
||||
assert_eq!(r("<i>x</i>", true), "\x1b[3mx\x1b[0m"); |
||||
assert_eq!(r("<u>x</u>", true), "\x1b[4mx\x1b[0m"); |
||||
assert_eq!(r("<s>x</s>", true), "\x1b[9mx\x1b[0m"); |
||||
assert_eq!(r("<b><i>x</i></b>", true), "\x1b[1;3mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn inline_spec_combined() { |
||||
assert_eq!( |
||||
r("<fg=red;bg=blue;options=bold,underscore>x</>", true), |
||||
"\x1b[1;4;31;44mx\x1b[0m" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn all_options() { |
||||
assert_eq!( |
||||
r("<options=bold,italic,underscore,blink,reverse,conceal,strikethrough>x</>", true), |
||||
"\x1b[1;3;4;5;7;8;9mx\x1b[0m" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn shorthand_tokens() { |
||||
assert_eq!(r("<red>x</>", true), "\x1b[31mx\x1b[0m"); |
||||
assert_eq!(r("<on-white>x</>", true), "\x1b[47mx\x1b[0m"); |
||||
assert_eq!(r("<red;on-white;bold>x</>", true), "\x1b[1;31;47mx\x1b[0m"); |
||||
assert_eq!(r("<bright-cyan;strikethrough>x</>", true), "\x1b[9;96mx\x1b[0m"); |
||||
// Hex colors work as shorthand too, fg and bg.
|
||||
assert_eq!(r("<#f00>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m"); |
||||
assert_eq!(r("<on-#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m"); |
||||
// Shorthand mixes with key=value parts.
|
||||
assert_eq!( |
||||
r("<red;href=https://e.com>x</>", true), |
||||
"\x1b]8;;https://e.com\x1b\\\x1b[31mx\x1b[0m\x1b]8;;\x1b\\" |
||||
); |
||||
// Built-in names work as tokens too.
|
||||
assert_eq!(r("<info;bold>x</>", true), "\x1b[1;32mx\x1b[0m"); |
||||
assert_eq!(r("<b;i>x</>", true), "\x1b[1;3mx\x1b[0m"); |
||||
// Shorthand closing tags pop like any other.
|
||||
assert_eq!(r("<red;bold>x</red;bold>", true), "\x1b[1;31mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn default_as_color() { |
||||
// `default` emits nothing by itself...
|
||||
assert_eq!(r("<default>x</>", true), "x"); |
||||
assert_eq!(r("<on-default>x</>", true), "x"); |
||||
assert_eq!(r("<fg=default;bg=blue>x</>", true), "\x1b[44mx\x1b[0m"); |
||||
// ...but explicitly resets an inherited color when nested.
|
||||
assert_eq!( |
||||
r("<error>a<default>b</>c</error>", true), |
||||
"\x1b[37;41ma\x1b[0m\x1b[41mb\x1b[0m\x1b[37;41mc\x1b[0m" |
||||
); |
||||
assert_eq!( |
||||
r("<error>a<on-default>b</>c</error>", true), |
||||
"\x1b[37;41ma\x1b[0m\x1b[37mb\x1b[0m\x1b[37;41mc\x1b[0m" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn custom_presets() { |
||||
let mut presets = Presets::default(); |
||||
presets.0.insert("keyword".into(), resolve_style_spec("fg=white;bg=magenta").unwrap()); |
||||
presets.0.insert("info".into(), resolve_style_spec("fg=blue").unwrap()); |
||||
|
||||
// A registered preset works as a tag, with both closing forms.
|
||||
assert_eq!( |
||||
render("<keyword>foo</keyword>", true, &presets), |
||||
"\x1b[37;45mfoo\x1b[0m" |
||||
); |
||||
assert_eq!(render("<keyword>foo</>", true, &presets), "\x1b[37;45mfoo\x1b[0m"); |
||||
// Presets nest and merge like any style.
|
||||
assert_eq!( |
||||
render("<keyword>a<b>b</b></keyword>", true, &presets), |
||||
"\x1b[37;45ma\x1b[0m\x1b[1;37;45mb\x1b[0m" |
||||
); |
||||
// Presets shadow built-ins.
|
||||
assert_eq!(render("<info>x</>", true, &presets), "\x1b[34mx\x1b[0m"); |
||||
// Unregistered names are still literal.
|
||||
assert_eq!(render("<keyward>x", true, &presets), "<keyward>x"); |
||||
} |
||||
|
||||
#[test] |
||||
fn bright_gray_colors() { |
||||
assert_eq!(r("<fg=bright-cyan>x</>", true), "\x1b[96mx\x1b[0m"); |
||||
assert_eq!(r("<fg=gray>x</>", true), "\x1b[90mx\x1b[0m"); |
||||
assert_eq!(r("<bg=bright-red>x</>", true), "\x1b[101mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn hex_colors() { |
||||
assert_eq!(r("<fg=#ff0000>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m"); |
||||
assert_eq!(r("<fg=#f0a>x</>", true), "\x1b[38;2;255;0;170mx\x1b[0m"); |
||||
assert_eq!(r("<bg=#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn nesting_merges_and_restores() { |
||||
// Inner <info> overrides fg only; bg red is inherited. After the
|
||||
// close, the outer error style is restored.
|
||||
assert_eq!( |
||||
r("<error>a<info>b</info>c</error>", true), |
||||
"\x1b[37;41ma\x1b[0m\x1b[1;32;41mb\x1b[0m\x1b[37;41mc\x1b[0m" |
||||
); |
||||
assert_eq!( |
||||
r("<fg=red>a<fg=blue>b</>c</>", true), |
||||
"\x1b[31ma\x1b[0m\x1b[34mb\x1b[0m\x1b[31mc\x1b[0m" |
||||
); |
||||
// Options accumulate.
|
||||
assert_eq!(r("<options=bold><options=underscore>x</></>", true), "\x1b[1;4mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn closing_tag_forms_all_pop() { |
||||
assert_eq!(r("<info>x</info>", true), r("<info>x</>", true)); |
||||
assert_eq!(r("<fg=blue;bg=red>x</fg=blue;bg=red>", true), "\x1b[34;41mx\x1b[0m"); |
||||
// Any style-parseable close pops (it is not matched against the opener).
|
||||
assert_eq!(r("<info>x</error>", true), "\x1b[1;32mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn invalid_tags_are_literal() { |
||||
for s in [ |
||||
"<foo>x</foo>", |
||||
"<Info>x", |
||||
"<>", |
||||
"<fg=notacolor>x", |
||||
"<fg=red;wat=1>x", |
||||
"<red;wat>x", |
||||
"<on-nope>x", |
||||
"<options=sparkle>x", |
||||
"<fg = red>x", // strict: no whitespace around `=`
|
||||
"<fg=>x", |
||||
"</garbage>", |
||||
"a < b and c > d", |
||||
"<info", // unclosed at EOF
|
||||
] { |
||||
assert_eq!(r(s, true), s, "should pass through literally: {s:?}"); |
||||
} |
||||
// `<` never matches across another `<` or a newline.
|
||||
assert_eq!(r("a <<info>b</>", true), "a <\x1b[1;32mb\x1b[0m"); |
||||
assert_eq!(r("<foo\n>x", true), "<foo\n>x"); |
||||
} |
||||
|
||||
#[test] |
||||
fn stray_close_is_literal() { |
||||
assert_eq!(r("x</>", true), "x</>"); |
||||
assert_eq!(r("<info>a</></>", true), "\x1b[1;32ma\x1b[0m</>"); |
||||
} |
||||
|
||||
#[test] |
||||
fn escaping() { |
||||
assert_eq!(r("\\<info>x", true), "<info>x"); |
||||
assert_eq!(r("\\<info>x", false), "<info>x"); |
||||
assert_eq!(r("foo\\<bar", true), "foo<bar"); |
||||
assert_eq!(r("\\>", true), ">"); |
||||
// `\\` 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!( |
||||
r("<href=https://example.com/>link</>", true), |
||||
"\x1b]8;;https://example.com/\x1b\\link\x1b]8;;\x1b\\" |
||||
); |
||||
// Styled text inside the hyperlink span.
|
||||
assert_eq!( |
||||
r("<href=https://e.com><info>x</info></>", true), |
||||
"\x1b]8;;https://e.com\x1b\\\x1b[1;32mx\x1b[0m\x1b]8;;\x1b\\" |
||||
); |
||||
assert_eq!(r("<href=>x", true), "<href=>x"); |
||||
} |
||||
|
||||
#[test] |
||||
fn colorize_false_strips_everything() { |
||||
let input = |
||||
"<error>a<info>b</info></error> <red;on-white;b>c</> <href=https://e.com>d</> \\<e>"; |
||||
let out = r(input, false); |
||||
assert_eq!(out, "ab c d <e>"); |
||||
assert!(!out.contains('\x1b')); |
||||
} |
||||
|
||||
#[test] |
||||
fn empty_and_tag_only_inputs() { |
||||
assert_eq!(r("", true), ""); |
||||
assert_eq!(r("<info></>", true), ""); |
||||
assert_eq!(r("<info><error></></>", true), ""); |
||||
} |
||||
|
||||
#[test] |
||||
fn unclosed_styles_at_eof_are_fine() { |
||||
assert_eq!(r("<info>x", true), "\x1b[1;32mx\x1b[0m"); |
||||
} |
||||
|
||||
#[test] |
||||
fn action_tags() { |
||||
assert_eq!(r("<up>", true), "\x1b[1A"); |
||||
assert_eq!(r("<up=3>", true), "\x1b[3A"); |
||||
assert_eq!(r("<down>x", true), "\x1b[1Bx"); |
||||
assert_eq!(r("<left=5;right=2>", true), "\x1b[5D\x1b[2C"); |
||||
assert_eq!(r("<row=3;col=2>", true), "\x1b[3d\x1b[2G"); |
||||
assert_eq!(r("<row=5>", true), "\x1b[5d"); |
||||
assert_eq!(r("<start>", true), "\r"); |
||||
assert_eq!(r("<home>", true), "\x1b[H"); |
||||
// Whole and to-start clears also return the cursor (like the
|
||||
// tui.clearLine/clearScreen functions); to-end clears don't move it.
|
||||
assert_eq!(r("<clear=screen>", true), "\x1b[2J\x1b[H"); |
||||
assert_eq!(r("<clear=up>", true), "\x1b[1J\x1b[H"); |
||||
assert_eq!(r("<clear=down>", true), "\x1b[0J"); |
||||
assert_eq!(r("<clear=line>", true), "\x1b[2K\r"); |
||||
assert_eq!(r("<clear=start>", true), "\x1b[1K\r"); |
||||
assert_eq!(r("<clear=end>", true), "\x1b[0K"); |
||||
// Ops combine in one tag, emitted in order.
|
||||
assert_eq!(r("<up;clear=line>", true), "\x1b[1A\x1b[2K\r"); |
||||
assert_eq!(r("<home;clear=down>", true), "\x1b[H\x1b[0J"); |
||||
} |
||||
|
||||
#[test] |
||||
fn action_tags_do_not_affect_the_style_stack() { |
||||
assert_eq!( |
||||
r("<info>a<up>b</info>", true), |
||||
"\x1b[1;32ma\x1b[0m\x1b[1A\x1b[1;32mb\x1b[0m" |
||||
); |
||||
// An action is not an opener: `</>` after it pops the *style*.
|
||||
assert_eq!(r("<info>a<up></>b", true), "\x1b[1;32ma\x1b[0m\x1b[1Ab"); |
||||
} |
||||
|
||||
#[test] |
||||
fn action_tags_stripped_without_colorize() { |
||||
let out = r("a<up=3>b<clear=line>c<home>", false); |
||||
assert_eq!(out, "abc"); |
||||
assert!(!out.contains('\x1b') && !out.contains('\r')); |
||||
} |
||||
|
||||
#[test] |
||||
fn invalid_action_tags_are_literal() { |
||||
for s in [ |
||||
"<up=0>", |
||||
"<up=-1>", |
||||
"<up=+3>", |
||||
"<up=x>", |
||||
"<up=>", |
||||
"<clear>", |
||||
"<clear=all>", |
||||
"<clear=>", |
||||
"<red;up>", // style and action parts don't mix in one tag
|
||||
"<up;red>", |
||||
"<row>", // absolute ops require a value
|
||||
"<col>", |
||||
"<home=2>", |
||||
"<start=2>", |
||||
"</up>", // actions have no closing form
|
||||
] { |
||||
assert_eq!(r(s, true), s, "should pass through literally: {s:?}"); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn preset_shadows_action_name() { |
||||
let mut presets = Presets::default(); |
||||
presets.0.insert("up".into(), resolve_style_spec("fg=red").unwrap()); |
||||
assert_eq!(render("<up>x</>", true, &presets), "\x1b[31mx\x1b[0m"); |
||||
} |
||||
} |
||||
@ -1,57 +0,0 @@ |
||||
//! `tui` — simple terminal interactivity for scripts.
|
||||
//!
|
||||
//! The module has two halves, each in its own submodule:
|
||||
//!
|
||||
//! - **Prompts** ([`prompts`]): interactive one-liners backed by the
|
||||
//! `inquire` crate — `tui.confirm`, `tui.promptText`, `tui.promptSelect`,
|
||||
//! …. Shared shape `(text, default, opts)`; Esc returns `nil`, Ctrl-C
|
||||
//! raises `"tui.<fn>: interrupted"`, no terminal raises `"not a terminal"`.
|
||||
//! - **Output** ([`output`]): `tui.styled` markup (engine in [`markup`]),
|
||||
//! `tui.addPreset`, SymfonyStyle-like message blocks (`tui.success` & co.,
|
||||
//! rendering in [`blocks`]), and the `tui.raw` / `tui.screenInfo`
|
||||
//! building blocks.
|
||||
//!
|
||||
//! A third piece, **terminal control** ([`term`]), exposes the raw escape
|
||||
//! sequences behind rich TUIs — cursor movement, clearing, the alternate
|
||||
//! screen, scroll regions, title/clipboard — as `tui.moveTo`, `tui.altScreen`
|
||||
//! and friends, with [`cleanup`] restoring the terminal on every exit path.
|
||||
//!
|
||||
//! Shared argument parsing (the validated opts table, positional-arg
|
||||
//! helpers) lives in [`opts`].
|
||||
|
||||
mod blocks; |
||||
mod markup; |
||||
mod opts; |
||||
mod output; |
||||
mod prompts; |
||||
mod term; |
||||
|
||||
// The fs module's permission prompt shares the terminal with tui's prompts
|
||||
// and must take the same lock. (In test builds fs never opens a real prompt,
|
||||
// leaving this re-export unused.)
|
||||
#[cfg_attr(test, allow(unused_imports))] |
||||
pub(crate) use prompts::PROMPT_LOCK; |
||||
pub(crate) use term::cleanup; |
||||
|
||||
use mlua::prelude::LuaResult; |
||||
use mlua::Lua; |
||||
|
||||
/// `mlua::Error::external(format!(...))` — every Lua-facing error in this
|
||||
/// module goes through this, prefixed `tui.<fn>: …`.
|
||||
macro_rules! ext_err { |
||||
($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) }; |
||||
} |
||||
use ext_err; |
||||
|
||||
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||
// Registry for tui.addPreset, read by tui.styled and the block `style`
|
||||
// option; per Lua state, so scripts and tests don't leak into each other.
|
||||
lua.set_app_data(markup::Presets::default()); |
||||
|
||||
let tui = lua.create_table()?; |
||||
prompts::install(lua, &tui)?; |
||||
output::install(lua, &tui)?; |
||||
term::install(lua, &tui)?; |
||||
lua.globals().raw_set("tui", tui)?; |
||||
Ok(()) |
||||
} |
||||
@ -1,328 +0,0 @@ |
||||
//! Shared argument parsing for the tui Lua bindings: the validated `opts`
|
||||
//! table view and the positional-argument helpers. All errors follow the
|
||||
//! project convention — prefixed with `tui.<fn>:` and raised via
|
||||
//! `mlua::Error::external`.
|
||||
|
||||
use std::fmt; |
||||
|
||||
use chrono::NaiveDate; |
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
|
||||
use super::ext_err; |
||||
|
||||
pub(super) const DATE_FMT: &str = "%Y-%m-%d"; |
||||
|
||||
/// Validated view over the optional `opts` table. Construction rejects
|
||||
/// unknown keys so a typo (`placholder`) fails loudly instead of being
|
||||
/// silently ignored.
|
||||
#[derive(Debug)] |
||||
pub(super) struct Opts { |
||||
fname: &'static str, |
||||
table: Option<LuaTable>, |
||||
} |
||||
|
||||
impl Opts { |
||||
pub(super) fn parse( |
||||
fname: &'static str, |
||||
table: Option<LuaTable>, |
||||
allowed: &'static [&'static str], |
||||
) -> LuaResult<Opts> { |
||||
if let Some(t) = &table { |
||||
for pair in t.pairs::<LuaValue, LuaValue>() { |
||||
let (key, _) = pair?; |
||||
let name = match &key { |
||||
LuaValue::String(s) => s.to_string_lossy(), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{fname}: option keys must be strings (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
if !allowed.contains(&name.as_str()) { |
||||
return Err(ext_err!( |
||||
"{fname}: unknown option '{name}' (allowed: {})", |
||||
allowed.join(", ") |
||||
)); |
||||
} |
||||
} |
||||
} |
||||
Ok(Opts { fname, table }) |
||||
} |
||||
|
||||
pub(super) fn fname(&self) -> &'static str { |
||||
self.fname |
||||
} |
||||
|
||||
pub(super) fn raw(&self, key: &str) -> LuaResult<LuaValue> { |
||||
match &self.table { |
||||
Some(t) => t.raw_get(key), |
||||
None => Ok(LuaValue::Nil), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn string(&self, key: &str) -> LuaResult<Option<String>> { |
||||
match self.raw(key)? { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::String(s) => Ok(Some( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{}: option '{key}' must be valid UTF-8", self.fname))? |
||||
.to_string(), |
||||
)), |
||||
other => Err(ext_err!( |
||||
"{}: option '{key}' must be a string (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn boolean(&self, key: &str) -> LuaResult<Option<bool>> { |
||||
match self.raw(key)? { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::Boolean(b) => Ok(Some(b)), |
||||
other => Err(ext_err!( |
||||
"{}: option '{key}' must be a boolean (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// A non-negative whole number.
|
||||
pub(super) fn count(&self, key: &str) -> LuaResult<Option<usize>> { |
||||
let v = self.raw(key)?; |
||||
match int_of(&v) { |
||||
_ if v.is_nil() => Ok(None), |
||||
Some(n) if n >= 0 => Ok(Some(n as usize)), |
||||
_ => Err(ext_err!( |
||||
"{}: option '{key}' must be a non-negative integer (got {})", |
||||
self.fname, |
||||
lua_value_brief(&v) |
||||
)), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn number(&self, key: &str) -> LuaResult<Option<f64>> { |
||||
match self.raw(key)? { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::Integer(i) => Ok(Some(i as f64)), |
||||
LuaValue::Number(n) => Ok(Some(n)), |
||||
other => Err(ext_err!( |
||||
"{}: option '{key}' must be a number (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn integer(&self, key: &str) -> LuaResult<Option<i64>> { |
||||
let v = self.raw(key)?; |
||||
match int_of(&v) { |
||||
_ if v.is_nil() => Ok(None), |
||||
Some(n) => Ok(Some(n)), |
||||
None => Err(ext_err!( |
||||
"{}: option '{key}' must be an integer (got {})", |
||||
self.fname, |
||||
lua_value_brief(&v) |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// An array of strings; `key[i]` errors name the offending element.
|
||||
pub(super) fn string_array(&self, key: &str) -> LuaResult<Option<Vec<String>>> { |
||||
let t = match self.raw(key)? { |
||||
LuaValue::Nil => return Ok(None), |
||||
LuaValue::Table(t) => t, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{}: option '{key}' must be an array of strings (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
let mut out = Vec::with_capacity(t.raw_len()); |
||||
for i in 1..=t.raw_len() { |
||||
match t.raw_get::<LuaValue>(i)? { |
||||
LuaValue::String(s) => out.push( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{}: {key}[{i}] must be valid UTF-8", self.fname))? |
||||
.to_string(), |
||||
), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{}: {key}[{i}] must be a string (got {})", |
||||
self.fname, |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
} |
||||
Ok(Some(out)) |
||||
} |
||||
|
||||
/// A `YYYY-MM-DD` date string.
|
||||
pub(super) fn date(&self, key: &str) -> LuaResult<Option<NaiveDate>> { |
||||
match self.string(key)? { |
||||
None => Ok(None), |
||||
Some(s) => Ok(Some(parse_date(self.fname, &format!("option '{key}'"), &s)?)), |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// The prompt text (first argument): a required, valid-UTF-8 string.
|
||||
pub(super) fn text_arg(fname: &str, v: LuaValue) -> LuaResult<String> { |
||||
match v { |
||||
LuaValue::String(s) => Ok(s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{fname}: prompt text must be valid UTF-8"))? |
||||
.to_string()), |
||||
other => Err(ext_err!( |
||||
"{fname}: prompt text must be a string (got {})", |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// The positional `default` argument, when the prompt expects a string.
|
||||
pub(super) fn default_string(fname: &str, v: LuaValue) -> LuaResult<Option<String>> { |
||||
match v { |
||||
LuaValue::Nil => Ok(None), |
||||
LuaValue::String(s) => Ok(Some( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{fname}: default must be valid UTF-8"))? |
||||
.to_string(), |
||||
)), |
||||
other => Err(ext_err!( |
||||
"{fname}: default must be a string (got {})", |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// Integer value of a Lua number, treating an integral float (`3.0`) as its
|
||||
/// integer; `None` for everything else (including `3.5`).
|
||||
pub(super) fn int_of(v: &LuaValue) -> Option<i64> { |
||||
match v { |
||||
LuaValue::Integer(i) => Some(*i), |
||||
LuaValue::Number(n) if n.fract() == 0.0 && n.is_finite() => Some(*n as i64), |
||||
_ => None, |
||||
} |
||||
} |
||||
|
||||
/// `type_name()` alone hides *why* a number was rejected, so show the value
|
||||
/// for numbers and the type for everything else.
|
||||
pub(super) fn lua_value_brief(v: &LuaValue) -> String { |
||||
match v { |
||||
LuaValue::Number(n) => n.to_string(), |
||||
other => other.type_name().to_string(), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn parse_date(fname: &str, what: &str, s: &str) -> LuaResult<NaiveDate> { |
||||
NaiveDate::parse_from_str(s, DATE_FMT) |
||||
.map_err(|_| ext_err!("{fname}: {what} must be a YYYY-MM-DD string (got '{s}')")) |
||||
} |
||||
|
||||
/// `min`/`max` bounds must be ordered when both are present.
|
||||
pub(super) fn check_bounds<T: PartialOrd + fmt::Display>( |
||||
fname: &str, |
||||
min: &Option<T>, |
||||
max: &Option<T>, |
||||
) -> LuaResult<()> { |
||||
if let (Some(a), Some(b)) = (min, max) |
||||
&& a > b |
||||
{ |
||||
return Err(ext_err!("{fname}: min ({a}) must not be greater than max ({b})")); |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
/// Human message for a violated numeric/length bound, phrased by which bounds
|
||||
/// exist: "must be between 1 and 10" / "must be at least 1" / "must be at most 10".
|
||||
pub(super) fn bounds_message<T: fmt::Display>(noun: &str, min: &Option<T>, max: &Option<T>) -> String { |
||||
match (min, max) { |
||||
(Some(a), Some(b)) => format!("{noun} must be between {a} and {b}"), |
||||
(Some(a), None) => format!("{noun} must be at least {a}"), |
||||
(None, Some(b)) => format!("{noun} must be at most {b}"), |
||||
(None, None) => unreachable!("bounds_message called without bounds"), |
||||
} |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
use mlua::Lua; |
||||
|
||||
#[test] |
||||
fn parse_date_accepts_iso_only() { |
||||
assert_eq!( |
||||
parse_date("tui.promptDate", "default", "2026-07-06").unwrap(), |
||||
NaiveDate::from_ymd_opt(2026, 7, 6).unwrap() |
||||
); |
||||
// chrono's %Y-%m-%d is lenient about zero-padding, so "2026-1-5" is
|
||||
// accepted too — only genuinely malformed inputs are rejected.
|
||||
assert!(parse_date("tui.promptDate", "default", "2026-1-5").is_ok()); |
||||
for bad in ["06.07.2026", "not a date", "2026-13-01", "2026-02-30"] { |
||||
let err = parse_date("tui.promptDate", "default", bad).unwrap_err(); |
||||
assert!(err.to_string().contains("YYYY-MM-DD"), "{bad}: {err}"); |
||||
} |
||||
} |
||||
|
||||
#[test] |
||||
fn int_of_accepts_integral_floats_only() { |
||||
assert_eq!(int_of(&LuaValue::Integer(3)), Some(3)); |
||||
assert_eq!(int_of(&LuaValue::Number(3.0)), Some(3)); |
||||
assert_eq!(int_of(&LuaValue::Number(3.5)), None); |
||||
assert_eq!(int_of(&LuaValue::Number(f64::INFINITY)), None); |
||||
assert_eq!(int_of(&LuaValue::Boolean(true)), None); |
||||
} |
||||
|
||||
#[test] |
||||
fn bounds_messages() { |
||||
assert_eq!(bounds_message("value", &Some(1), &Some(10)), "value must be between 1 and 10"); |
||||
assert_eq!(bounds_message("value", &Some(1), &None), "value must be at least 1"); |
||||
assert_eq!(bounds_message::<i64>("value", &None, &Some(10)), "value must be at most 10"); |
||||
} |
||||
|
||||
#[test] |
||||
fn check_bounds_rejects_inverted() { |
||||
assert!(check_bounds("tui.promptInt", &Some(1), &Some(10)).is_ok()); |
||||
assert!(check_bounds("tui.promptInt", &Some(1), &None::<i64>).is_ok()); |
||||
let err = check_bounds("tui.promptInt", &Some(10), &Some(1)).unwrap_err(); |
||||
assert!( |
||||
err.to_string() |
||||
.contains("tui.promptInt: min (10) must not be greater than max (1)"), |
||||
"got: {err}" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn opts_rejects_unknown_keys() { |
||||
let lua = Lua::new(); |
||||
let t = lua.create_table().unwrap(); |
||||
t.raw_set("placholder", "x").unwrap(); |
||||
let err = Opts::parse("tui.promptText", Some(t), &["help", "placeholder"]).unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains("tui.promptText: unknown option 'placholder'"), "got: {msg}"); |
||||
assert!(msg.contains("allowed: help, placeholder"), "got: {msg}"); |
||||
} |
||||
|
||||
#[test] |
||||
fn opts_typed_getters_check_types() { |
||||
let lua = Lua::new(); |
||||
let t = lua.create_table().unwrap(); |
||||
t.raw_set("help", 42).unwrap(); |
||||
t.raw_set("minLen", -1).unwrap(); |
||||
t.raw_set("multiple", "yes").unwrap(); |
||||
let opts = Opts::parse("tui.x", Some(t), &["help", "minLen", "multiple"]).unwrap(); |
||||
assert!(opts.string("help").unwrap_err().to_string().contains("must be a string")); |
||||
assert!(opts |
||||
.count("minLen") |
||||
.unwrap_err() |
||||
.to_string() |
||||
.contains("must be a non-negative integer")); |
||||
assert!(opts.boolean("multiple").unwrap_err().to_string().contains("must be a boolean")); |
||||
} |
||||
} |
||||
@ -1,370 +0,0 @@ |
||||
//! The output half of the tui module: message blocks (`tui.success` & co.,
|
||||
//! rendering in [`super::blocks`]), styled markup (`tui.styled` /
|
||||
//! `tui.addPreset`, engine in [`super::markup`]), and the low-level
|
||||
//! `tui.raw` / `tui.screenInfo` building blocks.
|
||||
|
||||
use std::io::IsTerminal; |
||||
|
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
use super::opts::Opts; |
||||
use super::{blocks, ext_err, markup}; |
||||
|
||||
pub(super) fn colorize() -> bool { |
||||
colored::control::SHOULD_COLORIZE.should_colorize() |
||||
} |
||||
|
||||
/// Width blocks are rendered to: the terminal width capped at
|
||||
/// [`blocks::MAX_LINE_LENGTH`], or the cap itself when there is no terminal.
|
||||
fn terminal_line_length() -> usize { |
||||
match crossterm::terminal::size() { |
||||
Ok((w, _)) if w > 0 => (w as usize).min(blocks::MAX_LINE_LENGTH), |
||||
_ => blocks::MAX_LINE_LENGTH, |
||||
} |
||||
} |
||||
|
||||
/// The block functions' first argument: one message or an array of messages
|
||||
/// (rendered into the same block, separated by a blank line).
|
||||
fn messages_arg(fname: &str, v: LuaValue) -> LuaResult<Vec<String>> { |
||||
match v { |
||||
LuaValue::String(s) => Ok(vec![ |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{fname}: message must be valid UTF-8"))? |
||||
.to_string(), |
||||
]), |
||||
LuaValue::Table(t) => { |
||||
let mut out = Vec::with_capacity(t.raw_len()); |
||||
for i in 1..=t.raw_len() { |
||||
match t.raw_get::<LuaValue>(i)? { |
||||
LuaValue::String(s) => out.push( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{fname}: message[{i}] must be valid UTF-8"))? |
||||
.to_string(), |
||||
), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{fname}: message[{i}] must be a string (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
} |
||||
if out.is_empty() { |
||||
return Err(ext_err!("{fname}: message must not be empty")); |
||||
} |
||||
Ok(out) |
||||
} |
||||
other => Err(ext_err!( |
||||
"{fname}: message must be a string or an array of strings (got {})", |
||||
other.type_name() |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// The `style` option: same syntax as a markup tag body — a preset or
|
||||
/// built-in name (`error`), an attribute spec (`fg=black;bg=green`), or
|
||||
/// shorthand (`black;on-green`). Unlike markup (where a bad tag passes
|
||||
/// through), a bad *argument* fails loudly.
|
||||
fn style_opt(lua: &Lua, opts: &Opts, key: &str) -> LuaResult<Option<markup::Style>> { |
||||
match opts.string(key)? { |
||||
None => Ok(None), |
||||
Some(spec) => { |
||||
let resolved = match lua.app_data_ref::<markup::Presets>() { |
||||
Some(presets) => markup::resolve_spec(&spec, &presets), |
||||
None => markup::resolve_style_spec(&spec), |
||||
}; |
||||
resolved |
||||
.map(Some) |
||||
.ok_or_else(|| ext_err!("{}: invalid style '{spec}'", opts.fname())) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// The module's single stdout sink. In test builds output goes through
|
||||
/// `print!` so the libtest harness can capture it — direct `stdout()` writes
|
||||
/// bypass the capture and trash the test runner's output. In normal builds it
|
||||
/// writes the bytes directly, ignores errors (a broken pipe must not panic
|
||||
/// the runtime) and flushes (tui.raw is used for `\r` redraws mid-line).
|
||||
///
|
||||
/// Serialized with the prompts via [`PROMPT_LOCK`]: while a prompt (inquire
|
||||
/// or a permission request) owns the terminal, a concurrent coroutine's
|
||||
/// output — e.g. a spinner's `<clear=line>` redraw, which would erase the
|
||||
/// prompt's question line — blocks until the prompt is answered, then
|
||||
/// resumes.
|
||||
pub(super) fn write_stdout(bytes: &[u8]) { |
||||
let _guard = super::prompts::PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); |
||||
#[cfg(test)] |
||||
print!("{}", String::from_utf8_lossy(bytes)); |
||||
#[cfg(not(test))] |
||||
{ |
||||
use std::io::Write; |
||||
let mut out = std::io::stdout().lock(); |
||||
let _ = out.write_all(bytes); |
||||
let _ = out.flush(); |
||||
} |
||||
} |
||||
|
||||
/// Print a rendered block surrounded by blank lines (Symfony's block
|
||||
/// spacing), best-effort like `print`.
|
||||
fn print_block(lines: &[String]) { |
||||
let mut out = String::with_capacity(lines.iter().map(|l| l.len() + 1).sum::<usize>() + 2); |
||||
out.push('\n'); |
||||
for line in lines { |
||||
out.push_str(line); |
||||
out.push('\n'); |
||||
} |
||||
out.push('\n'); |
||||
write_stdout(out.as_bytes()); |
||||
} |
||||
|
||||
/// The `[LABEL]` block presets (Symfony's SymfonyStyle block styles, labels
|
||||
/// without decoration). `tui.block` is the generic form with no defaults.
|
||||
struct BlockPreset { |
||||
name: &'static str, |
||||
fname: &'static str, |
||||
label: Option<&'static str>, |
||||
style: Option<&'static str>, |
||||
prefix: &'static str, |
||||
padding: bool, |
||||
} |
||||
|
||||
const BLOCK_PRESETS: &[BlockPreset] = &[ |
||||
BlockPreset { name: "block", fname: "tui.block", label: None, style: None, prefix: " ", padding: false }, |
||||
BlockPreset { name: "success", fname: "tui.success", label: Some("OK"), style: Some("fg=black;bg=green"), prefix: " ", padding: true }, |
||||
BlockPreset { name: "error", fname: "tui.error", label: Some("ERROR"), style: Some("fg=white;bg=red"), prefix: " ", padding: true }, |
||||
BlockPreset { name: "warning", fname: "tui.warning", label: Some("WARNING"), style: Some("fg=black;bg=yellow"), prefix: " ", padding: true }, |
||||
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: Some("fg=gray"), prefix: " ", padding: false }, |
||||
]; |
||||
|
||||
/// The outlined-box presets. Titles are plain words (no emoji) and are only
|
||||
/// fallbacks — `opts.title` overrides them.
|
||||
struct OutlinePreset { |
||||
name: &'static str, |
||||
fname: &'static str, |
||||
title: Option<&'static str>, |
||||
style: Option<&'static str>, |
||||
} |
||||
|
||||
const OUTLINE_PRESETS: &[OutlinePreset] = &[ |
||||
OutlinePreset { name: "outlineBlock", fname: "tui.outlineBlock", title: None, style: None }, |
||||
OutlinePreset { name: "outlineSuccess", fname: "tui.outlineSuccess", title: Some("Success"), style: Some("fg=green") }, |
||||
OutlinePreset { name: "outlineError", fname: "tui.outlineError", title: Some("Error"), style: Some("fg=red") }, |
||||
OutlinePreset { name: "outlineWarning", fname: "tui.outlineWarning", title: Some("Warning"), style: Some("fg=yellow") }, |
||||
OutlinePreset { name: "outlineNote", fname: "tui.outlineNote", title: Some("Note"), style: Some("fg=blue") }, |
||||
OutlinePreset { name: "outlineInfo", fname: "tui.outlineInfo", title: Some("Info"), style: Some("fg=green") }, |
||||
OutlinePreset { name: "outlineCaution", fname: "tui.outlineCaution", title: Some("Caution"), style: Some("fg=red") }, |
||||
]; |
||||
|
||||
/// A preset style spec is a compile-time literal; parsing it cannot fail.
|
||||
fn preset_style(spec: Option<&str>) -> Option<markup::Style> { |
||||
spec.map(|s| markup::resolve_style_spec(s).expect("preset style spec is valid")) |
||||
} |
||||
|
||||
/// A `tui.addPreset` name: identifier-like, so it can never collide with
|
||||
/// attribute specs (which contain `=`) or be mistaken for a closing tag.
|
||||
fn valid_preset_name(name: &str) -> bool { |
||||
let mut chars = name.chars(); |
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic()) |
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') |
||||
} |
||||
|
||||
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> { |
||||
// Message blocks: tui.block + the [LABEL] presets. All print directly to
|
||||
// stdout (blank-line separated), like Symfony's SymfonyStyle.
|
||||
for p in BLOCK_PRESETS { |
||||
tui.raw_set( |
||||
p.name, |
||||
lua.create_function(move |lua, (msg, opts): (LuaValue, Option<LuaTable>)| { |
||||
let opts = Opts::parse(p.fname, opts, &["label", "style", "prefix", "padding"])?; |
||||
let messages = messages_arg(p.fname, msg)?; |
||||
let cfg = blocks::BlockCfg { |
||||
label: opts.string("label")?.or_else(|| p.label.map(str::to_string)), |
||||
style: style_opt(lua, &opts, "style")?.or_else(|| preset_style(p.style)), |
||||
prefix: opts.string("prefix")?.unwrap_or_else(|| p.prefix.to_string()), |
||||
padding: opts.boolean("padding")?.unwrap_or(p.padding), |
||||
}; |
||||
print_block(&blocks::render_block( |
||||
&messages, |
||||
&cfg, |
||||
terminal_line_length(), |
||||
colorize(), |
||||
)); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
} |
||||
|
||||
// Outlined boxes: tui.outlineBlock + presets. opts.title overrides the
|
||||
// preset's fallback title.
|
||||
for p in OUTLINE_PRESETS { |
||||
tui.raw_set( |
||||
p.name, |
||||
lua.create_function(move |lua, (msg, opts): (LuaValue, Option<LuaTable>)| { |
||||
let opts = Opts::parse(p.fname, opts, &["title", "style", "padding"])?; |
||||
let messages = messages_arg(p.fname, msg)?; |
||||
let title = opts.string("title")?.or_else(|| p.title.map(str::to_string)); |
||||
let style = style_opt(lua, &opts, "style")?.or_else(|| preset_style(p.style)); |
||||
let padding = opts.boolean("padding")?.unwrap_or(true); |
||||
print_block(&blocks::render_outline_block( |
||||
&messages, |
||||
title.as_deref(), |
||||
style.as_ref(), |
||||
padding, |
||||
terminal_line_length(), |
||||
colorize(), |
||||
)); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
} |
||||
|
||||
// tui.styled: render markup to an ANSI string (or plain text when colors
|
||||
// are off — pipes, NO_COLOR).
|
||||
tui.raw_set( |
||||
"styled", |
||||
lua.create_function(|lua, v: LuaValue| { |
||||
let s = match v { |
||||
LuaValue::String(s) => s, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"tui.styled: expected a string (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
let s = s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("tui.styled: input must be valid UTF-8"))?; |
||||
let presets = lua |
||||
.app_data_ref::<markup::Presets>() |
||||
.ok_or_else(|| ext_err!("tui.styled: preset registry missing"))?; |
||||
Ok(markup::render(&s, colorize(), &presets)) |
||||
})?, |
||||
)?; |
||||
|
||||
// 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
|
||||
// reference an existing preset or built-in. Re-registering a name
|
||||
// overwrites it; built-in names can be shadowed.
|
||||
tui.raw_set( |
||||
"addPreset", |
||||
lua.create_function(|lua, (name, spec): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.addPreset"; |
||||
let name = match name { |
||||
LuaValue::String(s) => s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{F}: name must be valid UTF-8"))? |
||||
.to_string(), |
||||
other => { |
||||
return Err(ext_err!("{F}: name must be a string (got {})", other.type_name())) |
||||
} |
||||
}; |
||||
if !valid_preset_name(&name) { |
||||
return Err(ext_err!( |
||||
"{F}: name must start with a letter and contain only letters, digits, '-' or '_' (got '{name}')" |
||||
)); |
||||
} |
||||
let spec = match spec { |
||||
LuaValue::String(s) => s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))? |
||||
.to_string(), |
||||
other => { |
||||
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name())) |
||||
} |
||||
}; |
||||
let mut presets = lua |
||||
.app_data_mut::<markup::Presets>() |
||||
.ok_or_else(|| ext_err!("{F}: preset registry missing"))?; |
||||
let style = markup::resolve_spec(&spec, &presets) |
||||
.ok_or_else(|| ext_err!("{F}: invalid style '{spec}'"))?; |
||||
presets.0.insert(name, style); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.raw: write to stdout with no newline and no separator (io.write
|
||||
// semantics) and flush — the building block for progress-style output
|
||||
// (`tui.raw("\rprogress: 50%")`). Values go through tostring; byte
|
||||
// strings pass through unmodified.
|
||||
tui.raw_set( |
||||
"raw", |
||||
lua.create_function(|lua, args: mlua::Variadic<LuaValue>| { |
||||
let tostring: mlua::Function = lua.globals().get("tostring")?; |
||||
let mut out = Vec::new(); |
||||
for v in args { |
||||
let s = tostring.call::<mlua::String>(v)?; |
||||
out.extend_from_slice(&s.as_bytes()); |
||||
} |
||||
write_stdout(&out); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.screenInfo: terminal facts for scripts that render their own UI
|
||||
// (progress bars, right-aligned text, …). width/height are nil when
|
||||
// there is no terminal to measure.
|
||||
tui.raw_set( |
||||
"screenInfo", |
||||
lua.create_function(|lua, ()| { |
||||
let t = lua.create_table()?; |
||||
// Some ptys report 0x0; treat that as "unknown", like no terminal.
|
||||
if let Ok((w, h)) = crossterm::terminal::size() |
||||
&& w > 0 |
||||
&& h > 0 |
||||
{ |
||||
t.raw_set("width", w)?; |
||||
t.raw_set("height", h)?; |
||||
} |
||||
t.raw_set("tty", std::io::stdout().is_terminal())?; |
||||
t.raw_set("colors", colorize())?; |
||||
Ok(t) |
||||
})?, |
||||
)?; |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
#[test] |
||||
fn preset_name_validation() { |
||||
// Names that shadow shorthand tokens ("red", "on-red") are allowed —
|
||||
// shadowing is the user's explicit, documented choice.
|
||||
for ok in ["keyword", "a", "warn2", "my-tag", "my_tag", "B", "red", "on-red"] { |
||||
assert!(valid_preset_name(ok), "{ok} should be valid"); |
||||
} |
||||
for bad in ["", "2fast", "-x", "fg=red", "a;b", "a b", "žluť"] { |
||||
assert!(!valid_preset_name(bad), "{bad} should be invalid"); |
||||
} |
||||
} |
||||
} |
||||
@ -1,742 +0,0 @@ |
||||
//! The interactive prompts (`tui.confirm`, `tui.prompt*`), backed by the
|
||||
//! `inquire` crate.
|
||||
//!
|
||||
//! inquire prompts block their thread (crossterm raw mode), so each one runs
|
||||
//! under `spawn_blocking` like sqlite's I/O — the calling Lua coroutine
|
||||
//! suspends while timers and sibling coroutines keep running. A process-wide
|
||||
//! mutex serializes prompts so two coroutines under `task.join` can't fight
|
||||
//! over the terminal.
|
||||
//!
|
||||
//! Each Lua binding validates its arguments into a `Send` config struct
|
||||
//! *before* spawning, so malformed calls error deterministically even without
|
||||
//! a terminal; the `run_*` workers then build and drive the inquire prompt on
|
||||
//! the blocking pool.
|
||||
|
||||
use std::fmt; |
||||
use std::sync::Mutex; |
||||
|
||||
use chrono::NaiveDate; |
||||
use inquire::autocompletion::{Autocomplete, Replacement}; |
||||
use inquire::error::CustomUserError; |
||||
use inquire::list_option::ListOption; |
||||
use inquire::validator::Validation; |
||||
use inquire::{ |
||||
Confirm, CustomType, DateSelect, Editor, InquireError, MultiSelect, Password, |
||||
PasswordDisplayMode, Select, Text, |
||||
}; |
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
use super::ext_err; |
||||
use super::opts::{ |
||||
bounds_message, check_bounds, default_string, int_of, lua_value_brief, parse_date, text_arg, |
||||
Opts, DATE_FMT, |
||||
}; |
||||
|
||||
/// Serializes terminal ownership across concurrent Lua coroutines. Locked
|
||||
/// inside the blocking closure, so a waiting prompt parks a blocking-pool
|
||||
/// thread, never the tokio event loop. Shared with `term`'s cursor-position
|
||||
/// query and the fs module's permission prompt, which also read from the
|
||||
/// terminal (re-exported as `stdlib::tui::PROMPT_LOCK`).
|
||||
pub(crate) static PROMPT_LOCK: Mutex<()> = Mutex::new(()); |
||||
|
||||
/// `Send`-safe error carried out of the blocking prompt worker; the Lua-side
|
||||
/// wrapper prepends the `tui.<fn>:` prefix.
|
||||
enum PromptError { |
||||
Interrupted, |
||||
NotTty, |
||||
Other(String), |
||||
} |
||||
|
||||
impl fmt::Display for PromptError { |
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
||||
match self { |
||||
PromptError::Interrupted => f.write_str("interrupted"), |
||||
PromptError::NotTty => f.write_str("not a terminal"), |
||||
PromptError::Other(msg) => f.write_str(msg), |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Fold an inquire outcome into the module's cancellation model:
|
||||
/// Esc → `Ok(None)` (surfaces as `nil`), Ctrl-C / no-tty / everything else →
|
||||
/// error.
|
||||
fn map_inquire_result<T>(r: Result<T, InquireError>) -> Result<Option<T>, PromptError> { |
||||
match r { |
||||
Ok(v) => Ok(Some(v)), |
||||
Err(InquireError::OperationCanceled) => Ok(None), |
||||
Err(InquireError::OperationInterrupted) => Err(PromptError::Interrupted), |
||||
Err(InquireError::NotTTY) => Err(PromptError::NotTty), |
||||
Err(e) => Err(PromptError::Other(e.to_string())), |
||||
} |
||||
} |
||||
|
||||
/// Run a blocking prompt worker on the blocking pool, holding the prompt
|
||||
/// lock, and translate its error with the `tui.<fn>:` prefix.
|
||||
async fn run_prompt<T, F>(fname: &'static str, worker: F) -> LuaResult<Option<T>> |
||||
where |
||||
T: Send + 'static, |
||||
F: FnOnce() -> Result<Option<T>, PromptError> + Send + 'static, |
||||
{ |
||||
tokio::task::spawn_blocking(move || { |
||||
let _guard = PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); |
||||
worker() |
||||
}) |
||||
.await |
||||
.unwrap_or_else(|e| Err(PromptError::Other(format!("background task failed: {e}")))) |
||||
.map_err(|e| ext_err!("{fname}: {e}")) |
||||
} |
||||
|
||||
fn find_option_index(fname: &str, options: &[String], value: &str) -> LuaResult<usize> { |
||||
options |
||||
.iter() |
||||
.position(|o| o == value) |
||||
.ok_or_else(|| ext_err!("{fname}: default '{value}' is not one of the options")) |
||||
} |
||||
|
||||
/// Ensure an editor file extension has its leading dot (`md` → `.md`).
|
||||
fn normalize_extension(ext: &str) -> String { |
||||
if ext.starts_with('.') { |
||||
ext.to_string() |
||||
} else { |
||||
format!(".{ext}") |
||||
} |
||||
} |
||||
|
||||
/// Static-list autocompleter for `promptText`'s `suggestions` option:
|
||||
/// case-insensitive substring filter, Tab completes the highlighted entry.
|
||||
#[derive(Clone)] |
||||
struct StaticSuggester(Vec<String>); |
||||
|
||||
impl Autocomplete for StaticSuggester { |
||||
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> { |
||||
let needle = input.to_lowercase(); |
||||
Ok(self |
||||
.0 |
||||
.iter() |
||||
.filter(|s| s.to_lowercase().contains(&needle)) |
||||
.cloned() |
||||
.collect()) |
||||
} |
||||
|
||||
fn get_completion( |
||||
&mut self, |
||||
_input: &str, |
||||
highlighted_suggestion: Option<String>, |
||||
) -> Result<Replacement, CustomUserError> { |
||||
Ok(highlighted_suggestion) |
||||
} |
||||
} |
||||
|
||||
struct ConfirmCfg { |
||||
text: String, |
||||
default: Option<bool>, |
||||
help: Option<String>, |
||||
placeholder: Option<String>, |
||||
} |
||||
|
||||
fn run_confirm(cfg: &ConfirmCfg) -> Result<Option<bool>, PromptError> { |
||||
let mut p = Confirm::new(&cfg.text); |
||||
if let Some(d) = cfg.default { |
||||
p = p.with_default(d); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(ph) = &cfg.placeholder { |
||||
p = p.with_placeholder(ph); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct TextCfg { |
||||
text: String, |
||||
initial: Option<String>, |
||||
default: Option<String>, |
||||
help: Option<String>, |
||||
placeholder: Option<String>, |
||||
min_len: Option<usize>, |
||||
max_len: Option<usize>, |
||||
suggestions: Option<Vec<String>>, |
||||
page_size: Option<usize>, |
||||
} |
||||
|
||||
fn run_text(cfg: &TextCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Text::new(&cfg.text); |
||||
if let Some(v) = &cfg.initial { |
||||
p = p.with_initial_value(v); |
||||
} |
||||
if let Some(v) = &cfg.default { |
||||
p = p.with_default(v); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(ph) = &cfg.placeholder { |
||||
p = p.with_placeholder(ph); |
||||
} |
||||
if cfg.min_len.is_some() || cfg.max_len.is_some() { |
||||
let (min, max) = (cfg.min_len, cfg.max_len); |
||||
let msg = bounds_message("length", &min, &max); |
||||
p = p.with_validator(move |s: &str| { |
||||
let len = s.chars().count(); |
||||
let ok = min.is_none_or(|m| len >= m) && max.is_none_or(|m| len <= m); |
||||
if ok { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
if let Some(sugg) = &cfg.suggestions { |
||||
p = p.with_autocomplete(StaticSuggester(sugg.clone())); |
||||
} |
||||
if let Some(n) = cfg.page_size { |
||||
p = p.with_page_size(n); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct LongtextCfg { |
||||
text: String, |
||||
predefined: Option<String>, |
||||
help: Option<String>, |
||||
extension: Option<String>, |
||||
editor: Option<String>, |
||||
} |
||||
|
||||
fn run_longtext(cfg: &LongtextCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Editor::new(&cfg.text); |
||||
if let Some(v) = &cfg.predefined { |
||||
p = p.with_predefined_text(v); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(e) = &cfg.extension { |
||||
p = p.with_file_extension(e); |
||||
} |
||||
if let Some(cmd) = &cfg.editor { |
||||
p = p.with_editor_command(std::ffi::OsStr::new(cmd)); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct SelectCfg { |
||||
text: String, |
||||
options: Vec<String>, |
||||
help: Option<String>, |
||||
page_size: Option<usize>, |
||||
vim_mode: Option<bool>, |
||||
filter: bool, |
||||
// single mode
|
||||
starting_cursor: Option<usize>, |
||||
// multiple mode
|
||||
default_indices: Vec<usize>, |
||||
all_selected: bool, |
||||
min_selected: Option<usize>, |
||||
max_selected: Option<usize>, |
||||
} |
||||
|
||||
fn run_select_single(cfg: &SelectCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Select::new(&cfg.text, cfg.options.clone()); |
||||
if let Some(idx) = cfg.starting_cursor { |
||||
p = p.with_starting_cursor(idx); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(n) = cfg.page_size { |
||||
p = p.with_page_size(n); |
||||
} |
||||
if let Some(v) = cfg.vim_mode { |
||||
p = p.with_vim_mode(v); |
||||
} |
||||
if !cfg.filter { |
||||
p = p.without_filtering(); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
fn run_select_multiple(cfg: &SelectCfg) -> Result<Option<Vec<String>>, PromptError> { |
||||
let mut p = MultiSelect::new(&cfg.text, cfg.options.clone()); |
||||
if !cfg.default_indices.is_empty() { |
||||
p = p.with_default(&cfg.default_indices); |
||||
} |
||||
if cfg.all_selected { |
||||
p = p.with_all_selected_by_default(); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(n) = cfg.page_size { |
||||
p = p.with_page_size(n); |
||||
} |
||||
if let Some(v) = cfg.vim_mode { |
||||
p = p.with_vim_mode(v); |
||||
} |
||||
if !cfg.filter { |
||||
p = p.without_filtering(); |
||||
} |
||||
if cfg.min_selected.is_some() || cfg.max_selected.is_some() { |
||||
let (min, max) = (cfg.min_selected, cfg.max_selected); |
||||
let msg = bounds_message("number of selected options", &min, &max); |
||||
p = p.with_validator(move |sel: &[ListOption<&String>]| { |
||||
let n = sel.len(); |
||||
let ok = min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m); |
||||
if ok { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct PasswordCfg { |
||||
text: String, |
||||
help: Option<String>, |
||||
confirm: bool, |
||||
display: PasswordDisplayMode, |
||||
toggle: bool, |
||||
min_len: Option<usize>, |
||||
} |
||||
|
||||
fn run_password(cfg: &PasswordCfg) -> Result<Option<String>, PromptError> { |
||||
let mut p = Password::new(&cfg.text).with_display_mode(cfg.display); |
||||
if !cfg.confirm { |
||||
p = p.without_confirmation(); |
||||
} |
||||
if cfg.toggle { |
||||
p = p.with_display_toggle_enabled(); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(min) = cfg.min_len { |
||||
let msg = bounds_message("length", &Some(min), &None); |
||||
p = p.with_validator(move |s: &str| { |
||||
if s.chars().count() >= min { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct NumberCfg<T> { |
||||
text: String, |
||||
default: Option<T>, |
||||
help: Option<String>, |
||||
placeholder: Option<String>, |
||||
min: Option<T>, |
||||
max: Option<T>, |
||||
parse_error: &'static str, |
||||
} |
||||
|
||||
fn run_number<T>(cfg: &NumberCfg<T>) -> Result<Option<T>, PromptError> |
||||
where |
||||
T: Clone + Copy + PartialOrd + fmt::Display + std::str::FromStr + Send + Sync + 'static, |
||||
{ |
||||
let mut p = CustomType::<T>::new(&cfg.text).with_error_message(cfg.parse_error); |
||||
if let Some(d) = cfg.default { |
||||
p = p.with_default(d); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(ph) = &cfg.placeholder { |
||||
p = p.with_placeholder(ph); |
||||
} |
||||
if cfg.min.is_some() || cfg.max.is_some() { |
||||
let (min, max) = (cfg.min, cfg.max); |
||||
let msg = bounds_message("value", &min, &max); |
||||
p = p.with_validator(move |v: &T| { |
||||
let ok = min.is_none_or(|m| *v >= m) && max.is_none_or(|m| *v <= m); |
||||
if ok { |
||||
Ok(Validation::Valid) |
||||
} else { |
||||
Ok(Validation::Invalid(msg.clone().into())) |
||||
} |
||||
}); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
struct DateCfg { |
||||
text: String, |
||||
default: Option<NaiveDate>, |
||||
help: Option<String>, |
||||
min: Option<NaiveDate>, |
||||
max: Option<NaiveDate>, |
||||
week_start: Option<chrono::Weekday>, |
||||
} |
||||
|
||||
fn run_date(cfg: &DateCfg) -> Result<Option<NaiveDate>, PromptError> { |
||||
let mut p = DateSelect::new(&cfg.text) |
||||
// Show the answer as ISO too, not inquire's "Month Day, Year".
|
||||
.with_formatter(&|d| d.format(DATE_FMT).to_string()); |
||||
if let Some(d) = cfg.default { |
||||
p = p.with_default(d); |
||||
} |
||||
if let Some(h) = &cfg.help { |
||||
p = p.with_help_message(h); |
||||
} |
||||
if let Some(d) = cfg.min { |
||||
p = p.with_min_date(d); |
||||
} |
||||
if let Some(d) = cfg.max { |
||||
p = p.with_max_date(d); |
||||
} |
||||
if let Some(w) = cfg.week_start { |
||||
p = p.with_week_start(w); |
||||
} |
||||
map_inquire_result(p.prompt()) |
||||
} |
||||
|
||||
/// Register the prompt functions on the `tui` table. Each binding validates
|
||||
/// its arguments into the matching `*Cfg` struct above, then drives the
|
||||
/// blocking worker through [`run_prompt`].
|
||||
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> { |
||||
tui.raw_set( |
||||
"confirm", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.confirm"; |
||||
let opts = Opts::parse(F, opts, &["help", "placeholder"])?; |
||||
let cfg = ConfirmCfg { |
||||
text: text_arg(F, text)?, |
||||
default: match default { |
||||
LuaValue::Nil => None, |
||||
LuaValue::Boolean(b) => Some(b), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a boolean (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
}; |
||||
run_prompt(F, move || run_confirm(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptText", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptText"; |
||||
let opts = Opts::parse( |
||||
F, |
||||
opts, |
||||
&["help", "placeholder", "default", "minLen", "maxLen", "suggestions", "pageSize"], |
||||
)?; |
||||
let cfg = TextCfg { |
||||
text: text_arg(F, text)?, |
||||
initial: default_string(F, default)?, |
||||
default: opts.string("default")?, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
min_len: opts.count("minLen")?, |
||||
max_len: opts.count("maxLen")?, |
||||
suggestions: opts.string_array("suggestions")?, |
||||
page_size: opts.count("pageSize")?, |
||||
}; |
||||
check_bounds(F, &cfg.min_len, &cfg.max_len)?; |
||||
run_prompt(F, move || run_text(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptLongText", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptLongText"; |
||||
let opts = Opts::parse(F, opts, &["help", "extension", "editor"])?; |
||||
let cfg = LongtextCfg { |
||||
text: text_arg(F, text)?, |
||||
predefined: default_string(F, default)?, |
||||
help: opts.string("help")?, |
||||
extension: opts.string("extension")?.map(|e| normalize_extension(&e)), |
||||
editor: opts.string("editor")?, |
||||
}; |
||||
run_prompt(F, move || run_longtext(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptSelect", |
||||
lua.create_async_function( |
||||
|lua, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptSelect"; |
||||
if opts.is_none() { |
||||
return Err(ext_err!("{F}: opts table with 'options' is required")); |
||||
} |
||||
let opts = Opts::parse( |
||||
F, |
||||
opts, |
||||
&[ |
||||
"options", "multiple", "help", "pageSize", "vimMode", "filter", |
||||
"minSelected", "maxSelected", "allSelected", |
||||
], |
||||
)?; |
||||
let options = opts |
||||
.string_array("options")? |
||||
.ok_or_else(|| ext_err!("{F}: opts table with 'options' is required"))?; |
||||
if options.is_empty() { |
||||
return Err(ext_err!("{F}: options must not be empty")); |
||||
} |
||||
let multiple = opts.boolean("multiple")?.unwrap_or(false); |
||||
|
||||
let mut cfg = SelectCfg { |
||||
text: text_arg(F, text)?, |
||||
help: opts.string("help")?, |
||||
page_size: opts.count("pageSize")?, |
||||
vim_mode: opts.boolean("vimMode")?, |
||||
filter: opts.boolean("filter")?.unwrap_or(true), |
||||
starting_cursor: None, |
||||
default_indices: Vec::new(), |
||||
all_selected: opts.boolean("allSelected")?.unwrap_or(false), |
||||
min_selected: opts.count("minSelected")?, |
||||
max_selected: opts.count("maxSelected")?, |
||||
options, |
||||
}; |
||||
check_bounds(F, &cfg.min_selected, &cfg.max_selected)?; |
||||
if !multiple { |
||||
for key in ["minSelected", "maxSelected", "allSelected"] { |
||||
if !opts.raw(key)?.is_nil() { |
||||
return Err(ext_err!("{F}: option '{key}' requires multiple = true")); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// The default is matched against the options *by value*; a
|
||||
// stale default is a script bug, so it errors rather than
|
||||
// being silently ignored.
|
||||
match (multiple, default) { |
||||
(_, LuaValue::Nil) => {} |
||||
(false, LuaValue::String(s)) => { |
||||
let s = s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?; |
||||
cfg.starting_cursor = Some(find_option_index(F, &cfg.options, &s)?); |
||||
} |
||||
(false, other) => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a string (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
(true, LuaValue::String(s)) => { |
||||
let s = s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?; |
||||
cfg.default_indices = vec![find_option_index(F, &cfg.options, &s)?]; |
||||
} |
||||
(true, LuaValue::Table(t)) => { |
||||
for i in 1..=t.raw_len() { |
||||
match t.raw_get::<LuaValue>(i)? { |
||||
LuaValue::String(s) => { |
||||
let s = s.to_str().map_err(|_| { |
||||
ext_err!("{F}: default[{i}] must be valid UTF-8") |
||||
})?; |
||||
cfg.default_indices |
||||
.push(find_option_index(F, &cfg.options, &s)?); |
||||
} |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: default[{i}] must be a string (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
(true, other) => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a string or an array of strings (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
} |
||||
|
||||
if multiple { |
||||
let chosen = run_prompt(F, move || run_select_multiple(&cfg)).await?; |
||||
match chosen { |
||||
Some(items) => Ok(LuaValue::Table(lua.create_sequence_from(items)?)), |
||||
None => Ok(LuaValue::Nil), |
||||
} |
||||
} else { |
||||
let chosen = run_prompt(F, move || run_select_single(&cfg)).await?; |
||||
match chosen { |
||||
Some(s) => Ok(LuaValue::String(lua.create_string(&s)?)), |
||||
None => Ok(LuaValue::Nil), |
||||
} |
||||
} |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptSecret", |
||||
lua.create_async_function( |
||||
|_, (text, opts): (LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptSecret"; |
||||
let opts = Opts::parse(F, opts, &["help", "confirm", "display", "toggle", "minLen"])?; |
||||
let cfg = PasswordCfg { |
||||
text: text_arg(F, text)?, |
||||
help: opts.string("help")?, |
||||
confirm: opts.boolean("confirm")?.unwrap_or(false), |
||||
display: match opts.string("display")?.as_deref() { |
||||
None | Some("masked") => PasswordDisplayMode::Masked, |
||||
Some("hidden") => PasswordDisplayMode::Hidden, |
||||
Some("full") => PasswordDisplayMode::Full, |
||||
Some(other) => { |
||||
return Err(ext_err!( |
||||
"{F}: option 'display' must be one of 'masked', 'hidden', 'full' (got '{other}')" |
||||
)) |
||||
} |
||||
}, |
||||
toggle: opts.boolean("toggle")?.unwrap_or(false), |
||||
min_len: opts.count("minLen")?, |
||||
}; |
||||
run_prompt(F, move || run_password(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptNumber", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptNumber"; |
||||
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?; |
||||
let cfg = NumberCfg::<f64> { |
||||
text: text_arg(F, text)?, |
||||
default: match &default { |
||||
LuaValue::Nil => None, |
||||
LuaValue::Integer(i) => Some(*i as f64), |
||||
LuaValue::Number(n) => Some(*n), |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be a number (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
min: opts.number("min")?, |
||||
max: opts.number("max")?, |
||||
parse_error: "please enter a valid number", |
||||
}; |
||||
check_bounds(F, &cfg.min, &cfg.max)?; |
||||
run_prompt(F, move || run_number(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptInt", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptInt"; |
||||
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?; |
||||
let cfg = NumberCfg::<i64> { |
||||
text: text_arg(F, text)?, |
||||
default: match int_of(&default) { |
||||
_ if default.is_nil() => None, |
||||
Some(i) => Some(i), |
||||
None => { |
||||
return Err(ext_err!( |
||||
"{F}: default must be an integer (got {})", |
||||
lua_value_brief(&default) |
||||
)) |
||||
} |
||||
}, |
||||
help: opts.string("help")?, |
||||
placeholder: opts.string("placeholder")?, |
||||
min: opts.integer("min")?, |
||||
max: opts.integer("max")?, |
||||
parse_error: "please enter a whole number", |
||||
}; |
||||
check_bounds(F, &cfg.min, &cfg.max)?; |
||||
run_prompt(F, move || run_number(&cfg)).await |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
tui.raw_set( |
||||
"promptDate", |
||||
lua.create_async_function( |
||||
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move { |
||||
const F: &str = "tui.promptDate"; |
||||
let opts = Opts::parse(F, opts, &["help", "min", "max", "weekStart"])?; |
||||
let cfg = DateCfg { |
||||
text: text_arg(F, text)?, |
||||
default: match default_string(F, default)? { |
||||
None => None, |
||||
Some(s) => Some(parse_date(F, "default", &s)?), |
||||
}, |
||||
help: opts.string("help")?, |
||||
min: opts.date("min")?, |
||||
max: opts.date("max")?, |
||||
week_start: match opts.string("weekStart")? { |
||||
None => None, |
||||
Some(s) => Some(s.parse::<chrono::Weekday>().map_err(|_| { |
||||
ext_err!("{F}: option 'weekStart' must be a weekday name like 'monday' (got '{s}')") |
||||
})?), |
||||
}, |
||||
}; |
||||
check_bounds(F, &cfg.min, &cfg.max)?; |
||||
let picked = run_prompt(F, move || run_date(&cfg)).await?; |
||||
Ok(picked.map(|d| d.format(DATE_FMT).to_string())) |
||||
}, |
||||
)?, |
||||
)?; |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
#[test] |
||||
fn find_option_index_matches_by_value() { |
||||
let opts = vec!["red".to_string(), "green".to_string()]; |
||||
assert_eq!(find_option_index("tui.promptSelect", &opts, "green").unwrap(), 1); |
||||
let err = find_option_index("tui.promptSelect", &opts, "blue").unwrap_err(); |
||||
assert!( |
||||
err.to_string() |
||||
.contains("tui.promptSelect: default 'blue' is not one of the options"), |
||||
"got: {err}" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn normalize_extension_adds_dot() { |
||||
assert_eq!(normalize_extension("md"), ".md"); |
||||
assert_eq!(normalize_extension(".md"), ".md"); |
||||
} |
||||
|
||||
#[test] |
||||
fn static_suggester_filters_case_insensitively() { |
||||
let mut s = StaticSuggester(vec!["Apple".into(), "banana".into(), "Cherry".into()]); |
||||
assert_eq!(s.get_suggestions("an").unwrap(), vec!["banana".to_string()]); |
||||
assert_eq!( |
||||
s.get_suggestions("A").unwrap(), |
||||
vec!["Apple".to_string(), "banana".to_string()] |
||||
); |
||||
assert_eq!(s.get_suggestions("").unwrap().len(), 3); |
||||
assert_eq!(s.get_suggestions("xyz").unwrap(), Vec::<String>::new()); |
||||
} |
||||
} |
||||
@ -1,586 +0,0 @@ |
||||
//! Terminal control bindings: cursor movement, line/screen clearing, the
|
||||
//! alternate screen, scroll regions, window title, clipboard and soft reset —
|
||||
//! the xterm-style escape sequences behind rich TUIs, exposed as plain
|
||||
//! functions (`tui.moveTo`, `tui.clearLine`, `tui.altScreen`, …).
|
||||
//!
|
||||
//! Two rules hold for every function here:
|
||||
//!
|
||||
//! - **Arguments are validated first, unconditionally** — a typo fails loudly
|
||||
//! even when nothing would be emitted (same contract as the prompts).
|
||||
//! - **Sequences go only to a real terminal.** When stdout is not a tty the
|
||||
//! functions validate and then do nothing: piped output must never contain
|
||||
//! control bytes. This is a plain tty check, deliberately not the color
|
||||
//! heuristics — `NO_COLOR` turns off colors, not cursor control. (Markup
|
||||
//! *action tags* on the other hand ride inside `tui.styled` strings and
|
||||
//! follow its color detection; see [`super::markup`].)
|
||||
//!
|
||||
//! Stateful operations (alternate screen, hidden cursor, cursor style, scroll
|
||||
//! region, pushed title) are tracked in a process-wide [`TermState`];
|
||||
//! [`cleanup`] undoes whatever is still active and runs on every exit path —
|
||||
//! normal return, script error, and panic (hooked in `main`). A script that
|
||||
//! forgets `tui.altScreen(false)` cannot wreck the user's terminal.
|
||||
|
||||
use std::io::IsTerminal; |
||||
use std::sync::{Mutex, MutexGuard}; |
||||
|
||||
use base64::Engine; |
||||
use crossterm::{cursor, terminal}; |
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
use super::opts::{int_of, lua_value_brief, Opts}; |
||||
use super::output::write_stdout; |
||||
use super::{ext_err, prompts}; |
||||
|
||||
/// Terminal modes that outlive the call that set them. One instance per
|
||||
/// process (not per Lua state): there is one terminal, and the cleanup runs
|
||||
/// from `main` / the panic hook where no Lua state may exist anymore.
|
||||
struct TermState { |
||||
alt_screen: bool, |
||||
cursor_hidden: bool, |
||||
cursor_styled: bool, |
||||
scroll_region: bool, |
||||
title_pushed: bool, |
||||
} |
||||
|
||||
const CLEAN: TermState = TermState { |
||||
alt_screen: false, |
||||
cursor_hidden: false, |
||||
cursor_styled: false, |
||||
scroll_region: false, |
||||
title_pushed: false, |
||||
}; |
||||
|
||||
static STATE: Mutex<TermState> = Mutex::new(CLEAN); |
||||
|
||||
fn state() -> MutexGuard<'static, TermState> { |
||||
STATE.lock().unwrap_or_else(|e| e.into_inner()) |
||||
} |
||||
|
||||
/// Everything needed to undo `st`, in an order that works from any state:
|
||||
/// scroll region and cursor first (they may belong to the alternate screen),
|
||||
/// then leave the alternate screen, then pop the title.
|
||||
fn restore_sequence(st: &TermState) -> String { |
||||
let mut out = String::new(); |
||||
if st.scroll_region { |
||||
out.push_str("\x1b[r"); |
||||
} |
||||
if st.cursor_styled { |
||||
out.push_str("\x1b[0 q"); |
||||
} |
||||
if st.cursor_hidden { |
||||
out.push_str(&seq(cursor::Show)); |
||||
} |
||||
if st.alt_screen { |
||||
out.push_str(&seq(terminal::LeaveAlternateScreen)); |
||||
} |
||||
if st.title_pushed { |
||||
out.push_str("\x1b[23;2t"); |
||||
} |
||||
out |
||||
} |
||||
|
||||
/// Restore any terminal state a script left behind. Called by `main` on every
|
||||
/// exit path (normal, error, panic hook); idempotent — the tracked state is
|
||||
/// cleared, so a second call emits nothing.
|
||||
pub(crate) fn cleanup() { |
||||
let mut st = state(); |
||||
let out = restore_sequence(&st); |
||||
*st = CLEAN; |
||||
drop(st); |
||||
if !out.is_empty() { |
||||
write_stdout(out.as_bytes()); |
||||
} |
||||
} |
||||
|
||||
/// The ANSI bytes of a crossterm command (writing to a `String` cannot fail).
|
||||
fn seq(cmd: impl crossterm::Command) -> String { |
||||
let mut s = String::new(); |
||||
cmd.write_ansi(&mut s).expect("write_ansi to String"); |
||||
s |
||||
} |
||||
|
||||
/// Whether control sequences are emitted at all — see the module docs.
|
||||
fn active() -> bool { |
||||
std::io::stdout().is_terminal() |
||||
} |
||||
|
||||
/// Send a fire-and-forget sequence, or drop it when stdout is not a terminal.
|
||||
fn emit(s: &str) { |
||||
if active() { |
||||
write_stdout(s.as_bytes()); |
||||
} |
||||
} |
||||
|
||||
/// `lua_value_brief`, but integral values print their value (a rejected `0`
|
||||
/// or `2` should say so, not "integer").
|
||||
fn brief(v: &LuaValue) -> String { |
||||
match int_of(v) { |
||||
Some(n) => n.to_string(), |
||||
None => lua_value_brief(v), |
||||
} |
||||
} |
||||
|
||||
/// A 1-based screen coordinate, or nil. Values beyond u16 are clamped — the
|
||||
/// terminal clamps to the screen edge anyway.
|
||||
fn opt_coord(fname: &str, what: &str, v: &LuaValue) -> LuaResult<Option<u16>> { |
||||
if v.is_nil() { |
||||
return Ok(None); |
||||
} |
||||
match int_of(v) { |
||||
Some(n) if n >= 1 => Ok(Some(n.min(u16::MAX as i64) as u16)), |
||||
_ => Err(ext_err!("{fname}: {what} must be a positive integer (got {})", brief(v))), |
||||
} |
||||
} |
||||
|
||||
/// A relative-move distance; nil counts as 0 (no movement on that axis).
|
||||
fn opt_delta(fname: &str, what: &str, v: &LuaValue) -> LuaResult<i64> { |
||||
if v.is_nil() { |
||||
return Ok(0); |
||||
} |
||||
int_of(v) |
||||
.ok_or_else(|| ext_err!("{fname}: {what} must be an integer (got {})", lua_value_brief(v))) |
||||
} |
||||
|
||||
/// Magnitude of a delta as the u16 a CSI parameter can carry.
|
||||
fn magnitude(n: i64) -> u16 { |
||||
n.unsigned_abs().min(u16::MAX as u64) as u16 |
||||
} |
||||
|
||||
/// The shared clear-mode argument: nil/0 = whole, -1 = to start, 1 = to end.
|
||||
fn clear_mode(fname: &str, v: &LuaValue) -> LuaResult<i64> { |
||||
if v.is_nil() { |
||||
return Ok(0); |
||||
} |
||||
match int_of(v) { |
||||
Some(m @ -1..=1) => Ok(m), |
||||
_ => Err(ext_err!( |
||||
"{fname}: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got {}", |
||||
brief(v) |
||||
)), |
||||
} |
||||
} |
||||
|
||||
/// A required string argument that is valid UTF-8.
|
||||
fn string_arg(fname: &str, what: &str, v: LuaValue) -> LuaResult<String> { |
||||
match v { |
||||
LuaValue::String(s) => Ok(s |
||||
.to_str() |
||||
.map_err(|_| ext_err!("{fname}: {what} must be valid UTF-8"))? |
||||
.to_string()), |
||||
other => Err(ext_err!("{fname}: {what} must be a string (got {})", other.type_name())), |
||||
} |
||||
} |
||||
|
||||
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> { |
||||
// tui.moveTo(row, col): absolute cursor position, 1-based like the
|
||||
// terminal itself; nil keeps that axis (CUP / VPA / CHA).
|
||||
tui.raw_set( |
||||
"moveTo", |
||||
lua.create_function(|_, (row, col): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.moveTo"; |
||||
let row = opt_coord(F, "row", &row)?; |
||||
let col = opt_coord(F, "col", &col)?; |
||||
let s = match (row, col) { |
||||
(Some(r), Some(c)) => seq(cursor::MoveTo(c - 1, r - 1)), |
||||
(Some(r), None) => seq(cursor::MoveToRow(r - 1)), |
||||
(None, Some(c)) => seq(cursor::MoveToColumn(c - 1)), |
||||
(None, None) => return Err(ext_err!("{F}: row and col cannot both be nil")), |
||||
}; |
||||
emit(&s); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.move(dx, dy): relative move — positive dx right, positive dy down
|
||||
// (screen coordinates); nil is 0. The terminal clamps at the edges.
|
||||
tui.raw_set( |
||||
"move", |
||||
lua.create_function(|_, (dx, dy): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.move"; |
||||
let dx = opt_delta(F, "dx", &dx)?; |
||||
let dy = opt_delta(F, "dy", &dy)?; |
||||
let mut s = String::new(); |
||||
match dx { |
||||
1.. => s.push_str(&seq(cursor::MoveRight(magnitude(dx)))), |
||||
..0 => s.push_str(&seq(cursor::MoveLeft(magnitude(dx)))), |
||||
0 => {} |
||||
} |
||||
match dy { |
||||
1.. => s.push_str(&seq(cursor::MoveDown(magnitude(dy)))), |
||||
..0 => s.push_str(&seq(cursor::MoveUp(magnitude(dy)))), |
||||
0 => {} |
||||
} |
||||
emit(&s); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.saveCursor() / tui.restoreCursor(): DECSC/DECRC — position, but
|
||||
// also the SGR state, one slot deep.
|
||||
tui.raw_set( |
||||
"saveCursor", |
||||
lua.create_function(|_, ()| { |
||||
emit(&seq(cursor::SavePosition)); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
tui.raw_set( |
||||
"restoreCursor", |
||||
lua.create_function(|_, ()| { |
||||
emit(&seq(cursor::RestorePosition)); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.cursorPos() -> row, col (1-based), or nil off-tty. The DSR-CPR
|
||||
// round trip reads the terminal's reply from its input, so it runs under
|
||||
// the prompt lock: a concurrent coroutine's prompt must not eat the reply.
|
||||
tui.raw_set( |
||||
"cursorPos", |
||||
lua.create_async_function(|_, ()| async { |
||||
if !active() { |
||||
return Ok((None, None)); |
||||
} |
||||
let pos = tokio::task::spawn_blocking(|| { |
||||
let _guard = prompts::PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); |
||||
cursor::position().ok() |
||||
}) |
||||
.await |
||||
.ok() |
||||
.flatten(); |
||||
Ok(match pos { |
||||
Some((col, row)) => (Some(row as u32 + 1), Some(col as u32 + 1)), |
||||
None => (None, None), |
||||
}) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.showCursor(visible): DECTCEM; no argument means show. A hidden
|
||||
// cursor is tracked and re-shown on exit.
|
||||
tui.raw_set( |
||||
"showCursor", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.showCursor"; |
||||
let show = match v { |
||||
LuaValue::Nil => true, |
||||
LuaValue::Boolean(b) => b, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"{F}: expected a boolean or nil (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
if active() { |
||||
write_stdout(seq_of_visibility(show).as_bytes()); |
||||
state().cursor_hidden = !show; |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.cursorStyle("block"|"underline"|"bar", {blink=}): DECSCUSR; no
|
||||
// style resets to the terminal default. A changed style is restored on
|
||||
// exit.
|
||||
tui.raw_set( |
||||
"cursorStyle", |
||||
lua.create_function(|_, (style, opts): (LuaValue, Option<LuaTable>)| { |
||||
const F: &str = "tui.cursorStyle"; |
||||
let opts = Opts::parse(F, opts, &["blink"])?; |
||||
let blink = opts.boolean("blink")?.unwrap_or(false); |
||||
let style = match style { |
||||
LuaValue::Nil => None, |
||||
LuaValue::String(s) => Some( |
||||
s.to_str() |
||||
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))? |
||||
.to_string(), |
||||
), |
||||
other => { |
||||
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name())) |
||||
} |
||||
}; |
||||
use cursor::SetCursorStyle::*; |
||||
let cmd = match (style.as_deref(), blink) { |
||||
(None, false) => DefaultUserShape, |
||||
(None, true) => { |
||||
return Err(ext_err!("{F}: a style is required when 'blink' is set")) |
||||
} |
||||
(Some("block"), false) => SteadyBlock, |
||||
(Some("block"), true) => BlinkingBlock, |
||||
(Some("underline"), false) => SteadyUnderScore, |
||||
(Some("underline"), true) => BlinkingUnderScore, |
||||
(Some("bar"), false) => SteadyBar, |
||||
(Some("bar"), true) => BlinkingBar, |
||||
(Some(other), _) => { |
||||
return Err(ext_err!( |
||||
"{F}: style must be 'block', 'underline' or 'bar' (got '{other}')" |
||||
)) |
||||
} |
||||
}; |
||||
if active() { |
||||
write_stdout(seq(cmd).as_bytes()); |
||||
state().cursor_styled = style.is_some(); |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.clearLine(mode): EL. Whole-line and to-start clears also return the
|
||||
// cursor to column 1 (the natural spot to redraw from); to-end does not
|
||||
// move it.
|
||||
tui.raw_set( |
||||
"clearLine", |
||||
lua.create_function(|_, mode: LuaValue| { |
||||
emit(match clear_mode("tui.clearLine", &mode)? { |
||||
-1 => "\x1b[1K\r", |
||||
1 => "\x1b[0K", |
||||
_ => "\x1b[2K\r", |
||||
}); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.clearScreen(mode): ED, same modes; whole-screen and to-start clears
|
||||
// home the cursor.
|
||||
tui.raw_set( |
||||
"clearScreen", |
||||
lua.create_function(|_, mode: LuaValue| { |
||||
emit(match clear_mode("tui.clearScreen", &mode)? { |
||||
-1 => "\x1b[1J\x1b[H", |
||||
1 => "\x1b[0J", |
||||
_ => "\x1b[2J\x1b[H", |
||||
}); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.altScreen(on): the 1049 alternate screen (saves/restores the cursor
|
||||
// too). Idempotent — re-entering is ignored — and tracked, so an exit or
|
||||
// error always lands back on the normal screen.
|
||||
tui.raw_set( |
||||
"altScreen", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
let on = match v { |
||||
LuaValue::Boolean(b) => b, |
||||
other => { |
||||
return Err(ext_err!( |
||||
"tui.altScreen: expected a boolean (got {})", |
||||
other.type_name() |
||||
)) |
||||
} |
||||
}; |
||||
if active() { |
||||
let mut st = state(); |
||||
if on != st.alt_screen { |
||||
let s = if on { |
||||
seq(terminal::EnterAlternateScreen) |
||||
} else { |
||||
seq(terminal::LeaveAlternateScreen) |
||||
}; |
||||
write_stdout(s.as_bytes()); |
||||
st.alt_screen = on; |
||||
} |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.scrollRegion(top, bottom): DECSTBM, 1-based inclusive; no arguments
|
||||
// reset it to the full screen. Setting a region moves the cursor home. A
|
||||
// set region is tracked and reset on exit.
|
||||
tui.raw_set( |
||||
"scrollRegion", |
||||
lua.create_function(|_, (top, bottom): (LuaValue, LuaValue)| { |
||||
const F: &str = "tui.scrollRegion"; |
||||
let top = opt_coord(F, "top", &top)?; |
||||
let bottom = opt_coord(F, "bottom", &bottom)?; |
||||
match (top, bottom) { |
||||
(None, None) => { |
||||
if active() { |
||||
write_stdout(b"\x1b[r"); |
||||
state().scroll_region = false; |
||||
} |
||||
} |
||||
(Some(t), Some(b)) => { |
||||
if t >= b { |
||||
return Err(ext_err!("{F}: top ({t}) must be less than bottom ({b})")); |
||||
} |
||||
if active() { |
||||
write_stdout(format!("\x1b[{t};{b}r").as_bytes()); |
||||
state().scroll_region = true; |
||||
} |
||||
} |
||||
_ => { |
||||
return Err(ext_err!( |
||||
"{F}: top and bottom must both be given (or both nil to reset)" |
||||
)) |
||||
} |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.scroll(n): scroll the scroll region up by n lines (content moves
|
||||
// up); negative scrolls down, 0 does nothing.
|
||||
tui.raw_set( |
||||
"scroll", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.scroll"; |
||||
let n = int_of(&v) |
||||
.ok_or_else(|| ext_err!("{F}: expected an integer (got {})", lua_value_brief(&v)))?; |
||||
match n { |
||||
1.. => emit(&seq(terminal::ScrollUp(magnitude(n)))), |
||||
..0 => emit(&seq(terminal::ScrollDown(magnitude(n)))), |
||||
0 => {} |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.setTitle(text): OSC 2. The first call pushes the current title on
|
||||
// the terminal's title stack (XTWINOPS 22) so exit can pop it back.
|
||||
tui.raw_set( |
||||
"setTitle", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.setTitle"; |
||||
let title = string_arg(F, "title", v)?; |
||||
// A control byte would terminate the OSC string early and leak
|
||||
// the rest as terminal input — reject rather than sanitize.
|
||||
if title.chars().any(char::is_control) { |
||||
return Err(ext_err!("{F}: title must not contain control characters")); |
||||
} |
||||
if active() { |
||||
let mut st = state(); |
||||
let mut out = String::new(); |
||||
if !st.title_pushed { |
||||
out.push_str("\x1b[22;2t"); |
||||
st.title_pushed = true; |
||||
} |
||||
out.push_str(&format!("\x1b]2;{title}\x07")); |
||||
write_stdout(out.as_bytes()); |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.setClipboard(text): OSC 52 write (any bytes; base64 on the wire).
|
||||
// Write-only — querying the clipboard is disabled by most terminals.
|
||||
tui.raw_set( |
||||
"setClipboard", |
||||
lua.create_function(|_, v: LuaValue| { |
||||
const F: &str = "tui.setClipboard"; |
||||
let bytes = match v { |
||||
LuaValue::String(s) => s.as_bytes().to_vec(), |
||||
other => { |
||||
return Err(ext_err!("{F}: text must be a string (got {})", other.type_name())) |
||||
} |
||||
}; |
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); |
||||
emit(&format!("\x1b]52;c;{b64}\x07")); |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
// tui.reset(): DECSTR soft reset — un-sticks a garbled terminal (wrong
|
||||
// charset, stuck attributes) without clearing the screen.
|
||||
tui.raw_set( |
||||
"reset", |
||||
lua.create_function(|_, ()| { |
||||
if active() { |
||||
write_stdout(b"\x1b[!p"); |
||||
// DECSTR itself makes the cursor visible and resets the
|
||||
// scroll region; keep the tracking in sync. It does not leave
|
||||
// the alternate screen or touch the title stack.
|
||||
let mut st = state(); |
||||
st.cursor_hidden = false; |
||||
st.scroll_region = false; |
||||
} |
||||
Ok(()) |
||||
})?, |
||||
)?; |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
fn seq_of_visibility(show: bool) -> String { |
||||
if show { |
||||
seq(cursor::Show) |
||||
} else { |
||||
seq(cursor::Hide) |
||||
} |
||||
} |
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
|
||||
#[test] |
||||
fn restore_sequence_bytes() { |
||||
let all = TermState { |
||||
alt_screen: true, |
||||
cursor_hidden: true, |
||||
cursor_styled: true, |
||||
scroll_region: true, |
||||
title_pushed: true, |
||||
}; |
||||
assert_eq!(restore_sequence(&all), "\x1b[r\x1b[0 q\x1b[?25h\x1b[?1049l\x1b[23;2t"); |
||||
assert_eq!(restore_sequence(&CLEAN), ""); |
||||
assert_eq!( |
||||
restore_sequence(&TermState { cursor_hidden: true, ..CLEAN }), |
||||
"\x1b[?25h" |
||||
); |
||||
} |
||||
|
||||
#[test] |
||||
fn cleanup_clears_tracked_state_and_is_idempotent() { |
||||
*state() = TermState { |
||||
alt_screen: true, |
||||
cursor_hidden: true, |
||||
cursor_styled: true, |
||||
scroll_region: true, |
||||
title_pushed: true, |
||||
}; |
||||
cleanup(); |
||||
{ |
||||
let st = state(); |
||||
assert!( |
||||
!st.alt_screen |
||||
&& !st.cursor_hidden |
||||
&& !st.cursor_styled |
||||
&& !st.scroll_region |
||||
&& !st.title_pushed |
||||
); |
||||
} |
||||
cleanup(); // second call must be a no-op (nothing tracked)
|
||||
} |
||||
|
||||
#[test] |
||||
fn coord_and_mode_parsing() { |
||||
use LuaValue::{Boolean, Integer, Nil, Number}; |
||||
|
||||
assert_eq!(opt_coord("f", "row", &Nil).unwrap(), None); |
||||
assert_eq!(opt_coord("f", "row", &Integer(3)).unwrap(), Some(3)); |
||||
assert_eq!(opt_coord("f", "row", &Number(3.0)).unwrap(), Some(3)); |
||||
assert_eq!(opt_coord("f", "row", &Integer(1 << 20)).unwrap(), Some(u16::MAX)); |
||||
for bad in [Integer(0), Integer(-2), Number(1.5), Boolean(true)] { |
||||
assert!(opt_coord("f", "row", &bad).is_err(), "{bad:?}"); |
||||
} |
||||
let err = opt_coord("tui.moveTo", "row", &Integer(0)).unwrap_err(); |
||||
assert!(err.to_string().contains("row must be a positive integer (got 0)"), "{err}"); |
||||
|
||||
assert_eq!(clear_mode("f", &Nil).unwrap(), 0); |
||||
for m in [-1, 0, 1] { |
||||
assert_eq!(clear_mode("f", &Integer(m)).unwrap(), m); |
||||
} |
||||
for bad in [Integer(2), Integer(-2), Number(0.5)] { |
||||
assert!(clear_mode("f", &bad).is_err(), "{bad:?}"); |
||||
} |
||||
|
||||
assert_eq!(opt_delta("f", "dx", &Nil).unwrap(), 0); |
||||
assert_eq!(opt_delta("f", "dx", &Integer(-7)).unwrap(), -7); |
||||
assert!(opt_delta("f", "dx", &Boolean(false)).is_err()); |
||||
assert_eq!(magnitude(-7), 7); |
||||
assert_eq!(magnitude(i64::MIN), u16::MAX); |
||||
} |
||||
} |
||||
@ -1,2 +0,0 @@ |
||||
* |
||||
!.gitignore |
||||
Loading…
Reference in new issue