mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
feat(options): add options metadata derive
This commit is contained in:
parent
f52ef5aa07
commit
fc7382ce79
9 changed files with 941 additions and 1 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -1937,6 +1937,8 @@ dependencies = [
|
|||
name = "fabro-macros"
|
||||
version = "0.213.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
|
|
@ -1990,6 +1992,14 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.213.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.213.0-nightly.0"
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ percent-encoding = "2"
|
|||
minijinja = "2"
|
||||
miette = { version = "7.6", features = ["fancy"] }
|
||||
fabro-http = { path = "lib/crates/fabro-http" }
|
||||
fabro-options-metadata = { path = "lib/crates/fabro-options-metadata" }
|
||||
fabro-redact = { path = "lib/crates/fabro-redact" }
|
||||
fabro-static = { path = "lib/crates/fabro-static" }
|
||||
graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" }
|
||||
|
|
|
|||
|
|
@ -742,7 +742,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen
|
|||
|
||||
### Phase 6 — OptionsMetadata and CLI reference generation
|
||||
|
||||
- [ ] **Unit 6.1: Add `fabro-options-metadata` and `#[derive(OptionsMetadata)]`**
|
||||
- [x] **Unit 6.1: Add `fabro-options-metadata` and `#[derive(OptionsMetadata)]`**
|
||||
|
||||
**Goal:** Ship the runtime metadata model plus a proc macro that extracts clap field help, `ValueEnum` possibilities, and option descriptions into that model.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,3 +16,7 @@ workspace = true
|
|||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
|
||||
[dev-dependencies]
|
||||
clap.workspace = true
|
||||
fabro-options-metadata.workspace = true
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ use syn::parse::{Parse, ParseStream};
|
|||
use syn::punctuated::Punctuated;
|
||||
use syn::{DeriveInput, Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input};
|
||||
|
||||
mod options_metadata;
|
||||
|
||||
enum E2eRequirement {
|
||||
Twin,
|
||||
Live(LitStr),
|
||||
|
|
@ -166,6 +168,14 @@ pub fn derive_combine(input: TokenStream) -> TokenStream {
|
|||
impl_combine(&input)
|
||||
}
|
||||
|
||||
#[proc_macro_derive(OptionsMetadata, attributes(option, option_group, arg, clap, serde))]
|
||||
pub fn derive_options_metadata(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
options_metadata::derive_impl(input)
|
||||
.unwrap_or_else(syn::Error::into_compile_error)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn impl_combine(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let fields = match ast.data {
|
||||
|
|
|
|||
409
lib/crates/fabro-macros/src/options_metadata.rs
Normal file
409
lib/crates/fabro-macros/src/options_metadata.rs
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::{quote, quote_spanned};
|
||||
use syn::meta::ParseNestedMeta;
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{
|
||||
Attribute, Data, DataStruct, DeriveInput, ExprLit, Field, Fields, GenericArgument, Lit, Meta,
|
||||
PathArguments, Type,
|
||||
};
|
||||
|
||||
pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
|
||||
let DeriveInput {
|
||||
ident,
|
||||
data,
|
||||
attrs,
|
||||
generics,
|
||||
..
|
||||
} = input;
|
||||
|
||||
let Data::Struct(DataStruct {
|
||||
fields: Fields::Named(fields),
|
||||
..
|
||||
}) = data
|
||||
else {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
"OptionsMetadata can only be derived for structs with named fields",
|
||||
));
|
||||
};
|
||||
|
||||
let mut records = Vec::new();
|
||||
for field in &fields.named {
|
||||
if let Some(attr) = field
|
||||
.attrs
|
||||
.iter()
|
||||
.find(|attr| attr.path().is_ident("option"))
|
||||
{
|
||||
records.push(handle_option(field, attr)?);
|
||||
} else if field
|
||||
.attrs
|
||||
.iter()
|
||||
.any(|attr| attr.path().is_ident("option_group"))
|
||||
{
|
||||
records.push(handle_option_group(field)?);
|
||||
} else if has_serde_flatten(field)? {
|
||||
let ty = &field.ty;
|
||||
records.push(quote_spanned!(ty.span() => <#ty as ::fabro_options_metadata::OptionsMetadata>::record(visit)));
|
||||
}
|
||||
}
|
||||
|
||||
let documentation = quote_option_str(doc_string(&attrs)?);
|
||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||
|
||||
Ok(quote! {
|
||||
#[automatically_derived]
|
||||
impl #impl_generics ::fabro_options_metadata::OptionsMetadata for #ident #ty_generics #where_clause {
|
||||
fn record(visit: &mut dyn ::fabro_options_metadata::Visit) {
|
||||
#(#records;)*
|
||||
}
|
||||
|
||||
fn documentation() -> Option<&'static str> {
|
||||
#documentation
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_option_group(field: &Field) -> syn::Result<TokenStream> {
|
||||
let ident = field_ident(field)?;
|
||||
let name = option_name(field)?;
|
||||
let ty = get_inner_type_if_option(&field.ty).unwrap_or(&field.ty);
|
||||
|
||||
Ok(quote_spanned!(
|
||||
ident.span() => visit.record_set(#name, ::fabro_options_metadata::OptionSet::of::<#ty>())
|
||||
))
|
||||
}
|
||||
|
||||
fn handle_option(field: &Field, attr: &Attribute) -> syn::Result<TokenStream> {
|
||||
let ident = field_ident(field)?;
|
||||
let attrs = parse_option_attributes(attr)?;
|
||||
let name = attrs
|
||||
.name
|
||||
.clone()
|
||||
.or(option_long_name(field)?)
|
||||
.unwrap_or_else(|| ident.to_string().replace('_', "-"));
|
||||
let doc = quote_option_str(doc_string(&field.attrs)?);
|
||||
let default = quote_option_str(attrs.default);
|
||||
let value_type = quote_option_str(attrs.value_type);
|
||||
let scope = quote_option_str(attrs.scope);
|
||||
let example = quote_option_str(attrs.example);
|
||||
let added_in = quote_option_str(attrs.added_in);
|
||||
let deprecated = deprecated_metadata(field)?;
|
||||
let possible_values = if attrs.possible_values.unwrap_or(false) || has_value_enum_arg(field)? {
|
||||
let ty = get_inner_type_if_option(&field.ty).unwrap_or(&field.ty);
|
||||
quote! {
|
||||
Some(
|
||||
<#ty as ::clap::ValueEnum>::value_variants()
|
||||
.iter()
|
||||
.filter_map(::clap::ValueEnum::to_possible_value)
|
||||
.map(|value| ::fabro_options_metadata::PossibleValue {
|
||||
name: value.get_name().to_string(),
|
||||
help: value.get_help().map(ToString::to_string),
|
||||
})
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
quote!(None)
|
||||
};
|
||||
|
||||
Ok(quote_spanned!(
|
||||
ident.span() => visit.record_field(#name, ::fabro_options_metadata::OptionField {
|
||||
doc: #doc,
|
||||
default: #default,
|
||||
value_type: #value_type,
|
||||
scope: #scope,
|
||||
example: #example,
|
||||
deprecated: #deprecated,
|
||||
possible_values: #possible_values,
|
||||
added_in: #added_in,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
fn field_ident(field: &Field) -> syn::Result<&syn::Ident> {
|
||||
field
|
||||
.ident
|
||||
.as_ref()
|
||||
.ok_or_else(|| syn::Error::new(field.span(), "expected named field"))
|
||||
}
|
||||
|
||||
fn option_name(field: &Field) -> syn::Result<String> {
|
||||
let ident = field_ident(field)?;
|
||||
Ok(ident.to_string().replace('_', "-"))
|
||||
}
|
||||
|
||||
fn doc_string(attrs: &[Attribute]) -> syn::Result<Option<String>> {
|
||||
let docs = attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("doc"))
|
||||
.map(parse_doc)
|
||||
.collect::<syn::Result<Vec<_>>>()?;
|
||||
let doc = docs
|
||||
.into_iter()
|
||||
.map(|line| line.trim().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim_matches('\n')
|
||||
.to_string();
|
||||
|
||||
if doc.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(doc))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_doc(attr: &Attribute) -> syn::Result<String> {
|
||||
match &attr.meta {
|
||||
Meta::NameValue(name_value) => match &name_value.value {
|
||||
syn::Expr::Lit(ExprLit {
|
||||
lit: Lit::Str(lit), ..
|
||||
}) => Ok(lit.value()),
|
||||
value => Err(syn::Error::new(value.span(), "expected doc string literal")),
|
||||
},
|
||||
meta => Err(syn::Error::new(meta.span(), "expected doc attribute")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldAttributes {
|
||||
name: Option<String>,
|
||||
default: Option<String>,
|
||||
value_type: Option<String>,
|
||||
scope: Option<String>,
|
||||
example: Option<String>,
|
||||
possible_values: Option<bool>,
|
||||
added_in: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_option_attributes(attr: &Attribute) -> syn::Result<FieldAttributes> {
|
||||
let mut attrs = FieldAttributes::default();
|
||||
|
||||
match &attr.meta {
|
||||
Meta::Path(_) => return Ok(attrs),
|
||||
Meta::List(_) => {}
|
||||
meta @ Meta::NameValue(_) => {
|
||||
return Err(syn::Error::new(
|
||||
meta.span(),
|
||||
"expected `#[option]` or `#[option(...)]`",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("name") {
|
||||
attrs.name = Some(string_literal(&meta, "name", "option")?.value());
|
||||
} else if meta.path.is_ident("default") {
|
||||
attrs.default = Some(string_literal(&meta, "default", "option")?.value());
|
||||
} else if meta.path.is_ident("value_type") {
|
||||
attrs.value_type = Some(string_literal(&meta, "value_type", "option")?.value());
|
||||
} else if meta.path.is_ident("scope") {
|
||||
attrs.scope = Some(string_literal(&meta, "scope", "option")?.value());
|
||||
} else if meta.path.is_ident("example") {
|
||||
attrs.example = Some(string_literal(&meta, "example", "option")?.value());
|
||||
} else if meta.path.is_ident("possible_values") {
|
||||
attrs.possible_values = Some(bool_literal(&meta, "possible_values", "option")?);
|
||||
} else if meta.path.is_ident("added_in") {
|
||||
attrs.added_in = Some(string_literal(&meta, "added_in", "option")?.value());
|
||||
} else {
|
||||
return Err(syn::Error::new(
|
||||
meta.path.span(),
|
||||
"unsupported `option` metadata key",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(attrs)
|
||||
}
|
||||
|
||||
fn deprecated_metadata(field: &Field) -> syn::Result<TokenStream> {
|
||||
let Some(attr) = field
|
||||
.attrs
|
||||
.iter()
|
||||
.find(|attr| attr.path().is_ident("deprecated"))
|
||||
else {
|
||||
return Ok(quote!(None));
|
||||
};
|
||||
|
||||
let mut since = None;
|
||||
let mut message = None;
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("since") {
|
||||
since = Some(string_literal(&meta, "since", "deprecated")?.value());
|
||||
} else if meta.path.is_ident("note") {
|
||||
message = Some(string_literal(&meta, "note", "deprecated")?.value());
|
||||
} else {
|
||||
return Err(syn::Error::new(
|
||||
meta.path.span(),
|
||||
"unsupported `deprecated` metadata key",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let since = quote_option_str(since);
|
||||
let message = quote_option_str(message);
|
||||
Ok(quote!(Some(::fabro_options_metadata::Deprecated {
|
||||
since: #since,
|
||||
message: #message,
|
||||
})))
|
||||
}
|
||||
|
||||
fn option_long_name(field: &Field) -> syn::Result<Option<String>> {
|
||||
let mut long = None;
|
||||
for attr in field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("arg") || attr.path().is_ident("clap"))
|
||||
{
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("long") {
|
||||
if meta.input.peek(syn::Token![=]) {
|
||||
long = Some(string_literal(&meta, "long", "arg")?.value());
|
||||
} else {
|
||||
long = Some(option_name(field)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(long)
|
||||
}
|
||||
|
||||
fn has_value_enum_arg(field: &Field) -> syn::Result<bool> {
|
||||
let mut has_value_enum = false;
|
||||
for attr in field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("arg") || attr.path().is_ident("clap"))
|
||||
{
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("value_enum") {
|
||||
has_value_enum = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(has_value_enum)
|
||||
}
|
||||
|
||||
fn has_serde_flatten(field: &Field) -> syn::Result<bool> {
|
||||
let mut flatten = false;
|
||||
for attr in field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("serde"))
|
||||
{
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("flatten") {
|
||||
flatten = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(flatten)
|
||||
}
|
||||
|
||||
fn get_inner_type_if_option(ty: &Type) -> Option<&Type> {
|
||||
let Type::Path(type_path) = ty else {
|
||||
return None;
|
||||
};
|
||||
if type_path.path.segments.len() != 1 || type_path.path.segments[0].ident != "Option" {
|
||||
return None;
|
||||
}
|
||||
let PathArguments::AngleBracketed(args) = &type_path.path.segments[0].arguments else {
|
||||
return None;
|
||||
};
|
||||
if args.args.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let GenericArgument::Type(inner) = &args.args[0] else {
|
||||
return None;
|
||||
};
|
||||
Some(inner)
|
||||
}
|
||||
|
||||
fn string_literal(
|
||||
meta: &ParseNestedMeta<'_>,
|
||||
meta_name: &str,
|
||||
attribute_name: &str,
|
||||
) -> syn::Result<syn::LitStr> {
|
||||
let expr: syn::Expr = meta.value()?.parse()?;
|
||||
let mut value = &expr;
|
||||
while let syn::Expr::Group(group) = value {
|
||||
value = &group.expr;
|
||||
}
|
||||
|
||||
if let syn::Expr::Lit(ExprLit {
|
||||
lit: Lit::Str(lit), ..
|
||||
}) = value
|
||||
{
|
||||
Ok(lit.clone())
|
||||
} else {
|
||||
Err(syn::Error::new(
|
||||
expr.span(),
|
||||
format!("expected {attribute_name} attribute to be a string: `{meta_name} = \"...\"`"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_literal(
|
||||
meta: &ParseNestedMeta<'_>,
|
||||
meta_name: &str,
|
||||
attribute_name: &str,
|
||||
) -> syn::Result<bool> {
|
||||
let expr: syn::Expr = meta.value()?.parse()?;
|
||||
let mut value = &expr;
|
||||
while let syn::Expr::Group(group) = value {
|
||||
value = &group.expr;
|
||||
}
|
||||
|
||||
if let syn::Expr::Lit(ExprLit {
|
||||
lit: Lit::Bool(lit),
|
||||
..
|
||||
}) = value
|
||||
{
|
||||
Ok(lit.value)
|
||||
} else {
|
||||
Err(syn::Error::new(
|
||||
expr.span(),
|
||||
format!("expected {attribute_name} attribute to be a boolean: `{meta_name} = true`"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn quote_option_str(value: Option<String>) -> TokenStream {
|
||||
if let Some(value) = value {
|
||||
quote!(Some(#value))
|
||||
} else {
|
||||
quote!(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_option_attribute_reports_clear_error() {
|
||||
let input: DeriveInput = syn::parse_quote! {
|
||||
struct Args {
|
||||
#[option(unknown = "value")]
|
||||
field: bool,
|
||||
}
|
||||
};
|
||||
|
||||
let error = derive_impl(input).expect_err("unknown option key should fail");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported `option` metadata key")
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/crates/fabro-macros/tests/options_metadata.rs
Normal file
76
lib/crates/fabro-macros/tests/options_metadata.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#![expect(dead_code, reason = "derive test fixtures are inspected via metadata")]
|
||||
|
||||
use clap::ValueEnum;
|
||||
use fabro_options_metadata::{OptionEntry, OptionsMetadata as _};
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
enum ExecutionMode {
|
||||
Fast,
|
||||
Careful,
|
||||
}
|
||||
|
||||
/// Root command options.
|
||||
#[derive(fabro_macros::OptionsMetadata)]
|
||||
struct RootArgs {
|
||||
/// Enable verbose output.
|
||||
#[arg(long)]
|
||||
#[option(added_in = "0.213.0")]
|
||||
verbose_output: bool,
|
||||
|
||||
/// Select execution mode.
|
||||
#[arg(long, value_enum)]
|
||||
#[option]
|
||||
mode: Option<ExecutionMode>,
|
||||
|
||||
#[option_group]
|
||||
nested: Option<NestedArgs>,
|
||||
|
||||
#[option]
|
||||
undocumented: bool,
|
||||
}
|
||||
|
||||
#[derive(fabro_macros::OptionsMetadata)]
|
||||
struct NestedArgs {
|
||||
/// Preview the work.
|
||||
#[arg(long)]
|
||||
#[option]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_records_fields_docs_and_value_enum_variants() {
|
||||
let metadata = RootArgs::metadata();
|
||||
|
||||
assert_eq!(RootArgs::documentation(), Some("Root command options."));
|
||||
assert!(metadata.has("verbose-output"));
|
||||
assert!(metadata.has("nested.dry-run"));
|
||||
|
||||
let Some(OptionEntry::Field(verbose)) = metadata.find("verbose-output") else {
|
||||
panic!("verbose-output should be a field");
|
||||
};
|
||||
assert_eq!(verbose.doc, Some("Enable verbose output."));
|
||||
assert_eq!(verbose.added_in, Some("0.213.0"));
|
||||
|
||||
let Some(OptionEntry::Field(mode)) = metadata.find("mode") else {
|
||||
panic!("mode should be a field");
|
||||
};
|
||||
let values = mode
|
||||
.possible_values
|
||||
.expect("value_enum field should record possible values");
|
||||
assert_eq!(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["fast", "careful"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_allows_missing_field_doc() {
|
||||
let Some(OptionEntry::Field(field)) = RootArgs::metadata().find("undocumented") else {
|
||||
panic!("undocumented should be a field");
|
||||
};
|
||||
|
||||
assert_eq!(field.doc, None);
|
||||
}
|
||||
16
lib/crates/fabro-options-metadata/Cargo.toml
Normal file
16
lib/crates/fabro-options-metadata/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "fabro-options-metadata"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description = "Runtime option metadata model for Fabro"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
414
lib/crates/fabro-options-metadata/src/lib.rs
Normal file
414
lib/crates/fabro-options-metadata/src/lib.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
/// Visits [`OptionsMetadata`] entries.
|
||||
pub trait Visit {
|
||||
/// Record a single option field.
|
||||
fn record_field(&mut self, name: &str, field: OptionField);
|
||||
|
||||
/// Record a nested option set.
|
||||
fn record_set(&mut self, name: &str, set: OptionSet);
|
||||
}
|
||||
|
||||
/// Returns metadata for a type's options.
|
||||
pub trait OptionsMetadata {
|
||||
/// Visits each option in this type.
|
||||
fn record(visit: &mut dyn Visit);
|
||||
|
||||
/// Returns documentation for the whole option set.
|
||||
fn documentation() -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the extracted metadata set.
|
||||
fn metadata() -> OptionSet
|
||||
where
|
||||
Self: Sized + 'static,
|
||||
{
|
||||
OptionSet::of::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> OptionsMetadata for Option<T>
|
||||
where
|
||||
T: OptionsMetadata,
|
||||
{
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
T::record(visit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for an option entry, either a field or a nested option set.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum OptionEntry {
|
||||
/// A single option.
|
||||
Field(OptionField),
|
||||
/// A nested set of options.
|
||||
Set(OptionSet),
|
||||
}
|
||||
|
||||
impl Display for OptionEntry {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Field(field) => Display::fmt(field, formatter),
|
||||
Self::Set(set) => Display::fmt(set, formatter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A set of options for a type implementing [`OptionsMetadata`].
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct OptionSet {
|
||||
record: fn(&mut dyn Visit),
|
||||
doc: fn() -> Option<&'static str>,
|
||||
}
|
||||
|
||||
impl OptionSet {
|
||||
/// Create an option set for a type.
|
||||
pub fn of<T>() -> Self
|
||||
where
|
||||
T: OptionsMetadata + 'static,
|
||||
{
|
||||
Self {
|
||||
record: T::record,
|
||||
doc: T::documentation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit each option in this set.
|
||||
pub fn record(&self, visit: &mut dyn Visit) {
|
||||
(self.record)(visit);
|
||||
}
|
||||
|
||||
/// Returns documentation for this option set.
|
||||
pub fn documentation(&self) -> Option<&'static str> {
|
||||
(self.doc)()
|
||||
}
|
||||
|
||||
/// Returns true if this set contains an option by dotted name.
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.find(name).is_some()
|
||||
}
|
||||
|
||||
/// Find an option by dotted name.
|
||||
pub fn find(&self, name: &str) -> Option<OptionEntry> {
|
||||
struct FindVisitor<'a> {
|
||||
entry: Option<OptionEntry>,
|
||||
needle: &'a str,
|
||||
parts: std::str::Split<'a, char>,
|
||||
}
|
||||
|
||||
impl Visit for FindVisitor<'_> {
|
||||
fn record_field(&mut self, name: &str, field: OptionField) {
|
||||
if self.entry.is_none() && name == self.needle && self.parts.next().is_none() {
|
||||
self.entry = Some(OptionEntry::Field(field));
|
||||
}
|
||||
}
|
||||
|
||||
fn record_set(&mut self, name: &str, set: OptionSet) {
|
||||
if self.entry.is_none() && name == self.needle {
|
||||
if let Some(next) = self.parts.next() {
|
||||
self.needle = next;
|
||||
set.record(self);
|
||||
} else {
|
||||
self.entry = Some(OptionEntry::Set(set));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = name.split('.');
|
||||
let first = parts.next()?;
|
||||
let mut visitor = FindVisitor {
|
||||
entry: None,
|
||||
needle: first,
|
||||
parts,
|
||||
};
|
||||
self.record(&mut visitor);
|
||||
visitor.entry
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for OptionSet {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
std::ptr::fn_addr_eq(self.record, other.record) && std::ptr::fn_addr_eq(self.doc, other.doc)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for OptionSet {}
|
||||
|
||||
impl Display for OptionSet {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
struct DisplayVisitor<'a, 'b> {
|
||||
formatter: &'a mut Formatter<'b>,
|
||||
result: std::fmt::Result,
|
||||
}
|
||||
|
||||
impl Visit for DisplayVisitor<'_, '_> {
|
||||
fn record_field(&mut self, name: &str, field: OptionField) {
|
||||
self.result = self.result.and_then(|()| {
|
||||
write!(self.formatter, "{name}")?;
|
||||
if field.deprecated.is_some() {
|
||||
write!(self.formatter, " (deprecated)")?;
|
||||
}
|
||||
writeln!(self.formatter)
|
||||
});
|
||||
}
|
||||
|
||||
fn record_set(&mut self, name: &str, _set: OptionSet) {
|
||||
self.result = self
|
||||
.result
|
||||
.and_then(|()| writeln!(self.formatter, "{name}"));
|
||||
}
|
||||
}
|
||||
|
||||
let mut visitor = DisplayVisitor {
|
||||
formatter,
|
||||
result: Ok(()),
|
||||
};
|
||||
self.record(&mut visitor);
|
||||
visitor.result
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for OptionSet {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
Display::fmt(self, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for OptionSet {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
struct SerializeVisitor<'a> {
|
||||
entries: &'a mut BTreeMap<String, OptionField>,
|
||||
}
|
||||
|
||||
impl Visit for SerializeVisitor<'_> {
|
||||
fn record_field(&mut self, name: &str, field: OptionField) {
|
||||
self.entries.insert(name.to_string(), field);
|
||||
}
|
||||
|
||||
fn record_set(&mut self, name: &str, set: OptionSet) {
|
||||
let mut nested = BTreeMap::new();
|
||||
set.record(&mut SerializeVisitor {
|
||||
entries: &mut nested,
|
||||
});
|
||||
for (key, value) in nested {
|
||||
self.entries.insert(format!("{name}.{key}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut entries = BTreeMap::new();
|
||||
self.record(&mut SerializeVisitor {
|
||||
entries: &mut entries,
|
||||
});
|
||||
entries.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for a single option field.
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Serialize)]
|
||||
pub struct OptionField {
|
||||
/// Option documentation from doc comments, when present.
|
||||
pub doc: Option<&'static str>,
|
||||
/// The option's default value, formatted for docs.
|
||||
pub default: Option<&'static str>,
|
||||
/// The option value type, formatted for docs.
|
||||
pub value_type: Option<&'static str>,
|
||||
/// Optional scope, for docs that group settings by source.
|
||||
pub scope: Option<&'static str>,
|
||||
/// Example usage for the option.
|
||||
pub example: Option<&'static str>,
|
||||
/// Deprecation metadata.
|
||||
pub deprecated: Option<Deprecated>,
|
||||
/// Possible values for enum-like options.
|
||||
pub possible_values: Option<Vec<PossibleValue>>,
|
||||
/// Version where this option was added.
|
||||
pub added_in: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Display for OptionField {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(doc) = self.doc {
|
||||
writeln!(formatter, "{doc}")?;
|
||||
writeln!(formatter)?;
|
||||
}
|
||||
|
||||
if let Some(default) = self.default {
|
||||
writeln!(formatter, "Default value: {default}")?;
|
||||
}
|
||||
|
||||
if let Some(possible_values) = self
|
||||
.possible_values
|
||||
.as_ref()
|
||||
.filter(|values| !values.is_empty())
|
||||
{
|
||||
writeln!(formatter, "Possible values:")?;
|
||||
for value in possible_values {
|
||||
writeln!(formatter, "- {value}")?;
|
||||
}
|
||||
} else if let Some(value_type) = self.value_type {
|
||||
writeln!(formatter, "Type: {value_type}")?;
|
||||
}
|
||||
|
||||
if let Some(deprecated) = &self.deprecated {
|
||||
write!(formatter, "Deprecated")?;
|
||||
if let Some(since) = deprecated.since {
|
||||
write!(formatter, " (since {since})")?;
|
||||
}
|
||||
if let Some(message) = deprecated.message {
|
||||
write!(formatter, ": {message}")?;
|
||||
}
|
||||
writeln!(formatter)?;
|
||||
}
|
||||
|
||||
if let Some(example) = self.example {
|
||||
writeln!(formatter, "Example usage:\n```toml\n{example}\n```")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Deprecation metadata for an option.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
|
||||
pub struct Deprecated {
|
||||
/// Version where the option was deprecated.
|
||||
pub since: Option<&'static str>,
|
||||
/// Deprecation message.
|
||||
pub message: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// A possible value for an enum-like option.
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Serialize)]
|
||||
pub struct PossibleValue {
|
||||
/// Value name as it appears on the CLI or in config.
|
||||
pub name: String,
|
||||
/// Optional value help text.
|
||||
pub help: Option<String>,
|
||||
}
|
||||
|
||||
impl Display for PossibleValue {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "`\"{}\"`", self.name)?;
|
||||
if let Some(help) = &self.help {
|
||||
write!(formatter, ": {help}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn field(doc: Option<&'static str>) -> OptionField {
|
||||
OptionField {
|
||||
doc,
|
||||
default: None,
|
||||
value_type: Some("bool"),
|
||||
scope: None,
|
||||
example: None,
|
||||
deprecated: None,
|
||||
possible_values: None,
|
||||
added_in: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_set_finds_child_and_nested_options() {
|
||||
struct Root;
|
||||
struct Nested;
|
||||
|
||||
impl OptionsMetadata for Root {
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
visit.record_field("verbose", field(Some("Enable verbose output.")));
|
||||
visit.record_set("nested", Nested::metadata());
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsMetadata for Nested {
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
visit.record_field("dry-run", field(Some("Preview the work.")));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(Root::metadata().has("verbose"));
|
||||
assert!(Root::metadata().has("nested.dry-run"));
|
||||
assert!(!Root::metadata().has("nested.missing"));
|
||||
assert!(matches!(
|
||||
Root::metadata().find("nested"),
|
||||
Some(OptionEntry::Set(_))
|
||||
));
|
||||
assert_eq!(
|
||||
Root::metadata().find("nested.dry-run"),
|
||||
Some(OptionEntry::Field(field(Some("Preview the work."))))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_set_display_lists_fields_and_sets() {
|
||||
struct Root;
|
||||
struct Nested;
|
||||
|
||||
impl OptionsMetadata for Root {
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
visit.record_field("verbose", field(Some("Enable verbose output.")));
|
||||
visit.record_set("nested", Nested::metadata());
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsMetadata for Nested {
|
||||
fn record(_visit: &mut dyn Visit) {}
|
||||
}
|
||||
|
||||
assert_eq!(Root::metadata().to_string(), "verbose\nnested\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_set_serializes_nested_fields_with_dot_keys() {
|
||||
struct Root;
|
||||
struct Nested;
|
||||
|
||||
impl OptionsMetadata for Root {
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
visit.record_set("nested", Nested::metadata());
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsMetadata for Nested {
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
visit.record_field("dry-run", field(Some("Preview the work.")));
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_value(Root::metadata()).expect("metadata should serialize");
|
||||
assert_eq!(
|
||||
json["nested.dry-run"]["doc"],
|
||||
serde_json::json!("Preview the work.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_doc_can_be_absent() {
|
||||
struct Root;
|
||||
|
||||
impl OptionsMetadata for Root {
|
||||
fn record(visit: &mut dyn Visit) {
|
||||
visit.record_field("undocumented", field(None));
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
Root::metadata().find("undocumented"),
|
||||
Some(OptionEntry::Field(field(None)))
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue