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:
Claude
2026-03-07 19:23:53 +00:00
parent f90953a3bb
commit e8d835e196
11 changed files with 1156 additions and 21 deletions
+207
View File
@@ -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("&lt;"),
'>' => self.inner.push_str("&gt;"),
'"' => self.inner.push_str("&quot;"),
'\'' => self.inner.push_str("&#x27;"),
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");
}
}