diff --git a/Cargo.lock b/Cargo.lock index 0e955b9f8d..e9d5ac5d6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7902,6 +7902,39 @@ dependencies = [ "serde", ] +[[package]] +name = "smithy" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "smithy-core", + "syn 2.0.101", +] + +[[package]] +name = "smithy-core" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "serde", + "serde_json", + "syn 2.0.101", +] + +[[package]] +name = "smithy-generator" +version = "0.1.0" +dependencies = [ + "api_models", + "common_enums", + "common_types", + "common_utils", + "regex", + "router_env", + "smithy-core", +] + [[package]] name = "snap" version = "1.1.1" diff --git a/crates/smithy-core/Cargo.toml b/crates/smithy-core/Cargo.toml new file mode 100644 index 0000000000..9b4d524b71 --- /dev/null +++ b/crates/smithy-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "smithy-core" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +syn = { version = "2.0", features = ["full"] } +proc-macro2 = "1.0" + +[lints] +workspace = true diff --git a/crates/smithy-core/src/generator.rs b/crates/smithy-core/src/generator.rs new file mode 100644 index 0000000000..34abb2fd40 --- /dev/null +++ b/crates/smithy-core/src/generator.rs @@ -0,0 +1,296 @@ +// crates/smithy-core/generator.rs + +use std::{collections::HashMap, fs, path::Path}; + +use crate::types::{self as types, SmithyModel}; + +/// Generator for creating Smithy IDL files from models +pub struct SmithyGenerator { + models: Vec, +} + +impl SmithyGenerator { + pub fn new() -> Self { + Self { models: Vec::new() } + } + + pub fn add_model(&mut self, model: SmithyModel) { + self.models.push(model); + } + + pub fn generate_idl(&self, output_dir: &Path) -> Result<(), Box> { + fs::create_dir_all(output_dir)?; + + let mut namespace_models: HashMap> = HashMap::new(); + let mut shape_to_namespace: HashMap = HashMap::new(); + + // First, build a map of all shape names to their namespaces + for model in &self.models { + for shape_name in model.shapes.keys() { + shape_to_namespace.insert(shape_name.clone(), model.namespace.clone()); + } + } + + // Group models by namespace for file generation + for model in &self.models { + namespace_models + .entry(model.namespace.clone()) + .or_default() + .push(model); + } + + for (namespace, models) in namespace_models { + let filename = format!("{}.smithy", namespace.replace('.', "_")); + let filepath = output_dir.join(filename); + + let mut content = String::new(); + content.push_str("$version: \"2\"\n\n"); + content.push_str(&format!("namespace {}\n\n", namespace)); + + // Collect all unique shape definitions for the current namespace + let mut shapes_in_namespace = HashMap::new(); + for model in models { + for (shape_name, shape) in &model.shapes { + shapes_in_namespace.insert(shape_name.clone(), shape.clone()); + } + } + + // Generate definitions for each shape in the namespace + for (shape_name, shape) in &shapes_in_namespace { + content.push_str(&self.generate_shape_definition( + shape_name, + shape, + &namespace, + &shape_to_namespace, + )); + content.push_str("\n\n"); + } + + fs::write(filepath, content)?; + } + + Ok(()) + } + + fn generate_shape_definition( + &self, + name: &str, + shape: &types::SmithyShape, + current_namespace: &str, + shape_to_namespace: &HashMap, + ) -> String { + let resolve_target = + |target: &str| self.resolve_type(target, current_namespace, shape_to_namespace); + + match shape { + types::SmithyShape::Structure { + members, + documentation, + traits, + } => { + let mut def = String::new(); + + if let Some(doc) = documentation { + def.push_str(&format!("/// {}\n", doc)); + } + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("structure {} {{\n", name)); + + for (member_name, member) in members { + if let Some(doc) = &member.documentation { + def.push_str(&format!(" /// {}\n", doc)); + } + + for smithy_trait in &member.traits { + def.push_str(&format!(" @{}\n", self.trait_to_string(smithy_trait))); + } + + let resolved_target = resolve_target(&member.target); + def.push_str(&format!(" {}: {}\n", member_name, resolved_target)); + } + + def.push('}'); + def + } + types::SmithyShape::Union { + members, + documentation, + traits, + } => { + let mut def = String::new(); + + if let Some(doc) = documentation { + def.push_str(&format!("/// {}\n", doc)); + } + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("union {} {{\n", name)); + + for (member_name, member) in members { + if let Some(doc) = &member.documentation { + def.push_str(&format!(" /// {}\n", doc)); + } + + for smithy_trait in &member.traits { + def.push_str(&format!(" @{}\n", self.trait_to_string(smithy_trait))); + } + + let resolved_target = resolve_target(&member.target); + def.push_str(&format!(" {}: {}\n", member_name, resolved_target)); + } + + def.push('}'); + def + } + types::SmithyShape::Enum { + values, + documentation, + traits, + } => { + let mut def = String::new(); + + if let Some(doc) = documentation { + def.push_str(&format!("/// {}\n", doc)); + } + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("enum {} {{\n", name)); + + for (value_name, enum_value) in values { + if let Some(doc) = &enum_value.documentation { + def.push_str(&format!(" /// {}\n", doc)); + } + def.push_str(&format!(" {}\n", value_name)); + } + + def.push('}'); + def + } + types::SmithyShape::String { traits } => { + let mut def = String::new(); + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("string {}", name)); + def + } + types::SmithyShape::Integer { traits } => { + let mut def = String::new(); + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("integer {}", name)); + def + } + types::SmithyShape::Long { traits } => { + let mut def = String::new(); + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("long {}", name)); + def + } + types::SmithyShape::Boolean { traits } => { + let mut def = String::new(); + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("boolean {}", name)); + def + } + types::SmithyShape::List { member, traits } => { + let mut def = String::new(); + + for smithy_trait in traits { + def.push_str(&format!("@{}\n", self.trait_to_string(smithy_trait))); + } + + def.push_str(&format!("list {} {{\n", name)); + let resolved_target = resolve_target(&member.target); + def.push_str(&format!(" member: {}\n", resolved_target)); + def.push('}'); + def + } + } + } + + fn resolve_type( + &self, + target: &str, + current_namespace: &str, + shape_to_namespace: &HashMap, + ) -> String { + // If the target is a primitive or a fully qualified Smithy type, return it as is + if target.starts_with("smithy.api#") { + return target.to_string(); + } + + // If the target is a custom type, resolve its namespace + if let Some(target_namespace) = shape_to_namespace.get(target) { + if target_namespace == current_namespace { + // The type is in the same namespace, so no qualification is needed + target.to_string() + } else { + // The type is in a different namespace, so it needs to be fully qualified + format!("{}#{}", target_namespace, target) + } + } else { + // If the type is not found in the shape map, it might be a primitive + // or an unresolved type. For now, return it as is. + target.to_string() + } + } + + fn trait_to_string(&self, smithy_trait: &types::SmithyTrait) -> String { + match smithy_trait { + types::SmithyTrait::Pattern { pattern } => { + format!("pattern(\"{}\")", pattern) + } + types::SmithyTrait::Range { min, max } => match (min, max) { + (Some(min), Some(max)) => format!("range(min: {}, max: {})", min, max), + (Some(min), None) => format!("range(min: {})", min), + (None, Some(max)) => format!("range(max: {})", max), + (None, None) => "range".to_string(), + }, + types::SmithyTrait::Required => "required".to_string(), + types::SmithyTrait::Documentation { documentation } => { + format!("documentation(\"{}\")", documentation) + } + types::SmithyTrait::Length { min, max } => match (min, max) { + (Some(min), Some(max)) => format!("length(min: {}, max: {})", min, max), + (Some(min), None) => format!("length(min: {})", min), + (None, Some(max)) => format!("length(max: {})", max), + (None, None) => "length".to_string(), + }, + types::SmithyTrait::HttpLabel => "httpLabel".to_string(), + types::SmithyTrait::HttpQuery { name } => { + format!("httpQuery(\"{}\")", name) + } + types::SmithyTrait::Mixin => "mixin".to_string(), + } + } +} + +impl Default for SmithyGenerator { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/smithy-core/src/lib.rs b/crates/smithy-core/src/lib.rs new file mode 100644 index 0000000000..fd51cb03b1 --- /dev/null +++ b/crates/smithy-core/src/lib.rs @@ -0,0 +1,7 @@ +// // crates/smithy-core/lib.rs + +pub mod generator; +pub mod types; + +pub use generator::SmithyGenerator; +pub use types::*; diff --git a/crates/smithy-core/src/types.rs b/crates/smithy-core/src/types.rs new file mode 100644 index 0000000000..fc2db45a52 --- /dev/null +++ b/crates/smithy-core/src/types.rs @@ -0,0 +1,331 @@ +// crates/smithy-core/types.rs + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmithyModel { + pub namespace: String, + pub shapes: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum SmithyShape { + #[serde(rename = "structure")] + Structure { + members: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + documentation: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "string")] + String { + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "integer")] + Integer { + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "long")] + Long { + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "boolean")] + Boolean { + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "list")] + List { + member: Box, + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "union")] + Union { + members: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + documentation: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, + #[serde(rename = "enum")] + Enum { + values: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + documentation: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + traits: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmithyMember { + pub target: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub traits: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmithyEnumValue { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, + pub is_default: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "trait")] +pub enum SmithyTrait { + #[serde(rename = "smithy.api#pattern")] + Pattern { pattern: String }, + #[serde(rename = "smithy.api#range")] + Range { min: Option, max: Option }, + #[serde(rename = "smithy.api#required")] + Required, + #[serde(rename = "smithy.api#documentation")] + Documentation { documentation: String }, + #[serde(rename = "smithy.api#length")] + Length { min: Option, max: Option }, + #[serde(rename = "smithy.api#httpLabel")] + HttpLabel, + #[serde(rename = "smithy.api#httpQuery")] + HttpQuery { name: String }, + #[serde(rename = "smithy.api#mixin")] + Mixin, +} + +#[derive(Debug, Clone)] +pub struct SmithyField { + pub name: String, + pub value_type: String, + pub constraints: Vec, + pub documentation: Option, + pub optional: bool, + pub flatten: bool, +} + +#[derive(Debug, Clone)] +pub struct SmithyEnumVariant { + pub name: String, + pub fields: Vec, + pub constraints: Vec, + pub documentation: Option, +} + +#[derive(Debug, Clone)] +pub enum SmithyConstraint { + Pattern(String), + Range(Option, Option), + Length(Option, Option), + Required, + HttpLabel, + HttpQuery(String), +} + +pub trait SmithyModelGenerator { + fn generate_smithy_model() -> SmithyModel; +} + +// Helper functions moved from the proc-macro crate to be accessible by it. + +pub fn resolve_type_and_generate_shapes( + value_type: &str, + shapes: &mut HashMap, +) -> Result<(String, HashMap), syn::Error> { + let value_type = value_type.trim(); + let value_type_span = proc_macro2::Span::call_site(); + let mut generated_shapes = HashMap::new(); + + let target_type = match value_type { + "String" | "str" => "smithy.api#String".to_string(), + "i8" | "i16" | "i32" | "u8" | "u16" | "u32" => "smithy.api#Integer".to_string(), + "i64" | "u64" | "isize" | "usize" => "smithy.api#Long".to_string(), + "f32" => "smithy.api#Float".to_string(), + "f64" => "smithy.api#Double".to_string(), + "bool" => "smithy.api#Boolean".to_string(), + "PrimitiveDateTime" | "time::PrimitiveDateTime" => "smithy.api#Timestamp".to_string(), + "Amount" | "MinorUnit" => "smithy.api#Long".to_string(), + "serde_json::Value" | "Value" | "Object" => "smithy.api#Document".to_string(), + "Url" | "url::Url" => "smithy.api#String".to_string(), + + vt if vt.starts_with("Option<") && vt.ends_with('>') => { + let inner_type = extract_generic_inner_type(vt, "Option") + .map_err(|e| syn::Error::new(value_type_span, e))?; + let (resolved_type, new_shapes) = resolve_type_and_generate_shapes(inner_type, shapes)?; + generated_shapes.extend(new_shapes); + resolved_type + } + + vt if vt.starts_with("Vec<") && vt.ends_with('>') => { + let inner_type = extract_generic_inner_type(vt, "Vec") + .map_err(|e| syn::Error::new(value_type_span, e))?; + let (inner_smithy_type, new_shapes) = + resolve_type_and_generate_shapes(inner_type, shapes)?; + generated_shapes.extend(new_shapes); + + let list_shape_name = format!( + "{}List", + inner_smithy_type + .split("::") + .last() + .unwrap_or(&inner_smithy_type) + .split('#') + .next_back() + .unwrap_or(&inner_smithy_type) + ); + if !shapes.contains_key(&list_shape_name) + && !generated_shapes.contains_key(&list_shape_name) + { + let list_shape = SmithyShape::List { + member: Box::new(SmithyMember { + target: inner_smithy_type, + documentation: None, + traits: vec![], + }), + traits: vec![], + }; + generated_shapes.insert(list_shape_name.clone(), list_shape); + } + list_shape_name + } + + vt if vt.starts_with("Box<") && vt.ends_with('>') => { + let inner_type = extract_generic_inner_type(vt, "Box") + .map_err(|e| syn::Error::new(value_type_span, e))?; + let (resolved_type, new_shapes) = resolve_type_and_generate_shapes(inner_type, shapes)?; + generated_shapes.extend(new_shapes); + resolved_type + } + + vt if vt.starts_with("Secret<") && vt.ends_with('>') => { + let inner_type = extract_generic_inner_type(vt, "Secret") + .map_err(|e| syn::Error::new(value_type_span, e))?; + let (resolved_type, new_shapes) = resolve_type_and_generate_shapes(inner_type, shapes)?; + generated_shapes.extend(new_shapes); + resolved_type + } + + vt if vt.starts_with("HashMap<") && vt.ends_with('>') => { + let inner_types = extract_generic_inner_type(vt, "HashMap") + .map_err(|e| syn::Error::new(value_type_span, e))?; + let (key_type, value_type) = + parse_map_types(inner_types).map_err(|e| syn::Error::new(value_type_span, e))?; + let (key_smithy_type, key_shapes) = resolve_type_and_generate_shapes(key_type, shapes)?; + generated_shapes.extend(key_shapes); + let (value_smithy_type, value_shapes) = + resolve_type_and_generate_shapes(value_type, shapes)?; + generated_shapes.extend(value_shapes); + format!( + "smithy.api#Map", + key_smithy_type, value_smithy_type + ) + } + + vt if vt.starts_with("BTreeMap<") && vt.ends_with('>') => { + let inner_types = extract_generic_inner_type(vt, "BTreeMap") + .map_err(|e| syn::Error::new(value_type_span, e))?; + let (key_type, value_type) = + parse_map_types(inner_types).map_err(|e| syn::Error::new(value_type_span, e))?; + let (key_smithy_type, key_shapes) = resolve_type_and_generate_shapes(key_type, shapes)?; + generated_shapes.extend(key_shapes); + let (value_smithy_type, value_shapes) = + resolve_type_and_generate_shapes(value_type, shapes)?; + generated_shapes.extend(value_shapes); + format!( + "smithy.api#Map", + key_smithy_type, value_smithy_type + ) + } + + _ => { + if value_type.contains("::") { + value_type.replace("::", ".") + } else { + value_type.to_string() + } + } + }; + + Ok((target_type, generated_shapes)) +} + +fn extract_generic_inner_type<'a>(full_type: &'a str, wrapper: &str) -> Result<&'a str, String> { + let expected_start = format!("{}<", wrapper); + + if !full_type.starts_with(&expected_start) || !full_type.ends_with('>') { + return Err(format!("Invalid {} type format: {}", wrapper, full_type)); + } + + let start_idx = expected_start.len(); + let end_idx = full_type.len() - 1; + + if start_idx >= end_idx { + return Err(format!("Empty {} type: {}", wrapper, full_type)); + } + + if start_idx >= full_type.len() || end_idx > full_type.len() { + return Err(format!( + "Invalid index bounds for {} type: {}", + wrapper, full_type + )); + } + + Ok(full_type + .get(start_idx..end_idx) + .ok_or_else(|| { + format!( + "Failed to extract inner type from {}: {}", + wrapper, full_type + ) + })? + .trim()) +} + +fn parse_map_types(inner_types: &str) -> Result<(&str, &str), String> { + // Handle nested generics by counting angle brackets + let mut bracket_count = 0; + let mut comma_pos = None; + + for (i, ch) in inner_types.char_indices() { + match ch { + '<' => bracket_count += 1, + '>' => bracket_count -= 1, + ',' if bracket_count == 0 => { + comma_pos = Some(i); + break; + } + _ => {} + } + } + + if let Some(pos) = comma_pos { + let key_type = inner_types + .get(..pos) + .ok_or_else(|| format!("Invalid key type bounds in map: {}", inner_types))? + .trim(); + let value_type = inner_types + .get(pos + 1..) + .ok_or_else(|| format!("Invalid value type bounds in map: {}", inner_types))? + .trim(); + + if key_type.is_empty() || value_type.is_empty() { + return Err(format!("Invalid map type format: {}", inner_types)); + } + + Ok((key_type, value_type)) + } else { + Err(format!( + "Invalid map type format, missing comma: {}", + inner_types + )) + } +} diff --git a/crates/smithy-generator/Cargo.toml b/crates/smithy-generator/Cargo.toml new file mode 100644 index 0000000000..3538dcf8f3 --- /dev/null +++ b/crates/smithy-generator/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "smithy-generator" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +build = "build.rs" + +[features] +default = ["v1"] +v1 = ["api_models/v1", "common_utils/v1"] +v2 = ["api_models/v2", "common_utils/v2"] + +[dependencies] +smithy-core = { path = "../smithy-core" } +api_models = { version = "0.1.0", path = "../api_models" } +common_enums = { version = "0.1.0", path = "../common_enums" } +common_types = { version = "0.1.0", path = "../common_types" } +common_utils = { version = "0.1.0", path = "../common_utils" } +router_env = { version = "0.1.0", path = "../router_env" } + +[build-dependencies] +regex = "1" + +[lints] +workspace = true diff --git a/crates/smithy-generator/build.rs b/crates/smithy-generator/build.rs new file mode 100644 index 0000000000..9d03af4d54 --- /dev/null +++ b/crates/smithy-generator/build.rs @@ -0,0 +1,306 @@ +// crates/smithy-generator/build.rs + +use std::{fs, path::Path}; + +use regex::Regex; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../"); + run_build() +} + +fn run_build() -> Result<(), Box> { + let workspace_root = get_workspace_root()?; + + let mut smithy_models = Vec::new(); + + // Scan all crates in the workspace for SmithyModel derives + let crates_dir = workspace_root.join("crates"); + if let Ok(entries) = fs::read_dir(&crates_dir) { + for entry in entries.flatten() { + if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + let crate_path = entry.path(); + let crate_name = match crate_path.file_name() { + Some(name) => name.to_string_lossy(), + None => { + println!( + "cargo:warning=Skipping crate with invalid path: {}", + crate_path.display() + ); + continue; + } + }; + + // Skip the smithy crate itself to avoid self-dependency + if crate_name == "smithy" + || crate_name == "smithy-core" + || crate_name == "smithy-generator" + { + continue; + } + + if let Err(e) = + scan_crate_for_smithy_models(&crate_path, &crate_name, &mut smithy_models) + { + println!("cargo:warning=Failed to scan crate {}: {}", crate_name, e); + } + } + } + } + + // Generate the registry file + generate_model_registry(&smithy_models)?; + + Ok(()) +} + +fn get_workspace_root() -> Result> { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .map_err(|_| "CARGO_MANIFEST_DIR environment variable not set")?; + + let manifest_path = Path::new(&manifest_dir); + + let parent1 = manifest_path + .parent() + .ok_or("Cannot get parent directory of CARGO_MANIFEST_DIR")?; + + let workspace_root = parent1 + .parent() + .ok_or("Cannot get workspace root directory")?; + + Ok(workspace_root.to_path_buf()) +} + +fn scan_crate_for_smithy_models( + crate_path: &Path, + crate_name: &str, + models: &mut Vec, +) -> Result<(), Box> { + let src_path = crate_path.join("src"); + if !src_path.exists() { + return Ok(()); + } + + scan_directory(&src_path, crate_name, "", models)?; + Ok(()) +} + +fn scan_directory( + dir: &Path, + crate_name: &str, + module_path: &str, + models: &mut Vec, +) -> Result<(), Box> { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let dir_name = match path.file_name() { + Some(name) => name.to_string_lossy(), + None => { + println!( + "cargo:warning=Skipping directory with invalid name: {}", + path.display() + ); + continue; + } + }; + let new_module_path = if module_path.is_empty() { + dir_name.to_string() + } else { + format!("{}::{}", module_path, dir_name) + }; + scan_directory(&path, crate_name, &new_module_path, models)?; + } else if path.extension().map(|ext| ext == "rs").unwrap_or(false) { + if let Err(e) = scan_rust_file(&path, crate_name, module_path, models) { + println!( + "cargo:warning=Failed to scan Rust file {}: {}", + path.display(), + e + ); + } + } + } + } + Ok(()) +} + +fn scan_rust_file( + file_path: &Path, + crate_name: &str, + module_path: &str, + models: &mut Vec, +) -> Result<(), Box> { + if let Ok(content) = fs::read_to_string(file_path) { + // Enhanced regex that handles comments, doc comments, and multiple attributes + // between derive and struct/enum declarations + let re = Regex::new(r"(?ms)^#\[derive\(([^)]*(?:\([^)]*\))*[^)]*)\)\]\s*(?:(?:#\[[^\]]*\]\s*)|(?://[^\r\n]*\s*)|(?:///[^\r\n]*\s*)|(?:/\*.*?\*/\s*))*(?:pub\s+)?(?:struct|enum)\s+([A-Z][A-Za-z0-9_]*)\s*[<\{\(]") + .map_err(|e| format!("Failed to compile regex: {}", e))?; + + for captures in re.captures_iter(&content) { + let derive_content = match captures.get(1) { + Some(capture) => capture.as_str(), + None => { + println!( + "cargo:warning=Missing derive content in regex capture for {}", + file_path.display() + ); + continue; + } + }; + let item_name = match captures.get(2) { + Some(capture) => capture.as_str(), + None => { + println!( + "cargo:warning=Missing item name in regex capture for {}", + file_path.display() + ); + continue; + } + }; + + // Check if "SmithyModel" is present in the derive macro's content. + if derive_content.contains("SmithyModel") { + // Validate that the item name is a valid Rust identifier + if is_valid_rust_identifier(item_name) { + let full_module_path = create_module_path(file_path, crate_name, module_path)?; + + models.push(SmithyModelInfo { + struct_name: item_name.to_string(), + module_path: full_module_path, + }); + } else { + println!( + "cargo:warning=Skipping invalid identifier: {} in {}", + item_name, + file_path.display() + ); + } + } + } + } + Ok(()) +} + +fn is_valid_rust_identifier(name: &str) -> bool { + if name.is_empty() { + return false; + } + + // Rust identifiers must start with a letter or underscore + let first_char = match name.chars().next() { + Some(ch) => ch, + None => return false, // This shouldn't happen since we checked is_empty above, but being safe + }; + if !first_char.is_ascii_alphabetic() && first_char != '_' { + return false; + } + + // Must not be a Rust keyword + let keywords = [ + "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", + "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", + "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe", + "use", "where", "while", "async", "await", "dyn", "is", "abstract", "become", "box", "do", + "final", "macro", "override", "priv", "typeof", "unsized", "virtual", "yield", "try", + ]; + + if keywords.contains(&name) { + return false; + } + + // All other characters must be alphanumeric or underscore + name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn create_module_path( + file_path: &Path, + crate_name: &str, + module_path: &str, +) -> Result> { + let file_name = file_path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + format!( + "Cannot extract file name from path: {}", + file_path.display() + ) + })?; + + let crate_name_normalized = crate_name.replace('-', "_"); + + let result = if file_name == "lib" || file_name == "mod" { + if module_path.is_empty() { + crate_name_normalized + } else { + format!("{}::{}", crate_name_normalized, module_path) + } + } else if module_path.is_empty() { + format!("{}::{}", crate_name_normalized, file_name) + } else { + format!("{}::{}::{}", crate_name_normalized, module_path, file_name) + }; + + Ok(result) +} + +#[derive(Debug)] +struct SmithyModelInfo { + struct_name: String, + module_path: String, +} + +fn generate_model_registry(models: &[SmithyModelInfo]) -> Result<(), Box> { + let out_dir = std::env::var("OUT_DIR").map_err(|_| "OUT_DIR environment variable not set")?; + let registry_path = Path::new(&out_dir).join("model_registry.rs"); + + let mut content = String::new(); + content.push_str("// Auto-generated model registry\n"); + content.push_str("// DO NOT EDIT - This file is generated by build.rs\n\n"); + + if !models.is_empty() { + content.push_str("use smithy_core::{SmithyModel, SmithyModelGenerator};\n\n"); + + // Generate imports + for model in models { + content.push_str(&format!( + "use {}::{};\n", + model.module_path, model.struct_name + )); + } + + content.push_str("\npub fn discover_smithy_models() -> Vec {\n"); + content.push_str(" let mut models = Vec::new();\n\n"); + + // Generate model collection calls + for model in models { + content.push_str(&format!( + " models.push({}::generate_smithy_model());\n", + model.struct_name + )); + } + + content.push_str("\n models\n"); + content.push_str("}\n"); + } else { + // Generate empty function if no models found + content.push_str("use smithy_core::SmithyModel;\n\n"); + content.push_str("pub fn discover_smithy_models() -> Vec {\n"); + content.push_str( + " router_env::logger::info!(\"No SmithyModel structs found in workspace\");\n", + ); + content.push_str(" Vec::new()\n"); + content.push_str("}\n"); + } + + fs::write(®istry_path, content).map_err(|e| { + format!( + "Failed to write model registry to {}: {}", + registry_path.display(), + e + ) + })?; + + Ok(()) +} diff --git a/crates/smithy-generator/src/main.rs b/crates/smithy-generator/src/main.rs new file mode 100644 index 0000000000..a821c7b370 --- /dev/null +++ b/crates/smithy-generator/src/main.rs @@ -0,0 +1,59 @@ +// crates/smithy-generator/main.rs + +use std::path::Path; + +use router_env::logger; +use smithy_core::SmithyGenerator; + +// Include the auto-generated model registry +include!(concat!(env!("OUT_DIR"), "/model_registry.rs")); + +fn main() -> Result<(), Box> { + let mut generator = SmithyGenerator::new(); + + logger::info!("Discovering Smithy models from workspace..."); + + // Automatically discover and add all models + let models = discover_smithy_models(); + logger::info!("Found {} Smithy models", models.len()); + + if models.is_empty() { + logger::info!("No SmithyModel structs found. Make sure your structs:"); + logger::info!(" 1. Derive SmithyModel: #[derive(SmithyModel)]"); + logger::info!(" 2. Are in a crate that smithy can access"); + logger::info!(" 3. Have the correct smithy attributes"); + return Ok(()); + } + + for model in models { + logger::info!(" Processing namespace: {}", model.namespace); + let shape_names: Vec<_> = model.shapes.keys().collect(); + logger::info!(" Shapes: {:?}", shape_names); + generator.add_model(model); + } + + logger::info!("Generating Smithy IDL files..."); + + // Generate IDL files + let output_dir = Path::new("smithy/models"); + let absolute_output_dir = std::env::current_dir()?.join(output_dir); + + logger::info!("Output directory: {}", absolute_output_dir.display()); + + generator.generate_idl(output_dir)?; + + logger::info!("✅ Smithy models generated successfully!"); + logger::info!("Files written to: {}", absolute_output_dir.display()); + + // List generated files + if let Ok(entries) = std::fs::read_dir(output_dir) { + logger::info!("Generated files:"); + for entry in entries.flatten() { + if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + logger::info!(" - {}", entry.file_name().to_string_lossy()); + } + } + } + + Ok(()) +} diff --git a/crates/smithy/Cargo.toml b/crates/smithy/Cargo.toml new file mode 100644 index 0000000000..d06998600e --- /dev/null +++ b/crates/smithy/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "smithy" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[features] +default = [] + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "extra-traits"] } +smithy-core = { path = "../smithy-core" } diff --git a/crates/smithy/src/lib.rs b/crates/smithy/src/lib.rs new file mode 100644 index 0000000000..76a65f62f8 --- /dev/null +++ b/crates/smithy/src/lib.rs @@ -0,0 +1,1002 @@ +// crates/smithy/lib.rs - Fixed with proper optional type handling in flattening + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use smithy_core::{SmithyConstraint, SmithyEnumVariant, SmithyField}; +use syn::{parse_macro_input, Attribute, DeriveInput, Fields, Lit, Meta, Variant}; + +/// Derive macro for generating Smithy models from Rust structs and enums +#[proc_macro_derive(SmithyModel, attributes(smithy))] +pub fn derive_smithy_model(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match generate_smithy_impl(&input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn generate_smithy_impl(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + let (namespace, is_mixin) = extract_namespace_and_mixin(&input.attrs)?; + + match &input.data { + syn::Data::Struct(data_struct) => { + generate_struct_impl(name, &namespace, data_struct, &input.attrs, is_mixin) + } + syn::Data::Enum(data_enum) => generate_enum_impl(name, &namespace, data_enum, &input.attrs), + _ => Err(syn::Error::new_spanned( + input, + "SmithyModel can only be derived for structs and enums", + )), + } +} + +fn generate_struct_impl( + name: &syn::Ident, + namespace: &str, + data_struct: &syn::DataStruct, + attrs: &[Attribute], + is_mixin: bool, +) -> syn::Result { + let fields = extract_fields(&data_struct.fields)?; + + let struct_doc = extract_documentation(attrs); + let struct_doc_expr = struct_doc + .as_ref() + .map(|doc| quote! { Some(#doc.to_string()) }) + .unwrap_or(quote! { None }); + + let field_implementations = fields.iter().map(|field| { + let field_name = &field.name; + let value_type = &field.value_type; + let documentation = &field.documentation; + let constraints = &field.constraints; + let optional = field.optional; + let flatten = field.flatten; + + if flatten { + // Extract the inner type from Option if it's an optional type + let inner_type = if value_type.starts_with("Option<") && value_type.ends_with('>') { + let start_idx = "Option<".len(); + let end_idx = value_type.len() - 1; + &value_type[start_idx..end_idx] + } else { + value_type + }; + + let inner_type_ident = syn::parse_str::(inner_type).unwrap(); + // For flattened fields, we merge the fields from the inner type + // but we don't add the field itself to the structure + quote! { + { + let flattened_model = <#inner_type_ident as smithy_core::SmithyModelGenerator>::generate_smithy_model(); + let flattened_struct_name = stringify!(#inner_type_ident).to_string(); + + for (shape_name, shape) in flattened_model.shapes { + if shape_name == flattened_struct_name { + match shape { + smithy_core::SmithyShape::Structure { members: flattened_members, .. } | + smithy_core::SmithyShape::Union { members: flattened_members, .. } => { + members.extend(flattened_members); + } + _ => { + // Potentially handle other shapes or log a warning + } + } + } else { + shapes.insert(shape_name, shape); + } + } + } + } + } else { + + let field_doc = documentation + .as_ref() + .map(|doc| quote! { Some(#doc.to_string()) }) + .unwrap_or(quote! { None }); + + let mut all_constraints = constraints.clone(); + if !optional && !all_constraints.iter().any(|c| matches!(c, SmithyConstraint::Required)) { + all_constraints.push(SmithyConstraint::Required); + } + + let traits = if all_constraints.is_empty() { + quote! { vec![] } + } else { + let trait_tokens = all_constraints + .iter() + .map(|constraint| match constraint { + SmithyConstraint::Pattern(pattern) => quote! { + smithy_core::SmithyTrait::Pattern { pattern: #pattern.to_string() } + }, + SmithyConstraint::Range(min, max) => { + let min_expr = min.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + let max_expr = max.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + quote! { + smithy_core::SmithyTrait::Range { + min: #min_expr, + max: #max_expr + } + } + }, + SmithyConstraint::Length(min, max) => { + let min_expr = min.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + let max_expr = max.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + quote! { + smithy_core::SmithyTrait::Length { + min: #min_expr, + max: #max_expr + } + } + }, + SmithyConstraint::Required => quote! { + smithy_core::SmithyTrait::Required + }, + SmithyConstraint::HttpLabel => quote! { + smithy_core::SmithyTrait::HttpLabel + }, + SmithyConstraint::HttpQuery(name) => quote! { + smithy_core::SmithyTrait::HttpQuery { name: #name.to_string() } + }, + }) + .collect::>(); + + quote! { vec![#(#trait_tokens),*] } + }; + + quote! { + { + let (target_type, new_shapes) = smithy_core::types::resolve_type_and_generate_shapes(#value_type, &mut shapes).unwrap(); + shapes.extend(new_shapes); + members.insert(#field_name.to_string(), smithy_core::SmithyMember { + target: target_type, + documentation: #field_doc, + traits: #traits, + }); + } + } + } + }); + + let traits_expr = if is_mixin { + quote! { vec![smithy_core::SmithyTrait::Mixin] } + } else { + quote! { vec![] } + }; + + let expanded = quote! { + impl smithy_core::SmithyModelGenerator for #name { + fn generate_smithy_model() -> smithy_core::SmithyModel { + let mut shapes = std::collections::HashMap::new(); + let mut members = std::collections::HashMap::new(); + + #(#field_implementations;)* + + let shape = smithy_core::SmithyShape::Structure { + members, + documentation: #struct_doc_expr, + traits: #traits_expr + }; + + shapes.insert(stringify!(#name).to_string(), shape); + + smithy_core::SmithyModel { + namespace: #namespace.to_string(), + shapes + } + } + } + }; + + Ok(expanded) +} + +fn generate_enum_impl( + name: &syn::Ident, + namespace: &str, + data_enum: &syn::DataEnum, + attrs: &[Attribute], +) -> syn::Result { + let variants = extract_enum_variants(&data_enum.variants)?; + let serde_enum_attrs = parse_serde_enum_attributes(attrs)?; + + let enum_doc = extract_documentation(attrs); + let enum_doc_expr = enum_doc + .as_ref() + .map(|doc| quote! { Some(#doc.to_string()) }) + .unwrap_or(quote! { None }); + + // Check if this is a string enum (all variants are unit variants) or a union + let is_string_enum = variants.iter().all(|v| v.fields.is_empty()); + + if is_string_enum { + // Generate as Smithy enum + let variant_implementations = variants + .iter() + .map(|variant| { + let variant_name = &variant.name; + let variant_doc = variant + .documentation + .as_ref() + .map(|doc| quote! { Some(#doc.to_string()) }) + .unwrap_or(quote! { None }); + + // Apply serde rename transformation if specified + let rename_all = serde_enum_attrs.rename_all.as_deref(); + let transformed_name = if let Some(rename_pattern) = rename_all { + // Generate the transformation at compile time + let transformed = transform_variant_name(variant_name, Some(rename_pattern)); + quote! { #transformed.to_string() } + } else { + quote! { #variant_name.to_string() } + }; + + quote! { + enum_values.insert(#transformed_name, smithy_core::SmithyEnumValue { + name: #transformed_name, + documentation: #variant_doc, + is_default: false, + }); + } + }) + .collect::>(); + + let expanded = quote! { + impl smithy_core::SmithyModelGenerator for #name { + fn generate_smithy_model() -> smithy_core::SmithyModel { + let mut shapes = std::collections::HashMap::new(); + let mut enum_values = std::collections::HashMap::new(); + + #(#variant_implementations)* + + let shape = smithy_core::SmithyShape::Enum { + values: enum_values, + documentation: #enum_doc_expr, + traits: vec![] + }; + + shapes.insert(stringify!(#name).to_string(), shape); + + smithy_core::SmithyModel { + namespace: #namespace.to_string(), + shapes + } + } + } + }; + + Ok(expanded) + } else { + // Generate as Smithy union + let variant_implementations = variants + .iter() + .filter_map(|variant| { + let variant_name = &variant.name; + let variant_doc = variant + .documentation + .as_ref() + .map(|doc| quote! { Some(#doc.to_string()) }) + .unwrap_or(quote! { None }); + + let target_type_expr = if variant.fields.is_empty() { + // If there are no fields with `value_type`, this variant should be skipped. + return None; + } else if variant.fields.len() == 1 { + // Single field - reference the type directly instead of creating a wrapper + let field = &variant.fields[0]; + let field_value_type = &field.value_type; + if field_value_type.is_empty() { + return None; + } + + quote! { + { + let (target_type, new_shapes) = smithy_core::types::resolve_type_and_generate_shapes(#field_value_type, &mut shapes).unwrap(); + shapes.extend(new_shapes); + target_type + } + } + } else { + // Multiple fields - create an inline structure + let inline_struct_members = variant.fields.iter().map(|field| { + let field_name = &field.name; + let field_value_type = &field.value_type; + let field_doc = field + .documentation + .as_ref() + .map(|doc| quote! { Some(#doc.to_string()) }) + .unwrap_or(quote! { None }); + + let mut field_constraints = field.constraints.clone(); + if !field.optional && !field_constraints.iter().any(|c| matches!(c, SmithyConstraint::Required)) { + field_constraints.push(SmithyConstraint::Required); + } + + let field_traits = if field_constraints.is_empty() { + quote! { vec![] } + } else { + let trait_tokens = field_constraints + .iter() + .map(|constraint| match constraint { + SmithyConstraint::Pattern(pattern) => quote! { + smithy_core::SmithyTrait::Pattern { pattern: #pattern.to_string() } + }, + SmithyConstraint::Range(min, max) => { + let min_expr = min.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + let max_expr = max.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + quote! { + smithy_core::SmithyTrait::Range { + min: #min_expr, + max: #max_expr + } + } + }, + SmithyConstraint::Length(min, max) => { + let min_expr = min.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + let max_expr = max.map(|v| quote! { Some(#v) }).unwrap_or(quote! { None }); + quote! { + smithy_core::SmithyTrait::Length { + min: #min_expr, + max: #max_expr + } + } + }, + SmithyConstraint::Required => quote! { + smithy_core::SmithyTrait::Required + }, + SmithyConstraint::HttpLabel => quote! { + smithy_core::SmithyTrait::HttpLabel + }, + SmithyConstraint::HttpQuery(name) => quote! { + smithy_core::SmithyTrait::HttpQuery { name: #name.to_string() } + }, + }) + .collect::>(); + + quote! { vec![#(#trait_tokens),*] } + }; + + quote! { + { + let (field_target, field_shapes) = smithy_core::types::resolve_type_and_generate_shapes(#field_value_type, &mut shapes).unwrap(); + shapes.extend(field_shapes); + inline_members.insert(#field_name.to_string(), smithy_core::SmithyMember { + target: field_target, + documentation: #field_doc, + traits: #field_traits, + }); + } + } + }); + + quote! { + { + let inline_struct_name = format!("{}{}Data", stringify!(#name), #variant_name); + let mut inline_members = std::collections::HashMap::new(); + #(#inline_struct_members)* + let inline_shape = smithy_core::SmithyShape::Structure { + members: inline_members, + documentation: None, + traits: vec![], + }; + shapes.insert(inline_struct_name.clone(), inline_shape); + inline_struct_name + } + } + }; + + // Apply serde rename transformation if specified + let rename_all = serde_enum_attrs.rename_all.as_deref(); + let transformed_name = if let Some(rename_pattern) = rename_all { + // Generate the transformation at compile time + let transformed = transform_variant_name(variant_name, Some(rename_pattern)); + quote! { #transformed.to_string() } + } else { + quote! { #variant_name.to_string() } + }; + + Some(quote! { + let target_type = #target_type_expr; + members.insert(#transformed_name, smithy_core::SmithyMember { + target: target_type, + documentation: #variant_doc, + traits: vec![] + }); + }) + }) + .collect::>(); + + let expanded = quote! { + impl smithy_core::SmithyModelGenerator for #name { + fn generate_smithy_model() -> smithy_core::SmithyModel { + let mut shapes = std::collections::HashMap::new(); + let mut members = std::collections::HashMap::new(); + + #(#variant_implementations;)* + + let shape = smithy_core::SmithyShape::Union { + members, + documentation: #enum_doc_expr, + traits: vec![] + }; + + shapes.insert(stringify!(#name).to_string(), shape); + + smithy_core::SmithyModel { + namespace: #namespace.to_string(), + shapes + } + } + } + }; + + Ok(expanded) + } +} + +fn extract_namespace_and_mixin(attrs: &[Attribute]) -> syn::Result<(String, bool)> { + for attr in attrs { + if attr.path().is_ident("smithy") { + let mut namespace = None; + let mut mixin = false; + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("namespace") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + namespace = Some(lit_str.value()); + } + } + } else if meta.path.is_ident("mixin") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Bool(lit_bool)) = value.parse::() { + mixin = lit_bool.value; + } + } + } + Ok(()) + })?; // Propagate parsing errors + + return Ok(( + namespace.unwrap_or_else(|| "com.hyperswitch.default".to_string()), + mixin, + )); + } + } + Ok(("com.hyperswitch.default".to_string(), false)) +} + +fn extract_fields(fields: &Fields) -> syn::Result> { + let mut smithy_fields = Vec::new(); + + match fields { + Fields::Named(fields_named) => { + for field in &fields_named.named { + let field_name = field.ident.as_ref().unwrap().to_string(); + let field_attrs = parse_smithy_field_attributes(&field.attrs)?; + let serde_attrs = parse_serde_attributes(&field.attrs)?; + + if let Some(value_type) = field_attrs.value_type { + let documentation = extract_documentation(&field.attrs); + let optional = value_type.trim().starts_with("Option<"); + + smithy_fields.push(SmithyField { + name: field_name, + value_type, + constraints: field_attrs.constraints, + documentation, + optional, + flatten: serde_attrs.flatten, + }); + } + } + } + _ => { + return Err(syn::Error::new_spanned( + fields, + "Only named fields are supported", + )) + } + } + + Ok(smithy_fields) +} + +fn extract_enum_variants( + variants: &syn::punctuated::Punctuated, +) -> syn::Result> { + let mut smithy_variants = Vec::new(); + + for variant in variants { + let variant_name = variant.ident.to_string(); + let documentation = extract_documentation(&variant.attrs); + let variant_attrs = parse_smithy_field_attributes(&variant.attrs)?; + + // Extract fields from the variant + let fields = match &variant.fields { + Fields::Unit => Vec::new(), + Fields::Named(fields_named) => { + let mut variant_fields = Vec::new(); + for field in &fields_named.named { + let field_name = field.ident.as_ref().unwrap().to_string(); + let field_attrs = parse_smithy_field_attributes(&field.attrs)?; + + if let Some(value_type) = field_attrs.value_type { + let field_documentation = extract_documentation(&field.attrs); + let optional = value_type.trim().starts_with("Option<"); + + variant_fields.push(SmithyField { + name: field_name, + value_type, + constraints: field_attrs.constraints, + documentation: field_documentation, + optional, + flatten: false, + }); + } + } + variant_fields + } + Fields::Unnamed(fields_unnamed) => { + let mut variant_fields = Vec::new(); + for (index, field) in fields_unnamed.unnamed.iter().enumerate() { + let field_name = format!("field_{}", index); + let field_attrs = parse_smithy_field_attributes(&field.attrs)?; + + // For single unnamed fields, use the variant attribute if field doesn't have one + let value_type = field_attrs + .value_type + .or_else(|| variant_attrs.value_type.clone()); + + if let Some(value_type) = value_type { + let field_documentation = extract_documentation(&field.attrs); + let optional = value_type.trim().starts_with("Option<"); + + variant_fields.push(SmithyField { + name: field_name, + value_type, + constraints: field_attrs.constraints, + documentation: field_documentation, + optional, + flatten: false, + }); + } + } + variant_fields + } + }; + + smithy_variants.push(SmithyEnumVariant { + name: variant_name, + fields, + constraints: variant_attrs.constraints, + documentation, + }); + } + + Ok(smithy_variants) +} + +#[derive(Default)] +struct SmithyFieldAttributes { + value_type: Option, + constraints: Vec, +} + +#[derive(Default)] +struct SerdeAttributes { + flatten: bool, +} + +#[derive(Default)] +struct SerdeEnumAttributes { + rename_all: Option, +} + +fn parse_serde_attributes(attrs: &[Attribute]) -> syn::Result { + let mut serde_attributes = SerdeAttributes::default(); + + for attr in attrs { + if attr.path().is_ident("serde") { + if let Ok(list) = attr.meta.require_list() { + if list.path.is_ident("serde") { + for item in list.tokens.clone() { + if let Some(ident) = item.to_string().split_whitespace().next() { + if ident == "flatten" { + serde_attributes.flatten = true; + } + } + } + } + } + } + } + + Ok(serde_attributes) +} + +fn parse_serde_enum_attributes(attrs: &[Attribute]) -> syn::Result { + let mut serde_enum_attributes = SerdeEnumAttributes::default(); + + for attr in attrs { + if attr.path().is_ident("serde") { + // Use more robust parsing that handles all serde attributes + let parse_result = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + serde_enum_attributes.rename_all = Some(lit_str.value()); + } + } + } else if meta.path.is_ident("tag") { + // Parse and ignore the tag attribute + if let Ok(value) = meta.value() { + let _ = value.parse::(); + } + } else if meta.path.is_ident("content") { + // Parse and ignore the content attribute + if let Ok(value) = meta.value() { + let _ = value.parse::(); + } + } else if meta.path.is_ident("rename") { + // Parse and ignore the rename attribute (used for enum renaming) + if let Ok(value) = meta.value() { + let _ = value.parse::(); + } + } else if meta.path.is_ident("deny_unknown_fields") { + // Handle deny_unknown_fields (no value needed) + // This is a flag attribute with no value + } else if meta.path.is_ident("skip_serializing") { + // Handle skip_serializing + } else if meta.path.is_ident("skip_deserializing") { + // Handle skip_deserializing + } else if meta.path.is_ident("skip_serializing_if") { + // Handle skip_serializing_if + if let Ok(value) = meta.value() { + let _ = value.parse::(); + } + } else if meta.path.is_ident("default") { + // Handle default attribute + // Could have a value or be a flag + if meta.value().is_ok() { + let _ = meta.value().and_then(|v| v.parse::()); + } + } else if meta.path.is_ident("flatten") { + // Handle flatten (flag attribute) + } else if meta.path.is_ident("untagged") { + // Handle untagged (flag attribute) + } else if meta.path.is_ident("bound") { + // Handle bound attribute + if let Ok(value) = meta.value() { + let _ = value.parse::(); + } + } + // Silently ignore any other serde attributes to prevent parsing errors + Ok(()) + }); + + // If parsing failed, provide a more helpful error message + if let Err(e) = parse_result { + return Err(syn::Error::new_spanned( + attr, + format!("Failed to parse serde attribute: {}. This may be due to multiple serde attributes on separate lines. Consider consolidating them into a single #[serde(...)] attribute.", e) + )); + } + } + } + + Ok(serde_enum_attributes) +} + +fn transform_variant_name(name: &str, rename_all: Option<&str>) -> String { + match rename_all { + Some("snake_case") => to_snake_case(name), + Some("camelCase") => to_camel_case(name), + Some("kebab-case") => to_kebab_case(name), + Some("PascalCase") => name.to_string(), // No change for PascalCase + Some("SCREAMING_SNAKE_CASE") => to_screaming_snake_case(name), + Some("lowercase") => name.to_lowercase(), + Some("UPPERCASE") => name.to_uppercase(), + _ => name.to_string(), // No transformation if no rename_all or unknown pattern + } +} + +fn to_snake_case(input: &str) -> String { + let mut result = String::new(); + let chars = input.chars(); + + for ch in chars { + if ch.is_uppercase() && !result.is_empty() { + // Add underscore before uppercase letters (except the first character) + result.push('_'); + } + result.push(ch.to_lowercase().next().unwrap()); + } + + result +} + +fn to_camel_case(input: &str) -> String { + let mut result = String::new(); + let mut chars = input.chars(); + + // First character should be lowercase + if let Some(ch) = chars.next() { + result.push(ch.to_lowercase().next().unwrap()); + } + + // Rest of the characters remain the same + for ch in chars { + result.push(ch); + } + + result +} + +fn to_kebab_case(input: &str) -> String { + let mut result = String::new(); + + for ch in input.chars() { + if ch.is_uppercase() && !result.is_empty() { + // Add hyphen before uppercase letters (except the first character) + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } + + result +} + +fn to_screaming_snake_case(input: &str) -> String { + let mut result = String::new(); + + for ch in input.chars() { + if ch.is_uppercase() && !result.is_empty() { + // Add underscore before uppercase letters (except the first character) + result.push('_'); + } + result.push(ch.to_uppercase().next().unwrap()); + } + + result +} + +fn parse_smithy_field_attributes(attrs: &[Attribute]) -> syn::Result { + let mut field_attributes = SmithyFieldAttributes::default(); + + for attr in attrs { + if attr.path().is_ident("smithy") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("value_type") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + field_attributes.value_type = Some(lit_str.value()); + } + } + } else if meta.path.is_ident("pattern") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + field_attributes + .constraints + .push(SmithyConstraint::Pattern(lit_str.value())); + } + } + } else if meta.path.is_ident("range") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + let range_str = lit_str.value(); + match parse_range(&range_str) { + Ok((min, max)) => { + field_attributes + .constraints + .push(SmithyConstraint::Range(min, max)); + } + Err(e) => { + return Err(syn::Error::new_spanned( + &meta.path, + format!("Invalid range: {}", e), + )); + } + } + } + } + } else if meta.path.is_ident("length") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + let length_str = lit_str.value(); + match parse_length(&length_str) { + Ok((min, max)) => { + field_attributes + .constraints + .push(SmithyConstraint::Length(min, max)); + } + Err(e) => { + return Err(syn::Error::new_spanned( + &meta.path, + format!("Invalid length: {}", e), + )); + } + } + } + } + } else if meta.path.is_ident("required") { + field_attributes + .constraints + .push(SmithyConstraint::Required); + } else if meta.path.is_ident("http_label") { + field_attributes + .constraints + .push(SmithyConstraint::HttpLabel); + } else if meta.path.is_ident("http_query") { + if let Ok(value) = meta.value() { + if let Ok(Lit::Str(lit_str)) = value.parse::() { + field_attributes + .constraints + .push(SmithyConstraint::HttpQuery(lit_str.value())); + } + } + } + Ok(()) + })?; + } + } + + // Automatically add Required for http_label fields + if field_attributes + .constraints + .iter() + .any(|c| matches!(c, SmithyConstraint::HttpLabel)) + && !field_attributes + .constraints + .iter() + .any(|c| matches!(c, SmithyConstraint::Required)) + { + field_attributes + .constraints + .push(SmithyConstraint::Required); + } + + Ok(field_attributes) +} + +fn extract_documentation(attrs: &[Attribute]) -> Option { + let mut docs = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") { + if let Meta::NameValue(meta_name_value) = &attr.meta { + if let syn::Expr::Lit(expr_lit) = &meta_name_value.value { + if let Lit::Str(lit_str) = &expr_lit.lit { + docs.push(lit_str.value().trim().to_string()); + } + } + } + } + } + + if docs.is_empty() { + None + } else { + Some(docs.join(" ")) + } +} + +fn parse_range(range_str: &str) -> Result<(Option, Option), String> { + if range_str.contains("..=") { + let parts: Vec<&str> = range_str.split("..=").collect(); + if parts.len() != 2 { + return Err( + "Invalid range format: must be 'min..=max', '..=max', or 'min..='".to_string(), + ); + } + let min = if parts[0].is_empty() { + None + } else { + Some( + parts[0] + .parse() + .map_err(|_| format!("Invalid range min: '{}'", parts[0]))?, + ) + }; + let max = if parts[1].is_empty() { + None + } else { + Some( + parts[1] + .parse() + .map_err(|_| format!("Invalid range max: '{}'", parts[1]))?, + ) + }; + Ok((min, max)) + } else if range_str.contains("..") { + let parts: Vec<&str> = range_str.split("..").collect(); + if parts.len() != 2 { + return Err( + "Invalid range format: must be 'min..max', '..max', or 'min..'".to_string(), + ); + } + let min = if parts[0].is_empty() { + None + } else { + Some( + parts[0] + .parse() + .map_err(|_| format!("Invalid range min: '{}'", parts[0]))?, + ) + }; + let max = if parts[1].is_empty() { + None + } else { + Some( + parts[1] + .parse::() + .map_err(|_| format!("Invalid range max: '{}'", parts[1]))? + - 1, + ) + }; + Ok((min, max)) + } else { + Err("Invalid range format: must contain '..' or '..='".to_string()) + } +} + +fn parse_length(length_str: &str) -> Result<(Option, Option), String> { + if length_str.contains("..=") { + let parts: Vec<&str> = length_str.split("..=").collect(); + if parts.len() != 2 { + return Err( + "Invalid length format: must be 'min..=max', '..=max', or 'min..='".to_string(), + ); + } + let min = if parts[0].is_empty() { + None + } else { + Some( + parts[0] + .parse() + .map_err(|_| format!("Invalid length min: '{}'", parts[0]))?, + ) + }; + let max = if parts[1].is_empty() { + None + } else { + Some( + parts[1] + .parse() + .map_err(|_| format!("Invalid length max: '{}'", parts[1]))?, + ) + }; + Ok((min, max)) + } else if length_str.contains("..") { + let parts: Vec<&str> = length_str.split("..").collect(); + if parts.len() != 2 { + return Err( + "Invalid length format: must be 'min..max', '..max', or 'min..'".to_string(), + ); + } + let min = if parts[0].is_empty() { + None + } else { + Some( + parts[0] + .parse() + .map_err(|_| format!("Invalid length min: '{}'", parts[0]))?, + ) + }; + let max = if parts[1].is_empty() { + None + } else { + Some( + parts[1] + .parse::() + .map_err(|_| format!("Invalid length max: '{}'", parts[1]))? + - 1, + ) + }; + Ok((min, max)) + } else { + Err("Invalid length format: must contain '..' or '..='".to_string()) + } +}