use proc_macro2::TokenStream;
use quote::quote;
use crate::ir::types::{Constraint, NewtypeDef, RustType, TypeRef};
use super::util::{make_ident, standard_derives, type_ref_tokens};
pub fn emit_newtype(def: &NewtypeDef) -> TokenStream {
let derives = standard_derives();
let name = make_ident(&def.name);
let inner_type = type_ref_tokens(&TypeRef::Builtin(def.inner));
let constraint_docs: TokenStream = def
.constraints
.iter()
.map(|c| {
let doc = constraint_doc(c);
quote! { #[doc = #doc] }
})
.collect();
let mut tokens = quote! {
#constraint_docs
#derives
#[serde(transparent)]
pub struct #name(pub #inner_type);
};
if !def.constraints.is_empty() && def.inner != RustType::Bool {
tokens.extend(emit_try_from(def));
}
tokens
}
fn emit_try_from(def: &NewtypeDef) -> TokenStream {
let name = make_ident(&def.name);
let ascii_only = def
.constraints
.iter()
.any(|c| matches!(c, Constraint::Pattern(p) if super::validate::is_ascii_only_pattern(p)));
let guard_blocks: Vec<TokenStream> = def
.constraints
.iter()
.filter_map(|c| {
let parts = super::validate::emit_constraint_expr(c, def.inner, ascii_only, false)?;
let preamble = parts.preamble;
let condition = parts.condition;
let message = parts.message;
let kind = parts.kind;
Some(quote! {
{
#preamble
let violated = #condition;
if violated {
return Err(crate::common::validate::ConstraintError {
kind: #kind,
message: #message,
});
}
}
})
})
.collect();
if guard_blocks.is_empty() {
return TokenStream::new();
}
quote! {
impl TryFrom<String> for #name {
type Error = crate::common::validate::ConstraintError;
#[allow(clippy::unreadable_literal)]
fn try_from(value: String) -> Result<Self, Self::Error> {
{
let value: &str = &value;
#(#guard_blocks)*
}
Ok(Self(value))
}
}
impl #name {
#[allow(clippy::unreadable_literal)]
pub fn new(value: impl Into<String>) -> Result<Self, crate::common::validate::ConstraintError> {
Self::try_from(value.into())
}
}
impl From<#name> for String {
fn from(v: #name) -> Self {
v.0
}
}
}
}
fn constraint_doc(c: &Constraint) -> String {
match c {
Constraint::MinLength(n) => format!(" Minimum length: {n}"),
Constraint::MaxLength(n) => format!(" Maximum length: {n}"),
Constraint::Pattern(p) => format!(" Pattern: `{p}`"),
Constraint::MinInclusive(v) => format!(" Minimum value (inclusive): {v}"),
Constraint::MaxInclusive(v) => format!(" Maximum value (inclusive): {v}"),
Constraint::TotalDigits(n) => format!(" Total digits: {n}"),
Constraint::FractionDigits(n) => format!(" Fraction digits: {n}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::types::{Constraint, NewtypeDef, RustType};
#[test]
fn newtype_string() {
let def = NewtypeDef {
name: "Max35Text".to_owned(),
inner: RustType::String,
constraints: vec![],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("pub struct Max35Text"), "src = {src}");
assert!(src.contains("(pub String)"), "src = {src}");
assert!(src.contains("transparent"), "src = {src}");
}
#[test]
fn newtype_bool() {
let def = NewtypeDef {
name: "TrueFalseIndicator".to_owned(),
inner: RustType::Bool,
constraints: vec![],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("(pub bool)"), "src = {src}");
}
#[test]
fn newtype_decimal_maps_to_string() {
let def = NewtypeDef {
name: "ImpliedCurrencyAndAmount".to_owned(),
inner: RustType::Decimal,
constraints: vec![],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("(pub String)"), "src = {src}");
}
#[test]
fn newtype_with_constraints_emits_docs() {
let def = NewtypeDef {
name: "Max35Text".to_owned(),
inner: RustType::String,
constraints: vec![Constraint::MinLength(1), Constraint::MaxLength(35)],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("Minimum length"), "src = {src}");
assert!(src.contains("Maximum length"), "src = {src}");
}
#[test]
fn newtype_with_pattern_emits_doc() {
let def = NewtypeDef {
name: "BICFIDec2014Identifier".to_owned(),
inner: RustType::String,
constraints: vec![Constraint::Pattern(
"[A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}".to_owned(),
)],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("Pattern"), "src = {src}");
}
#[test]
fn newtype_is_valid_rust() {
let def = NewtypeDef {
name: "Max35Text".to_owned(),
inner: RustType::String,
constraints: vec![Constraint::MinLength(1), Constraint::MaxLength(35)],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
syn::parse_file(&src).expect("must be parseable Rust");
}
#[test]
fn constrained_newtype_emits_try_from_new_from() {
let def = NewtypeDef {
name: "CountryCode".to_owned(),
inner: RustType::String,
constraints: vec![Constraint::Pattern("[A-Z]{2,2}".to_owned())],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(
src.contains("TryFrom < String >"),
"should emit TryFrom: {src}"
);
assert!(src.contains("fn new"), "should emit new(): {src}");
assert!(
src.contains("impl From < CountryCode > for String"),
"should emit From<T> for String: {src}"
);
syn::parse_file(&src).expect("must be parseable Rust");
}
#[test]
fn unconstrained_newtype_no_try_from() {
let def = NewtypeDef {
name: "FreeText".to_owned(),
inner: RustType::String,
constraints: vec![],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(
!src.contains("TryFrom"),
"unconstrained should NOT emit TryFrom: {src}"
);
}
#[test]
fn bool_newtype_no_try_from() {
let def = NewtypeDef {
name: "TrueFalseIndicator".to_owned(),
inner: RustType::Bool,
constraints: vec![Constraint::MinLength(1)], };
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(
!src.contains("TryFrom"),
"bool inner should NOT emit TryFrom: {src}"
);
}
#[test]
fn pattern_only_newtype_emits_try_from() {
let def = NewtypeDef {
name: "ActiveCurrencyCode".to_owned(),
inner: RustType::String,
constraints: vec![Constraint::Pattern("[A-Z]{3,3}".to_owned())],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(
src.contains("TryFrom"),
"pattern-only should emit TryFrom: {src}"
);
assert!(
src.contains("ConstraintError"),
"should reference ConstraintError: {src}"
);
syn::parse_file(&src).expect("must be parseable Rust");
}
#[test]
fn constrained_newtype_with_length_is_valid_rust() {
let def = NewtypeDef {
name: "Max35Text".to_owned(),
inner: RustType::String,
constraints: vec![Constraint::MinLength(1), Constraint::MaxLength(35)],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("TryFrom"), "should emit TryFrom: {src}");
syn::parse_file(&src).expect("must be parseable Rust");
}
#[test]
fn decimal_with_digits_emits_try_from() {
let def = NewtypeDef {
name: "Amount".to_owned(),
inner: RustType::Decimal,
constraints: vec![Constraint::TotalDigits(18), Constraint::FractionDigits(5)],
};
let ts = emit_newtype(&def);
let src = ts.to_string();
assert!(src.contains("TryFrom"), "should emit TryFrom: {src}");
syn::parse_file(&src).expect("must be parseable Rust");
}
}