Rewrite parser again
Now it's some one-pass monstrosity oh noes
This commit is contained in:
parent
4a03e09cbb
commit
905edd0ae0
5 changed files with 245 additions and 197 deletions
|
@ -1,6 +1,9 @@
|
|||
//! Super fast HTML template engine.
|
||||
|
||||
use std::io::IoResult;
|
||||
use std::fmt;
|
||||
use std::fmt::Writer as FmtWriter;
|
||||
|
||||
pub type FmtResult<T> = Result<T, fmt::Error>;
|
||||
|
||||
/// Utilities for escaping HTML5 markup.
|
||||
///
|
||||
|
@ -9,27 +12,19 @@ use std::io::IoResult;
|
|||
///
|
||||
/// [1]: http://www.w3.org/TR/html51/syntax.html#escapingString
|
||||
pub mod escape {
|
||||
use std::fmt::Writer as FmtWriter;
|
||||
|
||||
use super::render;
|
||||
use super::rt;
|
||||
|
||||
/// Escape a double-quoted attribute value, as per HTML5 rules.
|
||||
pub fn attribute(s: &str) -> String {
|
||||
render(|w| {
|
||||
for c in s.chars() {
|
||||
try!(rt::escape_attribute(c, w));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
render(|w| rt::escape_attribute(w, |w| w.write_str(s)))
|
||||
}
|
||||
|
||||
/// Escape non-attribute text content, as per HTML5 rules.
|
||||
pub fn non_attribute(s: &str) -> String {
|
||||
render(|w| {
|
||||
for c in s.chars() {
|
||||
try!(rt::escape_non_attribute(c, w));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
render(|w| rt::escape_non_attribute(w, |w| w.write_str(s)))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,33 +33,66 @@ pub mod escape {
|
|||
#[experimental = "These functions should not be called directly.
|
||||
Use the macros in `maud_macros` instead."]
|
||||
pub mod rt {
|
||||
use std::io::IoResult;
|
||||
use std::fmt::Writer as FmtWriter;
|
||||
use super::FmtResult;
|
||||
|
||||
#[inline]
|
||||
pub fn escape_attribute(c: char, w: &mut Writer) -> IoResult<()> {
|
||||
match c {
|
||||
'&' => w.write_str("&"),
|
||||
'\u{A0}' => w.write_str(" "),
|
||||
'"' => w.write_str("""),
|
||||
_ => w.write_char(c),
|
||||
struct AttrEscaper<'a, 'b: 'a> {
|
||||
inner: &'a mut (FmtWriter + 'b),
|
||||
}
|
||||
|
||||
impl<'a, 'b> FmtWriter for AttrEscaper<'a, 'b> {
|
||||
fn write_str(&mut self, s: &str) -> FmtResult<()> {
|
||||
for c in s.chars() {
|
||||
try!(match c {
|
||||
'&' => self.inner.write_str("&"),
|
||||
'\u{A0}' => self.inner.write_str(" "),
|
||||
'"' => self.inner.write_str("""),
|
||||
_ => write!(self.inner, "{}", c),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct NonAttrEscaper<'a, 'b: 'a> {
|
||||
inner: &'a mut (FmtWriter + 'b),
|
||||
}
|
||||
|
||||
impl<'a, 'b> FmtWriter for NonAttrEscaper<'a, 'b> {
|
||||
fn write_str(&mut self, s: &str) -> FmtResult<()> {
|
||||
for c in s.chars() {
|
||||
try!(match c {
|
||||
'&' => self.inner.write_str("&"),
|
||||
'\u{A0}' => self.inner.write_str(" "),
|
||||
'<' => self.inner.write_str("<"),
|
||||
'>' => self.inner.write_str(">"),
|
||||
_ => write!(self.inner, "{}", c),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn escape_non_attribute(c: char, w: &mut Writer) -> IoResult<()> {
|
||||
match c {
|
||||
'&' => w.write_str("&"),
|
||||
'\u{A0}' => w.write_str(" "),
|
||||
'<' => w.write_str("<"),
|
||||
'>' => w.write_str(">"),
|
||||
_ => w.write_char(c),
|
||||
}
|
||||
pub fn escape_attribute<F>(w: &mut FmtWriter, f: F) -> FmtResult<()> where
|
||||
F: FnOnce(&mut FmtWriter) -> FmtResult<()>
|
||||
{
|
||||
f(&mut AttrEscaper { inner: w })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn escape_non_attribute<F>(w: &mut FmtWriter, f: F) -> FmtResult<()> where
|
||||
F: FnOnce(&mut FmtWriter) -> FmtResult<()>
|
||||
{
|
||||
f(&mut NonAttrEscaper { inner: w })
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a template into a `String`.
|
||||
pub fn render<F: FnOnce(&mut Writer) -> IoResult<()>>(template: F) -> String {
|
||||
let mut buf = vec![];
|
||||
pub fn render<F>(template: F) -> String where
|
||||
F: FnOnce(&mut FmtWriter) -> FmtResult<()>
|
||||
{
|
||||
let mut buf = String::new();
|
||||
template(&mut buf).unwrap();
|
||||
String::from_utf8(buf).unwrap()
|
||||
buf
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
#![crate_type = "dylib"]
|
||||
#![feature(plugin_registrar, quote, slicing_syntax)]
|
||||
#![feature(plugin_registrar, quote)]
|
||||
#![allow(unstable)]
|
||||
|
||||
extern crate syntax;
|
||||
extern crate rustc;
|
||||
|
@ -15,10 +16,7 @@ mod render;
|
|||
|
||||
fn expand_html<'cx>(cx: &'cx mut ExtCtxt, sp: Span, args: &[TokenTree]) -> Box<MacResult + 'cx> {
|
||||
match parse::parse(cx, args) {
|
||||
Some(markups) => {
|
||||
let expr = render::render(cx, markups[]);
|
||||
MacExpr::new(expr)
|
||||
},
|
||||
Some(expr) => MacExpr::new(expr),
|
||||
None => DummyResult::any(sp),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,56 +5,34 @@ use syntax::parse::parser::Parser as RustParser;
|
|||
use syntax::parse::token;
|
||||
use syntax::ptr::P;
|
||||
|
||||
#[derive(Show)]
|
||||
pub enum Markup {
|
||||
Element(String, Vec<(String, Value)>, Option<Box<Markup>>),
|
||||
Block(Vec<Markup>),
|
||||
Value(Value),
|
||||
}
|
||||
|
||||
#[derive(Show)]
|
||||
pub struct Value {
|
||||
pub value: Value_,
|
||||
pub escape: Escape,
|
||||
}
|
||||
|
||||
#[derive(Show)]
|
||||
pub enum Value_ {
|
||||
Literal(String),
|
||||
Splice(P<Expr>),
|
||||
}
|
||||
use super::render::Renderer;
|
||||
|
||||
#[derive(Copy, PartialEq, Show)]
|
||||
pub enum Escape {
|
||||
NoEscape,
|
||||
Escape,
|
||||
None,
|
||||
Attr,
|
||||
Body,
|
||||
}
|
||||
|
||||
macro_rules! some {
|
||||
($e:expr) => (
|
||||
match $e {
|
||||
Some(x) => x,
|
||||
None => return None,
|
||||
}
|
||||
)
|
||||
macro_rules! guard {
|
||||
($e:expr) => (if !$e { return false; })
|
||||
}
|
||||
|
||||
macro_rules! any {
|
||||
($self_:expr;) => (None);
|
||||
($self_:expr; $e:expr) => (any!($self_; $e,));
|
||||
macro_rules! branch {
|
||||
($self_:expr;) => (return false);
|
||||
($self_:expr; $e:expr) => (branch!($self_; $e,));
|
||||
($self_:expr; $e:expr, $($es:expr),*) => ({
|
||||
let start_ptr = $self_.input.as_ptr();
|
||||
match $e {
|
||||
Some(x) => Some(x),
|
||||
None => {
|
||||
if $self_.input.as_ptr() == start_ptr {
|
||||
// Parsing failed, but did not consume input.
|
||||
// Keep going.
|
||||
any!($self_; $($es),*)
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
},
|
||||
if $e {
|
||||
true
|
||||
} else {
|
||||
if $self_.input.as_ptr() == start_ptr {
|
||||
// Parsing failed, but did not consume input.
|
||||
// Keep going.
|
||||
branch!($self_; $($es),*)
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -78,59 +56,72 @@ macro_rules! ident {
|
|||
($x:pat) => (TtToken(_, token::Ident($x, token::IdentStyle::Plain)))
|
||||
}
|
||||
|
||||
pub fn parse(cx: &mut ExtCtxt, input: &[TokenTree]) -> Option<Vec<Markup>> {
|
||||
Parser { cx: cx, input: input }.markups()
|
||||
pub fn parse(cx: &mut ExtCtxt, input: &[TokenTree]) -> Option<P<Expr>> {
|
||||
let mut success = true;
|
||||
let expr = Renderer::with(cx, |render| {
|
||||
let mut parser = Parser {
|
||||
in_attr: false,
|
||||
input: input,
|
||||
render: render,
|
||||
};
|
||||
success = parser.markups();
|
||||
});
|
||||
if success {
|
||||
Some(expr)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'cx, 's: 'cx, 'i> {
|
||||
cx: &'cx mut ExtCtxt<'s>,
|
||||
struct Parser<'cx: 'r, 's: 'cx, 'i, 'r, 'o: 'r> {
|
||||
in_attr: bool,
|
||||
input: &'i [TokenTree],
|
||||
render: &'r mut Renderer<'cx, 's, 'o>,
|
||||
}
|
||||
|
||||
impl<'cx, 's, 'i> Parser<'cx, 's, 'i> {
|
||||
impl<'cx: 'r, 's: 'cx, 'i, 'r, 'o: 'r> Parser<'cx, 's, 'i, 'r, 'o> {
|
||||
/// Consume `n` items from the input.
|
||||
fn shift(&mut self, n: uint) {
|
||||
fn shift(&mut self, n: usize) {
|
||||
self.input = self.input.slice_from(n);
|
||||
}
|
||||
|
||||
fn choose_escape(&self) -> Escape {
|
||||
if self.in_attr {
|
||||
Escape::Attr
|
||||
} else {
|
||||
Escape::Body
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a Rust AST parser from the given token tree.
|
||||
fn new_rust_parser(&self, tts: Vec<TokenTree>) -> RustParser<'s> {
|
||||
parse::tts_to_parser(self.cx.parse_sess, tts, self.cx.cfg.clone())
|
||||
parse::tts_to_parser(self.render.cx.parse_sess, tts, self.render.cx.cfg.clone())
|
||||
}
|
||||
|
||||
fn markups(&mut self) -> Option<Vec<Markup>> {
|
||||
let mut result = vec![];
|
||||
fn markups(&mut self) -> bool {
|
||||
loop {
|
||||
match self.input {
|
||||
[] => return Some(result),
|
||||
[] => return true,
|
||||
[semi!(), ..] => self.shift(1),
|
||||
[ref tt, ..] => {
|
||||
match self.markup() {
|
||||
Some(markup) => result.push(markup),
|
||||
None => {
|
||||
self.cx.span_err(tt.get_span(), "invalid syntax");
|
||||
return None;
|
||||
},
|
||||
if !self.markup() {
|
||||
self.render.cx.span_err(tt.get_span(), "invalid syntax");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn markup(&mut self) -> Option<Markup> {
|
||||
any!(self;
|
||||
self.value().map(Markup::Value),
|
||||
self.block(),
|
||||
self.element())
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Option<Value> {
|
||||
any!(self;
|
||||
fn markup(&mut self) -> bool {
|
||||
branch!(self;
|
||||
self.literal(),
|
||||
self.splice())
|
||||
self.splice(),
|
||||
self.block(),
|
||||
!self.in_attr && self.element())
|
||||
}
|
||||
|
||||
fn literal(&mut self) -> Option<Value> {
|
||||
fn literal(&mut self) -> bool {
|
||||
let (tt, minus) = match self.input {
|
||||
[minus!(), ref tt @ literal!(), ..] => {
|
||||
self.shift(2);
|
||||
|
@ -140,27 +131,30 @@ impl<'cx, 's, 'i> Parser<'cx, 's, 'i> {
|
|||
self.shift(1);
|
||||
(tt, false)
|
||||
},
|
||||
_ => return None,
|
||||
_ => return false,
|
||||
};
|
||||
let lit = self.new_rust_parser(vec![tt.clone()]).parse_lit();
|
||||
lit_to_string(self.cx, lit, minus)
|
||||
.map(|s| Value {
|
||||
value: Value_::Literal(s),
|
||||
escape: Escape::Escape,
|
||||
})
|
||||
match lit_to_string(self.render.cx, lit, minus) {
|
||||
Some(s) => {
|
||||
let escape = self.choose_escape();
|
||||
self.render.string(s.as_slice(), escape);
|
||||
},
|
||||
None => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn splice(&mut self) -> Option<Value> {
|
||||
fn splice(&mut self) -> bool {
|
||||
let (escape, sp) = match self.input {
|
||||
[ref tt @ dollar!(), dollar!(), ..] => {
|
||||
self.shift(2);
|
||||
(Escape::NoEscape, tt.get_span())
|
||||
(Escape::None, tt.get_span())
|
||||
},
|
||||
[ref tt @ dollar!(), ..] => {
|
||||
self.shift(1);
|
||||
(Escape::Escape, tt.get_span())
|
||||
(self.choose_escape(), tt.get_span())
|
||||
},
|
||||
_ => return None,
|
||||
_ => return false,
|
||||
};
|
||||
let tt = match self.input {
|
||||
[ref tt, ..] => {
|
||||
|
@ -168,48 +162,57 @@ impl<'cx, 's, 'i> Parser<'cx, 's, 'i> {
|
|||
self.new_rust_parser(vec![tt.clone()]).parse_expr()
|
||||
},
|
||||
_ => {
|
||||
self.cx.span_err(sp, "expected expression for this splice");
|
||||
return None;
|
||||
self.render.cx.span_err(sp, "expected expression for this splice");
|
||||
return false;
|
||||
},
|
||||
};
|
||||
Some(Value {
|
||||
value: Value_::Splice(tt),
|
||||
escape: escape,
|
||||
})
|
||||
self.render.splice(tt, escape);
|
||||
true
|
||||
}
|
||||
|
||||
fn element(&mut self) -> Option<Markup> {
|
||||
fn element(&mut self) -> bool {
|
||||
let name = match self.input {
|
||||
[ident!(name), ..] => {
|
||||
self.shift(1);
|
||||
name.as_str().to_string()
|
||||
},
|
||||
_ => return None,
|
||||
_ => return false,
|
||||
};
|
||||
let attrs = some!(self.attrs());
|
||||
let body = any!(self; self.markup());
|
||||
Some(Markup::Element(name, attrs, body.map(|body| box body)))
|
||||
let name = name.as_slice();
|
||||
self.render.element_open_start(name);
|
||||
guard!(self.attrs());
|
||||
self.render.element_open_end();
|
||||
guard!(self.markup());
|
||||
self.render.element_close(name);
|
||||
true
|
||||
}
|
||||
|
||||
fn attrs(&mut self) -> Option<Vec<(String, Value)>> {
|
||||
let mut attrs = vec![];
|
||||
fn attrs(&mut self) -> bool {
|
||||
while let [ident!(name), eq!(), ..] = self.input {
|
||||
self.shift(2);
|
||||
let name = name.as_str().to_string();
|
||||
let value = some!(self.value());
|
||||
attrs.push((name, value));
|
||||
self.render.attribute_start(name.as_str());
|
||||
{
|
||||
let old_in_attr = self.in_attr;
|
||||
self.in_attr = true;
|
||||
guard!(self.markup());
|
||||
self.in_attr = old_in_attr;
|
||||
}
|
||||
self.render.attribute_end();
|
||||
}
|
||||
Some(attrs)
|
||||
true
|
||||
}
|
||||
|
||||
fn block(&mut self) -> Option<Markup> {
|
||||
fn block(&mut self) -> bool {
|
||||
match self.input {
|
||||
[TtDelimited(_, ref d), ..] if d.delim == token::DelimToken::Brace => {
|
||||
self.shift(1);
|
||||
Parser { cx: self.cx, input: d.tts[] }.markups()
|
||||
.map(Markup::Block)
|
||||
Parser {
|
||||
in_attr: self.in_attr,
|
||||
input: d.tts.as_slice(),
|
||||
render: self.render,
|
||||
}.markups()
|
||||
},
|
||||
_ => None,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -228,7 +231,7 @@ fn lit_to_string(cx: &mut ExtCtxt, lit: Lit, minus: bool) -> Option<String> {
|
|||
return None;
|
||||
},
|
||||
LitChar(c) => result.push(c),
|
||||
LitInt(x, _) => result.push_str(x.to_string()[]),
|
||||
LitInt(x, _) => result.push_str(x.to_string().as_slice()),
|
||||
LitFloat(s, _) | LitFloatUnsuffixed(s) => result.push_str(s.get()),
|
||||
LitBool(b) => result.push_str(if b { "true" } else { "false" }),
|
||||
};
|
||||
|
|
|
@ -4,70 +4,88 @@ use syntax::ext::base::ExtCtxt;
|
|||
use syntax::parse::token;
|
||||
use syntax::ptr::P;
|
||||
|
||||
use super::parse::{Markup, Value};
|
||||
use super::parse::Escape;
|
||||
use maud::escape;
|
||||
|
||||
pub fn render(cx: &mut ExtCtxt, markups: &[Markup]) -> P<Expr> {
|
||||
let w = Ident::new(token::intern("w"));
|
||||
let mut stmts = vec![];
|
||||
for markup in markups.iter() {
|
||||
render_markup(cx, markup, w, &mut stmts);
|
||||
}
|
||||
quote_expr!(cx, |&: $w: &mut ::std::io::Writer| -> ::std::io::IoResult<()> {
|
||||
$stmts
|
||||
Ok(())
|
||||
})
|
||||
pub struct Renderer<'cx, 's: 'cx, 'o> {
|
||||
pub cx: &'cx mut ExtCtxt<'s>,
|
||||
stmts: &'o mut Vec<P<Stmt>>,
|
||||
w: Ident,
|
||||
}
|
||||
|
||||
fn render_markup(cx: &mut ExtCtxt, markup: &Markup, w: Ident, out: &mut Vec<P<Stmt>>) {
|
||||
use super::parse::Markup::*;
|
||||
match *markup {
|
||||
Element(..) => unimplemented!(),
|
||||
Block(ref markups) => {
|
||||
for markup in markups.iter() {
|
||||
render_markup(cx, markup, w, out);
|
||||
}
|
||||
},
|
||||
Value(ref value) => {
|
||||
let expr = render_value(cx, value, w, false);
|
||||
out.push(quote_stmt!(cx, $expr));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_value(cx: &mut ExtCtxt, value: &Value, w: Ident, is_attr: bool) -> P<Expr> {
|
||||
use super::parse::Escape::*;
|
||||
use super::parse::Value_::*;
|
||||
let &Value { ref value, escape } = value;
|
||||
match *value {
|
||||
Literal(ref s) => {
|
||||
let s = match escape {
|
||||
NoEscape => s[].into_cow(),
|
||||
Escape => if is_attr {
|
||||
escape::attribute(s[]).into_cow()
|
||||
} else {
|
||||
escape::non_attribute(s[]).into_cow()
|
||||
},
|
||||
impl<'cx, 's: 'cx, 'o> Renderer<'cx, 's, 'o> {
|
||||
pub fn with<F>(cx: &'cx mut ExtCtxt<'s>, f: F) -> P<Expr> where
|
||||
F: for<'o_> FnOnce(&mut Renderer<'cx, 's, 'o_>)
|
||||
{
|
||||
let mut stmts = vec![];
|
||||
let w = Ident::new(token::intern("w"));
|
||||
let cx = {
|
||||
let mut render = Renderer {
|
||||
cx: cx,
|
||||
stmts: &mut stmts,
|
||||
w: w,
|
||||
};
|
||||
let s = s[];
|
||||
quote_expr!(cx, {
|
||||
try!($w.write_str($s))
|
||||
})
|
||||
},
|
||||
Splice(ref expr) => match escape {
|
||||
NoEscape => quote_expr!(cx, {
|
||||
try!(write!($w, "{}", $expr));
|
||||
}),
|
||||
Escape => quote_expr!(cx, {
|
||||
let s = $expr.to_string();
|
||||
for c in s.chars() {
|
||||
try!(if $is_attr {
|
||||
::maud::rt::escape_attribute(c, $w)
|
||||
} else {
|
||||
::maud::rt::escape_non_attribute(c, $w)
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
f(&mut render);
|
||||
render.cx
|
||||
};
|
||||
quote_expr!(cx, |&: $w: &mut ::std::fmt::Writer| -> ::std::result::Result<(), ::std::fmt::Error> {
|
||||
$stmts
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Append a literal pre-escaped string.
|
||||
pub fn write(&mut self, s: &str) {
|
||||
let w = self.w;
|
||||
self.stmts.push(quote_stmt!(self.cx, try!($w.write_str($s))));
|
||||
}
|
||||
|
||||
/// Append a literal string, with the specified escaping method.
|
||||
pub fn string(&mut self, s: &str, escape: Escape) {
|
||||
let s = match escape {
|
||||
Escape::None => s.into_cow(),
|
||||
Escape::Attr => escape::attribute(s).into_cow(),
|
||||
Escape::Body => escape::non_attribute(s).into_cow(),
|
||||
};
|
||||
self.write(s.as_slice());
|
||||
}
|
||||
|
||||
/// Append the result of an expression, with the specified escaping method.
|
||||
pub fn splice(&mut self, expr: P<Expr>, escape: Escape) {
|
||||
let w = self.w;
|
||||
self.stmts.push(match escape {
|
||||
Escape::None => quote_stmt!(self.cx, try!(write!($w, "{}", $expr))),
|
||||
Escape::Attr =>
|
||||
quote_stmt!(self.cx,
|
||||
try!(::maud::rt::escape_attribute($w, |w| write!(w, "{}", $expr)))),
|
||||
Escape::Body =>
|
||||
quote_stmt!(self.cx,
|
||||
try!(::maud::rt::escape_non_attribute($w, |w| write!(w, "{}", $expr)))),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn element_open_start(&mut self, name: &str) {
|
||||
self.write("<");
|
||||
self.write(name);
|
||||
}
|
||||
|
||||
pub fn attribute_start(&mut self, name: &str) {
|
||||
self.write(" ");
|
||||
self.write(name);
|
||||
self.write("=\"");
|
||||
}
|
||||
|
||||
pub fn attribute_end(&mut self) {
|
||||
self.write("\"");
|
||||
}
|
||||
|
||||
pub fn element_open_end(&mut self) {
|
||||
self.write(">");
|
||||
}
|
||||
|
||||
pub fn element_close(&mut self, name: &str) {
|
||||
self.write("</");
|
||||
self.write(name);
|
||||
self.write(">");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#![feature(plugin)]
|
||||
#![allow(unstable)]
|
||||
|
||||
extern crate maud;
|
||||
#[plugin] #[no_link] extern crate maud_macros;
|
||||
|
|
Loading…
Add table
Reference in a new issue