use std::env; use std::error::Error; use std::fs; use std::io; use std::path::{Path, PathBuf}; use clap::{Command, CommandFactory, ValueEnum}; use clap_complete::{Shell, generate_to}; use clap_mangen::Man; use flate2::{Compression, write::GzEncoder}; use i18n_embed::unic_langid::LanguageIdentifier; use quote::ToTokens; const JSON_RPC_METHODS_RS: &str = "src/components/json_rpc/methods.rs"; mod i18n { include!("src/i18n.rs"); } mod zallet { include!("src/cli.rs"); } #[macro_export] macro_rules! fl { ($message_id:literal) => {{ i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id) }}; ($message_id:literal, $($args:expr),* $(,)?) => {{ i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *) }}; } fn main() -> Result<(), Box> { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rerun-if-changed=src/cli.rs"); println!("cargo::rerun-if-changed=src/i18n.rs"); println!("cargo::rerun-if-changed={JSON_RPC_METHODS_RS}"); // Expose a cfg option so we can make parts of the CLI conditional on not being built // within the buildscript. println!("cargo:rustc-cfg=outside_buildscript"); let out_dir = match env::var_os("OUT_DIR") { None => return Ok(()), Some(out_dir) => PathBuf::from(out_dir), }; // `OUT_DIR` is "intentionally opaque as it is only intended for `rustc` interaction" // (https://github.com/rust-lang/cargo/issues/9858). Peek into the black box and use // it to figure out where the target directory is. let target_dir = out_dir .ancestors() .nth(3) .expect("should be absolute path") .to_path_buf(); generate_rpc_openrpc(&out_dir)?; // Generate the completions in English, because these aren't easily localizable. i18n::load_languages(&[]); Cli::build().generate_completions(&target_dir.join("completions"))?; // Generate manpages for all supported languages. let manpage_dir = target_dir.join("manpages"); for lang_dir in fs::read_dir("./i18n")? { let lang_dir = lang_dir?.file_name(); let lang_dir = lang_dir.to_str().expect("should be valid Unicode"); println!("cargo::rerun-if-changed=i18n/{lang_dir}/zallet.ftl"); let lang: LanguageIdentifier = lang_dir .parse() .expect("should be valid language identifier"); // Render the manpages into the correct folder structure, so that local checks can // be performed with `man -M target/debug/manpages BINARY_NAME`. let mut out_dir = if lang.language.as_str() == "en" { manpage_dir.clone() } else { let mut lang_str = lang.language.as_str().to_owned(); if let Some(region) = lang.region { // Locales for manpages use the POSIX format with underscores. lang_str += "_"; lang_str += region.as_str(); } manpage_dir.join(lang_str) }; out_dir.push("man1"); i18n::load_languages(&[lang]); Cli::build().generate_manpages(&out_dir)?; } Ok(()) } #[derive(Clone)] struct Cli { zallet: Command, } impl Cli { fn build() -> Self { Self { zallet: zallet::EntryPoint::command(), } } fn generate_completions(&mut self, out_dir: &Path) -> io::Result<()> { fs::create_dir_all(out_dir)?; for &shell in Shell::value_variants() { generate_to(shell, &mut self.zallet, "zallet", out_dir)?; } Ok(()) } fn generate_manpages(self, out_dir: &Path) -> io::Result<()> { fs::create_dir_all(out_dir)?; fn generate_manpage( out_dir: &Path, name: &str, cmd: Command, custom: impl FnOnce(&Man, &mut GzEncoder) -> io::Result<()>, ) -> io::Result<()> { let file = fs::File::create(out_dir.join(format!("{}.1.gz", name)))?; let mut w = GzEncoder::new(file, Compression::best()); let man = Man::new(cmd); man.render_title(&mut w)?; man.render_name_section(&mut w)?; man.render_synopsis_section(&mut w)?; man.render_description_section(&mut w)?; man.render_options_section(&mut w)?; custom(&man, &mut w)?; man.render_version_section(&mut w)?; man.render_authors_section(&mut w) } generate_manpage( out_dir, "zallet", self.zallet .about(fl!("man-zallet-about")) .long_about(fl!("man-zallet-description")), |_, _| Ok(()), )?; Ok(()) } } fn generate_rpc_openrpc(out_dir: &Path) -> Result<(), Box> { // Parse the source file containing the `Rpc` trait. let methods_rs = fs::read_to_string(JSON_RPC_METHODS_RS)?; let methods_ast = syn::parse_file(&methods_rs)?; let rpc_trait = methods_ast .items .iter() .find_map(|item| match item { syn::Item::Trait(item_trait) if item_trait.ident == "Rpc" => Some(item_trait), _ => None, }) .expect("present"); let mut contents = "#[allow(unused_qualifications)] pub(super) static METHODS: ::phf::Map<&str, RpcMethod> = ::phf::phf_map! { " .to_string(); for item in &rpc_trait.items { if let syn::TraitItem::Fn(method) = item { // Find methods via their `#[method(name = "command")]` attribute. let mut command = None; method .attrs .iter() .find(|attr| attr.path().is_ident("method")) .and_then(|attr| { attr.parse_nested_meta(|meta| { command = Some(meta.value()?.parse::()?.value()); Ok(()) }) .ok() }); if let Some(command) = command { let module = match &method.sig.output { syn::ReturnType::Type(_, ret) => match ret.as_ref() { syn::Type::Path(type_path) => type_path.path.segments.first(), _ => None, }, _ => None, } .expect("required") .ident .to_string(); let params = method.sig.inputs.iter().filter_map(|arg| match arg { syn::FnArg::Receiver(_) => None, syn::FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { syn::Pat::Ident(pat_ident) => { let parameter = pat_ident.ident.to_string(); let rust_ty = pat_type.ty.as_ref(); // If we can determine the parameter's optionality, do so. let (param_ty, required) = match rust_ty { syn::Type::Path(type_path) => { let is_standalone_ident = type_path.path.leading_colon.is_none() && type_path.path.segments.len() == 1; let first_segment = &type_path.path.segments[0]; if first_segment.ident == "Option" && is_standalone_ident { // Strip the `Option<_>` for the schema type. let schema_ty = match &first_segment.arguments { syn::PathArguments::AngleBracketed(args) => { match args.args.first().expect("valid Option") { syn::GenericArgument::Type(ty) => ty, _ => panic!("Invalid Option"), } } _ => panic!("Invalid Option"), }; (schema_ty, Some(false)) } else if first_segment.ident == "Vec" { // We don't know whether the vec may be empty. (rust_ty, None) } else { (rust_ty, Some(true)) } } _ => (rust_ty, Some(true)), }; // Handle a few conversions we know we need. let param_ty = param_ty.to_token_stream().to_string(); let schema_ty = match param_ty.as_str() { "age :: secrecy :: SecretString" => "String".into(), _ => param_ty, }; Some((parameter, schema_ty, required)) } _ => None, }, }); contents.push('"'); contents.push_str(&command); contents.push_str("\" => RpcMethod {\n"); contents.push_str(" description: \""); for attr in method .attrs .iter() .filter(|attr| attr.path().is_ident("doc")) { if let syn::Meta::NameValue(doc_line) = &attr.meta { if let syn::Expr::Lit(docs) = &doc_line.value { if let syn::Lit::Str(s) = &docs.lit { // Trim the leading space from the doc comment line. let line = s.value(); let trimmed_line = if line.is_empty() { &line } else { &line[1..] }; let escaped = trimmed_line.escape_default().collect::(); contents.push_str(&escaped); contents.push_str("\\n"); } } } } contents.push_str("\",\n"); contents.push_str(" params: |_g| vec![\n"); for (parameter, schema_ty, required) in params { let param_upper = parameter.to_uppercase(); contents.push_str(" _g.param::<"); contents.push_str(&schema_ty); contents.push_str(">(\""); contents.push_str(¶meter); contents.push_str("\", super::"); contents.push_str(&module); contents.push_str("::PARAM_"); contents.push_str(¶m_upper); contents.push_str("_DESC, "); match required { Some(required) => contents.push_str(&required.to_string()), None => { // Require a helper const to be present. contents.push_str("super::"); contents.push_str(&module); contents.push_str("::PARAM_"); contents.push_str(¶m_upper); contents.push_str("_REQUIRED"); } } contents.push_str("),\n"); } contents.push_str(" ],\n"); contents.push_str(" result: |g| g.result::(\""); contents.push_str(&command); contents.push_str("_result\"),\n"); contents.push_str(" deprecated: "); contents.push_str( &method .attrs .iter() .any(|attr| attr.path().is_ident("deprecated")) .to_string(), ); contents.push_str(",\n"); contents.push_str("},\n"); } } } contents.push_str("};"); let rpc_openrpc_path = out_dir.join("rpc_openrpc.rs"); fs::write(&rpc_openrpc_path, contents)?; Ok(()) }