pub mod derive_deserialize;
pub mod derive_serialize;
use crate::host::ForeignTypeConfig;
use crate::host::import_registry::{with_registry, with_registry_mut};
use crate::ts_syn::abi::{DecoratorIR, DiagnosticCollector, SpanIR};
pub use crate::host::import_registry::{
ImportRegistry, clear_registry, install_registry, take_registry,
};
pub fn get_foreign_types() -> Vec<ForeignTypeConfig> {
let mut types = crate::host::import_registry::with_foreign_types(|ft| ft.to_vec());
types.extend(get_builtin_foreign_types());
types
}
fn get_builtin_foreign_types() -> Vec<ForeignTypeConfig> {
let ft = |name: &str, ser: &str, deser: &str| ForeignTypeConfig {
name: name.to_string(),
namespace: None,
from: vec![],
serialize_expr: Some(ser.to_string()),
serialize_import: None,
deserialize_expr: Some(deser.to_string()),
deserialize_import: None,
default_expr: None,
default_import: None,
has_shape_expr: None,
has_shape_import: None,
aliases: vec![],
expression_namespaces: vec![],
};
let typed_array = |name: &str| {
ft(
name,
"(v) => Array.from(v)",
&format!("(v) => new {name}(v as number[])"),
)
};
let big_typed_array = |name: &str| {
ft(
name,
"(v) => Array.from(v, (n) => String(n))",
&format!("(v) => new {name}((v as string[]).map((s) => BigInt(s)))"),
)
};
vec![
ft("bigint", "(v) => String(v)", "(v) => BigInt(v as string)"),
ft("URL", "(v) => v.toString()", "(v) => new URL(v as string)"),
ft(
"URLSearchParams",
"(v) => v.toString()",
"(v) => new URLSearchParams(v as string)",
),
ft(
"RegExp",
"(v) => ({ source: v.source, flags: v.flags })",
"(v) => new RegExp((v as any).source, (v as any).flags)",
),
ft(
"Error",
"(v) => ({ name: v.name, message: v.message, stack: v.stack })",
"(v) => Object.assign(new Error((v as any).message), { name: (v as any).name })",
),
typed_array("Int8Array"),
typed_array("Uint8Array"),
typed_array("Uint8ClampedArray"),
typed_array("Int16Array"),
typed_array("Uint16Array"),
typed_array("Int32Array"),
typed_array("Uint32Array"),
typed_array("Float32Array"),
typed_array("Float64Array"),
big_typed_array("BigInt64Array"),
big_typed_array("BigUint64Array"),
ft(
"ArrayBuffer",
"(v) => Array.from(new Uint8Array(v))",
"(v) => new Uint8Array(v as number[]).buffer",
),
]
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum RenameAll {
#[default]
None,
CamelCase,
SnakeCase,
ScreamingSnakeCase,
KebabCase,
PascalCase,
}
impl std::str::FromStr for RenameAll {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().replace(['-', '_'], "").as_str() {
"camelcase" => Ok(Self::CamelCase),
"snakecase" => Ok(Self::SnakeCase),
"screamingsnakecase" => Ok(Self::ScreamingSnakeCase),
"kebabcase" => Ok(Self::KebabCase),
"pascalcase" => Ok(Self::PascalCase),
_ => Err(()),
}
}
}
impl RenameAll {
pub fn apply(&self, name: &str) -> String {
use convert_case::{Case, Casing};
match self {
Self::None => name.to_string(),
Self::CamelCase => name.to_case(Case::Camel),
Self::SnakeCase => name.to_case(Case::Snake),
Self::ScreamingSnakeCase => name.to_case(Case::UpperSnake),
Self::KebabCase => name.to_case(Case::Kebab),
Self::PascalCase => name.to_case(Case::Pascal),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TaggingMode {
InternallyTagged { tag: String },
ExternallyTagged,
AdjacentlyTagged { tag: String, content: String },
Untagged,
}
impl Default for TaggingMode {
fn default() -> Self {
Self::InternallyTagged {
tag: "__type".to_string(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SerdeContainerOptions {
pub rename_all: RenameAll,
pub deny_unknown_fields: bool,
pub tagging: TaggingMode,
}
impl SerdeContainerOptions {
pub fn from_decorators(decorators: &[DecoratorIR]) -> Self {
let mut opts = Self::default();
for decorator in decorators {
if !decorator.name.eq_ignore_ascii_case("serde") {
continue;
}
let args = decorator.args_src.trim();
if let Some(rename_all) = extract_named_string(args, "renameAll")
&& let Ok(convention) = rename_all.parse::<RenameAll>()
{
opts.rename_all = convention;
}
if has_flag(args, "denyUnknownFields") {
opts.deny_unknown_fields = true;
}
let tag = extract_named_string(args, "tag");
let content = extract_named_string(args, "content");
let untagged = has_flag(args, "untagged");
let externally_tagged = has_flag(args, "externallyTagged");
if untagged {
opts.tagging = TaggingMode::Untagged;
} else if externally_tagged {
opts.tagging = TaggingMode::ExternallyTagged;
} else if let Some(tag_name) = tag {
if let Some(content_name) = content {
opts.tagging = TaggingMode::AdjacentlyTagged {
tag: tag_name,
content: content_name,
};
} else {
opts.tagging = TaggingMode::InternallyTagged { tag: tag_name };
}
}
}
opts
}
pub fn tag_field(&self) -> Option<&str> {
match &self.tagging {
TaggingMode::InternallyTagged { tag } => Some(tag.as_str()),
TaggingMode::AdjacentlyTagged { tag, .. } => Some(tag.as_str()),
_ => None,
}
}
pub fn tag_field_or_default(&self) -> &str {
match &self.tagging {
TaggingMode::InternallyTagged { tag } => tag.as_str(),
TaggingMode::AdjacentlyTagged { tag, .. } => tag.as_str(),
_ => "__type",
}
}
pub fn content_field(&self) -> Option<&str> {
match &self.tagging {
TaggingMode::AdjacentlyTagged { content, .. } => Some(content.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SerdeFieldOptions {
pub skip: bool,
pub skip_serializing: bool,
pub skip_deserializing: bool,
pub rename: Option<String>,
pub default: bool,
pub default_expr: Option<String>,
pub flatten: bool,
pub validators: Vec<ValidatorSpec>,
pub serialize_with: Option<String>,
pub deserialize_with: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SerdeFieldParseResult {
pub options: SerdeFieldOptions,
pub diagnostics: DiagnosticCollector,
}
impl SerdeFieldOptions {
pub fn from_decorators(decorators: &[DecoratorIR], field_name: &str) -> SerdeFieldParseResult {
let mut opts = Self::default();
let mut diagnostics = DiagnosticCollector::new();
for decorator in decorators {
if !decorator.name.eq_ignore_ascii_case("serde") {
continue;
}
let args = decorator.args_src.trim();
let decorator_span = decorator.span;
if has_flag(args, "skip") {
opts.skip = true;
}
if has_flag(args, "skipSerializing") {
opts.skip_serializing = true;
}
if has_flag(args, "skipDeserializing") {
opts.skip_deserializing = true;
}
if has_flag(args, "flatten") {
opts.flatten = true;
}
if let Some(default_expr) = extract_named_string(args, "default") {
opts.default = true;
opts.default_expr = Some(default_expr);
} else if has_flag(args, "default") {
opts.default = true;
}
if let Some(rename) = extract_named_string(args, "rename") {
opts.rename = Some(rename);
}
if let Some(fn_name) = extract_named_string(args, "serializeWith") {
opts.serialize_with = Some(fn_name);
}
if let Some(fn_name) = extract_named_string(args, "deserializeWith") {
opts.deserialize_with = Some(fn_name);
}
if let Some(format) = extract_named_string(args, "format") {
opts.format = Some(format);
}
let validators = extract_validators(args, decorator_span, field_name, &mut diagnostics);
opts.validators.extend(validators);
}
SerdeFieldParseResult {
options: opts,
diagnostics,
}
}
pub fn should_serialize(&self) -> bool {
!self.skip && !self.skip_serializing
}
pub fn should_deserialize(&self) -> bool {
!self.skip && !self.skip_deserializing
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TypeCategory {
Primitive,
Array(String),
Optional(String),
Nullable(String),
Date,
Map(String, String),
Set(String),
Record(String, String),
Wrapper(String),
Serializable(String),
Unknown,
}
impl TypeCategory {
pub fn from_ts_type(ts_type: &str) -> Self {
let trimmed = ts_type.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return Self::Primitive;
}
match trimmed {
"string" | "number" | "boolean" | "null" | "undefined" | "bigint" => {
return Self::Primitive;
}
"Date" => return Self::Date,
_ => {}
}
if trimmed.starts_with("Array<") && trimmed.ends_with('>') {
let inner = &trimmed[6..trimmed.len() - 1];
return Self::Array(inner.to_string());
}
if let Some(inner) = trimmed.strip_suffix("[]") {
return Self::Array(inner.to_string());
}
if trimmed.starts_with("Map<") && trimmed.ends_with('>') {
let inner = &trimmed[4..trimmed.len() - 1];
if let Some(comma_pos) = find_top_level_comma(inner) {
let key = inner[..comma_pos].trim().to_string();
let value = inner[comma_pos + 1..].trim().to_string();
return Self::Map(key, value);
}
}
if trimmed.starts_with("Set<") && trimmed.ends_with('>') {
let inner = &trimmed[4..trimmed.len() - 1];
return Self::Set(inner.to_string());
}
if trimmed.starts_with("Record<") && trimmed.ends_with('>') {
let inner = &trimmed[7..trimmed.len() - 1];
if let Some(comma_pos) = find_top_level_comma(inner) {
let key = inner[..comma_pos].trim().to_string();
let value = inner[comma_pos + 1..].trim().to_string();
return Self::Record(key, value);
}
}
if let Some(parts) = split_top_level_union(trimmed) {
if parts.contains(&"undefined") {
let non_undefined: Vec<&str> = parts
.iter()
.filter(|p| *p != &"undefined")
.copied()
.collect();
return Self::Optional(non_undefined.join(" | "));
}
if parts.contains(&"null") {
let non_null: Vec<&str> = parts.iter().filter(|p| *p != &"null").copied().collect();
return Self::Nullable(non_null.join(" | "));
}
return Self::Unknown;
}
if let Some(first_char) = trimmed.chars().next()
&& first_char.is_uppercase()
&& !matches!(
trimmed,
"String"
| "Number"
| "Boolean"
| "Object"
| "Function"
| "Symbol"
| "URL"
| "URLSearchParams"
| "RegExp"
| "Error"
| "EvalError"
| "RangeError"
| "ReferenceError"
| "SyntaxError"
| "TypeError"
| "URIError"
| "Int8Array"
| "Uint8Array"
| "Uint8ClampedArray"
| "Int16Array"
| "Uint16Array"
| "Int32Array"
| "Uint32Array"
| "Float32Array"
| "Float64Array"
| "BigInt64Array"
| "BigUint64Array"
| "ArrayBuffer"
| "DataView"
)
{
let base_type = if let Some(idx) = trimmed.find('<') {
&trimmed[..idx]
} else {
trimmed
};
if matches!(
base_type,
"Partial" | "Required" | "Readonly" | "NonNullable"
) {
if let Some(start) = trimmed.find('<') {
let inner = &trimmed[start + 1..trimmed.len() - 1];
return Self::Wrapper(inner.to_string());
}
}
if matches!(base_type, "Pick" | "Omit") {
if let Some(start) = trimmed.find('<') {
let inner = &trimmed[start + 1..trimmed.len() - 1];
if let Some(comma_pos) = find_top_level_comma(inner) {
let first_arg = inner[..comma_pos].trim();
return Self::Wrapper(first_arg.to_string());
}
}
}
if matches!(
base_type,
"Exclude"
| "Extract"
| "ReturnType"
| "Parameters"
| "InstanceType"
| "ThisType"
| "Awaited"
| "Promise"
) {
return Self::Unknown;
}
let base = if let Some(idx) = trimmed.find('<') {
&trimmed[..idx]
} else {
trimmed
};
if base.contains('.') {
return Self::Unknown;
}
return Self::Serializable(base.to_string());
}
Self::Unknown
}
pub fn match_foreign_type<'a>(
ts_type: &str,
foreign_types: &'a [ForeignTypeConfig],
) -> ForeignTypeMatch<'a> {
let trimmed = ts_type.trim();
let import_sources = with_registry(|r| r.source_modules());
let import_aliases = with_registry(|r| r.aliases());
if trimmed.is_empty() {
return ForeignTypeMatch::none();
}
let base_type = if let Some(idx) = trimmed.find('<') {
&trimmed[..idx]
} else {
trimmed
};
let (namespace_part, type_name) = if let Some(dot_idx) = base_type.rfind('.') {
(Some(&base_type[..dot_idx]), &base_type[dot_idx + 1..])
} else {
(None, base_type)
};
let first_namespace_part = namespace_part.map(|ns| {
if let Some(dot_idx) = ns.find('.') {
&ns[..dot_idx]
} else {
ns
}
});
let import_name = first_namespace_part.unwrap_or(type_name);
let resolved_type_name = if namespace_part.is_none() {
import_aliases
.get(type_name)
.map(String::as_str)
.unwrap_or(type_name)
} else {
type_name
};
let resolved_base_type = if namespace_part.is_none() {
import_aliases
.get(base_type)
.map(String::as_str)
.unwrap_or(base_type)
} else {
base_type
};
let resolved_namespace: Option<String> = namespace_part.map(|ns| {
let parts: Vec<&str> = ns.split('.').collect();
if let Some(first_part) = parts.first() {
if let Some(resolved_first) = import_aliases.get(*first_part) {
let mut resolved_parts: Vec<&str> = vec![resolved_first.as_str()];
resolved_parts.extend(&parts[1..]);
resolved_parts.join(".")
} else {
ns.to_string()
}
} else {
ns.to_string()
}
});
let mut near_match: Option<(&ForeignTypeConfig, String)> = None;
for ft in foreign_types {
let ft_type_name = ft.get_type_name();
let ft_namespace = ft.get_namespace();
let ft_qualified = ft.get_qualified_name();
let name_matches = type_name == ft_type_name
|| base_type == ft.name
|| base_type == ft_qualified
|| resolved_type_name == ft_type_name
|| resolved_base_type == ft.name
|| resolved_base_type == ft_qualified;
let namespace_matches = match (namespace_part, ft_namespace) {
(Some(ns), Some(ft_ns)) => {
let resolved_ns = resolved_namespace.as_deref().unwrap_or(ns);
ns == ft_ns || resolved_ns == ft_ns
}
(None, None) => true,
(None, Some(_)) => false,
(Some(_), None) => base_type == ft.name || resolved_base_type == ft.name,
};
if name_matches && namespace_matches {
if ft.from.is_empty() {
return ForeignTypeMatch::matched(ft);
}
if let Some(actual_source) = import_sources.get(import_name) {
let source_matches = ft.from.iter().any(|configured_source| {
actual_source == configured_source
|| actual_source.ends_with(configured_source)
|| configured_source.ends_with(actual_source)
});
if source_matches {
register_foreign_type_namespaces(ft, actual_source);
return ForeignTypeMatch::matched(ft);
}
} else {
}
} else if (type_name == ft_type_name || resolved_type_name == ft_type_name)
&& !name_matches
{
let warning = format!(
"Type '{}' has the same name as foreign type '{}' but uses different qualification. \
Expected '{}' or configure with namespace: '{}'.",
base_type,
ft.name,
ft_qualified,
namespace_part.unwrap_or(type_name)
);
if near_match.is_none() {
near_match = Some((ft, warning));
}
}
for alias in &ft.aliases {
let (alias_namespace, alias_type_name) =
if let Some(dot_idx) = alias.name.rfind('.') {
(Some(&alias.name[..dot_idx]), &alias.name[dot_idx + 1..])
} else {
(None, alias.name.as_str())
};
let alias_name_matches = type_name == alias_type_name || base_type == alias.name;
let alias_namespace_matches = match (namespace_part, alias_namespace) {
(Some(ns), Some(alias_ns)) => ns == alias_ns,
(None, None) => true,
(None, Some(_)) => false,
(Some(_), None) => base_type == alias.name,
};
if alias_name_matches && alias_namespace_matches {
if ft.from.is_empty() {
return ForeignTypeMatch::matched(ft);
}
let import_name = namespace_part.unwrap_or(type_name);
if let Some(actual_source) = import_sources.get(import_name) {
if actual_source == &alias.from
|| actual_source.ends_with(&alias.from)
|| alias.from.ends_with(actual_source)
{
register_foreign_type_namespaces(ft, actual_source);
return ForeignTypeMatch::matched(ft);
}
}
}
}
}
if let Some((ft, warning)) = near_match {
ForeignTypeMatch::near_match(ft, warning)
} else {
ForeignTypeMatch::none()
}
}
}
pub fn rewrite_expression_namespaces(expr: &str) -> String {
with_registry(|r| {
let mut result = expr.to_string();
let mut found_any = false;
for import in r.generated_imports() {
if let Some(ref original) = import.original_name
&& !import.is_type_only
{
let pattern = format!("{}.", original);
let replacement = format!("{}.", import.local_name);
if result.contains(&pattern) {
result = result.replace(&pattern, &replacement);
found_any = true;
}
}
}
if !found_any {
return expr.to_string();
}
result
})
}
fn register_foreign_type_namespaces(ft: &ForeignTypeConfig, import_module: &str) {
with_registry_mut(|r| {
for ns in &ft.expression_namespaces {
let has_import = r.is_available(ns);
let has_config_import = r.config_imports.contains_key(ns);
if !has_import && !has_config_import {
continue;
}
let is_type_only = r.is_type_only(ns);
if is_type_only || (!r.source_map().contains_key(ns) && has_config_import) {
let module = r.config_imports.get(ns).cloned().unwrap_or_else(|| {
ft.from
.first()
.cloned()
.unwrap_or_else(|| import_module.to_string())
});
let alias = format!("__mf_{}", ns);
r.request_namespace_import(ns, &module, &alias);
}
}
let type_name = ft.get_type_name();
if !r.is_available(&ft.name) && !r.is_available(type_name) && !ft.from.is_empty() {
r.request_type_import(type_name, &ft.from[0]);
}
});
}
#[derive(Debug)]
pub struct ForeignTypeMatch<'a> {
pub config: Option<&'a ForeignTypeConfig>,
pub warning: Option<String>,
pub error: Option<String>,
}
impl<'a> ForeignTypeMatch<'a> {
pub fn matched(config: &'a ForeignTypeConfig) -> Self {
Self {
config: Some(config),
warning: None,
error: None,
}
}
pub fn import_mismatch(_config: &'a ForeignTypeConfig, error: String) -> Self {
Self {
config: None,
warning: None,
error: Some(error),
}
}
pub fn near_match(_config: &'a ForeignTypeConfig, warning: String) -> Self {
Self {
config: None,
warning: Some(warning),
error: None,
}
}
pub fn none() -> Self {
Self {
config: None,
warning: None,
error: None,
}
}
pub fn is_match(&self) -> bool {
self.config.is_some()
}
pub fn has_error(&self) -> bool {
self.error.is_some()
}
}
#[derive(Debug, Clone)]
pub struct ValidatorSpec {
pub validator: Validator,
pub custom_message: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Validator {
Email,
Url,
Uuid,
MaxLength(usize),
MinLength(usize),
Length(usize),
LengthRange(usize, usize),
Pattern(String),
NonEmpty,
Trimmed,
Lowercase,
Uppercase,
Capitalized,
Uncapitalized,
StartsWith(String),
EndsWith(String),
Includes(String),
GreaterThan(f64),
GreaterThanOrEqualTo(f64),
LessThan(f64),
LessThanOrEqualTo(f64),
Between(f64, f64),
Int,
NonNaN,
Finite,
Positive,
NonNegative,
Negative,
NonPositive,
MultipleOf(f64),
Uint8,
MaxItems(usize),
MinItems(usize),
ItemsCount(usize),
ValidDate,
GreaterThanDate(String),
GreaterThanOrEqualToDate(String),
LessThanDate(String),
LessThanOrEqualToDate(String),
BetweenDate(String, String),
GreaterThanBigInt(String),
GreaterThanOrEqualToBigInt(String),
LessThanBigInt(String),
LessThanOrEqualToBigInt(String),
BetweenBigInt(String, String),
PositiveBigInt,
NonNegativeBigInt,
NegativeBigInt,
NonPositiveBigInt,
Custom(String),
}
#[derive(Debug, Clone)]
pub struct ValidatorParseError {
pub message: String,
pub help: Option<String>,
}
impl ValidatorParseError {
pub fn unknown_validator(name: &str) -> Self {
let similar = find_similar_validator(name);
Self {
message: format!("unknown validator '{}'", name),
help: similar.map(|s| format!("did you mean '{}'?", s)),
}
}
pub fn invalid_args(name: &str, reason: &str) -> Self {
Self {
message: format!("invalid arguments for '{}': {}", name, reason),
help: None,
}
}
}
const KNOWN_VALIDATORS: &[&str] = &[
"email",
"url",
"uuid",
"maxLength",
"minLength",
"length",
"pattern",
"nonEmpty",
"trimmed",
"lowercase",
"uppercase",
"capitalized",
"uncapitalized",
"startsWith",
"endsWith",
"includes",
"greaterThan",
"greaterThanOrEqualTo",
"lessThan",
"lessThanOrEqualTo",
"between",
"int",
"nonNaN",
"finite",
"positive",
"nonNegative",
"negative",
"nonPositive",
"multipleOf",
"uint8",
"maxItems",
"minItems",
"itemsCount",
"validDate",
"greaterThanDate",
"greaterThanOrEqualToDate",
"lessThanDate",
"lessThanOrEqualToDate",
"betweenDate",
"positiveBigInt",
"nonNegativeBigInt",
"negativeBigInt",
"nonPositiveBigInt",
"greaterThanBigInt",
"greaterThanOrEqualToBigInt",
"lessThanBigInt",
"lessThanOrEqualToBigInt",
"betweenBigInt",
"custom",
];
fn find_similar_validator(name: &str) -> Option<&'static str> {
let name_lower = name.to_lowercase();
KNOWN_VALIDATORS
.iter()
.filter_map(|v| {
let dist = levenshtein_distance(&v.to_lowercase(), &name_lower);
if dist <= 2 { Some((*v, dist)) } else { None }
})
.min_by_key(|(_, dist)| *dist)
.map(|(v, _)| v)
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let len_a = a_chars.len();
let len_b = b_chars.len();
if len_a == 0 {
return len_b;
}
if len_b == 0 {
return len_a;
}
let mut prev_row: Vec<usize> = (0..=len_b).collect();
let mut curr_row: Vec<usize> = vec![0; len_b + 1];
for i in 1..=len_a {
curr_row[0] = i;
for j in 1..=len_b {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
curr_row[j] = (prev_row[j] + 1)
.min(curr_row[j - 1] + 1)
.min(prev_row[j - 1] + cost);
}
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[len_b]
}
pub fn has_flag(args: &str, flag: &str) -> bool {
if flag_explicit_false(args, flag) {
return false;
}
args.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|token| token.eq_ignore_ascii_case(flag))
}
fn flag_explicit_false(args: &str, flag: &str) -> bool {
let lower = args.to_ascii_lowercase();
let condensed: String = lower.chars().filter(|c| !c.is_whitespace()).collect();
condensed.contains(&format!("{flag}:false")) || condensed.contains(&format!("{flag}=false"))
}
pub fn extract_named_string(args: &str, name: &str) -> Option<String> {
let lower = args.to_ascii_lowercase();
let name_lower = name.to_ascii_lowercase();
let mut search_start = 0;
while let Some(relative_idx) = lower[search_start..].find(&name_lower) {
let idx = search_start + relative_idx;
let at_word_start = idx == 0 || {
let prev_char = lower.chars().nth(idx - 1).unwrap_or(' ');
!prev_char.is_alphanumeric() && prev_char != '_'
};
if at_word_start {
let remainder = &args[idx + name.len()..];
let remainder = remainder.trim_start();
if remainder.starts_with(':') || remainder.starts_with('=') {
let value = remainder[1..].trim_start();
return parse_string_literal(value);
}
if remainder.starts_with('(')
&& let Some(close) = remainder.rfind(')')
{
let inner = remainder[1..close].trim();
return parse_string_literal(inner);
}
}
search_start = idx + 1;
}
None
}
fn parse_string_literal(input: &str) -> Option<String> {
let trimmed = input.trim();
let mut chars = trimmed.chars();
let quote = chars.next()?;
if quote != '"' && quote != '\'' {
return None;
}
let mut escaped = false;
let mut buf = String::new();
for c in chars {
if escaped {
buf.push(c);
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == quote {
return Some(buf);
}
buf.push(c);
}
None
}
pub(super) fn find_top_level_comma(s: &str) -> Option<usize> {
let mut depth = 0;
for (i, c) in s.char_indices() {
match c {
'<' => depth += 1,
'>' => depth -= 1,
',' if depth == 0 => return Some(i),
_ => {}
}
}
None
}
pub(crate) fn split_top_level_union(s: &str) -> Option<Vec<&str>> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut in_string = false;
let mut string_char = '"';
let mut start = 0;
let mut found_pipe = false;
for (i, c) in s.char_indices() {
if in_string {
if c == string_char {
in_string = false;
}
continue;
}
match c {
'\'' | '"' => {
in_string = true;
string_char = c;
}
'<' | '(' | '[' | '{' => depth += 1,
'>' | ')' | ']' | '}' => depth = depth.saturating_sub(1),
'|' if depth == 0 => {
parts.push(s[start..i].trim());
start = i + 1;
found_pipe = true;
}
_ => {}
}
}
if !found_pipe {
return None;
}
parts.push(s[start..].trim());
Some(parts)
}
const KNOWN_OPTIONS: &[&str] = &[
"skip",
"skipSerializing",
"skipDeserializing",
"flatten",
"default",
"rename",
"validate",
"message",
"serializeWith",
"deserializeWith",
"format",
];
pub fn extract_validators(
args: &str,
decorator_span: SpanIR,
field_name: &str,
diagnostics: &mut DiagnosticCollector,
) -> Vec<ValidatorSpec> {
let mut validators = Vec::new();
let lower = args.to_ascii_lowercase();
if let Some(idx) = lower.find("validate") {
let remainder = &args[idx + 8..].trim_start();
if remainder.starts_with(':') || remainder.starts_with('=') {
let value_start = &remainder[1..].trim_start();
if value_start.starts_with('[') {
return parse_validator_array(value_start, decorator_span, field_name, diagnostics);
} else {
diagnostics.error(
decorator_span,
format!(
"field '{}': validate must be an array, e.g., validate: [\"email\"]",
field_name
),
);
return validators;
}
}
}
for item in split_decorator_args(args) {
let item = item.trim();
if item.is_empty() {
continue;
}
let item_lower = item.to_ascii_lowercase();
let base_name = item_lower.split('(').next().unwrap_or(&item_lower);
let base_name = base_name.split(':').next().unwrap_or(base_name).trim();
if KNOWN_OPTIONS.contains(&base_name) {
continue;
}
let is_likely_validator = KNOWN_VALIDATORS.contains(&base_name) || item.contains('(');
if is_likely_validator {
match parse_validator_string(item) {
Ok(v) => validators.push(ValidatorSpec {
validator: v,
custom_message: None,
}),
Err(err) => {
if let Some(help) = err.help {
diagnostics.error_with_help(
decorator_span,
format!("field '{}': {}", field_name, err.message),
help,
);
} else {
diagnostics.error(
decorator_span,
format!("field '{}': {}", field_name, err.message),
);
}
}
}
}
}
validators
}
fn split_decorator_args(input: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut depth = 0;
let mut in_string = false;
let mut string_char = '"';
for c in input.chars() {
if in_string {
current.push(c);
if c == string_char {
in_string = false;
}
continue;
}
match c {
'"' | '\'' => {
in_string = true;
string_char = c;
current.push(c);
}
'(' | '[' | '{' => {
depth += 1;
current.push(c);
}
')' | ']' | '}' => {
depth -= 1;
current.push(c);
}
',' if depth == 0 => {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
items.push(trimmed);
}
current.clear();
}
_ => current.push(c),
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
items.push(trimmed);
}
items
}
fn parse_validator_array(
input: &str,
decorator_span: SpanIR,
field_name: &str,
diagnostics: &mut DiagnosticCollector,
) -> Vec<ValidatorSpec> {
let mut validators = Vec::new();
let Some(content) = extract_bracket_content(input, '[', ']') else {
diagnostics.error(
decorator_span,
format!("field '{}': malformed validator array", field_name),
);
return validators;
};
for item in split_array_items(&content) {
let item = item.trim();
if item.starts_with('{') {
match parse_validator_object(item) {
Ok(spec) => validators.push(spec),
Err(err) => {
if let Some(help) = err.help {
diagnostics.error_with_help(
decorator_span,
format!("field '{}': {}", field_name, err.message),
help,
);
} else {
diagnostics.error(
decorator_span,
format!("field '{}': {}", field_name, err.message),
);
}
}
}
} else if item.starts_with('"') || item.starts_with('\'') {
if let Some(s) = parse_string_literal(item) {
match parse_validator_string(&s) {
Ok(v) => validators.push(ValidatorSpec {
validator: v,
custom_message: None,
}),
Err(err) => {
if let Some(help) = err.help {
diagnostics.error_with_help(
decorator_span,
format!("field '{}': {}", field_name, err.message),
help,
);
} else {
diagnostics.error(
decorator_span,
format!("field '{}': {}", field_name, err.message),
);
}
}
}
}
}
}
validators
}
fn extract_bracket_content(input: &str, open: char, close: char) -> Option<String> {
let mut depth = 0;
let mut start = None;
for (i, c) in input.char_indices() {
if c == open {
if depth == 0 {
start = Some(i + 1);
}
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0
&& let Some(s) = start
{
return Some(input[s..i].to_string());
}
}
}
None
}
fn split_array_items(input: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
let mut depth = 0;
let mut in_string = false;
let mut string_char = '"';
for c in input.chars() {
if in_string {
current.push(c);
if c == string_char {
in_string = false;
}
continue;
}
match c {
'"' | '\'' => {
in_string = true;
string_char = c;
current.push(c);
}
'[' | '{' | '(' => {
depth += 1;
current.push(c);
}
']' | '}' | ')' => {
depth -= 1;
current.push(c);
}
',' if depth == 0 => {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
items.push(trimmed);
}
current.clear();
}
_ => current.push(c),
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
items.push(trimmed);
}
items
}
fn parse_validator_object(input: &str) -> Result<ValidatorSpec, ValidatorParseError> {
let content = extract_bracket_content(input, '{', '}')
.ok_or_else(|| ValidatorParseError::invalid_args("object", "malformed validator object"))?;
let validator_str = extract_named_string(&content, "validate")
.ok_or_else(|| ValidatorParseError::invalid_args("object", "missing 'validate' field"))?;
let validator = parse_validator_string(&validator_str)?;
let custom_message = extract_named_string(&content, "message");
Ok(ValidatorSpec {
validator,
custom_message,
})
}
fn parse_validator_string(s: &str) -> Result<Validator, ValidatorParseError> {
let trimmed = s.trim();
if let Some(paren_idx) = trimmed.find('(') {
let name = &trimmed[..paren_idx];
let Some(args_end) = trimmed.rfind(')') else {
return Err(ValidatorParseError::invalid_args(
name,
"missing closing parenthesis",
));
};
let args = &trimmed[paren_idx + 1..args_end];
return parse_validator_with_args(name, args);
}
match trimmed.to_lowercase().as_str() {
"email" => Ok(Validator::Email),
"url" => Ok(Validator::Url),
"uuid" => Ok(Validator::Uuid),
"nonempty" | "nonemptystring" => Ok(Validator::NonEmpty),
"trimmed" => Ok(Validator::Trimmed),
"lowercase" | "lowercased" => Ok(Validator::Lowercase),
"uppercase" | "uppercased" => Ok(Validator::Uppercase),
"capitalized" => Ok(Validator::Capitalized),
"uncapitalized" => Ok(Validator::Uncapitalized),
"int" => Ok(Validator::Int),
"nonnan" => Ok(Validator::NonNaN),
"finite" => Ok(Validator::Finite),
"positive" => Ok(Validator::Positive),
"nonnegative" => Ok(Validator::NonNegative),
"negative" => Ok(Validator::Negative),
"nonpositive" => Ok(Validator::NonPositive),
"uint8" => Ok(Validator::Uint8),
"validdate" | "validdatefromself" => Ok(Validator::ValidDate),
"positivebigint" | "positivebigintfromself" => Ok(Validator::PositiveBigInt),
"nonnegativebigint" | "nonnegativebigintfromself" => Ok(Validator::NonNegativeBigInt),
"negativebigint" | "negativebigintfromself" => Ok(Validator::NegativeBigInt),
"nonpositivebigint" | "nonpositivebigintfromself" => Ok(Validator::NonPositiveBigInt),
"nonnegativeint" => Ok(Validator::Int), _ => Err(ValidatorParseError::unknown_validator(trimmed)),
}
}
fn parse_validator_with_args(name: &str, args: &str) -> Result<Validator, ValidatorParseError> {
let name_lower = name.to_lowercase();
match name_lower.as_str() {
"maxlength" => args
.trim()
.parse()
.map(Validator::MaxLength)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a positive integer")),
"minlength" => args
.trim()
.parse()
.map(Validator::MinLength)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a positive integer")),
"length" => {
let parts: Vec<&str> = args.split(',').collect();
match parts.len() {
1 => parts[0].trim().parse().map(Validator::Length).map_err(|_| {
ValidatorParseError::invalid_args(name, "expected a positive integer")
}),
2 => {
let min = parts[0].trim().parse().map_err(|_| {
ValidatorParseError::invalid_args(name, "expected two positive integers")
})?;
let max = parts[1].trim().parse().map_err(|_| {
ValidatorParseError::invalid_args(name, "expected two positive integers")
})?;
Ok(Validator::LengthRange(min, max))
}
_ => Err(ValidatorParseError::invalid_args(
name,
"expected 1 or 2 arguments",
)),
}
}
"pattern" => parse_validator_string_arg(args)
.map(Validator::Pattern)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a string pattern")),
"startswith" => parse_validator_string_arg(args)
.map(Validator::StartsWith)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a string")),
"endswith" => parse_validator_string_arg(args)
.map(Validator::EndsWith)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a string")),
"includes" => parse_validator_string_arg(args)
.map(Validator::Includes)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a string")),
"greaterthan" => args
.trim()
.parse()
.map(Validator::GreaterThan)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a number")),
"greaterthanorequalto" => args
.trim()
.parse()
.map(Validator::GreaterThanOrEqualTo)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a number")),
"lessthan" => args
.trim()
.parse()
.map(Validator::LessThan)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a number")),
"lessthanorequalto" => args
.trim()
.parse()
.map(Validator::LessThanOrEqualTo)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a number")),
"between" => {
let parts: Vec<&str> = args.split(',').collect();
if parts.len() == 2 {
let min = parts[0]
.trim()
.parse()
.map_err(|_| ValidatorParseError::invalid_args(name, "expected two numbers"))?;
let max = parts[1]
.trim()
.parse()
.map_err(|_| ValidatorParseError::invalid_args(name, "expected two numbers"))?;
Ok(Validator::Between(min, max))
} else {
Err(ValidatorParseError::invalid_args(
name,
"expected two numbers separated by comma",
))
}
}
"multipleof" => args
.trim()
.parse()
.map(Validator::MultipleOf)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a number")),
"maxitems" => args
.trim()
.parse()
.map(Validator::MaxItems)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a positive integer")),
"minitems" => args
.trim()
.parse()
.map(Validator::MinItems)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a positive integer")),
"itemscount" => args
.trim()
.parse()
.map(Validator::ItemsCount)
.map_err(|_| ValidatorParseError::invalid_args(name, "expected a positive integer")),
"greaterthandate" => parse_validator_string_arg(args)
.map(Validator::GreaterThanDate)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a date string")),
"greaterthanorequaltodate" => parse_validator_string_arg(args)
.map(Validator::GreaterThanOrEqualToDate)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a date string")),
"lessthandate" => parse_validator_string_arg(args)
.map(Validator::LessThanDate)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a date string")),
"lessthanorequaltodate" => parse_validator_string_arg(args)
.map(Validator::LessThanOrEqualToDate)
.ok_or_else(|| ValidatorParseError::invalid_args(name, "expected a date string")),
"betweendate" => {
let parts: Vec<&str> = args.splitn(2, ',').collect();
if parts.len() == 2 {
let min = parse_validator_string_arg(parts[0].trim()).ok_or_else(|| {
ValidatorParseError::invalid_args(name, "expected two date strings")
})?;
let max = parse_validator_string_arg(parts[1].trim()).ok_or_else(|| {
ValidatorParseError::invalid_args(name, "expected two date strings")
})?;
Ok(Validator::BetweenDate(min, max))
} else {
Err(ValidatorParseError::invalid_args(
name,
"expected two date strings separated by comma",
))
}
}
"greaterthanbigint" => Ok(Validator::GreaterThanBigInt(args.trim().to_string())),
"greaterthanorequaltobigint" => Ok(Validator::GreaterThanOrEqualToBigInt(
args.trim().to_string(),
)),
"lessthanbigint" => Ok(Validator::LessThanBigInt(args.trim().to_string())),
"lessthanorequaltobigint" => {
Ok(Validator::LessThanOrEqualToBigInt(args.trim().to_string()))
}
"betweenbigint" => {
let parts: Vec<&str> = args.splitn(2, ',').collect();
if parts.len() == 2 {
Ok(Validator::BetweenBigInt(
parts[0].trim().to_string(),
parts[1].trim().to_string(),
))
} else {
Err(ValidatorParseError::invalid_args(
name,
"expected two bigint values separated by comma",
))
}
}
"custom" => {
let fn_name =
parse_validator_string_arg(args).unwrap_or_else(|| args.trim().to_string());
Ok(Validator::Custom(fn_name))
}
_ => Err(ValidatorParseError::unknown_validator(name)),
}
}
fn parse_validator_string_arg(input: &str) -> Option<String> {
let trimmed = input.trim();
if let Some(s) = parse_string_literal(trimmed) {
return Some(s);
}
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ts_syn::abi::SpanIR;
fn span() -> SpanIR {
SpanIR::new(0, 0)
}
fn make_decorator(args: &str) -> DecoratorIR {
DecoratorIR {
name: "serde".into(),
args_src: args.into(),
span: span(),
node: None,
}
}
#[test]
fn test_field_skip() {
let decorator = make_decorator("skip");
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert!(opts.skip);
assert!(!opts.should_serialize());
assert!(!opts.should_deserialize());
}
#[test]
fn test_field_skip_serializing() {
let decorator = make_decorator("skipSerializing");
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert!(opts.skip_serializing);
assert!(!opts.should_serialize());
assert!(opts.should_deserialize());
}
#[test]
fn test_field_rename() {
let decorator = make_decorator(r#"{ rename: "user_id" }"#);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert_eq!(opts.rename.as_deref(), Some("user_id"));
}
#[test]
fn test_field_default_flag() {
let decorator = make_decorator("default");
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert!(opts.default);
assert!(opts.default_expr.is_none());
}
#[test]
fn test_field_default_expr() {
let decorator = make_decorator(r#"{ default: "new Date()" }"#);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert!(opts.default);
assert_eq!(opts.default_expr.as_deref(), Some("new Date()"));
}
#[test]
fn test_field_flatten() {
let decorator = make_decorator("flatten");
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert!(opts.flatten);
}
#[test]
fn test_container_rename_all() {
let decorator = make_decorator(r#"{ renameAll: "camelCase" }"#);
let opts = SerdeContainerOptions::from_decorators(&[decorator]);
assert_eq!(opts.rename_all, RenameAll::CamelCase);
}
#[test]
fn test_container_deny_unknown_fields() {
let decorator = make_decorator("denyUnknownFields");
let opts = SerdeContainerOptions::from_decorators(&[decorator]);
assert!(opts.deny_unknown_fields);
}
#[test]
fn test_container_tag_internally_tagged() {
let decorator = make_decorator(r#"{ tag: "type" }"#);
let opts = SerdeContainerOptions::from_decorators(&[decorator]);
assert_eq!(
opts.tagging,
TaggingMode::InternallyTagged {
tag: "type".to_string()
}
);
assert_eq!(opts.tag_field(), Some("type"));
assert_eq!(opts.tag_field_or_default(), "type");
assert_eq!(opts.content_field(), None);
}
#[test]
fn test_container_tag_default() {
let opts = SerdeContainerOptions::default();
assert_eq!(
opts.tagging,
TaggingMode::InternallyTagged {
tag: "__type".to_string()
}
);
assert_eq!(opts.tag_field(), Some("__type"));
assert_eq!(opts.tag_field_or_default(), "__type");
}
#[test]
fn test_container_externally_tagged() {
let decorator = make_decorator(r#"{ externallyTagged: true }"#);
let opts = SerdeContainerOptions::from_decorators(&[decorator]);
assert_eq!(opts.tagging, TaggingMode::ExternallyTagged);
assert_eq!(opts.tag_field(), None);
assert_eq!(opts.tag_field_or_default(), "__type");
}
#[test]
fn test_container_adjacently_tagged() {
let decorator = make_decorator(r#"{ tag: "t", content: "c" }"#);
let opts = SerdeContainerOptions::from_decorators(&[decorator]);
assert_eq!(
opts.tagging,
TaggingMode::AdjacentlyTagged {
tag: "t".to_string(),
content: "c".to_string()
}
);
assert_eq!(opts.tag_field(), Some("t"));
assert_eq!(opts.tag_field_or_default(), "t");
assert_eq!(opts.content_field(), Some("c"));
}
#[test]
fn test_container_untagged() {
let decorator = make_decorator(r#"{ untagged: true }"#);
let opts = SerdeContainerOptions::from_decorators(&[decorator]);
assert_eq!(opts.tagging, TaggingMode::Untagged);
assert_eq!(opts.tag_field(), None);
assert_eq!(opts.tag_field_or_default(), "__type");
assert_eq!(opts.content_field(), None);
}
#[test]
fn test_type_category_primitives() {
assert_eq!(
TypeCategory::from_ts_type("string"),
TypeCategory::Primitive
);
assert_eq!(
TypeCategory::from_ts_type("number"),
TypeCategory::Primitive
);
assert_eq!(
TypeCategory::from_ts_type("boolean"),
TypeCategory::Primitive
);
}
#[test]
fn test_type_category_date() {
assert_eq!(TypeCategory::from_ts_type("Date"), TypeCategory::Date);
}
#[test]
fn test_type_category_array() {
assert_eq!(
TypeCategory::from_ts_type("string[]"),
TypeCategory::Array("string".into())
);
assert_eq!(
TypeCategory::from_ts_type("Array<number>"),
TypeCategory::Array("number".into())
);
}
#[test]
fn test_type_category_map() {
assert_eq!(
TypeCategory::from_ts_type("Map<string, number>"),
TypeCategory::Map("string".into(), "number".into())
);
}
#[test]
fn test_type_category_set() {
assert_eq!(
TypeCategory::from_ts_type("Set<string>"),
TypeCategory::Set("string".into())
);
}
#[test]
fn test_type_category_optional() {
assert_eq!(
TypeCategory::from_ts_type("string | undefined"),
TypeCategory::Optional("string".into())
);
}
#[test]
fn test_type_category_nullable() {
assert_eq!(
TypeCategory::from_ts_type("string | null"),
TypeCategory::Nullable("string".into())
);
}
#[test]
fn test_type_category_serializable() {
assert_eq!(
TypeCategory::from_ts_type("User"),
TypeCategory::Serializable("User".into())
);
}
#[test]
fn test_type_category_serializable_strips_generics() {
assert_eq!(
TypeCategory::from_ts_type("RecordLink<Employee>"),
TypeCategory::Serializable("RecordLink".into())
);
assert_eq!(
TypeCategory::from_ts_type("RecordLink<Account>"),
TypeCategory::Serializable("RecordLink".into())
);
}
#[test]
fn test_convert_case_with_angle_brackets() {
use convert_case::{Case, Casing};
let base = "RecordLink";
let fn_name = format!("{}SerializeWithContext", base.to_case(Case::Camel));
assert_eq!(fn_name, "recordLinkSerializeWithContext");
}
#[test]
fn test_rename_all_camel_case() {
assert_eq!(RenameAll::CamelCase.apply("user_name"), "userName");
assert_eq!(RenameAll::CamelCase.apply("created_at"), "createdAt");
}
#[test]
fn test_rename_all_snake_case() {
assert_eq!(RenameAll::SnakeCase.apply("userName"), "user_name");
assert_eq!(RenameAll::SnakeCase.apply("createdAt"), "created_at");
}
#[test]
fn test_rename_all_pascal_case() {
assert_eq!(RenameAll::PascalCase.apply("user_name"), "UserName");
}
#[test]
fn test_rename_all_kebab_case() {
assert_eq!(RenameAll::KebabCase.apply("userName"), "user-name");
}
#[test]
fn test_rename_all_screaming_snake_case() {
assert_eq!(RenameAll::ScreamingSnakeCase.apply("userName"), "USER_NAME");
}
#[test]
fn test_parse_simple_validators() {
assert!(matches!(
parse_validator_string("email"),
Ok(Validator::Email)
));
assert!(matches!(parse_validator_string("url"), Ok(Validator::Url)));
assert!(matches!(
parse_validator_string("uuid"),
Ok(Validator::Uuid)
));
assert!(matches!(
parse_validator_string("nonEmpty"),
Ok(Validator::NonEmpty)
));
assert!(matches!(
parse_validator_string("trimmed"),
Ok(Validator::Trimmed)
));
assert!(matches!(
parse_validator_string("lowercase"),
Ok(Validator::Lowercase)
));
assert!(matches!(
parse_validator_string("uppercase"),
Ok(Validator::Uppercase)
));
assert!(matches!(parse_validator_string("int"), Ok(Validator::Int)));
assert!(matches!(
parse_validator_string("positive"),
Ok(Validator::Positive)
));
assert!(matches!(
parse_validator_string("validDate"),
Ok(Validator::ValidDate)
));
}
#[test]
fn test_parse_validators_with_args() {
assert!(matches!(
parse_validator_string("maxLength(255)"),
Ok(Validator::MaxLength(255))
));
assert!(matches!(
parse_validator_string("minLength(1)"),
Ok(Validator::MinLength(1))
));
assert!(matches!(
parse_validator_string("length(36)"),
Ok(Validator::Length(36))
));
assert!(matches!(
parse_validator_string("between(0, 100)"),
Ok(Validator::Between(min, max)) if min == 0.0 && max == 100.0
));
assert!(matches!(
parse_validator_string("greaterThan(5)"),
Ok(Validator::GreaterThan(n)) if n == 5.0
));
}
#[test]
fn test_parse_validators_with_string_args() {
assert!(matches!(
parse_validator_string(r#"startsWith("https://")"#),
Ok(Validator::StartsWith(s)) if s == "https://"
));
assert!(matches!(
parse_validator_string(r#"endsWith(".com")"#),
Ok(Validator::EndsWith(s)) if s == ".com"
));
assert!(matches!(
parse_validator_string(r#"includes("@")"#),
Ok(Validator::Includes(s)) if s == "@"
));
}
#[test]
fn test_parse_custom_validator() {
assert!(matches!(
parse_validator_string("custom(myValidator)"),
Ok(Validator::Custom(fn_name)) if fn_name == "myValidator"
));
}
#[test]
fn test_extract_validators_from_args() {
let mut diagnostics = DiagnosticCollector::new();
let validators = extract_validators(
r#"{ validate: ["email", "maxLength(255)"] }"#,
span(),
"test_field",
&mut diagnostics,
);
assert_eq!(validators.len(), 2);
assert!(matches!(validators[0].validator, Validator::Email));
assert!(matches!(validators[1].validator, Validator::MaxLength(255)));
assert!(!diagnostics.has_errors());
}
#[test]
fn test_extract_validators_with_message() {
let mut diagnostics = DiagnosticCollector::new();
let validators = extract_validators(
r#"{ validate: [{ validate: "email", message: "Invalid email!" }] }"#,
span(),
"test_field",
&mut diagnostics,
);
assert_eq!(validators.len(), 1);
assert!(matches!(validators[0].validator, Validator::Email));
assert_eq!(
validators[0].custom_message.as_deref(),
Some("Invalid email!")
);
assert!(!diagnostics.has_errors());
}
#[test]
fn test_extract_validators_mixed() {
let mut diagnostics = DiagnosticCollector::new();
let validators = extract_validators(
r#"{ validate: ["nonEmpty", { validate: "email", message: "Bad email" }] }"#,
span(),
"test_field",
&mut diagnostics,
);
assert_eq!(validators.len(), 2);
assert!(matches!(validators[0].validator, Validator::NonEmpty));
assert!(validators[0].custom_message.is_none());
assert!(matches!(validators[1].validator, Validator::Email));
assert_eq!(validators[1].custom_message.as_deref(), Some("Bad email"));
assert!(!diagnostics.has_errors());
}
#[test]
fn test_field_with_validators() {
let decorator = make_decorator(r#"{ validate: ["email", "maxLength(255)"] }"#);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert_eq!(opts.validators.len(), 2);
assert!(matches!(opts.validators[0].validator, Validator::Email));
assert!(matches!(
opts.validators[1].validator,
Validator::MaxLength(255)
));
}
#[test]
fn test_parse_date_validators() {
assert!(matches!(
parse_validator_string("validDate"),
Ok(Validator::ValidDate)
));
assert!(matches!(
parse_validator_string(r#"greaterThanDate("2020-01-01")"#),
Ok(Validator::GreaterThanDate(d)) if d == "2020-01-01"
));
assert!(matches!(
parse_validator_string(r#"greaterThanOrEqualToDate("2020-01-01")"#),
Ok(Validator::GreaterThanOrEqualToDate(d)) if d == "2020-01-01"
));
assert!(matches!(
parse_validator_string(r#"lessThanDate("2030-01-01")"#),
Ok(Validator::LessThanDate(d)) if d == "2030-01-01"
));
assert!(matches!(
parse_validator_string(r#"lessThanOrEqualToDate("2030-01-01")"#),
Ok(Validator::LessThanOrEqualToDate(d)) if d == "2030-01-01"
));
assert!(matches!(
parse_validator_string(r#"betweenDate("2020-01-01", "2030-12-31")"#),
Ok(Validator::BetweenDate(min, max)) if min == "2020-01-01" && max == "2030-12-31"
));
}
#[test]
fn test_parse_bigint_validators() {
assert!(matches!(
parse_validator_string("positiveBigInt"),
Ok(Validator::PositiveBigInt)
));
assert!(matches!(
parse_validator_string("nonNegativeBigInt"),
Ok(Validator::NonNegativeBigInt)
));
assert!(matches!(
parse_validator_string("negativeBigInt"),
Ok(Validator::NegativeBigInt)
));
assert!(matches!(
parse_validator_string("nonPositiveBigInt"),
Ok(Validator::NonPositiveBigInt)
));
assert!(matches!(
parse_validator_string("greaterThanBigInt(100)"),
Ok(Validator::GreaterThanBigInt(n)) if n == "100"
));
assert!(matches!(
parse_validator_string("greaterThanOrEqualToBigInt(0)"),
Ok(Validator::GreaterThanOrEqualToBigInt(n)) if n == "0"
));
assert!(matches!(
parse_validator_string("lessThanBigInt(1000)"),
Ok(Validator::LessThanBigInt(n)) if n == "1000"
));
assert!(matches!(
parse_validator_string("lessThanOrEqualToBigInt(999)"),
Ok(Validator::LessThanOrEqualToBigInt(n)) if n == "999"
));
assert!(matches!(
parse_validator_string("betweenBigInt(0, 100)"),
Ok(Validator::BetweenBigInt(min, max)) if min == "0" && max == "100"
));
}
#[test]
fn test_parse_array_validators() {
assert!(matches!(
parse_validator_string("maxItems(10)"),
Ok(Validator::MaxItems(10))
));
assert!(matches!(
parse_validator_string("minItems(1)"),
Ok(Validator::MinItems(1))
));
assert!(matches!(
parse_validator_string("itemsCount(5)"),
Ok(Validator::ItemsCount(5))
));
}
#[test]
fn test_parse_additional_number_validators() {
assert!(matches!(
parse_validator_string("nonNaN"),
Ok(Validator::NonNaN)
));
assert!(matches!(
parse_validator_string("finite"),
Ok(Validator::Finite)
));
assert!(matches!(
parse_validator_string("uint8"),
Ok(Validator::Uint8)
));
assert!(matches!(
parse_validator_string("multipleOf(5)"),
Ok(Validator::MultipleOf(n)) if n == 5.0
));
assert!(matches!(
parse_validator_string("negative"),
Ok(Validator::Negative)
));
assert!(matches!(
parse_validator_string("nonNegative"),
Ok(Validator::NonNegative)
));
assert!(matches!(
parse_validator_string("nonPositive"),
Ok(Validator::NonPositive)
));
}
#[test]
fn test_parse_additional_string_validators() {
assert!(matches!(
parse_validator_string("capitalized"),
Ok(Validator::Capitalized)
));
assert!(matches!(
parse_validator_string("uncapitalized"),
Ok(Validator::Uncapitalized)
));
assert!(matches!(
parse_validator_string("length(5, 10)"),
Ok(Validator::LengthRange(5, 10))
));
}
#[test]
fn test_parse_validators_case_insensitive() {
assert!(matches!(
parse_validator_string("EMAIL"),
Ok(Validator::Email)
));
assert!(matches!(
parse_validator_string("Email"),
Ok(Validator::Email)
));
assert!(matches!(
parse_validator_string("NONEMPTY"),
Ok(Validator::NonEmpty)
));
assert!(matches!(
parse_validator_string("NonEmpty"),
Ok(Validator::NonEmpty)
));
assert!(matches!(
parse_validator_string("MAXLENGTH(10)"),
Ok(Validator::MaxLength(10))
));
}
#[test]
fn test_parse_pattern_with_special_chars() {
assert!(matches!(
parse_validator_string(r#"pattern("^[A-Z]{3}$")"#),
Ok(Validator::Pattern(p)) if p == "^[A-Z]{3}$"
));
assert!(matches!(
parse_validator_string(r#"pattern("\\d+")"#),
Ok(Validator::Pattern(p)) if p == "\\d+"
));
assert!(matches!(
parse_validator_string(r#"pattern("^test\\.json$")"#),
Ok(Validator::Pattern(p)) if p == "^test\\.json$"
));
}
#[test]
fn test_parse_validators_with_whitespace() {
assert!(matches!(
parse_validator_string(" email "),
Ok(Validator::Email)
));
assert!(matches!(
parse_validator_string("between( 1 , 100 )"),
Ok(Validator::Between(min, max)) if min == 1.0 && max == 100.0
));
assert!(matches!(
parse_validator_string("maxLength( 50 )"),
Ok(Validator::MaxLength(50))
));
}
#[test]
fn test_unknown_validator_returns_error() {
let result = parse_validator_string("unknownValidator");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("unknown validator"));
assert!(err.message.contains("unknownValidator"));
}
#[test]
fn test_unknown_validator_with_typo_suggests_correction() {
let result = parse_validator_string("emai");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.help.is_some());
assert!(err.help.as_ref().unwrap().contains("email"));
}
#[test]
fn test_unknown_validator_no_suggestion_for_unrelated() {
let result = parse_validator_string("xyz");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.help.is_none() || !err.help.as_ref().unwrap().contains("email"));
}
#[test]
fn test_invalid_maxlength_args_returns_error() {
let result = parse_validator_string("maxLength(abc)");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("maxLength"));
}
#[test]
fn test_invalid_between_args_returns_error() {
let result = parse_validator_string("between(abc, def)");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("between"));
}
#[test]
fn test_extract_validators_collects_errors() {
let mut diagnostics = DiagnosticCollector::new();
let validators = extract_validators(
r#"{ validate: ["unknownValidator", "email"] }"#,
span(),
"test_field",
&mut diagnostics,
);
assert_eq!(validators.len(), 1);
assert!(matches!(validators[0].validator, Validator::Email));
assert!(diagnostics.has_errors());
assert_eq!(diagnostics.len(), 1);
}
#[test]
fn test_extract_validators_multiple_errors() {
let mut diagnostics = DiagnosticCollector::new();
let validators = extract_validators(
r#"{ validate: ["unknown1", "unknown2", "email"] }"#,
span(),
"test_field",
&mut diagnostics,
);
assert_eq!(validators.len(), 1);
assert!(diagnostics.has_errors());
assert_eq!(diagnostics.len(), 2);
}
#[test]
fn test_typo_suggestion_url_vs_uuid() {
let result = parse_validator_string("rul");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.help.is_some());
assert!(err.help.as_ref().unwrap().contains("url"));
}
#[test]
fn test_typo_suggestion_maxlength() {
let result = parse_validator_string("maxLenth(10)");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.help.is_some());
assert!(err.help.as_ref().unwrap().contains("maxLength"));
}
#[test]
fn test_field_serialize_with() {
let decorator = make_decorator(r#"{ serializeWith: "mySerializer" }"#);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert_eq!(opts.serialize_with.as_deref(), Some("mySerializer"));
assert!(opts.deserialize_with.is_none());
}
#[test]
fn test_field_deserialize_with() {
let decorator = make_decorator(r#"{ deserializeWith: "myDeserializer" }"#);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert!(opts.serialize_with.is_none());
assert_eq!(opts.deserialize_with.as_deref(), Some("myDeserializer"));
}
#[test]
fn test_field_serialize_and_deserialize_with() {
let decorator =
make_decorator(r#"{ serializeWith: "toJson", deserializeWith: "fromJson" }"#);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert_eq!(opts.serialize_with.as_deref(), Some("toJson"));
assert_eq!(opts.deserialize_with.as_deref(), Some("fromJson"));
}
#[test]
fn test_field_serialize_with_combined_with_other_options() {
let decorator = make_decorator(
r#"{ serializeWith: "customSerialize", rename: "custom_field", skip: false }"#,
);
let result = SerdeFieldOptions::from_decorators(&[decorator], "test_field");
let opts = result.options;
assert_eq!(opts.serialize_with.as_deref(), Some("customSerialize"));
assert_eq!(opts.rename.as_deref(), Some("custom_field"));
assert!(!opts.skip);
}
#[test]
fn test_type_category_string_literal_double_quotes() {
assert_eq!(
TypeCategory::from_ts_type(r#""Zoned""#),
TypeCategory::Primitive
);
assert_eq!(
TypeCategory::from_ts_type(r#""some_value""#),
TypeCategory::Primitive
);
}
#[test]
fn test_type_category_string_literal_single_quotes() {
assert_eq!(TypeCategory::from_ts_type("'foo'"), TypeCategory::Primitive);
assert_eq!(
TypeCategory::from_ts_type("'bar_baz'"),
TypeCategory::Primitive
);
}
#[test]
fn test_type_category_non_literal_type_names() {
assert_eq!(
TypeCategory::from_ts_type("Zoned"),
TypeCategory::Serializable("Zoned".into())
);
assert_eq!(
TypeCategory::from_ts_type("User"),
TypeCategory::Serializable("User".into())
);
}
#[test]
fn test_type_category_record() {
assert_eq!(
TypeCategory::from_ts_type("Record<string, unknown>"),
TypeCategory::Record("string".into(), "unknown".into())
);
assert_eq!(
TypeCategory::from_ts_type("Record<string, number>"),
TypeCategory::Record("string".into(), "number".into())
);
assert_eq!(
TypeCategory::from_ts_type("Record<string, User>"),
TypeCategory::Record("string".into(), "User".into())
);
}
#[test]
fn test_split_top_level_union_tracks_braces() {
assert_eq!(split_top_level_union("{ a: string | number }"), None);
assert_eq!(
split_top_level_union("{ status: \"active\" | \"inactive\" }"),
None
);
assert_eq!(
split_top_level_union("{ a: string } | { b: number }"),
Some(vec!["{ a: string }", "{ b: number }"])
);
assert_eq!(
split_top_level_union("{ a: string | number } | null"),
Some(vec!["{ a: string | number }", "null"])
);
}
#[test]
fn test_type_category_wrapper_utility_types() {
assert_eq!(
TypeCategory::from_ts_type("Partial<User>"),
TypeCategory::Wrapper("User".into())
);
assert_eq!(
TypeCategory::from_ts_type("Required<Config>"),
TypeCategory::Wrapper("Config".into())
);
assert_eq!(
TypeCategory::from_ts_type("Readonly<Data>"),
TypeCategory::Wrapper("Data".into())
);
assert_eq!(
TypeCategory::from_ts_type("NonNullable<User>"),
TypeCategory::Wrapper("User".into())
);
assert_eq!(
TypeCategory::from_ts_type("Pick<User, 'name' | 'email'>"),
TypeCategory::Wrapper("User".into())
);
assert_eq!(
TypeCategory::from_ts_type("Omit<User, 'password'>"),
TypeCategory::Wrapper("User".into())
);
}
#[test]
fn test_type_category_non_serializable_utility_types() {
assert_eq!(
TypeCategory::from_ts_type("Promise<string>"),
TypeCategory::Unknown
);
assert_eq!(
TypeCategory::from_ts_type("ReturnType<typeof fn>"),
TypeCategory::Unknown
);
assert_eq!(
TypeCategory::from_ts_type("Awaited<Promise<User>>"),
TypeCategory::Unknown
);
}
}