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:
Generated
+9
@@ -12,6 +12,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "misses-rt"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["misses-compiler"]
|
||||
members = ["misses-compiler", "misses-rt"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Misses
|
||||
|
||||
**Misses** is a DSL compiler that transforms `.mrs` macro definitions into ready-to-use Rust proc macro source files. Write your macro once in a concise, readable syntax; Misses generates the full `syn`/`quote` boilerplate for you.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build everything
|
||||
cargo build
|
||||
|
||||
# Run a bundled example (compare .mrs source with generated Rust)
|
||||
cargo run --example make_id_type
|
||||
cargo run --example derive_builder
|
||||
cargo run --example derive_getters
|
||||
cargo run --example declare_table
|
||||
cargo run --example component
|
||||
|
||||
# Compile a .mrs file to Rust
|
||||
cargo run --bin misses -- path/to/my_macro.mrs > src/generated.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The `.mrs` syntax
|
||||
|
||||
A `.mrs` file contains one or more macro definitions:
|
||||
|
||||
```
|
||||
macro macro_name! {
|
||||
<pattern> => {
|
||||
<output>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern bindings
|
||||
|
||||
| Syntax | Parsed as |
|
||||
|---|---|
|
||||
| `$name:ident` | A single identifier |
|
||||
| `$expr:expr` | Any Rust expression |
|
||||
| `$ty:ty` | A Rust type |
|
||||
| `$body:tt` | A raw token-tree (brace group) |
|
||||
| `$($field:ident : $ty:ty),*` | Variadic — zero-or-more comma-separated pairs |
|
||||
|
||||
Literal tokens in the pattern are matched verbatim. Rust keywords use `syn::Token![…]`; arbitrary identifiers are matched by value.
|
||||
|
||||
### Output blocks
|
||||
|
||||
An output block may contain any mix of:
|
||||
|
||||
**Escape block** `@{ … }` — arbitrary Rust code executed at macro-expansion time:
|
||||
|
||||
```
|
||||
@{
|
||||
let builder_name = format_ident!("{}_Builder", name.to_string());
|
||||
}
|
||||
```
|
||||
|
||||
**Token template** `T~{ … }` — generates the `quote::quote! { … }` call. Inside a template you may use:
|
||||
|
||||
| Syntax | Meaning |
|
||||
|---|---|
|
||||
| `$name` | Interpolate the bound variable `name` |
|
||||
| `$field.name` / `$field.ty` | Access a field of a variadic element |
|
||||
| `${ expr }` | Evaluate an arbitrary Rust expression and interpolate the result |
|
||||
| `for x in $xs => …` | Spread — iterate the variadic collection and repeat a line |
|
||||
| `#[a => b_$name]` | Construct an identifier from parts (uses `format_ident!`) |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### `make_id_type.mrs` — newtype ID wrapper
|
||||
|
||||
```
|
||||
macro make_id_type! {
|
||||
$name:ident => {
|
||||
T~{
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct $name(pub u64);
|
||||
|
||||
impl $name {
|
||||
pub fn new(val: u64) -> Self { Self(val) }
|
||||
pub fn value(self) -> u64 { self.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `derive_builder.mrs` — builder pattern
|
||||
|
||||
```
|
||||
macro derive_builder! {
|
||||
struct $name:ident { $($field:ident : $ty:ty),* } => {
|
||||
@{
|
||||
let builder_name = format_ident!("{}_Builder", name.to_string());
|
||||
}
|
||||
T~{
|
||||
pub struct ${ builder_name } {
|
||||
for field in $fields =>
|
||||
pub $field.name: Option<$field.ty>,
|
||||
}
|
||||
impl ${ builder_name } {
|
||||
pub fn build(self) -> $name {
|
||||
$name {
|
||||
for field in $fields =>
|
||||
$field.name: self.$field.name.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `declare_table.mrs` — DSL to struct with runtime helper
|
||||
|
||||
```
|
||||
macro declare_table! {
|
||||
$name:ident { $body:tt } => {
|
||||
@{
|
||||
let table_str = misses_rt::to_table_name(&name);
|
||||
}
|
||||
T~{
|
||||
pub struct $name {
|
||||
pub id: u64,
|
||||
}
|
||||
impl $name {
|
||||
pub const TABLE: &'static str = ${ table_str.as_str() };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The `misses-rt` runtime library
|
||||
|
||||
`misses-rt` provides helpers that generated proc macro code can call at expansion time. Add it to your proc macro crate:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
misses-rt = { path = "../misses-rt" }
|
||||
```
|
||||
|
||||
### Name helpers
|
||||
|
||||
```rust
|
||||
misses_rt::to_table_name(&ident) // UsersAccount → "users_accounts"
|
||||
misses_rt::to_snake_case(&ident) // MyType → "my_type"
|
||||
misses_rt::to_snake_case_str(s) // "MyType" → "my_type"
|
||||
misses_rt::to_pascal_case(s) // "my_type" → MyType (Ident)
|
||||
misses_rt::concat_ident(&[a, b]) // ["foo", "Bar"] → fooBar (Ident)
|
||||
```
|
||||
|
||||
### Type helpers
|
||||
|
||||
```rust
|
||||
misses_rt::is_option(&ty) // Option<T> → true
|
||||
misses_rt::unwrap_option(&ty) // Option<T> → Some(&T)
|
||||
misses_rt::to_sql_type(&ty) // syn::Type → "TEXT", "INTEGER", …
|
||||
```
|
||||
|
||||
### HTML template helpers
|
||||
|
||||
```rust
|
||||
let mut buf = misses_rt::Buffer::new();
|
||||
buf.push_str("<b>");
|
||||
buf.push_escaped("<script>"); // → "<script>"
|
||||
let markup: misses_rt::Markup = misses_rt::Markup::from_buffer(buf);
|
||||
println!("{}", markup.into_string());
|
||||
```
|
||||
|
||||
### Section / key-value parsing
|
||||
|
||||
```rust
|
||||
// Parse `props { … } slots { … }` blocks from a token stream
|
||||
let map = misses_rt::parse_sections(tokens, &["props", "slots"]);
|
||||
let props_tokens = map.get("props");
|
||||
|
||||
// Parse `key = value, …` pairs
|
||||
let kv = misses_rt::parse_kv(tokens);
|
||||
let val = kv.get("name");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
misses/
|
||||
├── misses-compiler/ # The .mrs → Rust transpiler
|
||||
│ ├── src/
|
||||
│ │ ├── ast.rs # Full AST types
|
||||
│ │ ├── parser.rs # Hand-written recursive descent parser
|
||||
│ │ ├── codegen.rs # AST → Rust source string
|
||||
│ │ ├── lib.rs # Library API (parse + codegen)
|
||||
│ │ └── main.rs # CLI binary
|
||||
│ └── examples/ # Runnable .mrs demonstrations
|
||||
│ ├── make_id_type.{mrs,rs}
|
||||
│ ├── derive_builder.{mrs,rs}
|
||||
│ ├── derive_getters.{mrs,rs}
|
||||
│ ├── declare_table.{mrs,rs}
|
||||
│ └── component.{mrs,rs}
|
||||
└── misses-rt/ # Runtime helpers for generated proc macros
|
||||
└── src/
|
||||
├── lib.rs # Buffer, Markup, name/type helpers
|
||||
├── parse_helpers.rs
|
||||
└── template.rs # HTML template AST + code-gen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How code generation works
|
||||
|
||||
For each `.mrs` macro, the compiler generates:
|
||||
|
||||
1. A `#[proc_macro]` function that receives `proc_macro::TokenStream`.
|
||||
2. Parser statements that destructure the input according to the pattern.
|
||||
3. Any `@{}` escape-block statements, executed inline.
|
||||
4. A `quote::quote! { … }` call whose body mirrors the `T~{}` template, with spread iterators for variadic bindings.
|
||||
5. The `quote!` result is returned as `proc_macro::TokenStream`.
|
||||
|
||||
The generated file is formatted with [prettyplease](https://github.com/dtolnay/prettyplease) for readable output.
|
||||
@@ -0,0 +1,32 @@
|
||||
// component.mrs — Vue-like component macro generator
|
||||
// Demonstrates: named literal tokens, variadic binding, $body:tt capture,
|
||||
// escape block using misses_rt, expression injection
|
||||
macro component! {
|
||||
$name:ident { $($prop_name:ident : $prop_type:ty),* } $body:tt => {
|
||||
@{
|
||||
let struct_name = format_ident!("{}", name.to_string());
|
||||
}
|
||||
T~{
|
||||
pub struct $name {
|
||||
for prop in $prop_names =>
|
||||
pub $prop_name.name: $prop_name.ty,
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn new(${ struct_name }(
|
||||
for prop in $prop_names =>
|
||||
$prop_name.name: $prop_name.ty,
|
||||
)) -> Self {
|
||||
Self {
|
||||
for prop in $prop_names =>
|
||||
$prop_name.name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self) -> misses_rt::Markup {
|
||||
misses_rt::parse_template($body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
fn main() {
|
||||
let source = include_str!("component.mrs");
|
||||
println!("=== SOURCE ===\n{source}");
|
||||
|
||||
let file = misses::parse(source).expect("parse error");
|
||||
let generated = misses::codegen(&file);
|
||||
println!("=== GENERATED ===\n{generated}");
|
||||
}
|
||||
@@ -170,13 +170,20 @@ fn codegen_parser_stmts(pattern: &Pattern) -> Vec<String> {
|
||||
} else if tok == "}" {
|
||||
// already consumed by braced!
|
||||
in_brace = false;
|
||||
} else if tok.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||
stmts.push(format!(
|
||||
"_input.parse::<syn::Token![{tok}]>()?;",
|
||||
tok = tok
|
||||
));
|
||||
} else if tok == "," {
|
||||
stmts.push("_input.parse::<syn::Token![,]>()?;".to_string());
|
||||
} else if tok.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||||
if is_syn_keyword(tok) {
|
||||
stmts.push(format!(
|
||||
"_input.parse::<syn::Token![{tok}]>()?;",
|
||||
tok = tok
|
||||
));
|
||||
} else {
|
||||
stmts.push(format!(
|
||||
r#"{{ let __lit: syn::Ident = _input.parse()?; if __lit != "{tok}" {{ return Err(syn::Error::new(__lit.span(), concat!("expected `", "{tok}", "`"))); }} }}"#,
|
||||
tok = tok
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
PatternItem::Binding { name, kind } => {
|
||||
@@ -213,6 +220,22 @@ fn codegen_parser_stmts(pattern: &Pattern) -> Vec<String> {
|
||||
stmts
|
||||
}
|
||||
|
||||
/// Returns true if `tok` is a valid operand for `syn::Token![...]`.
|
||||
/// This covers Rust keywords and built-in punctuation recognized by syn.
|
||||
fn is_syn_keyword(tok: &str) -> bool {
|
||||
matches!(
|
||||
tok,
|
||||
"as" | "async" | "await" | "break" | "const" | "continue" | "crate"
|
||||
| "dyn" | "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"
|
||||
| "union" | "unsafe" | "use" | "where" | "while" | "abstract"
|
||||
| "become" | "box" | "do" | "final" | "macro" | "override"
|
||||
| "priv" | "try" | "typeof" | "unsized" | "virtual" | "yield"
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate code for the output body
|
||||
fn codegen_output_body(body: &OutputBody) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -224,22 +224,25 @@ impl<'a> Parser<'a> {
|
||||
self.advance(1);
|
||||
items.push(self.parse_binding()?);
|
||||
} else {
|
||||
// literal token: collect until whitespace or $ or =>
|
||||
let len: usize = rest.chars()
|
||||
.take_while(|&c| !c.is_whitespace() && c != '$')
|
||||
.map(|c| c.len_utf8())
|
||||
.sum();
|
||||
// check if it starts with =>
|
||||
let tok = &rest[..len];
|
||||
if tok.is_empty() {
|
||||
break;
|
||||
let c = rest.chars().next().unwrap();
|
||||
if c.is_ascii_punctuation() {
|
||||
// Single-character punctuation token (`:`, `{`, `}`, `,`, `;`, etc.)
|
||||
// But first check that it's not the start of `=>`
|
||||
if rest.starts_with("=>") {
|
||||
break;
|
||||
}
|
||||
items.push(PatternItem::LiteralToken(c.to_string()));
|
||||
self.advance(c.len_utf8());
|
||||
} else {
|
||||
// Identifier / keyword token: collect alphanumeric + underscore
|
||||
let len: usize = rest.chars()
|
||||
.take_while(|&c| c.is_alphanumeric() || c == '_')
|
||||
.map(|c| c.len_utf8())
|
||||
.sum();
|
||||
if len == 0 { break; }
|
||||
items.push(PatternItem::LiteralToken(rest[..len].to_string()));
|
||||
self.advance(len);
|
||||
}
|
||||
// Check if this token starts with =>
|
||||
if tok.starts_with("=>") {
|
||||
break;
|
||||
}
|
||||
items.push(PatternItem::LiteralToken(tok.to_string()));
|
||||
self.advance(len);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "misses-rt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
syn = { version = "2", features = ["full"] }
|
||||
quote = "1"
|
||||
proc-macro2 = "1"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::collections::HashMap;
|
||||
use proc_macro2::TokenStream;
|
||||
|
||||
// ── SectionMap ────────────────────────────────────────────────
|
||||
|
||||
/// A map from section name → TokenStream, parsed from a token tree like:
|
||||
/// ```text
|
||||
/// fields { ... }
|
||||
/// methods { ... }
|
||||
/// ```
|
||||
pub struct SectionMap {
|
||||
sections: HashMap<String, TokenStream>,
|
||||
}
|
||||
|
||||
impl SectionMap {
|
||||
/// Get the token stream for a named section. Returns empty stream if absent.
|
||||
pub fn get(&self, name: &str) -> TokenStream {
|
||||
self.sections.get(name).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns true if the section exists (even if empty).
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.sections.contains_key(name)
|
||||
}
|
||||
|
||||
/// Iterate over all (name, tokens) pairs.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &TokenStream)> {
|
||||
self.sections.iter().map(|(k, v)| (k.as_str(), v))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a token stream as named sections: `name { ... } name { ... }`.
|
||||
///
|
||||
/// # Example
|
||||
/// Input tokens: `fields { x: u32, y: u32 } methods { fn sum(&self) -> u32 { ... } }`
|
||||
/// Result: `{ "fields" → tokens, "methods" → tokens }`
|
||||
pub fn parse_sections(tokens: TokenStream, _sections: &[&str]) -> SectionMap {
|
||||
use proc_macro2::{TokenTree, Delimiter};
|
||||
|
||||
let mut sections = HashMap::new();
|
||||
let mut iter = tokens.into_iter().peekable();
|
||||
|
||||
while let Some(tt) = iter.next() {
|
||||
// Expect an Ident (section name)
|
||||
let name = match &tt {
|
||||
TokenTree::Ident(id) => id.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
// Expect the next token to be a braced group
|
||||
match iter.peek() {
|
||||
Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Brace => {
|
||||
let group = match iter.next() {
|
||||
Some(TokenTree::Group(g)) => g,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
sections.insert(name, group.stream());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
SectionMap { sections }
|
||||
}
|
||||
|
||||
// ── KvMap ─────────────────────────────────────────────────────
|
||||
|
||||
/// A map of key = value pairs parsed from a token stream like:
|
||||
/// ```text
|
||||
/// table = "users", primary_key = id, nullable = true
|
||||
/// ```
|
||||
pub struct KvMap {
|
||||
entries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl KvMap {
|
||||
/// Get a value by key (as the string representation of the token(s)).
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.entries.get(key).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn get_str(&self, key: &str) -> Option<&str> {
|
||||
self.get(key)
|
||||
}
|
||||
|
||||
/// Returns true if the key is present.
|
||||
pub fn has(&self, key: &str) -> bool {
|
||||
self.entries.contains_key(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `key = value, key = value, ...` from a token stream.
|
||||
pub fn parse_kv(tokens: TokenStream) -> KvMap {
|
||||
use proc_macro2::TokenTree;
|
||||
|
||||
let mut entries = HashMap::new();
|
||||
let tts: Vec<TokenTree> = tokens.into_iter().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < tts.len() {
|
||||
// key: must be an ident
|
||||
let key = match &tts[i] {
|
||||
TokenTree::Ident(id) => id.to_string(),
|
||||
_ => { i += 1; continue; }
|
||||
};
|
||||
i += 1;
|
||||
|
||||
// skip whitespace (handled by tokenizer) — expect `=`
|
||||
if i >= tts.len() { break; }
|
||||
match &tts[i] {
|
||||
TokenTree::Punct(p) if p.as_char() == '=' => { i += 1; }
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
// value: collect tokens until `,` or end
|
||||
let mut value_parts: Vec<String> = Vec::new();
|
||||
while i < tts.len() {
|
||||
match &tts[i] {
|
||||
TokenTree::Punct(p) if p.as_char() == ',' => { i += 1; break; }
|
||||
tt => {
|
||||
value_parts.push(tt.to_string());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.insert(key, value_parts.join(" "));
|
||||
}
|
||||
|
||||
KvMap { entries }
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
use proc_macro2::{TokenStream, TokenTree, Delimiter};
|
||||
|
||||
// ── Public surface ────────────────────────────────────────────
|
||||
|
||||
pub struct TemplateAst {
|
||||
pub nodes: Vec<TemplateNode>,
|
||||
}
|
||||
|
||||
/// Parse a proc_macro2::TokenStream containing HTML-ish template markup.
|
||||
///
|
||||
/// Recognised constructs:
|
||||
/// - `{{ expr }}` → escaped interpolation (→ push_escaped)
|
||||
/// - `{{{ expr }}}` → raw interpolation (→ push_str of .to_string())
|
||||
/// - `<tag @if="cond">…</tag>` → conditional block
|
||||
/// - `<tag @for="x in y">…</tag>` → loop block
|
||||
/// - `<tag @else>…</tag>` → else branch
|
||||
/// - `<Component …/>` → component rendering call (PascalCase tag)
|
||||
/// - `<tag attr="val">…</tag>` → HTML element
|
||||
/// - everything else → emitted as literal text
|
||||
pub fn parse_template(tokens: TokenStream) -> TemplateAst {
|
||||
let flat = flatten(tokens);
|
||||
let (nodes, _) = parse_nodes(&flat, 0, None);
|
||||
TemplateAst { nodes }
|
||||
}
|
||||
|
||||
// ── AST types ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TemplateNode {
|
||||
Text(String),
|
||||
Escape { expr: String },
|
||||
Raw { expr: String },
|
||||
Element(Box<Element>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Element {
|
||||
pub tag: String,
|
||||
pub is_component: bool,
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub directives: Vec<Directive>,
|
||||
pub children: Vec<TemplateNode>,
|
||||
pub self_closing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Directive {
|
||||
If(String),
|
||||
For { var: String, iter: String },
|
||||
Else,
|
||||
}
|
||||
|
||||
// ── Flat token representation ─────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Tok {
|
||||
Lt,
|
||||
Gt,
|
||||
Slash,
|
||||
At,
|
||||
Eq,
|
||||
Comma,
|
||||
Ident(String),
|
||||
Lit(String), // string literal — includes the outer quotes
|
||||
BraceGroup(Vec<Tok>), // { ... }
|
||||
Other(String),
|
||||
}
|
||||
|
||||
fn flatten(ts: TokenStream) -> Vec<Tok> {
|
||||
ts.into_iter().flat_map(flatten_tt).collect()
|
||||
}
|
||||
|
||||
fn flatten_tt(tt: TokenTree) -> Vec<Tok> {
|
||||
match tt {
|
||||
TokenTree::Ident(i) => vec![Tok::Ident(i.to_string())],
|
||||
TokenTree::Literal(l) => vec![Tok::Lit(l.to_string())],
|
||||
TokenTree::Punct(p) => vec![match p.as_char() {
|
||||
'<' => Tok::Lt,
|
||||
'>' => Tok::Gt,
|
||||
'/' => Tok::Slash,
|
||||
'@' => Tok::At,
|
||||
'=' => Tok::Eq,
|
||||
',' => Tok::Comma,
|
||||
c => Tok::Other(c.to_string()),
|
||||
}],
|
||||
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
|
||||
vec![Tok::BraceGroup(flatten(g.stream()))]
|
||||
}
|
||||
TokenTree::Group(g) => {
|
||||
// Other delimiters (parens, brackets) — flatten inline with pseudo-tokens
|
||||
let (open, close) = match g.delimiter() {
|
||||
Delimiter::Parenthesis => ("(", ")"),
|
||||
Delimiter::Bracket => ("[", "]"),
|
||||
_ => ("{", "}"),
|
||||
};
|
||||
let mut v = vec![Tok::Other(open.to_string())];
|
||||
v.extend(flatten(g.stream()));
|
||||
v.push(Tok::Other(close.to_string()));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────
|
||||
|
||||
/// Returns (nodes, next_pos).
|
||||
/// `stop_at` — stop when we encounter `</stop_at>`.
|
||||
fn parse_nodes(toks: &[Tok], mut pos: usize, stop_at: Option<&str>) -> (Vec<TemplateNode>, usize) {
|
||||
let mut nodes: Vec<TemplateNode> = Vec::new();
|
||||
|
||||
while pos < toks.len() {
|
||||
// ── {{ expr }} / {{{ expr }}} interpolation ──────────
|
||||
if let Tok::BraceGroup(inner) = &toks[pos] {
|
||||
// Check for nested brace group → {{ }}
|
||||
if inner.len() == 1 {
|
||||
if let Tok::BraceGroup(inner2) = &inner[0] {
|
||||
// {{{ }}} → raw
|
||||
if inner2.len() == 1 {
|
||||
if let Tok::BraceGroup(expr_toks) = &inner2[0] {
|
||||
let expr = toks_to_str(expr_toks);
|
||||
nodes.push(TemplateNode::Raw { expr });
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// {{ }} → escaped
|
||||
let expr = toks_to_str(inner2);
|
||||
nodes.push(TemplateNode::Escape { expr });
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Other brace group → treat as text `{ ... }`
|
||||
let text = format!("{{ {} }}", toks_to_str(inner));
|
||||
push_text(&mut nodes, &text);
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── HTML tag ─────────────────────────────────────────
|
||||
if let Tok::Lt = &toks[pos] {
|
||||
// closing tag?
|
||||
if pos + 1 < toks.len() {
|
||||
if let Tok::Slash = &toks[pos + 1] {
|
||||
// </tag> — check if it matches our stop_at
|
||||
if let Some(stop) = stop_at {
|
||||
if let Some(Tok::Ident(tag)) = toks.get(pos + 2) {
|
||||
if tag == stop {
|
||||
// consume </tag>
|
||||
let end = find_gt(toks, pos + 3).unwrap_or(pos + 3);
|
||||
pos = end + 1;
|
||||
return (nodes, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unknown closing tag → emit as text
|
||||
let end = find_gt(toks, pos + 1).unwrap_or(pos + 1);
|
||||
let text = reconstruct_tag(toks, pos, end);
|
||||
push_text(&mut nodes, &text);
|
||||
pos = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Opening / self-closing tag
|
||||
if let Some((elem, next_pos)) = parse_element(toks, pos) {
|
||||
let tag_name = elem.tag.clone();
|
||||
let is_self = elem.self_closing;
|
||||
let mut elem = elem;
|
||||
|
||||
if !is_self {
|
||||
// parse children until </tag>
|
||||
let (children, after) = parse_nodes(toks, next_pos, Some(&tag_name));
|
||||
elem.children = children;
|
||||
pos = after;
|
||||
} else {
|
||||
pos = next_pos;
|
||||
}
|
||||
|
||||
nodes.push(TemplateNode::Element(Box::new(elem)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Couldn't parse as element — treat `<` as text
|
||||
push_text(&mut nodes, "<");
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Anything else → text ─────────────────────────────
|
||||
let text = tok_to_str(&toks[pos]);
|
||||
push_text(&mut nodes, &text);
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
(nodes, pos)
|
||||
}
|
||||
|
||||
/// Parse an element starting at `<`. Returns (Element, pos_after_closing_`>`).
|
||||
fn parse_element(toks: &[Tok], start: usize) -> Option<(Element, usize)> {
|
||||
debug_assert!(matches!(toks.get(start), Some(Tok::Lt)));
|
||||
let mut pos = start + 1;
|
||||
|
||||
// tag name
|
||||
let tag = match toks.get(pos) {
|
||||
Some(Tok::Ident(t)) => t.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
pos += 1;
|
||||
|
||||
let is_component = tag.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
|
||||
|
||||
// attributes + directives
|
||||
let mut attrs: Vec<(String, String)> = Vec::new();
|
||||
let mut directives: Vec<Directive> = Vec::new();
|
||||
let mut self_closing = false;
|
||||
|
||||
loop {
|
||||
match toks.get(pos) {
|
||||
None | Some(Tok::Gt) => {
|
||||
if matches!(toks.get(pos), Some(Tok::Gt)) { pos += 1; }
|
||||
break;
|
||||
}
|
||||
Some(Tok::Slash) => {
|
||||
// /> self-closing
|
||||
self_closing = true;
|
||||
if matches!(toks.get(pos + 1), Some(Tok::Gt)) { pos += 2; } else { pos += 1; }
|
||||
break;
|
||||
}
|
||||
Some(Tok::At) => {
|
||||
// directive: @name or @name="value"
|
||||
pos += 1;
|
||||
let name = match toks.get(pos) {
|
||||
Some(Tok::Ident(n)) => n.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
pos += 1;
|
||||
if matches!(toks.get(pos), Some(Tok::Eq)) {
|
||||
pos += 1;
|
||||
let val = match toks.get(pos) {
|
||||
Some(Tok::Lit(s)) => unquote(s),
|
||||
Some(other) => tok_to_str(other),
|
||||
None => String::new(),
|
||||
};
|
||||
pos += 1;
|
||||
match name.as_str() {
|
||||
"if" => directives.push(Directive::If(val)),
|
||||
"for" => {
|
||||
let (var, iter) = split_for_expr(&val);
|
||||
directives.push(Directive::For { var, iter });
|
||||
}
|
||||
_other => attrs.push((format!("@{name}"), val)),
|
||||
}
|
||||
} else {
|
||||
match name.as_str() {
|
||||
"else" => directives.push(Directive::Else),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Tok::Ident(name)) => {
|
||||
// regular attribute: name or name="value"
|
||||
let name = name.clone();
|
||||
pos += 1;
|
||||
if matches!(toks.get(pos), Some(Tok::Eq)) {
|
||||
pos += 1;
|
||||
let val = match toks.get(pos) {
|
||||
Some(Tok::Lit(s)) => unquote(s),
|
||||
Some(other) => tok_to_str(other),
|
||||
None => String::new(),
|
||||
};
|
||||
pos += 1;
|
||||
attrs.push((name, val));
|
||||
} else {
|
||||
attrs.push((name, String::new()));
|
||||
}
|
||||
}
|
||||
_ => { pos += 1; }
|
||||
}
|
||||
}
|
||||
|
||||
Some((Element { tag, is_component, attrs, directives, children: vec![], self_closing }, pos))
|
||||
}
|
||||
|
||||
// ── Codegen ───────────────────────────────────────────────────
|
||||
|
||||
impl TemplateAst {
|
||||
/// Returns the loop variable names used in `@for` directives.
|
||||
pub fn for_bindings(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
collect_for_bindings(&self.nodes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Produce the body of a `render()` function as a TokenStream:
|
||||
/// ```rust
|
||||
/// let mut __buf = misses_rt::Buffer::new();
|
||||
/// // ... push_str / push_escaped / if / for ...
|
||||
/// misses_rt::Markup::from_buffer(__buf)
|
||||
/// ```
|
||||
pub fn codegen(&self) -> TokenStream {
|
||||
let mut code = String::new();
|
||||
code.push_str("let mut __buf = misses_rt :: Buffer :: new () ;\n");
|
||||
for node in &self.nodes {
|
||||
codegen_node(&mut code, node, 0);
|
||||
}
|
||||
code.push_str("misses_rt :: Markup :: from_buffer ( __buf )\n");
|
||||
code.parse()
|
||||
.unwrap_or_else(|_| TokenStream::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_for_bindings(nodes: &[TemplateNode], out: &mut Vec<String>) {
|
||||
for node in nodes {
|
||||
if let TemplateNode::Element(e) = node {
|
||||
for d in &e.directives {
|
||||
if let Directive::For { var, .. } = d {
|
||||
out.push(var.clone());
|
||||
}
|
||||
}
|
||||
collect_for_bindings(&e.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codegen_node(code: &mut String, node: &TemplateNode, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
match node {
|
||||
TemplateNode::Text(s) => {
|
||||
if !s.trim().is_empty() {
|
||||
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
code.push_str(&format!("{indent}__buf . push_str ( \"{}\" ) ;\n", escaped));
|
||||
}
|
||||
}
|
||||
TemplateNode::Escape { expr } => {
|
||||
code.push_str(&format!(
|
||||
"{indent}__buf . push_escaped ( & ({expr}) . to_string () ) ;\n"
|
||||
));
|
||||
}
|
||||
TemplateNode::Raw { expr } => {
|
||||
code.push_str(&format!(
|
||||
"{indent}__buf . push_str ( & ({expr}) . to_string () ) ;\n"
|
||||
));
|
||||
}
|
||||
TemplateNode::Element(elem) => codegen_element(code, elem, depth),
|
||||
}
|
||||
}
|
||||
|
||||
fn codegen_element(code: &mut String, elem: &Element, depth: usize) {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
// Determine wrapping control flow from directives
|
||||
for dir in &elem.directives {
|
||||
match dir {
|
||||
Directive::If(cond) => {
|
||||
code.push_str(&format!("{indent}if {cond} {{\n"));
|
||||
}
|
||||
Directive::For { var, iter } => {
|
||||
code.push_str(&format!("{indent}for {var} in {iter} {{\n"));
|
||||
}
|
||||
Directive::Else => {
|
||||
code.push_str(&format!("{indent}}} else {{\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inner_depth = if elem.directives.is_empty() { depth } else { depth + 1 };
|
||||
let inner_indent = " ".repeat(inner_depth);
|
||||
|
||||
if elem.is_component {
|
||||
// Component invocation
|
||||
let tag = &elem.tag;
|
||||
let mut prop_args = String::new();
|
||||
for (k, v) in &elem.attrs {
|
||||
if !k.starts_with('@') {
|
||||
prop_args.push_str(&format!("{k} : ({v}) . into () , "));
|
||||
}
|
||||
}
|
||||
code.push_str(&format!(
|
||||
"{inner_indent}__buf . push_str ( & {tag} :: new ( {prop_args}) . render () . into_string () ) ;\n"
|
||||
));
|
||||
} else {
|
||||
// HTML element: open tag
|
||||
let open_tag = build_open_tag(elem);
|
||||
let escaped = open_tag.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
code.push_str(&format!("{inner_indent}__buf . push_str ( \"{escaped}\" ) ;\n"));
|
||||
|
||||
// Children
|
||||
for child in &elem.children {
|
||||
codegen_node(code, child, inner_depth);
|
||||
}
|
||||
|
||||
// Close tag
|
||||
if !elem.self_closing {
|
||||
let close = format!("</{}>", elem.tag);
|
||||
code.push_str(&format!("{inner_indent}__buf . push_str ( \"{}\" ) ;\n", close));
|
||||
}
|
||||
}
|
||||
|
||||
if !elem.directives.is_empty() {
|
||||
code.push_str(&format!("{indent}}}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_open_tag(elem: &Element) -> String {
|
||||
let mut s = format!("<{}", elem.tag);
|
||||
for (name, val) in &elem.attrs {
|
||||
if name.starts_with('@') { continue; }
|
||||
if val.is_empty() {
|
||||
s.push_str(&format!(" {name}"));
|
||||
} else {
|
||||
s.push_str(&format!(" {name}=\"{val}\""));
|
||||
}
|
||||
}
|
||||
if elem.self_closing {
|
||||
s.push_str(" />");
|
||||
} else {
|
||||
s.push('>');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
fn toks_to_str(toks: &[Tok]) -> String {
|
||||
toks.iter().map(tok_to_str).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn tok_to_str(tok: &Tok) -> String {
|
||||
match tok {
|
||||
Tok::Lt => "<".into(),
|
||||
Tok::Gt => ">".into(),
|
||||
Tok::Slash => "/".into(),
|
||||
Tok::At => "@".into(),
|
||||
Tok::Eq => "=".into(),
|
||||
Tok::Comma => ",".into(),
|
||||
Tok::Ident(s) => s.clone(),
|
||||
Tok::Lit(s) => s.clone(),
|
||||
Tok::BraceGroup(inner) => format!("{{ {} }}", toks_to_str(inner)),
|
||||
Tok::Other(s) => s.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove surrounding `"..."` from a string literal token.
|
||||
fn unquote(s: &str) -> String {
|
||||
if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
|
||||
s[1..s.len()-1].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `"item in &self.items"` → ("item", "&self.items")
|
||||
fn split_for_expr(s: &str) -> (String, String) {
|
||||
if let Some(idx) = s.find(" in ") {
|
||||
let var = s[..idx].trim().to_string();
|
||||
let iter = s[idx + 4..].trim().to_string();
|
||||
(var, iter)
|
||||
} else {
|
||||
(s.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_gt(toks: &[Tok], from: usize) -> Option<usize> {
|
||||
for i in from..toks.len() {
|
||||
if matches!(toks[i], Tok::Gt) { return Some(i); }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reconstruct_tag(toks: &[Tok], start: usize, end: usize) -> String {
|
||||
toks[start..=end].iter().map(tok_to_str).collect::<Vec<_>>().join("")
|
||||
}
|
||||
|
||||
fn push_text(nodes: &mut Vec<TemplateNode>, text: &str) {
|
||||
// Normalise whitespace runs to a single space when appending to existing Text
|
||||
let text = text.replace('\n', " ").replace('\r', "");
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
match nodes.last_mut() {
|
||||
Some(TemplateNode::Text(prev)) => prev.push_str(&text),
|
||||
_ => nodes.push(TemplateNode::Text(text)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user