From 6ae28811483209c1e396f8a922b2c1e3034c9871 Mon Sep 17 00:00:00 2001 From: PlexSheep Date: Fri, 28 Jun 2024 23:05:16 +0200 Subject: [PATCH] feat(cli::repl): #84 working and nice repl structure --- members/libpt-cli/Cargo.toml | 1 + members/libpt-cli/examples/repl.rs | 93 +++----------- members/libpt-cli/src/lib.rs | 1 + members/libpt-cli/src/repl/mod.rs | 190 +++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 76 deletions(-) diff --git a/members/libpt-cli/Cargo.toml b/members/libpt-cli/Cargo.toml index cf023a7..fbf5f6e 100644 --- a/members/libpt-cli/Cargo.toml +++ b/members/libpt-cli/Cargo.toml @@ -28,3 +28,4 @@ indicatif = "0.17.8" libpt-log = { workspace = true, optional = true } log = { version = "0.4.21", optional = true } shlex = "1.3.0" +strum = { version = "0.26.3", features = ["derive"] } diff --git a/members/libpt-cli/examples/repl.rs b/members/libpt-cli/examples/repl.rs index 106813e..0289149 100644 --- a/members/libpt-cli/examples/repl.rs +++ b/members/libpt-cli/examples/repl.rs @@ -1,21 +1,11 @@ -use libpt_cli::repl::REPL_HELP_TEMPLATE; -use libpt_cli::{clap, dialoguer, printing}; -use libpt_log::{debug, trace, Level, Logger}; +use libpt_cli::repl::{DefaultRepl, Repl}; +use libpt_cli::{clap, printing, strum}; +use libpt_log::{debug, Level, Logger}; -use clap::{Parser, Subcommand}; +use clap::Subcommand; +use strum::EnumIter; -/// This is the help menu of the repl -/// -/// More text here -#[derive(Parser, Debug)] -#[command(multicall = true)] -pub struct Repl { - /// the command you want to execute, along with its args - #[command(subcommand)] - command: ReplCommand, -} - -#[derive(Subcommand, Debug)] +#[derive(Subcommand, Debug, EnumIter, Clone)] enum ReplCommand { /// wait for LEN seconds Wait { @@ -36,72 +26,21 @@ enum ReplCommand { Exit, } -// TODO: somehow autogenerate this!!! -pub struct MyCompletion { - options: Vec, -} -impl Default for MyCompletion { - fn default() -> Self { - MyCompletion { - options: vec![ - "help".to_string(), - "?".to_string(), - "list".to_string(), - "publish".to_string(), - "unpublish".to_string(), - "delete".to_string(), - "read".to_string(), - "show".to_string(), - "new".to_string(), - "ls".to_string(), - ], - } - } -} - -impl dialoguer::Completion for MyCompletion { - /// Simple completion implementation based on substring - fn get(&self, input: &str) -> Option { - let matches = self - .options - .iter() - .filter(|option| option.starts_with(input)) - .collect::>(); - - if matches.len() == 1 { - Some(matches[0].to_string()) - } else { - None - } - } -} - fn main() -> anyhow::Result<()> { + // You would normally make a proper cli interface with clap before entering the repl. This is + // omitted here for brevity let _logger = Logger::builder() .show_time(false) .max_level(Level::DEBUG) .build(); - let mut buf: String = String::new(); - let mut buf_preparsed: Vec; - let completion = MyCompletion::default(); - let mut history = dialoguer::BasicHistory::new(); + // the compiler can infer that we want to use the ReplCommand enum. + let mut repl = DefaultRepl::::new(); debug!("entering the repl"); loop { - buf.clear(); - - buf = dialoguer::Input::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .completion_with(&completion) - .history_with(&mut history) - .interact_text()?; - - buf_preparsed = Vec::new(); - buf_preparsed.extend(shlex::split(&buf).unwrap_or_default()); - - trace!("read input: {buf_preparsed:?}"); - - let options = match Repl::try_parse_from(buf_preparsed) { + // repl.step() should be at the start of your loop + match repl.step() { Ok(c) => c, Err(e) => { println!("{e}"); @@ -109,7 +48,10 @@ fn main() -> anyhow::Result<()> { } }; - match options.command { + // now we can match our defined commands + // + // only None if the repl has not stepped yet + match repl.command().to_owned().unwrap() { ReplCommand::Exit => break, ReplCommand::Wait { len } => { debug!("len: {len}"); @@ -122,8 +64,7 @@ fn main() -> anyhow::Result<()> { ReplCommand::Echo { text, fancy } => { if !fancy { println!("{}", text.concat()) - } - else { + } else { printing::blockprint(text.concat(), console::Color::Cyan) } } diff --git a/members/libpt-cli/src/lib.rs b/members/libpt-cli/src/lib.rs index 6abc3cd..df6a305 100644 --- a/members/libpt-cli/src/lib.rs +++ b/members/libpt-cli/src/lib.rs @@ -10,3 +10,4 @@ pub use exitcode; pub use human_panic; pub use indicatif; pub use shlex; +pub use strum; diff --git a/members/libpt-cli/src/repl/mod.rs b/members/libpt-cli/src/repl/mod.rs index 8b13789..52dcc6d 100644 --- a/members/libpt-cli/src/repl/mod.rs +++ b/members/libpt-cli/src/repl/mod.rs @@ -1 +1,191 @@ +use std::fmt::Debug; +use clap::{Parser, Subcommand}; +use dialoguer::{BasicHistory, Completion}; +use libpt_log::trace; + +#[derive(Parser)] +#[command(multicall = true)] +pub struct DefaultRepl +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + /// the command you want to execute, along with its arguments + #[command(subcommand)] + command: Option, + + // the following fields are not to be parsed from a command, but used for the internal workings + // of the repl + #[clap(skip)] + buf: String, + #[clap(skip)] + buf_preparsed: Vec, + #[clap(skip)] + completion: DefaultReplCompletion, + #[clap(skip)] + history: BasicHistory, +} + +impl Debug for DefaultRepl +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DefaultRepl") + .field("command", &self.command) + .field("buf", &self.buf) + .field("buf_preparsed", &self.buf_preparsed) + .field("completion", &self.completion) + .field("history", &"(no debug)") + .finish() + } +} + +#[derive(Debug)] +pub struct DefaultReplCompletion +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + commands: std::marker::PhantomData, +} + +impl DefaultReplCompletion +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + pub fn new() -> Self { + Self { + commands: std::marker::PhantomData::, + } + } + fn commands(&self) -> Vec { + let mut buf = Vec::new(); + // every crate has the help command, but it is not part of the enum + buf.push("help".to_string()); + for c in C::iter() { + // HACK: this is a horrible way to do this + // I just need the names of the commands + buf.push( + format!("{c:?}") + .split_whitespace() + .map(|e| e.to_lowercase()) + .next() + .unwrap() + .to_string(), + ) + } + trace!("commands: {buf:?}"); + buf + } +} + +impl Default for DefaultReplCompletion +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + fn default() -> Self { + Self::new() + } +} + +pub trait Repl: Parser + Debug +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + /// create a new repl + fn new() -> Self; + /// get the command that was parsed from user input + /// + /// Will only be [None] if the repl has not had [step] executed yet. + fn command(&self) -> &Option; + /// return all possible commands in this repl + fn completion() -> impl Completion; + /// advance the repl to the next iteration of the main loop + /// + /// This should be used at the start of your loop + fn step(&mut self) -> anyhow::Result<()>; +} + +impl Completion for DefaultReplCompletion +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + /// Simple completion implementation based on substring + fn get(&self, input: &str) -> Option { + let matches = self + .commands() + .into_iter() + .filter(|option| option.starts_with(input)) + .collect::>(); + + trace!("\nmatches: {matches:#?}"); + if matches.len() == 1 { + Some(matches[0].to_string()) + } else { + None + } + } +} + +impl Repl for DefaultRepl +where + C: Debug, + C: Subcommand, + C: strum::IntoEnumIterator, +{ + fn new() -> Self { + Self { + command: None, + buf_preparsed: Vec::new(), + buf: String::new(), + history: BasicHistory::new(), + completion: DefaultReplCompletion::new(), + } + } + fn command(&self) -> &Option { + &self.command + } + #[allow(refining_impl_trait)] + fn completion() -> DefaultReplCompletion { + DefaultReplCompletion { + commands: std::marker::PhantomData::, + } + } + fn step(&mut self) -> anyhow::Result<()> { + self.buf.clear(); + + // NOTE: display::Input requires some kind of lifetime that would be a bother to store in + // our struct. It's documentation also uses it in place, so it should be fine to do it like + // this. + self.buf = dialoguer::Input::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .completion_with(&self.completion) + .history_with(&mut self.history) + .interact_text()?; + + self.buf_preparsed = Vec::new(); + self.buf_preparsed + .extend(shlex::split(&self.buf).unwrap_or_default()); + + trace!("read input: {:?}", self.buf_preparsed); + trace!("repl after step: {:#?}", self); + + // HACK: find a way to not allocate a new struct for this + let cmds = Self::try_parse_from(&self.buf_preparsed)?; + self.command = cmds.command; + Ok(()) + } +}