use proc_macro2::{Delimiter, LineColumn, Span, TokenStream, TokenTree};
use quote::quote;
#[allow(dead_code)] enum CmdArg {
Literal(String, Span),
Expr(TokenStream, Span),
Splat(TokenStream, Span),
}
pub(crate) fn expand_cmd(input: TokenStream) -> Result<TokenStream, syn::Error> {
let args = parse_args(input)?;
if args.is_empty() {
return Err(syn::Error::new(
Span::call_site(),
"cmd! requires at least a program name",
));
}
let mut chain = match &args[0] {
CmdArg::Literal(s, _) => quote! { ::rnme::cmd::Cmd::new(#s) },
CmdArg::Expr(expr, _) => quote! { ::rnme::cmd::Cmd::new(#expr) },
CmdArg::Splat(_, span) => {
return Err(syn::Error::new(
*span,
"cmd! program (first argument) cannot be a splat — it must be a single value",
));
}
};
for arg in &args[1..] {
chain = match arg {
CmdArg::Literal(s, _) => quote! { #chain.arg(#s) },
CmdArg::Expr(expr, _) => quote! { #chain.arg(#expr) },
CmdArg::Splat(expr, _) => quote! { #chain.args(#expr) },
};
}
Ok(chain)
}
fn parse_args(input: TokenStream) -> Result<Vec<CmdArg>, syn::Error> {
let mut args = Vec::new();
let mut current_word = String::new();
let mut word_span: Option<Span> = None;
let mut last_end: Option<LineColumn> = None;
for tt in input {
match &tt {
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
flush_word(&mut args, &mut current_word, &mut word_span);
last_end = Some(g.span().end());
let inner: Vec<TokenTree> = g.stream().into_iter().collect();
let trailing_dots = inner
.iter()
.rev()
.take_while(|t| matches!(t, TokenTree::Punct(p) if p.as_char() == '.'))
.count();
if trailing_dots > 3 {
return Err(syn::Error::new(
g.span(),
"splat uses exactly three dots: `{expr...}`",
));
} else if trailing_dots == 3 {
let expr_len = inner.len() - 3;
if expr_len == 0 {
return Err(syn::Error::new(
g.span(),
"empty splat: `{...}` needs an expression before `...`",
));
}
let expr_stream: TokenStream =
inner.into_iter().take(expr_len).collect();
args.push(CmdArg::Splat(expr_stream, g.span()));
} else {
args.push(CmdArg::Expr(g.stream(), g.span()));
}
}
TokenTree::Group(g) => {
return Err(syn::Error::new(
g.span(),
"unexpected group in cmd! — use {expr} for interpolation",
));
}
TokenTree::Literal(lit) => {
let repr = lit.to_string();
let is_string =
repr.starts_with('"') || repr.starts_with("r\"") || repr.starts_with("r#");
if is_string {
flush_word(&mut args, &mut current_word, &mut word_span);
last_end = Some(lit.span().end());
let s: syn::LitStr = syn::parse2(TokenStream::from(tt.clone()))?;
args.push(CmdArg::Literal(s.value(), lit.span()));
} else {
let start = lit.span().start();
if !is_adjacent(last_end, start) {
flush_word(&mut args, &mut current_word, &mut word_span);
}
last_end = Some(lit.span().end());
if word_span.is_none() {
word_span = Some(lit.span());
}
current_word.push_str(&repr);
}
}
TokenTree::Ident(ident) => {
let start = ident.span().start();
if !is_adjacent(last_end, start) {
flush_word(&mut args, &mut current_word, &mut word_span);
}
last_end = Some(ident.span().end());
if word_span.is_none() {
word_span = Some(ident.span());
}
current_word.push_str(&ident.to_string());
}
TokenTree::Punct(punct) => {
let start = punct.span().start();
if !is_adjacent(last_end, start) {
flush_word(&mut args, &mut current_word, &mut word_span);
}
last_end = Some(punct.span().end());
if word_span.is_none() {
word_span = Some(punct.span());
}
current_word.push(punct.as_char());
}
}
}
flush_word(&mut args, &mut current_word, &mut word_span);
Ok(args)
}
fn flush_word(args: &mut Vec<CmdArg>, word: &mut String, span: &mut Option<Span>) {
if !word.is_empty() {
args.push(CmdArg::Literal(
std::mem::take(word),
span.take().unwrap_or_else(Span::call_site),
));
}
*span = None;
}
fn is_adjacent(last_end: Option<LineColumn>, start: LineColumn) -> bool {
match last_end {
None => true,
Some(end) => end.line == start.line && end.column == start.column,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_arg_strings(input: &str) -> Vec<String> {
let ts: TokenStream = input.parse().unwrap();
let args = parse_args(ts).unwrap();
args.into_iter()
.map(|a| match a {
CmdArg::Literal(s, _) => format!("lit:{s}"),
CmdArg::Expr(ts, _) => format!("expr:{ts}"),
CmdArg::Splat(ts, _) => format!("splat:{ts}"),
})
.collect()
}
#[test]
fn simple_command() {
assert_eq!(
parse_arg_strings("curl -X POST"),
vec!["lit:curl", "lit:-X", "lit:POST"]
);
}
#[test]
fn double_dash_flag() {
assert_eq!(
parse_arg_strings("cargo build --release"),
vec!["lit:cargo", "lit:build", "lit:--release"]
);
}
#[test]
fn string_literal_arg() {
assert_eq!(
parse_arg_strings(r#"curl -H "Content-Type: application/json""#),
vec!["lit:curl", "lit:-H", "lit:Content-Type: application/json"]
);
}
#[test]
fn interpolation() {
let result = parse_arg_strings("curl -X POST {&url}");
assert_eq!(result[..3], ["lit:curl", "lit:-X", "lit:POST"]);
assert!(result[3].starts_with("expr:"));
}
#[test]
fn mixed_literal_and_interpolation() {
let result = parse_arg_strings(
r#"curl -X POST {&url} -H "Content-Type: multipart/form-data" -F {&path}"#,
);
assert_eq!(result[0], "lit:curl");
assert_eq!(result[1], "lit:-X");
assert_eq!(result[2], "lit:POST");
assert!(result[3].starts_with("expr:"));
assert_eq!(result[4], "lit:-H");
assert_eq!(result[5], "lit:Content-Type: multipart/form-data");
assert_eq!(result[6], "lit:-F");
assert!(result[7].starts_with("expr:"));
}
#[test]
fn empty_input() {
let ts: TokenStream = "".parse().unwrap();
assert!(expand_cmd(ts).is_err());
}
#[test]
fn numeric_arg() {
assert_eq!(parse_arg_strings("echo 42"), vec!["lit:echo", "lit:42"]);
}
#[test]
fn equals_in_flag() {
assert_eq!(
parse_arg_strings("cargo build --jobs=4"),
vec!["lit:cargo", "lit:build", "lit:--jobs=4"]
);
}
#[test]
fn path_like_arg() {
assert_eq!(
parse_arg_strings("ls src/main.rs"),
vec!["lit:ls", "lit:src/main.rs"]
);
}
#[test]
fn splat_basic() {
let result = parse_arg_strings("do {foo...} thing");
assert_eq!(result[0], "lit:do");
assert!(result[1].starts_with("splat:"));
assert_eq!(result[2], "lit:thing");
}
#[test]
fn splat_with_method_call() {
let result = parse_arg_strings("cargo test {names.iter()...}");
assert!(result[2].starts_with("splat:"));
assert!(result[2].contains("names"));
assert!(result[2].contains("iter"));
}
#[test]
fn splat_with_range_in_expr() {
let result = parse_arg_strings("echo {0..10}");
assert!(result[1].starts_with("expr:"));
}
#[test]
fn empty_splat_is_error() {
let ts: TokenStream = "echo {...}".parse().unwrap();
assert!(parse_args(ts).is_err());
}
#[test]
fn too_many_dots_is_error() {
let ts: TokenStream = "echo {foo....}".parse().unwrap();
assert!(parse_args(ts).is_err());
}
#[test]
fn splat_as_program_is_error() {
let ts: TokenStream = "{progs...}".parse().unwrap();
assert!(expand_cmd(ts).is_err());
}
}