//! 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.:` 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(r: Result) -> Result, 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.:` prefix. async fn run_prompt(fname: &'static str, worker: F) -> LuaResult> where T: Send + 'static, F: FnOnce() -> Result, 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 { 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); impl Autocomplete for StaticSuggester { fn get_suggestions(&mut self, input: &str) -> Result, 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, ) -> Result { Ok(highlighted_suggestion) } } struct ConfirmCfg { text: String, default: Option, help: Option, placeholder: Option, } fn run_confirm(cfg: &ConfirmCfg) -> Result, 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, default: Option, help: Option, placeholder: Option, min_len: Option, max_len: Option, suggestions: Option>, page_size: Option, } fn run_text(cfg: &TextCfg) -> Result, 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, help: Option, extension: Option, editor: Option, } fn run_longtext(cfg: &LongtextCfg) -> Result, 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, help: Option, page_size: Option, vim_mode: Option, filter: bool, // single mode starting_cursor: Option, // multiple mode default_indices: Vec, all_selected: bool, min_selected: Option, max_selected: Option, } fn run_select_single(cfg: &SelectCfg) -> Result, 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>, 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, confirm: bool, display: PasswordDisplayMode, toggle: bool, min_len: Option, } fn run_password(cfg: &PasswordCfg) -> Result, 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 { text: String, default: Option, help: Option, placeholder: Option, min: Option, max: Option, parse_error: &'static str, } fn run_number(cfg: &NumberCfg) -> Result, PromptError> where T: Clone + Copy + PartialOrd + fmt::Display + std::str::FromStr + Send + Sync + 'static, { let mut p = CustomType::::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, help: Option, min: Option, max: Option, week_start: Option, } fn run_date(cfg: &DateCfg) -> Result, 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)| 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)| 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)| 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)| 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::(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)| 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)| async move { const F: &str = "tui.promptNumber"; let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?; let cfg = NumberCfg:: { 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)| async move { const F: &str = "tui.promptInt"; let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?; let cfg = NumberCfg:: { 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)| 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::().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::::new()); } }