server-less-macros 0.6.0

Proc macros for server-less
Documentation
//! Blessed `#[program]` preset macro.
//!
//! Expands to `#[cli]` + `#[markdown]` (if feature enabled).
//!
//! # `no_sync` / `no_async` are not forwarded
//!
//! The `#[cli(no_sync)]` and `#[cli(no_async)]` flags suppress the sync or async
//! convenience entrypoints (`cli_run`, `cli_run_with`, `cli_run_async`,
//! `cli_run_with_async`).  These flags are **not** available on `#[program]` —
//! the preset always generates the full set of entrypoints.
//!
//! If you need to suppress one set of entrypoints, use `#[cli]` directly instead
//! of `#[program]`.

use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{ItemImpl, Token, parse::Parse, Path};

use crate::app::extract_app_meta;
use crate::cli::{self, CliArgs};
use crate::strip_first_impl;

/// Arguments for the #[program] preset attribute
#[derive(Default)]
pub(crate) struct ProgramArgs {
    /// CLI name (forwarded to CliArgs)
    pub name: Option<String>,
    /// CLI version (forwarded to CliArgs)
    pub version: Option<String>,
    /// Human-readable description (forwarded to CliArgs).
    pub description: Option<String>,
    /// Homepage URL (forwarded to CliArgs)
    pub homepage: Option<String>,
    /// Markdown toggle (default: true)
    pub markdown: Option<bool>,
    /// Config struct path for linked config management (`config = MyConfig`).
    pub config_ty: Option<Path>,
    /// Config subcommand name override (`config_cmd = "settings"`) or `false` to disable.
    pub config_cmd_name: Option<String>,
    /// Whether the config subcommand is enabled (default: true when config_ty is set).
    pub config_cmd: bool,
    /// Whether to prepend the program name to the description (forwarded to CliArgs).
    pub description_prefix: Option<bool>,
}

impl Parse for ProgramArgs {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        let mut args = ProgramArgs::default();

        while !input.is_empty() {
            let ident: syn::Ident = input.parse()?;
            input.parse::<Token![=]>()?;

            match ident.to_string().as_str() {
                "name" => {
                    let lit: syn::LitStr = input.parse()?;
                    args.name = Some(lit.value());
                }
                "version" => {
                    let lit: syn::LitStr = input.parse()?;
                    args.version = Some(lit.value());
                }
                "description" => {
                    let lit: syn::LitStr = input.parse()?;
                    args.description = Some(lit.value());
                }
                "homepage" => {
                    let lit: syn::LitStr = input.parse()?;
                    args.homepage = Some(lit.value());
                }
                "markdown" => {
                    let lit: syn::LitBool = input.parse()?;
                    args.markdown = Some(lit.value());
                }
                "config" => {
                    let path: Path = input.parse()?;
                    args.config_ty = Some(path);
                    args.config_cmd = true; // enabled by default when config is set
                }
                "description_prefix" => {
                    let lit: syn::LitBool = input.parse()?;
                    args.description_prefix = Some(lit.value());
                }
                "config_cmd" => {
                    // config_cmd = false | "custom-name"
                    if input.peek(syn::LitBool) {
                        let lit: syn::LitBool = input.parse()?;
                        args.config_cmd = lit.value();
                    } else {
                        let lit: syn::LitStr = input.parse()?;
                        args.config_cmd_name = Some(lit.value());
                        args.config_cmd = true;
                    }
                }
                other => {
                    if other == "about" {
                        return Err(syn::Error::new(
                            ident.span(),
                            "unknown argument `about` — renamed to `description` in 0.4.0\n\
                             \n\
                             Example: #[program(description = \"My CLI tool\")]",
                        ));
                    }
                    if other == "name_prefix" {
                        return Err(syn::Error::new(
                            ident.span(),
                            "unknown argument `name_prefix` — renamed to `description_prefix`\n\
                             \n\
                             Example: #[program(description_prefix = false)]",
                        ));
                    }
                    const VALID: &[&str] =
                        &["name", "version", "description", "homepage", "markdown", "config", "config_cmd", "description_prefix"];
                    let suggestion = crate::did_you_mean(other, VALID)
                        .map(|s| format!(" — did you mean `{s}`?"))
                        .unwrap_or_default();
                    return Err(syn::Error::new(
                        ident.span(),
                        format!(
                            "unknown argument `{other}`{suggestion}\n\
                             Valid arguments: name, version, description, homepage, markdown, config, config_cmd, description_prefix"
                        ),
                    ));
                }
            }

            if input.peek(Token![,]) {
                input.parse::<Token![,]>()?;
            }
        }

        Ok(args)
    }
}

pub(crate) fn expand_program(args: ProgramArgs, mut impl_block: ItemImpl) -> syn::Result<TokenStream2> {
    let app_meta = extract_app_meta(&mut impl_block.attrs);
    let name = args.name.or(app_meta.name);
    let version = args.version.or_else(|| app_meta.version.into_explicit());
    let description = args.description.or(app_meta.description);
    let homepage = args.homepage.or(app_meta.homepage);

    let config_ty = if args.config_cmd { args.config_ty } else { None };

    let cli_args = CliArgs {
        name,
        version,
        description,
        homepage,
        global: Vec::new(),
        defaults: None,
        no_sync: false,
        no_async: false,
        config_ty,
        config_cmd_name: args.config_cmd_name,
        description_prefix: args.description_prefix,
        manual: None,
        input_schema: None,
        output_schema: None,
    };
    let cli_tokens = cli::expand_cli(cli_args, impl_block.clone())?;

    #[cfg(feature = "markdown")]
    let md_tokens = if args.markdown.unwrap_or(true) {
        strip_first_impl(crate::markdown::expand_markdown(
            crate::markdown::MarkdownArgs {
                title: None,
                types: true,
            },
            impl_block,
        )?)
    } else {
        quote! {}
    };
    #[cfg(not(feature = "markdown"))]
    let md_tokens = quote! {};

    Ok(quote! { #cli_tokens #md_tokens })
}