diff --git a/CHANGELOG.md b/CHANGELOG.md index 036103c8..a4bea91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to MiniJinja are documented here. ## 2.1.1 - Added `indent` parameter to `tojson` filter. #546 +- Added `randrange`, `lipsum`, `random`, `cycler` and `joiner` to + `minijinja-contrib`. #547 ## 2.1.0 diff --git a/Cargo.lock b/Cargo.lock index 6684efbd..30688b8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1944,7 +1944,9 @@ name = "minijinja-contrib" version = "2.1.0" dependencies = [ "chrono", + "insta", "minijinja", + "rand", "serde", "similar-asserts", "time 0.3.36", diff --git a/minijinja-contrib/Cargo.toml b/minijinja-contrib/Cargo.toml index 299d7ed1..b747997f 100644 --- a/minijinja-contrib/Cargo.toml +++ b/minijinja-contrib/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "minijinja-contrib" version = "2.1.0" -edition = "2018" +edition = "2021" license = "Apache-2.0" authors = ["Armin Ronacher "] description = "Extra utilities for MiniJinja" @@ -20,14 +20,17 @@ default = [] pycompat = ["minijinja/builtins"] datetime = ["time"] timezone = ["time-tz"] +rand = ["dep:rand"] [dependencies] minijinja = { version = "2.1.0", path = "../minijinja", default-features = false } +rand = { version = "0.8.5", optional = true, default-features = false, features = ["std", "std_rng", "small_rng"] } serde = "1.0.164" time = { version = "0.3.35", optional = true, features = ["serde", "formatting", "parsing"] } time-tz = { version = "1.0.3", features = ["db"], optional = true } [dev-dependencies] +insta = { version = "1.38.0", features = ["glob", "serde"] } chrono = { version = "0.4.26", features = ["serde"] } minijinja = { version = "2.1.0", path = "../minijinja", features = ["loader"] } similar-asserts = "1.4.2" diff --git a/minijinja-contrib/src/filters/mod.rs b/minijinja-contrib/src/filters/mod.rs index 12348235..ec4ee1f0 100644 --- a/minijinja-contrib/src/filters/mod.rs +++ b/minijinja-contrib/src/filters/mod.rs @@ -58,3 +58,30 @@ pub fn pluralize(v: Value, singular: Option, plural: Option) -> Re Ok(rv) } } + +/// Choses a random element from a sequence or string. +/// +/// The random number generated can be seeded with the `RAND_SEED` +/// global context variable. +/// +/// ```jinja +/// {{ [1, 2, 3, 4]|random }} +/// ``` +#[cfg(feature = "rand")] +#[cfg_attr(docsrs, doc(cfg(feature = "rand")))] +pub fn random(state: &minijinja::State, seq: Value) -> Result { + use crate::globals::get_rng; + use minijinja::value::ValueKind; + use rand::Rng; + + if matches!(seq.kind(), ValueKind::Seq | ValueKind::String) { + let len = seq.len().unwrap_or(0); + let idx = get_rng(state).gen_range(0..len); + seq.get_item_by_index(idx) + } else { + Err(Error::new( + ErrorKind::InvalidOperation, + "can only select random elements from sequences", + )) + } +} diff --git a/minijinja-contrib/src/globals.rs b/minijinja-contrib/src/globals.rs index 54d5c271..d72c907b 100644 --- a/minijinja-contrib/src/globals.rs +++ b/minijinja-contrib/src/globals.rs @@ -1,5 +1,10 @@ +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; + #[allow(unused)] use minijinja::value::Value; +use minijinja::value::{from_args, Object, ObjectRepr}; +use minijinja::{Error, ErrorKind, State}; /// Returns the current time in UTC as unix timestamp. /// @@ -10,3 +15,268 @@ pub fn now() -> Value { let now = time::OffsetDateTime::now_utc(); Value::from(((now.unix_timestamp_nanos() / 1000) as f64) / 1_000_000.0) } + +/// Returns a cycler. +/// +/// Similar to `loop.cycle`, but can be used outside loops or across +/// multiple loops. For example, render a list of folders and files in a +/// list, alternating giving them "odd" and "even" classes. +/// +/// ```jinja +/// {% set row_class = cycler("odd", "even") %} +///
    +/// {% for folder in folders %} +///
  • {{ folder }} +/// {% endfor %} +/// {% for file in files %} +///
  • {{ file }} +/// {% endfor %} +///
+/// ``` +pub fn cycler(items: Vec) -> Result { + #[derive(Debug)] + pub struct Cycler { + items: Vec, + pos: AtomicUsize, + } + + impl Object for Cycler { + fn repr(self: &Arc) -> ObjectRepr { + ObjectRepr::Plain + } + + fn call_method( + self: &Arc, + _state: &State<'_, '_>, + method: &str, + args: &[Value], + ) -> Result { + match method { + "next" => { + from_args(args)?; + let idx = self.pos.load(Ordering::Relaxed); + self.pos + .store((idx + 1) % self.items.len(), Ordering::Relaxed); + Ok(self.items[idx].clone()) + } + _ => Err(Error::from(ErrorKind::UnknownMethod)), + } + } + } + + if items.is_empty() { + Err(Error::new( + ErrorKind::InvalidOperation, + "at least one value required", + )) + } else { + Ok(Value::from_object(Cycler { + items, + pos: AtomicUsize::new(0), + })) + } +} + +/// A tiny helper that can be used to “join” multiple sections. A +/// joiner is passed a string and will return that string every time +/// it’s called, except the first time (in which case it returns an +/// empty string). You can use this to join things: +/// +/// ```jinja +/// {% set pipe = joiner("|") %} +/// {% if categories %} {{ pipe() }} +/// Categories: {{ categories|join(", ") }} +/// {% endif %} +/// {% if author %} {{ pipe() }} +/// Author: {{ author() }} +/// {% endif %} +/// {% if can_edit %} {{ pipe() }} +/// Edit +/// {% endif %} +/// ``` +pub fn joiner(sep: Option) -> Value { + #[derive(Debug)] + struct Joiner { + sep: Value, + used: AtomicBool, + } + + impl Object for Joiner { + fn repr(self: &Arc) -> ObjectRepr { + ObjectRepr::Plain + } + + fn call(self: &Arc, _state: &State<'_, '_>, args: &[Value]) -> Result { + from_args(args)?; + let used = self.used.swap(true, Ordering::Relaxed); + if used { + Ok(self.sep.clone()) + } else { + Ok(Value::from("")) + } + } + } + + Value::from_object(Joiner { + sep: sep.unwrap_or_else(|| Value::from(", ")), + used: AtomicBool::new(false), + }) +} + +/// Returns the rng for the state +#[cfg(feature = "rand")] +pub(crate) fn get_rng(state: &State) -> rand::rngs::SmallRng { + use rand::rngs::SmallRng; + use rand::SeedableRng; + + if let Some(seed) = state + .lookup("RAND_SEED") + .and_then(|x| u64::try_from(x).ok()) + { + SmallRng::seed_from_u64(seed) + } else { + SmallRng::from_entropy() + } +} + +/// Returns a random number in a given range. +/// +/// If only one parameter is provided it's taken as exclusive upper +/// bound with 0 as lower bound, otherwise two parameters need to be +/// passed for the lower and upper bound. Only integers are permitted. +/// +/// The random number generated can be seeded with the `RAND_SEED` +/// global context variable. +#[cfg(feature = "rand")] +#[cfg_attr(docsrs, doc(cfg(feature = "rand")))] +pub fn randrange(state: &State, n: i64, m: Option) -> i64 { + use rand::Rng; + + let (lower, upper) = match m { + None => (0, n), + Some(m) => (n, m), + }; + + get_rng(state).gen_range(lower..upper) +} + +/// Generates a random lorem ipsum. +/// +/// The random number generated can be seeded with the `RAND_SEED` +/// global context variable. +/// +/// The function accepts various keyword arguments: +/// +/// * `n`: number of paragraphs to generate. +/// * `min`: minimum number of words to generate per paragraph. +/// * `max`: maximum number of words to generate per paragraph. +/// * `html`: set to `true` to generate HTML paragraphs instead. +#[cfg(feature = "rand")] +#[cfg_attr(docsrs, doc(cfg(feature = "rand")))] +pub fn lipsum( + state: &State, + n: Option, + kwargs: minijinja::value::Kwargs, +) -> Result { + use rand::seq::SliceRandom; + use rand::Rng; + + #[rustfmt::skip] + const LIPSUM_WORDS: &[&str] = &[ + "a", "ac", "accumsan", "ad", "adipiscing", "aenean", "aliquam", + "aliquet", "amet", "ante", "aptent", "arcu", "at", "auctor", "augue", + "bibendum", "blandit", "class", "commodo", "condimentum", "congue", + "consectetuer", "consequat", "conubia", "convallis", "cras", "cubilia", + "cum", "curabitur", "curae", "cursus", "dapibus", "diam", "dictum", + "dictumst", "dignissim", "dis", "dolor", "donec", "dui", "duis", + "egestas", "eget", "eleifend", "elementum", "elit", "enim", "erat", + "eros", "est", "et", "etiam", "eu", "euismod", "facilisi", "facilisis", + "fames", "faucibus", "felis", "fermentum", "feugiat", "fringilla", + "fusce", "gravida", "habitant", "habitasse", "hac", "hendrerit", + "hymenaeos", "iaculis", "id", "imperdiet", "in", "inceptos", "integer", + "interdum", "ipsum", "justo", "lacinia", "lacus", "laoreet", "lectus", + "leo", "libero", "ligula", "litora", "lobortis", "lorem", "luctus", + "maecenas", "magna", "magnis", "malesuada", "massa", "mattis", "mauris", + "metus", "mi", "molestie", "mollis", "montes", "morbi", "mus", "nam", + "nascetur", "natoque", "nec", "neque", "netus", "nibh", "nisi", "nisl", + "non", "nonummy", "nostra", "nulla", "nullam", "nunc", "odio", "orci", + "ornare", "parturient", "pede", "pellentesque", "penatibus", "per", + "pharetra", "phasellus", "placerat", "platea", "porta", "porttitor", + "posuere", "potenti", "praesent", "pretium", "primis", "proin", + "pulvinar", "purus", "quam", "quis", "quisque", "rhoncus", "ridiculus", + "risus", "rutrum", "sagittis", "sapien", "scelerisque", "sed", "sem", + "semper", "senectus", "sit", "sociis", "sociosqu", "ssociis", + "sociosqu", "ssociis", "sociosqu", "ssociis", "sociosqu", "ssociis", + "sociosqu", "ssoincidusociis", "sociosqu", "ssociis", "sociosqu", + "ssociis", "sociosqu", "ssociis", "sociosqu", "ssociis", "vsociis", + "sociosqu", "ssociis", "sociosqu", "ssociis", "sociosqu", "ssociis", + "sociosqu", "ssociis", "s", "vulputate", + ]; + + let n_kwargs: Option = kwargs.get("n")?; + let min: Option = kwargs.get("min")?; + let min = min.unwrap_or(20); + let max: Option = kwargs.get("max")?; + let max = max.unwrap_or(100); + let html: Option = kwargs.get("html")?; + let html = html.unwrap_or(false); + let n = n.or(n_kwargs).unwrap_or(5); + let mut rv = String::new(); + + let mut rng = get_rng(state); + + for _ in 0..n { + let mut next_capitalized = true; + let mut last_fullstop = 0; + let mut last = ""; + + for idx in 0..rng.gen_range(min..max) { + if idx > 0 { + rv.push(' '); + } else if html { + rv.push_str("

"); + } + let word = loop { + let word = LIPSUM_WORDS.choose(&mut rng).copied().unwrap_or(""); + if word != last { + last = word; + break word; + } + }; + + if next_capitalized { + for (idx, c) in word.char_indices() { + if idx == 0 { + use std::fmt::Write; + write!(rv, "{}", c.to_uppercase()).ok(); + } else { + rv.push(c); + } + } + next_capitalized = false; + } else { + rv.push_str(word); + } + + if idx - last_fullstop > rng.gen_range(10..20) { + rv.push('.'); + last_fullstop = idx; + next_capitalized = true; + } + } + + if !rv.ends_with('.') { + rv.push('.'); + } + if html { + rv.push_str("

"); + } + rv.push_str("\n\n"); + } + + if html { + Ok(Value::from_safe_string(rv)) + } else { + Ok(Value::from(rv)) + } +} diff --git a/minijinja-contrib/src/lib.rs b/minijinja-contrib/src/lib.rs index e7f2df37..71137e12 100644 --- a/minijinja-contrib/src/lib.rs +++ b/minijinja-contrib/src/lib.rs @@ -40,4 +40,12 @@ pub fn add_to_environment(env: &mut Environment) { env.add_filter("dateformat", filters::dateformat); env.add_function("now", globals::now); } + #[cfg(feature = "rand")] + { + env.add_filter("random", filters::random); + env.add_filter("randrange", globals::randrange); + env.add_filter("lipsum", globals::lipsum); + } + env.add_function("cycler", globals::cycler); + env.add_function("joiner", globals::joiner); } diff --git a/minijinja-contrib/tests/filters.rs b/minijinja-contrib/tests/filters.rs index ae920621..860f63f1 100644 --- a/minijinja-contrib/tests/filters.rs +++ b/minijinja-contrib/tests/filters.rs @@ -1,11 +1,10 @@ +use minijinja::{context, Environment}; use minijinja_contrib::filters::pluralize; use similar_asserts::assert_eq; #[test] fn test_pluralize() { - use minijinja::context; - - let mut env = minijinja::Environment::new(); + let mut env = Environment::new(); env.add_filter("pluralize", pluralize); for (num, s) in [ @@ -92,3 +91,16 @@ fn test_pluralize() { a length but of type number (in :1)", ); } + +#[test] +#[cfg(feature = "rand")] +fn test_random() { + use minijinja::render; + use minijinja_contrib::filters::random; + + let mut env = Environment::new(); + env.add_filter("random", random); + + insta::assert_snapshot!(render!(in env, r"{% set RAND_SEED = 42 %}{{ [1, 2, 3, 4]|random }}"), @"2"); + insta::assert_snapshot!(render!(in env, r"{% set RAND_SEED = 42 %}{{ 'HelloWorld'|random }}"), @"e"); +} diff --git a/minijinja-contrib/tests/globals.rs b/minijinja-contrib/tests/globals.rs new file mode 100644 index 00000000..d63cf54d --- /dev/null +++ b/minijinja-contrib/tests/globals.rs @@ -0,0 +1,85 @@ +use insta::assert_snapshot; +use minijinja::{render, Environment}; +use minijinja_contrib::globals::{cycler, joiner}; + +#[test] +fn test_cycler() { + let mut env = Environment::new(); + env.add_function("cycler", cycler); + + assert_snapshot!(render!(in env, r"{% set c = cycler([1, 2]) -%} +next(): {{ c.next() }} +next(): {{ c.next() }} +next(): {{ c.next() }} +cycler: {{ c }}"), @r###" + next(): 1 + next(): 2 + next(): 1 + cycler: Cycler { items: [1, 2], pos: 1 } + "###); +} + +#[test] +fn test_joiner() { + let mut env = Environment::new(); + env.add_function("joiner", joiner); + + assert_snapshot!(render!(in env, r"{% set j = joiner() -%} +first: [{{ j() }}] +second: [{{ j() }}] +joiner: {{ j }}"), @r###" + first: [] + second: [, ] + joiner: Joiner { sep: ", ", used: true } + "###); + + assert_snapshot!(render!(in env, r"{% set j = joiner('|') -%} +first: [{{ j() }}] +second: [{{ j() }}] +joiner: {{ j }}"), @r###" + first: [] + second: [|] + joiner: Joiner { sep: "|", used: true } + "###); +} + +#[test] +#[cfg(feature = "rand")] +fn test_lispum() { + use minijinja_contrib::globals::lipsum; + + let mut env = Environment::new(); + env.add_function("lipsum", lipsum); + + assert_snapshot!(render!(in env, r"{% set RAND_SEED = 42 %}{{ lipsum(5) }}"), @r###" + Facilisi accumsan class rutrum integer euismod gravida cras vsociis arcu lobortis sociosqu elementum lacus nulla. Leo imperdiet penatibus id quam malesuada pretium sociosqu scelerisque diam sociosqu penatibus imperdiet et nisl. Ante s vulputate nulla porta ssociis per gravida primis porta penatibus nostra congue dui. + + Ipsum cras integer magna ssociis etiam eu rutrum ac praesent ssociis primis nisl malesuada sociosqu. Senectus sem neque ridiculus aliquet duis nisl facilisis quam diam nibh ad eget. Rutrum mauris aliquam faucibus magna eu phasellus ssociis libero neque convallis magna. Ante aliquet proin montes nibh sociosqu vulputate auctor. + + Lacinia aliquam dictumst pellentesque nibh sociosqu sagittis leo ad dictum elementum sapien mi sociosqu. Et ssociis laoreet dolor egestas scelerisque potenti duis natoque ssociis feugiat. Proin luctus porta rhoncus quis phasellus netus non proin sociosqu nonummy ornare lacinia. Leo sociis inceptos cum leo non elit class sed sapien dictum diam mattis dapibus netus facilisis. Hendrerit montes aliquam ssociis ridiculus a cras sociosqu nisi ssociis curabitur. + + Justo nonummy pulvinar potenti in potenti at facilisi platea sagittis scelerisque quis sapien semper dictum in ipsum. Nunc nonummy ornare etiam elementum nullam curae eu nullam ad nascetur ssociis nullam mus. Nisi ssociis gravida dapibus non sociosqu laoreet adipiscing potenti ipsum parturient potenti mollis odio. Leo eget felis pretium libero consectetuer hymenaeos sociosqu ssociis in posuere. + + S commodo fames ridiculus luctus proin non aptent nullam mi eleifend consectetuer aliquam ad. Scelerisque nisl blandit sociis euismod curae semper nunc nec litora condimentum fames habitasse. Inceptos augue sociosqu hendrerit justo montes orci proin mus molestie id iaculis nostra lacus. Cum facilisis potenti facilisis nonummy sem. + + "###); + + assert_snapshot!(render!(in env, r"{% set RAND_SEED = 42 %}{{ lipsum(2, html=true) }}"), @r###" +

Facilisi accumsan class rutrum integer euismod gravida cras vsociis arcu lobortis sociosqu elementum lacus nulla. Leo imperdiet penatibus id quam malesuada pretium sociosqu scelerisque diam sociosqu penatibus imperdiet et nisl. Ante s vulputate nulla porta ssociis per gravida primis porta penatibus nostra congue dui.

+ +

Ipsum cras integer magna ssociis etiam eu rutrum ac praesent ssociis primis nisl malesuada sociosqu. Senectus sem neque ridiculus aliquet duis nisl facilisis quam diam nibh ad eget. Rutrum mauris aliquam faucibus magna eu phasellus ssociis libero neque convallis magna. Ante aliquet proin montes nibh sociosqu vulputate auctor.

+ + "###); +} + +#[test] +#[cfg(feature = "rand")] +fn test_randrange() { + use minijinja_contrib::globals::randrange; + + let mut env = Environment::new(); + env.add_function("randrange", randrange); + + assert_snapshot!(render!(in env, r"{% set RAND_SEED = 42 %}{{ randrange(10) }}"), @"1"); + assert_snapshot!(render!(in env, r"{% set RAND_SEED = 42 %}{{ randrange(-50, 50) }}"), @"-20"); +}