Add misses-rt crate, component example, README, and fix codegen for non-keyword literal tokens
- 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
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user