Skip to content

Commit

Permalink
Add <foo {..iter}> dynamic attribute syntax
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasKalbertodt committed Nov 15, 2023
1 parent e4d6fb1 commit e6e7fa6
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 58 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.


## [Unreleased]
- Add `<foo {..iter}>` syntax for dynamic attributes

## 0.1.0 - 2023-11-14
### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ XML builder macro letting you write XML inside Rust code (similar to `serde_json
Features:

- Value interpolation (with escaping of course)
- Interpolate lists or optional attributes with `<foo {..iter}>`
- Auto close tags for convenience (e.g. `<foo>"body"</>`)
- Minimal memory allocations (only the `String` being built allocates)
- Choice between minimized and pretty XML
Expand Down
8 changes: 7 additions & 1 deletion macros/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@ pub(crate) struct Prolog {
#[derive(Debug)]
pub(crate) struct Element {
pub(crate) name: Name,
pub(crate) attrs: Vec<(Name, AttrValue)>,
pub(crate) attrs: Vec<Attr>,
pub(crate) children: Vec<Child>,
pub(crate) empty: bool,
}

#[derive(Debug)]
pub(crate) enum Attr {
Single(Name, AttrValue),
Fill(TokenStream),
}

#[derive(Debug)]
pub(crate) enum Child {
Text(String),
Expand Down
31 changes: 20 additions & 11 deletions macros/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,26 @@ fn emit_element(elem: &ast::Element) -> TokenStream {
};


for (name, value) in &elem.attrs {
let (span, v) = match value {
ast::AttrValue::Literal(s) => (Span::call_site(), quote! { #s }),
ast::AttrValue::Expr(e) => (
span_of_tokenstream(&e),
quote! { (#e) },
),
};
out.extend(quote_spanned!{span=>
buf.attr(#name, &#v);
});
for attr in &elem.attrs {
match attr {
ast::Attr::Single(name, value) => {
let (span, v) = match value {
ast::AttrValue::Literal(s) => (Span::call_site(), quote! { #s }),
ast::AttrValue::Expr(e) => (
span_of_tokenstream(&e),
quote! { (#e) },
),
};
out.extend(quote_spanned!{span=>
buf.attr(#name, &#v);
});
}
ast::Attr::Fill(expr) => {
out.extend(quote! {
buf.attrs(#expr);
});
}
}
}

if elem.empty {
Expand Down
6 changes: 6 additions & 0 deletions macros/src/parse/buf.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::iter;

use proc_macro2::{
token_stream::IntoIter, TokenStream, TokenTree, Span, Group, Punct, Ident, Delimiter,
};
Expand Down Expand Up @@ -98,6 +100,10 @@ impl ParseBuf {
}
}

pub(crate) fn collect_rest(mut self) -> TokenStream {
TokenStream::from_iter(iter::from_fn(|| self.bump().ok()))
}

pub(crate) fn parse<T: Parse>(&mut self) -> Result<T, Error> {
T::parse(self)
}
Expand Down
60 changes: 14 additions & 46 deletions macros/src/parse/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
use std::iter;

use proc_macro2::{TokenStream, TokenTree, Delimiter};
use proc_macro2::{TokenStream, TokenTree, Delimiter, Spacing};
use litrs::StringLit;

use crate::{ast, err::{Error, err}};
Expand Down Expand Up @@ -47,8 +45,7 @@ impl Parse for ast::Input {
match key.to_string().as_str() {
"format" => {
let _ = inner.expect_punct('=')?;
let expr = TokenStream::from_iter(iter::from_fn(|| inner.bump().ok()));
format = Some(expr);
format = Some(inner.collect_rest());
}
other => return Err(err!(
@key.span(),
Expand Down Expand Up @@ -161,11 +158,21 @@ impl Parse for ast::Element {
empty: true,
})
}
TokenTree::Group(g) if g.delimiter() == Delimiter::Brace => {
let g = buf.expect_group(Delimiter::Brace)?;
let mut inner = ParseBuf::from_group(g);
let p = inner.expect_punct('.')?;
if p.spacing() == Spacing::Alone {
return Err(err!(@p.span(), "expected '..' but found single '.'"));
}
inner.expect_punct('.')?;
attrs.push(ast::Attr::Fill(inner.collect_rest()));
}
_ => {
let name = buf.parse()?;
buf.expect_punct('=')?;
let value = buf.parse()?;
attrs.push((name, value));
attrs.push(ast::Attr::Single(name, value));
}
}
}
Expand Down Expand Up @@ -380,43 +387,4 @@ impl Parse for ast::AttrValue {
}
}


fn is_name(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
is_name_start_char(first) && chars.all(is_name_char)
}

fn is_name_start_char(c: char) -> bool {
matches!(c,
':'
| 'A'..='Z'
| '_'
| 'a'..='z'
| '\u{C0}'..='\u{D6}'
| '\u{D8}'..='\u{F6}'
| '\u{F8}'..='\u{2FF}'
| '\u{370}'..='\u{37D}'
| '\u{37F}'..='\u{1FFF}'
| '\u{200C}'..='\u{200D}'
| '\u{2070}'..='\u{218F}'
| '\u{2C00}'..='\u{2FEF}'
| '\u{3001}'..='\u{D7FF}'
| '\u{F900}'..='\u{FDCF}'
| '\u{FDF0}'..='\u{FFFD}'
| '\u{10000}'..='\u{EFFFF}'
)
}

fn is_name_char(c: char) -> bool {
is_name_start_char(c) || matches!(c,
'-'
| '.'
| '0'..='9'
| '\u{B7}'
| '\u{0300}'..='\u{036F}'
| '\u{203F}'..='\u{2040}'
)
}
include!("../../../shared.rs");
45 changes: 45 additions & 0 deletions shared.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// This is ugly: the following code needs to be used by the macro at compile
// time, but also by the library at run time. The "proper" way would be to add
// yet another crate that both crates can depend on. But that's super annoying,
// it's already bad enough that two crates are required for all of this. So
// screw it, I just `include!` this code in both code bases.

fn is_name(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
is_name_start_char(first) && chars.all(is_name_char)
}

fn is_name_start_char(c: char) -> bool {
matches!(c,
':'
| 'A'..='Z'
| '_'
| 'a'..='z'
| '\u{C0}'..='\u{D6}'
| '\u{D8}'..='\u{F6}'
| '\u{F8}'..='\u{2FF}'
| '\u{370}'..='\u{37D}'
| '\u{37F}'..='\u{1FFF}'
| '\u{200C}'..='\u{200D}'
| '\u{2070}'..='\u{218F}'
| '\u{2C00}'..='\u{2FEF}'
| '\u{3001}'..='\u{D7FF}'
| '\u{F900}'..='\u{FDCF}'
| '\u{FDF0}'..='\u{FFFD}'
| '\u{10000}'..='\u{EFFFF}'
)
}

fn is_name_char(c: char) -> bool {
is_name_start_char(c) || matches!(c,
'-'
| '.'
| '0'..='9'
| '\u{B7}'
| '\u{0300}'..='\u{036F}'
| '\u{203F}'..='\u{2040}'
)
}
71 changes: 71 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,53 @@ use std::{fmt::Write, matches, unreachable};
/// your outer function and can use `.await` or `?` as appropriate. See below
/// for a useful example.
///
/// ## Fill attribute syntax `{..iter}`
///
/// You can dynamically add attributes to an element by using the `<foo
/// {..iter}>` syntax. There, `iter` must be an expression that implements
/// `IntoIterator<Item = (N, V)>` where `N` and `V` must implement
/// `fmt::Display`. This allows you to interpolate hash maps or lists,
/// and also to easily model optional attributes (as `Option` does implement
/// `IntoIterator`). Examples:
///
/// ```rust
/// use std::{collections::BTreeMap, path::Path};
/// use ogrim::xml;
///
/// let description = Some("Lorem Ipsum");
/// let map = BTreeMap::from([
/// ("cat", Path::new("/usr/bin/cat").display()),
/// ("dog", Path::new("/home/goodboy/image.jpg").display()),
/// ]);
///
/// let doc = xml!(
/// <?xml version="1.1" ?>
/// <root
/// // Optional attributes via `Option`
/// {..description.map(|v| ("description", v))}
/// // Can be mixed with normal attributes, retaining source order
/// bar="green"
/// // Naturally, maps work as well. Remember that hash map has a random
/// // iteration order, so consider using `BTreeMap` instead.
/// {..map}
/// // Arrays can also be useful, as they also implement `IntoIterator`
/// {..["Alice", "Bob"].map(|name| (name.to_lowercase(), "invited"))}
/// >
/// </>
/// );
///
/// # assert_eq!(doc.as_str(), concat!(
/// # r#"<?xml version="1.1" encoding="UTF-8"?>"#,
/// # r#"<root description="Lorem Ipsum" bar="green" "#,
/// # r#"cat="/usr/bin/cat" dog="/home/goodboy/image.jpg" "#,
/// # r#"alice="invited" bob="invited"></root>"#,
/// # ));
/// ```
///
/// Note: as the attribute names cannot be checked at compile time, the check
/// has to be performed at runtime. If passed invalid XML names, this will
/// panic.
///
///
/// # Create new document (entry point)
///
Expand Down Expand Up @@ -277,6 +324,28 @@ impl Document {
self.buf.push('"');
}

#[doc(hidden)]
pub fn attrs<I, N, V>(&mut self, attrs: I)
where
I: IntoIterator<Item = (N, V)>,
V: fmt::Display,
N: fmt::Display,
{
for (name, value) in attrs {
// To check whether the name is valid, we first just write it to the
// buffer to avoid temporary heap allocations.
let len_before = self.buf.len();
wr!(self.buf, r#" {name}=""#);
let written_name = &self.buf[len_before + 1..self.buf.len() - 2];
if !is_name(written_name) {
panic!("attribute name '{written_name}' is not a valid XML name");
}

escape_into(&mut self.buf, &value, true);
self.buf.push('"');
}
}

#[doc(hidden)]
pub fn close_start_tag(&mut self) {
self.buf.push('>');
Expand Down Expand Up @@ -394,3 +463,5 @@ impl fmt::Write for EscapedWriter<'_> {
Ok(())
}
}

include!("../shared.rs");

0 comments on commit e6e7fa6

Please sign in to comment.