use syn::meta::ParseNestedMeta;
use syn::parenthesized;
use syn::{Attribute, Expr, Lit, LitStr, Token};
mod clap_attrs;
mod doc_attrs;
mod doc_types;
mod input;
mod literals;
mod serde_attrs;
#[cfg(test)]
mod tests;
mod type_utils;
pub(crate) use clap_attrs::{
ClapInferredDefault, clap_arg_id, clap_arg_id_from_attribute, clap_default_value,
clap_field_is_subcommand, clap_variant_name, reject_subcommand_ortho_config_attrs,
};
use doc_attrs::{apply_field_doc_attr, apply_struct_doc_attr};
pub(crate) use doc_types::{
DocExampleAttr, DocFieldAttrs, DocLinkAttr, DocNoteAttr, DocStructAttrs, HeadingOverrides,
};
pub(crate) use input::parse_input;
#[cfg(any(test, doctest))]
pub(crate) use literals::__doc_lit_str;
pub(crate) use literals::lit_crate_path;
use literals::{lit_bool, lit_char, lit_str};
pub(crate) use serde_attrs::{
SerdeRenameAll, serde_field_rename, serde_has_default, serde_rename_all,
serde_serialized_field_key,
};
pub(crate) use type_utils::{btree_map_inner, hash_map_inner, option_inner, vec_inner};
const _: fn(&Attribute, &mut Option<LitStr>) -> syn::Result<()> = clap_arg_id_from_attribute;
const _: fn(&syn::Field) -> syn::Result<bool> = clap_field_is_subcommand;
const _: fn(&syn::Variant) -> syn::Result<Option<LitStr>> = clap_variant_name;
const _: fn(&[Attribute]) -> syn::Result<Option<String>> = serde_field_rename;
#[derive(Default, Clone)]
pub(crate) struct StructAttrs {
pub prefix: Option<String>,
pub discovery: Option<DiscoveryAttrs>,
pub post_merge_hook: bool,
pub doc: DocStructAttrs,
pub crate_path: Option<syn::Path>,
}
#[derive(Default, Clone)]
pub(crate) struct FieldAttrs {
pub cli_long: Option<String>,
pub cli_short: Option<char>,
pub default: Option<Expr>,
pub inferred_clap_default: Option<ClapInferredDefault>,
pub merge_strategy: Option<MergeStrategy>,
pub skip_cli: bool,
pub cli_default_as_absent: bool,
pub is_subcommand: bool,
pub doc: DocFieldAttrs,
}
#[derive(Default, Clone)]
pub(crate) struct DiscoveryAttrs {
pub app_name: Option<String>,
pub env_var: Option<String>,
pub config_file_name: Option<String>,
pub dotfile_name: Option<String>,
pub project_file_name: Option<String>,
pub config_cli_long: Option<String>,
pub config_cli_short: Option<char>,
pub config_cli_visible: Option<bool>,
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum MergeStrategy {
Append,
Replace,
Keyed,
}
impl MergeStrategy {
pub(crate) fn parse(s: &str, span: proc_macro2::Span) -> Result<Self, syn::Error> {
match s {
"append" => Ok(Self::Append),
"replace" => Ok(Self::Replace),
"keyed" => Ok(Self::Keyed),
_ => Err(syn::Error::new(
span,
format!(
"unknown merge_strategy '{s}'; expected one of \"append\", \"replace\", or \"keyed\""
),
)),
}
}
}
fn parse_ortho_config<F>(attrs: &[Attribute], mut f: F) -> syn::Result<()>
where
F: FnMut(&syn::meta::ParseNestedMeta) -> syn::Result<()>,
{
for attr in attrs.iter().filter(|a| a.path().is_ident("ortho_config")) {
attr.parse_nested_meta(|meta| f(&meta))?;
}
Ok(())
}
fn discard_unknown(meta: &syn::meta::ParseNestedMeta) -> syn::Result<()> {
if meta.input.peek(Token![=]) {
meta.value()?.parse::<proc_macro2::TokenStream>()?;
} else if meta.input.peek(syn::token::Paren) {
let content;
parenthesized!(content in meta.input);
content.parse::<proc_macro2::TokenStream>()?;
}
Ok(())
}
fn parse_prefix(meta: &ParseNestedMeta) -> syn::Result<String> {
let lit = meta.value()?.parse::<Lit>()?;
match lit {
Lit::Str(s) => {
let mut value = s.value();
if !value.is_empty() && !value.ends_with('_') {
value.push('_');
}
Ok(value)
}
other => Err(syn::Error::new(other.span(), "prefix must be a string")),
}
}
fn parse_discovery_meta(meta: &ParseNestedMeta, discovery: &mut DiscoveryAttrs) -> syn::Result<()> {
meta.parse_nested_meta(|nested| handle_discovery_nested(&nested, discovery))
}
fn handle_discovery_nested(
nested: &ParseNestedMeta,
discovery: &mut DiscoveryAttrs,
) -> syn::Result<()> {
let Some(ident) = nested.path.get_ident().map(ToString::to_string) else {
return discard_unknown(nested);
};
match ident.as_str() {
"app_name" => assign_str(&mut discovery.app_name, nested, "app_name"),
"env_var" => assign_str(&mut discovery.env_var, nested, "env_var"),
"config_file_name" => {
assign_str(&mut discovery.config_file_name, nested, "config_file_name")
}
"dotfile_name" => assign_str(&mut discovery.dotfile_name, nested, "dotfile_name"),
"project_file_name" => assign_str(
&mut discovery.project_file_name,
nested,
"project_file_name",
),
"config_cli_long" => assign_str(&mut discovery.config_cli_long, nested, "config_cli_long"),
"config_cli_short" => {
assign_char(&mut discovery.config_cli_short, nested, "config_cli_short")
}
"config_cli_visible" => assign_bool(
&mut discovery.config_cli_visible,
nested,
"config_cli_visible",
),
_ => discard_unknown(nested),
}
}
fn assign_str(target: &mut Option<String>, nested: &ParseNestedMeta, key: &str) -> syn::Result<()> {
let value = lit_str(nested, key)?.value();
*target = Some(value);
Ok(())
}
fn assign_char(target: &mut Option<char>, nested: &ParseNestedMeta, key: &str) -> syn::Result<()> {
let value = lit_char(nested, key)?;
*target = Some(value);
Ok(())
}
fn assign_bool(target: &mut Option<bool>, nested: &ParseNestedMeta, key: &str) -> syn::Result<()> {
let value = lit_bool(nested, key)?;
*target = Some(value);
Ok(())
}
pub(crate) fn parse_struct_attrs(attrs: &[Attribute]) -> Result<StructAttrs, syn::Error> {
let mut out = StructAttrs::default();
parse_ortho_config(attrs, |meta| {
match meta.path.get_ident().map(ToString::to_string).as_deref() {
Some("prefix") => {
let value = parse_prefix(meta)?;
out.prefix = Some(value);
Ok(())
}
Some("discovery") => {
let mut discovery = out.discovery.take().unwrap_or_default();
parse_discovery_meta(meta, &mut discovery)?;
out.discovery = Some(discovery);
Ok(())
}
Some("post_merge_hook") => {
let v = if meta.input.peek(Token![=]) {
meta.value()?.parse::<syn::LitBool>()?.value
} else {
true
};
out.post_merge_hook = v;
Ok(())
}
Some("crate") => {
if out.crate_path.is_some() {
return Err(syn::Error::new_spanned(
&meta.path,
"duplicate `crate` attribute",
));
}
out.crate_path = Some(lit_crate_path(meta)?);
Ok(())
}
_ => {
if apply_struct_doc_attr(meta, &mut out.doc)? {
return Ok(());
}
discard_unknown(meta)
}
}
})?;
Ok(out)
}
fn parse_cli_default_as_absent(meta: &syn::meta::ParseNestedMeta) -> Result<bool, syn::Error> {
if meta.input.peek(Token![=]) {
Ok(meta.value()?.parse::<syn::LitBool>()?.value)
} else {
Ok(true)
}
}
fn apply_field_attr(
meta: &syn::meta::ParseNestedMeta,
out: &mut FieldAttrs,
) -> Result<bool, syn::Error> {
let Some(ident) = meta.path.get_ident() else {
return Ok(false);
};
let key = ident.to_string();
match key.as_str() {
"cli_long" => {
let s = lit_str(meta, "cli_long")?;
out.cli_long = Some(s.value());
Ok(true)
}
"cli_short" => {
let c = lit_char(meta, "cli_short")?;
out.cli_short = Some(c);
Ok(true)
}
"default" => {
out.default = Some(meta.value()?.parse()?);
Ok(true)
}
"merge_strategy" => {
let s = lit_str(meta, "merge_strategy")?;
out.merge_strategy = Some(MergeStrategy::parse(&s.value(), s.span())?);
Ok(true)
}
"skip_cli" => {
out.skip_cli = true;
Ok(true)
}
"cli_default_as_absent" => {
out.cli_default_as_absent = parse_cli_default_as_absent(meta)?;
Ok(true)
}
_ => apply_field_doc_attr(meta, out),
}
}
pub(crate) fn parse_field_attrs(field: &syn::Field) -> Result<FieldAttrs, syn::Error> {
let mut out = FieldAttrs {
is_subcommand: clap_field_is_subcommand(field)?,
..FieldAttrs::default()
};
if out.is_subcommand {
reject_subcommand_ortho_config_attrs(field)?;
return Ok(out);
}
parse_ortho_config(&field.attrs, |meta| {
if !apply_field_attr(meta, &mut out)? {
discard_unknown(meta)?;
}
Ok(())
})?;
if out.cli_default_as_absent && out.default.is_none() {
out.inferred_clap_default = clap_default_value(field)?;
if let Some(ClapInferredDefault::Value(_)) = out.inferred_clap_default {
return Err(syn::Error::new_spanned(
field,
concat!(
"inferring defaults from clap `default_value` is not yet supported for ",
"`cli_default_as_absent`; use `default_value_t`/`default_values_t` or ",
"add `#[ortho_config(default = ...)]`. Parser-faithful `default_value` ",
"inference is planned as a day-2 follow-up."
),
));
}
}
Ok(out)
}