diff --git a/.gitignore b/.gitignore index 5c37022..a6c31a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /expand.rs +/.sccache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ca83a0c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,3 @@ +include: + - project: wunker-bunker/ci-pipelines + file: cargo-lib.yml diff --git a/.helix/languages.toml b/.helix/languages.toml new file mode 100644 index 0000000..f3f8474 --- /dev/null +++ b/.helix/languages.toml @@ -0,0 +1,6 @@ +[language-server.rust-analyzer.config] +cargo.features = "all" + +[language-server.rust-analyzer.config.check] +command = "clippy" +args = ["--no-deps", "--workspace"] diff --git a/Cargo.lock b/Cargo.lock index 2e8d075..fde1fa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "bitflags" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" + [[package]] name = "cfg-if" version = "1.0.0" @@ -36,11 +42,21 @@ dependencies = [ name = "comlexr" version = "1.2.0" dependencies = [ - "proc-macro2", - "quote", + "comlexr_macro", "rstest", "rusty-hook", + "tempfile", + "thiserror", +] + +[[package]] +name = "comlexr_macro" +version = "1.2.0" +dependencies = [ + "proc-macro2", + "quote", "syn", + "tempfile", ] [[package]] @@ -59,6 +75,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fsio" version = "0.1.3" @@ -117,6 +149,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi", + "windows-targets", +] + [[package]] name = "glob" version = "0.3.2" @@ -155,6 +199,18 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "memchr" version = "2.7.4" @@ -167,6 +223,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab250442c86f1850815b5d268639dff018c0627022bc1940eb2d642ca1ce12f0" +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -280,6 +342,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rusty-hook" version = "0.11.2" @@ -338,6 +413,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.5.11" @@ -376,6 +485,88 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.6.24" @@ -384,3 +575,12 @@ checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] diff --git a/Cargo.toml b/Cargo.toml index 35a5630..5771e3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ -[package] -name = "comlexr" +[workspace] +members = ["macro"] + +[workspace.package] description = "Dynamically build Command objects with conditional expressions" repository = "https://gitlab.com/wunker-bunker/comlexr" version = "1.2.0" @@ -7,22 +9,13 @@ edition = "2021" rust-version = "1.60" license = "MIT" -[lib] -proc-macro = true +[workspace.dependencies] +tempfile = "3.6" -[dependencies] -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full", "derive"] } - -[dev-dependencies] -rstest = "0.24" -rusty-hook = "0.11" - -[lints.rust] +[workspace.lints.rust] unsafe_code = "forbid" -[lints.clippy] +[workspace.lints.clippy] correctness = "deny" suspicious = "deny" perf = "deny" @@ -30,8 +23,34 @@ style = "deny" nursery = "deny" pedantic = "deny" +[package] +name = "comlexr" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true + [package.metadata.release] pre-release-hook = ["git", "cliff", "-o", "CHANGELOG.md", "--tag", "{{version}}"] pre-release-replacements = [ { file = "README.md", search = "comlexr = \"\\d+.\\d+.\\d+\"", replace = "comlexr = \"{{version}}\"" } ] + +[package.metadata."docs.rs"] +all-features = true + +[dependencies] +comlexr_macro = { version = "=1.2.0", path = "./macro" } +thiserror = "1.0.65" + +[dev-dependencies] +rstest = "0.24" +rusty-hook = "0.11" + +tempfile.workspace = true + +[lints] +workspace = true diff --git a/README.md b/README.md index 65b2ace..942a92d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Add `comlexr` to your project's `Cargo.toml`: comlexr = "1.2.0" ``` -### Rust Edition -This project uses Rust **2018 edition** to ensure compatibility and stable language features. +## MSRV +The minimum supported Rust version is `1.60.0` for broader support. ## Usage @@ -206,14 +206,42 @@ assert_eq!( ); ``` +### Piping commands +When using the `pipe` feature, you can make use of `pipe!` to chain the stdout of commands to stdin. Execution is lazy so commands aren't run until `status()` or `output()` is called. + +```rust +use comlexr::{pipe, cmd}; + +let dir = tempfile::tempdir().unwrap(); +let file = dir.path().join("out"); +let mut pipe = pipe!(cmd!("echo", "test") | cmd!("tee", &file)); + +let status = pipe.status().unwrap(); +assert!(status.success()); +``` + +### Sending variables to stdin +You can also send data to the stdin of the first command in the chain. + +```rust +use comlexr::{pipe, cmd}; + +let mut pipe = pipe!(stdin = "test"; cmd!("sed", "s|e|oa|")); +let output = pipe.output().unwrap(); + +assert!(output.status.success()); +assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +``` + ## Features - Conditional expressions (`if`, `if let`) - Iteration constructs (`for`, `for in`) - Pattern matching (`match`) - Support for closures and dynamic expressions +- Piping stdout from one command to the stdin of another ## Examples See the [tests](./tests/) directory for more examples on how to use `comlexr` effectively in your project. ## License -This project is licensed under the [MIT License](LICENSE). +This project is licensed under the [MIT License](./LICENSE). diff --git a/bacon.toml b/bacon.toml index 6810121..6100cc0 100644 --- a/bacon.toml +++ b/bacon.toml @@ -11,32 +11,32 @@ default_job = "clippy-all" command = ["cargo", "check", "--color", "always"] need_stdout = false default_watch = false -watch = ["src", "Cargo.toml"] +watch = ["src", "Cargo.toml", "macro"] [jobs.check-all] command = ["cargo", "check", "--all-features", "--color", "always"] need_stdout = false default_watch = false -watch = ["src", "Cargo.toml"] +watch = ["src", "Cargo.toml", "macro"] [jobs.clippy] command = [ - "cargo", "clippy", + "cargo", "clippy", "--workspace", "--color", "always", ] need_stdout = false default_watch = false -watch = ["src", "Cargo.toml"] +watch = ["src", "Cargo.toml", "macro"] [jobs.clippy-all] command = [ - "cargo", "clippy", + "cargo", "clippy", "--workspace", "--all-features", "--color", "always", ] need_stdout = false default_watch = false -watch = ["src", "Cargo.toml"] +watch = ["src", "Cargo.toml", "macro"] [jobs.test] command = [ @@ -46,7 +46,7 @@ command = [ env.RUSTFLAGS="-Zmacro-backtrace" need_stdout = true default_watch = false -watch = ["src", "Cargo.toml", "tests", "README.md"] +watch = ["src", "Cargo.toml", "tests", "README.md", "macro"] [jobs.test-all] command = [ @@ -56,18 +56,18 @@ command = [ env.RUSTFLAGS="-Zmacro-backtrace" need_stdout = true default_watch = false -watch = ["src", "Cargo.toml", "tests", "README.md"] +watch = ["src", "Cargo.toml", "tests", "README.md", "macro"] [jobs.doc] -command = ["cargo", "doc", "--color", "always", "--no-deps"] +command = ["cargo", "doc", "--color", "always", "--no-deps", "--workspace", "--all-features"] need_stdout = false default_watch = false -watch = ["src", "Cargo.toml"] +watch = ["src", "Cargo.toml", "macro"] # If the doc compiles, then it opens in your browser and bacon switches # to the previous job [jobs.doc-open] -command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"] +command = ["cargo", "doc", "--color", "always", "--no-deps", "--open", "--workspace", "--all-features"] need_stdout = false on_success = "back" # so that we don't open the browser at each change diff --git a/macro/Cargo.toml b/macro/Cargo.toml new file mode 100644 index 0000000..04762dc --- /dev/null +++ b/macro/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "comlexr_macro" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true + +[package.metadata."docs.rs"] +all-features = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "derive"] } + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/src/cmd.rs b/macro/src/cmd.rs similarity index 91% rename from src/cmd.rs rename to macro/src/cmd.rs index bcad7be..7f2cbd5 100644 --- a/src/cmd.rs +++ b/macro/src/cmd.rs @@ -6,6 +6,8 @@ use syn::{ token, Token, }; +use crate::macros::enum_to_tokens; + pub struct Command { cd: CurrentDir, env_vars: EnvVars, @@ -101,19 +103,7 @@ impl Parse for LogicArg { } } -impl ToTokens for LogicArg { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - tokens.extend(match self { - Self::Expr(expr) => quote! { #expr }, - Self::ForIter(for_iter) => quote! { #for_iter }, - Self::ForIn(for_in) => quote! { #for_in }, - Self::IfLet(if_let) => quote! { #if_let }, - Self::If(if_) => quote! { #if_ }, - Self::Match(match_) => quote! { #match_ }, - Self::Closure(closure) => quote! { #closure }, - }); - } -} +enum_to_tokens! {LogicArg: Expr, ForIter, ForIn, IfLet, If, Match, Closure} struct ForIter { expr: Value, @@ -368,14 +358,7 @@ impl Parse for Arguments { } } -impl ToTokens for Arguments { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - tokens.extend(match self { - Self::Single(arg) => quote! { #arg }, - Self::Multi(args) => quote! { #args }, - }); - } -} +enum_to_tokens! {Arguments: Single, Multi} struct MultiArg(Punctuated); @@ -506,7 +489,7 @@ impl ToTokens for CurrentDir { } } -enum Value { +pub enum Value { Lit(syn::Lit), Ident(syn::Ident), Expr(syn::Expr), @@ -536,12 +519,4 @@ impl Parse for Value { } } -impl ToTokens for Value { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - tokens.extend(match self { - Self::Lit(lit) => quote! { #lit }, - Self::Ident(ident) => quote! { #ident }, - Self::Expr(expr) => quote! { #expr }, - }); - } -} +enum_to_tokens! {Value: Lit, Ident, Expr} diff --git a/macro/src/lib.rs b/macro/src/lib.rs new file mode 100644 index 0000000..c6593f9 --- /dev/null +++ b/macro/src/lib.rs @@ -0,0 +1,200 @@ +extern crate quote; +extern crate syn; + +use quote::quote; +use syn::parse_macro_input; + +mod cmd; +mod macros; +mod pipe; + +/// Generates a command expression by combining static strings, conditional logic, loops, +/// pattern matching, and closures to dynamically build and format command-line arguments. +/// +/// The `cmd!` macro supports flexible syntax constructs such as: +/// - **Static arguments**: Basic string arguments. +/// - **Conditional inclusion**: Using `if` or `if let` to conditionally include arguments. +/// - **Iterative inclusion**: Using `for` loops to include multiple arguments from collections. +/// - **Pattern matching**: Using `match` expressions for dynamic argument selection. +/// - **Closures**: Dynamically generating arguments at runtime. +/// +/// # Examples +/// +/// ## Basic Usage +/// ``` +/// # use comlexr_macro::cmd; +/// let command = cmd!("echo", "test"); +/// assert_eq!(format!("{command:?}"), r#""echo" "test""#.to_string()); +/// ``` +/// +/// ## Current Directory +/// ``` +/// # use comlexr_macro::cmd; +/// let command = cmd!( +/// cd "~/"; +/// "echo", +/// "test", +/// ); +/// +/// assert_eq!(format!("{command:?}"), r#"cd "~/" && "echo" "test""#); +/// ``` +/// +/// ## Environment Vars +/// ``` +/// # use comlexr_macro::cmd; +/// const NEW_VAR: &str = "NEW_VAR"; +/// +/// let command = cmd!( +/// env { +/// "TEST": "test", +/// NEW_VAR: "new_var" +/// }; +/// "echo", +/// "test", +/// ); +/// +/// assert_eq!(format!("{command:?}"), r#"NEW_VAR="new_var" TEST="test" "echo" "test""#); +/// ``` +/// +/// ### Current Directory and Environment Variable Order +/// ``` +/// # use comlexr_macro::cmd; +/// let command = cmd!( +/// cd "~/"; +/// env { +/// "TEST": "test", +/// }; +/// "echo", +/// "test", +/// ); +/// +/// assert_eq!( +/// format!("{command:?}"), +/// r#"cd "~/" && TEST="test" "echo" "test""# +/// ); +/// +/// ``` +/// +/// ## Conditional Arguments +/// ``` +/// # use comlexr_macro::cmd; +/// let include_arg = true; +/// +/// let command = cmd!("echo", "test", if include_arg => "optional_arg"); +/// assert_eq!(format!("{command:?}"), r#""echo" "test" "optional_arg""#.to_string()); +/// ``` +/// +/// ## Conditional Pattern Matching +/// ``` +/// # use comlexr_macro::cmd; +/// let single_option = Some("single"); +/// let multi_option: Option<&str> = None; +/// +/// let command = cmd!( +/// "echo", +/// "test", +/// if let Some(arg) = single_option => arg, +/// if let Some(arg) = multi_option => [ +/// "multi", +/// arg, +/// ], +/// ); +/// assert_eq!(format!("{command:?}"), r#""echo" "test" "single""#.to_string()); +/// ``` +/// +/// ## Iterative Argument Inclusion +/// ``` +/// # use comlexr_macro::cmd; +/// let args = &["arg1", "arg2"]; +/// +/// let command = cmd!("echo", for args); +/// assert_eq!(format!("{command:?}"), r#""echo" "arg1" "arg2""#.to_string()); +/// ``` +/// +/// ## Iteration with `for in` +/// ``` +/// # use comlexr_macro::cmd; +/// let single_iter = &["arg1", "arg2"]; +/// let multi_iter = &["multi1", "multi2"]; +/// +/// let command = cmd!( +/// "echo", +/// "test", +/// for arg in single_iter => arg, +/// for arg in multi_iter => [ +/// "multi", +/// arg, +/// ], +/// ); +/// assert_eq!(format!("{command:?}"), r#""echo" "test" "arg1" "arg2" "multi" "multi1" "multi" "multi2""#.to_string()); +/// ``` +/// +/// ## Match Statements +/// ``` +/// # use comlexr_macro::cmd; +/// enum TestArgs { +/// Arg1, +/// Arg2, +/// Arg3, +/// } +/// +/// let match_arg = TestArgs::Arg2; +/// let command = cmd!( +/// "echo", +/// "test", +/// match match_arg { +/// TestArgs::Arg1 => "arg1", +/// TestArgs::Arg2 => ["arg1", "arg2"], +/// TestArgs::Arg3 => ["arg1", "arg2", "arg3"], +/// } +/// ); +/// assert_eq!(format!("{command:?}"), r#""echo" "test" "arg1" "arg2""#.to_string()); +/// ``` +/// +/// ## Dynamic Closures +/// ``` +/// # use comlexr_macro::cmd; +/// let numbers = vec![1, 2, 3]; +/// let multiplier = 2; +/// +/// let command = cmd!("echo", || numbers.into_iter().map(|n| format!("{}", n * multiplier))); +/// assert_eq!(format!("{command:?}"), r#""echo" "2" "4" "6""#.to_string()); +/// ``` +#[proc_macro] +pub fn cmd(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let command = parse_macro_input!(input as cmd::Command); + quote! { #command }.into() +} + +/// Chain the stdout of commands to stdin. Execution is lazy so commands aren't run until `status()` or `output()` is called. +/// +/// ```ignore +/// use comlexr::{pipe, cmd}; +/// +/// let dir = tempfile::tempdir().unwrap(); +/// let file = dir.path().join("out"); +/// let mut pipe = pipe!(cmd!("echo", "test") | cmd!("sed", "s|e|oa|") | cmd!("tee", &file)); +/// +/// let output = pipe.output().unwrap(); +/// assert!(output.status.success()); +/// assert_eq!(String::from_utf8_lossy(&output.stdout), "toast\n"); +/// ``` +/// +/// Or pass data via stdin. +/// +/// NOTE: Data must implement `AsRef<[u8]>`. +/// +/// ```ignore +/// use comlexr::{pipe, cmd}; +/// +/// let mut pipe = pipe!(stdin = "test"; cmd!("sed", "s|e|oa|")); +/// +/// let output = pipe.output().unwrap(); +/// assert!(output.status.success()); +/// assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +/// ``` +#[proc_macro] +pub fn pipe(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let pipe = parse_macro_input!(input as pipe::Pipe); + quote! { #pipe }.into() +} diff --git a/macro/src/macros.rs b/macro/src/macros.rs new file mode 100644 index 0000000..baa814c --- /dev/null +++ b/macro/src/macros.rs @@ -0,0 +1,13 @@ +macro_rules! enum_to_tokens { + ($enum:ident: $($variant:ident),*) => { + impl ToTokens for $enum { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(match self { + $(Self::$variant(variant) => quote! { #variant },)* + }); + } + } + }; +} + +pub(crate) use enum_to_tokens; diff --git a/macro/src/pipe.rs b/macro/src/pipe.rs new file mode 100644 index 0000000..d2052ed --- /dev/null +++ b/macro/src/pipe.rs @@ -0,0 +1,174 @@ +use quote::{quote, ToTokens}; +use syn::{ + parse::{discouraged::Speculative, Parse}, + punctuated::Punctuated, + spanned::Spanned, + Error, Token, +}; + +use crate::{cmd::Value, macros::enum_to_tokens}; + +pub struct Pipe { + stdin: Option, + commands: Punctuated, +} + +impl Parse for Pipe { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let stdin = match input.cursor().ident() { + Some((ident, _)) if ident == "stdin" && input.peek2(Token![=]) => { + _ = input.parse::()?; + _ = input.parse::()?; + let stdin = input.parse()?; + _ = input.parse::()?; + Some(stdin) + } + _ => None, + }; + let commands = Punctuated::parse_separated_nonempty(input)?; + + if commands.is_empty() { + return Err(Error::new_spanned( + commands, + "At least one command is required", + )); + } + + Ok(Self { stdin, commands }) + } +} + +impl ToTokens for Pipe { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let Self { stdin, commands } = self; + let command_count = commands.len(); + let last_command_span = commands.last().unwrap().span(); + let mut commands_iter = commands.iter(); + let first_command = commands_iter.next().unwrap(); + + let initial = quote! { + let mut _c_0 = #first_command; + _c_0.stdout(::std::process::Stdio::piped()); + _c_0.stdin(::std::process::Stdio::piped()); + let mut _child_0 = _c_0.spawn()?; + + if let Some(stdin) = stdin { + _child_0 + .stdin + .as_mut() + .ok_or(::comlexr::ExecutorError::NoStdIn)? + .write_all(stdin.as_ref())?; + } + }; + + let stdin = stdin.as_ref().map_or_else( + || { + quote! { + None::<&[u8]> + } + }, + |stdin| { + quote! { + Some(#stdin) + } + }, + ); + + let commands = commands_iter.enumerate().map(|(index, command)| { + let previous_span = if index == 0 { + first_command.span() + } else { + commands[index - 1].span() + }; + let prev_com_ident = syn::Ident::new(&format!("_c_{index}"), previous_span); + let prev_child_ident = syn::Ident::new(&format!("_child_{index}"), previous_span); + let com_ident = syn::Ident::new(&format!("_c_{}", index + 1), command.span()); + let child_ident = syn::Ident::new(&format!("_child_{}", index + 1), command.span()); + + quote! { + let _output = #prev_child_ident.wait_with_output()?; + + if !_output.status.success() { + return Err(::comlexr::ExecutorError::FailedCommand{ + command: #prev_com_ident, + exit_code: _output.status.code().unwrap_or(1), + }); + } + + let mut #com_ident = #command; + #com_ident.stdout(::std::process::Stdio::piped()); + #com_ident.stdin(::std::process::Stdio::piped()); + + let mut #child_ident = #com_ident.spawn()?; + #child_ident + .stdin + .as_mut() + .ok_or(::comlexr::ExecutorError::NoStdIn)? + .write_all(&_output.stdout)?; + } + }); + + let last_child_ident = + syn::Ident::new(&format!("_child_{}", command_count - 1), last_command_span); + + tokens.extend(quote! { + ::comlexr::Executor::new( + #stdin, + |stdin| -> ::std::result::Result< + ::std::process::Child, + ::comlexr::ExecutorError, + > { + use ::std::io::Write; + #initial + #(#commands)* + Ok(#last_child_ident) + } + ) + }); + } +} + +enum CommandExpr { + Macro(syn::ExprMacro), + Reference(syn::ExprReference), + Function(syn::ExprCall), + Block(syn::ExprBlock), +} + +impl Parse for CommandExpr { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let fork = input.fork(); + + fork.parse() + .map(|value| { + input.advance_to(&fork); + Self::Macro(value) + }) + .or_else(|_| { + let fork = input.fork(); + let value = fork.parse()?; + input.advance_to(&fork); + Ok(Self::Function(value)) + }) + .or_else(|_: syn::Error| { + let fork = input.fork(); + let value = fork.parse()?; + input.advance_to(&fork); + Ok(Self::Block(value)) + }) + .or_else(|_: syn::Error| { + let fork = input.fork(); + let value = fork.parse()?; + input.advance_to(&fork); + Ok(Self::Reference(value)) + }) + .map_err(|_: syn::Error| { + syn::Error::new( + input.span(), + "Only references, function calls, macro calls, and blocks are allowed", + ) + }) + } +} + +enum_to_tokens! {CommandExpr: Macro, Reference, Function, Block} diff --git a/src/lib.rs b/src/lib.rs index 501ffa1..9ad52e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,177 +1,8 @@ #![doc = include_str!("../README.md")] -extern crate quote; -extern crate syn; +extern crate comlexr_macro; -use quote::quote; -use syn::parse_macro_input; +pub use comlexr_macro::*; +pub use pipe::*; -mod cmd; - -/// Generates a command expression by combining static strings, conditional logic, loops, -/// pattern matching, and closures to dynamically build and format command-line arguments. -/// -/// The `cmd!` macro supports flexible syntax constructs such as: -/// - **Static arguments**: Basic string arguments. -/// - **Conditional inclusion**: Using `if` or `if let` to conditionally include arguments. -/// - **Iterative inclusion**: Using `for` loops to include multiple arguments from collections. -/// - **Pattern matching**: Using `match` expressions for dynamic argument selection. -/// - **Closures**: Dynamically generating arguments at runtime. -/// -/// # Examples -/// -/// ## Basic Usage -/// ``` -/// use comlexr::cmd; -/// -/// let command = cmd!("echo", "test"); -/// assert_eq!(format!("{command:?}"), r#""echo" "test""#.to_string()); -/// ``` -/// -/// ## Current Directory -/// ``` -/// use comlexr::cmd; -/// -/// let command = cmd!( -/// cd "~/"; -/// "echo", -/// "test", -/// ); -/// -/// assert_eq!(format!("{command:?}"), r#"cd "~/" && "echo" "test""#); -/// ``` -/// -/// ## Environment Vars -/// ``` -/// use comlexr::cmd; -/// -/// const NEW_VAR: &str = "NEW_VAR"; -/// -/// let command = cmd!( -/// env { -/// "TEST": "test", -/// NEW_VAR: "new_var" -/// }; -/// "echo", -/// "test", -/// ); -/// -/// assert_eq!(format!("{command:?}"), r#"NEW_VAR="new_var" TEST="test" "echo" "test""#); -/// ``` -/// -/// ### Current Directory and Environment Variable Order -/// ``` -/// use comlexr::cmd; -/// -/// let command = cmd!( -/// cd "~/"; -/// env { -/// "TEST": "test", -/// }; -/// "echo", -/// "test", -/// ); -/// -/// assert_eq!( -/// format!("{command:?}"), -/// r#"cd "~/" && TEST="test" "echo" "test""# -/// ); -/// -/// ``` -/// -/// ## Conditional Arguments -/// ``` -/// use comlexr::cmd; -/// -/// let include_arg = true; -/// -/// let command = cmd!("echo", "test", if include_arg => "optional_arg"); -/// assert_eq!(format!("{command:?}"), r#""echo" "test" "optional_arg""#.to_string()); -/// ``` -/// -/// ## Conditional Pattern Matching -/// ``` -/// use comlexr::cmd; -/// -/// let single_option = Some("single"); -/// let multi_option: Option<&str> = None; -/// -/// let command = cmd!( -/// "echo", -/// "test", -/// if let Some(arg) = single_option => arg, -/// if let Some(arg) = multi_option => [ -/// "multi", -/// arg, -/// ], -/// ); -/// assert_eq!(format!("{command:?}"), r#""echo" "test" "single""#.to_string()); -/// ``` -/// -/// ## Iterative Argument Inclusion -/// ``` -/// use comlexr::cmd; -/// -/// let args = &["arg1", "arg2"]; -/// -/// let command = cmd!("echo", for args); -/// assert_eq!(format!("{command:?}"), r#""echo" "arg1" "arg2""#.to_string()); -/// ``` -/// -/// ## Iteration with `for in` -/// ``` -/// use comlexr::cmd; -/// -/// let single_iter = &["arg1", "arg2"]; -/// let multi_iter = &["multi1", "multi2"]; -/// -/// let command = cmd!( -/// "echo", -/// "test", -/// for arg in single_iter => arg, -/// for arg in multi_iter => [ -/// "multi", -/// arg, -/// ], -/// ); -/// assert_eq!(format!("{command:?}"), r#""echo" "test" "arg1" "arg2" "multi" "multi1" "multi" "multi2""#.to_string()); -/// ``` -/// -/// ## Match Statements -/// ``` -/// use comlexr::cmd; -/// -/// enum TestArgs { -/// Arg1, -/// Arg2, -/// Arg3, -/// } -/// -/// let match_arg = TestArgs::Arg2; -/// let command = cmd!( -/// "echo", -/// "test", -/// match match_arg { -/// TestArgs::Arg1 => "arg1", -/// TestArgs::Arg2 => ["arg1", "arg2"], -/// TestArgs::Arg3 => ["arg1", "arg2", "arg3"], -/// } -/// ); -/// assert_eq!(format!("{command:?}"), r#""echo" "test" "arg1" "arg2""#.to_string()); -/// ``` -/// -/// ## Dynamic Closures -/// ``` -/// use comlexr::cmd; -/// -/// let numbers = vec![1, 2, 3]; -/// let multiplier = 2; -/// -/// let command = cmd!("echo", || numbers.into_iter().map(|n| format!("{}", n * multiplier))); -/// assert_eq!(format!("{command:?}"), r#""echo" "2" "4" "6""#.to_string()); -/// ``` -#[proc_macro] -pub fn cmd(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - let command = parse_macro_input!(input as cmd::Command); - quote! { #command }.into() -} +mod pipe; diff --git a/src/pipe.rs b/src/pipe.rs new file mode 100644 index 0000000..b99d393 --- /dev/null +++ b/src/pipe.rs @@ -0,0 +1,73 @@ +use std::{ + ffi::OsStr, + process::Command, + process::{Child, ExitStatus, Output}, +}; + +use thiserror::Error; + +/// Errors that can be created when running the `PipeExecutor` +#[derive(Debug, Error)] +pub enum ExecutorError { + /// IO Error + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Failed Command Error + #[error("Failed to run command '{} {}', exit code {exit_code}", + .command + .get_program() + .to_string_lossy(), + .command + .get_args() + .map(OsStr::to_string_lossy) + .collect::>() + .join(" "), + )] + FailedCommand { command: Command, exit_code: i32 }, + + /// No stdin Error + #[error("Unable to get mutable stdin")] + NoStdIn, +} + +/// A lazy piped command executor. +pub struct Executor<'a, S> +where + S: AsRef<[u8]> + ?Sized, +{ + stdin: Option<&'a S>, + piped_commands: Box) -> Result + 'a>, +} + +impl<'a, S> Executor<'a, S> +where + S: AsRef<[u8]> + ?Sized, +{ + /// Construct a `PipeExecutor`. + pub fn new( + stdin: Option<&'a S>, + piped_commands: impl FnMut(Option<&'a S>) -> Result + 'a, + ) -> Self { + Self { + stdin, + piped_commands: Box::new(piped_commands), + } + } + + /// Retrieves the `ExitStatus` of the last command. + /// + /// # Errors + /// Will error if the exit status of any chained commands fail. + pub fn status(&mut self) -> Result { + Ok((self.piped_commands)(self.stdin)?.wait()?) + } + + /// Retrieves the `Output` of the last command. + /// + /// # Errors + /// Will error if the exit status of any chained commands fail. + pub fn output(&mut self) -> Result { + Ok((self.piped_commands)(self.stdin)?.wait_with_output()?) + } +} diff --git a/tests/pipe.rs b/tests/pipe.rs new file mode 100644 index 0000000..d36f9bc --- /dev/null +++ b/tests/pipe.rs @@ -0,0 +1,104 @@ +use core::panic; +use std::ffi::OsStr; + +use comlexr::{cmd, pipe}; +use tempfile::tempdir; + +#[test] +fn pipe_status() { + let dir = tempdir().unwrap(); + let file = dir.path().join("out"); + let mut pipe = pipe!(cmd!("echo", "test") | cmd!("tee", &file)); + + let status = pipe.status().unwrap(); + assert!(status.success()); +} + +#[test] +fn pipe_stdin_output() { + let dir = tempdir().unwrap(); + let file = dir.path().join("out"); + let mut pipe = pipe!(stdin = "test"; cmd!("sed", "s|e|oa|") | cmd!("tee", &file)); + println!("{}", file.display()); + + let output = pipe.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +} + +#[test] +fn pipe_stdin_output_single_command() { + let mut pipe = pipe!(stdin = "test"; cmd!("sed", "s|e|oa|")); + + let output = pipe.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +} + +#[test] +fn pipe_expect_fail() { + let mut pipe = pipe!(stdin = "test"; cmd!("false") | cmd!("sed", "s|e|oa|")); + + let error = pipe.output().unwrap_err(); + + match error { + comlexr::ExecutorError::FailedCommand { command, exit_code } => { + assert_eq!(command.get_program(), OsStr::new("false")); + assert_eq!(exit_code, 1); + } + err => panic!("Expected exit code, got: {err}"), + } +} + +#[test] +fn pipe_expect_fail_command_not_exist() { + let mut pipe = pipe!(stdin = "test"; cmd!("no_command") | cmd!("sed", "s|e|oa|")); + + let error = pipe.output().unwrap_err(); + + match error { + comlexr::ExecutorError::Io(err) => assert_eq!(err.raw_os_error().unwrap(), 2), + err => panic!("Expected IO Error, got: {err}"), + } +} + +#[test] +fn pipe_block() { + let mut pipe = pipe!(stdin = "test"; { + let c = cmd!("sed", "s|e|oa|"); + println!("{c:?}"); + c + }); + + let output = pipe.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +} + +#[test] +fn pipe_ref() { + let mut command = { + let c = cmd!("sed", "s|e|oa|"); + println!("{c:?}"); + c + }; + let mut pipe = pipe!(stdin = "test"; &mut command); + + let output = pipe.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +} + +#[test] +fn pipe_fn() { + let command = || { + let c = cmd!("sed", "s|e|oa|"); + println!("{c:?}"); + c + }; + let mut pipe = pipe!(stdin = "test"; command()); + + let output = pipe.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "toast"); +}