mirror of
https://github.com/cargo-bins/cargo-binstall.git
synced 2025-05-04 19:20:03 +00:00
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:
parent
daf8cdd010
commit
2227d363f7
20 changed files with 2382 additions and 162 deletions
85
crates/leon/src/error.rs
Normal file
85
crates/leon/src/error.rs
Normal 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
148
crates/leon/src/lib.rs
Normal 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
50
crates/leon/src/macros.rs
Normal 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
61
crates/leon/src/main.rs
Normal 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
565
crates/leon/src/parser.rs
Normal 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
198
crates/leon/src/template.rs
Normal 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
133
crates/leon/src/values.rs
Normal 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 }
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue