feat(uci): derive UCI schema from settings
Add uci-derive proc-macro crate with UciConfig and UciSection derive macros that generate UCI schema metadata from Settings structs. The schema maps UCI section types and option names to config-rs field paths. UciSource::from_schema() creates a source from the derived schema, mapping known UCI options to their Settings fields while preserving unknown options under raw UCI-derived paths.
This commit is contained in:
7 files changed
+468
-9
No files matched your search
@@ -0,0 +1,252 @@
|
||||
// SPDX-FileCopyrightText: 2026-Present Nils Andreas Svee mailto:contact@lochnair.net (github @Lochnair)
|
||||
//
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::ext::IdentExt;
|
||||
use syn::{Data, DeriveInput, Field, Fields, FieldsNamed, LitStr, Token, parse_macro_input};
|
||||
|
||||
/// Generates `UciSectionSchema` from a settings struct.
|
||||
///
|
||||
/// Every named field is mapped one-to-one:
|
||||
///
|
||||
/// ```text
|
||||
/// Rust field name -> UCI option name
|
||||
/// Rust field name -> config-rs field name
|
||||
/// ```
|
||||
#[proc_macro_derive(UciSection)]
|
||||
pub fn derive_uci_section(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
match expand_uci_section(&input) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(error) => error.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates `UciConfigSchema` from the root settings struct.
|
||||
///
|
||||
/// Required container attribute:
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[uci(package = "sqm-autorate-rust")]
|
||||
/// ```
|
||||
///
|
||||
/// Every field must specify its UCI section:
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[uci(section)]
|
||||
/// network: NetworkSettings,
|
||||
///
|
||||
/// #[uci(section = "advanced_settings")]
|
||||
/// advanced: AdvancedSettings,
|
||||
/// ```
|
||||
#[proc_macro_derive(UciConfig, attributes(uci))]
|
||||
pub fn derive_uci_config(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
match expand_uci_config(&input) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(error) => error.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_uci_section(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||
let struct_name = &input.ident;
|
||||
let fields = require_named_fields(input)?;
|
||||
|
||||
let mappings = fields
|
||||
.named
|
||||
.iter()
|
||||
.map(|field| {
|
||||
let field_ident = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("named fields always have identifiers");
|
||||
|
||||
let field_name = field_ident.unraw().to_string();
|
||||
let field_name = LitStr::new(&field_name, field_ident.span());
|
||||
|
||||
quote! {
|
||||
crate::settings::uci_schema::UciOptionMapping {
|
||||
uci_option: #field_name,
|
||||
config_field: #field_name,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
Ok(quote! {
|
||||
#[automatically_derived]
|
||||
impl #impl_generics
|
||||
crate::settings::uci_schema::UciSectionSchema
|
||||
for #struct_name #type_generics
|
||||
#where_clause
|
||||
{
|
||||
const UCI_OPTIONS: &'static [
|
||||
crate::settings::uci_schema::UciOptionMapping
|
||||
] = &[
|
||||
#(#mappings),*
|
||||
];
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_uci_config(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
|
||||
let struct_name = &input.ident;
|
||||
let fields = require_named_fields(input)?;
|
||||
let package = parse_package_name(input)?;
|
||||
|
||||
let mut sections = Vec::with_capacity(fields.named.len());
|
||||
|
||||
for field in &fields.named {
|
||||
let field_ident = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("named fields always have identifiers");
|
||||
|
||||
let field_type = &field.ty;
|
||||
|
||||
let config_section_name = field_ident.unraw().to_string();
|
||||
let config_section_name = LitStr::new(&config_section_name, field_ident.span());
|
||||
|
||||
let uci_section_name = parse_section_name(field)?;
|
||||
|
||||
sections.push(quote! {
|
||||
crate::settings::uci_schema::UciSectionMapping {
|
||||
uci_section: #uci_section_name,
|
||||
config_section: #config_section_name,
|
||||
options: <
|
||||
#field_type as
|
||||
crate::settings::uci_schema::UciSectionSchema
|
||||
>::UCI_OPTIONS,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
|
||||
|
||||
Ok(quote! {
|
||||
#[automatically_derived]
|
||||
impl #impl_generics
|
||||
crate::settings::uci_schema::UciConfigSchema
|
||||
for #struct_name #type_generics
|
||||
#where_clause
|
||||
{
|
||||
const UCI_PACKAGE: &'static str = #package;
|
||||
|
||||
const UCI_SECTIONS: &'static [
|
||||
crate::settings::uci_schema::UciSectionMapping
|
||||
] = &[
|
||||
#(#sections),*
|
||||
];
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn require_named_fields(input: &DeriveInput) -> syn::Result<&FieldsNamed> {
|
||||
match &input.data {
|
||||
Data::Struct(data) => match &data.fields {
|
||||
Fields::Named(fields) => Ok(fields),
|
||||
|
||||
Fields::Unnamed(_) | Fields::Unit => Err(syn::Error::new_spanned(
|
||||
input,
|
||||
"this derive only supports structs with named fields",
|
||||
)),
|
||||
},
|
||||
|
||||
Data::Enum(_) | Data::Union(_) => Err(syn::Error::new_spanned(
|
||||
input,
|
||||
"this derive only supports structs",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_package_name(input: &DeriveInput) -> syn::Result<LitStr> {
|
||||
let mut package = None;
|
||||
|
||||
for attribute in &input.attrs {
|
||||
if !attribute.path().is_ident("uci") {
|
||||
continue;
|
||||
}
|
||||
|
||||
attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("package") {
|
||||
if package.is_some() {
|
||||
return Err(meta.error("duplicate `package` attribute"));
|
||||
}
|
||||
|
||||
let value: LitStr = meta.value()?.parse()?;
|
||||
|
||||
if value.value().is_empty() {
|
||||
return Err(syn::Error::new(value.span(), "`package` cannot be empty"));
|
||||
}
|
||||
|
||||
package = Some(value);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(meta.error("expected `package = \"...\"`"))
|
||||
})?;
|
||||
}
|
||||
|
||||
package.ok_or_else(|| {
|
||||
syn::Error::new_spanned(
|
||||
&input.ident,
|
||||
"UciConfig requires `#[uci(package = \"...\")]`",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_section_name(field: &Field) -> syn::Result<LitStr> {
|
||||
let field_ident = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.expect("named fields always have identifiers");
|
||||
|
||||
let default_name = field_ident.unraw().to_string();
|
||||
let default_name = LitStr::new(&default_name, field_ident.span());
|
||||
|
||||
let mut section = None;
|
||||
|
||||
for attribute in &field.attrs {
|
||||
if !attribute.path().is_ident("uci") {
|
||||
continue;
|
||||
}
|
||||
|
||||
attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("section") {
|
||||
if section.is_some() {
|
||||
return Err(meta.error("duplicate `section` attribute"));
|
||||
}
|
||||
|
||||
if meta.input.peek(Token![=]) {
|
||||
let value: LitStr = meta.value()?.parse()?;
|
||||
|
||||
if value.value().is_empty() {
|
||||
return Err(syn::Error::new(value.span(), "`section` cannot be empty"));
|
||||
}
|
||||
|
||||
section = Some(value);
|
||||
} else {
|
||||
section = Some(default_name.clone());
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(meta.error("expected `section` or `section = \"...\"`"))
|
||||
})?;
|
||||
}
|
||||
|
||||
section.ok_or_else(|| {
|
||||
syn::Error::new_spanned(
|
||||
field,
|
||||
"UciConfig fields require `#[uci(section)]` \
|
||||
or `#[uci(section = \"...\")]`",
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user