From e8873d44ffd679fc32c0eaa2bc1b7c2d534c5124 Mon Sep 17 00:00:00 2001 From: MarcusGrass <34198073+MarcusGrass@users.noreply.github.com> Date: Tue, 7 Nov 2023 23:41:12 +0100 Subject: [PATCH] Implement simple-ish proc macro cli (#25) --- .local/lint-all.sh | 1 + .local/test-all.sh | 3 +- Cargo.lock | 7 + Cargo.toml | 2 +- rusl/Changelog.md | 8 +- rusl/src/macros.rs | 22 + rusl/src/string/unix_str.rs | 53 +- tiny-cli/Cargo.toml | 11 + tiny-cli/Changelog.md | 17 + tiny-cli/src/derive_struct.rs | 603 ++++++++++++++++++++++ tiny-cli/src/derive_struct/impl_struct.rs | 309 +++++++++++ tiny-cli/src/lib.rs | 136 +++++ tiny-cli/src/subcommand.rs | 315 +++++++++++ tiny-cli/tests/derive_test.rs | 590 +++++++++++++++++++++ tiny-std/Cargo.toml | 2 + tiny-std/Changelog.md | 8 + tiny-std/src/lib.rs | 1 + tiny-std/src/unix.rs | 2 + tiny-std/src/unix/cli.rs | 149 ++++++ 19 files changed, 2234 insertions(+), 5 deletions(-) create mode 100644 tiny-cli/Cargo.toml create mode 100644 tiny-cli/Changelog.md create mode 100644 tiny-cli/src/derive_struct.rs create mode 100644 tiny-cli/src/derive_struct/impl_struct.rs create mode 100644 tiny-cli/src/lib.rs create mode 100644 tiny-cli/src/subcommand.rs create mode 100644 tiny-cli/tests/derive_test.rs create mode 100644 tiny-std/src/unix/cli.rs diff --git a/.local/lint-all.sh b/.local/lint-all.sh index e125ec5..7a5b7e9 100755 --- a/.local/lint-all.sh +++ b/.local/lint-all.sh @@ -12,3 +12,4 @@ cargo fmt --all --check cd ../.. /bin/sh .local/rusl-lint.sh /bin/sh .local/tiny-std-lint.sh +cargo clippy -p tiny-cli diff --git a/.local/test-all.sh b/.local/test-all.sh index 932ceec..41a72ca 100755 --- a/.local/test-all.sh +++ b/.local/test-all.sh @@ -1,4 +1,5 @@ #!/bin/sh set -ex /bin/sh .local/rusl-test.sh -/bin/sh .local/tiny-std-test.sh \ No newline at end of file +/bin/sh .local/tiny-std-test.sh +cargo test -p tiny-cli \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4355324..b861092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,13 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010e18bd3bfd1d45a7e666b236c78720df0d9a7698ebaa9c1c559961eb60a38b" +[[package]] +name = "tiny-cli" +version = "0.1.0" +dependencies = [ + "tiny-std", +] + [[package]] name = "tiny-start" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index aad40e4..bcdfff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] resolver = "2" -members = ["rusl", "tiny-start", "tiny-std"] +members = ["rusl", "tiny-cli", "tiny-start", "tiny-std"] exclude = ["test-runners/alloc-st-main", "test-runners/no-alloc-main", "test-runners/threaded-main", "test-runners/test-lib"] \ No newline at end of file diff --git a/rusl/Changelog.md b/rusl/Changelog.md index 06e8039..ed5bc42 100644 --- a/rusl/Changelog.md +++ b/rusl/Changelog.md @@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Fixed +### Added + +### Changed + +## [v0.2.2] ### Added - Utility methods for `UnixStr` to make it easier to navigate them as paths - Find-method for `UnixStr` - Accessors for some inner fields of `Statx` - -### Changed +- `unix_lit!` macros ## [v0.2.1] - 2023-10-01 diff --git a/rusl/src/macros.rs b/rusl/src/macros.rs index ff8fe24..db32761 100644 --- a/rusl/src/macros.rs +++ b/rusl/src/macros.rs @@ -116,3 +116,25 @@ macro_rules! expect_errno { assert_eq!($errno, $crate::errno_or_throw!($res)); }; } + +/// comp-time-checked null-terminated string literal, adds null terminator. +/// Will fail to compile if invalid. +#[macro_export] +macro_rules! unix_lit { + ($lit: literal) => {{ + const __LIT_VAL: &$crate::string::unix_str::UnixStr = + $crate::string::unix_str::UnixStr::from_str_checked(concat!($lit, "\0")); + __LIT_VAL + }}; +} + +#[cfg(test)] +mod tests { + use crate::string::unix_str::UnixStr; + + #[test] + fn lit_macro() { + let my_var = unix_lit!("hello"); + assert_eq!(UnixStr::try_from_str("hello\0").unwrap(), my_var); + } +} diff --git a/rusl/src/string/unix_str.rs b/rusl/src/string/unix_str.rs index aa35b10..ce8f99a 100644 --- a/rusl/src/string/unix_str.rs +++ b/rusl/src/string/unix_str.rs @@ -141,6 +141,7 @@ impl UnixStr { /// # Safety /// `&str` needs to be null terminated or downstream UB may occur + #[inline] #[must_use] pub const unsafe fn from_str_unchecked(s: &str) -> &Self { core::mem::transmute(s) @@ -148,6 +149,7 @@ impl UnixStr { /// # Safety /// `&[u8]` needs to be null terminated or downstream UB may occur + #[inline] #[must_use] pub const unsafe fn from_bytes_unchecked(s: &[u8]) -> &Self { core::mem::transmute(s) @@ -212,7 +214,7 @@ impl UnixStr { /// Get this `&UnixStr` as a slice, including the null byte #[inline] #[must_use] - pub fn as_slice(&self) -> &[u8] { + pub const fn as_slice(&self) -> &[u8] { &self.0 } @@ -313,6 +315,36 @@ impl UnixStr { true } + /// Get the last component of a path, if possible. + /// + /// # Example + /// ``` + /// use rusl::string::unix_str::UnixStr; + /// use rusl::unix_lit; + /// fn get_file_paths() { + /// // Has no filename, just a root path + /// let a = unix_lit!("/"); + /// assert_eq!(None, a.path_file_name()); + /// // Has a 'filename' + /// let a = unix_lit!("/etc"); + /// assert_eq!(Some(unix_lit!("etc")), a.path_file_name()); + /// } + /// ``` + #[must_use] + #[allow(clippy::borrow_as_ptr)] + pub fn path_file_name(&self) -> Option<&UnixStr> { + for (ind, byte) in self.0.iter().enumerate().rev() { + if *byte == b'/' { + return if ind + 2 < self.len() { + unsafe { Some(&*(&self.0[ind + 1..] as *const [u8] as *const Self)) } + } else { + None + }; + } + } + None + } + /// Joins this [`UnixStr`] with some other [`UnixStr`] adding a slash if necessary. /// Will make sure that there's at most one slash at the boundary but won't check /// either string for "path validity" in any other case @@ -768,6 +800,25 @@ mod tests { assert_eq!("hello/there", new.as_str().unwrap()); } + #[test] + fn can_get_last_path_happy() { + let base = unix_lit!("a/b/c"); + let res = base.path_file_name().unwrap(); + let expect = unix_lit!("c"); + assert_eq!(expect, res); + } + + #[test] + fn can_get_last_path_root_gives_none() { + let base = unix_lit!("/"); + assert!(base.path_file_name().is_none()); + } + + #[test] + fn can_get_last_path_empty_gives_none() { + assert!(UnixStr::EMPTY.path_file_name().is_none()); + } + #[test] fn find_parent_path_happy() { let a = UnixStr::from_str_checked("hello/there/friend\0"); diff --git a/tiny-cli/Cargo.toml b/tiny-cli/Cargo.toml new file mode 100644 index 0000000..e8e8c3f --- /dev/null +++ b/tiny-cli/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tiny-cli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +proc-macro = true + +[dev-dependencies] +tiny-std = { path = "../tiny-std", features = ["alloc", "cli"]} \ No newline at end of file diff --git a/tiny-cli/Changelog.md b/tiny-cli/Changelog.md new file mode 100644 index 0000000..f1cd9a6 --- /dev/null +++ b/tiny-cli/Changelog.md @@ -0,0 +1,17 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [Unreleased] +### Fixed + +### Added + +### Changed + +## [v0.1.0] - 2023-10-01 + +### Added +- Tiny-cli ArgParser diff --git a/tiny-cli/src/derive_struct.rs b/tiny-cli/src/derive_struct.rs new file mode 100644 index 0000000..35320db --- /dev/null +++ b/tiny-cli/src/derive_struct.rs @@ -0,0 +1,603 @@ +use crate::derive_struct::impl_struct::CodeWriter; +use crate::{ + pop_expect_ident, pop_expect_punct, pop_group, pop_ident, pop_lit, try_extract_doc_comment, +}; +use proc_macro::{Group, Ident, Punct, TokenStream, TokenTree}; +use std::fmt::Write; +use std::str::FromStr; + +mod impl_struct; + +#[inline] +pub(crate) fn do_derive(struct_candidate: TokenStream) -> TokenStream { + let mut state = StructTreeParse::new(); + for tree in struct_candidate { + check_state(&mut state, tree); + if let StructTreeParseState::FoundGroup(name, group) = &state.state { + return parse_group(name, &state.metadata, group); + } + } + panic!("[ArgParse derive] Failed to derive arg_parse, failed to find struct information"); +} + +#[derive(Debug)] +struct StructTreeParse { + metadata: StructMetadata, + state: StructTreeParseState, +} + +#[derive(Debug, Clone)] +pub(crate) struct StructMetadata { + doc_comments: Vec, + help_info: Vec, +} + +impl StructTreeParse { + fn new() -> Self { + Self { + metadata: StructMetadata { + doc_comments: vec![], + help_info: vec![], + }, + state: StructTreeParseState::None, + } + } +} + +#[derive(Debug, Clone)] +enum StructTreeParseState { + None, + SeenStruct, + SeenName(String), + FoundGroup(String, Group), +} + +fn check_state(state: &mut StructTreeParse, tree: TokenTree) { + match tree { + TokenTree::Group(g) => match state.state.clone() { + StructTreeParseState::SeenName(n) => { + state.state = StructTreeParseState::FoundGroup(n.clone(), g); + } + StructTreeParseState::None + | StructTreeParseState::SeenStruct + | StructTreeParseState::FoundGroup(_, _) => { + try_extract_more_metadata(&mut state.metadata, &g); + } + }, + TokenTree::Ident(i) => match state.state.clone() { + StructTreeParseState::None => { + if matches!(i.to_string().as_str(), "struct") { + state.state = StructTreeParseState::SeenStruct; + } + } + StructTreeParseState::SeenStruct => { + state.state = StructTreeParseState::SeenName(i.to_string()); + } + StructTreeParseState::SeenName(_) | StructTreeParseState::FoundGroup(_, _) => { + panic!("[ArgParse derive] Inconsistent state expected state to be `SeenName` (this is a bug)."); + } + }, + TokenTree::Punct(_) | TokenTree::Literal(_) => {} + } +} + +fn try_extract_more_metadata(meta: &mut StructMetadata, group: &Group) { + if let Some(cmnt) = try_extract_doc_comment(group) { + meta.doc_comments.push(cmnt); + return; + } + let mut stream = group.stream().into_iter(); + if let Some(TokenTree::Ident(ident)) = stream.next() { + let to_string = ident.to_string(); + if "cli" == to_string { + let inner_group = pop_group( + &mut stream, + format!("[ArgParse derive] Expected to find a group following #[cli in {group}"), + ); + let mut inner_group_stream = inner_group.stream().into_iter(); + while let Some((k, v)) = extract_struct_level_cli_properties(&mut inner_group_stream) { + match k.as_str() { + "help_path" => { + let help_info = v + .split(',') + .filter_map(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect::>(); + meta.help_info = help_info; + } + val => { + panic!("[ArgParse derive] Unrecognized argument placed in struct declaration #[cli(... expected 'help_path' found {val}"); + } + } + } + } + } +} + +fn extract_struct_level_cli_properties( + g_it: &mut impl Iterator, +) -> Option<(String, String)> { + if let Some(mut next_token_tree) = g_it.next() { + if let TokenTree::Punct(p) = next_token_tree { + if p.as_char() == ',' { + if let Some(next) = g_it.next() { + next_token_tree = next; + } else { + // Trailing comma + return None; + } + } else { + panic!("[ArgParse derive] only punctuation expected within #[cli(... is '=' between keys and values, found {}", p.as_char()); + } + } + let TokenTree::Ident(key) = next_token_tree else { + panic!("[ArgParse derive] Expected to find arguments inside #[cli on form #[cli(k = \"val\", ...)") + }; + pop_expect_punct(g_it, '=', "[ArgParse derive] Expected to find punctuation '=' after ident when parsing #[cli(k ('=')..."); + let val = pop_lit(g_it, "[ArgParse derive] Expected ident and '=' to be followed by a literal in the form #[cli(k = \"val\"..."); + let val_string = val.to_string(); + let val_string = val_string.trim().trim_matches('"').trim().to_string(); + return Some((key.to_string(), val_string)); + } + None +} + +#[derive(Debug, Clone)] +enum ArgsParsedTreeParseState { + Ready, + WantsAnnotation, + WantsSubcommand, + WantsMember(CliPreferences), +} +fn parse_group(name: &str, metadata: &StructMetadata, g: &Group) -> TokenStream { + let mut stream = g.stream().into_iter(); + let mut state = ArgsParsedTreeParseState::Ready; + let mut doc_comments_for_next = Vec::new(); + let mut members = Vec::new(); + let mut subcommand = None; + let mut c = CodeWriter::new(name, metadata); + while let Some(tree) = stream.next() { + match &tree { + TokenTree::Group(g) => match state { + ArgsParsedTreeParseState::WantsAnnotation => { + let res = parse_annotation_group(g); + match res { + GroupParseResult::Ignore => {} + GroupParseResult::DocComment(com) => { + doc_comments_for_next.push(com); + } + GroupParseResult::SubCommand => { + state = ArgsParsedTreeParseState::WantsSubcommand; + continue; + } + GroupParseResult::FieldPreferences(prefs) => { + state = ArgsParsedTreeParseState::WantsMember(prefs); + continue; + } + } + state = ArgsParsedTreeParseState::Ready; + continue; + } + ArgsParsedTreeParseState::WantsSubcommand + | ArgsParsedTreeParseState::WantsMember(_) + | ArgsParsedTreeParseState::Ready => { + panic!("[ArgParse derive] Bad state ready encountering group, expected ReadyParseAnnotation") + } + }, + TokenTree::Ident(ident) => match state.clone() { + ArgsParsedTreeParseState::WantsAnnotation => { + panic!("[ArgParse derive] Bad state encountering ident, expected Ready") + } + ArgsParsedTreeParseState::WantsSubcommand => { + let mem = parse_member(ident, &mut stream); + let field_ty = match mem.ty { + FieldTy::UnixStr | FieldTy::Str => { + panic!("[ArgParse derive] Invalid type for subcommand"); + } + FieldTy::Unknown(ty) => ty, + }; + assert!(subcommand.is_none(), "Found multiple subcommands"); + doc_comments_for_next = Vec::new(); + let sc = ParsedSubcommand { + field_name: mem.name, + field_ty, + is_opt: matches!(mem.package, FieldPackageKind::Option), + }; + c.push_subcommand(&sc); + subcommand = Some(sc); + state = ArgsParsedTreeParseState::Ready; + } + ArgsParsedTreeParseState::Ready => { + let mem = parse_member(ident, &mut stream); + let pf = ParsedField { + doc_comments: core::mem::take(&mut doc_comments_for_next), + name: mem.name.clone(), + ty: mem.ty, + is_ref: mem.is_ref, + package: mem.package, + long_match: mem.name, + short_match: None, + }; + c.push_field(&pf); + members.push(pf); + } + ArgsParsedTreeParseState::WantsMember(p) => { + let mem = parse_member(ident, &mut stream); + let pf = ParsedField { + doc_comments: core::mem::take(&mut doc_comments_for_next), + name: mem.name.clone(), + ty: mem.ty, + is_ref: mem.is_ref, + package: mem.package, + long_match: p.long.unwrap_or(mem.name), + short_match: p.short, + }; + c.push_field(&pf); + members.push(pf); + state = ArgsParsedTreeParseState::Ready; + } + }, + TokenTree::Punct(p) => { + if p.as_char() == '#' { + state = ArgsParsedTreeParseState::WantsAnnotation; + } + } + TokenTree::Literal(_) => {} + } + } + let out = c.finish(); + TokenStream::from_str(&out) + .expect("[ArgParse derive] Failed to convert generated struct to token stream") +} + +fn parse_annotation_group(g: &Group) -> GroupParseResult { + let mut group_stream = g.stream().into_iter(); + let first = group_stream + .next() + .expect("[ArgParse derive] Expected at least one item in group"); + let TokenTree::Ident(ident) = first else { + panic!("[ArgParse derive] Expected first item in gorup to be an ident"); + }; + match ident.to_string().as_str() { + "doc" => { + pop_expect_punct( + &mut group_stream, + '=', + "Expected doc group with a '=' punctuation".to_string(), + ); + let lit = pop_lit( + &mut group_stream, + "Expected doc group to contain a literal after '='", + ); + GroupParseResult::DocComment( + lit.to_string() + .trim_matches(|ch: char| ch == '"' || ch.is_whitespace()) + .to_string(), + ) + } + "cli" => { + let g = pop_group(&mut group_stream, "Expected cli to be followed by a group starting with (. ex: #[cli( long= \"my-arg\")]"); + let mut g_stream = g.stream().into_iter(); + let mut preferred_short = None; + let mut preferred_long = None; + while let Some(next_item) = g_stream.next() { + let ident = match next_item { + TokenTree::Ident(i) => i, + TokenTree::Group(_) | TokenTree::Punct(_) | TokenTree::Literal(_) => { + continue; + } + }; + match ident.to_string().trim() { + "short" => { + assert!( + preferred_short.is_none(), + "Found multiple cli(short) in struct" + ); + pop_expect_punct( + &mut g_stream, + '=', + "Expected 'short' in #[cli(short... to be followed by an =", + ); + let short_lit = pop_lit( + &mut g_stream, + "Expected a literal in #[cli(short = \"\"...", + ) + .to_string() + .trim_matches('\"') + .to_string(); + assert_eq!(1, short_lit.chars().count(), "Expected short literal in #[cli(short = \"\"... to be a single character, got {short_lit}"); + preferred_short = Some(short_lit.to_string()); + } + "long" => { + assert!( + preferred_long.is_none(), + "Found multiple cli(long) in struct" + ); + pop_expect_punct( + &mut g_stream, + '=', + "Expected 'long' in #[cli(long... to be followed by an =", + ); + let long_lit = pop_lit( + &mut g_stream, + "Expected a literal in #[cli(long = \"\"...", + ) + .to_string() + .trim_matches('\"') + .to_string(); + preferred_long = Some(long_lit); + } + "subcommand" => { + assert!(!(preferred_long.is_some() || preferred_short.is_some()), "Found both subcommand and long/short on the same field, subcommands are named by their enum tags"); + return GroupParseResult::SubCommand; + } + v => panic!("[ArgParse derive] Expected cli group ident to be either 'long' or 'short' got {v}"), + } + } + + GroupParseResult::FieldPreferences(CliPreferences { + long: preferred_long, + short: preferred_short, + }) + } + _ => GroupParseResult::Ignore, + } +} + +fn parse_member>(ident: &Ident, it: &mut I) -> ParsedMember { + let field_name = ident.to_string(); + pop_expect_punct(it, ':', "Failed to parse member, expected ':' punctuation"); + let next = it + .next() + .expect("[ArgParse derive] Failed to parse member, expected a type"); + match next { + TokenTree::Ident(id) => { + let trimmed_name = id.to_string(); + + match trimmed_name.as_str() { + "Vec" => { + pop_expect_punct( + it, + '<', + format!("Expected a '<' following vec declaration for {field_name}"), + ); + let next = it.next().unwrap_or_else(|| { + panic!( + "[ArgParse derive] Expected something following '<' in vec declaration for {field_name}" + ) + }); + match next { + TokenTree::Ident(ident) => { + let ty = FieldTy::from_ident(&ident); + ParsedMember { + name: field_name, + ty, + is_ref: false, + package: FieldPackageKind::Vec, + } + } + TokenTree::Punct(p) => { + parse_static_ref(it, &p, field_name, FieldPackageKind::Vec) + } + t => { + panic!("Found unexpected token {t:?} parsing {field_name}"); + } + } + } + "Option" => { + pop_expect_punct( + it, + '<', + format!("Expected a '<' following optional declaration for {field_name}"), + ); + let next = it.next().unwrap_or_else(|| panic!("Expected something following '<' in optional declaration for {field_name}")); + match next { + TokenTree::Ident(ident) => { + let ty = FieldTy::from_ident(&ident); + ParsedMember { + name: field_name, + ty, + is_ref: false, + package: FieldPackageKind::Option, + } + } + TokenTree::Punct(p) => { + parse_static_ref(it, &p, field_name, FieldPackageKind::Option) + } + t => { + panic!("Found unexpected token {t:?} parsing {field_name}"); + } + } + } + _ty => { + let ty = FieldTy::from_ident(&id); + ParsedMember { + name: field_name, + ty, + is_ref: false, + package: FieldPackageKind::None, + } + } + } + } + TokenTree::Punct(p) => parse_static_ref(it, &p, field_name, FieldPackageKind::None), + TokenTree::Group(_) | TokenTree::Literal(_) => { + panic!("Expected ident or punct when parsing tiny-cli annotated struct, found group or literal") + } + } +} + +fn parse_static_ref>( + it: &mut I, + p: &Punct, + field_name: String, + package: FieldPackageKind, +) -> ParsedMember { + assert_eq!( + '&', + p.as_char(), + "Expected a reference '&' or an ident after ':' for {field_name}" + ); + pop_expect_punct( + it, + '\'', + format!("Expected a 'static after '&' for {field_name}"), + ); + pop_expect_ident( + it, + "static", + format!("Expected a &'static after '&' for {field_name}"), + ); + let ident = pop_ident( + it, + format!("Expected an ident after &'static for {field_name}"), + ); + let ty = FieldTy::from_ident(&ident); + ParsedMember { + name: field_name, + ty, + is_ref: true, + package, + } +} + +#[derive(Clone, Debug)] +enum GroupParseResult { + Ignore, + DocComment(String), + SubCommand, + FieldPreferences(CliPreferences), +} + +#[derive(Debug, Clone)] +struct CliPreferences { + long: Option, + short: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ParsedMember { + name: String, + ty: FieldTy, + is_ref: bool, + package: FieldPackageKind, +} + +#[derive(Clone, Debug)] +pub(crate) struct ParsedField { + doc_comments: Vec, + name: String, + ty: FieldTy, + is_ref: bool, + package: FieldPackageKind, + long_match: String, + short_match: Option, +} + +impl ParsedField { + pub(crate) fn long_const_ident(&self) -> String { + self.long_match.replace('-', "_").to_uppercase() + } + pub(crate) fn long_match_lit(&self) -> String { + self.long_match.to_lowercase().replace('_', "-") + } + + pub(crate) fn short_const_ident(&self) -> Option { + self.short_match + .as_ref() + .map(|s| s.replace('-', "_").to_uppercase()) + } + + pub(crate) fn short_match_lit(&self) -> Option { + self.short_match + .as_ref() + .map(|s| s.to_lowercase().replace('_', "-")) + } + + pub(crate) fn as_const_match(&self) -> String { + if let Some(short) = self.short_const_ident() { + format!("{} | {}", short, self.long_const_ident()) + } else { + self.long_const_ident().to_string() + } + } + + pub(crate) fn type_decl(&self) -> String { + match &self.ty { + FieldTy::UnixStr => "&'static tiny_std::UnixStr".to_string(), + FieldTy::Str => "&'static str".to_string(), + FieldTy::Unknown(ty) => { + if self.is_ref { + format!("&'static {ty}") + } else { + ty.clone() + } + } + } + } + + pub(crate) fn as_lit_match(&self) -> String { + let mut help_row = String::new(); + if let Some(short_lit) = self.short_match_lit() { + let _ = + help_row.write_fmt(format_args!("-{} | --{}", short_lit, self.long_match_lit())); + } else { + let _ = help_row.write_fmt(format_args!("--{}", self.long_match_lit())); + } + help_row + } + + pub(crate) fn write_into_help(&self, help_row: &mut String) { + if let Some(short_lit) = self.short_match_lit() { + let _ = help_row.write_fmt(format_args!( + " -{}, --{}\n", + short_lit, + self.long_match_lit() + )); + } else { + let _ = help_row.write_fmt(format_args!(" --{}\n", self.long_match_lit())); + } + for dc in &self.doc_comments { + let _ = help_row.write_fmt(format_args!(" {dc}\n")); + } + let _ = help_row.write_char('\n'); + } +} + +#[derive(Clone, Debug)] +pub(crate) enum FieldTy { + UnixStr, + Str, + Unknown(String), +} + +impl FieldTy { + fn from_ident(ident: &Ident) -> Self { + let trimmed_ident = ident.to_string().trim().to_string(); + match trimmed_ident.as_str() { + "UnixStr" => Self::UnixStr, + "str" => Self::Str, + &_ => Self::Unknown(trimmed_ident), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ParsedSubcommand { + field_name: String, + field_ty: String, + is_opt: bool, +} + +#[derive(Debug, Copy, Clone)] +pub(crate) enum FieldPackageKind { + None, + Vec, + Option, +} diff --git a/tiny-cli/src/derive_struct/impl_struct.rs b/tiny-cli/src/derive_struct/impl_struct.rs new file mode 100644 index 0000000..6b98778 --- /dev/null +++ b/tiny-cli/src/derive_struct/impl_struct.rs @@ -0,0 +1,309 @@ +use crate::derive_struct::{ + FieldPackageKind, FieldTy, ParsedField, ParsedSubcommand, StructMetadata, +}; +use std::fmt::Write; + +pub(crate) struct CodeWriter { + printer_head: String, + printer_mid: String, + printer_opts: Option, + sc_print_fmt: Option, + impl_head: String, + var_decl_head: String, + match_head: String, + match_tail: Option, + struct_out: String, +} + +impl CodeWriter { + pub(crate) fn new(name: &str, pkg_meta: &StructMetadata) -> Self { + let help_printer_name = format!("__{name}HelpPrinterZst"); + let mut printer_mid = String::new(); + for dc in &pkg_meta.doc_comments { + let _ = printer_mid.write_fmt(format_args!("{dc}\n")); + } + if !pkg_meta.doc_comments.is_empty() { + printer_mid.push('\n'); + } + let _ = printer_mid.write_str("Usage:"); + for info in &pkg_meta.help_info { + let _ = printer_mid.write_fmt(format_args!(" {info}")); + } + Self { + printer_head: gen_printer_head(&help_printer_name), + printer_mid, + printer_opts: None, + sc_print_fmt: None, + impl_head: gen_impl_head(name, &help_printer_name), + var_decl_head: String::new(), + match_head: String::new(), + match_tail: None, + struct_out: String::new(), + } + } + pub(crate) fn push_field(&mut self, field: &ParsedField) { + self.field_push_const_match(field); + self.field_push_var_decl(field); + self.field_push_to_match(field); + self.field_push_to_out(field); + self.field_push_printer(field); + } + + pub(crate) fn push_subcommand(&mut self, sc: &ParsedSubcommand) { + self.subcommand_push_var_decl(sc); + self.subcommand_push_match_tail(sc); + self.subcommand_push_to_out(sc); + self.sc_print_fmt = Some(sc.field_ty.clone()); + } + + fn field_push_printer(&mut self, field: &ParsedField) { + if self.printer_opts.is_none() { + self.printer_opts = Some("\nOptions:\n".to_string()); + } + let Some(opts) = self.printer_opts.as_mut() else { + unreachable!() + }; + field.write_into_help(opts); + } + + fn field_push_const_match(&mut self, field: &ParsedField) { + let _ = self.impl_head.write_fmt(format_args!( + "\t\tconst {}: &[u8] = tiny_std::UnixStr::from_str_checked(\"--{}\\0\").as_slice();\n", + field.long_const_ident(), + field.long_match_lit() + )); + if let (Some(short_const), Some(short_match)) = + (field.short_const_ident(), field.short_match_lit()) + { + let _ = self.impl_head.write_fmt(format_args!("\t\tconst {short_const}: &[u8] = tiny_std::UnixStr::from_str_checked(\"-{short_match}\\0\").as_slice();\n")); + } + } + + fn field_push_var_decl(&mut self, field: &ParsedField) { + match field.package { + FieldPackageKind::None | FieldPackageKind::Option => { + let _ = self.var_decl_head.write_fmt(format_args!( + "\t\tlet mut {}: Option<{}> = None;\n", + field.name, + field.type_decl() + )); + } + FieldPackageKind::Vec => { + let _ = self.var_decl_head.write_fmt(format_args!( + "\t\tlet mut {}: Vec<{}> = Vec::new();\n", + field.name, + field.type_decl() + )); + } + } + } + + #[inline] + fn subcommand_push_var_decl(&mut self, sc: &ParsedSubcommand) { + let _ = self.var_decl_head.write_fmt(format_args!( + "\t\tlet mut {}: Option<{}> = None;\n", + sc.field_name, sc.field_ty + )); + } + + fn field_push_to_match(&mut self, field: &ParsedField) { + let asgn = member_try_assign(field); + let _ = self.match_head.write_fmt(format_args!( + "\ +\t\t\t\t{} => {{ + {asgn}; + }}, +", + field.as_const_match() + )); + } + + fn subcommand_push_match_tail(&mut self, sc: &ParsedSubcommand) { + let match_tail = format!("\ + if let Some(sc_parsed) = <{} as tiny_std::unix::cli::SubcommandParse>::subcommand_parse(next, args)? {{ + {} = Some(sc_parsed); + }} else {{ + return Err(tiny_std::unix::cli::ArgParseError::new_cause_fmt(Self::help_printer(), format_args!(\"Unrecognized argument: {{:?}}\", core::str::from_utf8(no_match)))?); + }} + ", sc.field_ty, sc.field_name); + self.match_tail = Some(match_tail); + } + + fn field_push_to_out(&mut self, field: &ParsedField) { + if matches!(field.package, FieldPackageKind::None) { + let _ = self.struct_out.write_fmt(format_args!("\ + {}: {{ + if let Some(found_arg) = {} {{ + found_arg + }} else {{ + return Err(tiny_std::unix::cli::ArgParseError::new_cause_str(Self::help_printer(), \"Required option '{}' not supplied.\")?); + }} + }}, + ", field.name, field.name, field.as_lit_match())); + } else { + let _ = self.struct_out.write_fmt(format_args!( + "\ +{}, +", + field.name + )); + } + } + + fn subcommand_push_to_out(&mut self, sc: &ParsedSubcommand) { + if sc.is_opt { + let _ = self + .struct_out + .write_fmt(format_args!("\t\t\t{},\n", sc.field_name)); + } else { + let _ = self.struct_out.write_fmt(format_args!("\ + {}: {{ + if let Some(found_arg) = {} {{ + found_arg + }} else {{ + return Err(tiny_std::unix::cli::ArgParseError::new_cause_fmt(Self::help_printer(), format_args!(\"Required command '{{}}' not supplied.\", {}::__command_lit_matches()))?); + }} + }}, +", sc.field_name, sc.field_name, sc.field_ty)); + } + } + + pub(crate) fn finish(mut self) -> String { + let mut output = String::new(); + if self.printer_opts.is_some() { + let _ = self.printer_mid.write_str(" [OPTIONS]"); + } + let has_subcommand = self.match_tail.is_some(); + if has_subcommand { + let _ = self.printer_mid.write_str(" [COMMAND]\n\n{}"); + } else { + let _ = self.printer_mid.write_str("\n"); + } + + let _ = output.write_str(&self.printer_head); + if let Some(sc_ty) = self.sc_print_fmt { + if let Some(opts) = &self.printer_opts { + let _ = output.write_fmt(format_args!("\t\tf.write_fmt(format_args!(\"{}{}\", <{} as tiny_std::unix::cli::SubcommandParse>::help_printer()))\n", self.printer_mid, opts, sc_ty)); + } else { + let _ = output.write_fmt(format_args!("\t\tf.write_fmt(format_args!(\"{}\", <{} as tiny_std::unix::cli::SubcommandParse>::help_printer()))\n", self.printer_mid, sc_ty)); + } + } else if let Some(opts) = &self.printer_opts { + let _ = output.write_fmt(format_args!( + "\t\tf.write_str(\"{}{}\")", + self.printer_mid, opts + )); + } else { + let _ = output.write_fmt(format_args!("\t\tf.write_str(\"{}\")", self.printer_mid)); + } + let _ = output.write_str("\n\t}\n}\n"); + let _ = output.write_str(&self.impl_head); + let _ = output.write_str(&self.var_decl_head); + let _ = output + .write_str("\t\twhile let Some(next) = args.next() {\n\t\t\tmatch next.as_slice() {\n"); + if let Some(mt) = &self.match_tail { + let _ = self.match_head.write_fmt(format_args!("\ +\t\t\t\tb\"-h\\0\" | b\"--help\\0\" => {{ return Err(tiny_std::unix::cli::ArgParseError::new_cause_str(Self::help_printer(), \"\")?)}}, + no_match => {{ + {mt} + }}, +")); + } else { + let _ = self.match_head.write_str("\ +\t\t\t\tb\"-h\\0\" | b\"--help\\0\" => { return Err(tiny_std::unix::cli::ArgParseError::new_cause_str(Self::help_printer(), \"\")?) }, + no_match => { + return Err(tiny_std::unix::cli::ArgParseError::new_cause_fmt(Self::help_printer(), format_args!(\"Unrecognized argument: {:?}\", core::str::from_utf8(no_match)))?); + }, +"); + } + let _ = output.write_str(&self.match_head); + let _ = output.write_str("\t\t\t}\n\t\t}\n"); + let _ = output.write_fmt(format_args!( + "\t\tOk(Self {{\n\t\t\t{}\t\t}})\n\t}}\n}}\n", + &self.struct_out + )); + output + } +} +fn gen_printer_head(help_printer_name: &str) -> String { + format!( + "\ +pub struct {help_printer_name}; +impl core::fmt::Display for {help_printer_name} {{ + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {{ +" + ) +} + +fn gen_impl_head(name: &str, help_printer_name: &str) -> String { + format!("\ +impl tiny_std::unix::cli::ArgParse for {name} {{ + type HelpPrinter = {help_printer_name}; + #[inline] + fn help_printer() -> &'static Self::HelpPrinter {{ + &{help_printer_name} + }} + fn arg_parse(args: &mut impl Iterator) -> core::result::Result {{ +") +} +fn member_try_assign(m: &ParsedField) -> String { + match m.package { + FieldPackageKind::None | FieldPackageKind::Option => { + format!("{} = Some({})", m.name, member_as_convert(m)) + } + FieldPackageKind::Vec => { + format!("{}.push({})", m.name, member_as_convert(m)) + } + } +} + +fn member_as_convert(m: &ParsedField) -> String { + let mut out = String::from("{\n"); + let _ = out.write_str("\t\t\t\t\t\tlet Some(next_arg) = args.next() else {\n"); + let _ = out.write_fmt(format_args!( + "\t\t\t\t\t\t\treturn Err(tiny_std::unix::cli::ArgParseError::new_cause_str(Self::help_printer(), \"Expected argument following '{}'.\")?);\n", + m.as_lit_match() + )); + let _ = out.write_str("\t\t\t\t\t\t};\n"); + match &m.ty { + FieldTy::UnixStr => { + let _ = out.write_str("\t\t\t\t\t\tnext_arg\n"); + let _ = out.write_str("\t\t\t\t\t\t}"); + } + FieldTy::Str => { + let _ = out.write_str("\t\t\t\t\t\tmatch next_arg.as_str() {\n"); + let _ = out.write_str("\t\t\t\t\t\t\tOk(s) => s,\n"); + let _ = out.write_str("\t\t\t\t\t\t\tErr(e) => {\n"); + let _ = out.write_fmt(format_args!( + "\t\t\t\t\t\t\t\treturn Err(tiny_std::unix::cli::ArgParseError::new_cause_str(Self::help_printer(), \"Failed to parse argument at '{}' as utf8-str\")?);\n", + m.as_lit_match() + )); + let _ = out.write_str("\t\t\t\t\t\t\t},\n"); + + let _ = out.write_str("\t\t\t\t\t\t}\n"); + let _ = out.write_str("\t\t\t\t\t}"); + } + FieldTy::Unknown(ty) => { + let _ = out.write_str("\t\t\t\t\t\tlet next_str_arg = match next_arg.as_str() {\n"); + let _ = out.write_str("\t\t\t\t\t\t\tOk(s) => s,\n"); + let _ = out.write_str("\t\t\t\t\t\t\tErr(e) => {\n"); + let _ = out.write_fmt(format_args!( + "\t\t\t\t\t\t\t\treturn Err(tiny_std::unix::cli::ArgParseError::new_cause_str(Self::help_printer(), \"Failed to parse argument at '{}' as utf8-str\")?);\n", + m.as_lit_match() + )); + let _ = out.write_str("\t\t\t\t\t\t\t},\n"); + let _ = out.write_str("\t\t\t\t\t\t};\n"); + let _ = out.write_fmt(format_args!( + "\t\t\t\t\t\tmatch <{ty} as core::str::FromStr>::from_str(next_str_arg) {{\n" + )); + let _ = out.write_str("\t\t\t\t\t\t\tOk(s) => s,\n"); + let _ = out.write_str("\t\t\t\t\t\t\tErr(e) => {\n"); + let _ = out.write_fmt(format_args!( + "\t\t\t\t\t\t\t\treturn Err(tiny_std::unix::cli::ArgParseError::new_cause_fmt(Self::help_printer(), format_args!(\"Failed to convert argument at '{}' from str: {{e}}\"))?);\n", + m.as_lit_match() + )); + let _ = out.write_str("\t\t\t\t\t\t\t}\n"); + let _ = out.write_str("\t\t\t\t\t\t}\n\t\t\t\t\t}"); + } + } + out +} diff --git a/tiny-cli/src/lib.rs b/tiny-cli/src/lib.rs new file mode 100644 index 0000000..c1f0f26 --- /dev/null +++ b/tiny-cli/src/lib.rs @@ -0,0 +1,136 @@ +#![warn(clippy::pedantic)] +mod derive_struct; +mod subcommand; + +extern crate proc_macro; + +use proc_macro::{Group, Ident, Literal, TokenStream, TokenTree}; +use std::fmt::Display; + +#[proc_macro_derive(ArgParse, attributes(cli))] +pub fn derive_arg_parse(struct_candidate: TokenStream) -> TokenStream { + derive_struct::do_derive(struct_candidate) +} + +#[proc_macro_derive(Subcommand, attributes(cli))] +pub fn derive_sc_parse(struct_candidate: TokenStream) -> TokenStream { + subcommand::do_derive(struct_candidate) +} + +fn pop_expect_punct, D: Display>( + stream: &mut I, + expect: char, + err_msg: D, +) { + let punct = stream + .next() + .unwrap_or_else(|| panic!("[ArgParse derive] {}", err_msg.to_string())); + if let TokenTree::Punct(p) = punct { + assert_eq!(p.as_char(), expect, "{err_msg}"); + } else { + panic!( + "[ArgParse derive] Expected punctation with {expect}, found: {punct:?}, ctx: {err_msg}" + ); + } +} + +fn pop_expect_ident, D: Display>( + stream: &mut I, + expect: &str, + err_msg: D, +) { + let ident = pop_ident(stream, &err_msg); + assert_eq!( + expect, + ident.to_string().trim(), + "[ArgParse derive] Ident {ident} didn't match expected {expect}, ctx: {err_msg}" + ); +} + +fn pop_ident, D: Display>(stream: &mut I, err_msg: D) -> Ident { + let ident = stream + .next() + .unwrap_or_else(|| panic!("[ArgParse derive] {}", err_msg.to_string())); + if let TokenTree::Ident(ident) = ident { + ident + } else { + panic!("[ArgParse derive] Expected ident, found {ident:?}, ctx: {err_msg}"); + } +} + +fn pop_lit, D: Display>(stream: &mut I, err_msg: D) -> Literal { + let lit = stream + .next() + .unwrap_or_else(|| panic!("[ArgParse derive] {}", err_msg.to_string())); + if let TokenTree::Literal(l) = lit { + l + } else { + panic!("[ArgParse derive] Expected literal found: {lit:?}, ctx: {err_msg}"); + } +} + +fn pop_group, D: Display>(stream: &mut I, err_msg: D) -> Group { + let group = stream + .next() + .unwrap_or_else(|| panic!("[ArgParse derive] {}", err_msg.to_string())); + if let TokenTree::Group(g) = group { + g + } else { + panic!("[ArgParse derive] Expected group, found: {group:?}, ctx: {err_msg}"); + } +} + +pub(crate) fn try_extract_doc_comment(g: &Group) -> Option { + let mut stream = g.stream().into_iter(); + if let Some(TokenTree::Ident(id)) = stream.next() { + if id.to_string().trim() == "doc" { + pop_expect_punct(&mut stream, '=', "Expected a '=' after #[doc"); + let ident = pop_lit(&mut stream, "Expected #[doc = ..."); + let id_str = ident.to_string(); + let id_trimmed = id_str.trim().trim_matches('"').trim().to_string(); + return Some(id_trimmed); + } + } + None +} + +pub(crate) fn pascal_to_snake(prev: &str) -> String { + let mut new = String::new(); + let mut chars = prev.chars(); + if let Some(next) = chars.next() { + for lc in next.to_lowercase() { + new.push(lc); + } + } else { + return new; + } + for char in chars { + if char.is_uppercase() { + new.push('-'); + } + for lc in char.to_lowercase() { + new.push(lc); + } + } + new +} + +#[inline] +pub(crate) fn snake_to_scream(prev: &str) -> String { + prev.replace('-', "_").to_uppercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switch_case() { + let orig = "MyStructDecl"; + assert_eq!("my-struct-decl", pascal_to_snake(orig)); + let orig = "M"; + assert_eq!("m", pascal_to_snake(orig)); + assert_eq!("", pascal_to_snake("")); + assert_eq!("m-m", pascal_to_snake("MM")); + } +} diff --git a/tiny-cli/src/subcommand.rs b/tiny-cli/src/subcommand.rs new file mode 100644 index 0000000..d2d5079 --- /dev/null +++ b/tiny-cli/src/subcommand.rs @@ -0,0 +1,315 @@ +use crate::{pascal_to_snake, pop_ident, snake_to_scream, try_extract_doc_comment}; +use proc_macro::{Delimiter, Group, TokenStream, TokenTree}; +use std::fmt::Write; +use std::str::FromStr; + +#[inline] +pub(crate) fn do_derive(struct_candidate: TokenStream) -> TokenStream { + let mut state = EnumTreeParse { + doc_comments: vec![], + state: EnumTreeParseState::None, + }; + for tree in struct_candidate { + check_tree(&mut state, tree); + match state.state.clone() { + EnumTreeParseState::None + | EnumTreeParseState::SeenEnum + | EnumTreeParseState::SeenName(_) => {} + EnumTreeParseState::FoundGroup(name, group) => { + let out = parse_inner_enum(&name, &group); + return TokenStream::from_str(&out).expect( + "[ArgParse derive] Failed to produce a valid token stream (this is a bug)", + ); + } + } + } + TokenStream::new() +} + +struct EnumTreeParse { + doc_comments: Vec, + state: EnumTreeParseState, +} + +#[derive(Debug, Clone)] +enum EnumTreeParseState { + None, + SeenEnum, + SeenName(String), + FoundGroup(String, Group), +} + +fn check_tree(state: &mut EnumTreeParse, tree: TokenTree) { + match tree { + TokenTree::Group(g) => { + if let EnumTreeParseState::SeenName(name) = state.state.clone() { + state.state = EnumTreeParseState::FoundGroup(name, g); + } else if let Some(cmnt) = try_extract_doc_comment(&g) { + state.doc_comments.push(cmnt); + } + } + TokenTree::Ident(id) => match state.state.clone() { + EnumTreeParseState::None => { + if id.to_string().as_str() == "enum" { + state.state = EnumTreeParseState::SeenEnum; + } + } + EnumTreeParseState::SeenEnum => { + state.state = EnumTreeParseState::SeenName(id.to_string()); + } + EnumTreeParseState::SeenName(_) | EnumTreeParseState::FoundGroup(_, _) => { + panic!("[ArgParse derive] Inconsistent state expected state to be `SeenName` (this is a bug)."); + } + }, + TokenTree::Punct(_) | TokenTree::Literal(_) => {} + } +} + +#[derive(Debug, Clone)] +struct SubCommandParsed { + doc_comments: Vec, + tag_name: String, + inner_parse_type_name: Option, +} + +#[derive(Debug, Clone)] +enum ArgsParsedTreeParseState { + Ready, + ReadyOrInner(SubCommandParsed), + WantsAnnotationGroup, +} + +fn parse_inner_enum<'a>(name: &'a str, group: &'a Group) -> String { + let mut state = ArgsParsedTreeParseState::Ready; + let mut pending_doc_comments = Vec::new(); + let mut cw = CodeWriter::new(name); + for tree in group.stream() { + match tree { + TokenTree::Group(g) => match state.clone() { + ArgsParsedTreeParseState::ReadyOrInner(mut r) => { + match g.delimiter() { + Delimiter::Parenthesis => { + let mut inner_g_stream = g.stream().into_iter(); + let ident = pop_ident(&mut inner_g_stream, "[ArgParse derive] Expected subcommand enum tag to contain a member \ + on the form (Struct), found group: {g}"); + r.inner_parse_type_name = Some(ident.to_string()); + } + Delimiter::Brace | Delimiter::Bracket | Delimiter::None => {} + } + cw.push_cmd(r); + state = ArgsParsedTreeParseState::Ready; + } + ArgsParsedTreeParseState::WantsAnnotationGroup => { + if let Some(dc) = try_extract_doc_comment(&g) { + pending_doc_comments.push(dc); + } + state = ArgsParsedTreeParseState::Ready; + } + ArgsParsedTreeParseState::Ready => { + panic!("[ArgParse derive] Expected an ident when parsing enum inner, found group: {g}"); + } + }, + TokenTree::Ident(id) => match state.clone() { + ArgsParsedTreeParseState::Ready => { + let sc = SubCommandParsed { + doc_comments: core::mem::take(&mut pending_doc_comments), + tag_name: id.to_string(), + inner_parse_type_name: None, + }; + state = ArgsParsedTreeParseState::ReadyOrInner(sc); + } + ArgsParsedTreeParseState::ReadyOrInner(sc) => { + cw.push_cmd(sc); + let sc = SubCommandParsed { + doc_comments: core::mem::take(&mut pending_doc_comments), + tag_name: id.to_string(), + inner_parse_type_name: None, + }; + state = ArgsParsedTreeParseState::ReadyOrInner(sc); + } + ArgsParsedTreeParseState::WantsAnnotationGroup => { + panic!("[ArgParse derive] Expected a group when parsing enum inner, found ident: {id}"); + } + }, + TokenTree::Punct(p) => { + if p.as_char() == '#' { + if let ArgsParsedTreeParseState::ReadyOrInner(sc) = state.clone() { + cw.push_cmd(sc); + } + state = ArgsParsedTreeParseState::WantsAnnotationGroup; + } + } + TokenTree::Literal(_) => {} + } + } + if let ArgsParsedTreeParseState::ReadyOrInner(sc) = state { + cw.push_cmd(sc.clone()); + } + cw.finish() +} + +/// Single pass code writer (as far as that's possible) +struct CodeWriter { + longest_name: usize, + printer_head: String, + printer_cmd_out: Vec<(String, Option)>, + lit_matches_head: String, + subcommand_head: String, + subcommand_match_head: String, +} + +impl CodeWriter { + fn new(name: &str) -> Self { + Self { + longest_name: 0, + printer_head: gen_printer_head(name), + printer_cmd_out: vec![], + lit_matches_head: gen_lit_matches_head(name), + subcommand_head: gen_subcommand_head(name), + subcommand_match_head: subcommand_match_head().to_string(), + } + } + + fn push_cmd(&mut self, cmd: SubCommandParsed) { + let snake = pascal_to_snake(&cmd.tag_name); + let scream = snake_to_scream(&snake); + self.push_to_match_head(&scream, &cmd); + self.push_to_subcommand(&snake, &scream); + self.push_lit_matches(&snake); + self.push_print_data(snake, cmd); + } + + fn push_to_match_head(&mut self, scream: &str, cmd: &SubCommandParsed) { + if let Some(inner) = &cmd.inner_parse_type_name { + let _ = self.subcommand_match_head.write_fmt(format_args!( + " {} => {{ Self::{}(<{} as tiny_std::unix::cli::ArgParse>::arg_parse(args)?) }},\n", + scream, cmd.tag_name, inner + )); + } else { + let _ = self.subcommand_match_head.write_fmt(format_args!( + " {} => {{ Self::{} }},\n", + scream, cmd.tag_name + )); + } + } + + #[inline] + fn push_to_subcommand(&mut self, snake: &str, scream: &str) { + let _ = self.subcommand_head.write_fmt(format_args!( + "\tconst {scream}: &[u8] = UnixStr::from_str_checked(\"{snake}\\0\").as_slice();\n", + )); + } + + #[inline] + fn push_lit_matches(&mut self, snake: &str) { + let _ = self.lit_matches_head.write_fmt(format_args!("{snake} | ")); + } + fn push_print_data(&mut self, snake: String, mut cmd: SubCommandParsed) { + if snake.len() > self.longest_name { + self.longest_name = snake.len(); + } + let mut use_comment = None; + if !cmd.doc_comments.is_empty() { + use_comment = Some(cmd.doc_comments.swap_remove(0)); + } + self.printer_cmd_out.push((snake, use_comment)); + } + + fn finish(mut self) -> String { + const PRINTER_TAIL: &str = printer_tail(); + const LIT_MATCHES_TAIL: &str = lit_matches_tail(); + const SUBCOMMAND_TAIL: &str = subcommand_tail(); + let _ = self + .printer_head + .write_fmt(format_args!("\tlet pad = {}usize;\n", self.longest_name)); + for (snake_name, doc_comment) in self.printer_cmd_out { + if let Some(first_line_doc_comment) = doc_comment { + let _ = self.printer_head.write_fmt(format_args!( + "\tf.write_fmt(format_args!(\" {{: String { + let help_printer_name = format!("__{name}HelpPrinterZst"); + format!( + "\ +pub struct {help_printer_name}; +impl core::fmt::Display for {help_printer_name} {{ + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {{ + f.write_str(\"Commands:\\n\")?; + " + ) +} + +const fn printer_tail() -> &'static str { + " Ok(()) + } +} +" +} + +fn gen_lit_matches_head(name: &str) -> String { + format!( + "\ +impl {name} {{ + pub const fn __command_lit_matches() -> &'static str {{ + \"" + ) +} + +const fn lit_matches_tail() -> &'static str { + "\" + } +} +" +} + +fn gen_subcommand_head(name: &str) -> String { + let help_printer_name = format!("__{name}HelpPrinterZst"); + format!("\ +impl tiny_std::unix::cli::SubcommandParse for {name} {{ + type HelpPrinter = {help_printer_name}; + #[inline] + fn help_printer() -> &'static Self::HelpPrinter {{ + &{help_printer_name} + }} + fn subcommand_parse(cmd: &'static UnixStr, args: &mut impl Iterator) -> core::result::Result, tiny_std::unix::cli::ArgParseError> {{ +") +} + +const fn subcommand_match_head() -> &'static str { + " Ok(Some(match cmd.as_slice() { +" +} + +const fn subcommand_tail() -> &'static str { + " _val => { return Ok(None) } + })) + } +} +" +} diff --git a/tiny-cli/tests/derive_test.rs b/tiny-cli/tests/derive_test.rs new file mode 100644 index 0000000..00a333c --- /dev/null +++ b/tiny-cli/tests/derive_test.rs @@ -0,0 +1,590 @@ +#![allow(unused)] + +use std::any::{Any, TypeId}; +use std::panic; +use std::sync::{Arc, Mutex}; +use tiny_cli::{ArgParse, Subcommand}; +use tiny_std::unix::cli::ArgParse; +use tiny_std::{UnixStr, UnixString}; + +#[derive(ArgParse)] +#[cli(help_path = "tiny-cli")] +pub struct SimplestStruct { + one_req_field: i32, +} + +#[test] +fn simplest_struct_happy() { + let mut values = [ + UnixStr::from_str_checked("--one-req-field\0"), + UnixStr::from_str_checked("15\0"), + ]; + let ss = SimplestStruct::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(15, ss.one_req_field); +} +#[test] +fn simplest_struct_err() { + let mut values = [UnixStr::from_str_checked("--one-req-field\0")]; + let ss = SimplestStruct::arg_parse(&mut values.into_iter()); + let Err(e) = ss else { + panic!("Expected arg parse to fail on simple struct") + }; + let string_out = e.to_string(); + assert_eq!( + "\ +Usage: tiny-cli [OPTIONS] + +Options: + --one-req-field + +Expected argument following '--one-req-field'.", + string_out + ); +} + +#[derive(ArgParse)] +pub struct SimpleStructWithAliases { + #[cli(short = "s", long = "long")] + one_req_field: i32, +} + +#[test] +fn aliases_work() { + let mut values = [ + UnixStr::from_str_checked("-s\0"), + UnixStr::from_str_checked("15\0"), + ]; + let ss = SimpleStructWithAliases::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(15, ss.one_req_field); + let mut values = [ + UnixStr::from_str_checked("--long\0"), + UnixStr::from_str_checked("15\0"), + ]; + let ss = SimpleStructWithAliases::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(15, ss.one_req_field); +} + +#[derive(ArgParse)] +pub struct StructWithDifferentPackaging { + req_field: i32, + opt_field: Option, + rep_field: Vec, +} + +#[test] +fn required_optional_repeated() { + let mut values = [ + UnixStr::from_str_checked("--req-field\0"), + UnixStr::from_str_checked("15\0"), + ]; + let ss = StructWithDifferentPackaging::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(15, ss.req_field); + assert!(ss.opt_field.is_none()); + assert!(ss.rep_field.is_empty()); + let mut values = [ + UnixStr::from_str_checked("--req-field\0"), + UnixStr::from_str_checked("15\0"), + UnixStr::from_str_checked("--opt-field\0"), + UnixStr::from_str_checked("30\0"), + ]; + let ss = StructWithDifferentPackaging::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(15, ss.req_field); + assert_eq!(Some(30), ss.opt_field); + assert!(ss.rep_field.is_empty()); + let mut values = [ + UnixStr::from_str_checked("--req-field\0"), + UnixStr::from_str_checked("15\0"), + UnixStr::from_str_checked("--rep-field\0"), + UnixStr::from_str_checked("30\0"), + UnixStr::from_str_checked("--rep-field\0"), + UnixStr::from_str_checked("45\0"), + ]; + let ss = StructWithDifferentPackaging::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(15, ss.req_field); + assert!(ss.opt_field.is_none()); + assert_eq!(vec![30, 45], ss.rep_field); +} + +/// Doc comment on struct +#[derive(ArgParse)] +#[cli(help_path = "tiny-cli")] +pub struct WithEnumSubcommand { + /// Doc comment on field + #[cli(subcommand)] + sc: TestSubcommand, +} + +/// Doc comment on cmd +#[derive(Subcommand, Debug, Eq, PartialEq)] +pub enum TestSubcommand { + /// Doc comment on tag + CmdOne, + CmdTwo(TestSubTwo), + CmdThree, +} + +#[derive(ArgParse, Debug, Eq, PartialEq)] +#[cli(help_path = "tiny-cli, cmd-two")] +pub struct TestSubTwo { + field1: i32, +} + +#[test] +fn simple_subcommand() { + let mut values = [UnixStr::from_str_checked("cmd-one\0")]; + let ss = WithEnumSubcommand::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(TestSubcommand::CmdOne, ss.sc); + let mut values = [ + UnixStr::from_str_checked("cmd-two\0"), + UnixStr::from_str_checked("--field1\0"), + UnixStr::from_str_checked("7\0"), + ]; + let ss = WithEnumSubcommand::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(TestSubcommand::CmdTwo(TestSubTwo { field1: 7 }), ss.sc); + let mut values = [UnixStr::from_str_checked("cmd-three\0")]; + let ss = WithEnumSubcommand::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(TestSubcommand::CmdThree, ss.sc); +} + +#[test] +fn simple_subcommand_help_print() { + let mut values = [UnixStr::from_str_checked("cmd-one\0")]; + let ss = WithEnumSubcommand::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(TestSubcommand::CmdOne, ss.sc); + let mut values = [ + UnixStr::from_str_checked("cmd-two\0"), + UnixStr::from_str_checked("-h\0"), + ]; + let Err(e1) = WithEnumSubcommand::arg_parse(&mut values.into_iter()) else { + panic!("Expected err on help"); + }; + let mut values = [ + UnixStr::from_str_checked("cmd-two\0"), + UnixStr::from_str_checked("--help\0"), + ]; + let Err(e2) = WithEnumSubcommand::arg_parse(&mut values.into_iter()) else { + panic!("Expected err on help"); + }; + assert_eq!(e1.to_string(), e2.to_string()); + assert_eq!(0, e1.cause.len()); + assert_eq!( + "\ +Usage: tiny-cli cmd-two [OPTIONS] + +Options: + --field1 + +", + e1.to_string() + ); +} + +#[derive(ArgParse, Debug, Eq, PartialEq)] +struct NestedCommands { + #[cli(subcommand)] + command: NestedCommandSubcommand, +} + +#[derive(Subcommand, Debug, Eq, PartialEq)] +enum NestedCommandSubcommand { + MyTag(Nest), +} + +#[derive(ArgParse, Debug, Eq, PartialEq)] +struct Nest { + #[cli(subcommand)] + inner: Option, +} + +#[derive(Subcommand, Debug, Eq, PartialEq)] +enum NestedInner { + A, + B, +} + +#[test] +fn nested_optional_subcommand() { + let values = [UnixStr::from_str_checked("my-tag\0")]; + assert_eq!( + NestedCommands { + command: NestedCommandSubcommand::MyTag(Nest { inner: None }) + }, + NestedCommands::arg_parse(&mut values.into_iter()).unwrap() + ); + let values = [ + UnixStr::from_str_checked("my-tag\0"), + UnixStr::from_str_checked("a\0"), + ]; + assert_eq!( + NestedCommands { + command: NestedCommandSubcommand::MyTag(Nest { + inner: Some(NestedInner::A) + }) + }, + NestedCommands::arg_parse(&mut values.into_iter()).unwrap() + ); + let values = [ + UnixStr::from_str_checked("my-tag\0"), + UnixStr::from_str_checked("b\0"), + ]; + assert_eq!( + NestedCommands { + command: NestedCommandSubcommand::MyTag(Nest { + inner: Some(NestedInner::B) + }) + }, + NestedCommands::arg_parse(&mut values.into_iter()).unwrap() + ); +} + +/// My complex cli tool +#[derive(ArgParse, Debug)] +#[cli(help_path = "tiny-cli")] +struct ComplexUsesAllFeatures { + /// Naked field, but has comment + my_field: i32, + #[cli(short = "s")] + my_field_has_short: String, + #[cli(long = "long-field")] + my_field_has_long_remap: &'static UnixStr, + #[cli(short = "c", long = "long-double")] + my_field_has_double_remap: &'static str, + #[cli(subcommand)] + subcommand: ComplexSubcommand, +} + +#[derive(Subcommand, Debug)] +enum ComplexSubcommand { + /// For running + Run(RunArgs), + // We won't be looking at this + List(ListArgs), + /// No comment + Other(OtherArgs), +} + +#[derive(ArgParse, Debug)] +#[cli(help_path = "tiny-cli, run")] +struct RunArgs { + arg_has_opt_str: Option<&'static str>, + arg_has_opt_unix_str: Option<&'static UnixStr>, + arg_has_rep_str: Vec<&'static str>, + arg_has_rep_unix_str: Vec<&'static UnixStr>, +} + +#[derive(ArgParse, Debug)] +#[cli(help_path = "tiny-cli, list")] +struct ListArgs { + arg_has_opt_string: Option, + arg_has_rep_unix_string: Vec, + /// This is required + arg_has_required_string: String, +} +#[derive(ArgParse, Debug)] +#[cli(help_path = "tiny-cli, other")] +struct OtherArgs { + /// This field is required + required_field: i32, + /// Also has optional subcommand + #[cli(subcommand)] + subc_opt: Option, +} + +#[derive(Subcommand, Debug)] +enum OtherSubcommand { + OnlyOneOption(OptStruct), +} + +#[derive(ArgParse, Debug)] +#[cli(help_path = "tiny-cli, other, only-one-option")] +pub struct OptStruct { + /// This isn't required + only_one_opt_owned_field: Option, +} + +#[test] +fn complex_run_no_opts() { + let mut values = [ + UnixStr::from_str_checked("--my-field\0"), + UnixStr::from_str_checked("1\0"), + UnixStr::from_str_checked("-s\0"), + UnixStr::from_str_checked("string-value\0"), + UnixStr::from_str_checked("--long-field\0"), + UnixStr::from_str_checked("unixstr\0"), + UnixStr::from_str_checked("-c\0"), + UnixStr::from_str_checked("remapped string field\0"), + UnixStr::from_str_checked("run\0"), + ]; + let res = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(1, res.my_field); + assert_eq!("string-value", res.my_field_has_short); + assert_eq!( + UnixStr::from_str_checked("unixstr\0"), + res.my_field_has_long_remap + ); + assert_eq!("remapped string field", res.my_field_has_double_remap); + let ComplexSubcommand::Run(r) = res.subcommand else { + panic!("Expected run subcommand to have been invoked"); + }; + assert!(r.arg_has_opt_str.is_none()); + assert!(r.arg_has_opt_unix_str.is_none()); + assert!(r.arg_has_rep_str.is_empty()); + assert!(r.arg_has_rep_unix_str.is_empty()); +} + +#[test] +fn complex_run_full_opts() { + let mut values = [ + UnixStr::from_str_checked("--my-field\0"), + UnixStr::from_str_checked("1\0"), + UnixStr::from_str_checked("-s\0"), + UnixStr::from_str_checked("string-value\0"), + UnixStr::from_str_checked("--long-field\0"), + UnixStr::from_str_checked("unixstr\0"), + UnixStr::from_str_checked("-c\0"), + UnixStr::from_str_checked("remapped string field\0"), + UnixStr::from_str_checked("run\0"), + UnixStr::from_str_checked("--arg-has-opt-str\0"), + UnixStr::from_str_checked("myval\0"), + UnixStr::from_str_checked("--arg-has-opt-unix-str\0"), + UnixStr::from_str_checked("myunixval\0"), + UnixStr::from_str_checked("--arg-has-rep-str\0"), + UnixStr::from_str_checked("myrepval\0"), + UnixStr::from_str_checked("--arg-has-rep-str\0"), + UnixStr::from_str_checked("myrepval2\0"), + UnixStr::from_str_checked("--arg-has-rep-unix-str\0"), + UnixStr::from_str_checked("myrepunixval\0"), + ]; + let res = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(1, res.my_field); + assert_eq!("string-value", res.my_field_has_short); + assert_eq!( + UnixStr::from_str_checked("unixstr\0"), + res.my_field_has_long_remap + ); + assert_eq!("remapped string field", res.my_field_has_double_remap); + let ComplexSubcommand::Run(r) = res.subcommand else { + panic!("Expected run subcommand to have been invoked"); + }; + assert_eq!(Some("myval"), r.arg_has_opt_str); + assert_eq!( + Some(UnixStr::from_str_checked("myunixval\0")), + r.arg_has_opt_unix_str + ); + assert_eq!(vec!["myrepval", "myrepval2"], r.arg_has_rep_str); + assert_eq!( + vec![UnixStr::from_str_checked("myrepunixval\0")], + r.arg_has_rep_unix_str + ); +} + +#[test] +fn complex_subcommand_skip_nested() { + let mut values = [ + UnixStr::from_str_checked("--my-field\0"), + UnixStr::from_str_checked("1\0"), + UnixStr::from_str_checked("-s\0"), + UnixStr::from_str_checked("string-value\0"), + UnixStr::from_str_checked("--long-field\0"), + UnixStr::from_str_checked("unixstr\0"), + UnixStr::from_str_checked("-c\0"), + UnixStr::from_str_checked("remapped string field\0"), + UnixStr::from_str_checked("other\0"), + UnixStr::from_str_checked("--required-field\0"), + UnixStr::from_str_checked("2\0"), + ]; + let res = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(1, res.my_field); + assert_eq!("string-value", res.my_field_has_short); + assert_eq!( + UnixStr::from_str_checked("unixstr\0"), + res.my_field_has_long_remap + ); + assert_eq!("remapped string field", res.my_field_has_double_remap); + let ComplexSubcommand::Other(o) = res.subcommand else { + panic!("Expected other subcommand to have been invoked"); + }; + assert_eq!(2, o.required_field); +} + +#[test] +fn complex_subcommand_full_nested() { + let mut values = [ + UnixStr::from_str_checked("--my-field\0"), + UnixStr::from_str_checked("1\0"), + UnixStr::from_str_checked("-s\0"), + UnixStr::from_str_checked("string-value\0"), + UnixStr::from_str_checked("--long-field\0"), + UnixStr::from_str_checked("unixstr\0"), + UnixStr::from_str_checked("-c\0"), + UnixStr::from_str_checked("remapped string field\0"), + UnixStr::from_str_checked("other\0"), + UnixStr::from_str_checked("--required-field\0"), + UnixStr::from_str_checked("2\0"), + UnixStr::from_str_checked("only-one-option\0"), + UnixStr::from_str_checked("--only-one-opt-owned-field\0"), + UnixStr::from_str_checked("57287493014712903472878465\0"), + ]; + let res = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()).unwrap(); + assert_eq!(1, res.my_field); + assert_eq!("string-value", res.my_field_has_short); + assert_eq!( + UnixStr::from_str_checked("unixstr\0"), + res.my_field_has_long_remap + ); + assert_eq!("remapped string field", res.my_field_has_double_remap); + let ComplexSubcommand::Other(o) = res.subcommand else { + panic!("Expected other subcommand to have been invoked"); + }; + assert_eq!(2, o.required_field); + let Some(OtherSubcommand::OnlyOneOption(opt)) = o.subc_opt else { + panic!("Expected nested subcommand to have been invoked"); + }; + assert_eq!( + 57287493014712903472878465, + opt.only_one_opt_owned_field.unwrap() + ); +} + +#[test] +fn complex_top_level_help() { + let mut values = [UnixStr::from_str_checked("-h\0")]; + let Err(e) = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()) else { + panic!("Expect err on complex help"); + }; + assert_eq!(0, e.cause.len()); + let output = e.to_string(); + assert_eq!( + "\ +My complex cli tool + +Usage: tiny-cli [OPTIONS] [COMMAND] + +Commands: + run - For running + list + other - No comment + +Options: + --my-field + Naked field, but has comment + + -s, --my-field-has-short + + --long-field + + -c, --long-double + +", + output + ); +} + +#[test] +fn complex_run_help() { + let mut values = [ + UnixStr::from_str_checked("run\0"), + UnixStr::from_str_checked("-h\0"), + ]; + let Err(e) = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()) else { + panic!("Expect err on complex run help"); + }; + assert_eq!(0, e.cause.len()); + let output = e.to_string(); + assert_eq!( + "\ +Usage: tiny-cli run [OPTIONS] + +Options: + --arg-has-opt-str + + --arg-has-opt-unix-str + + --arg-has-rep-str + + --arg-has-rep-unix-str + +", + output + ); +} + +#[test] +fn complex_list_help() { + let mut values = [ + UnixStr::from_str_checked("list\0"), + UnixStr::from_str_checked("--help\0"), + ]; + let Err(e) = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()) else { + panic!("Expect err on complex run help"); + }; + assert_eq!(0, e.cause.len()); + let output = e.to_string(); + assert_eq!( + "\ +Usage: tiny-cli list [OPTIONS] + +Options: + --arg-has-opt-string + + --arg-has-rep-unix-string + + --arg-has-required-string + This is required + +", + output + ); +} + +#[test] +fn complex_other_help() { + let mut values = [ + UnixStr::from_str_checked("other\0"), + UnixStr::from_str_checked("--help\0"), + ]; + let Err(e) = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()) else { + panic!("Expect err on complex run help"); + }; + assert_eq!(0, e.cause.len()); + let output = e.to_string(); + assert_eq!( + "\ +Usage: tiny-cli other [OPTIONS] [COMMAND] + +Commands: + only-one-option + +Options: + --required-field + This field is required + +", + output + ); +} + +#[test] +fn complex_other_opt_help() { + let mut values = [ + UnixStr::from_str_checked("other\0"), + UnixStr::from_str_checked("only-one-option\0"), + UnixStr::from_str_checked("-h\0"), + ]; + let Err(e) = ComplexUsesAllFeatures::arg_parse(&mut values.into_iter()) else { + panic!("Expect err on complex run help"); + }; + assert_eq!(0, e.cause.len()); + let output = e.to_string(); + assert_eq!( + "\ +Usage: tiny-cli other only-one-option [OPTIONS] + +Options: + --only-one-opt-owned-field + This isn't required + +", + output + ); +} diff --git a/tiny-std/Cargo.toml b/tiny-std/Cargo.toml index f3a7cc6..e582836 100644 --- a/tiny-std/Cargo.toml +++ b/tiny-std/Cargo.toml @@ -20,6 +20,8 @@ alloc = ["rusl/alloc"] allocator-provided = [] +cli = ["start"] + global-allocator = ["allocator-provided"] # Pulls in all features to make an executable that works as expected (env etc), std-incompatible diff --git a/tiny-std/Changelog.md b/tiny-std/Changelog.md index 755e6c7..6228915 100644 --- a/tiny-std/Changelog.md +++ b/tiny-std/Changelog.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added +### Changed + + +## [v0.2.3] + +### Added +- cli shim code to integrate with tiny-cli + ### Changed - Implement `From` for `SystemTime` diff --git a/tiny-std/src/lib.rs b/tiny-std/src/lib.rs index 9e89adb..06882d3 100644 --- a/tiny-std/src/lib.rs +++ b/tiny-std/src/lib.rs @@ -12,6 +12,7 @@ extern crate alloc; pub use error::{Error, Result}; pub use rusl::error::Errno; pub use rusl::string::unix_str::*; +pub use rusl::unix_lit; pub use rusl::Error as RuslError; #[cfg(feature = "allocator-provided")] diff --git a/tiny-std/src/unix.rs b/tiny-std/src/unix.rs index 0533752..2f41bb0 100644 --- a/tiny-std/src/unix.rs +++ b/tiny-std/src/unix.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "cli")] +pub mod cli; pub mod fd; pub mod host_name; pub mod misc; diff --git a/tiny-std/src/unix/cli.rs b/tiny-std/src/unix/cli.rs new file mode 100644 index 0000000..5527e99 --- /dev/null +++ b/tiny-std/src/unix/cli.rs @@ -0,0 +1,149 @@ +use core::fmt::{Arguments, Debug, Display, Formatter, Write}; +use rusl::string::unix_str::UnixStr; + +/// Parses provided args of this running executable into the provided struct +/// On failure, prints help, then exits 1 +pub fn parse_cli_args() -> T { + let mut args_os = crate::env::args_os(); + // Pop off this bin + let _bin = args_os.next(); + match T::arg_parse(&mut args_os) { + Ok(v) => v, + Err(e) => { + crate::eprintln!("{}", e); + crate::process::exit(1); + } + } +} + +pub trait ArgParse: Sized { + type HelpPrinter: Display + Sized; + fn arg_parse(args: &mut impl Iterator) -> Result; + + fn help_printer() -> &'static Self::HelpPrinter; +} + +pub trait SubcommandParse: Sized { + /// Helper struct for no-alloc printing + type HelpPrinter: Display + Sized; + + fn help_printer() -> &'static Self::HelpPrinter; + + fn subcommand_parse( + cmd: &'static UnixStr, + args: &mut impl Iterator, + ) -> Result, ArgParseError>; +} + +#[derive(Clone)] +pub struct ArgParseError { + pub relevant_help: &'static dyn Display, + pub cause: ArgParseCauseBuffer, +} + +impl ArgParseError { + const OVERFLOW_MSG: [u8; STACK_BUFFER_CAP] = *b"Cause unknown, too many characters to write into output buffer (BUG)\0\0\0\0\0\0\ + \0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; + const OVERFLOW_BUF: ArgParseCauseBuffer = ArgParseCauseBuffer { + buf: Self::OVERFLOW_MSG, + len: 68, + }; + pub fn new_cause_str( + relevant_help: &'static dyn Display, + cause: &str, + ) -> Result { + let mut buf = ArgParseCauseBuffer::new(); + buf.write_str(cause).map_err(|_e| ArgParseError { + relevant_help, + cause: Self::OVERFLOW_BUF, + })?; + Ok(Self { + relevant_help, + cause: buf, + }) + } + + pub fn new_cause_fmt( + relevant_help: &'static dyn Display, + cause: Arguments<'_>, + ) -> Result { + let mut buf = ArgParseCauseBuffer::new(); + buf.oneshot_write(cause).map_err(|_e| ArgParseError { + relevant_help, + cause: Self::OVERFLOW_BUF, + })?; + Ok(Self { + relevant_help, + cause: buf, + }) + } +} + +impl Debug for ArgParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!( + "ArgParseError {{ relevant_help: {}, cause: {}}}", + self.relevant_help, self.cause + )) + } +} + +impl Display for ArgParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + f.write_fmt(format_args!("{}{}", self.relevant_help, self.cause)) + } +} + +const STACK_BUFFER_CAP: usize = 128; +#[derive(Debug, Copy, Clone)] +pub struct ArgParseCauseBuffer { + buf: [u8; STACK_BUFFER_CAP], + len: usize, +} + +impl Write for ArgParseCauseBuffer { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + if self.len != 0 { + return Err(core::fmt::Error::default()); + } + let buf_write = s.as_bytes(); + let rem = STACK_BUFFER_CAP - self.len; + if buf_write.len() > rem { + return Err(core::fmt::Error::default()); + } + self.buf + .get_mut(self.len..self.len + buf_write.len()) + .unwrap() + .copy_from_slice(buf_write); + self.len += buf_write.len(); + Ok(()) + } +} + +impl ArgParseCauseBuffer { + const fn new() -> Self { + Self { + buf: [0u8; STACK_BUFFER_CAP], + len: 0, + } + } + + /// Can write into this buffer at most once + #[inline] + fn oneshot_write(&mut self, args: Arguments<'_>) -> core::fmt::Result { + Self::write_fmt(self, args) + } + + #[inline] + pub fn len(&self) -> usize { + self.len + } +} + +impl Display for ArgParseCauseBuffer { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + let s = core::str::from_utf8(&self.buf[..self.len]) + .map_err(|_e| core::fmt::Error::default())?; + f.write_str(s) + } +}