662 lines
22 KiB
Rust
662 lines
22 KiB
Rust
use syn::{
|
|
Attribute, Data, DeriveInput, Expr, Field, Fields, Ident, Lit, Path, Result, Type, UnOp,
|
|
spanned::Spanned,
|
|
};
|
|
|
|
const MAX_DISPLAY_KEY_BYTES: usize = 48;
|
|
const MAX_UNIT_BYTES: usize = 16;
|
|
|
|
pub(crate) struct Model {
|
|
pub(crate) ident: Ident,
|
|
pub(crate) generics: syn::Generics,
|
|
pub(crate) crate_path: Path,
|
|
pub(crate) post_validate: Option<Path>,
|
|
pub(crate) kind: ModelKind,
|
|
pub(crate) fields: Vec<FieldModel>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
|
pub(crate) enum ModelKind {
|
|
Scalars,
|
|
Sections,
|
|
}
|
|
|
|
pub(crate) struct FieldModel {
|
|
pub(crate) ident: Ident,
|
|
pub(crate) ty: Type,
|
|
pub(crate) id: Option<u16>,
|
|
pub(crate) display_key: Option<syn::LitStr>,
|
|
pub(crate) unit: Option<syn::LitStr>,
|
|
pub(crate) checks: Vec<Check>,
|
|
pub(crate) enum_backed: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) enum Check {
|
|
Finite,
|
|
Nonzero,
|
|
Minimum(Expr),
|
|
Maximum(Expr),
|
|
}
|
|
|
|
impl Model {
|
|
pub(crate) fn parse(input: DeriveInput) -> Result<Self> {
|
|
let (crate_path, post_validate) = parse_container_attributes(&input.attrs)?;
|
|
let Data::Struct(data) = input.data else {
|
|
return Err(syn::Error::new(
|
|
input.ident.span(),
|
|
"Config can only be derived for structs",
|
|
));
|
|
};
|
|
let Fields::Named(fields) = data.fields else {
|
|
return Err(syn::Error::new(
|
|
input.ident.span(),
|
|
"Config requires named struct fields",
|
|
));
|
|
};
|
|
if fields.named.is_empty() {
|
|
return Err(syn::Error::new(
|
|
input.ident.span(),
|
|
"Config requires at least one field",
|
|
));
|
|
}
|
|
let mut models = Vec::with_capacity(fields.named.len());
|
|
let mut ids = Vec::new();
|
|
for field in fields.named {
|
|
let model = parse_field(field)?;
|
|
if let Some(id) = model.id {
|
|
if ids.contains(&id) {
|
|
return Err(syn::Error::new(
|
|
model.ident.span(),
|
|
"duplicate configuration field id",
|
|
));
|
|
}
|
|
ids.push(id);
|
|
}
|
|
models.push(model);
|
|
}
|
|
let kind = if models.iter().all(|field| field.id.is_some()) {
|
|
ModelKind::Scalars
|
|
} else if models.iter().all(|field| field.id.is_none()) {
|
|
ModelKind::Sections
|
|
} else {
|
|
let field = models
|
|
.iter()
|
|
.find(|field| field.id.is_none())
|
|
.expect("mixed configuration model has one invalid field");
|
|
return Err(syn::Error::new(
|
|
field.ident.span(),
|
|
"Config records must contain either scalar fields or child configuration records exclusively",
|
|
));
|
|
};
|
|
if kind == ModelKind::Scalars {
|
|
let mut display_keys = Vec::with_capacity(models.len());
|
|
for field in &models {
|
|
let Some(display_key) = &field.display_key else {
|
|
return Err(syn::Error::new(
|
|
field.ident.span(),
|
|
"configuration fields require config(key = \"...\")",
|
|
));
|
|
};
|
|
validate_metadata_text(
|
|
display_key,
|
|
MAX_DISPLAY_KEY_BYTES,
|
|
"configuration display key",
|
|
false,
|
|
)?;
|
|
if display_keys
|
|
.iter()
|
|
.any(|previous: &&syn::LitStr| previous.value() == display_key.value())
|
|
{
|
|
return Err(syn::Error::new(
|
|
display_key.span(),
|
|
"duplicate configuration display key",
|
|
));
|
|
}
|
|
display_keys.push(display_key);
|
|
let Some(unit) = &field.unit else {
|
|
return Err(syn::Error::new(
|
|
field.ident.span(),
|
|
"configuration fields require config(unit = \"...\")",
|
|
));
|
|
};
|
|
validate_metadata_text(unit, MAX_UNIT_BYTES, "configuration unit", true)?;
|
|
let primitive = scalar_type_name(&field.ty).is_some();
|
|
if field.enum_backed && primitive {
|
|
return Err(syn::Error::new(
|
|
field.ty.span(),
|
|
"config(enum) requires a ConfigEnum-derived enum type",
|
|
));
|
|
}
|
|
if !field.enum_backed && !primitive {
|
|
return Err(syn::Error::new(
|
|
field.ty.span(),
|
|
"non-primitive configuration fields require config(enum)",
|
|
));
|
|
}
|
|
validate_static_constraints(field)?;
|
|
}
|
|
}
|
|
Ok(Self {
|
|
ident: input.ident,
|
|
generics: input.generics,
|
|
crate_path,
|
|
post_validate,
|
|
kind,
|
|
fields: models,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn validate_metadata_text(
|
|
literal: &syn::LitStr,
|
|
maximum_bytes: usize,
|
|
name: &str,
|
|
allow_empty: bool,
|
|
) -> Result<()> {
|
|
let value = literal.value();
|
|
if !allow_empty && value.is_empty() {
|
|
return Err(syn::Error::new(
|
|
literal.span(),
|
|
format!("{name} must not be empty"),
|
|
));
|
|
}
|
|
if value.contains('\0') {
|
|
return Err(syn::Error::new(
|
|
literal.span(),
|
|
format!("{name} cannot contain NUL bytes"),
|
|
));
|
|
}
|
|
if value.len() > maximum_bytes {
|
|
return Err(syn::Error::new(
|
|
literal.span(),
|
|
format!("{name} exceeds the {maximum_bytes}-byte wire limit"),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
|
|
let mut crate_path = syn::parse_quote!(::kaptan_config);
|
|
let mut has_crate_path = false;
|
|
let mut post_validate = None;
|
|
for attribute in attributes
|
|
.iter()
|
|
.filter(|attribute| attribute.path().is_ident("config"))
|
|
{
|
|
attribute.parse_nested_meta(|meta| {
|
|
if meta.path.is_ident("crate") {
|
|
if has_crate_path {
|
|
return Err(meta.error("duplicate crate path override"));
|
|
}
|
|
crate_path = meta.value()?.parse()?;
|
|
has_crate_path = true;
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("post") {
|
|
if post_validate.is_some() {
|
|
return Err(meta.error("duplicate post validation hook"));
|
|
}
|
|
post_validate = Some(meta.value()?.parse()?);
|
|
return Ok(());
|
|
}
|
|
Err(meta.error("struct config options are crate = path and post = path"))
|
|
})?;
|
|
}
|
|
Ok((crate_path, post_validate))
|
|
}
|
|
|
|
fn parse_field(field: Field) -> Result<FieldModel> {
|
|
let ident = field
|
|
.ident
|
|
.clone()
|
|
.ok_or_else(|| syn::Error::new(field.span(), "configuration fields must be named"))?;
|
|
let mut checks = Vec::new();
|
|
let mut id = None;
|
|
let mut display_key = None;
|
|
let mut unit = None;
|
|
let mut finite = false;
|
|
let mut nonzero = false;
|
|
let mut range_minimum = false;
|
|
let mut range_maximum = false;
|
|
let mut enum_backed = false;
|
|
for attribute in field
|
|
.attrs
|
|
.iter()
|
|
.filter(|attribute| attribute.path().is_ident("config"))
|
|
{
|
|
attribute.parse_nested_meta(|meta| {
|
|
if meta.path.is_ident("id") {
|
|
if id.is_some() {
|
|
return Err(meta.error("duplicate configuration field id"));
|
|
}
|
|
let literal: syn::LitInt = meta.value()?.parse()?;
|
|
let parsed = literal.base10_parse::<u16>()?;
|
|
if parsed == 0 || parsed == u16::MAX {
|
|
return Err(meta.error("configuration field id is reserved"));
|
|
}
|
|
id = Some(parsed);
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("key") {
|
|
if display_key.is_some() {
|
|
return Err(meta.error("duplicate configuration display key"));
|
|
}
|
|
display_key = Some(meta.value()?.parse()?);
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("unit") {
|
|
if unit.is_some() {
|
|
return Err(meta.error("duplicate configuration unit"));
|
|
}
|
|
unit = Some(meta.value()?.parse()?);
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("enum") {
|
|
if enum_backed {
|
|
return Err(meta.error("duplicate enum configuration marker"));
|
|
}
|
|
enum_backed = true;
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("finite") {
|
|
if finite {
|
|
return Err(meta.error("duplicate finite validation rule"));
|
|
}
|
|
require_float(&field.ty, &meta.path)?;
|
|
checks.push(Check::Finite);
|
|
finite = true;
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("nonzero") {
|
|
if nonzero {
|
|
return Err(meta.error("duplicate nonzero validation rule"));
|
|
}
|
|
require_numeric(&field.ty, &meta.path)?;
|
|
checks.push(Check::Nonzero);
|
|
nonzero = true;
|
|
return Ok(());
|
|
}
|
|
if meta.path.is_ident("range") {
|
|
require_numeric(&field.ty, &meta.path)?;
|
|
let (minimum, maximum) = parse_range(meta)?;
|
|
if minimum.is_none() && maximum.is_none() {
|
|
return Err(syn::Error::new(
|
|
field.span(),
|
|
"range requires min and/or max",
|
|
));
|
|
}
|
|
if let Some(minimum) = minimum {
|
|
if range_minimum {
|
|
return Err(syn::Error::new(field.span(), "duplicate range minimum"));
|
|
}
|
|
checks.push(Check::Minimum(minimum));
|
|
range_minimum = true;
|
|
}
|
|
if let Some(maximum) = maximum {
|
|
if range_maximum {
|
|
return Err(syn::Error::new(field.span(), "duplicate range maximum"));
|
|
}
|
|
checks.push(Check::Maximum(maximum));
|
|
range_maximum = true;
|
|
}
|
|
return Ok(());
|
|
}
|
|
Err(meta.error(
|
|
"supported field config rules are id, key, unit, enum, finite, nonzero, and range",
|
|
))
|
|
})?;
|
|
}
|
|
if id.is_none()
|
|
&& (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
|
|
{
|
|
return Err(syn::Error::new(
|
|
ident.span(),
|
|
"scalar configuration fields require config(id = N)",
|
|
));
|
|
}
|
|
Ok(FieldModel {
|
|
ident,
|
|
ty: field.ty,
|
|
id,
|
|
display_key,
|
|
unit,
|
|
checks,
|
|
enum_backed,
|
|
})
|
|
}
|
|
|
|
fn parse_range(meta: syn::meta::ParseNestedMeta<'_>) -> Result<(Option<Expr>, Option<Expr>)> {
|
|
let mut minimum = None;
|
|
let mut maximum = None;
|
|
meta.parse_nested_meta(|nested| {
|
|
if nested.path.is_ident("min") {
|
|
if minimum.is_some() {
|
|
return Err(nested.error("duplicate range minimum"));
|
|
}
|
|
let expression = nested.value()?.parse()?;
|
|
validate_numeric_literal(&expression)?;
|
|
minimum = Some(expression);
|
|
return Ok(());
|
|
}
|
|
if nested.path.is_ident("max") {
|
|
if maximum.is_some() {
|
|
return Err(nested.error("duplicate range maximum"));
|
|
}
|
|
let expression = nested.value()?.parse()?;
|
|
validate_numeric_literal(&expression)?;
|
|
maximum = Some(expression);
|
|
return Ok(());
|
|
}
|
|
Err(nested.error("range accepts only min and max"))
|
|
})?;
|
|
Ok((minimum, maximum))
|
|
}
|
|
|
|
fn validate_numeric_literal(expression: &Expr) -> Result<()> {
|
|
match expression {
|
|
Expr::Lit(expression) if matches!(expression.lit, Lit::Int(_) | Lit::Float(_)) => Ok(()),
|
|
Expr::Unary(expression)
|
|
if matches!(expression.op, UnOp::Neg(_))
|
|
&& matches!(
|
|
expression.expr.as_ref(),
|
|
Expr::Lit(literal) if matches!(literal.lit, Lit::Int(_) | Lit::Float(_))
|
|
) =>
|
|
{
|
|
Ok(())
|
|
}
|
|
_ => Err(syn::Error::new(
|
|
expression.span(),
|
|
"range bounds must be numeric literals",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
|
let Some(ty) = scalar_type_name(&field.ty) else {
|
|
if field.checks.is_empty() {
|
|
return Ok(());
|
|
}
|
|
return Err(syn::Error::new(
|
|
field.ty.span(),
|
|
"validation rules require a supported primitive scalar type",
|
|
));
|
|
};
|
|
if ty == "bool" {
|
|
return Ok(());
|
|
}
|
|
let minimum = field.checks.iter().find_map(|check| match check {
|
|
Check::Minimum(value) => Some(value),
|
|
_ => None,
|
|
});
|
|
let maximum = field.checks.iter().find_map(|check| match check {
|
|
Check::Maximum(value) => Some(value),
|
|
_ => None,
|
|
});
|
|
let minimum = minimum.map(|value| scalar_literal(value, ty)).transpose()?;
|
|
let maximum = maximum.map(|value| scalar_literal(value, ty)).transpose()?;
|
|
if minimum.is_some_and(|value| !value.is_finite())
|
|
|| maximum.is_some_and(|value| !value.is_finite())
|
|
{
|
|
return Err(syn::Error::new(
|
|
field.ident.span(),
|
|
"configuration range bounds must be finite",
|
|
));
|
|
}
|
|
if let (Some(minimum), Some(maximum)) = (minimum, maximum)
|
|
&& compare_scalar(minimum, maximum).is_gt()
|
|
{
|
|
return Err(syn::Error::new(
|
|
field.ident.span(),
|
|
"configuration range minimum exceeds maximum",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ScalarLiteral {
|
|
Signed(i128),
|
|
Unsigned(u128),
|
|
Float(f64),
|
|
}
|
|
|
|
impl ScalarLiteral {
|
|
fn is_finite(self) -> bool {
|
|
match self {
|
|
Self::Signed(_) | Self::Unsigned(_) => true,
|
|
Self::Float(value) => value.is_finite(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
|
match ty {
|
|
"i32" => {
|
|
let value = signed_literal(expression)?;
|
|
i32::try_from(value).map_err(|_| {
|
|
syn::Error::new(
|
|
expression.span(),
|
|
"configuration literal is outside i32 range",
|
|
)
|
|
})?;
|
|
Ok(ScalarLiteral::Signed(value))
|
|
}
|
|
"u32" => {
|
|
let value = unsigned_literal(expression)?;
|
|
u32::try_from(value).map_err(|_| {
|
|
syn::Error::new(
|
|
expression.span(),
|
|
"configuration literal is outside u32 range",
|
|
)
|
|
})?;
|
|
Ok(ScalarLiteral::Unsigned(value))
|
|
}
|
|
"u8" => {
|
|
let value = unsigned_literal(expression)?;
|
|
u8::try_from(value).map_err(|_| {
|
|
syn::Error::new(
|
|
expression.span(),
|
|
"configuration literal is outside u8 range",
|
|
)
|
|
})?;
|
|
Ok(ScalarLiteral::Unsigned(value))
|
|
}
|
|
"u16" => {
|
|
let value = unsigned_literal(expression)?;
|
|
u16::try_from(value).map_err(|_| {
|
|
syn::Error::new(
|
|
expression.span(),
|
|
"configuration literal is outside u16 range",
|
|
)
|
|
})?;
|
|
Ok(ScalarLiteral::Unsigned(value))
|
|
}
|
|
"u64" => {
|
|
let value = unsigned_literal(expression)?;
|
|
u64::try_from(value).map_err(|_| {
|
|
syn::Error::new(
|
|
expression.span(),
|
|
"configuration literal is outside u64 range",
|
|
)
|
|
})?;
|
|
Ok(ScalarLiteral::Unsigned(value))
|
|
}
|
|
"f32" => Ok(ScalarLiteral::Float(f64::from(
|
|
float_literal(expression)? as f32
|
|
))),
|
|
"f64" => float_literal(expression).map(ScalarLiteral::Float),
|
|
_ => Err(syn::Error::new(
|
|
expression.span(),
|
|
"configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn signed_literal(expression: &Expr) -> Result<i128> {
|
|
match expression {
|
|
Expr::Lit(expression) => match &expression.lit {
|
|
Lit::Int(value) => value.base10_parse(),
|
|
_ => Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected an integer literal",
|
|
)),
|
|
},
|
|
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
|
let Expr::Lit(value) = expression.expr.as_ref() else {
|
|
return Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected an integer literal",
|
|
));
|
|
};
|
|
let Lit::Int(value) = &value.lit else {
|
|
return Err(syn::Error::new(value.span(), "expected an integer literal"));
|
|
};
|
|
value
|
|
.base10_parse::<i128>()
|
|
.map(|value| -value)
|
|
.map_err(|error| syn::Error::new(value.span(), error))
|
|
}
|
|
_ => Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected an integer literal",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn unsigned_literal(expression: &Expr) -> Result<u128> {
|
|
match expression {
|
|
Expr::Lit(expression) => match &expression.lit {
|
|
Lit::Int(value) => value
|
|
.base10_parse()
|
|
.map_err(|error| syn::Error::new(value.span(), error)),
|
|
_ => Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected an unsigned integer literal",
|
|
)),
|
|
},
|
|
_ => Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected an unsigned integer literal",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn float_literal(expression: &Expr) -> Result<f64> {
|
|
let (negative, literal) = match expression {
|
|
Expr::Lit(expression) => (false, &expression.lit),
|
|
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
|
let Expr::Lit(value) = expression.expr.as_ref() else {
|
|
return Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected a float literal",
|
|
));
|
|
};
|
|
(true, &value.lit)
|
|
}
|
|
_ => {
|
|
return Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected a float literal",
|
|
));
|
|
}
|
|
};
|
|
let value = match literal {
|
|
Lit::Int(value) => value
|
|
.base10_parse::<f64>()
|
|
.map_err(|error| syn::Error::new(value.span(), error))?,
|
|
Lit::Float(value) => value
|
|
.base10_parse::<f64>()
|
|
.map_err(|error| syn::Error::new(value.span(), error))?,
|
|
_ => {
|
|
return Err(syn::Error::new(
|
|
expression.span(),
|
|
"expected a float literal",
|
|
));
|
|
}
|
|
};
|
|
Ok(if negative { -value } else { value })
|
|
}
|
|
|
|
fn compare_scalar(left: ScalarLiteral, right: ScalarLiteral) -> core::cmp::Ordering {
|
|
match (left, right) {
|
|
(ScalarLiteral::Signed(left), ScalarLiteral::Signed(right)) => left.cmp(&right),
|
|
(ScalarLiteral::Unsigned(left), ScalarLiteral::Unsigned(right)) => left.cmp(&right),
|
|
(ScalarLiteral::Float(left), ScalarLiteral::Float(right)) => {
|
|
left.partial_cmp(&right).expect("finite literals compare")
|
|
}
|
|
_ => unreachable!("field literals share their scalar type"),
|
|
}
|
|
}
|
|
|
|
fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
|
|
if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") {
|
|
Ok(())
|
|
} else {
|
|
Err(syn::Error::new(
|
|
span.span(),
|
|
"finite requires an f32 or f64 field",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> {
|
|
if scalar_type_name(ty).is_some_and(|name| {
|
|
matches!(
|
|
name,
|
|
"f32"
|
|
| "f64"
|
|
| "i8"
|
|
| "i16"
|
|
| "i32"
|
|
| "i64"
|
|
| "i128"
|
|
| "isize"
|
|
| "u8"
|
|
| "u16"
|
|
| "u32"
|
|
| "u64"
|
|
| "u128"
|
|
| "usize"
|
|
)
|
|
}) {
|
|
Ok(())
|
|
} else {
|
|
Err(syn::Error::new(
|
|
span.span(),
|
|
"nonzero and range require a primitive numeric field",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn scalar_type_name(ty: &Type) -> Option<&str> {
|
|
let Type::Path(path) = ty else {
|
|
return None;
|
|
};
|
|
if path.qself.is_some() || path.path.segments.len() != 1 {
|
|
return None;
|
|
}
|
|
let segment = path.path.segments.first()?;
|
|
if !matches!(segment.arguments, syn::PathArguments::None) {
|
|
return None;
|
|
}
|
|
Some(match &segment.ident {
|
|
ident if ident == "bool" => "bool",
|
|
ident if ident == "f32" => "f32",
|
|
ident if ident == "f64" => "f64",
|
|
ident if ident == "i8" => "i8",
|
|
ident if ident == "i16" => "i16",
|
|
ident if ident == "i32" => "i32",
|
|
ident if ident == "i64" => "i64",
|
|
ident if ident == "i128" => "i128",
|
|
ident if ident == "isize" => "isize",
|
|
ident if ident == "u8" => "u8",
|
|
ident if ident == "u16" => "u16",
|
|
ident if ident == "u32" => "u32",
|
|
ident if ident == "u64" => "u64",
|
|
ident if ident == "u128" => "u128",
|
|
ident if ident == "usize" => "usize",
|
|
_ => return None,
|
|
})
|
|
}
|