commit bdfe1d10005447c54a995de964486ed6d62670ba Author: soup Date: Tue May 14 21:28:42 2024 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1bcecda --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,19 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "clime" +version = "0.1.0" +dependencies = [ + "e", + "stilts", +] + +[[package]] +name = "e" +version = "1.0.0" + +[[package]] +name = "stilts" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e405663 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "clime" +version = "0.1.0" +edition = "2021" + +[dependencies] +stilts = { path = "../stilts/" } +e = { path = "../e" } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b21f127 --- /dev/null +++ b/flake.lock @@ -0,0 +1,97 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1715322226, + "narHash": "sha256-ezoe/FwfJpA7sskLoLP2iwfwkYnscEFCP6Vk5kPwh9k=", + "owner": "nix-community", + "repo": "fenix", + "rev": "297c756ba6249d483c1dafe42378560458842173", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1713828541, + "narHash": "sha256-KtvQeE12MSkCOhvVmnmcZCjnx7t31zWin2XVSDOwBDE=", + "path": "/nix/store/h5g4m28iz22w31c0asl73mcir6z7x5ra-source", + "rev": "b500489fd3cf653eafc075f9362423ad5cdd8676", + "type": "path" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1715255944, + "narHash": "sha256-vLLgYpdtKBaGYTamNLg1rbRo1bPXp4Jgded/gnprPVw=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "5bf2f85c8054d80424899fa581db1b192230efb5", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..71e051b --- /dev/null +++ b/flake.nix @@ -0,0 +1,26 @@ +{ + inputs.nixpkgs.url = "nixpkgs"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs.fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { self, nixpkgs, flake-utils, fenix }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + fenix' = fenix.packages.${system}; + nightly = fenix'.default; + stable = fenix'.stable; + in { + devShells.default = pkgs.mkShell { + packages = [ (fenix'.combine [ + (stable.withComponents [ + "cargo" "rustc" "rust-src" "rust-analyzer" "clippy" + ]) + nightly.rustfmt + ]) pkgs.cargo-watch ]; + }; + }); +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7000591 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +hard_tabs = true +match_block_trailing_comma = true +max_width = 80 +empty_item_single_line = false diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..94aeaca --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,415 @@ +use e::default; +use stilts::Push; + +pub type HelpMessage = &'static str; + +pub trait Value { + fn parse(s: &str) -> Result + where + Self: Sized; +} +macro_rules! impl_value_using_from_str { + ($t:ty) => { + impl $crate::Value for $t { + fn parse(s: &str) -> Result { + s.parse().map_err(|_| concat!("Invalid ", stringify!($t))) + } + } + }; +} +impl_value_using_from_str!(String); +impl_value_using_from_str!(u8); +impl_value_using_from_str!(u16); +impl_value_using_from_str!(u32); +impl_value_using_from_str!(u64); +impl_value_using_from_str!(i8); +impl_value_using_from_str!(i16); +impl_value_using_from_str!(i32); +impl_value_using_from_str!(i64); +impl_value_using_from_str!(std::path::PathBuf); + +pub trait Collect { + fn collect(&mut self, s: &str) -> Result<(), HelpMessage>; +} + +impl Collect for T +where + T: Value, +{ + fn collect(&mut self, s: &str) -> Result<(), HelpMessage> { + *self = T::parse(s)?; + + Ok(()) + } +} + +impl Collect for Vec +where + T: Value, +{ + fn collect(&mut self, s: &str) -> Result<(), HelpMessage> { + self.push(T::parse(s)?); + + Ok(()) + } +} + +impl Collect for Option +where + T: Value, +{ + fn collect(&mut self, s: &str) -> Result<(), HelpMessage> { + *self = Some(T::parse(s)?); + + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub enum ParseError { + InvalidParamFormat { + param: &'static str, + expected: &'static str, + }, + MissingParamFor(&'static str), + UnknownParam(String), + SingleDashMustBeSingleCharacter(String), +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub enum BindError { + DuplicateShort(char), + DuplicateLong(&'static str), +} + +/// Struct-of-array-style storage +struct Soa<'a, T: ?Sized> { + ts: Vec<&'a mut T>, + longs: Vec<&'static str>, + descriptions: Vec>, + + // Shorts are not not 1:1 in length with + // bindings, so we create a second linked vec that maps indices in + // shorts to indices in bindings + shorts: Vec, + shorts_indices: Vec, +} + +impl<'a, T: ?Sized> Soa<'a, T> { + fn new() -> Self { + Self { + ts: Vec::new(), + longs: default(), + descriptions: default(), + shorts: default(), + shorts_indices: default(), + } + } + + fn add( + &mut self, + t: &'a mut T, + long: &'static str, + short: Option, + description: Option<&'static str>, + ) -> Result<(), BindError> { + if self.longs.iter().any(|l| *l == long) { + return Err(BindError::DuplicateLong(long)); + } + if let Some(short) = short { + if self.shorts.iter().any(|c| *c == short) { + return Err(BindError::DuplicateShort(short)); + } + } + + let next = self.ts.len(); + + self.ts.push(t); + self.longs.push(long); + self.descriptions.push(description); + + if let Some(c) = short { + self.shorts.push(c); + self.shorts_indices.push(next); + } + + Ok(()) + } + + fn find_for_long( + &mut self, + long: &str, + ) -> Option<(&'static str, &mut &'a mut T)> { + self.longs + .iter() + .enumerate() + .find(|(_, s)| *s == &long) + .map(|(i, s)| (*s, &mut self.ts[i])) + } + + fn find_for_short( + &mut self, + short: char, + ) -> Option<(&'static str, &mut &'a mut T)> { + self.shorts + .iter() + .zip(self.shorts_indices.iter().copied()) + .find(|(s, _)| **s == short) + .map(|(_, i)| (self.longs[i], &mut self.ts[i])) + } +} + +type Params<'a> = Soa<'a, dyn Collect + 'a>; +type Flags<'a> = Soa<'a, bool>; + +pub struct Cli<'a> { + #[allow(dead_code)] + name: &'static str, + description: Option<&'static str>, + version: Option<&'static str>, + + params: Params<'a>, + flags: Flags<'a>, +} + +impl<'a> Cli<'a> { + pub fn new(name: &'static str) -> Self { + Self { + name, + description: default(), + version: default(), + + params: Soa::new(), + flags: Soa::new(), + } + } + + pub fn description(&mut self, d: &'static str) -> &mut Self { + self.description = Some(d); + self + } + + pub fn version(&mut self, v: &'static str) -> &mut Self { + self.version = Some(v); + self + } + + pub fn bind(&mut self, pb: ParamBuilder<'a>) -> Result<(), BindError> { + match pb.kind { + Kind::Param(p) => { + self.params.add(p, pb.long, pb.short, pb.description) + }, + Kind::Flag(f) => { + self.flags.add(f, pb.long, pb.short, pb.description) + }, + } + } + + pub fn parse< + 'b, + A: AsRef, + B: From<&'b str>, + PB: Push, + PE: Push, + >( + &mut self, + args: &'b [A], + mut out: PB, + mut errors: PE, + ) { + let mut it = args.iter(); + fn next_param<'a, A: AsRef + 'a>( + it: &mut impl Iterator, + name: &'static str, + ) -> Result<&'a str, ParseError> { + it.next() + .map(|s| s.as_ref()) + .ok_or(ParseError::MissingParamFor(name)) + } + + while let Some(arg) = it.next() { + let mut go = || { + if let Some(arg) = arg.as_ref().strip_prefix("--") { + if let Some((n, b)) = self.params.find_for_long(arg) { + return b.collect(next_param(&mut it, n)?).map_err( + |e| ParseError::InvalidParamFormat { + param: n, + expected: e, + }, + ); + }; + + match self.flags.find_for_long(arg) { + Some((_, b)) => { + **b = true; + Ok(()) + }, + None => Err(ParseError::UnknownParam(arg.to_string())), + } + } else if let Some(arg) = arg.as_ref().strip_prefix('-') { + if arg.len() > 1 { + return Err( + ParseError::SingleDashMustBeSingleCharacter( + arg.to_string(), + ), + ); + } + + let arg = arg.chars().next().unwrap(); + + if let Some((n, b)) = self.params.find_for_short(arg) { + return b.collect(next_param(&mut it, n)?).map_err( + |e| ParseError::InvalidParamFormat { + param: n, + expected: e, + }, + ); + }; + + match self.flags.find_for_short(arg) { + Some((_, b)) => { + **b = true; + Ok(()) + }, + None => Err(ParseError::UnknownParam(arg.to_string())), + } + } else { + out.push(arg.as_ref().into()); + Ok(()) + } + }; + + if let Some(err) = go().err() { + errors.push(err); + } + } + } + + pub fn parse_env, PE: Push>( + &mut self, + out: PS, + errors: PE, + ) { + let env_args = std::env::args().collect::>(); + self.parse(env_args.as_slice(), out, errors); + } +} + +enum Kind<'a> { + Param(&'a mut dyn Collect), + Flag(&'a mut bool), +} +pub struct ParamBuilder<'a> { + kind: Kind<'a>, + long: &'static str, + description: Option<&'static str>, + short: Option, +} + +pub fn param<'a>( + long: &'static str, + v: &'a mut dyn Collect, +) -> ParamBuilder<'a> { + ParamBuilder { + kind: Kind::Param(v), + long, + description: default(), + short: default(), + } +} + +pub fn flag<'a>(long: &'static str, v: &'a mut bool) -> ParamBuilder<'a> { + ParamBuilder { + kind: Kind::Flag(v), + long, + description: default(), + short: default(), + } +} + +impl<'a> ParamBuilder<'a> { + pub fn short(mut self, s: char) -> Self { + self.short = Some(s); + self + } + + pub fn description(mut self, d: &'static str) -> Self { + self.description = Some(d); + self + } + + pub fn bind(self, cli: &mut Cli<'a>) { + cli.bind(self).unwrap() + } + + pub fn bind_checked(self, cli: &mut Cli<'a>) -> Result<(), BindError> { + cli.bind(self) + } +} + +#[cfg(test)] +mod test { + use crate::{flag, param, Cli}; + use e::default; + + #[test] + fn basic() { + let mut cli = Cli::new("test"); + cli.description("A simple test CLI"); + cli.version("0.0.0"); + + let mut foo = String::new(); + param("foo", &mut foo) + .short('f') + .description("foo") + .bind(&mut cli); + + let mut bar: u64 = 0; + param("bar", &mut bar).description("bar").bind(&mut cli); + + let mut baz: bool = false; + flag("baz", &mut baz) + .short('b') + .description("baz") + .bind(&mut cli); + + let mut quux: bool = false; + assert!(flag("quux", &mut quux) + .short('b') + .bind_checked(&mut cli) + .is_err()); + + let mut args: Vec<&str> = Vec::new(); + let mut errors = Vec::new(); + cli.parse( + &["--foo", "foo", "--bar", "854", "-b", "arg"], + &mut args, + &mut errors, + ); + assert_eq!(foo, "foo"); + assert_eq!(bar, 854); + assert!(baz); + assert_eq!(args, &["arg"]); + assert!(errors.is_empty()); + } + + #[test] + fn complex_types() { + let mut cli = Cli::new("test"); + + let mut foos: Vec = default(); + param("foo", &mut foos).bind(&mut cli); + + let mut bar: Option = default(); + param("bar", &mut bar).bind(&mut cli); + + cli.parse::<_, String, _, _>( + &["--foo", "test", "--foo", "test2", "--bar", "bar"], + (), + (), + ); + + assert_eq!(foos, &["test", "test2"]); + assert_eq!(bar, Some("bar".to_string())); + } +}