From ee64d2ef9cfdd4bb300b2b57e3e85c76cc496bcb Mon Sep 17 00:00:00 2001 From: Filip Tibell Date: Mon, 6 Feb 2023 12:59:48 -0500 Subject: [PATCH] Implement stdio prompt function --- CHANGELOG.md | 25 ++++++- Cargo.lock | 66 ++++++++++++++++ lune.yml | 9 +++ luneDocs.json | 29 ++++++++ luneTypes.d.luau | 22 ++++++ packages/lib/Cargo.toml | 1 + packages/lib/src/globals/stdio.rs | 120 ++++++++++++++++++++++++++++++ tests/stdio/prompt.luau | 37 +++++++++ 8 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 tests/stdio/prompt.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f757c2..ba009fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a new global `stdio` which replaces `console` and adds a couple new functions: - - `write` writes a string directly to stdout, without any newlines - - `ewrite` writes a string directly to stderr, without any newlines +- Added a new global `stdio` which replaces `console` +- Added `stdio.write` which writes a string directly to stdout, without any newlines +- Added `stdio.ewrite` which writes a string directly to stderr, without any newlines +- Added `stdio.prompt` which will prompt the user for different kinds of input + + Example usage: + + ```lua + local text = stdio.prompt() + + local text2 = stdio.prompt("text", "Please write some text") + + local didConfirm = stdio.prompt("confirm", "Please confirm this action") + + local optionIndex = stdio.prompt("select", "Please select an option", { "one", "two", "three" }) + + local optionIndices = stdio.prompt( + "multiselect", + "Please select one or more options", + { "one", "two", "three", "four", "five" } + ) + ``` ### Changed diff --git a/Cargo.lock b/Cargo.lock index 5c04033..65310e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3c796f3b0b408d9fd581611b47fa850821fcb84aa640b83a3c1a5be2d691f2" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -194,6 +206,15 @@ dependencies = [ "libc", ] +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + [[package]] name = "fnv" version = "1.0.7" @@ -426,6 +447,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + [[package]] name = "io-lifetimes" version = "1.0.5" @@ -544,6 +574,7 @@ version = "0.2.2" dependencies = [ "anyhow", "console", + "dialoguer", "hyper", "lazy_static", "mlua", @@ -787,6 +818,15 @@ version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "reqwest" version = "0.11.14" @@ -962,6 +1002,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -1028,6 +1074,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + [[package]] name = "termcolor" version = "1.2.0" @@ -1421,3 +1481,9 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] + +[[package]] +name = "zeroize" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" diff --git a/lune.yml b/lune.yml index 5db9349..07c349a 100644 --- a/lune.yml +++ b/lune.yml @@ -86,6 +86,15 @@ globals: stdio.ewrite: args: - type: string + stdio.prompt: + must_use: true + args: + - required: false + type: string + - required: false + type: string + - required: false + type: string | boolean | table # Task task.cancel: args: diff --git a/luneDocs.json b/luneDocs.json index cc177d5..e110bba 100644 --- a/luneDocs.json +++ b/luneDocs.json @@ -387,6 +387,35 @@ "@roblox/global/stdio.format/return/0": { "documentation": "The formatted string" }, + "@roblox/global/stdio.prompt": { + "code_sample": "", + "documentation": "Prompts for user input using the wanted kind of prompt:\n\n* `\"text\"` - Prompts for a plain text string from the user\n* `\"confirm\"` - Prompts the user to confirm with y / n\n* `\"select\"` - Prompts the user to select *one* value from a list\n* `\"multiselect\"` - Prompts the user to select *one or more* values from a list\n* `nil` - Equivalent to `\"text\"` with no extra arguments", + "learn_more_link": "", + "params": [ + { + "documentation": "@roblox/global/stdio.prompt/param/0", + "name": "kind" + }, + { + "documentation": "@roblox/global/stdio.prompt/param/1", + "name": "message" + }, + { + "documentation": "@roblox/global/stdio.prompt/param/2", + "name": "defaultOrOptions" + } + ], + "returns": [] + }, + "@roblox/global/stdio.prompt/param/0": { + "documentation": "The kind of prompt to use" + }, + "@roblox/global/stdio.prompt/param/1": { + "documentation": "The message to show the user" + }, + "@roblox/global/stdio.prompt/param/2": { + "documentation": "The default value for the prompt, or options to choose from for selection prompts" + }, "@roblox/global/stdio.style": { "code_sample": "", "documentation": "Return an ANSI string that can be used to modify the persistent output style.\n\nPass `\"reset\"` to get a string that can reset the persistent output style.\n\n### Example usage\n\n```lua\nstdio.write(stdio.style(\"bold\"))\nprint(\"This text will be bold\")\nstdio.write(stdio.style(\"reset\"))\nprint(\"This text will be normal\")\n```", diff --git a/luneTypes.d.luau b/luneTypes.d.luau index 66739e9..08ef9dc 100644 --- a/luneTypes.d.luau +++ b/luneTypes.d.luau @@ -351,6 +351,28 @@ declare stdio: { @param s The string to write to stderr ]=] ewrite: (s: string) -> (), + --[=[ + @within stdio + + Prompts for user input using the wanted kind of prompt: + + * `"text"` - Prompts for a plain text string from the user + * `"confirm"` - Prompts the user to confirm with y / n + * `"select"` - Prompts the user to select *one* value from a list + * `"multiselect"` - Prompts the user to select *one or more* values from a list + * `nil` - Equivalent to `"text"` with no extra arguments + + @param kind The kind of prompt to use + @param message The message to show the user + @param defaultOrOptions The default value for the prompt, or options to choose from for selection prompts + ]=] + prompt: ( + (() -> string) + & ((kind: "text", message: string?, defaultOrOptions: string?) -> string) + & ((kind: "confirm", message: string, defaultOrOptions: boolean?) -> boolean) + & ((kind: "select", message: string?, defaultOrOptions: { string }) -> number?) + & ((kind: "multiselect", message: string?, defaultOrOptions: { string }) -> { number }?) + ), } --[=[ diff --git a/packages/lib/Cargo.toml b/packages/lib/Cargo.toml index 25e2972..49d3117 100644 --- a/packages/lib/Cargo.toml +++ b/packages/lib/Cargo.toml @@ -21,6 +21,7 @@ tokio.workspace = true reqwest.workspace = true console = "0.15.5" +dialoguer = "0.10.3" lazy_static = "1.4.0" os_str_bytes = "6.4.1" diff --git a/packages/lib/src/globals/stdio.rs b/packages/lib/src/globals/stdio.rs index e20fa04..002831e 100644 --- a/packages/lib/src/globals/stdio.rs +++ b/packages/lib/src/globals/stdio.rs @@ -1,3 +1,4 @@ +use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select}; use mlua::prelude::*; use crate::utils::{ @@ -30,6 +31,125 @@ pub fn create(lua: &Lua) -> LuaResult<()> { eprint!("{s}"); Ok(()) })? + .with_function("prompt", prompt)? .build_readonly()?, ) } + +fn prompt_theme() -> ColorfulTheme { + ColorfulTheme::default() +} + +fn prompt<'a>( + lua: &'a Lua, + (kind, message, options): (Option, Option, LuaValue<'a>), +) -> LuaResult> { + match kind.map(|k| k.trim().to_ascii_lowercase()).as_deref() { + None | Some("text") => { + let theme = prompt_theme(); + let mut prompt = Input::with_theme(&theme); + if let Some(message) = message { + prompt.with_prompt(message); + }; + if let LuaValue::String(s) = options { + let txt = String::from_lua(LuaValue::String(s), lua)?; + prompt.with_initial_text(&txt); + }; + let input: String = prompt.allow_empty(true).interact_text()?; + Ok(LuaValue::String(lua.create_string(&input)?)) + } + Some("confirm") => { + if let Some(message) = message { + let theme = prompt_theme(); + let mut prompt = Confirm::with_theme(&theme); + if let LuaValue::Boolean(b) = options { + prompt.default(b); + }; + let result = prompt.with_prompt(&message).interact()?; + Ok(LuaValue::Boolean(result)) + } else { + Err(LuaError::RuntimeError( + "Argument #2 missing or nil".to_string(), + )) + } + } + Some(s) if matches!(s, "select" | "multiselect") => { + let options = match options { + LuaValue::Table(t) => { + let v: Vec = Vec::from_lua(LuaValue::Table(t), lua)?; + if v.len() < 2 { + return Err(LuaError::RuntimeError( + "Options table must contain at least 2 options".to_string(), + )); + } + v + } + LuaValue::Nil => { + return Err(LuaError::RuntimeError( + "Argument #3 missing or nil".to_string(), + )) + } + value => { + return Err(LuaError::RuntimeError(format!( + "Argument #3 must be a table, got '{}'", + value.type_name() + ))) + } + }; + if let Some(message) = message { + match s { + "select" => { + let chosen = Select::with_theme(&prompt_theme()) + .with_prompt(&message) + .items(&options) + .interact_opt()?; + Ok(match chosen { + Some(idx) => LuaValue::Number((idx + 1) as f64), + None => LuaValue::Nil, + }) + } + "multiselect" => { + let chosen = MultiSelect::with_theme(&prompt_theme()) + .with_prompt(&message) + .items(&options) + .interact_opt()?; + Ok(match chosen { + Some(indices) => indices + .iter() + .map(|idx| (*idx + 1) as f64) + .collect::>() + .to_lua(lua)?, + None => LuaValue::Nil, + }) + } + _ => unreachable!(), + } + } else { + match s { + "select" => { + let chosen = Select::new().items(&options).interact_opt()?; + Ok(match chosen { + Some(idx) => LuaValue::Number((idx + 1) as f64), + None => LuaValue::Nil, + }) + } + "multiselect" => { + let chosen = MultiSelect::new().items(&options).interact_opt()?; + Ok(match chosen { + Some(indices) => indices + .iter() + .map(|idx| (*idx + 1) as f64) + .collect::>() + .to_lua(lua)?, + None => LuaValue::Nil, + }) + } + _ => unreachable!(), + } + } + } + Some(s) => Err(LuaError::RuntimeError(format!( + "Invalid stdio prompt kind: '{s}'" + ))), + } +} diff --git a/tests/stdio/prompt.luau b/tests/stdio/prompt.luau new file mode 100644 index 0000000..fb068ee --- /dev/null +++ b/tests/stdio/prompt.luau @@ -0,0 +1,37 @@ +-- NOTE: This test is intentionally not included in the +-- automated tests suite since it requires user input + +-- Text prompt + +local text = stdio.prompt("text", "Type some text") +assert(#text > 0, "Did not get any text") +print(`Got text '{text}'\n`) + +-- Confirmation prompt + +local confirmed = stdio.prompt("confirm", "Please confirm", true) +assert(confirmed == true, "Did not get true as result") +print(if confirmed then "Confirmed\n" else "Did not confirm\n") + +-- Selection prompt + +local option = stdio.prompt( + "select", + "Please select the first option from the list", + { "one", "two", "three", "four" } +) +assert(option == 1, "Did not get the first option as result") +print(if option then `Got option #{option}\n` else "Got no option\n") + +-- Multi-selection prompt + +local options = stdio.prompt( + "multiselect", + "Please select options two and four", + { "one", "two", "three", "four", "five" } +) +assert( + options ~= nil and table.find(options, 2) and table.find(options, 4), + "Did not get options 2 and 4 as result" +) +print(if options then `Got option(s) {stdio.format(options)}\n` else "Got no option\n")