expand tui with shorthand markup etc

master
Ondřej Hruška 2 weeks ago
parent 52632f4072
commit d559eb10ef
  1. 50
      docs/tui.md
  2. 2
      lua/tui_prompts.lua
  3. 28
      lua/tui_styles.lua
  4. 55
      src/lua_tests/tui_tests.rs
  5. 379
      src/stdlib/tui/markup.rs
  6. 1325
      src/stdlib/tui/mod.rs
  7. 328
      src/stdlib/tui/opts.rs
  8. 341
      src/stdlib/tui/output.rs
  9. 740
      src/stdlib/tui/prompts.rs
  10. 2
      tmp/.gitignore

@ -197,10 +197,15 @@ print(tui.styled("<error> FAIL </error> expected <info>42</info>, see " ..
| Tag | Rendering | | Tag | Rendering |
|-----|-----------| |-----|-----------|
| `<info>` | green text | | `<info>` | bold green text |
| `<comment>` | yellow text |
| `<error>` | white on red | | `<error>` | white on red |
| `<question>` | black on cyan | | `<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 ### Inline styles
@ -213,11 +218,39 @@ tui.styled("<fg=green>ok</> <bg=yellow;options=bold>careful</> <fg=#c0392b>brick
- Colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, - Colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`,
`white`, `gray`, their `bright-*` variants, `default` (the terminal's own `white`, `gray`, their `bright-*` variants, `default` (the terminal's own
color), and hex `#rrggbb` / `#rgb` (truecolor). color — as an *inner* tag it resets an inherited color), and hex
- Options (comma-separated): `bold`, `underscore`, `blink`, `reverse`, `#rrggbb` / `#rgb` (truecolor).
`conceal`. - Options (comma-separated): `bold`, `italic`, `underscore`, `blink`,
`reverse`, `conceal`, `strikethrough`.
- The syntax is strict: no spaces around `=` or `;`. - 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 ### Closing tags and nesting
`</>` closes the innermost open style; named forms (`</info>`, `</>` closes the innermost open style; named forms (`</info>`,
@ -283,8 +316,9 @@ tui.error("Build failed")
lines wrap by word to fit the width. lines wrap by word to fit the width.
- `opts` (each overrides the preset's default): - `opts` (each overrides the preset's default):
- `label` — the `[LABEL]` text; continuation lines align under it. - `label` — the `[LABEL]` text; continuation lines align under it.
- `style` — same syntax as a `styled` tag body: a built-in name (`"error"`) - `style` — same syntax as a `styled` tag body: a preset or built-in name
or a spec (`"fg=black;bg=cyan"`). An invalid style is an error. (`"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 `" "`). - `prefix` — string prepended to every line (default `" "`).
- `padding` — blank styled line above and below the text (only applies when - `padding` — blank styled line above and below the text (only applies when
colors are on; without a background it would just be empty lines). colors are on; without a background it would just be empty lines).

@ -86,7 +86,7 @@ banner("Summary")
---------------------------------------------------------------------- ----------------------------------------------------------------------
local function show(k, v) local function show(k, v)
if v == nil then if v == nil then
v = "<comment>(skipped)</comment>" v = "<gray;i>(skipped)</>"
elseif type(v) == "table" then elseif type(v) == "table" then
v = table.concat(v, ", ") v = table.concat(v, ", ")
end end

@ -11,19 +11,31 @@ end
---------------------------------------------------------------------- ----------------------------------------------------------------------
banner("1. tui.styled — built-in tags") banner("1. tui.styled — built-in tags")
---------------------------------------------------------------------- ----------------------------------------------------------------------
print(tui.styled("<info>info is green</info>, <comment>comment is yellow</comment>")) print(tui.styled("<info>info is bold green</info> and <error> error is white on red </error>"))
print(tui.styled("<error> error: white on red </error> <question> question: black on cyan </question>")) -- 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") 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=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("<fg=#c0392b>hex truecolor #c0392b</> and <fg=#0af>short #0af</>"))
print(tui.styled("<bg=blue>background</> <options=bold>bold</> <options=underscore>underscore</>")) 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 </>")) 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. Nesting, links, escaping") 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. -- 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>")) print(tui.styled("<error>outer <options=bold>bold inherits the red bg</> and back</error>"))
@ -35,14 +47,14 @@ print(tui.styled("read <href=https://example.com/docs>the docs</> for details"))
print(tui.styled("literal \\<info> stays, <not-a-tag> too, a < b as well")) print(tui.styled("literal \\<info> stays, <not-a-tag> too, a < b as well"))
---------------------------------------------------------------------- ----------------------------------------------------------------------
banner("4. Message blocks (Symfony style)") banner("5. Message blocks (Symfony style)")
---------------------------------------------------------------------- ----------------------------------------------------------------------
tui.success("Deployment finished without errors") tui.success("Deployment finished without errors")
tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" }) tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" })
tui.error("Build failed") tui.error("Build failed")
tui.caution("This will overwrite production data") tui.caution("This will overwrite production data")
tui.info("14 files processed") tui.info("14 files processed")
tui.note("Long messages word-wrap to the terminal width and continuation lines stay aligned under the label") 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") tui.comment("git push --force-with-lease")
-- The generic form: everything is configurable. -- The generic form: everything is configurable.
tui.block( tui.block(
@ -51,7 +63,7 @@ tui.block(
) )
---------------------------------------------------------------------- ----------------------------------------------------------------------
banner("5. Outline blocks (colored borders, plain content)") banner("6. Outline blocks (colored borders, plain content)")
---------------------------------------------------------------------- ----------------------------------------------------------------------
tui.outlineSuccess("All tests passed") tui.outlineSuccess("All tests passed")
tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset
@ -61,7 +73,7 @@ tui.outlineBlock({ "Generic box.", "Multiple messages are separated by a blank l
}) })
---------------------------------------------------------------------- ----------------------------------------------------------------------
banner("6. tui.raw + tui.screenInfo — progress bar building blocks") banner("7. tui.raw + tui.screenInfo — progress bar building blocks")
---------------------------------------------------------------------- ----------------------------------------------------------------------
local info = tui.screenInfo() local info = tui.screenInfo()
print("screen:", (info.width or "?") .. "x" .. (info.height or "?"), "tty:", info.tty, "colors:", info.colors) print("screen:", (info.width or "?") .. "x" .. (info.height or "?"), "tty:", info.tty, "colors:", info.colors)

@ -39,6 +39,7 @@ fn test_tui_global_has_all_functions() {
"outlineCaution", "outlineCaution",
"raw", "raw",
"screenInfo", "screenInfo",
"addPreset",
] { ] {
let is_fn: bool = lua let is_fn: bool = lua
.load(format!("return type(tui.{f}) == 'function'")) .load(format!("return type(tui.{f}) == 'function'"))
@ -91,6 +92,58 @@ fn test_styled_resolves_escapes_and_keeps_invalid_tags() {
assert!(out.contains("<notatag>"), "invalid tag should stay literal: {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] #[test]
fn test_styled_rejects_non_string() { fn test_styled_rejects_non_string() {
let lua = lua(); let lua = lua();
@ -277,7 +330,7 @@ fn test_block_functions_print_without_error() {
tui.success("all good") tui.success("all good")
tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true}) tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true})
tui.outlineError("boom") tui.outlineError("boom")
tui.outlineBlock("custom", {title="Box", style="comment", padding=false}) tui.outlineBlock("custom", {title="Box", style="gray;bold", padding=false})
tui.raw("progress ", 42, "\r") tui.raw("progress ", 42, "\r")
"#, "#,
) )

@ -1,30 +1,46 @@
//! Symfony-console-style markup renderer backing `tui.styled`. //! Symfony-console-style markup renderer backing `tui.styled`.
//! //!
//! Input is plain text with HTML-like tags: built-in style names //! Input is plain text with HTML-like tags:
//! (`<info>`, `<comment>`, `<error>`, `<question>`), inline attribute specs //! - built-in style names: `<info>` (bold green), `<error>` (white on red),
//! (`<fg=red;bg=blue;options=bold,underscore>`), and hyperlinks //! and the HTML-like `<b>` (bold), `<i>` (italic), `<u>` (underscore),
//! (`<href=https://…>text</>`). Styles nest: an inner tag overrides only the //! `<s>` (strikethrough);
//! attributes it sets, everything else is inherited from the enclosing style. //! - custom presets registered with `tui.addPreset` (checked first, so they
//! `</>`, `</info>` and `</fg=…>` all simply pop the innermost style. //! 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</>`.
//! //!
//! A tag that doesn't parse — unknown name, bad color, `<>`, wrong case, //! Styles nest: an inner tag overrides only the attributes it sets,
//! a stray `</>` — is emitted as literal text, never an error, matching //! 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. //! Symfony's formatter. `\<` (and `\>`) escape the brackets.
//! //!
//! Rendering is pure: `render(input, colorize)` has no I/O and consults no //! Rendering is pure: `render(input, colorize, presets)` has no I/O and
//! globals, so tests can pin exact byte output. Each text segment is emitted //! consults no globals, so tests can pin exact byte output. Each text segment
//! as a self-contained `\x1b[…m…\x1b[0m` run computed from the full effective //! is emitted as a self-contained `\x1b[…m…\x1b[0m` run computed from the
//! style, rather than Symfony's reset-and-reapply diffing — simpler, and no //! full effective style, rather than Symfony's reset-and-reapply diffing —
//! stale attributes can leak between segments. //! simpler, and no stale attributes can leak between segments.
use std::collections::HashMap;
use std::fmt::Write; use std::fmt::Write;
use colored::Color; 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 /// A color attribute in a style spec. `Inherit` (attribute not mentioned in
/// the tag) takes the enclosing style's value when nested; `Default` /// the tag) takes the enclosing style's value when nested; `Default`
/// (`fg=default`) explicitly resets to the terminal default, overriding an /// (`fg=default` / `<default>` / `<on-default>`) explicitly resets to the
/// enclosing color. /// terminal default, overriding an enclosing color.
#[derive(Clone, Copy, PartialEq, Debug, Default)] #[derive(Clone, Copy, PartialEq, Debug, Default)]
enum ColorSpec { enum ColorSpec {
#[default] #[default]
@ -47,10 +63,12 @@ pub(super) struct Style {
fg: ColorSpec, fg: ColorSpec,
bg: ColorSpec, bg: ColorSpec,
bold: bool, bold: bool,
italic: bool,
underscore: bool, underscore: bool,
blink: bool, blink: bool,
reverse: bool, reverse: bool,
conceal: bool, conceal: bool,
strikethrough: bool,
href: Option<String>, href: Option<String>,
} }
@ -70,10 +88,12 @@ impl Style {
fg: self.fg.over(base.fg), fg: self.fg.over(base.fg),
bg: self.bg.over(base.bg), bg: self.bg.over(base.bg),
bold: self.bold || base.bold, bold: self.bold || base.bold,
italic: self.italic || base.italic,
underscore: self.underscore || base.underscore, underscore: self.underscore || base.underscore,
blink: self.blink || base.blink, blink: self.blink || base.blink,
reverse: self.reverse || base.reverse, reverse: self.reverse || base.reverse,
conceal: self.conceal || base.conceal, conceal: self.conceal || base.conceal,
strikethrough: self.strikethrough || base.strikethrough,
href: self.href.clone().or_else(|| base.href.clone()), href: self.href.clone().or_else(|| base.href.clone()),
} }
} }
@ -90,14 +110,20 @@ enum Tag {
Action(String), Action(String),
} }
/// Resolve a style spec outside of tag context — a built-in name (`error`) or /// Resolve a style spec: a custom preset, a built-in name, or an
/// an attribute spec (`fg=black;bg=green`) — for callers like the block /// attribute/shorthand spec, in that order. Used for tag bodies and for
/// helpers, whose `style` option uses the same syntax as tags. /// `style`/preset arguments outside of tag context (`tui.block`,
pub(super) fn resolve_style_spec(spec: &str) -> Option<Style> { /// `tui.addPreset`).
match resolve_tag(spec) { pub(super) fn resolve_spec(spec: &str, presets: &Presets) -> Option<Style> {
Some(Tag::Style(s)) => Some(s), if let Some(style) = presets.0.get(spec) {
_ => None, 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 /// Render one string with a single style (no tag parsing). The block helpers
@ -109,57 +135,90 @@ pub(super) fn paint_str(text: &str, style: &Style, colorize: bool) -> String {
out 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 `<...>`. `None` means the whole tag (brackets /// Parse the inside of `<...>`. `None` means the whole tag (brackets
/// included) is literal text. /// included) is literal text.
fn resolve_tag(body: &str) -> Option<Tag> { fn resolve_tag(body: &str, presets: &Presets) -> Option<Tag> {
// Built-in named styles (case-sensitive, like Symfony's). resolve_spec(body, presets).map(Tag::Style)
match body {
"info" => return Some(Tag::Style(Style::fg(Color::Green))),
"comment" => return Some(Tag::Style(Style::fg(Color::Yellow))),
"error" => return Some(Tag::Style(Style::fg_bg(Color::White, Color::Red))),
"question" => return Some(Tag::Style(Style::fg_bg(Color::Black, Color::Cyan))),
_ => {}
}
parse_attr_spec(body).map(Tag::Style)
} }
/// Parse an inline attribute spec: `key=value` pairs separated by `;`, with /// Parse an inline spec: `;`-separated parts, each either `key=value`
/// keys `fg`, `bg`, `options` (comma-separated) and `href`. Strict — no /// (keys `fg`, `bg`, `options`, `href`) or a bare shorthand token (an option
/// whitespace trimming, unknown keys/values reject the whole tag. /// 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> { fn parse_attr_spec(body: &str) -> Option<Style> {
if body.is_empty() { if body.is_empty() {
return None; return None;
} }
let mut style = Style::default(); let mut style = Style::default();
for part in body.split(';') { for part in body.split(';') {
let (key, value) = part.split_once('=')?; match part.split_once('=') {
match key { Some(("fg", value)) => style.fg = parse_color(value)?,
"fg" => style.fg = parse_color(value)?, Some(("bg", value)) => style.bg = parse_color(value)?,
"bg" => style.bg = parse_color(value)?, Some(("options", value)) => {
"options" => {
for opt in value.split(',') { for opt in value.split(',') {
match opt { apply_option(&mut style, opt)?;
"bold" => style.bold = true,
"underscore" => style.underscore = true,
"blink" => style.blink = true,
"reverse" => style.reverse = true,
"conceal" => style.conceal = true,
_ => return None,
}
} }
} }
"href" => { Some(("href", value)) => {
if value.is_empty() { if value.is_empty() {
return None; return None;
} }
style.href = Some(value.to_string()); style.href = Some(value.to_string());
} }
_ => return None, Some(_) => return None,
None => apply_shorthand(&mut style, part)?,
} }
} }
Some(style) 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> { fn parse_color(s: &str) -> Option<ColorSpec> {
Some(ColorSpec::Set(match s { Some(ColorSpec::Set(match s {
"default" => return Some(ColorSpec::Default), "default" => return Some(ColorSpec::Default),
@ -204,15 +263,15 @@ fn parse_hex_color(s: &str) -> Option<ColorSpec> {
/// needs no escape codes at all (explicit `default` colors emit nothing — /// needs no escape codes at all (explicit `default` colors emit nothing —
/// every segment is already self-terminated by `\x1b[0m`). /// every segment is already self-terminated by `\x1b[0m`).
fn sgr_params(style: &Style) -> Vec<String> { fn sgr_params(style: &Style) -> Vec<String> {
fn color_params(spec: ColorSpec, base: u16, truecolor_intro: u16) -> Option<String> { fn color_params(spec: ColorSpec, base: u16, extended_intro: u16) -> Option<String> {
match spec { match spec {
ColorSpec::Inherit | ColorSpec::Default => None, ColorSpec::Inherit | ColorSpec::Default => None,
ColorSpec::Set(Color::TrueColor { r, g, b }) => { ColorSpec::Set(Color::TrueColor { r, g, b }) => {
Some(format!("{truecolor_intro};2;{r};{g};{b}")) Some(format!("{extended_intro};2;{r};{g};{b}"))
} }
// Not produced by parse_color today, but the colored enum has it // Not produced by parse_color today, but the colored enum has it
// (256-color palette); map it correctly rather than panicking. // (256-color palette); map it correctly rather than panicking.
ColorSpec::Set(Color::AnsiColor(n)) => Some(format!("{truecolor_intro};5;{n}")), ColorSpec::Set(Color::AnsiColor(n)) => Some(format!("{extended_intro};5;{n}")),
ColorSpec::Set(c) => { ColorSpec::Set(c) => {
let n = match c { let n = match c {
Color::Black => 0, Color::Black => 0,
@ -242,6 +301,9 @@ fn sgr_params(style: &Style) -> Vec<String> {
if style.bold { if style.bold {
params.push("1".to_string()); params.push("1".to_string());
} }
if style.italic {
params.push("3".to_string());
}
if style.underscore { if style.underscore {
params.push("4".to_string()); params.push("4".to_string());
} }
@ -254,6 +316,9 @@ fn sgr_params(style: &Style) -> Vec<String> {
if style.conceal { if style.conceal {
params.push("8".to_string()); 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.fg, 30, 38));
params.extend(color_params(style.bg, 40, 48)); params.extend(color_params(style.bg, 40, 48));
params params
@ -269,13 +334,10 @@ fn paint(out: &mut String, text: &str, style: Option<&Style>, colorize: bool) {
return; return;
} }
}; };
if let Some(url) = &style.href {
write!(out, "\x1b]8;;{url}\x1b\\").unwrap();
}
let params = sgr_params(style); let params = sgr_params(style);
let open_link = |out: &mut String| {
if let Some(url) = &style.href {
write!(out, "\x1b]8;;{url}\x1b\\").unwrap();
}
};
open_link(out);
if params.is_empty() { if params.is_empty() {
out.push_str(text); out.push_str(text);
} else { } else {
@ -299,7 +361,7 @@ enum Event<'a> {
/// Single pass over the input. Byte-wise scanning is safe: the only bytes /// 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 /// acted on (`\`, `<`, `>`, `\n`) are ASCII and never occur inside a UTF-8
/// multi-byte sequence, and all slice cuts land on those ASCII boundaries. /// multi-byte sequence, and all slice cuts land on those ASCII boundaries.
fn tokenize(input: &str) -> Vec<Event<'_>> { 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) { fn flush<'a>(events: &mut Vec<Event<'a>>, input: &'a str, from: usize, to: usize) {
if from < to { if from < to {
events.push(Event::Text(&input[from..to])); events.push(Event::Text(&input[from..to]));
@ -344,10 +406,10 @@ fn tokenize(input: &str) -> Vec<Event<'_>> {
let raw = &input[i..=end]; let raw = &input[i..=end];
let event = if let Some(rest) = body.strip_prefix('/') { let event = if let Some(rest) = body.strip_prefix('/') {
// `</>`, or any spec that would be a valid *style* opener. // `</>`, or any spec that would be a valid *style* opener.
(rest.is_empty() || matches!(resolve_tag(rest), Some(Tag::Style(_)))) (rest.is_empty() || matches!(resolve_tag(rest, presets), Some(Tag::Style(_))))
.then_some(Event::Close { raw }) .then_some(Event::Close { raw })
} else { } else {
resolve_tag(body).map(Event::Open) resolve_tag(body, presets).map(Event::Open)
}; };
match event { match event {
Some(ev) => { Some(ev) => {
@ -370,7 +432,7 @@ fn tokenize(input: &str) -> Vec<Event<'_>> {
/// Render markup to a string. With `colorize == false` the tags are still /// Render markup to a string. With `colorize == false` the tags are still
/// parsed and stripped and escapes resolved, but no escape sequences of any /// parsed and stripped and escapes resolved, but no escape sequences of any
/// kind are emitted — safe for pipes and NO_COLOR. /// kind are emitted — safe for pipes and NO_COLOR.
pub(super) fn render(input: &str, colorize: bool) -> String { pub(super) fn render(input: &str, colorize: bool, presets: &Presets) -> String {
let mut out = String::with_capacity(input.len()); let mut out = String::with_capacity(input.len());
// Effective (pre-merged) styles; `last()` is what the current text gets. // Effective (pre-merged) styles; `last()` is what the current text gets.
let mut stack: Vec<Style> = Vec::new(); let mut stack: Vec<Style> = Vec::new();
@ -378,7 +440,7 @@ pub(super) fn render(input: &str, colorize: bool) -> String {
// buffered so a contiguous same-styled run becomes one SGR segment. // buffered so a contiguous same-styled run becomes one SGR segment.
let mut pending = String::new(); let mut pending = String::new();
for event in tokenize(input) { for event in tokenize(input, presets) {
if !matches!(event, Event::Text(_)) && !pending.is_empty() { if !matches!(event, Event::Text(_)) && !pending.is_empty() {
paint(&mut out, &pending, stack.last(), colorize); paint(&mut out, &pending, stack.last(), colorize);
pending.clear(); pending.clear();
@ -414,24 +476,40 @@ pub(super) fn render(input: &str, colorize: bool) -> String {
mod tests { mod tests {
use super::*; use super::*;
/// render() without custom presets.
fn r(input: &str, colorize: bool) -> String {
render(input, colorize, &Presets::default())
}
#[test] #[test]
fn plain_text_passes_through() { fn plain_text_passes_through() {
assert_eq!(render("hello world", true), "hello world"); assert_eq!(r("hello world", true), "hello world");
assert_eq!(render("hello world", false), "hello world"); assert_eq!(r("hello world", false), "hello world");
} }
#[test] #[test]
fn builtin_styles() { fn builtin_styles() {
assert_eq!(render("<info>x</info>", true), "\x1b[32mx\x1b[0m"); assert_eq!(r("<info>x</info>", true), "\x1b[1;32mx\x1b[0m");
assert_eq!(render("<comment>x</>", true), "\x1b[33mx\x1b[0m"); assert_eq!(r("<error>x</>", true), "\x1b[37;41mx\x1b[0m");
assert_eq!(render("<error>x</>", true), "\x1b[37;41mx\x1b[0m"); // Symfony's <comment> and <question> are deliberately NOT built in
assert_eq!(render("<question>x</>", true), "\x1b[30;46mx\x1b[0m"); // (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] #[test]
fn inline_spec_combined() { fn inline_spec_combined() {
assert_eq!( assert_eq!(
render("<fg=red;bg=blue;options=bold,underscore>x</>", true), r("<fg=red;bg=blue;options=bold,underscore>x</>", true),
"\x1b[1;4;31;44mx\x1b[0m" "\x1b[1;4;31;44mx\x1b[0m"
); );
} }
@ -439,27 +517,84 @@ mod tests {
#[test] #[test]
fn all_options() { fn all_options() {
assert_eq!( assert_eq!(
render("<options=bold,underscore,blink,reverse,conceal>x</>", true), r("<options=bold,italic,underscore,blink,reverse,conceal,strikethrough>x</>", true),
"\x1b[1;4;5;7;8mx\x1b[0m" "\x1b[1;3;4;5;7;8;9mx\x1b[0m"
); );
} }
#[test] #[test]
fn bright_gray_and_default_colors() { fn shorthand_tokens() {
assert_eq!(render("<fg=bright-cyan>x</>", true), "\x1b[96mx\x1b[0m"); assert_eq!(r("<red>x</>", true), "\x1b[31mx\x1b[0m");
assert_eq!(render("<fg=gray>x</>", true), "\x1b[90mx\x1b[0m"); assert_eq!(r("<on-white>x</>", true), "\x1b[47mx\x1b[0m");
assert_eq!(render("<bg=bright-red>x</>", true), "\x1b[101mx\x1b[0m"); assert_eq!(r("<red;on-white;bold>x</>", true), "\x1b[1;31;47mx\x1b[0m");
// Explicit `default` fg emits nothing; the bg still applies. assert_eq!(r("<bright-cyan;strikethrough>x</>", true), "\x1b[9;96mx\x1b[0m");
assert_eq!(render("<fg=default;bg=blue>x</>", true), "\x1b[44mx\x1b[0m"); // Hex colors work as shorthand too, fg and bg.
// A style that boils down to nothing stays plain. assert_eq!(r("<#f00>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m");
assert_eq!(render("<fg=default>x</>", true), "x"); 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] #[test]
fn hex_colors() { fn hex_colors() {
assert_eq!(render("<fg=#ff0000>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m"); assert_eq!(r("<fg=#ff0000>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m");
assert_eq!(render("<fg=#f0a>x</>", true), "\x1b[38;2;255;0;170mx\x1b[0m"); assert_eq!(r("<fg=#f0a>x</>", true), "\x1b[38;2;255;0;170mx\x1b[0m");
assert_eq!(render("<bg=#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m"); assert_eq!(r("<bg=#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m");
} }
#[test] #[test]
@ -467,34 +602,23 @@ mod tests {
// Inner <info> overrides fg only; bg red is inherited. After the // Inner <info> overrides fg only; bg red is inherited. After the
// close, the outer error style is restored. // close, the outer error style is restored.
assert_eq!( assert_eq!(
render("<error>a<info>b</info>c</error>", true), r("<error>a<info>b</info>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[32;41mb\x1b[0m\x1b[37;41mc\x1b[0m" "\x1b[37;41ma\x1b[0m\x1b[1;32;41mb\x1b[0m\x1b[37;41mc\x1b[0m"
); );
assert_eq!( assert_eq!(
render("<fg=red>a<fg=blue>b</>c</>", true), r("<fg=red>a<fg=blue>b</>c</>", true),
"\x1b[31ma\x1b[0m\x1b[34mb\x1b[0m\x1b[31mc\x1b[0m" "\x1b[31ma\x1b[0m\x1b[34mb\x1b[0m\x1b[31mc\x1b[0m"
); );
// fg=default inside a colored parent resets the fg for that span.
assert_eq!(
render("<error>a<fg=default>b</>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[41mb\x1b[0m\x1b[37;41mc\x1b[0m"
);
// Options accumulate. // Options accumulate.
assert_eq!( assert_eq!(r("<options=bold><options=underscore>x</></>", true), "\x1b[1;4mx\x1b[0m");
render("<options=bold><options=underscore>x</></>", true),
"\x1b[1;4mx\x1b[0m"
);
} }
#[test] #[test]
fn closing_tag_forms_all_pop() { fn closing_tag_forms_all_pop() {
assert_eq!(render("<info>x</info>", true), render("<info>x</>", true)); assert_eq!(r("<info>x</info>", true), r("<info>x</>", true));
assert_eq!( assert_eq!(r("<fg=blue;bg=red>x</fg=blue;bg=red>", true), "\x1b[34;41mx\x1b[0m");
render("<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). // Any style-parseable close pops (it is not matched against the opener).
assert_eq!(render("<info>x</comment>", true), "\x1b[32mx\x1b[0m"); assert_eq!(r("<info>x</error>", true), "\x1b[1;32mx\x1b[0m");
} }
#[test] #[test]
@ -505,75 +629,72 @@ mod tests {
"<>", "<>",
"<fg=notacolor>x", "<fg=notacolor>x",
"<fg=red;wat=1>x", "<fg=red;wat=1>x",
"<red;wat>x",
"<on-nope>x",
"<options=sparkle>x", "<options=sparkle>x",
"<fg = red>x", // strict: no whitespace around `=` "<fg = red>x", // strict: no whitespace around `=`
"<fg=>x", "<fg=>x",
"</garbage>", "</garbage>",
"a < b and c > d", "a < b and c > d",
"<info", // unclosed at EOF "<info", // unclosed at EOF
"<#ff0000>x",
] { ] {
assert_eq!(render(s, true), s, "should pass through literally: {s:?}"); assert_eq!(r(s, true), s, "should pass through literally: {s:?}");
} }
// `<` never matches across another `<` or a newline. // `<` never matches across another `<` or a newline.
assert_eq!(render("a <<info>b</>", true), "a <\x1b[32mb\x1b[0m"); assert_eq!(r("a <<info>b</>", true), "a <\x1b[1;32mb\x1b[0m");
assert_eq!(render("<foo\n>x", true), "<foo\n>x"); assert_eq!(r("<foo\n>x", true), "<foo\n>x");
} }
#[test] #[test]
fn stray_close_is_literal() { fn stray_close_is_literal() {
assert_eq!(render("x</>", true), "x</>"); assert_eq!(r("x</>", true), "x</>");
assert_eq!(render("<info>a</></>", true), "\x1b[32ma\x1b[0m</>"); assert_eq!(r("<info>a</></>", true), "\x1b[1;32ma\x1b[0m</>");
} }
#[test] #[test]
fn escaping() { fn escaping() {
assert_eq!(render("\\<info>x", true), "<info>x"); assert_eq!(r("\\<info>x", true), "<info>x");
assert_eq!(render("\\<info>x", false), "<info>x"); assert_eq!(r("\\<info>x", false), "<info>x");
assert_eq!(render("foo\\<bar", true), "foo<bar"); assert_eq!(r("foo\\<bar", true), "foo<bar");
assert_eq!(render("\\>", true), ">"); assert_eq!(r("\\>", true), ">");
// A backslash before anything else is ordinary text. // A backslash before anything else is ordinary text.
assert_eq!(render("a\\b\\", true), "a\\b\\"); assert_eq!(r("a\\b\\", true), "a\\b\\");
// Escaped bracket inside a styled span stays styled. // Escaped bracket inside a styled span stays styled.
assert_eq!(render("<info>\\<x></info>", true), "\x1b[32m<x>\x1b[0m"); assert_eq!(r("<info>\\<x></info>", true), "\x1b[1;32m<x>\x1b[0m");
} }
#[test] #[test]
fn href() { fn href() {
assert_eq!( assert_eq!(
render("<href=https://example.com/>link</>", true), r("<href=https://example.com/>link</>", true),
"\x1b]8;;https://example.com/\x1b\\link\x1b]8;;\x1b\\" "\x1b]8;;https://example.com/\x1b\\link\x1b]8;;\x1b\\"
); );
// Styled text inside the hyperlink span. // Styled text inside the hyperlink span.
assert_eq!( assert_eq!(
render("<href=https://e.com><info>x</info></>", true), r("<href=https://e.com><info>x</info></>", true),
"\x1b]8;;https://e.com\x1b\\\x1b[32mx\x1b[0m\x1b]8;;\x1b\\" "\x1b]8;;https://e.com\x1b\\\x1b[1;32mx\x1b[0m\x1b]8;;\x1b\\"
);
// Combined href + color in one tag.
assert_eq!(
render("<href=https://e.com;fg=green>x</>", true),
"\x1b]8;;https://e.com\x1b\\\x1b[32mx\x1b[0m\x1b]8;;\x1b\\"
); );
assert_eq!(render("<href=>x", true), "<href=>x"); assert_eq!(r("<href=>x", true), "<href=>x");
} }
#[test] #[test]
fn colorize_false_strips_everything() { fn colorize_false_strips_everything() {
let input = "<error>a<info>b</info></error> <fg=#f00>c</> <href=https://e.com>d</> \\<e>"; let input =
let out = render(input, false); "<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_eq!(out, "ab c d <e>");
assert!(!out.contains('\x1b')); assert!(!out.contains('\x1b'));
} }
#[test] #[test]
fn empty_and_tag_only_inputs() { fn empty_and_tag_only_inputs() {
assert_eq!(render("", true), ""); assert_eq!(r("", true), "");
assert_eq!(render("<info></>", true), ""); assert_eq!(r("<info></>", true), "");
assert_eq!(render("<info><comment></></>", true), ""); assert_eq!(r("<info><error></></>", true), "");
} }
#[test] #[test]
fn unclosed_styles_at_eof_are_fine() { fn unclosed_styles_at_eof_are_fine() {
assert_eq!(render("<info>x", true), "\x1b[32mx\x1b[0m"); assert_eq!(r("<info>x", true), "\x1b[1;32mx\x1b[0m");
} }
} }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,328 @@
//! 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"));
}
}

@ -0,0 +1,341 @@
//! 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).
fn write_stdout(bytes: &[u8]) {
#[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: None, 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.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");
}
}
}

@ -0,0 +1,740 @@
//! 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.
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());
}
}

2
tmp/.gitignore vendored

@ -0,0 +1,2 @@
*
!.gitignore
Loading…
Cancel
Save