Repository: JeffrayZhang/terse-cli Branch: main Commit: 4104691e4906 Files: 13 Total size: 14.5 KB Directory structure: gitextract_0gob4d2c/ ├── .gitignore ├── Cargo.toml ├── LICENSE.md ├── example/ │ ├── Cargo.toml │ └── main.rs ├── readme.md ├── src/ │ └── main.rs ├── terse_cli/ │ ├── Cargo.toml │ └── lib.rs ├── terse_cli_lib/ │ ├── Cargo.toml │ └── lib.rs └── tests/ ├── main.rs └── test_cli_subcommand.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /target ================================================ FILE: Cargo.toml ================================================ [package] name = "terse-cli-workspace" version = "0.1.0" edition = "2021" [dependencies] terse_cli_lib = { path = "./terse_cli_lib" } quote = "1.0.37" pretty_assertions = "1.4.1" [workspace] members = ["terse_cli_lib", "terse_cli", "example"] ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2024 Jeffray Zhang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: example/Cargo.toml ================================================ [package] name = "example" version = "0.1.0" edition = "2021" [dependencies] clap = { version = "4.5.21", features = ["derive", "unstable-doc"] } terse_cli = { path = "../terse_cli" } [[bin]] name = "cli" path = "main.rs" ================================================ FILE: example/main.rs ================================================ use clap::Parser; use terse_cli::{command, subcommands}; /// Example: cargo run -- command-one --a 3 #[command] fn command_one(a: i32, b: Option) -> i32 { a + b.unwrap_or(0) } /// Example: cargo run -- my-subcommands command-two --name Bob #[command] fn command_two(name: String) -> String { format!("hello {}", name) } /// Example: cargo run -- my-subcommands command-three --a 7 --b 3 #[command] fn command_three(a: i32, b: i32) -> String { format!("the difference is {}", a - b) } /// Example: cargo run -- my-subcommands command-four #[command] fn command_four() -> String { "command four".to_string() } subcommands!( /// These are the subcommands my_subcommands, [command_two, command_three, command_four]); subcommands!( /// Example docs for the "root" cli, [command_one, my_subcommands]); fn main() { cli::run(cli::Args::parse()); } // you can also use `--help` as you would expect // Example: cargo run -- my-subcommands --help ================================================ FILE: readme.md ================================================ # Terse CLI A wrapper around [clap](https://github.com/clap-rs/clap) that lets you build CLI applications with very little boilerplate code. Modeled after [tiangolo's typer python library](https://github.com/fastapi/typer), you simply define your commands and subcommands as functions and annotate them with the `#[command]` attribute. ## Current Status: Alpha This is a work in progress. The core functionality is implemented, but if you want any customization on how your CLI is used (e.g. positional arguments, custom help messages, etc.) those things are not yet supported. Known issues: - [ ] every command must have a return type that implements `Display` - [ ] positional arguments are not yet supported - [ ] argument docs are not yet supported ## Installation Install both clap and terse_cli: ```sh $ cargo add clap --features derive $ cargo add terse_cli ``` ## Example Below snippet is from [./example/main.rs](./example/main.rs). ```rs use clap::Parser; use terse_cli::{command, subcommands}; /// Example: cargo run -- command-one --a 3 #[command] fn command_one(a: i32, b: Option) -> i32 { a + b.unwrap_or(0) } /// Example: cargo run -- my-subcommands command-two --name Bob #[command] fn command_two(name: String) -> String { format!("hello {}", name) } /// Example: cargo run -- my-subcommands command-three --a 7 --b 3 #[command] fn command_three(a: i32, b: i32) -> String { format!("the difference is {}", a - b) } /// Example: cargo run -- my-subcommands command-four #[command] fn command_four() -> String { "command four".to_string() } subcommands!( /// These are the subcommands my_subcommands, [command_two, command_three, command_four]); subcommands!( /// Example docs for the "root" cli, [command_one, my_subcommands]); fn main() { cli::run(cli::Args::parse()); } // you can also use `--help` as you would expect // Example: cargo run -- my-subcommands --help ``` ================================================ FILE: src/main.rs ================================================ fn main() { println!("workspace root, this doesn't do anything!"); } ================================================ FILE: terse_cli/Cargo.toml ================================================ [package] name = "terse_cli" version = "0.1.4" authors = ["David McNamee ", "Jeffray Zhang "] edition = "2021" description = "A library for building no-boilerplate CLI apps" license = "MIT" repository = "https://github.com/JeffrayZhang/terse-cli" homepage = "https://github.com/JeffrayZhang/terse-cli" readme = "../readme.md" [dependencies] proc-macro2 = "1.0.92" terse_cli_lib = { path = "../terse_cli_lib", version = "0.1.2" } [lib] proc-macro = true path = "lib.rs" ================================================ FILE: terse_cli/lib.rs ================================================ extern crate proc_macro; use proc_macro::TokenStream; #[proc_macro_attribute] pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream { let _attr = proc_macro2::TokenStream::from(_attr); let item = proc_macro2::TokenStream::from(item); terse_cli_lib::command(_attr, item).unwrap().into() } #[proc_macro] pub fn subcommands(item: TokenStream) -> TokenStream { terse_cli_lib::subcommands(proc_macro2::TokenStream::from(item)) .unwrap() .into() } ================================================ FILE: terse_cli_lib/Cargo.toml ================================================ [package] name = "terse_cli_lib" version = "0.1.2" authors = ["David McNamee ", "Jeffray Zhang "] edition = "2021" description = "A library for building no-boilerplate CLI apps" license = "MIT" [dependencies] convert_case = "0.6.0" proc-macro2 = "1.0.92" quote = "1.0.37" syn = { version = "2.0.89", features = ["full"] } [lib] path = "lib.rs" ================================================ FILE: terse_cli_lib/lib.rs ================================================ use convert_case::{Case, Casing}; use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use syn::parse::{Parse, ParseStream}; use syn::{bracketed, parse2, Attribute, Ident, ItemFn, Token}; fn extract_docs(a: &[Attribute]) -> impl Iterator { a.iter().filter(|a| a.path().is_ident("doc")) } #[derive(Debug)] pub enum CommandMacroError { InvalidCliArgumentError(InvalidCliArgumentError), InvalidSubcommandFunctionError(InvalidSubcommandFunctionError), } /// don't use this directly, use apps/macros instead pub fn command( _attr: TokenStream, item: TokenStream, ) -> Result { let input = parse2::(item).map_err(|err| CommandMacroError::InvalidSubcommandFunctionError(InvalidSubcommandFunctionError { message: "Failed to parse function", err, }), )?; let fn_name = &input.sig.ident; let args = &input.sig.inputs; let (fields, arg_names): (Vec<_>, Vec<_>) = args .iter() .map(|arg| match arg { syn::FnArg::Typed(syn::PatType { pat, ty, .. }) => { Ok((quote! { #[arg(long)] #pat: #ty }, quote! { #pat })) } _ => Err(CommandMacroError::InvalidCliArgumentError(InvalidCliArgumentError(format!( "Invalid cli argument: {}", arg.to_token_stream() )))), }) .collect::, _>>()? .into_iter() .unzip(); let docs = extract_docs(&input.attrs); let expanded = quote! { mod #fn_name { use super::#fn_name; use clap::{command, Parser}; #[derive(Parser)] #[command(version, about, long_about = None)] #(#docs)* pub struct Args { #(#fields),* } pub fn run(Args { #(#arg_names),* }: Args) { let result = #fn_name(#(#arg_names),*); println!("{}", result); } } #input }; Ok(expanded) } #[derive(Debug)] pub enum SubcommandsMacroError { InvalidIdentifierError(InvalidIdentifierError), InvalidIdentifierListError(InvalidIdentifierListError), } /// don't use this directly, use apps/macros instead pub fn subcommands( item: TokenStream, ) -> Result { let MergeSubcommandsInput { sub_doc, cli_ident, subcommands, } = parse2::(item).map_err(|err| SubcommandsMacroError::InvalidIdentifierListError(InvalidIdentifierListError { message: "subcommands only accepts lists of identifiers", err, }))?; let match_arms = subcommands.iter().map(|sc| { let ident = &sc.ident; let cmd_name = get_command_name(ident); quote! { Subcommands::#cmd_name(args) => #ident::run(args) } }); let command_enum_fields = subcommands.iter().map(|sc| { let docs = extract_docs(&sc.attrs); let ident = &sc.ident; let cmd_name = get_command_name(ident); quote! { #(#docs)* #cmd_name(#ident::Args) } }); let idents_tokens = subcommands.iter().map(|sc| sc.ident.to_token_stream()); let sub_doc_mod = sub_doc.clone(); let expanded = quote! { #(#sub_doc_mod)* mod #cli_ident { use super::{#(#idents_tokens),*}; use clap::{command, Parser, Subcommand}; #[derive(Subcommand)] #(#sub_doc)* pub enum Subcommands { #(#command_enum_fields),* } #[derive(Parser)] #[command(version, about, long_about = None)] pub struct Args { #[command(subcommand)] command: Subcommands, } pub fn run(Args { command }: Args) { match command { #(#match_arms),* }; } } }; Ok(expanded) } #[allow(dead_code)] struct Subcommand { attrs: Vec, ident: Ident, } struct MergeSubcommandsInput { sub_doc: Vec, cli_ident: Ident, subcommands: Vec, } impl Parse for Subcommand { fn parse(input: ParseStream) -> syn::Result { let attrs = input.call(Attribute::parse_outer)?; let ident: Ident = input.parse()?; Ok(Subcommand { attrs, ident }) } } impl Parse for MergeSubcommandsInput { fn parse(input: ParseStream) -> syn::Result { // parse any doc comments attached to the cli_ident let sub_doc = Attribute::parse_outer(input) .unwrap_or_default() .into_iter() .filter(|attr| attr.path().is_ident("doc")) .collect::>(); let cli_ident: Ident = input.parse()?; input.parse::()?; let content; bracketed!(content in input); let subcommands: syn::punctuated::Punctuated = content.parse_terminated(Subcommand::parse, Token![,])?; Ok(MergeSubcommandsInput { sub_doc, cli_ident, subcommands: subcommands.into_iter().collect(), }) } } fn get_command_name(func_name: &Ident) -> Ident { Ident::new( &func_name.to_string().to_case(Case::UpperCamel), func_name.span(), ) } #[allow(dead_code)] #[derive(Debug, Clone, Copy)] pub struct InvalidIdentifierError(&'static str); #[allow(dead_code)] #[derive(Debug, Clone)] pub struct InvalidIdentifierListError { message: &'static str, err: syn::Error, } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct InvalidSubcommandFunctionError { message: &'static str, err: syn::Error, } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct InvalidCliArgumentError(String); ================================================ FILE: tests/main.rs ================================================ mod test_cli_subcommand; ================================================ FILE: tests/test_cli_subcommand.rs ================================================ use terse_cli_lib::{command, subcommands}; use pretty_assertions::assert_eq; use quote::quote; #[test] pub fn test_command_macro() { let in_stream = quote! { fn my_func(a: i32, b: i32) -> i32 { a + b } }; let attr_stream = quote! { #[subcommand] }; let out_stream = command(attr_stream, in_stream).unwrap(); let expected = quote! { mod my_func { use super::my_func; use clap::{command, Parser}; #[derive(Parser)] # [command (version , about , long_about = None)] pub struct Args { #[arg(long)] a: i32, #[arg(long)] b: i32 } pub fn run(Args { a, b }: Args) { let result = my_func(a, b); println!("{}", result); } } fn my_func(a: i32, b: i32) -> i32 { a + b } }; assert_eq!(out_stream.to_string(), expected.to_string()); } #[test] pub fn test_command_macro_no_args() { let in_stream = quote! { /// doc comment test fn my_func() -> i32 { 42 } }; let attr_stream = quote! { #[subcommand] }; let out_stream = command(attr_stream, in_stream).unwrap(); let expected = quote! { mod my_func { use super::my_func; use clap::{command, Parser}; #[derive(Parser)] #[command(version, about, long_about = None)] #[doc = r" doc comment test"] pub struct Args {} pub fn run(Args {}: Args) { let result = my_func(); println!("{}", result); } } #[doc = r" doc comment test"] fn my_func() -> i32 { 42 } }; assert_eq!(out_stream.to_string(), expected.to_string()); } #[test] pub fn test_subcommands_macro() { let in_stream = quote! { cli, [command_one, command_two] }; let out_stream = subcommands(in_stream).unwrap(); let expected = quote! { mod cli { use super::{command_one, command_two}; use clap::{command, Parser, Subcommand}; #[derive(Subcommand)] pub enum Subcommands { CommandOne(command_one::Args), CommandTwo(command_two::Args) } #[derive(Parser)] #[command(version, about, long_about = None)] pub struct Args { #[command(subcommand)] command: Subcommands, } pub fn run(Args { command }: Args) { match command { Subcommands::CommandOne(args) => command_one::run(args), Subcommands::CommandTwo(args) => command_two::run(args) }; } } }; assert_eq!(out_stream.to_string(), expected.to_string()); }