Leon template library (#766)

* leon: first implementation

* Update crates/leon/src/values.rs

Co-authored-by: Jiahao XU <Jiahao_XU@outlook.com>

* Workaround orphan rules to make API more intuitive

* Fmt

* Clippy

* Use CoW

* Use cow for items too

* Test that const construction works

* leon: Initial attempt at O(n) parser

* leon: finish parser (except escapes)

* leon: Improve ergonomics of compile-time templates

* Document helpers

* leon: Docs tweaks

* leon: Use macro to minimise parser tests

* leon: add escapes to parser

* leon: test escapes preceding keys

* leon: add multibyte tests

* leon: test escapes following keys

* Format

* Debug

* leon: Don't actually need to keep track of the key

* leon: Parse to vec first

* leon: there's actually no need for string cows

* leon: reorganise and redo macro now that there's no coww

* Well that was silly

* leon: Adjust text end when pushing

* leon: catch unbalanced keys

* Add error tests

* leon: Catch unfinished escape

* Comment out debugging

* leon: fuzz

* Clippy

* leon: Box parse error

* leon: &dyn instead of impl

* Can't impl FromStr, so rename to parse

* Add Vec<> to values

* leon: Add benches for ways to supply values

* leon: Add bench comparing to std and tt

* Fix fuzz

* Fmt

* Split ParseError and RenderError

* Make miette optional

* Remove RenderError lifetime

* Simplify ParseError type schema

* Write concrete Values types instead of generics

* Add license files

* Reduce criterion deps

* Make default a cow

* Add a CLI leon tool

* Fix tests

* Clippy

* Disable cli by default

* Avoid failing the build when cli is off

* Add to ci

* Update crates/leon/src/main.rs

Co-authored-by: Jiahao XU <Jiahao_XU@outlook.com>

* Update crates/leon/Cargo.toml

Co-authored-by: Jiahao XU <Jiahao_XU@outlook.com>

* Bump version

* Error not transparent

* Diagnostic can do forwarding

* Simplify error type

* Expand doc examples

* Generic Values for Hash and BTree maps

* One more borrowed

* Forward implementations

* More generics

* Add has_keys

* Lock stdout in leon tool

* No more debug comments in parser

* Even more generics

* Macros to reduce bench duplication

* Further simplify error

* Fix leon main

* Stable support

* Clippy

---------

Co-authored-by: Jiahao XU <Jiahao_XU@outlook.com>
This commit is contained in:
Félix Saparelli 2023-03-21 14:36:02 +13:00 committed by GitHub
parent daf8cdd010
commit 2227d363f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 2382 additions and 162 deletions

85
crates/leon/src/error.rs Normal file
View file

@ -0,0 +1,85 @@
#[derive(Debug, thiserror::Error)]
#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
pub enum RenderError {
/// A key was missing from the provided values.
#[error("missing key `{0}`")]
MissingKey(String),
/// An I/O error passed through from [`Template::render_into`].
#[error("write failed: {0}")]
Io(#[from] std::io::Error),
}
/// An error that can occur when parsing a template.
///
/// When the `miette` feature is enabled, this is a rich miette-powered error
/// which will highlight the source of the error in the template when output
/// (with miette's `fancy` feature). With `miette` disabled, this is opaque.
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
#[cfg_attr(feature = "miette", diagnostic(transparent))]
#[error(transparent)]
pub struct ParseError(Box<InnerParseError>);
/// The inner (unboxed) type of [`ParseError`].
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
#[error("template parse failed")]
struct InnerParseError {
#[cfg_attr(feature = "miette", source_code)]
src: String,
#[cfg_attr(feature = "miette", label("This bracket is not opening or closing anything. Try removing it, or escaping it with a backslash."))]
unbalanced: Option<(usize, usize)>,
#[cfg_attr(feature = "miette", label("This escape is malformed."))]
escape: Option<(usize, usize)>,
#[cfg_attr(feature = "miette", label("A key cannot be empty."))]
key_empty: Option<(usize, usize)>,
#[cfg_attr(feature = "miette", label("Escapes are not allowed in keys."))]
key_escape: Option<(usize, usize)>,
}
impl ParseError {
pub(crate) fn unbalanced(src: &str, start: usize, end: usize) -> Self {
Self(Box::new(InnerParseError {
src: String::from(src),
unbalanced: Some((start, end.saturating_sub(start) + 1)),
escape: None,
key_empty: None,
key_escape: None,
}))
}
pub(crate) fn escape(src: &str, start: usize, end: usize) -> Self {
Self(Box::new(InnerParseError {
src: String::from(src),
unbalanced: None,
escape: Some((start, end.saturating_sub(start) + 1)),
key_empty: None,
key_escape: None,
}))
}
pub(crate) fn key_empty(src: &str, start: usize, end: usize) -> Self {
Self(Box::new(InnerParseError {
src: String::from(src),
unbalanced: None,
escape: None,
key_empty: Some((start, end.saturating_sub(start) + 1)),
key_escape: None,
}))
}
pub(crate) fn key_escape(src: &str, start: usize, end: usize) -> Self {
Self(Box::new(InnerParseError {
src: String::from(src),
unbalanced: None,
escape: None,
key_empty: None,
key_escape: Some((start, end.saturating_sub(start) + 1)),
}))
}
}

148
crates/leon/src/lib.rs Normal file
View file

@ -0,0 +1,148 @@
//! Dead-simple string templating.
//!
//! Leon parses a template string into a list of tokens, and then substitutes
//! provided values in. Unlike other templating engines, it is extremely simple:
//! it supports no logic, only replaces. It is even simpler than `format!()`,
//! albeit with a similar syntax.
//!
//! # Syntax
//!
//! ```plain
//! it is better to rule { group }
//! one can live {adverb} without power
//! ```
//!
//! A replacement is denoted by `{` and `}`. The contents of the braces, trimmed
//! of any whitespace, are the key. Any text outside of braces is left as-is.
//!
//! To escape a brace, use `\{` or `\}`. To escape a backslash, use `\\`. Keys
//! cannot contain escapes.
//!
//! ```plain
//! \{ leon \}
//! ```
//!
//! The above examples, given the values `group = "no one"` and
//! `adverb = "honourably"`, would render to:
//!
//! ```plain
//! it is better to rule no one
//! one can live honourably without power
//! { leon }
//! ```
//!
//! # Usage
//!
//! A template is first parsed to a token list:
//!
//! ```
//! use leon::Template;
//!
//! let template = Template::parse("hello {name}").unwrap();
//! ```
//!
//! The template can be inspected, for example to check if a key is present:
//!
//! ```
//! # use leon::Template;
//! #
//! # let template = Template::parse("hello {name}").unwrap();
//! assert!(template.has_key("name"));
//! ```
//!
//! The template can be rendered to a string:
//!
//! ```
//! # use leon::Template;
//! use leon::vals;
//! #
//! # let template = Template::parse("hello {name}").unwrap();
//! assert_eq!(
//! template.render(
//! &vals(|_key| Some("marcus".into()))
//! ).unwrap().as_str(),
//! "hello marcus",
//! );
//! ```
//!
//! …or to a writer:
//!
//! ```
//! use std::io::Write;
//! # use leon::Template;
//! use leon::vals;
//! #
//! # let template = Template::parse("hello {name}").unwrap();
//! let mut buf: Vec<u8> = Vec::new();
//! template.render_into(
//! &mut buf,
//! &vals(|key| if key == "name" {
//! Some("julius".into())
//! } else {
//! None
//! })
//! ).unwrap();
//! assert_eq!(buf.as_slice(), b"hello julius");
//! ```
//!
//! …with a map:
//!
//! ```
//! use std::collections::HashMap;
//! # use leon::Template;
//! # let template = Template::parse("hello {name}").unwrap();
//! let mut values = HashMap::new();
//! values.insert("name", "brutus");
//! assert_eq!(template.render(&values).unwrap().as_str(), "hello brutus");
//! ```
//!
//! …or with your own type, if you implement the [`Values`] trait:
//!
//! ```
//! # use leon::Template;
//! use std::borrow::Cow;
//! use leon::Values;
//!
//! struct MyMap {
//! name: &'static str,
//! }
//! impl Values for MyMap {
//! fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
//! if key == "name" {
//! Some(self.name.into())
//! } else {
//! None
//! }
//! }
//! }
//! #
//! # let template = Template::parse("hello {name}").unwrap();
//! let values = MyMap { name: "pontifex" };
//! assert_eq!(template.render(&values).unwrap().as_str(), "hello pontifex");
//! ```
//!
//! # Errors
//!
//! Leon will return a [`LeonError::InvalidTemplate`] if the template fails to
//! parse. This can happen if there are unbalanced braces, or if a key is empty.
//!
//! Leon will return a [`LeonError::MissingKey`] if a key is missing from keyed
//! values passed to [`Template::render()`], unless a default value is provided
//! with [`Template.default`].
//!
//! It will also pass through I/O errors when using [`Template::render_into()`].
#[doc(inline)]
pub use error::*;
#[doc(inline)]
pub use template::*;
#[doc(inline)]
pub use values::*;
mod error;
mod macros;
mod parser;
mod template;
mod values;

50
crates/leon/src/macros.rs Normal file
View file

@ -0,0 +1,50 @@
/// Construct a template constant without needing to make an items constant.
///
/// This is essentially a shorthand for:
///
/// ```
/// use leon::{Item, Template};
/// Template::new({
/// const ITEMS: &'static [Item<'static>] = &[Item::Text("Hello "), Item::Key("name")];
/// ITEMS
/// }, Some("world"));
/// ```
///
/// # Examples
///
/// ```
/// use leon::Item::*;
/// assert_eq!(
/// leon::template!(Text("Hello "), Key("name"))
/// .render(&[("name", "Магда Нахман")])
/// .unwrap(),
/// "Hello Магда Нахман",
/// );
/// ```
///
/// With a default:
///
/// ```
/// use leon::Item::*;
/// assert_eq!(
/// leon::template!(Text("Hello "), Key("name"); "M. P. T. Acharya")
/// .render(&[("city", "Madras")])
/// .unwrap(),
/// "Hello M. P. T. Acharya",
/// );
/// ```
#[macro_export]
macro_rules! template {
($($item:expr),* $(,)?) => {
$crate::Template::new({
const ITEMS: &'static [$crate::Item<'static>] = &[$($item),*];
ITEMS
}, ::core::option::Option::None)
};
($($item:expr),* $(,)? ; $default:expr) => {
$crate::Template::new({
const ITEMS: &'static [$crate::Item<'static>] = &[$($item),*];
ITEMS
}, ::core::option::Option::Some($default))
};
}

61
crates/leon/src/main.rs Normal file
View file

@ -0,0 +1,61 @@
#[cfg(feature = "cli")]
fn main() -> miette::Result<()> {
use std::{collections::HashMap, error::Error, io::stdout};
use clap::Parser;
use leon::Template;
/// Render a Leon template with the given values.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Leon template
template: String,
/// Default to use for missing keys
#[arg(long)]
default: Option<String>,
/// Use values from the environment
#[arg(long)]
env: bool,
/// Key-value pairs to use
#[arg(short, long, value_parser = parse_key_val::<String, String>)]
values: Vec<(String, String)>,
}
/// Parse a single key-value pair
fn parse_key_val<T, U>(s: &str) -> Result<(T, U), Box<dyn Error + Send + Sync + 'static>>
where
T: std::str::FromStr,
T::Err: Error + Send + Sync + 'static,
U: std::str::FromStr,
U::Err: Error + Send + Sync + 'static,
{
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
Ok((k.parse()?, v.parse()?))
}
let args = Args::parse();
let mut values: HashMap<String, String> = HashMap::from_iter(args.values);
if args.env {
for (key, value) in std::env::vars() {
values.entry(key).or_insert(value);
}
}
let template = args.template;
let mut template = Template::parse(&template)?;
if let Some(default) = &args.default {
template.set_default(default);
}
template.render_into(&mut stdout().lock(), &values)?;
Ok(())
}
#[cfg(not(feature = "cli"))]
fn main() {}

565
crates/leon/src/parser.rs Normal file
View file

@ -0,0 +1,565 @@
use std::mem::replace;
use crate::{Item, ParseError, Template};
#[derive(Debug, Clone, Copy)]
enum Token {
Text {
start: usize,
end: usize,
},
BracePair {
start: usize,
key_seen: bool,
end: usize,
},
Escape {
start: usize,
end: usize,
ch: Option<char>,
},
}
impl Token {
fn start_text(pos: usize, ch: char) -> Self {
Self::Text {
start: pos,
end: pos + ch.len_utf8(),
}
}
fn start_text_single(pos: usize) -> Self {
Self::Text {
start: pos,
end: pos,
}
}
fn start_brace_pair(pos: usize, ch: char) -> Self {
Self::BracePair {
start: pos,
key_seen: false,
end: pos + ch.len_utf8(),
}
}
fn start_escape(pos: usize, ch: char) -> Self {
Self::Escape {
start: pos,
end: pos + ch.len_utf8(),
ch: None,
}
}
fn is_empty(&self, source_len: usize) -> bool {
match self {
Self::Text { start, end } => {
*start >= source_len || *end >= source_len || *start > *end
}
Self::BracePair {
start,
key_seen,
end,
} => !key_seen || *start >= source_len || *end >= source_len || *start > *end,
Self::Escape { start, end, .. } => {
*start >= source_len || *end >= source_len || *start > *end
}
}
}
fn start(&self) -> usize {
match self {
Self::Text { start, .. }
| Self::BracePair { start, .. }
| Self::Escape { start, .. } => *start,
}
}
fn end(&self) -> usize {
match self {
Self::Text { end, .. } | Self::BracePair { end, .. } | Self::Escape { end, .. } => *end,
}
}
fn set_end(&mut self, pos: usize) {
match self {
Self::Text { end, .. } | Self::BracePair { end, .. } | Self::Escape { end, .. } => {
*end = pos
}
}
}
}
impl<'s> Template<'s> {
pub(crate) fn parse_items(s: &'s str) -> Result<Vec<Item<'s>>, ParseError> {
let source_len = s.len();
let mut tokens = Vec::new();
let mut current = Token::start_text(0, '\0');
for (pos, chara) in s.char_indices() {
match (&mut current, chara) {
(tok @ (Token::Text { .. } | Token::Escape { ch: Some(_), .. }), ch @ '{') => {
if matches!(tok, Token::Text { .. }) && tok.start() == pos {
*tok = Token::start_brace_pair(pos, ch);
} else {
if let Token::Text { end, .. } = tok {
*end = pos - 1;
}
tokens.push(replace(tok, Token::start_brace_pair(pos, ch)));
}
}
(txt @ Token::Text { .. }, ch @ '\\') => {
if txt.is_empty(source_len) || txt.start() == pos {
*txt = Token::start_escape(pos, ch);
} else {
if let Token::Text { end, .. } = txt {
*end = pos - 1;
}
tokens.push(replace(txt, Token::start_escape(pos, ch)));
}
}
(bp @ Token::BracePair { .. }, '}') => {
if let Token::BracePair { end, .. } = bp {
*end = pos;
} else {
unreachable!("bracepair isn't bracepair");
}
tokens.push(replace(bp, Token::start_text_single(pos + 1)));
}
(Token::BracePair { start, .. }, '\\') => {
return Err(ParseError::key_escape(s, *start, pos));
}
(Token::BracePair { key_seen, end, .. }, ws) if ws.is_whitespace() => {
if *key_seen {
*end = pos;
} else {
// We're in a brace pair, but we're not in the key yet.
}
}
(Token::BracePair { key_seen, end, .. }, _) => {
*key_seen = true;
*end = pos + 1;
}
(Token::Text { .. }, '}') => {
return Err(ParseError::unbalanced(s, pos, pos));
}
(Token::Text { end, .. }, _) => {
*end = pos;
}
(esc @ Token::Escape { .. }, es @ ('\\' | '{' | '}')) => {
if let Token::Escape { start, end, ch, .. } = esc {
if ch.is_none() {
*end = pos;
*ch = Some(es);
} else if es == '\\' {
// A new escape right after a completed escape.
tokens.push(replace(esc, Token::start_escape(pos, es)));
} else if es == '{' {
// A new brace pair right after a completed escape, should be handled prior to this.
unreachable!("escape followed by brace pair, unhandled");
} else {
// } right after a completed escape, probably unreachable but just in case:
return Err(ParseError::key_escape(s, *start, pos));
}
} else {
unreachable!("escape is not an escape");
}
}
(
Token::Escape {
start, ch: None, ..
},
_,
) => {
return Err(ParseError::escape(s, *start, pos));
}
(Token::Escape { ch: Some(_), .. }, _) => {
tokens.push(replace(&mut current, Token::start_text_single(pos)));
}
}
}
if !current.is_empty(source_len) {
if current.end() < source_len - 1 {
current.set_end(source_len - 1);
}
tokens.push(current);
}
if let Token::BracePair { start, end, .. } = current {
return Err(ParseError::unbalanced(s, start, end));
}
if let Token::Escape {
start,
end,
ch: None,
} = current
{
return Err(ParseError::escape(s, start, end));
}
let mut items = Vec::with_capacity(tokens.len());
for token in tokens {
match token {
Token::Text { start, end } => {
items.push(Item::Text(&s[start..=end]));
}
Token::BracePair {
start,
end,
key_seen: false,
} => {
return Err(ParseError::key_empty(s, start, end));
}
Token::BracePair {
start,
end,
key_seen: true,
} => {
let key = s[start..=end]
.trim_matches(|c: char| c.is_whitespace() || c == '{' || c == '}');
if key.is_empty() {
return Err(ParseError::key_empty(s, start, end));
} else {
items.push(Item::Key(key));
}
}
Token::Escape {
ch: Some(_), end, ..
} => {
items.push(Item::Text(&s[end..=end]));
}
Token::Escape {
ch: None,
start,
end,
} => {
return Err(ParseError::escape(s, start, end));
}
}
}
Ok(items)
}
}
#[cfg(test)]
mod test_valid {
use crate::{template, Item::*, Template};
#[test]
fn empty() {
let template = Template::parse("").unwrap();
assert_eq!(template, Template::default());
}
#[test]
fn no_keys() {
let template = Template::parse("hello world").unwrap();
assert_eq!(template, template!(Text("hello world")));
}
#[test]
fn leading_key() {
let template = Template::parse("{salutation} world").unwrap();
assert_eq!(template, template!(Key("salutation"), Text(" world")));
}
#[test]
fn trailing_key() {
let template = Template::parse("hello {name}").unwrap();
assert_eq!(template, template!(Text("hello "), Key("name")));
}
#[test]
fn middle_key() {
let template = Template::parse("hello {name}!").unwrap();
assert_eq!(template, template!(Text("hello "), Key("name"), Text("!")));
}
#[test]
fn middle_text() {
let template = Template::parse("{salutation} good {title}").unwrap();
assert_eq!(
template,
template!(Key("salutation"), Text(" good "), Key("title"))
);
}
#[test]
fn multiline() {
let template = Template::parse(
"
And if thy native country was { ancient civilisation },
What need to slight thee? Came not {hero} thence,
Who gave to { country } her books and art of writing?
",
)
.unwrap();
assert_eq!(
template,
template!(
Text("\n And if thy native country was "),
Key("ancient civilisation"),
Text(",\n What need to slight thee? Came not "),
Key("hero"),
Text(" thence,\n Who gave to "),
Key("country"),
Text(" her books and art of writing?\n "),
)
);
}
#[test]
fn key_no_whitespace() {
let template = Template::parse("{word}").unwrap();
assert_eq!(template, template!(Key("word")));
}
#[test]
fn key_leading_whitespace() {
let template = Template::parse("{ word}").unwrap();
assert_eq!(template, template!(Key("word")));
}
#[test]
fn key_trailing_whitespace() {
let template = Template::parse("{word\n}").unwrap();
assert_eq!(template, template!(Key("word")));
}
#[test]
fn key_both_whitespace() {
let template = Template::parse(
"{
\tword
}",
)
.unwrap();
assert_eq!(template, template!(Key("word")));
}
#[test]
fn key_inner_whitespace() {
let template = Template::parse("{ a word }").unwrap();
assert_eq!(template, template!(Key("a word")));
}
#[test]
fn escape_left() {
let template = Template::parse(r"this \{ single left brace").unwrap();
assert_eq!(
template,
template!(Text("this "), Text("{"), Text(" single left brace"))
);
}
#[test]
fn escape_right() {
let template = Template::parse(r"this \} single right brace").unwrap();
assert_eq!(
template,
template!(Text("this "), Text("}"), Text(" single right brace"))
);
}
#[test]
fn escape_both() {
let template = Template::parse(r"these \{ two \} braces").unwrap();
assert_eq!(
template,
template!(
Text("these "),
Text("{"),
Text(" two "),
Text("}"),
Text(" braces")
)
);
}
#[test]
fn escape_doubled() {
let template = Template::parse(r"these \{\{ four \}\} braces").unwrap();
assert_eq!(
template,
template!(
Text("these "),
Text("{"),
Text("{"),
Text(" four "),
Text("}"),
Text("}"),
Text(" braces")
)
);
}
#[test]
fn escape_escape() {
let template = Template::parse(r"these \\ backslashes \\\\").unwrap();
assert_eq!(
template,
template!(
Text("these "),
Text(r"\"),
Text(" backslashes "),
Text(r"\"),
Text(r"\"),
)
);
}
#[test]
fn escape_before_key() {
let template = Template::parse(r"\\{ a } \{{ b } \}{ c }").unwrap();
assert_eq!(
template,
template!(
Text(r"\"),
Key("a"),
Text(" "),
Text(r"{"),
Key("b"),
Text(" "),
Text(r"}"),
Key("c"),
)
);
}
#[test]
fn escape_after_key() {
let template = Template::parse(r"{ a }\\ { b }\{ { c }\}").unwrap();
assert_eq!(
template,
template!(
Key("a"),
Text(r"\"),
Text(" "),
Key("b"),
Text(r"{"),
Text(" "),
Key("c"),
Text(r"}"),
)
);
}
#[test]
fn multibyte_texts() {
let template = Template::parse("幸徳 {particle} 秋水").unwrap();
assert_eq!(
template,
template!(Text("幸徳 "), Key("particle"), Text(" 秋水"))
);
}
#[test]
fn multibyte_key() {
let template = Template::parse("The { 連盟 }").unwrap();
assert_eq!(template, template!(Text("The "), Key("連盟")));
}
#[test]
fn multibyte_both() {
let template = Template::parse("大杉 {栄}").unwrap();
assert_eq!(template, template!(Text("大杉 "), Key("")));
}
#[test]
fn multibyte_whitespace() {
let template = Template::parse("岩佐 作{ 太 }郎").unwrap();
assert_eq!(template, template!(Text("岩佐 作"), Key(""), Text("")));
}
#[test]
fn multibyte_with_escapes() {
let template = Template::parse(r"日本\{アナキスト\}連盟").unwrap();
assert_eq!(
template,
template!(
Text("日本"),
Text(r"{"),
Text("アナキスト"),
Text(r"}"),
Text("連盟")
)
);
}
#[test]
fn multibyte_rtl_text() {
let template = Template::parse("محمد صايل").unwrap();
assert_eq!(template, template!(Text("محمد صايل")));
}
#[test]
fn multibyte_rtl_key() {
let template = Template::parse("محمد {ريشة}").unwrap();
assert_eq!(template, template!(Text("محمد "), Key("ريشة")));
}
}
#[cfg(test)]
mod test_error {
use crate::{ParseError, Template};
#[test]
fn key_left_half() {
let template = Template::parse("{ open").unwrap_err();
assert_eq!(template, ParseError::unbalanced("{ open", 0, 6));
}
#[test]
fn key_right_half() {
let template = Template::parse("open }").unwrap_err();
assert_eq!(template, ParseError::unbalanced("open }", 5, 5));
}
#[test]
fn key_with_half_escape() {
let template = Template::parse(r"this is { not \ allowed }").unwrap_err();
assert_eq!(
template,
ParseError::key_escape(r"this is { not \ allowed }", 8, 14)
);
}
#[test]
fn key_with_full_escape() {
let template = Template::parse(r"{ not \} allowed }").unwrap_err();
assert_eq!(
template,
ParseError::key_escape(r"{ not \} allowed }", 0, 6)
);
}
#[test]
fn key_empty() {
let template = Template::parse(r"void: {}").unwrap_err();
assert_eq!(template, ParseError::key_empty(r"void: {}", 6, 7));
}
#[test]
fn key_only_whitespace() {
let template = Template::parse(r"nothing: { }").unwrap_err();
assert_eq!(template, ParseError::key_empty(r"nothing: { }", 9, 11));
}
#[test]
fn bad_escape() {
let template = Template::parse(r"not \a thing").unwrap_err();
assert_eq!(template, ParseError::escape(r"not \a thing", 4, 5));
}
#[test]
fn end_escape() {
let template = Template::parse(r"forget me not \").unwrap_err();
assert_eq!(template, ParseError::escape(r"forget me not \", 14, 15));
}
}

198
crates/leon/src/template.rs Normal file
View file

@ -0,0 +1,198 @@
use std::{borrow::Cow, fmt::Display, io::Write, ops::Add};
use crate::{ParseError, RenderError, Values};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Template<'s> {
pub items: Cow<'s, [Item<'s>]>,
pub default: Option<Cow<'s, str>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Item<'s> {
Text(&'s str),
Key(&'s str),
}
impl<'s> Template<'s> {
/// Construct a template with the given items and default.
///
/// You can write a template literal without any help by constructing it directly:
///
/// ```
/// use std::borrow::Cow;
/// use leon::{Item, Template};
/// const TEMPLATE: Template = Template {
/// items: Cow::Borrowed({
/// const ITEMS: &'static [Item<'static>] = &[
/// Item::Text("Hello"),
/// Item::Key("name"),
/// ];
/// ITEMS
/// }),
/// default: None,
/// };
/// assert_eq!(TEMPLATE.render(&[("name", "world")]).unwrap(), "Helloworld");
/// ```
///
/// As that's a bit verbose, using this function and the enum shorthands can be helpful:
///
/// ```
/// use leon::{Item, Item::*, Template};
/// const TEMPLATE: Template = Template::new({
/// const ITEMS: &'static [Item<'static>] = &[Text("Hello "), Key("name")];
/// ITEMS
/// }, Some("world"));
///
/// assert_eq!(TEMPLATE.render(&[("unrelated", "value")]).unwrap(), "Hello world");
/// ```
///
/// For an even more ergonomic syntax, see the [`leon::template!`] macro.
pub const fn new(items: &'s [Item<'s>], default: Option<&'s str>) -> Template<'s> {
Template {
items: Cow::Borrowed(items),
default: match default {
Some(default) => Some(Cow::Borrowed(default)),
None => None,
},
}
}
/// Parse a template from a string.
///
/// # Syntax
///
/// ```plain
/// it is better to rule { group }
/// one can live {adverb} without power
/// ```
///
/// A replacement is denoted by `{` and `}`. The contents of the braces, trimmed
/// of any whitespace, are the key. Any text outside of braces is left as-is.
///
/// To escape a brace, use `\{` or `\}`. To escape a backslash, use `\\`. Keys
/// cannot contain escapes.
///
/// ```plain
/// \{ leon \}
/// ```
///
/// The above examples, given the values `group = "no one"` and
/// `adverb = "honourably"`, would render to:
///
/// ```plain
/// it is better to rule no one
/// one can live honourably without power
/// { leon }
/// ```
///
/// # Example
///
/// ```
/// use leon::Template;
/// let template = Template::parse("hello {name}").unwrap();
/// ```
///
pub fn parse(s: &'s str) -> Result<Self, ParseError> {
Self::parse_items(s).map(|items| Template {
items: Cow::Owned(items),
default: None,
})
}
pub fn render_into(
&self,
writer: &mut dyn Write,
values: &dyn Values,
) -> Result<(), RenderError> {
for token in self.items.as_ref() {
match token {
Item::Text(text) => writer.write_all(text.as_bytes())?,
Item::Key(key) => {
if let Some(value) = values.get_value(key) {
writer.write_all(value.as_bytes())?;
} else if let Some(default) = &self.default {
writer.write_all(default.as_bytes())?;
} else {
return Err(RenderError::MissingKey(key.to_string()));
}
}
}
}
Ok(())
}
pub fn render(&self, values: &dyn Values) -> Result<String, RenderError> {
let mut buf = Vec::with_capacity(
self.items
.iter()
.map(|item| match item {
Item::Key(_) => 0,
Item::Text(t) => t.len(),
})
.sum(),
);
self.render_into(&mut buf, values)?;
// UNWRAP: We know that the buffer is valid UTF-8 because we only write strings.
Ok(String::from_utf8(buf).unwrap())
}
pub fn has_key(&self, key: &str) -> bool {
self.has_keys(&[key])
}
pub fn has_keys(&self, keys: &[&str]) -> bool {
self.items.iter().any(|token| match token {
Item::Key(k) => keys.contains(k),
_ => false,
})
}
pub fn keys(&self) -> impl Iterator<Item = &&str> {
self.items.iter().filter_map(|token| match token {
Item::Key(k) => Some(k),
_ => None,
})
}
/// Sets the default value for this template.
pub fn set_default(&mut self, default: &dyn Display) {
self.default = Some(Cow::Owned(default.to_string()));
}
}
impl<'s> Add for Template<'s> {
type Output = Self;
fn add(mut self, rhs: Self) -> Self::Output {
self.items
.to_mut()
.extend(rhs.items.as_ref().iter().cloned());
if let Some(default) = rhs.default {
self.default = Some(default);
}
self
}
}
#[cfg(test)]
mod test {
use crate::Item::{Key, Text};
#[test]
fn concat_templates() {
let t1 = crate::template!(Text("Hello"), Key("name"));
let t2 = crate::template!(Text("have a"), Key("adjective"), Text("day"));
assert_eq!(
t1 + t2,
crate::template!(
Text("Hello"),
Key("name"),
Text("have a"),
Key("adjective"),
Text("day")
),
);
}
}

133
crates/leon/src/values.rs Normal file
View file

@ -0,0 +1,133 @@
use std::{
borrow::{Borrow, Cow},
collections::{BTreeMap, HashMap},
hash::{BuildHasher, Hash},
};
pub trait Values {
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>>;
}
impl<T> Values for &T
where
T: Values,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
T::get_value(self, key)
}
}
impl<K, V> Values for [(K, V)]
where
K: AsRef<str>,
V: AsRef<str>,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
self.iter().find_map(|(k, v)| {
if k.as_ref() == key {
Some(Cow::Borrowed(v.as_ref()))
} else {
None
}
})
}
}
impl<K, V> Values for &[(K, V)]
where
K: AsRef<str>,
V: AsRef<str>,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
(*self).get_value(key)
}
}
impl<K, V, const N: usize> Values for [(K, V); N]
where
K: AsRef<str>,
V: AsRef<str>,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
self.as_slice().get_value(key)
}
}
impl<K, V> Values for Vec<(K, V)>
where
K: AsRef<str>,
V: AsRef<str>,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
self.as_slice().get_value(key)
}
}
impl<K, V, S> Values for HashMap<K, V, S>
where
K: Borrow<str> + Eq + Hash,
V: AsRef<str>,
S: BuildHasher,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
self.get(key).map(|v| Cow::Borrowed(v.as_ref()))
}
}
impl<K, V> Values for BTreeMap<K, V>
where
K: Borrow<str> + Ord,
V: AsRef<str>,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
self.get(key).map(|v| Cow::Borrowed(v.as_ref()))
}
}
/// Workaround to allow using functions as [`Values`].
///
/// As this isn't constructible you'll want to use [`vals()`] instead.
pub struct ValuesFn<F>
where
F: for<'s> Fn(&'s str) -> Option<Cow<'s, str>> + Send + 'static,
{
inner: F,
}
impl<F> Values for ValuesFn<F>
where
F: for<'s> Fn(&'s str) -> Option<Cow<'s, str>> + Send + 'static,
{
fn get_value<'s, 'k: 's>(&'s self, key: &'k str) -> Option<Cow<'s, str>> {
(self.inner)(key)
}
}
impl<F> From<F> for ValuesFn<F>
where
F: for<'s> Fn(&'s str) -> Option<Cow<'s, str>> + Send + 'static,
{
fn from(inner: F) -> Self {
Self { inner }
}
}
/// Workaround to allow using functions as [`Values`].
///
/// Wraps your function so it implements [`Values`].
///
/// # Example
///
/// ```
/// use leon::{Values, vals};
///
/// fn use_values(_values: impl Values) {}
///
/// use_values(vals(|_| Some("hello".into())));
/// ```
pub const fn vals<F>(func: F) -> ValuesFn<F>
where
F: for<'s> Fn(&'s str) -> Option<Cow<'s, str>> + Send + 'static,
{
ValuesFn { inner: func }
}