diff --git a/CHANGELOG.md b/CHANGELOG.md index c6458fe8..7ef26407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- Switch to `proc_macro_error2` + [#441](https://github.com/lambda-fairy/maud/issues/441) + [#442](https://github.com/lambda-fairy/maud/pull/442) + ## [0.26.0] - 2024-01-15 - Remove `AsRef` restriction from `PreEscaped` diff --git a/docs/Cargo.lock b/docs/Cargo.lock index 1f7fce2c..5766e7a9 100644 --- a/docs/Cargo.lock +++ b/docs/Cargo.lock @@ -132,7 +132,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.75", ] [[package]] @@ -143,7 +143,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.75", ] [[package]] @@ -173,7 +173,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.75", ] [[package]] @@ -183,7 +183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.75", ] [[package]] @@ -292,16 +292,28 @@ version = "0.26.0" dependencies = [ "itoa", "maud_macros", + "maud_macros_impl", ] [[package]] name = "maud_macros" version = "0.26.0" dependencies = [ - "proc-macro-error", + "maud_macros_impl", + "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.75", +] + +[[package]] +name = "maud_macros_impl" +version = "0.26.0" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -379,26 +391,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "proc-macro-error-attr", "proc-macro2", "quote", - "version_check", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ + "proc-macro-error-attr2", "proc-macro2", "quote", - "version_check", + "syn 2.0.75", ] [[package]] @@ -489,7 +500,7 @@ checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.75", ] [[package]] @@ -526,6 +537,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.75" @@ -577,7 +599,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.75", ] [[package]] @@ -653,12 +675,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" @@ -691,7 +707,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.75", "wasm-bindgen-shared", ] @@ -713,7 +729,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.75", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/maud/Cargo.toml b/maud/Cargo.toml index 419f95bf..0de523f8 100644 --- a/maud/Cargo.toml +++ b/maud/Cargo.toml @@ -14,6 +14,7 @@ edition.workspace = true [features] default = [] +hotreload = ["maud_macros_impl/hotreload", "maud_macros/hotreload"] # Web framework integrations actix-web = ["actix-web-dep", "futures-util"] @@ -21,6 +22,7 @@ axum = ["axum-core", "http"] [dependencies] maud_macros = { version = "0.26.0", path = "../maud_macros" } +maud_macros_impl = { version = "0.26.0", path = "../maud_macros_impl" } itoa = "1" rocket = { version = "0.5", optional = true } futures-util = { version = "0.3.0", optional = true, default-features = false } diff --git a/maud/src/escape.rs b/maud/src/escape.rs deleted file mode 100644 index 94cdeec1..00000000 --- a/maud/src/escape.rs +++ /dev/null @@ -1,34 +0,0 @@ -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -// !!!!! PLEASE KEEP THIS IN SYNC WITH `maud_macros/src/escape.rs` !!!!! -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -extern crate alloc; - -use alloc::string::String; - -pub fn escape_to_string(input: &str, output: &mut String) { - for b in input.bytes() { - match b { - b'&' => output.push_str("&"), - b'<' => output.push_str("<"), - b'>' => output.push_str(">"), - b'"' => output.push_str("""), - _ => unsafe { output.as_mut_vec().push(b) }, - } - } -} - -#[cfg(test)] -mod test { - extern crate alloc; - - use super::escape_to_string; - use alloc::string::String; - - #[test] - fn it_works() { - let mut s = String::new(); - escape_to_string("", &mut s); - assert_eq!(s, "<script>launchMissiles()</script>"); - } -} diff --git a/maud/src/lib.rs b/maud/src/lib.rs index 7647379c..6cc3a2ec 100644 --- a/maud/src/lib.rs +++ b/maud/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![cfg_attr(not(feature = "hotreload"), no_std)] //! A macro for writing HTML templates. //! @@ -14,9 +14,13 @@ extern crate alloc; use alloc::{borrow::Cow, boxed::Box, string::String, sync::Arc}; use core::fmt::{self, Arguments, Display, Write}; +pub use maud_macros::html as html_static; + +#[cfg(not(feature = "hotreload"))] pub use maud_macros::html; -mod escape; +#[cfg(feature = "hotreload")] +pub use {maud_macros::html_hotreload, maud_macros::html_hotreload as html}; /// An adapter that escapes HTML special characters. /// @@ -52,7 +56,7 @@ impl<'a> Escaper<'a> { impl fmt::Write for Escaper<'_> { fn write_str(&mut self, s: &str) -> fmt::Result { - escape::escape_to_string(s, self.0); + maud_macros_impl::escape_to_string(s, self.0); Ok(()) } } @@ -110,7 +114,7 @@ pub trait Render { impl Render for str { fn render_to(&self, w: &mut String) { - escape::escape_to_string(self, w); + maud_macros_impl::escape_to_string(self, w); } } @@ -415,8 +419,12 @@ mod submillisecond_support { #[doc(hidden)] pub mod macro_private { use crate::{display, Render}; - use alloc::string::String; + pub use alloc::{boxed::Box, string::String, vec::Vec}; use core::fmt::Display; + #[cfg(feature = "hotreload")] + pub use std::collections::HashMap; + #[cfg(feature = "hotreload")] + pub use std::env::var as env_var; #[doc(hidden)] #[macro_export] @@ -461,4 +469,23 @@ pub mod macro_private { display(value).render_to(buffer); } } + + #[cfg(feature = "hotreload")] + pub use maud_macros_impl::*; + + #[cfg(feature = "hotreload")] + pub fn render_runtime_error(e: &str) -> crate::Markup { + eprintln!("{}", e); + + match env_var("MAUD_ON_ERROR").as_deref() { + Ok("panic") => panic!("{}", e), + Ok("exit") => { + eprintln!("{}", e); + ::std::process::exit(2); + } + _ => {} + } + + crate::PreEscaped(alloc::format!("\"> -->

Template Errors:

{}
", e)) + } } diff --git a/maud/tests/hotreload.rs b/maud/tests/hotreload.rs new file mode 100644 index 00000000..6f48b31d --- /dev/null +++ b/maud/tests/hotreload.rs @@ -0,0 +1,63 @@ +//! track regressions specific to the hotreload feature in maud +use maud::{html, Markup}; +use maud_macros_impl::gather_html_macro_invocations; + +#[test] +fn regression_match_inline_tag() { + fn render(x: Option) -> Markup { + html! { + div id="main" { + @match x { + Some(42) => div.green { + "yes! fourty! two!" + }, + Some(_) => div.yellow { + "it's a number?" + }, + None => div.red { + "okay." + }, + } + } + } + } + + assert_eq!( + render(Some(42)).into_string(), + r#"
yes! fourty! two!
"# + ); + assert_eq!( + render(Some(420)).into_string(), + r#"
it's a number?
"# + ); + assert_eq!( + render(None).into_string(), + r#"
okay.
"# + ); +} + +#[test] +fn regression_basic() { + let result = html! { + "hello world" + }; + + assert_eq!(result.into_string(), "hello world"); +} + +#[test] +fn test_gather_html_macro_invocations() { + let file = file!(); + let line = line!(); + + let _foo = maud::html! { + "Hello world" + }; + + assert_eq!( + gather_html_macro_invocations(file, line) + .unwrap() + .to_string(), + "\"Hello world\"" + ); +} diff --git a/maud/tests/warnings/keyword-without-at.stderr b/maud/tests/warnings/keyword-without-at.stderr index 86918fad..9ff4d4be 100644 --- a/maud/tests/warnings/keyword-without-at.stderr +++ b/maud/tests/warnings/keyword-without-at.stderr @@ -1,7 +1,8 @@ error: found keyword `if` + + = help: should this be a `@if`? + --> $DIR/keyword-without-at.rs:5:9 | 5 | if {} | ^^ - | - = help: should this be a `@if`? diff --git a/maud/tests/warnings/non-string-literal.stderr b/maud/tests/warnings/non-string-literal.stderr index d0a128b2..527304a6 100644 --- a/maud/tests/warnings/non-string-literal.stderr +++ b/maud/tests/warnings/non-string-literal.stderr @@ -35,19 +35,21 @@ error: expected string | ^^^^ error: attribute value must be a string + + = help: to declare an empty attribute, omit the equals sign: `disabled` + = help: to toggle the attribute, use square brackets: `disabled[some_boolean_flag]` + --> tests/warnings/non-string-literal.rs:13:24 | 13 | input disabled=true; | ^^^^ - | - = help: to declare an empty attribute, omit the equals sign: `disabled` - = help: to toggle the attribute, use square brackets: `disabled[some_boolean_flag]` error: attribute value must be a string + + = help: to declare an empty attribute, omit the equals sign: `disabled` + = help: to toggle the attribute, use square brackets: `disabled[some_boolean_flag]` + --> tests/warnings/non-string-literal.rs:14:24 | 14 | input disabled=false; | ^^^^^ - | - = help: to declare an empty attribute, omit the equals sign: `disabled` - = help: to toggle the attribute, use square brackets: `disabled[some_boolean_flag]` diff --git a/maud/tests/warnings/void-element-slash.stderr b/maud/tests/warnings/void-element-slash.stderr index eb0f6d38..9ffedf86 100644 --- a/maud/tests/warnings/void-element-slash.stderr +++ b/maud/tests/warnings/void-element-slash.stderr @@ -1,17 +1,19 @@ error: void elements must use `;`, not `/` + + = help: change this to `;` + = help: see https://github.com/lambda-fairy/maud/pull/315 for details + --> $DIR/void-element-slash.rs:5:12 | 5 | br / | ^ - | - = help: change this to `;` - = help: see https://github.com/lambda-fairy/maud/pull/315 for details error: void elements must use `;`, not `/` + + = help: change this to `;` + = help: see https://github.com/lambda-fairy/maud/pull/315 for details + --> $DIR/void-element-slash.rs:7:27 | 7 | input type="text" / | ^ - | - = help: change this to `;` - = help: see https://github.com/lambda-fairy/maud/pull/315 for details diff --git a/maud_macros/Cargo.toml b/maud_macros/Cargo.toml index 1a938be0..c18d9038 100644 --- a/maud_macros/Cargo.toml +++ b/maud_macros/Cargo.toml @@ -11,11 +11,15 @@ homepage.workspace = true repository.workspace = true edition.workspace = true +[features] +hotreload = ["maud_macros_impl/hotreload"] + [dependencies] +maud_macros_impl = { path = "../maud_macros_impl" } syn = "2" quote = "1.0.7" proc-macro2 = "1.0.23" -proc-macro-error = { version = "1.0.0", default-features = false } +proc-macro-error2 = { version = "2.0.1", default-features = false } [lib] name = "maud_macros" diff --git a/maud_macros/src/lib.rs b/maud_macros/src/lib.rs index f52e27e4..12eb3c4d 100644 --- a/maud_macros/src/lib.rs +++ b/maud_macros/src/lib.rs @@ -5,33 +5,17 @@ extern crate proc_macro; -mod ast; -mod escape; -mod generate; -mod parse; - -use proc_macro2::{Ident, Span, TokenStream, TokenTree}; -use proc_macro_error::proc_macro_error; -use quote::quote; +use proc_macro_error2::proc_macro_error; #[proc_macro] #[proc_macro_error] pub fn html(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - expand(input.into()).into() + maud_macros_impl::expand(input.into()).into() } -fn expand(input: TokenStream) -> TokenStream { - let output_ident = TokenTree::Ident(Ident::new("__maud_output", Span::mixed_site())); - // Heuristic: the size of the resulting markup tends to correlate with the - // code size of the template itself - let size_hint = input.to_string().len(); - let markups = parse::parse(input); - let stmts = generate::generate(markups, output_ident.clone()); - quote!({ - extern crate alloc; - extern crate maud; - let mut #output_ident = alloc::string::String::with_capacity(#size_hint); - #stmts - maud::PreEscaped(#output_ident) - }) +#[cfg(feature = "hotreload")] +#[proc_macro] +#[proc_macro_error] +pub fn html_hotreload(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + maud_macros_impl::expand_runtime(input.into()).into() } diff --git a/maud_macros_impl/Cargo.toml b/maud_macros_impl/Cargo.toml new file mode 100644 index 00000000..5b67db4f --- /dev/null +++ b/maud_macros_impl/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "maud_macros_impl" +# When releasing a new version, please update html_root_url in src/lib.rs +version = "0.26.0" +authors = ["Chris Wong "] +license = "MIT/Apache-2.0" +documentation = "https://docs.rs/maud_macros/" +homepage = "https://maud.lambda.xyz/" +repository = "https://github.com/lambda-fairy/maud" +description = "Compile-time HTML templates." +edition = "2021" + +[features] +hotreload = [] + +[dependencies] +syn = "1.0.8" +quote = "1.0.7" +proc-macro2 = "1.0.23" +proc-macro-error2 = "2.0.1" + +[lib] +name = "maud_macros_impl" diff --git a/maud_macros/src/ast.rs b/maud_macros_impl/src/ast.rs similarity index 96% rename from maud_macros/src/ast.rs rename to maud_macros_impl/src/ast.rs index b95665ec..e7cbe306 100644 --- a/maud_macros/src/ast.rs +++ b/maud_macros_impl/src/ast.rs @@ -1,5 +1,5 @@ use proc_macro2::{TokenStream, TokenTree}; -use proc_macro_error::SpanRange; +use proc_macro_error2::SpanRange; use syn::Lit; #[derive(Debug)] @@ -58,7 +58,7 @@ impl Markup { at_span, ref tokens, } => at_span.join_range(span_tokens(tokens.clone())), - Markup::Special { ref segments } => join_ranges(segments.iter().map(Special::span)), + Markup::Special { ref segments, .. } => join_ranges(segments.iter().map(Special::span)), Markup::Match { at_span, arms_span, .. } => at_span.join_range(arms_span), @@ -129,6 +129,7 @@ impl ElementBody { pub struct Block { pub markups: Vec, pub outer_span: SpanRange, + pub raw_body: Option, } impl Block { diff --git a/maud_macros/src/escape.rs b/maud_macros_impl/src/escape.rs similarity index 74% rename from maud_macros/src/escape.rs rename to maud_macros_impl/src/escape.rs index 786d8c77..6bc17f3a 100644 --- a/maud_macros/src/escape.rs +++ b/maud_macros_impl/src/escape.rs @@ -1,6 +1,6 @@ -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -// !!!!!!!! PLEASE KEEP THIS IN SYNC WITH `maud/src/escape.rs` !!!!!!!!! -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +extern crate alloc; + +use alloc::string::String; pub fn escape_to_string(input: &str, output: &mut String) { for b in input.bytes() { diff --git a/maud_macros/src/generate.rs b/maud_macros_impl/src/generate.rs similarity index 97% rename from maud_macros/src/generate.rs rename to maud_macros_impl/src/generate.rs index ee27a55d..225e4c2f 100644 --- a/maud_macros/src/generate.rs +++ b/maud_macros_impl/src/generate.rs @@ -1,5 +1,5 @@ use proc_macro2::{Delimiter, Group, Ident, Literal, Span, TokenStream, TokenTree}; -use proc_macro_error::SpanRange; +use proc_macro_error2::SpanRange; use quote::quote; use crate::{ast::*, escape}; @@ -35,6 +35,7 @@ impl Generator { Markup::Block(Block { markups, outer_span, + raw_body, }) => { if markups .iter() @@ -44,6 +45,7 @@ impl Generator { Block { markups, outer_span, + raw_body, }, build, ); @@ -56,7 +58,7 @@ impl Generator { Markup::Splice { expr, .. } => self.splice(expr, build), Markup::Element { name, attrs, body } => self.element(name, attrs, body, build), Markup::Let { tokens, .. } => build.push_tokens(tokens), - Markup::Special { segments } => { + Markup::Special { segments, .. } => { for Special { head, body, .. } in segments { build.push_tokens(head); self.block(body, build); @@ -88,6 +90,7 @@ impl Generator { Block { markups, outer_span, + .. }: Block, build: &mut Builder, ) { @@ -170,7 +173,7 @@ impl Generator { //////////////////////////////////////////////////////// -fn desugar_attrs(attrs: Vec) -> Vec { +pub fn desugar_attrs(attrs: Vec) -> Vec { let mut classes_static = vec![]; let mut classes_toggled = vec![]; let mut ids = vec![]; @@ -214,6 +217,7 @@ fn desugar_classes_or_ids( markups: prepend_leading_space(name, &mut leading_space), // TODO: is this correct? outer_span: cond_span, + raw_body: None, }; markups.push(Markup::Special { segments: vec![Special { @@ -229,6 +233,7 @@ fn desugar_classes_or_ids( value: Markup::Block(Block { markups, outer_span: SpanRange::call_site(), + raw_body: None, }), }, }) diff --git a/maud_macros_impl/src/lib.rs b/maud_macros_impl/src/lib.rs new file mode 100644 index 00000000..301f6ce6 --- /dev/null +++ b/maud_macros_impl/src/lib.rs @@ -0,0 +1,251 @@ +//! Helper crate for `maud` and `maud_macros`. Nothing in this crate is semver-compliant, and is +//! not meant for direct consumption. +#![doc(html_root_url = "https://docs.rs/maud_macros_impl/0.25.0")] +// TokenStream values are reference counted, and the mental overhead of tracking +// lifetimes outweighs the marginal gains from explicit borrowing +#![allow(clippy::needless_pass_by_value)] + +extern crate alloc; +use alloc::string::String; + +mod ast; +mod escape; +mod generate; +mod parse; +#[cfg(feature = "hotreload")] +mod runtime; + +use std::{ + fs::File, + io::{BufRead, BufReader}, + path::Path, +}; + +use proc_macro2::{Ident, Span, TokenStream, TokenTree}; +use quote::quote; + +use crate::ast::Markup; + +#[cfg(feature = "hotreload")] +use {crate::parse::parse_at_runtime, proc_macro2::Literal, std::collections::HashMap}; + +#[cfg(feature = "hotreload")] +pub use crate::runtime::PartialTemplate; + +pub use crate::escape::escape_to_string; + +pub fn expand(input: TokenStream) -> TokenStream { + // Heuristic: the size of the resulting markup tends to correlate with the + // code size of the template itself + let size_hint = input.to_string().len(); + let markups = parse::parse(input.clone()); + + expand_from_parsed(markups, size_hint) +} + +fn expand_from_parsed(markups: Vec, size_hint: usize) -> TokenStream { + let output_ident = TokenTree::Ident(Ident::new("__maud_output", Span::mixed_site())); + let stmts = generate::generate(markups, output_ident.clone()); + quote!({ + extern crate maud; + let mut #output_ident = ::maud::macro_private::String::with_capacity(#size_hint); + #stmts + ::maud::PreEscaped(#output_ident) + }) +} + +// For the hot-reloadable version, maud will instead embed a tiny runtime +// that will render any markup-only changes. Any other changes will +// require a recompile. Of course, this is miles slower than the +// normal version, but it can be miles faster to iterate on. +#[cfg(feature = "hotreload")] +pub fn expand_runtime(input: TokenStream) -> TokenStream { + let input_string = input.to_string(); + let markups = parse::parse(input.clone()); + let partial_template = expand_runtime_from_parsed(markups); + let original_input = TokenTree::Literal(Literal::string(&input_string)); + + quote! {{ + extern crate maud; + + let __maud_file_info = ::std::file!(); + let __maud_line_info = ::std::line!(); + + let __maud_input = ::maud::macro_private::gather_html_macro_invocations( + __maud_file_info, + __maud_line_info, + ); + + let __maud_input = match __maud_input { + Ok(x) => x, + Err(e) => { + if ::maud::macro_private::env_var("MAUD_SOURCE_NO_FALLBACK").as_deref() == Ok("1") { + panic!("failed to find sourcecode for {}:{}, error: {:?}", __maud_file_info, __maud_line_info, e); + } + + // fall back to original, unedited input when finding file info fails + #original_input.parse().unwrap() + } + }; + + match #partial_template(::maud::macro_private::Vec::from([Some(__maud_input.clone())])) { + Ok(x) => ::maud::PreEscaped(x), + Err(e) => ::maud::macro_private::render_runtime_error(&e), + } + }} +} + +#[cfg(feature = "hotreload")] +fn expand_runtime_from_parsed(markups: Vec) -> TokenStream { + let vars_ident = TokenTree::Ident(Ident::new("__maud_vars", Span::mixed_site())); + let stmts = runtime::generate(Some(vars_ident.clone()), markups); + + quote!({ + let mut #vars_ident: ::maud::macro_private::HashMap<&'static str, ::maud::macro_private::PartialTemplate> = ::std::default::Default::default(); + + #stmts + + let f : ::maud::macro_private::PartialTemplate = ::maud::macro_private::Box::new(move |mut sources| { + assert!(sources.len() == 1); + let input = sources.pop().unwrap().unwrap(); + ::maud::macro_private::expand_runtime_main( + #vars_ident, + input, + ) + }); + + f + }) +} + +#[cfg(feature = "hotreload")] +pub fn expand_runtime_main( + vars: HashMap<&'static str, PartialTemplate>, + input: TokenStream, +) -> Result { + let res = ::std::panic::catch_unwind(|| parse_at_runtime(input.clone())); + + if let Err(e) = res { + if let Some(s) = e + // Try to convert it to a String, then turn that into a str + .downcast_ref::() + .map(String::as_str) + // If that fails, try to turn it into a &'static str + .or_else(|| { + e.downcast_ref::<&'static str>() + .map(::std::ops::Deref::deref) + }) + { + Err(format!("{}, source: {}", s, input)) + } else { + Err("unknown panic".to_owned()) + } + } else { + let markups = res.unwrap(); + let interpreter = runtime::build_interpreter(markups); + interpreter.run(vars) + } +} + +/// Grabs the inside of an html! {} invocation and returns it as a string +pub fn gather_html_macro_invocations( + file_path: &str, + start_line: u32, +) -> Result { + let mut errors = String::new(); + let mut file = None; + + for path in [ + // try a few paths to deal with workspaces. insta has a more sophisticated, complete + // version of this + Path::new(file_path).to_owned(), + Path::new("../").join(file_path), + ] { + let path = std::path::absolute(path).unwrap(); + match File::open(&path) { + Ok(f) => { + file = Some(f); + break; + } + Err(e) => { + errors.push_str(&e.to_string()); + errors.push('\n'); + } + } + } + + let file = match file { + Some(x) => x, + None => return Err(errors), + }; + + let buf_reader = BufReader::new(file); + + let mut output = String::new(); + + let mut lines_iter = buf_reader + .lines() + .skip(start_line as usize - 1) + .map(|line| line.unwrap()); + + let mut rest_of_line = String::new(); + let mut braces_diff = 0; + + fn track_braces(c: char) -> i32 { + match c { + '[' | '{' | '(' => 1, + ']' | '}' | ')' => -1, + _ => 0, + } + } + + // scan for beginning of the macro. start_line may point to it directly, but we want to + // handle code flowing slightly downward. + for line in &mut lines_iter { + for c in line.chars() { + braces_diff += track_braces(c); + if braces_diff < 0 { + return Err("too many closing braces".to_owned()); + } + } + + if let Some((_, after)) = line.split_once("html!") { + let after = if let Some((_, after2)) = after.split_once(['[', '{', '(']) { + after2 + } else { + after + }; + + rest_of_line.push_str(after); + break; + } + } + + braces_diff = 0; + + 'linewise: for line in Some(rest_of_line).into_iter().chain(lines_iter) { + for c in line.chars() { + braces_diff += track_braces(c); + if braces_diff == -1 { + break 'linewise; + } + output.push(c); + } + output.push('\n'); + } + + let output = output.trim(); + + if output.is_empty() { + return Err("output is empty".to_string()); + } + + if output.starts_with("///") { + // line/file information in doctests is 100% wrong and will lead to catastrophic results. + return Err("cannot handle livereload in doctests".to_string()); + } + + output + .parse() + .map_err(|e| format!("failed to parse output: {}", e)) +} diff --git a/maud_macros/src/parse.rs b/maud_macros_impl/src/parse.rs similarity index 74% rename from maud_macros/src/parse.rs rename to maud_macros_impl/src/parse.rs index 05af2894..51951401 100644 --- a/maud_macros/src/parse.rs +++ b/maud_macros_impl/src/parse.rs @@ -1,5 +1,5 @@ use proc_macro2::{Delimiter, Ident, Literal, Spacing, Span, TokenStream, TokenTree}; -use proc_macro_error::{abort, abort_call_site, emit_error, SpanRange}; +use proc_macro_error2::{abort, abort_call_site, emit_error, SpanRange}; use std::collections::HashMap; use syn::Lit; @@ -10,11 +10,18 @@ pub fn parse(input: TokenStream) -> Vec { Parser::new(input).markups() } +#[cfg(feature = "hotreload")] +pub fn parse_at_runtime(input: TokenStream) -> Vec { + Parser::new_at_runtime(input).markups() +} + #[derive(Clone)] struct Parser { /// If we're inside an attribute, then this contains the attribute name. current_attr: Option, input: ::IntoIter, + /// Whether this parsing is happening at runtime. Should only be used to control error reporting. + is_runtime: bool, } impl Iterator for Parser { @@ -30,6 +37,16 @@ impl Parser { Parser { current_attr: None, input: input.into_iter(), + is_runtime: false, + } + } + + #[cfg(feature = "hotreload")] + fn new_at_runtime(input: TokenStream) -> Parser { + Parser { + current_attr: None, + input: input.into_iter(), + is_runtime: true, } } @@ -37,6 +54,7 @@ impl Parser { Parser { current_attr: self.current_attr.clone(), input: input.into_iter(), + is_runtime: self.is_runtime, } } @@ -113,23 +131,35 @@ impl Parser { "for" => self.for_expr(at_span, keyword), "match" => self.match_expr(at_span, keyword), "let" => { - let span = SpanRange { - first: at_span, - last: ident.span(), - }; - abort!(span, "`@let` only works inside a block"); + if self.is_runtime { + panic!("`@let` only works inside a block"); + } else { + let span = SpanRange { + first: at_span, + last: ident.span(), + }; + abort!(span, "`@let` only works inside a block"); + } } other => { - let span = SpanRange { - first: at_span, - last: ident.span(), - }; - abort!(span, "unknown keyword `@{}`", other); + if self.is_runtime { + panic!("unknown keyword `@{}`", other); + } else { + let span = SpanRange { + first: at_span, + last: ident.span(), + }; + abort!(span, "unknown keyword `@{}`", other); + } } } } _ => { - abort!(at_span, "expected keyword after `@`"); + if self.is_runtime { + panic!("expected keyword after `@`"); + } else { + abort!(at_span, "expected keyword after `@`"); + } } } } @@ -138,11 +168,15 @@ impl Parser { let ident_string = ident.to_string(); match ident_string.as_str() { "if" | "while" | "for" | "match" | "let" => { - abort!( - ident, - "found keyword `{}`", ident_string; - help = "should this be a `@{}`?", ident_string - ); + if self.is_runtime { + panic!("found keyword `{}`, should this be a @...?", ident_string); + } else { + abort!( + ident, + "found keyword `{}`", ident_string; + help = "should this be a `@{}`?", ident_string + ); + } } "true" | "false" => { if let Some(attr_name) = &self.current_attr { @@ -187,7 +221,11 @@ impl Parser { } // ??? token => { - abort!(token, "invalid syntax"); + if self.is_runtime { + panic!("invalid syntax: {}", token); + } else { + abort!(token, "invalid syntax"); + } } }; markup @@ -214,17 +252,29 @@ impl Parser { }; } Lit::Int(..) | Lit::Float(..) => { - emit_error!(literal, r#"literal must be double-quoted: `"{}"`"#, literal); + if self.is_runtime { + panic!(r#"literal must be double-quoted: `"{}"`"#, literal); + } else { + emit_error!(literal, r#"literal must be double-quoted: `"{}"`"#, literal); + } } Lit::Char(lit_char) => { - emit_error!( - literal, - r#"literal must be double-quoted: `"{}"`"#, - lit_char.value(), - ); + if self.is_runtime { + panic!(r#"literal must be double-quoted: `"{}"`"#, lit_char.value(),); + } else { + emit_error!( + literal, + r#"literal must be double-quoted: `"{}"`"#, + lit_char.value(), + ); + } } _ => { - emit_error!(literal, "expected string"); + if self.is_runtime { + panic!("expected string"); + } else { + emit_error!(literal, "expected string"); + } } } ast::Markup::ParseError { @@ -246,7 +296,11 @@ impl Parser { None => { let mut span = ast::span_tokens(head); span.first = at_span; - abort!(span, "expected body for this `@if`"); + if self.is_runtime { + panic!("expected body for this `@if`"); + } else { + abort!(span, "expected body for this `@if`"); + } } } }; @@ -290,11 +344,15 @@ impl Parser { }); } _ => { - let span = SpanRange { - first: at_span, - last: else_keyword.span(), - }; - abort!(span, "expected body for this `@else`"); + if self.is_runtime { + panic!("expected body for this `@else`"); + } else { + let span = SpanRange { + first: at_span, + last: else_keyword.span(), + }; + abort!(span, "expected body for this `@else`"); + } } }, } @@ -317,11 +375,15 @@ impl Parser { } Some(token) => head.push(token), None => { - let span = SpanRange { - first: at_span, - last: keyword_span, - }; - abort!(span, "expected body for this `@while`"); + if self.is_runtime { + panic!("expected body for this `@while`"); + } else { + let span = SpanRange { + first: at_span, + last: keyword_span, + }; + abort!(span, "expected body for this `@while`"); + } } } }; @@ -348,11 +410,15 @@ impl Parser { } Some(token) => head.push(token), None => { - let span = SpanRange { - first: at_span, - last: keyword_span, - }; - abort!(span, "missing `in` in `@for` loop"); + if self.is_runtime { + panic!("missing `in` in `@for` loop"); + } else { + let span = SpanRange { + first: at_span, + last: keyword_span, + }; + abort!(span, "missing `in` in `@for` loop"); + } } } } @@ -363,11 +429,15 @@ impl Parser { } Some(token) => head.push(token), None => { - let span = SpanRange { - first: at_span, - last: keyword_span, - }; - abort!(span, "expected body for this `@for`"); + if self.is_runtime { + panic!("expected body for this `@for`"); + } else { + let span = SpanRange { + first: at_span, + last: keyword_span, + }; + abort!(span, "expected body for this `@for`"); + } } } }; @@ -394,11 +464,15 @@ impl Parser { } Some(token) => head.push(token), None => { - let span = SpanRange { - first: at_span, - last: keyword_span, - }; - abort!(span, "expected body for this `@match`"); + if self.is_runtime { + panic!("expected body for this `@match`"); + } else { + let span = SpanRange { + first: at_span, + last: keyword_span, + }; + abort!(span, "expected body for this `@match`"); + } } } }; @@ -439,6 +513,10 @@ impl Parser { None => { if head.is_empty() { return None; + } + + if self.is_runtime { + panic!("unexpected end of @match pattern"); } else { let head_span = ast::span_tokens(head); abort!(head_span, "unexpected end of @match pattern"); @@ -475,8 +553,12 @@ impl Parser { self.block(body.into_iter().collect(), span) } None => { - let span = ast::span_tokens(head); - abort!(span, "unexpected end of @match arm"); + if self.is_runtime { + panic!("unexpected end of @match arm"); + } else { + let span = ast::span_tokens(head); + abort!(span, "unexpected end of @match arm"); + } } }; Some(ast::MatchArm { @@ -500,9 +582,13 @@ impl Parser { _ => tokens.push(token), }, None => { - let mut span = ast::span_tokens(tokens); - span.first = at_span; - abort!(span, "unexpected end of `@let` expression"); + if self.is_runtime { + panic!("unexpected end of `@let` expression"); + } else { + let mut span = ast::span_tokens(tokens); + span.first = at_span; + abort!(span, "unexpected end of `@let` expression"); + } } } } @@ -516,13 +602,17 @@ impl Parser { _ => tokens.push(token), }, None => { - let mut span = ast::span_tokens(tokens); - span.first = at_span; - abort!( - span, - "unexpected end of `@let` expression"; - help = "are you missing a semicolon?" - ); + if self.is_runtime { + panic!("unexpected end of `@let` expression"); + } else { + let mut span = ast::span_tokens(tokens); + span.first = at_span; + abort!( + span, + "unexpected end of `@let` expression"; + help = "are you missing a semicolon?" + ); + } } } } @@ -537,8 +627,12 @@ impl Parser { /// The element name should already be consumed. fn element(&mut self, name: TokenStream) -> ast::Markup { if self.current_attr.is_some() { - let span = ast::span_tokens(name); - abort!(span, "unexpected element"); + if self.is_runtime { + panic!("unexpected element: {}", name); + } else { + let span = ast::span_tokens(name); + abort!(span, "unexpected element"); + } } let attrs = self.attrs(); let body = match self.peek() { @@ -548,29 +642,42 @@ impl Parser { // Void element self.advance(); if punct.as_char() == '/' { - emit_error!( - punct, - "void elements must use `;`, not `/`"; - help = "change this to `;`"; - help = "see https://github.com/lambda-fairy/maud/pull/315 for details"; - ); + if self.is_runtime { + panic!("void elements must use `;`, not `/`"); + } else { + emit_error!( + punct, + "void elements must use `;`, not `/`"; + help = "change this to `;`"; + help = "see https://github.com/lambda-fairy/maud/pull/315 for details"; + ); + } } ast::ElementBody::Void { semi_span: SpanRange::single_span(punct.span()), } } - Some(_) => match self.markup() { + Some(markup) => match self.markup() { ast::Markup::Block(block) => ast::ElementBody::Block { block }, - markup => { - let markup_span = markup.span(); - abort!( - markup_span, - "element body must be wrapped in braces"; - help = "see https://github.com/lambda-fairy/maud/pull/137 for details" - ); + _markup => { + if self.is_runtime { + panic!("element body must be wrapped in braces") + } else { + abort!( + markup, + "element body must be wrapped in braces"; + help = "see https://github.com/lambda-fairy/maud/pull/137 for details" + ) + } } }, - None => abort_call_site!("expected `;`, found end of macro"), + None => { + if self.is_runtime { + panic!("expected `;`, found end of macro: {}", name) + } else { + abort_call_site!("expected `;`, found end of macro") + } + } }; ast::Markup::Element { name, attrs, body } } @@ -676,9 +783,13 @@ impl Parser { for (name, spans) in attr_map { if spans.len() > 1 { - let mut spans = spans.into_iter(); - let first_span = spans.next().expect("spans should be non-empty"); - abort!(first_span, "duplicate attribute `{}`", name); + if self.is_runtime { + panic!("duplicate attribute `{}`", name); + } else { + let mut spans = spans.into_iter(); + let first_span = spans.next().expect("spans should be non-empty"); + abort!(first_span, "duplicate attribute `{}`", name); + } } } @@ -758,10 +869,11 @@ impl Parser { /// Parses the given token stream as a Maud expression. fn block(&mut self, body: TokenStream, outer_span: SpanRange) -> ast::Block { - let markups = self.with_input(body).markups(); + let markups = self.with_input(body.clone()).markups(); ast::Block { markups, outer_span, + raw_body: Some(body), } } } diff --git a/maud_macros_impl/src/runtime.rs b/maud_macros_impl/src/runtime.rs new file mode 100644 index 00000000..545d7499 --- /dev/null +++ b/maud_macros_impl/src/runtime.rs @@ -0,0 +1,382 @@ +use std::collections::HashMap; + +use proc_macro2::{Delimiter, Group, TokenStream, TokenTree}; +use quote::quote; + +use crate::{ + ast::*, escape, expand, expand_from_parsed, expand_runtime_from_parsed, generate::desugar_attrs, +}; + +pub fn generate(vars_ident: Option, markups: Vec) -> TokenStream { + let mut build = RuntimeBuilder::new(vars_ident.clone()); + RuntimeGenerator::new().markups(markups, &mut build); + build.finish() +} + +pub fn build_interpreter(markups: Vec) -> Interpreter { + let mut build = RuntimeBuilder::new(None); + RuntimeGenerator::new().markups(markups, &mut build); + build.interpreter() +} + +struct RuntimeGenerator {} + +impl RuntimeGenerator { + fn new() -> RuntimeGenerator { + RuntimeGenerator {} + } + + fn markups(&self, markups: Vec, build: &mut RuntimeBuilder) { + for markup in markups { + self.markup(markup, build); + } + } + + fn markup(&self, markup: Markup, build: &mut RuntimeBuilder) { + match markup { + Markup::ParseError { .. } => {} + Markup::Block(Block { + markups, + outer_span, + raw_body, + }) => { + if markups + .iter() + .any(|markup| matches!(*markup, Markup::Let { .. })) + { + self.block( + Block { + markups, + outer_span, + raw_body, + }, + build, + ); + } else { + self.markups(markups, build); + } + } + Markup::Literal { content, .. } => build.push_escaped(&content), + Markup::Symbol { symbol } => self.name(symbol, build), + Markup::Splice { expr, .. } => self.splice(expr, build), + Markup::Element { name, attrs, body } => self.element(name, attrs, body, build), + Markup::Let { tokens, .. } => { + // this is a bit dicey + build.tokens.extend(tokens); + } + Markup::Special { segments, .. } => self.special(segments, build), + Markup::Match { + head, + arms, + arms_span, + .. + } => { + let mut tt = TokenStream::new(); + let mut sources = Vec::new(); + for (i, MatchArm { head, body }) in arms.into_iter().enumerate() { + if let Some(ref template_source) = body.raw_body { + sources.push(Some(template_source.clone())); + } else { + sources.push(None); + } + tt.extend(head.clone()); + let partial = self.get_block(body); + tt.extend(quote! {{ + let __maud_match_partial = #partial; + Box::new(|sources| __maud_match_partial(vec![sources[#i].clone()])) + }}); + } + + let mut body = TokenTree::Group(Group::new(Delimiter::Brace, tt)); + body.set_span(arms_span.collapse()); + build.push_lazy_format_arg( + quote!(#head #body), + sources, + &format!("match_expr: {}", head), + ); + } + } + } + + fn block(&self, block: Block, build: &mut RuntimeBuilder) { + let source = block.raw_body.clone(); + build.push_lazy_format_arg(self.get_block(block), vec![source], "block"); + } + + fn get_block(&self, block: Block) -> TokenStream { + if block.raw_body.is_some() { + expand_runtime_from_parsed(block.markups) + } else { + // necessary to avoid bogus sources + let static_result = expand_from_parsed(block.markups, 0); + quote! {{ + let __maud_static_result = (#static_result); + let partial: ::maud::macro_private::PartialTemplate = Box::new(|_| Ok(__maud_static_result.into_string())); + partial + }} + } + } + + fn special(&self, segments: Vec, build: &mut RuntimeBuilder) { + let mut tt = TokenStream::new(); + let mut sources = Vec::new(); + let mut varname = String::from("special: "); + for (i, Special { head, body, .. }) in segments.into_iter().enumerate() { + if let Some(ref template_source) = body.raw_body { + varname.push_str(&normalize_source_for_hashing(head.to_string())); + varname.push('\n'); + sources.push(Some(template_source.clone())); + } else { + sources.push(None); + } + + let block = self.get_block(body); + tt.extend(quote! { + #head { + __maud_special_res.push((#i, #block)); + } + }); + } + let output = quote! {{ + extern crate maud; + let mut __maud_special_res = Vec::new(); + #tt + Box::new(move |sources| { + let mut maud_special_output = ::maud::macro_private::String::new(); + for (source_i, subpartial) in __maud_special_res { + let new_sources = ::maud::macro_private::Vec::from([sources[source_i].clone()]); + maud_special_output.push_str(&subpartial(new_sources)?); + } + + Ok(maud_special_output) + }) + }}; + + build.push_lazy_format_arg(output, sources, &varname); + } + + fn splice(&self, expr: TokenStream, build: &mut RuntimeBuilder) { + build.push_format_arg(expr, vec![None], "splice"); + } + + fn element( + &self, + name: TokenStream, + attrs: Vec, + body: ElementBody, + build: &mut RuntimeBuilder, + ) { + build.push_str("<"); + self.name(name.clone(), build); + self.attrs(attrs, build); + build.push_str(">"); + if let ElementBody::Block { block } = body { + self.markups(block.markups, build); + build.push_str(""); + } + } + + fn name(&self, name: TokenStream, build: &mut RuntimeBuilder) { + build.push_escaped(&name_to_string(name)); + } + + fn attrs(&self, attrs: Vec, build: &mut RuntimeBuilder) { + for NamedAttr { name, attr_type } in desugar_attrs(attrs) { + match attr_type { + AttrType::Normal { value } => { + build.push_str(" "); + self.name(name, build); + build.push_str("=\""); + self.markup(value, build); + build.push_str("\""); + } + AttrType::Optional { + toggler: Toggler { cond, .. }, + } => { + let inner_value = quote!(inner_value); + let name_tok = name_to_string(name); + let body = expand(quote! { + (::maud::PreEscaped(" ")) + (#name_tok) + (::maud::PreEscaped("=\"")) + (#inner_value) + (::maud::PreEscaped("\"")) + }); + + build.push_format_arg( + quote! { + if let Some(#inner_value) = (#cond) { + #body + } else { + ::maud::PreEscaped("".to_owned()) + } + }, + vec![None], + "optional_attr", + ); + } + AttrType::Empty { toggler: None } => { + build.push_str(" "); + self.name(name, build); + } + AttrType::Empty { + toggler: Some(Toggler { cond, .. }), + } => { + let name_tok = name_to_string(name); + let body = expand(quote! { + " " + (#name_tok) + }); + + build.push_format_arg( + quote! { + if (#cond) { + #body + } else { + ::maud::PreEscaped("".to_owned()) + } + }, + vec![None], + "empty_attr", + ); + } + } + } + } +} + +//////////////////////////////////////////////////////// + +struct RuntimeBuilder { + vars_ident: Option, + tokens: Vec, + commands: Vec, + arg_track: u32, +} + +impl RuntimeBuilder { + fn new(vars_ident: Option) -> RuntimeBuilder { + RuntimeBuilder { + vars_ident, + tokens: Vec::new(), + commands: Vec::new(), + arg_track: 0, + } + } + + fn push_str(&mut self, string: &str) { + self.commands.push(Command::String(string.to_owned())); + } + + fn push_escaped(&mut self, string: &str) { + let mut s = String::new(); + escape::escape_to_string(string, &mut s); + self.push_str(&s); + } + + fn push_format_arg( + &mut self, + expr: TokenStream, + template_sources: TemplateSourceContext, + named_variable: &str, + ) { + self.push_lazy_format_arg( + quote! {{ + extern crate maud; + let mut buf = ::maud::macro_private::String::new(); + ::maud::macro_private::render_to!(&(#expr), &mut buf); + ::maud::macro_private::Box::new(move |_| Ok(buf)) + }}, + template_sources, + named_variable, + ); + } + + fn push_lazy_format_arg( + &mut self, + expr: TokenStream, + template_sources: TemplateSourceContext, + named_variable: &str, + ) { + let variable_name = format!("{}_{}", self.arg_track, named_variable); + + if let Some(ref vars) = self.vars_ident { + self.tokens.extend(quote! { + #vars.insert(#variable_name, #expr); + }); + } + + self.commands.push(Command::Variable { + name: variable_name, + template_sources, + }); + + self.arg_track += 1; + } + + fn interpreter(self) -> Interpreter { + Interpreter { + commands: self.commands, + } + } + + fn finish(self) -> TokenStream { + self.tokens.into_iter().collect::() + } +} + +// /////// INTERPRETER + +pub enum Command { + String(String), + Variable { + name: String, + template_sources: TemplateSourceContext, + }, +} + +pub struct Interpreter { + pub commands: Vec, +} + +impl Interpreter { + pub fn run(self, mut variables: HashMap<&str, PartialTemplate>) -> Result { + let mut rv = String::new(); + for command in self.commands { + match command { + Command::String(s) => rv.push_str(&s), + Command::Variable { + name, + template_sources, + } => { + let s = variables.remove(name.as_str()).ok_or_else(|| { + format!( + "unknown var: {:?}\nremaining variables: {:?}", + name, + variables.keys() + ) + })?; + rv.push_str(&s(template_sources)?); + } + } + } + + Ok(rv) + } +} + +pub type TemplateSource = TokenStream; +pub type TemplateSourceContext = Vec>; + +// partial templates are generated code that take their own sourcecode for live reloading. +pub type PartialTemplate = Box Result>; + +// we add hashes of source code to our variable names to prevent the chances of mis-rendering +// something, such as when a user swaps blocks around in the template +fn normalize_source_for_hashing(mut input: String) -> String { + input.retain(|c| !c.is_ascii_whitespace()); + + input +}