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/prompts.rs

742 lines
27 KiB

//! The interactive prompts (`tui.confirm`, `tui.prompt*`), backed by the
//! `inquire` crate.
//!
//! inquire prompts block their thread (crossterm raw mode), so each one runs
//! under `spawn_blocking` like sqlite's I/O — the calling Lua coroutine
//! suspends while timers and sibling coroutines keep running. A process-wide
//! mutex serializes prompts so two coroutines under `task.join` can't fight
//! over the terminal.
//!
//! Each Lua binding validates its arguments into a `Send` config struct
//! *before* spawning, so malformed calls error deterministically even without
//! a terminal; the `run_*` workers then build and drive the inquire prompt on
//! the blocking pool.
use std::fmt;
use std::sync::Mutex;
use chrono::NaiveDate;
use inquire::autocompletion::{Autocomplete, Replacement};
use inquire::error::CustomUserError;
use inquire::list_option::ListOption;
use inquire::validator::Validation;
use inquire::{
Confirm, CustomType, DateSelect, Editor, InquireError, MultiSelect, Password,
PasswordDisplayMode, Select, Text,
};
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use mlua::Lua;
use super::ext_err;
use super::opts::{
bounds_message, check_bounds, default_string, int_of, lua_value_brief, parse_date, text_arg,
Opts, DATE_FMT,
};
/// Serializes terminal ownership across concurrent Lua coroutines. Locked
/// inside the blocking closure, so a waiting prompt parks a blocking-pool
/// thread, never the tokio event loop. Shared with `term`'s cursor-position
/// query and the fs module's permission prompt, which also read from the
/// terminal (re-exported as `stdlib::tui::PROMPT_LOCK`).
pub(crate) static PROMPT_LOCK: Mutex<()> = Mutex::new(());
/// `Send`-safe error carried out of the blocking prompt worker; the Lua-side
/// wrapper prepends the `tui.<fn>:` prefix.
enum PromptError {
Interrupted,
NotTty,
Other(String),
}
impl fmt::Display for PromptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PromptError::Interrupted => f.write_str("interrupted"),
PromptError::NotTty => f.write_str("not a terminal"),
PromptError::Other(msg) => f.write_str(msg),
}
}
}
/// Fold an inquire outcome into the module's cancellation model:
/// Esc → `Ok(None)` (surfaces as `nil`), Ctrl-C / no-tty / everything else →
/// error.
fn map_inquire_result<T>(r: Result<T, InquireError>) -> Result<Option<T>, PromptError> {
match r {
Ok(v) => Ok(Some(v)),
Err(InquireError::OperationCanceled) => Ok(None),
Err(InquireError::OperationInterrupted) => Err(PromptError::Interrupted),
Err(InquireError::NotTTY) => Err(PromptError::NotTty),
Err(e) => Err(PromptError::Other(e.to_string())),
}
}
/// Run a blocking prompt worker on the blocking pool, holding the prompt
/// lock, and translate its error with the `tui.<fn>:` prefix.
async fn run_prompt<T, F>(fname: &'static str, worker: F) -> LuaResult<Option<T>>
where
T: Send + 'static,
F: FnOnce() -> Result<Option<T>, PromptError> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let _guard = PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
worker()
})
.await
.unwrap_or_else(|e| Err(PromptError::Other(format!("background task failed: {e}"))))
.map_err(|e| ext_err!("{fname}: {e}"))
}
fn find_option_index(fname: &str, options: &[String], value: &str) -> LuaResult<usize> {
options
.iter()
.position(|o| o == value)
.ok_or_else(|| ext_err!("{fname}: default '{value}' is not one of the options"))
}
/// Ensure an editor file extension has its leading dot (`md` → `.md`).
fn normalize_extension(ext: &str) -> String {
if ext.starts_with('.') {
ext.to_string()
} else {
format!(".{ext}")
}
}
/// Static-list autocompleter for `promptText`'s `suggestions` option:
/// case-insensitive substring filter, Tab completes the highlighted entry.
#[derive(Clone)]
struct StaticSuggester(Vec<String>);
impl Autocomplete for StaticSuggester {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> {
let needle = input.to_lowercase();
Ok(self
.0
.iter()
.filter(|s| s.to_lowercase().contains(&needle))
.cloned()
.collect())
}
fn get_completion(
&mut self,
_input: &str,
highlighted_suggestion: Option<String>,
) -> Result<Replacement, CustomUserError> {
Ok(highlighted_suggestion)
}
}
struct ConfirmCfg {
text: String,
default: Option<bool>,
help: Option<String>,
placeholder: Option<String>,
}
fn run_confirm(cfg: &ConfirmCfg) -> Result<Option<bool>, PromptError> {
let mut p = Confirm::new(&cfg.text);
if let Some(d) = cfg.default {
p = p.with_default(d);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(ph) = &cfg.placeholder {
p = p.with_placeholder(ph);
}
map_inquire_result(p.prompt())
}
struct TextCfg {
text: String,
initial: Option<String>,
default: Option<String>,
help: Option<String>,
placeholder: Option<String>,
min_len: Option<usize>,
max_len: Option<usize>,
suggestions: Option<Vec<String>>,
page_size: Option<usize>,
}
fn run_text(cfg: &TextCfg) -> Result<Option<String>, PromptError> {
let mut p = Text::new(&cfg.text);
if let Some(v) = &cfg.initial {
p = p.with_initial_value(v);
}
if let Some(v) = &cfg.default {
p = p.with_default(v);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(ph) = &cfg.placeholder {
p = p.with_placeholder(ph);
}
if cfg.min_len.is_some() || cfg.max_len.is_some() {
let (min, max) = (cfg.min_len, cfg.max_len);
let msg = bounds_message("length", &min, &max);
p = p.with_validator(move |s: &str| {
let len = s.chars().count();
let ok = min.is_none_or(|m| len >= m) && max.is_none_or(|m| len <= m);
if ok {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
if let Some(sugg) = &cfg.suggestions {
p = p.with_autocomplete(StaticSuggester(sugg.clone()));
}
if let Some(n) = cfg.page_size {
p = p.with_page_size(n);
}
map_inquire_result(p.prompt())
}
struct LongtextCfg {
text: String,
predefined: Option<String>,
help: Option<String>,
extension: Option<String>,
editor: Option<String>,
}
fn run_longtext(cfg: &LongtextCfg) -> Result<Option<String>, PromptError> {
let mut p = Editor::new(&cfg.text);
if let Some(v) = &cfg.predefined {
p = p.with_predefined_text(v);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(e) = &cfg.extension {
p = p.with_file_extension(e);
}
if let Some(cmd) = &cfg.editor {
p = p.with_editor_command(std::ffi::OsStr::new(cmd));
}
map_inquire_result(p.prompt())
}
struct SelectCfg {
text: String,
options: Vec<String>,
help: Option<String>,
page_size: Option<usize>,
vim_mode: Option<bool>,
filter: bool,
// single mode
starting_cursor: Option<usize>,
// multiple mode
default_indices: Vec<usize>,
all_selected: bool,
min_selected: Option<usize>,
max_selected: Option<usize>,
}
fn run_select_single(cfg: &SelectCfg) -> Result<Option<String>, PromptError> {
let mut p = Select::new(&cfg.text, cfg.options.clone());
if let Some(idx) = cfg.starting_cursor {
p = p.with_starting_cursor(idx);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(n) = cfg.page_size {
p = p.with_page_size(n);
}
if let Some(v) = cfg.vim_mode {
p = p.with_vim_mode(v);
}
if !cfg.filter {
p = p.without_filtering();
}
map_inquire_result(p.prompt())
}
fn run_select_multiple(cfg: &SelectCfg) -> Result<Option<Vec<String>>, PromptError> {
let mut p = MultiSelect::new(&cfg.text, cfg.options.clone());
if !cfg.default_indices.is_empty() {
p = p.with_default(&cfg.default_indices);
}
if cfg.all_selected {
p = p.with_all_selected_by_default();
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(n) = cfg.page_size {
p = p.with_page_size(n);
}
if let Some(v) = cfg.vim_mode {
p = p.with_vim_mode(v);
}
if !cfg.filter {
p = p.without_filtering();
}
if cfg.min_selected.is_some() || cfg.max_selected.is_some() {
let (min, max) = (cfg.min_selected, cfg.max_selected);
let msg = bounds_message("number of selected options", &min, &max);
p = p.with_validator(move |sel: &[ListOption<&String>]| {
let n = sel.len();
let ok = min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m);
if ok {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
map_inquire_result(p.prompt())
}
struct PasswordCfg {
text: String,
help: Option<String>,
confirm: bool,
display: PasswordDisplayMode,
toggle: bool,
min_len: Option<usize>,
}
fn run_password(cfg: &PasswordCfg) -> Result<Option<String>, PromptError> {
let mut p = Password::new(&cfg.text).with_display_mode(cfg.display);
if !cfg.confirm {
p = p.without_confirmation();
}
if cfg.toggle {
p = p.with_display_toggle_enabled();
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(min) = cfg.min_len {
let msg = bounds_message("length", &Some(min), &None);
p = p.with_validator(move |s: &str| {
if s.chars().count() >= min {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
map_inquire_result(p.prompt())
}
struct NumberCfg<T> {
text: String,
default: Option<T>,
help: Option<String>,
placeholder: Option<String>,
min: Option<T>,
max: Option<T>,
parse_error: &'static str,
}
fn run_number<T>(cfg: &NumberCfg<T>) -> Result<Option<T>, PromptError>
where
T: Clone + Copy + PartialOrd + fmt::Display + std::str::FromStr + Send + Sync + 'static,
{
let mut p = CustomType::<T>::new(&cfg.text).with_error_message(cfg.parse_error);
if let Some(d) = cfg.default {
p = p.with_default(d);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(ph) = &cfg.placeholder {
p = p.with_placeholder(ph);
}
if cfg.min.is_some() || cfg.max.is_some() {
let (min, max) = (cfg.min, cfg.max);
let msg = bounds_message("value", &min, &max);
p = p.with_validator(move |v: &T| {
let ok = min.is_none_or(|m| *v >= m) && max.is_none_or(|m| *v <= m);
if ok {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
map_inquire_result(p.prompt())
}
struct DateCfg {
text: String,
default: Option<NaiveDate>,
help: Option<String>,
min: Option<NaiveDate>,
max: Option<NaiveDate>,
week_start: Option<chrono::Weekday>,
}
fn run_date(cfg: &DateCfg) -> Result<Option<NaiveDate>, PromptError> {
let mut p = DateSelect::new(&cfg.text)
// Show the answer as ISO too, not inquire's "Month Day, Year".
.with_formatter(&|d| d.format(DATE_FMT).to_string());
if let Some(d) = cfg.default {
p = p.with_default(d);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(d) = cfg.min {
p = p.with_min_date(d);
}
if let Some(d) = cfg.max {
p = p.with_max_date(d);
}
if let Some(w) = cfg.week_start {
p = p.with_week_start(w);
}
map_inquire_result(p.prompt())
}
/// Register the prompt functions on the `tui` table. Each binding validates
/// its arguments into the matching `*Cfg` struct above, then drives the
/// blocking worker through [`run_prompt`].
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
tui.raw_set(
"confirm",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.confirm";
let opts = Opts::parse(F, opts, &["help", "placeholder"])?;
let cfg = ConfirmCfg {
text: text_arg(F, text)?,
default: match default {
LuaValue::Nil => None,
LuaValue::Boolean(b) => Some(b),
other => {
return Err(ext_err!(
"{F}: default must be a boolean (got {})",
other.type_name()
))
}
},
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
};
run_prompt(F, move || run_confirm(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptText",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptText";
let opts = Opts::parse(
F,
opts,
&["help", "placeholder", "default", "minLen", "maxLen", "suggestions", "pageSize"],
)?;
let cfg = TextCfg {
text: text_arg(F, text)?,
initial: default_string(F, default)?,
default: opts.string("default")?,
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
min_len: opts.count("minLen")?,
max_len: opts.count("maxLen")?,
suggestions: opts.string_array("suggestions")?,
page_size: opts.count("pageSize")?,
};
check_bounds(F, &cfg.min_len, &cfg.max_len)?;
run_prompt(F, move || run_text(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptLongText",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptLongText";
let opts = Opts::parse(F, opts, &["help", "extension", "editor"])?;
let cfg = LongtextCfg {
text: text_arg(F, text)?,
predefined: default_string(F, default)?,
help: opts.string("help")?,
extension: opts.string("extension")?.map(|e| normalize_extension(&e)),
editor: opts.string("editor")?,
};
run_prompt(F, move || run_longtext(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptSelect",
lua.create_async_function(
|lua, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptSelect";
if opts.is_none() {
return Err(ext_err!("{F}: opts table with 'options' is required"));
}
let opts = Opts::parse(
F,
opts,
&[
"options", "multiple", "help", "pageSize", "vimMode", "filter",
"minSelected", "maxSelected", "allSelected",
],
)?;
let options = opts
.string_array("options")?
.ok_or_else(|| ext_err!("{F}: opts table with 'options' is required"))?;
if options.is_empty() {
return Err(ext_err!("{F}: options must not be empty"));
}
let multiple = opts.boolean("multiple")?.unwrap_or(false);
let mut cfg = SelectCfg {
text: text_arg(F, text)?,
help: opts.string("help")?,
page_size: opts.count("pageSize")?,
vim_mode: opts.boolean("vimMode")?,
filter: opts.boolean("filter")?.unwrap_or(true),
starting_cursor: None,
default_indices: Vec::new(),
all_selected: opts.boolean("allSelected")?.unwrap_or(false),
min_selected: opts.count("minSelected")?,
max_selected: opts.count("maxSelected")?,
options,
};
check_bounds(F, &cfg.min_selected, &cfg.max_selected)?;
if !multiple {
for key in ["minSelected", "maxSelected", "allSelected"] {
if !opts.raw(key)?.is_nil() {
return Err(ext_err!("{F}: option '{key}' requires multiple = true"));
}
}
}
// The default is matched against the options *by value*; a
// stale default is a script bug, so it errors rather than
// being silently ignored.
match (multiple, default) {
(_, LuaValue::Nil) => {}
(false, LuaValue::String(s)) => {
let s = s
.to_str()
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?;
cfg.starting_cursor = Some(find_option_index(F, &cfg.options, &s)?);
}
(false, other) => {
return Err(ext_err!(
"{F}: default must be a string (got {})",
other.type_name()
))
}
(true, LuaValue::String(s)) => {
let s = s
.to_str()
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?;
cfg.default_indices = vec![find_option_index(F, &cfg.options, &s)?];
}
(true, LuaValue::Table(t)) => {
for i in 1..=t.raw_len() {
match t.raw_get::<LuaValue>(i)? {
LuaValue::String(s) => {
let s = s.to_str().map_err(|_| {
ext_err!("{F}: default[{i}] must be valid UTF-8")
})?;
cfg.default_indices
.push(find_option_index(F, &cfg.options, &s)?);
}
other => {
return Err(ext_err!(
"{F}: default[{i}] must be a string (got {})",
other.type_name()
))
}
}
}
}
(true, other) => {
return Err(ext_err!(
"{F}: default must be a string or an array of strings (got {})",
other.type_name()
))
}
}
if multiple {
let chosen = run_prompt(F, move || run_select_multiple(&cfg)).await?;
match chosen {
Some(items) => Ok(LuaValue::Table(lua.create_sequence_from(items)?)),
None => Ok(LuaValue::Nil),
}
} else {
let chosen = run_prompt(F, move || run_select_single(&cfg)).await?;
match chosen {
Some(s) => Ok(LuaValue::String(lua.create_string(&s)?)),
None => Ok(LuaValue::Nil),
}
}
},
)?,
)?;
tui.raw_set(
"promptSecret",
lua.create_async_function(
|_, (text, opts): (LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptSecret";
let opts = Opts::parse(F, opts, &["help", "confirm", "display", "toggle", "minLen"])?;
let cfg = PasswordCfg {
text: text_arg(F, text)?,
help: opts.string("help")?,
confirm: opts.boolean("confirm")?.unwrap_or(false),
display: match opts.string("display")?.as_deref() {
None | Some("masked") => PasswordDisplayMode::Masked,
Some("hidden") => PasswordDisplayMode::Hidden,
Some("full") => PasswordDisplayMode::Full,
Some(other) => {
return Err(ext_err!(
"{F}: option 'display' must be one of 'masked', 'hidden', 'full' (got '{other}')"
))
}
},
toggle: opts.boolean("toggle")?.unwrap_or(false),
min_len: opts.count("minLen")?,
};
run_prompt(F, move || run_password(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptNumber",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptNumber";
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?;
let cfg = NumberCfg::<f64> {
text: text_arg(F, text)?,
default: match &default {
LuaValue::Nil => None,
LuaValue::Integer(i) => Some(*i as f64),
LuaValue::Number(n) => Some(*n),
other => {
return Err(ext_err!(
"{F}: default must be a number (got {})",
other.type_name()
))
}
},
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
min: opts.number("min")?,
max: opts.number("max")?,
parse_error: "please enter a valid number",
};
check_bounds(F, &cfg.min, &cfg.max)?;
run_prompt(F, move || run_number(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptInt",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptInt";
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?;
let cfg = NumberCfg::<i64> {
text: text_arg(F, text)?,
default: match int_of(&default) {
_ if default.is_nil() => None,
Some(i) => Some(i),
None => {
return Err(ext_err!(
"{F}: default must be an integer (got {})",
lua_value_brief(&default)
))
}
},
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
min: opts.integer("min")?,
max: opts.integer("max")?,
parse_error: "please enter a whole number",
};
check_bounds(F, &cfg.min, &cfg.max)?;
run_prompt(F, move || run_number(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptDate",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptDate";
let opts = Opts::parse(F, opts, &["help", "min", "max", "weekStart"])?;
let cfg = DateCfg {
text: text_arg(F, text)?,
default: match default_string(F, default)? {
None => None,
Some(s) => Some(parse_date(F, "default", &s)?),
},
help: opts.string("help")?,
min: opts.date("min")?,
max: opts.date("max")?,
week_start: match opts.string("weekStart")? {
None => None,
Some(s) => Some(s.parse::<chrono::Weekday>().map_err(|_| {
ext_err!("{F}: option 'weekStart' must be a weekday name like 'monday' (got '{s}')")
})?),
},
};
check_bounds(F, &cfg.min, &cfg.max)?;
let picked = run_prompt(F, move || run_date(&cfg)).await?;
Ok(picked.map(|d| d.format(DATE_FMT).to_string()))
},
)?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_option_index_matches_by_value() {
let opts = vec!["red".to_string(), "green".to_string()];
assert_eq!(find_option_index("tui.promptSelect", &opts, "green").unwrap(), 1);
let err = find_option_index("tui.promptSelect", &opts, "blue").unwrap_err();
assert!(
err.to_string()
.contains("tui.promptSelect: default 'blue' is not one of the options"),
"got: {err}"
);
}
#[test]
fn normalize_extension_adds_dot() {
assert_eq!(normalize_extension("md"), ".md");
assert_eq!(normalize_extension(".md"), ".md");
}
#[test]
fn static_suggester_filters_case_insensitively() {
let mut s = StaticSuggester(vec!["Apple".into(), "banana".into(), "Cherry".into()]);
assert_eq!(s.get_suggestions("an").unwrap(), vec!["banana".to_string()]);
assert_eq!(
s.get_suggestions("A").unwrap(),
vec!["Apple".to_string(), "banana".to_string()]
);
assert_eq!(s.get_suggestions("").unwrap().len(), 3);
assert_eq!(s.get_suggestions("xyz").unwrap(), Vec::<String>::new());
}
}