pub mod template; pub mod parse_helpers; pub use template::{TemplateAst, parse_template}; pub use parse_helpers::{SectionMap, KvMap, parse_sections, parse_kv}; // ── Runtime types ──────────────────────────────────────────── /// Accumulation buffer for HTML rendering. pub struct Buffer { inner: String, } impl Buffer { pub fn new() -> Self { Buffer { inner: String::new() } } /// Push literal HTML (already safe / already escaped). pub fn push_str(&mut self, s: &str) { self.inner.push_str(s); } /// Push a user-supplied value, HTML-escaping it. pub fn push_escaped(&mut self, s: &str) { for ch in s.chars() { match ch { '&' => self.inner.push_str("&"), '<' => self.inner.push_str("<"), '>' => self.inner.push_str(">"), '"' => self.inner.push_str("""), '\'' => self.inner.push_str("'"), c => self.inner.push(c), } } } } impl Default for Buffer { fn default() -> Self { Self::new() } } /// A string of trusted HTML markup produced by a component. pub struct Markup { inner: String, } impl Markup { pub fn from_buffer(buf: Buffer) -> Self { Markup { inner: buf.inner } } pub fn into_string(self) -> String { self.inner } pub fn as_str(&self) -> &str { &self.inner } } impl std::fmt::Display for Markup { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.inner) } } // ── Name helpers ───────────────────────────────────────────── /// `BlogPost` → `blog_posts` (table name: snake_case + plural) pub fn to_table_name(ident: &syn::Ident) -> String { let snake = pascal_to_snake(&ident.to_string()); format!("{}s", snake) } /// `BlogPost` → `blog_post` pub fn to_snake_case(ident: &syn::Ident) -> String { pascal_to_snake(&ident.to_string()) } /// Works on a `&str` (useful inside codegen where you have String/&str). pub fn to_snake_case_str(s: &str) -> String { pascal_to_snake(s) } /// `blog_post` → `BlogPost` (returns a `proc_macro2::Ident` for use in quote!) pub fn to_pascal_case(s: &str) -> proc_macro2::Ident { let pascal = snake_to_pascal(s); quote::format_ident!("{}", pascal) } /// Join parts into a single snake_case identifier. /// Each part that looks like PascalCase is first converted to snake_case. pub fn concat_ident(parts: &[&str]) -> proc_macro2::Ident { let snake_parts: Vec = parts.iter().map(|p| pascal_to_snake(p)).collect(); let joined = snake_parts.join("_"); quote::format_ident!("{}", joined) } // ── Type helpers ───────────────────────────────────────────── /// Returns `true` if `ty` is `Option` for any `T`. pub fn is_option(ty: &syn::Type) -> bool { unwrap_option(ty).is_some() } /// If `ty` is `Option`, returns `Some(&T)`, else `None`. pub fn unwrap_option(ty: &syn::Type) -> Option<&syn::Type> { if let syn::Type::Path(tp) = ty { let seg = tp.path.segments.last()?; if seg.ident != "Option" { return None; } if let syn::PathArguments::AngleBracketed(args) = &seg.arguments { if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { return Some(inner); } } } None } /// Best-effort mapping of a Rust type to a SQL column type string. pub fn to_sql_type(ty: &syn::Type) -> String { let s = quote::quote!(#ty).to_string(); let s = s.replace(' ', ""); match s.as_str() { "i8"|"i16"|"i32"|"i64"|"u8"|"u16"|"u32"|"u64"|"usize"|"isize" => "INTEGER".into(), "f32"|"f64" => "REAL".into(), "bool" => "BOOLEAN".into(), "String"|"&str"|"&'staticstr" => "TEXT".into(), _ if s.starts_with("Option<") => { // unwrap and recurse if let Some(inner) = unwrap_option(ty) { to_sql_type(inner) + " NULL" } else { "TEXT NULL".into() } } _ => "BLOB".into(), } } // ── Internal conversion utilities ──────────────────────────── fn pascal_to_snake(s: &str) -> String { if s.is_empty() { return String::new(); } let mut out = String::new(); let mut prev_upper = false; let chars: Vec = s.chars().collect(); for (i, &c) in chars.iter().enumerate() { if c.is_uppercase() { let next_is_lower = chars.get(i + 1).map(|c| c.is_lowercase()).unwrap_or(false); if i > 0 && (!prev_upper || next_is_lower) { out.push('_'); } out.push(c.to_lowercase().next().unwrap()); prev_upper = true; } else { out.push(c); prev_upper = false; } } out } fn snake_to_pascal(s: &str) -> String { s.split('_') .filter(|p| !p.is_empty()) .map(|p| { let mut c = p.chars(); match c.next() { None => String::new(), Some(f) => f.to_uppercase().to_string() + c.as_str(), } }) .collect() } // ── Tests ───────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn snake_cases() { assert_eq!(pascal_to_snake("BlogPost"), "blog_post"); assert_eq!(pascal_to_snake("UserID"), "user_id"); assert_eq!(pascal_to_snake("HTTPSRequest"), "https_request"); assert_eq!(pascal_to_snake("simple"), "simple"); } #[test] fn pascal_cases() { assert_eq!(snake_to_pascal("blog_post"), "BlogPost"); assert_eq!(snake_to_pascal("user_id"), "UserId"); } #[test] fn table_names() { let ident = quote::format_ident!("BlogPost"); assert_eq!(to_table_name(&ident), "blog_posts"); } }