- misses-rt: add Buffer/Markup HTML helpers, name/type helpers, parse_sections/parse_kv, and template AST - codegen: add is_syn_keyword() — non-keyword idents in patterns now generate parse+compare instead of syn::Token![…] - parser: split punct vs ident chars in literal pattern tokens so `props:` becomes two tokens - examples: add component.mrs/.rs demonstrating named literal tokens and tt capture - README: comprehensive guide covering .mrs syntax, running the compiler, misses-rt helpers, and examples https://claude.ai/code/session_01Tppkab4eLsL21asHGjQJoF
208 lines
6.2 KiB
Rust
208 lines
6.2 KiB
Rust
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<String> = 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<T>` for any `T`.
|
|
pub fn is_option(ty: &syn::Type) -> bool {
|
|
unwrap_option(ty).is_some()
|
|
}
|
|
|
|
/// If `ty` is `Option<T>`, 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<char> = 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");
|
|
}
|
|
}
|