General imrovements

Added app! macro that can extract package name and version.

Opts can be parsed for the application root prior to launching handling
functions.

Added further integration with the console create

Improved display of description_lists, these now wrap the description
text.

Long and short opts are now optional and one or both can be used.
This commit is contained in:
Joe Bellus 2021-11-14 02:47:55 +00:00
parent 2573ef293a
commit 445ae2ddfc
11 changed files with 302 additions and 71 deletions

58
Cargo.lock generated
View File

@ -2,14 +2,24 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
dependencies = [
"memchr",
]
[[package]]
name = "arkham"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"console",
"criterion",
"serde",
"serde_json",
"textwrap 0.14.2",
"toml",
]
@ -76,7 +86,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
dependencies = [
"bitflags",
"textwrap",
"textwrap 0.11.0",
"unicode-width",
]
@ -312,6 +322,12 @@ dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
[[package]]
name = "oorandom"
version = "11.1.3"
@ -404,7 +420,10 @@ version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
"thread_local",
]
[[package]]
@ -508,6 +527,12 @@ dependencies = [
"serde",
]
[[package]]
name = "smawk"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"
[[package]]
name = "syn"
version = "1.0.73"
@ -538,6 +563,26 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "textwrap"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "thread_local"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd"
dependencies = [
"once_cell",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
@ -563,6 +608,15 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
[[package]]
name = "unicode-linebreak"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f"
dependencies = [
"regex",
]
[[package]]
name = "unicode-width"
version = "0.1.8"

View File

@ -1,6 +1,6 @@
[package]
name = "arkham"
version = "0.1.0"
version = "0.1.1"
authors = ["Joe Bellus <joe@5sigma.io>"]
edition = "2018"
description = "A framework for CLI applications"
@ -19,11 +19,14 @@ console = "0.14.0"
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true}
toml = { version="0.5.8", optional = true}
textwrap = "0.14.2"
[features]
config = ["serde"]
config = ["serialize"]
config_toml = ["toml", "config"]
config_json = ["serde_json", "config"]
serialize = ["serde"]
[dev-dependencies]
criterion = "0.3"

View File

@ -27,4 +27,5 @@ structures: Detail lists, headers, etc.
# Basic Usage
* [fib](https://git.5sigma.io/arkham/arkham/-/blob/master/examples/fib.rs) - An example using subcommands and command line options
* [config](https://git.5sigma.io/arkham/arkham/-/blob/master/examples/config.rs) - An example loading a configuration file. This must be ran with `cargo run --example config --features toml_config`

View File

@ -4,6 +4,7 @@ fn main() {
let _ = App::new()
.name("Fibonacci App")
.version("1.0")
.long_desc("This app can be used to calculate fibonacci numbers")
.command(
Command::new("fib")
.opt(
@ -22,7 +23,7 @@ fn main() {
fn fibonacci_handler(_app: &App, ctx: &Context, _args: &[String]) {
let v = fibonacci(
ctx.get_string("count")
.unwrap_or("1".to_string())
.unwrap_or_else(|| "1".to_string())
.parse()
.unwrap_or(1),
);

View File

@ -1,4 +1,4 @@
use crate::print_command_help;
use crate::{print_command_help, vox};
use super::command::{help, Command, Handler};
use super::context::Context;
@ -110,6 +110,29 @@ impl App {
self
}
/// Similar to `App::Command` but allows multiple commands to be added at once.
/// Adds a root level commands to the application. This command can then be executed with:
///
/// myapp command_name
///
/// Help flags will also be generated for the command which will display command
/// information for:
///
/// myapp --help command_name or myapp help command_name
///
/// Example:
/// ```rust
/// use arkham::{App, Command, Context};
/// let names = vec!["one", "two", "three"];
/// let commands = names.into_iter().map(|name| Command::new(name).handler(my_handler)).collect();
/// App::new().commands(commands);
///
/// fn my_handler(app: &App, ctx: &Context, args: &[String]) {}
/// ```
pub fn commands(self, commands: Vec<Command>) -> Self {
commands.into_iter().fold(self, |app, cmd| app.command(cmd))
}
/// Adds a root level opt/flag that is available to all commands. Opts are given a name which
/// is used to reference them, as well as a short and long identifier.
///
@ -140,9 +163,19 @@ impl App {
self
}
/// Execute the app and any specified handlers based on the passed arguemnts. This function is
/// mostly used for testing or any situation where you need to pass arbitrary arguments instead
/// of using the ones passed to the application.
/// Sets the longe description for the bare app. This will be displayed in the help content
/// when no additional subcommands are given.
/// Example:
/// ```rust
/// use arkham::{App, Command, Context};
/// App::new().long_desc("This app does all the things");
/// ```
pub fn long_desc(mut self, desc: &str) -> Self {
self.root.long_desc = Some(desc.to_string());
self
}
/// Execute the app and any specified handlers based on the passed arguemnts. This function is mostly used for testing or any situation where you need to pass arbitrary arguments instead of using the ones passed to the application.
/// Example:
/// ```rust
/// use arkham::{App, Command, Context, Opt};
@ -160,11 +193,19 @@ impl App {
if let Some(filename) = self.config_filename {
ctx.load_config_file(filename);
}
run_command(self, &self.root, &args, &mut ctx)
if let Err(e) = run_command(self, &self.root, &args, &mut ctx) {
match e {
OptError::InvalidOpt(opt) => {
vox::error(format!("Invalid options {}", &opt));
Err(OptError::InvalidOpt(opt))
}
}
} else {
Ok(())
}
}
/// Execute the app and any specified handlers based on the arguments passsed to the
/// application.
/// Execute the app and any specified handlers based on the arguments passsed to the application.
///
/// Example:
/// running with myapp --name alice
@ -182,11 +223,17 @@ impl App {
pub fn run(&mut self) -> Result<()> {
self.run_with(env::args().skip(1).collect())
}
/// Generates a context for the root command allowing flags to be parsed. This can be useful to predetect certian root level arguments before processing sub commands and other app configurations.
pub fn root_context(&self) -> Result<Context> {
let mut ctx = Context::new(self.root.clone());
let args = env::args().skip(1).collect::<Vec<_>>();
populate_args(&mut ctx, &self.root, &args)?;
Ok(ctx)
}
}
/// This is the core logic for parsing arguments and executing handlers. It is ran but the App::run
/// and App::run_with functions.
fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) -> Result<()> {
fn populate_args(ctx: &mut Context, cmd: &Command, args: &[String]) -> Result<Vec<String>> {
// Get an iterator for the incomming arguments
let mut args = args.iter();
// We will keep track of any arguments that arent consumed by the current command.
@ -197,7 +244,11 @@ fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) ->
while let Some(arg) = args.next() {
// Check for long args
if arg.starts_with("--") {
if let Some(opt) = cmd.opts.iter().find(|o| &o.long == &arg[2..]) {
if let Some(opt) = cmd
.opts
.iter()
.find(|o| o.long == Some(arg[2..].to_string()))
{
match opt.kind {
OptKind::Flag => {
ctx.set_flag(opt);
@ -216,28 +267,42 @@ fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) ->
continue;
}
// Check for short args
if arg.starts_with("-") {
if let Some(opt) = cmd.opts.iter().find(|o| &o.short == &arg[1..]) {
match opt.kind {
OptKind::Flag => {
ctx.set_flag(opt);
}
OptKind::String => {
if let Some(value) = args.next() {
ctx.set_opt(opt, value.clone());
} else {
return Err(OptError::InvalidOpt(opt.name.clone()));
if arg.starts_with('-') {
for switch in arg.chars().skip(1) {
if let Some(opt) = cmd
.opts
.iter()
.find(|o| o.short == Some(switch.to_string()))
{
match opt.kind {
OptKind::Flag => {
ctx.set_flag(opt);
}
OptKind::String => {
if let Some(value) = args.next() {
ctx.set_opt(opt, value.clone());
} else {
return Err(OptError::InvalidOpt(opt.name.clone()));
}
}
}
} else {
ignored.push(arg.clone());
}
} else {
ignored.push(arg.clone());
}
continue;
}
ignored.push(arg.clone());
}
Ok(ignored)
}
/// This is the core logic for parsing arguments and executing handlers. It is ran but the App::run
/// and App::run_with functions.
fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) -> Result<()> {
let mut ignored = populate_args(ctx, cmd, args)?;
// Find an recurse into sub commands if the remaining argumetns match any subcommand name
if let Some(cmd) = cmd
.commands
@ -250,12 +315,12 @@ fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) ->
// Automatic command help display
if ignored.iter().any(|a| a == "-h" || a == "--help") {
super::command::print_command_help(cmd, &vec![]);
super::command::print_command_help(cmd, &[]);
return Ok(());
}
// If any ignored parameters start with "-" we will throw an unknwon flag error.
if let Some(arg) = ignored.iter().find(|a| a.starts_with("-")) {
if let Some(arg) = ignored.iter().find(|a| a.starts_with('-')) {
return Err(OptError::InvalidOpt(arg.clone()));
}
@ -265,10 +330,10 @@ fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) ->
if let Some(prefix) = app.env_prefix {
ctx.env_prefix = Some(prefix.to_string());
}
handler(app, &ctx, &ignored);
handler(app, ctx, &ignored);
} else {
crate::vox::print(app.application_header());
print_command_help(cmd, &vec![])
print_command_help(cmd, &[])
}
Ok(())
@ -328,15 +393,6 @@ mod tests {
.unwrap();
}
fn function_handler(_app: &App, _ctx: &Context, _args: &[String]) {
assert!(true);
}
#[test]
fn test_function_handler() {
App::new().handler(function_handler);
}
#[test]
fn test_extra_args() {
let args = vec!["somefile".to_string()];
@ -354,12 +410,27 @@ mod tests {
App::new()
.opt(Opt::flag("verbose").short("v").long("verbose"))
.handler(|_, ctx, _| {
assert_eq!(ctx.flag("verbose"), true);
assert!(ctx.flag("verbose"));
})
.run_with(vec!["-v".into()])
.unwrap();
}
#[test]
fn test_combined_short_flags() {
App::new()
.opt(Opt::flag("thingA").short("a").long("a"))
.opt(Opt::flag("thingB").short("b").long("b"))
.opt(Opt::flag("thingC").short("c").long("c"))
.handler(|_, ctx, _| {
assert!(ctx.flag("thingA"));
assert!(ctx.flag("thingB"));
assert!(ctx.flag("thingC"));
})
.run_with(vec!["-abc".into()])
.unwrap();
}
#[test]
fn test_invalid_long_flag() {
let r = App::new()
@ -383,7 +454,7 @@ mod tests {
.env_prefix("ARKHAM")
.opt(Opt::flag("thing").short("-t").long("--t"))
.handler(|_, ctx, _| {
assert_eq!(ctx.flag("thing"), true);
assert!(ctx.flag("thing"));
})
.run_with(vec![])
.unwrap();

View File

@ -1,3 +1,5 @@
use std::fmt::Debug;
use crate::{
context::Context,
opt::{self, Opt},
@ -16,6 +18,12 @@ pub struct Command {
pub short_desc: Option<String>,
}
impl Debug for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Command").field("name", &self.name).finish()
}
}
impl Command {
pub fn new<T: Into<String>>(name: T) -> Self {
Command {
@ -61,7 +69,7 @@ pub(crate) fn print_command_help(cmd: &Command, args: &[String]) {
}
}
vox::print("");
if let Some(desc) = cmd.long_desc.as_ref().or(cmd.short_desc.as_ref()) {
if let Some(desc) = cmd.long_desc.as_ref().or_else(|| cmd.short_desc.as_ref()) {
if cmd.name != "root" {
vox::header(&cmd.name.to_uppercase());
}
@ -79,7 +87,7 @@ pub(crate) fn print_command_help(cmd: &Command, args: &[String]) {
}
if !cmd.commands.is_empty() {
vox::header("Subcommands");
vox::header("Commands");
vox::description_list(
cmd.commands
.iter()
@ -87,4 +95,5 @@ pub(crate) fn print_command_help(cmd: &Command, args: &[String]) {
.collect(),
)
}
std::process::exit(1);
}

View File

@ -1,7 +1,7 @@
use crate::{Command, Opt};
use std::{collections::BTreeMap, env};
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Default)]
pub struct Map(BTreeMap<String, ContextValue>);
impl Map {
@ -102,9 +102,9 @@ impl ContextValue {
pub fn parse_string(value: String, kind: ValueKind) -> Option<Self> {
match kind {
ValueKind::String => Some(Self::String(value)),
ValueKind::Integer => value.parse::<i64>().ok().map(|v| Self::Integer(v)),
ValueKind::Float => value.parse::<f64>().ok().map(|v| Self::Float(v)),
ValueKind::Bool => value.parse::<bool>().ok().map(|v| Self::Bool(v)),
ValueKind::Integer => value.parse::<i64>().ok().map(Self::Integer),
ValueKind::Float => value.parse::<f64>().ok().map(Self::Float),
ValueKind::Bool => value.parse::<bool>().ok().map(Self::Bool),
ValueKind::Array => None,
ValueKind::Map => None,
}
@ -136,7 +136,7 @@ impl From<bool> for ContextValue {
}
pub struct Context {
pub(crate) cmd: Command,
pub cmd: Command,
pub(crate) config_data: Map,
pub(crate) env_prefix: Option<String>,
pub(crate) env_enabled: bool,
@ -166,14 +166,14 @@ impl Context {
}
fn get_env_value(&self, name: &str) -> Option<String> {
if self.env_enabled == false {
if !self.env_enabled {
return None;
}
let name = self
.env_prefix
.as_ref()
.map(|pre| format!("{}_{}", pre, name))
.unwrap_or(name.to_string());
.unwrap_or_else(|| name.to_string());
env::var(name).ok()
}
@ -182,7 +182,7 @@ impl Context {
self.config_data
.get(name)
.cloned()
.or(self.get_env_value(name).map(|v| ContextValue::String(v)))
.or_else(|| self.get_env_value(name).map(ContextValue::String))
}
/// Returns a string for the field if it is a string
@ -240,7 +240,7 @@ impl Context {
/// Can be used to display the automatic help message for the current command.
pub fn display_help(&self) {
crate::command::print_command_help(&self.cmd, &vec![])
crate::command::print_command_help(&self.cmd, &[])
}
pub(crate) fn load_config_file(&mut self, filename: &str) {
@ -340,7 +340,7 @@ mod tests {
App::new()
.opt(Opt::flag("thing").short("-t").long("--t"))
.handler(|_, ctx, _| {
assert_eq!(ctx.flag("thing"), true);
assert!(ctx.flag("thing"));
})
.run_with(vec![])
.unwrap();

View File

@ -1,9 +1,14 @@
#[macro_use]
mod macros;
mod app;
mod command;
mod context;
mod opt;
pub use console;
pub mod vox;
pub use app::*;
pub use command::*;
pub use context::*;
pub use opt::*;
pub use vox::*;

8
src/macros.rs Normal file
View File

@ -0,0 +1,8 @@
#[macro_export]
macro_rules! app {
() => {
App::default()
.version(env!("CARGO_PKG_VERSION"))
.name(env!("CARGO_PKG_NAME"))
};
}

View File

@ -6,8 +6,8 @@ pub enum OptError {
#[derive(Clone, Debug)]
pub struct Opt {
pub name: String,
pub short: String,
pub long: String,
pub short: Option<String>,
pub long: Option<String>,
pub desc: Option<String>,
pub(crate) kind: OptKind,
}
@ -23,8 +23,8 @@ impl Opt {
pub fn flag(name: &str) -> Self {
Self {
name: name.into(),
short: "".into(),
long: "".into(),
short: None,
long: None,
kind: OptKind::Flag,
desc: None,
}
@ -40,8 +40,8 @@ impl Opt {
pub fn scalar(name: &str) -> Self {
Self {
name: name.into(),
short: "".into(),
long: "".into(),
short: None,
long: None,
kind: OptKind::String,
desc: None,
}
@ -55,7 +55,7 @@ impl Opt {
/// App::new().opt(Opt::scalar("user").short("u").long("user"));
///```
pub fn short(mut self, short: &str) -> Self {
self.short = short.into();
self.short = Some(short.into());
self
}
@ -67,7 +67,7 @@ impl Opt {
/// App::new().opt(Opt::scalar("user").short("u").long("user"));
///```
pub fn long(mut self, long: &str) -> Self {
self.long = long.into();
self.long = Some(long.into());
self
}
@ -92,10 +92,32 @@ impl Opt {
pub(crate) fn usage(&self) -> String {
match self.kind {
OptKind::Flag => {
format!("-{}, --{}", self.short, self.long)
let short = self
.short
.as_ref()
.map(|s| format!("-{}", s))
.unwrap_or_else(String::new);
let long = self
.long
.as_ref()
.map(|s| format!("--{}", s))
.unwrap_or_else(String::new);
vec![short, long].join(",").trim_matches(',').to_string()
}
OptKind::String => {
format!("-{} [value], --{} [value]", self.short, self.long)
let short = self
.short
.as_ref()
.map(|s| format!("-{} [value]", s))
.unwrap_or_else(String::new);
let long = self
.long
.as_ref()
.map(|s| format!("--{} [value]", s))
.unwrap_or_else(String::new);
vec![short, long].join(",").trim_matches(',').to_string()
}
}
}

View File

@ -2,6 +2,41 @@ use console::style;
use std::collections::HashMap;
use std::fmt::Display;
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy)]
pub enum Color {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
Color256(u8),
}
impl From<Color> for console::Color {
fn from(fr: Color) -> Self {
match fr {
Color::Black => Self::Black,
Color::Red => Self::Red,
Color::Green => Self::Green,
Color::Yellow => Self::Yellow,
Color::Blue => Self::Blue,
Color::Magenta => Self::Magenta,
Color::Cyan => Self::Cyan,
Color::White => Self::White,
Color::Color256(v) => Self::Color256(v),
}
}
}
pub fn labeled(color: Color, label: &str, msg: &str) {
let label = format!("[{}]", label);
println!("{} {}", console::style(label).fg(color.into()).bold(), msg);
}
pub fn message<T: Display>(str: T) {
println!("{}", style(str).white().bold());
}
@ -20,16 +55,38 @@ pub fn note<T: Display>(str: T) {
}
pub fn description_list(list: HashMap<String, String>) {
let max_length = list.keys().map(|v| v.len()).max().unwrap_or(10) + 3;
for (name, desc) in list {
let spaced_name = format!("{:width$}", name, width = max_length);
println!("{}{}", style(spaced_name).bold(), style(desc).dim())
}
let mut lines = list.into_iter().collect::<Vec<(String, String)>>();
let key_max_length = lines.iter().map(|(v, _)| v.len()).max().unwrap_or(10) + 3;
let desc_max_length = 80 - key_max_length;
let indent_string = " ".repeat(key_max_length);
lines.sort_by(|(a, _), (b, _)| a.cmp(b));
let output = lines.iter().fold(String::new(), |output, (name, desc)| {
let spaced_key = format!("{:width$}", name, width = key_max_length);
let mut desc_lines: Vec<String> = textwrap::fill(desc, desc_max_length)
.split('\n')
.map(|s| s.to_string())
.collect();
desc_lines.reverse();
let first_line = desc_lines.pop().unwrap_or_else(String::new);
desc_lines.reverse();
output
+ &format!("{}{}", style(spaced_key).bold(), style(first_line).dim())
+ "\n"
+ &desc_lines
.iter()
.map(|s| indent_string.clone() + s + "\n")
.collect::<String>()
});
println!("{}", output);
}
pub fn print<T: Display>(s: T) {
println!("{}", s);
}
pub fn error<T: Display>(s: T) {
println!("{}", style(s).red().bold());
}
#[cfg(test)]
mod tests {}