diff --git a/git-version-macro/src/describe_submodules.rs b/git-version-macro/src/describe_submodules.rs new file mode 100644 index 0000000..e35fe22 --- /dev/null +++ b/git-version-macro/src/describe_submodules.rs @@ -0,0 +1,176 @@ +extern crate proc_macro; +use crate::canonicalize_path; +use crate::git_dependencies; +use crate::utils::run_git; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use std::ffi::OsStr; +use std::path::Path; +use std::process::Command; +use syn::{ + bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + token::{Comma, Eq}, + Ident, LitStr, +}; + +macro_rules! error { + ($($args:tt)*) => { + syn::Error::new(Span::call_site(), format!($($args)*)) + }; +} + +#[derive(Default)] +pub(crate) struct GitModArgs { + args: Option>, + prefix: Option, + suffix: Option, + fallback: Option, +} + +impl Parse for GitModArgs { + fn parse(input: ParseStream) -> syn::Result { + let mut result = GitModArgs::default(); + loop { + if input.is_empty() { + break; + } + let ident: Ident = input.parse()?; + let _: Eq = input.parse()?; + let check_dup = |dup: bool| { + if dup { + Err(error!("`{} = ` can only appear once", ident)) + } else { + Ok(()) + } + }; + match ident.to_string().as_str() { + "args" => { + check_dup(result.args.is_some())?; + let content; + bracketed!(content in input); + result.args = Some(Punctuated::parse_terminated(&content)?); + } + "prefix" => { + check_dup(result.prefix.is_some())?; + result.prefix = Some(input.parse()?); + } + "suffix" => { + check_dup(result.suffix.is_some())?; + result.suffix = Some(input.parse()?); + } + "fallback" => { + check_dup(result.fallback.is_some())?; + result.fallback = Some(input.parse()?); + } + x => Err(error!("Unexpected argument name `{}`", x))?, + } + if input.is_empty() { + break; + } + let _: Comma = input.parse()?; + } + Ok(result) + } +} + +pub(crate) fn git_module_versions_impl(args: GitModArgs) -> syn::Result { + let mut modules = match get_modules() { + Ok(x) => x, + Err(err) => return Err(error!("{}", err)), + }; + + modules.retain(|path| !path.is_empty()); + + let mut describe_paths: Vec<(String, String)> = vec![]; + + for path in modules { + let path_obj = Path::new(&path); + let path_obj = canonicalize_path(path_obj)?; + describe_paths.push((path, path_obj)); + } + + let git_describe_args = args.args.map_or_else( + || vec!["--always".to_string(), "--dirty=-modified".to_string()], + |list| list.iter().map(|x| x.value()).collect(), + ); + + let prefix = match args.prefix { + Some(x) => x.value(), + _ => "".to_string(), + }; + let suffix = match args.suffix { + Some(x) => x.value(), + _ => "".to_string(), + }; + let fallback = args.fallback.map(|x| x.value()); + + match describe_modules(describe_paths, &git_describe_args, prefix, suffix, fallback) { + Ok(result) => { + let dependencies = git_dependencies()?; + let (paths, versions) = result; + + Ok(quote!({ + #dependencies; + + [#((#paths, #versions)),*] + + })) + } + Err(e) => Err(error!("{}", e)), + } +} + +/// Run `git submodule foreach` command to discover submodules in the project. +fn get_modules() -> Result, String> { + let mut args: Vec = "submodule foreach --quiet --recursive" + .to_string() + .split(' ') + .map(|x| x.to_string()) + .collect(); + + args.push("echo $displaypath".to_string()); + + let result = run_git("git submodule", Command::new("git").args(args))?; + + Ok(result.split('\n').map(|x| x.to_string()).collect()) +} + +/// Run `git describe` for each submodule to get the git version with the specified args. +fn describe_modules( + paths: Vec<(String, String)>, + describe_args: I, + prefix: String, + suffix: String, + fallback: Option, +) -> Result<(Vec, Vec), String> +where + I: IntoIterator + Clone, + S: AsRef, +{ + let mut paths_out: Vec = vec![]; + let mut versions: Vec = vec![]; + + for (rel_path, abs_path) in paths.into_iter() { + // Get the submodule version or fallback. + let result = match run_git( + "git describe", + Command::new("git") + .current_dir(abs_path) + .arg("describe") + .args(describe_args.clone()), + ) { + Ok(version) => version, + Err(_git_err) if fallback.is_some() => fallback.clone().unwrap(), + Err(git_err) => { + // If git error and no fallback provided, return error. + return Err(git_err); + } + }; + paths_out.push(rel_path); + versions.push(format!("{}{}{}", prefix, result, suffix)) + } + + Ok((paths_out, versions)) +} diff --git a/git-version-macro/src/lib.rs b/git-version-macro/src/lib.rs index 8fb5a37..433a242 100644 --- a/git-version-macro/src/lib.rs +++ b/git-version-macro/src/lib.rs @@ -6,7 +6,7 @@ use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::token::{Comma, Eq}; use syn::{Expr, Ident, LitStr}; - +pub(crate) mod describe_submodules; mod utils; use self::utils::{describe_cwd, git_dir_cwd}; @@ -17,8 +17,7 @@ macro_rules! error { } fn canonicalize_path(path: &Path) -> syn::Result { - path - .canonicalize() + path.canonicalize() .map_err(|e| error!("failed to canonicalize {}: {}", path.display(), e))? .into_os_string() .into_string() @@ -29,12 +28,18 @@ fn canonicalize_path(path: &Path) -> syn::Result { fn git_dependencies() -> syn::Result { let git_dir = git_dir_cwd().map_err(|e| error!("failed to determine .git directory: {}", e))?; - let deps: Vec<_> = ["logs/HEAD", "index"].iter().flat_map(|&file| { - canonicalize_path(&git_dir.join(file)).map(Some).unwrap_or_else(|e| { - eprintln!("Failed to add dependency on the git state: {}. Git state changes might not trigger a rebuild.", e); - None + let deps: Vec<_> = ["logs/HEAD", "index"] + .iter() + .flat_map(|&file| { + canonicalize_path(&git_dir.join(file)).map(Some).unwrap_or_else(|e| { + eprintln!( + "Failed to add dependency on the git state: {}. Git state changes might not trigger a rebuild.", + e + ); + None + }) }) - }).collect(); + .collect(); Ok(quote! { #( include_bytes!(#deps); )* @@ -55,7 +60,9 @@ impl Parse for Args { fn parse(input: ParseStream) -> syn::Result { let mut result = Args::default(); loop { - if input.is_empty() { break; } + if input.is_empty() { + break; + } let ident: Ident = input.parse()?; let _: Eq = input.parse()?; let check_dup = |dup: bool| { @@ -94,7 +101,9 @@ impl Parse for Args { } x => Err(error!("Unexpected argument name `{}`", x))?, } - if input.is_empty() { break; } + if input.is_empty() { + break; + } let _: Comma = input.parse()?; } Ok(result) @@ -151,7 +160,7 @@ pub fn git_version(input: TokenStream) -> TokenStream { fn git_version_impl(args: Args) -> syn::Result { let git_args = args.git_args.map_or_else( || vec!["--always".to_string(), "--dirty=-modified".to_string()], - |list| list.iter().map(|x| x.value()).collect() + |list| list.iter().map(|x| x.value()).collect(), ); let cargo_fallback = args.cargo_prefix.is_some() || args.cargo_suffix.is_some(); @@ -170,20 +179,65 @@ fn git_version_impl(args: Args) -> syn::Result { if let Ok(version) = std::env::var("CARGO_PKG_VERSION") { let prefix = args.cargo_prefix.iter(); let suffix = args.cargo_suffix; - Ok(quote!( - concat!(#(#prefix,)* #version, #suffix) - )) + Ok(quote!(concat!(#(#prefix,)* #version, #suffix))) } else if let Some(fallback) = args.fallback { Ok(fallback.to_token_stream()) } else { Err(error!("Unable to get git or cargo version")) } } - Err(_) if args.fallback.is_some() => { - Ok(args.fallback.to_token_stream()) - } - Err(e) => { - Err(error!("{}", e)) - } + Err(_) if args.fallback.is_some() => Ok(args.fallback.to_token_stream()), + Err(e) => Err(error!("{}", e)), } } + +/// Get the git version for submodules below the cargo project. +/// +/// This macro will not infer type if there are no submodules in the project. +/// +/// This macro expands to `[(&str, &str), N]` where `N` is the total number of +/// submodules below the root of the project (evaluated recursively) +/// +/// The format of the array is as follows: +/// +/// `[("relative/path/to/submodule", "{prefix}{git_describe_output}{suffix}")]` +/// +/// The following (named) arguments can be given: +/// +/// - `args`: The arguments to call `git describe` with. +/// Default: `args = ["--always", "--dirty=-modified"]` +/// +/// - `prefix`, `suffix`: +/// The git version for each submodule will be prefixed/suffixed +/// by these strings. +/// +/// - `fallback`: +/// If all else fails, this string will be given instead of reporting an +/// error. This will yield the same type as if the macro was a success, but +/// format will be `[("relative/path/to/submodule", {fallback})]` +/// +/// # Examples +/// +/// ``` +/// const MODULE_VERSIONS: [(&str, &str), N] = git_version_modules!(); +/// ``` +/// +/// ``` +/// const MODULE_VERSIONS: [(&str, &str), N] = git_version_modules!(args = ["--abbrev=40", "--always"]); +/// ``` +/// +/// ``` +/// # use git_version::git_version_modules; +/// const MODULE_VERSIONS: [(&str, &str), N] = git_version_modules!(prefix = "git:", fallback = "unknown"); +/// ``` +#[proc_macro] +pub fn git_module_versions(input: TokenStream) -> TokenStream { + let args = syn::parse_macro_input!(input as describe_submodules::GitModArgs); + + let tokens = match describe_submodules::git_module_versions_impl(args) { + Ok(x) => x, + Err(e) => e.to_compile_error(), + }; + + TokenStream::from(tokens) +} diff --git a/git-version-macro/src/utils.rs b/git-version-macro/src/utils.rs index b650ea6..5aa3271 100644 --- a/git-version-macro/src/utils.rs +++ b/git-version-macro/src/utils.rs @@ -17,7 +17,7 @@ pub fn git_dir_cwd() -> Result { Ok(PathBuf::from(path)) } -fn run_git(program: &str, command: &mut std::process::Command) -> Result { +pub(crate) fn run_git(program: &str, command: &mut std::process::Command) -> Result { let output = command .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -34,8 +34,8 @@ fn run_git(program: &str, command: &mut std::process::Command) -> Result Result // If the command terminated with non-zero exit code, return an error. } else if let Some(status) = output.status.code() { // Include the first line of stderr in the error message, if it's valid UTF-8 and not empty. - let message = output.stderr.split(|c| *c == b'\n') + let message = output + .stderr + .split(|c| *c == b'\n') .next() .and_then(|x| std::str::from_utf8(x).ok()) .filter(|x| !x.is_empty()); @@ -82,8 +84,8 @@ fn strip_trailing_newline(mut input: Vec) -> Vec { #[test] fn test_git_dir() { - use std::path::Path; use assert2::{assert, let_assert}; + use std::path::Path; let_assert!(Ok(git_dir) = git_dir_cwd()); let_assert!(Ok(git_dir) = git_dir.canonicalize()); diff --git a/src/lib.rs b/src/lib.rs index 310dbd4..260df3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ //! These macros do not depend on libgit, but simply uses the `git` binary directly. //! So you must have `git` installed somewhere in your `PATH`. -pub use git_version_macro::git_version; +pub use git_version_macro::{git_module_versions, git_version}; /// Run `git describe` at compile time with custom flags. /// diff --git a/tests/version.rs b/tests/version.rs index d81fc8a..428d138 100644 --- a/tests/version.rs +++ b/tests/version.rs @@ -1,4 +1,4 @@ -use git_version::{git_describe, git_version}; +use git_version::{git_describe, git_module_versions, git_version}; #[test] fn git_describe_is_right() { @@ -14,3 +14,42 @@ fn git_describe_is_right() { assert_eq!(git_describe!("--always", "--dirty=-modified"), name); assert_eq!(git_version!(prefix = "[", suffix = "]"), format!("[{}]", name)); } + +#[test] +fn test_modules_macro_gives_expected_output() { + let vec = std::process::Command::new("git") + .args(["submodule", "foreach", "--quiet", "--recursive", "echo $displaypath"]) + .output() + .expect("failed to execute git") + .stdout; + let mut submodules: Vec = String::from_utf8(vec) + .expect("Failed to gather submodules for test") + .trim_end() + .to_string() + .split("\n") + .map(|str| str.to_string()) + .collect(); + + submodules.retain(|path| path != ""); + + let mut expected_result: Vec<(String, String)> = vec![]; + for submodule in submodules.into_iter() { + let abs_path = std::fs::canonicalize(submodule.clone()).expect("Failed to canonicalize submodule path in test"); + let vec = std::process::Command::new("git") + .current_dir(abs_path) + .args(["describe", "--always", "--dirty=-modified"]) + .output() + .expect("failed to execute git") + .stdout; + let name = std::str::from_utf8(&vec[..vec.len() - 1]).expect("non-utf8 error?!"); + expected_result.push((submodule.clone(), name.to_string())) + } + + let boxed_slice: Box<[(&str, &str)]> = expected_result + .iter() + .map(|(path, version)| (path.as_str(), version.as_str())) + .collect::>() + .into_boxed_slice(); + + assert_eq!(*boxed_slice, git_module_versions!(args = ["--always", "--dirty=-modified"])); +}