Lua runner with rich builtin stdlib
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
a/src/stdlib/tui/output.rs

363 lines
15 KiB

//! 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).
pub(super) 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: 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");
}
}
}