commit 3bc444068ca15d831796abe2a3937278f270b8bb Author: Bodil Stokke Date: Fri Oct 26 22:02:21 2018 +0100 Make it so. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6936990 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c77ad20 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "typed-html", + "macros" +] diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..1dc64db --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "typed-html-macros" +version = "0.1.0" +authors = ["Bodil Stokke "] +edition = "2018" + +[lib] +proc-macro = true + +[dependencies] +quote = "0.6.8" +pom = "2.0.1" +proc-macro2 = "0.4.20" diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..bfef96c --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,208 @@ +#![recursion_limit = "32768"] + +extern crate proc_macro; + +use pom::combinator::*; +use pom::{Error, Parser}; +use proc_macro2::{Group, Ident, Literal, TokenStream, TokenTree}; +use quote::quote; +use std::collections::HashMap; + +#[derive(Clone)] +enum Node { + Element(Element), + Text(Literal), + Block(Group), +} + +impl Node { + fn into_token_stream(self) -> TokenStream { + match self { + Node::Element(el) => el.into_token_stream(), + Node::Text(_) => panic!("top level must be an element"), + Node::Block(_) => panic!("top level must be an element"), + } + } + + fn into_child_stream(self) -> TokenStream { + match self { + Node::Element(el) => { + let el = el.into_token_stream(); + quote!( + element.append_child(#el); + ) + } + Node::Text(tx) => quote!( + element.append_child(typed_html::Node::Text(#tx.to_string())); + ), + Node::Block(group) => quote!({ + let iter = #group.into_iter(); + for child in iter { + element.append_child(child); + } + }), + } + } +} + +#[derive(Clone)] +struct Element { + name: String, + attributes: HashMap, + children: Vec, +} + +impl Element { + fn new(name: String) -> Self { + Element { + name, + attributes: HashMap::new(), + children: Vec::new(), + } + } + + fn into_token_stream(self) -> TokenStream { + let name = self.name; + let keys: Vec<_> = self.attributes.keys().cloned().collect(); + let values: Vec = self.attributes.values().cloned().collect(); + let children = self.children.into_iter().map(Node::into_child_stream); + quote!( + { + let mut element = typed_html::Element::new(#name); + #( + element.set_attr(#keys, #values.to_string()); + )* + #(#children)* + typed_html::Node::Element(element) + } + ) + } +} + +fn unit<'a, I: 'a, A: Clone>(value: A) -> Combinator> { + comb(move |_, start| Ok((value.clone(), start))) +} + +fn punct<'a>(punct: char) -> Combinator> { + comb(move |input: &[TokenTree], start| match input.get(start) { + Some(TokenTree::Punct(p)) if p.as_char() == punct => Ok(((), start + 1)), + _ => Err(Error::Mismatch { + message: format!("expected {:?}", punct), + position: start, + }), + }) +} + +fn ident<'a>() -> Combinator> { + comb(|input: &[TokenTree], start| match input.get(start) { + Some(TokenTree::Ident(i)) => Ok((i.clone(), start + 1)), + _ => Err(Error::Mismatch { + message: "expected identifier".to_string(), + position: start, + }), + }) +} + +fn ident_match<'a>(name: String) -> Combinator> { + comb(move |input: &[TokenTree], start| match input.get(start) { + Some(TokenTree::Ident(i)) => { + if *i == name { + Ok(((), start + 1)) + } else { + Err(Error::Mismatch { + message: format!("expected '', found ''", name, i.to_string()), + position: start, + }) + } + } + _ => Err(Error::Mismatch { + message: "expected identifier".to_string(), + position: start, + }), + }) +} + +fn literal<'a>() -> Combinator> { + comb(|input: &[TokenTree], start| match input.get(start) { + Some(TokenTree::Literal(l)) => Ok((l.clone(), start + 1)), + _ => Err(Error::Mismatch { + message: "expected literal".to_string(), + position: start, + }), + }) +} + +fn group<'a>() -> Combinator> { + comb(|input: &[TokenTree], start| match input.get(start) { + Some(TokenTree::Group(g)) => Ok((g.clone(), start + 1)), + _ => Err(Error::Mismatch { + message: "expected group".to_string(), + position: start, + }), + }) +} + +fn element_start<'a>() -> Combinator> { + (punct('<') * ident()).map(|i| Element::new(i.to_string())) +} + +fn attr_value<'a>() -> Combinator> { + literal().map(TokenTree::Literal) | ident().map(TokenTree::Ident) +} + +fn attr<'a>() -> Combinator> { + ident().map(|i| i.to_string()) + (punct('=') * attr_value()) +} + +fn element_with_attrs<'a>() -> Combinator> { + (element_start() + attr().repeat(0..)).map(|(mut el, attrs)| { + for (name, value) in attrs { + el.attributes.insert(name, value); + } + el + }) +} + +fn element_single<'a>() -> Combinator> { + element_with_attrs() - punct('/') - punct('>') +} + +fn element_open<'a>() -> Combinator> { + element_with_attrs() - punct('>') +} + +fn element_close<'a>(name: String) -> Combinator> { + // TODO make this return an error message containing the tag name + punct('<') * punct('/') * ident_match(name) * punct('>') +} + +fn element_with_children<'a>() -> Combinator> { + (element_open() + comb(node).repeat(0..)).map(|(mut el, children)| { + el.children.extend(children.into_iter()); + el + }) >> |el: Element| element_close(el.name.clone()).expect("closing tag") * unit(el) +} + +fn node(input: &[TokenTree], start: usize) -> pom::Result<(Node, usize)> { + (element_single().map(Node::Element) + | element_with_children().map(Node::Element) + | literal().map(Node::Text) + | group().map(Node::Block)) + .0 + .parse(input, start) +} + +fn macro_expand(input: &[TokenTree]) -> pom::Result { + comb(node).parse(input).map(|el| el.into_token_stream()) +} + +#[proc_macro] +pub fn html(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input: TokenStream = input.into(); + let input: Vec = input.into_iter().collect(); + let result = macro_expand(&input); + match result { + Err(error) => panic!("error: {:?}", error), + Ok(ts) => ts.into(), + } +} diff --git a/typed-html/Cargo.toml b/typed-html/Cargo.toml new file mode 100644 index 0000000..d452fef --- /dev/null +++ b/typed-html/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "typed-html" +version = "0.1.0" +authors = ["Bodil Stokke "] +edition = "2018" + +[dependencies] +typed-html-macros = { path = "../macros" } diff --git a/typed-html/src/bin/main.rs b/typed-html/src/bin/main.rs new file mode 100644 index 0000000..56c70da --- /dev/null +++ b/typed-html/src/bin/main.rs @@ -0,0 +1,27 @@ +#![feature(proc_macro_hygiene)] + +use typed_html::Node; +use typed_html_macros::html; + +fn main() { + let the_big_question = Node::text("How does she eat?"); + let splain_class = "well-actually"; + let doc = html!( + + + "Hello Kitty!" + + +

"Hello Kitty!"

+

"She is not a cat. She is a human girl."

+

{the_big_question}

+ { + (1..4).map(|i| { + html!(

{ Node::text(format!("Generated paragraph {}", i)) }

) + }) + } + + + ); + println!("{}", doc.to_string()); +} diff --git a/typed-html/src/lib.rs b/typed-html/src/lib.rs new file mode 100644 index 0000000..2cb0c39 --- /dev/null +++ b/typed-html/src/lib.rs @@ -0,0 +1,3 @@ +pub mod node; + +pub use crate::node::{Element, Node}; diff --git a/typed-html/src/node.rs b/typed-html/src/node.rs new file mode 100644 index 0000000..2994ae8 --- /dev/null +++ b/typed-html/src/node.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use std::fmt::{Debug, Display, Error, Formatter}; + +#[derive(PartialEq, Eq, Clone)] +pub enum Node { + Element(Element), + Text(String), +} + +impl Node { + pub fn text>(t: S) -> Self { + Node::Text(t.into()) + } +} + +impl Display for Node { + fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { + match self { + Node::Element(el) => (el as &Display).fmt(f), + Node::Text(tx) => (tx as &Display).fmt(f), + } + } +} + +impl Debug for Node { + fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { + (self as &Display).fmt(f) + } +} + +impl IntoIterator for Node { + type Item = Node; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + vec![self].into_iter() + } +} + +#[derive(PartialEq, Eq, Clone)] +pub struct Element { + name: String, + attributes: HashMap, + children: Vec, +} + +impl Element { + pub fn new>(name: S) -> Self { + Element { + name: name.into(), + attributes: HashMap::new(), + children: Vec::new(), + } + } + + pub fn set_attr, S2: Into>(&mut self, attr: S1, value: S2) { + self.attributes.insert(attr.into(), value.into()); + } + + pub fn append_child(&mut self, child: Node) { + self.children.push(child) + } +} + +impl Display for Element { + fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { + write!(f, "<{}", self.name)?; + for (attr, value) in &self.attributes { + write!(f, " {}={:?}", attr, value)?; + } + if self.children.is_empty() { + write!(f, "/>") + } else { + write!(f, ">")?; + for child in &self.children { + (child as &Display).fmt(f)?; + } + write!(f, "", self.name) + } + } +} + +impl Debug for Element { + fn fmt(&self, f: &mut Formatter) -> Result<(), Error> { + (self as &Display).fmt(f) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn construct() { + let el1 = Element::new("html"); + let el2 = Element::new("html".to_string()); + assert_eq!(el1, el2); + } + + #[test] + fn to_string() { + let mut doc = Element::new("html"); + doc.set_attr("version", "1.0"); + let mut head = Element::new("head"); + let mut style = Element::new("style"); + style.set_attr("src", "lol.css"); + let mut title = Element::new("title"); + title.append_child(Node::Text("Hello kitty!".to_string())); + head.append_child(Node::Element(title)); + head.append_child(Node::Element(style)); + doc.append_child(Node::Element(head)); + assert_eq!( + "Hello kitty!