use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc};
use minijinja::{Environment, Value};
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::cmp::Ordering;
use std::fmt::Write as _;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::utils::html;
const WEEKDAYS: [&str; 7] = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const WEEKDAYS_ABBR: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const MONTHS_ABBR: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const MONTHS_AP: [&str; 12] = [
"Jan.", "Feb.", "March", "April", "May", "June", "July", "Aug.", "Sept.", "Oct.", "Nov.",
"Dec.",
];
pub fn register_default_filters(env: &mut Environment<'_>) {
env.add_filter("addslashes", filter_addslashes);
env.add_filter("capfirst", filter_capfirst);
env.add_filter("center", filter_center);
env.add_filter("cut", filter_cut);
env.add_filter("escapejs", filter_escapejs);
env.add_filter("floatformat", filter_floatformat);
env.add_filter("linenumbers", filter_linenumbers);
env.add_filter("ljust", filter_ljust);
env.add_filter("lower", filter_lower);
env.add_filter("make_list", filter_make_list);
env.add_filter("phone2numeric", filter_phone2numeric);
env.add_filter("pluralize", filter_pluralize);
env.add_filter("rjust", filter_rjust);
env.add_filter("slugify", filter_slugify);
env.add_filter("stringformat", filter_stringformat);
env.add_filter("title", filter_title);
env.add_filter("truncatechars", filter_truncatechars);
env.add_filter("truncatewords", filter_truncatewords);
env.add_filter("upper", filter_upper);
env.add_filter("urlencode", filter_urlencode);
env.add_filter("wordcount", filter_wordcount);
env.add_filter("wordwrap", filter_wordwrap);
env.add_filter("dictsort", filter_dictsort);
env.add_filter("dictsortreversed", filter_dictsortreversed);
env.add_filter("first", filter_first);
env.add_filter("join", filter_join);
env.add_filter("last", filter_last);
env.add_filter("length", filter_length);
env.add_filter("length_is", filter_length_is);
env.add_filter("random", filter_random);
env.add_filter("slice", filter_slice);
env.add_filter("unordered_list", filter_unordered_list);
env.add_filter("yesno", filter_yesno);
env.add_filter("date", filter_date);
env.add_filter("time", filter_time);
env.add_filter("timesince", filter_timesince);
env.add_filter("timeuntil", filter_timeuntil);
env.add_filter("default", filter_default);
env.add_filter("default_if_none", filter_default_if_none);
env.add_filter("divisibleby", filter_divisibleby);
env.add_filter("escape", filter_escape);
env.add_filter("force_escape", filter_force_escape);
env.add_filter("linebreaks", filter_linebreaks);
env.add_filter("linebreaksbr", filter_linebreaksbr);
env.add_filter("safe", filter_safe);
env.add_filter("striptags", filter_striptags);
env.add_filter("filesizeformat", filter_filesizeformat);
env.add_filter("get_digit", filter_get_digit);
env.add_filter("pprint", filter_pprint);
}
fn to_json_value(value: &Value) -> JsonValue {
JsonValue::deserialize(value.clone()).unwrap_or_else(|_| JsonValue::String(value.to_string()))
}
fn value_from_json(value: JsonValue) -> Value {
Value::from_serialize(value)
}
fn safe_value(value: impl Into<String>) -> Value {
Value::from_safe_string(value.into())
}
fn json_to_string(value: &JsonValue) -> String {
match value {
JsonValue::Null => String::new(),
JsonValue::Bool(boolean) => boolean.to_string(),
JsonValue::Number(number) => number.to_string(),
JsonValue::String(string) => string.clone(),
JsonValue::Array(array) => array
.iter()
.map(json_to_string)
.collect::<Vec<_>>()
.join(", "),
JsonValue::Object(object) => serde_json::to_string(object).unwrap_or_default(),
}
}
fn json_length(value: &JsonValue) -> usize {
match value {
JsonValue::Null => 0,
JsonValue::String(string) => string.chars().count(),
JsonValue::Array(array) => array.len(),
JsonValue::Object(object) => object.len(),
JsonValue::Bool(_) | JsonValue::Number(_) => json_to_string(value).chars().count(),
}
}
fn is_truthy(value: &JsonValue) -> bool {
match value {
JsonValue::Null => false,
JsonValue::Bool(boolean) => *boolean,
JsonValue::Number(number) => {
if let Some(integer) = number.as_i64() {
integer != 0
} else if let Some(unsigned) = number.as_u64() {
unsigned != 0
} else {
number.as_f64().is_some_and(|float| float != 0.0)
}
}
JsonValue::String(string) => !string.is_empty(),
JsonValue::Array(array) => !array.is_empty(),
JsonValue::Object(object) => !object.is_empty(),
}
}
fn parse_i64(value: &JsonValue) -> Option<i64> {
match value {
JsonValue::Number(number) => number.as_i64().or_else(|| {
number
.as_u64()
.and_then(|unsigned| i64::try_from(unsigned).ok())
}),
JsonValue::String(string) => string.trim().parse().ok(),
JsonValue::Bool(boolean) => Some(i64::from(*boolean)),
JsonValue::Null | JsonValue::Array(_) | JsonValue::Object(_) => None,
}
}
fn parse_f64(value: &JsonValue) -> Option<f64> {
match value {
JsonValue::Number(number) => number.as_f64(),
JsonValue::String(string) => string.trim().parse().ok(),
JsonValue::Bool(boolean) => Some(if *boolean { 1.0 } else { 0.0 }),
JsonValue::Null | JsonValue::Array(_) | JsonValue::Object(_) => None,
}
}
fn parse_naive_date(value: &JsonValue) -> Option<NaiveDate> {
match value {
JsonValue::String(string) => NaiveDate::parse_from_str(string.trim(), "%Y-%m-%d").ok(),
_ => None,
}
}
fn parse_naive_time(value: &JsonValue) -> Option<NaiveTime> {
match value {
JsonValue::String(string) => ["%H:%M:%S", "%H:%M"]
.into_iter()
.find_map(|format| NaiveTime::parse_from_str(string.trim(), format).ok()),
_ => None,
}
}
fn parse_datetime(value: &JsonValue) -> Option<DateTime<Utc>> {
match value {
JsonValue::Null => None,
JsonValue::String(string) => {
let trimmed = string.trim();
DateTime::parse_from_rfc3339(trimmed)
.map(|datetime| datetime.with_timezone(&Utc))
.ok()
.or_else(|| {
["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S"]
.into_iter()
.find_map(|format| NaiveDateTime::parse_from_str(trimmed, format).ok())
.map(|datetime| DateTime::from_naive_utc_and_offset(datetime, Utc))
})
.or_else(|| {
parse_naive_date(value).and_then(|date| {
date.and_hms_opt(0, 0, 0)
.map(|datetime| DateTime::from_naive_utc_and_offset(datetime, Utc))
})
})
.or_else(|| {
parse_naive_time(value).and_then(|time| {
NaiveDate::from_ymd_opt(1970, 1, 1)
.map(|date| date.and_time(time))
.map(|datetime| DateTime::from_naive_utc_and_offset(datetime, Utc))
})
})
}
JsonValue::Number(number) => number
.as_i64()
.and_then(|seconds| DateTime::from_timestamp(seconds, 0)),
JsonValue::Bool(_) | JsonValue::Array(_) | JsonValue::Object(_) => None,
}
}
fn format_django_datetime(datetime: &DateTime<Utc>, format_string: &str) -> String {
let mut output = String::with_capacity(format_string.len() + 16);
let mut escaped = false;
for ch in format_string.chars() {
if escaped {
output.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
match ch {
'Y' => {
let _ = write!(output, "{:04}", datetime.year());
}
'm' => {
let _ = write!(output, "{:02}", datetime.month());
}
'd' => {
let _ = write!(output, "{:02}", datetime.day());
}
'H' => {
let _ = write!(output, "{:02}", datetime.hour());
}
'i' => {
let _ = write!(output, "{:02}", datetime.minute());
}
's' => {
let _ = write!(output, "{:02}", datetime.second());
}
'D' => {
output.push_str(WEEKDAYS_ABBR[datetime.weekday().num_days_from_monday() as usize])
}
'l' => output.push_str(WEEKDAYS[datetime.weekday().num_days_from_monday() as usize]),
'M' => output.push_str(MONTHS_ABBR[datetime.month0() as usize]),
'F' => output.push_str(MONTHS[datetime.month0() as usize]),
'N' => output.push_str(MONTHS_AP[datetime.month0() as usize]),
'j' => {
let _ = write!(output, "{}", datetime.day());
}
'G' => {
let _ = write!(output, "{}", datetime.hour());
}
'g' => {
let _ = write!(
output,
"{}",
if datetime.hour().is_multiple_of(12) {
12
} else {
datetime.hour() % 12
}
);
}
'A' => output.push_str(if datetime.hour() < 12 { "AM" } else { "PM" }),
'P' => output.push_str(&format_ap_time(datetime)),
_ => output.push(ch),
}
}
if escaped {
output.push('\\');
}
output
}
fn format_ap_time(datetime: &DateTime<Utc>) -> String {
if datetime.hour() == 0 && datetime.minute() == 0 {
return "midnight".to_owned();
}
if datetime.hour() == 12 && datetime.minute() == 0 {
return "noon".to_owned();
}
let meridiem = if datetime.hour() < 12 { "a.m." } else { "p.m." };
let hour = if datetime.hour().is_multiple_of(12) {
12
} else {
datetime.hour() % 12
};
if datetime.minute() == 0 {
format!("{hour} {meridiem}")
} else {
format!("{hour}:{:02} {meridiem}", datetime.minute())
}
}
fn humanize_duration(delta: Duration) -> String {
let mut seconds = delta.num_seconds().max(0);
let units = [
("year", 365 * 24 * 60 * 60),
("month", 30 * 24 * 60 * 60),
("week", 7 * 24 * 60 * 60),
("day", 24 * 60 * 60),
("hour", 60 * 60),
("minute", 60),
("second", 1),
];
let mut parts = Vec::new();
for (label, unit_seconds) in units {
if seconds < unit_seconds {
continue;
}
let amount = seconds / unit_seconds;
seconds %= unit_seconds;
let suffix = if amount == 1 { "" } else { "s" };
parts.push(format!("{amount} {label}{suffix}"));
if parts.len() == 2 {
break;
}
}
if parts.is_empty() {
"0 minutes".to_owned()
} else {
parts.join(", ")
}
}
fn char_len(value: &str) -> usize {
value.chars().count()
}
fn take_chars(value: &str, count: usize) -> String {
value.chars().take(count).collect()
}
fn pad_left_and_right(value: &str, left: usize, right: usize) -> String {
let mut output = String::with_capacity(value.len() + left + right);
output.push_str(&" ".repeat(left));
output.push_str(value);
output.push_str(&" ".repeat(right));
output
}
fn title_case(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut new_word = true;
for ch in value.chars() {
if ch.is_alphanumeric() {
if new_word {
output.extend(ch.to_uppercase());
new_word = false;
} else {
output.extend(ch.to_lowercase());
}
} else {
output.push(ch);
new_word = matches!(ch, ' ' | '\t' | '\n' | '\r' | '-' | '_' | '/');
}
}
output
}
fn slugify_string(value: &str) -> String {
let mut slug = String::with_capacity(value.len());
let mut previous_dash = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
previous_dash = false;
} else if (ch.is_whitespace() || matches!(ch, '-' | '_'))
&& !previous_dash
&& !slug.is_empty()
{
slug.push('-');
previous_dash = true;
}
}
slug.trim_matches('-').to_owned()
}
fn wrap_words(value: &str, width: usize) -> String {
if width == 0 {
return value.to_owned();
}
let mut lines = Vec::new();
let mut current = String::new();
for word in value.split_whitespace() {
let current_width = char_len(¤t);
let word_width = char_len(word);
if current.is_empty() {
current.push_str(word);
} else if current_width + 1 + word_width <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_owned();
}
}
if !current.is_empty() {
lines.push(current);
}
lines.join("\n")
}
fn compare_json(left: &JsonValue, right: &JsonValue) -> Ordering {
match (parse_f64(left), parse_f64(right)) {
(Some(left), Some(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal),
_ => json_to_string(left).cmp(&json_to_string(right)),
}
}
fn maybe_get_key<'a>(value: &'a JsonValue, key: &str) -> Option<&'a JsonValue> {
match value {
JsonValue::Object(object) => object.get(key),
_ => None,
}
}
fn random_index(len: usize) -> usize {
if len == 0 {
return 0;
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.subsec_nanos())
.unwrap_or(0);
usize::try_from(nanos).unwrap_or(0) % len
}
fn parse_slice_spec(spec: &str) -> (Option<isize>, Option<isize>) {
let mut parts = spec.splitn(3, ':');
let start = parts.next().and_then(|part| {
if part.is_empty() {
None
} else {
part.parse::<isize>().ok()
}
});
let stop = parts.next().and_then(|part| {
if part.is_empty() {
None
} else {
part.parse::<isize>().ok()
}
});
(start, stop)
}
fn normalize_index(index: Option<isize>, len: usize, default: usize) -> usize {
match index {
Some(index) if index < 0 => {
let normalized = len as isize + index;
usize::try_from(normalized.max(0)).unwrap_or(0)
}
Some(index) => usize::try_from(index).unwrap_or(default).min(len),
None => default,
}
}
fn render_unordered_items(items: &[JsonValue]) -> String {
let mut output = String::from("<ul>");
for item in items {
output.push_str("<li>");
match item {
JsonValue::Array(parts) if !parts.is_empty() => {
output.push_str(&html::escape(&json_to_string(&parts[0])));
if let Some(JsonValue::Array(children)) = parts.get(1) {
output.push_str(&render_unordered_items(children));
}
}
_ => output.push_str(&html::escape(&json_to_string(item))),
}
output.push_str("</li>");
}
output.push_str("</ul>");
output
}
fn format_with_spec(value: &JsonValue, spec: &str) -> String {
let Some(kind) = spec.chars().last() else {
return json_to_string(value);
};
let mut body = spec[..spec.len().saturating_sub(1)].to_owned();
let zero_pad = body.starts_with('0');
if zero_pad {
body.remove(0);
}
let (width, precision) = if let Some((width, precision)) = body.split_once('.') {
(width.parse::<usize>().ok(), precision.parse::<usize>().ok())
} else {
(body.parse::<usize>().ok(), None)
};
match kind {
's' => {
let string = json_to_string(value);
match width {
Some(width) => format!("{string:>width$}"),
None => string,
}
}
'd' | 'i' | 'u' => parse_i64(value).map_or_else(
|| json_to_string(value),
|number| match width {
Some(width) if zero_pad => format!("{number:0width$}"),
Some(width) => format!("{number:>width$}"),
None => number.to_string(),
},
),
'f' => parse_f64(value).map_or_else(
|| json_to_string(value),
|number| {
let precision = precision.unwrap_or(6);
match (zero_pad, width) {
(true, Some(width)) => format!("{number:0width$.precision$}"),
(false, Some(width)) => format!("{number:>width$.precision$}"),
(_, None) => format!("{number:.precision$}"),
}
},
),
_ => json_to_string(value),
}
}
fn is_pluralize_singular(value: &JsonValue) -> bool {
if let Some(number) = parse_i64(value) {
return number == 1;
}
match value {
JsonValue::String(string) => string == "1" || string.chars().count() == 1,
JsonValue::Array(array) => array.len() == 1,
JsonValue::Object(object) => object.len() == 1,
JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => false,
}
}
fn percent_encode_url_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(char::from(byte));
} else {
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
fn filter_addslashes(value: Value) -> Value {
let value = json_to_string(&to_json_value(&value));
Value::from(
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\'', "\\\'"),
)
}
fn filter_capfirst(value: Value) -> Value {
let value = json_to_string(&to_json_value(&value));
let mut chars = value.chars();
match chars.next() {
Some(first) => {
let mut output = String::new();
output.extend(first.to_uppercase());
output.push_str(chars.as_str());
Value::from(output)
}
None => Value::from(String::new()),
}
}
fn filter_center(value: Value, width: usize) -> Value {
let value = json_to_string(&to_json_value(&value));
let value_width = char_len(&value);
if width <= value_width {
return Value::from(value);
}
let total_padding = width - value_width;
let left = total_padding / 2;
let right = total_padding - left;
Value::from(pad_left_and_right(&value, left, right))
}
fn filter_cut(value: Value, arg: String) -> Value {
let value = json_to_string(&to_json_value(&value));
Value::from(value.replace(&arg, ""))
}
fn filter_escapejs(value: Value) -> Value {
safe_value(html::escapejs(&json_to_string(&to_json_value(&value))))
}
fn filter_floatformat(value: Value, decimal_places: Option<i32>) -> Value {
let value = to_json_value(&value);
let Some(number) = parse_f64(&value) else {
return Value::from(json_to_string(&value));
};
if !number.is_finite() {
return Value::from(number.to_string());
}
let places = decimal_places.unwrap_or(-1);
let precision = usize::try_from(places.unsigned_abs()).unwrap_or(0);
let mut formatted = format!("{number:.precision$}");
if places < 0 {
while formatted.contains('.') && formatted.ends_with('0') {
formatted.pop();
}
if formatted.ends_with('.') {
formatted.pop();
}
}
Value::from(formatted)
}
fn filter_linenumbers(value: Value) -> Value {
let string = json_to_string(&to_json_value(&value));
let lines: Vec<&str> = string.split('\n').collect();
let width = lines.len().to_string().len();
Value::from(
lines
.iter()
.enumerate()
.map(|(index, line)| format!("{:0width$}. {line}", index + 1, width = width))
.collect::<Vec<_>>()
.join("\n"),
)
}
fn filter_ljust(value: Value, width: usize) -> Value {
let value = json_to_string(&to_json_value(&value));
let padding = width.saturating_sub(char_len(&value));
Value::from(pad_left_and_right(&value, 0, padding))
}
fn filter_lower(value: Value) -> Value {
Value::from(json_to_string(&to_json_value(&value)).to_lowercase())
}
fn filter_make_list(value: Value) -> Value {
let value = json_to_string(&to_json_value(&value));
value_from_json(JsonValue::Array(
value
.chars()
.map(|ch| JsonValue::String(ch.to_string()))
.collect(),
))
}
fn filter_phone2numeric(value: Value) -> Value {
let mapped = json_to_string(&to_json_value(&value))
.chars()
.map(|ch| match ch.to_ascii_uppercase() {
'A' | 'B' | 'C' => '2',
'D' | 'E' | 'F' => '3',
'G' | 'H' | 'I' => '4',
'J' | 'K' | 'L' => '5',
'M' | 'N' | 'O' => '6',
'P' | 'Q' | 'R' | 'S' => '7',
'T' | 'U' | 'V' => '8',
'W' | 'X' | 'Y' | 'Z' => '9',
other => other,
})
.collect::<String>();
Value::from(mapped)
}
fn filter_pluralize(value: Value, suffix: Option<String>) -> Value {
let value = to_json_value(&value);
let suffix = suffix.unwrap_or_else(|| "s".to_owned());
let is_singular = is_pluralize_singular(&value);
let result = match suffix.split_once(',') {
Some((singular, plural)) => {
if is_singular {
singular
} else {
plural
}
}
None => {
if is_singular {
""
} else {
&suffix
}
}
};
Value::from(result.to_owned())
}
fn filter_rjust(value: Value, width: usize) -> Value {
let value = json_to_string(&to_json_value(&value));
let padding = width.saturating_sub(char_len(&value));
Value::from(pad_left_and_right(&value, padding, 0))
}
fn filter_slugify(value: Value) -> Value {
Value::from(slugify_string(&json_to_string(&to_json_value(&value))))
}
fn filter_stringformat(value: Value, spec: String) -> Value {
let value = to_json_value(&value);
Value::from(format_with_spec(&value, &spec))
}
fn filter_title(value: Value) -> Value {
Value::from(title_case(&json_to_string(&to_json_value(&value))))
}
fn filter_truncatechars(value: Value, length: usize) -> Value {
let value = json_to_string(&to_json_value(&value));
let value_length = char_len(&value);
if value_length <= length {
return Value::from(value);
}
if length <= 3 {
return Value::from(".".repeat(length));
}
Value::from(format!("{}...", take_chars(&value, length - 3)))
}
fn filter_truncatewords(value: Value, count: usize) -> Value {
let value = json_to_string(&to_json_value(&value));
let words: Vec<&str> = value.split_whitespace().collect();
if words.len() <= count {
return Value::from(words.join(" "));
}
Value::from(format!("{}...", words[..count].join(" ")))
}
fn filter_upper(value: Value) -> Value {
Value::from(json_to_string(&to_json_value(&value)).to_uppercase())
}
fn filter_urlencode(value: Value) -> Value {
Value::from(percent_encode_url_component(&json_to_string(
&to_json_value(&value),
)))
}
fn filter_wordcount(value: Value) -> Value {
let count = json_to_string(&to_json_value(&value))
.split_whitespace()
.count();
Value::from(count)
}
fn filter_wordwrap(value: Value, width: usize) -> Value {
Value::from(wrap_words(&json_to_string(&to_json_value(&value)), width))
}
fn filter_dictsort(value: Value, key: String) -> Value {
let value = to_json_value(&value);
let JsonValue::Array(mut items) = value else {
return Value::from(String::new());
};
items.sort_by(|left, right| {
compare_json(
maybe_get_key(left, &key).unwrap_or(&JsonValue::Null),
maybe_get_key(right, &key).unwrap_or(&JsonValue::Null),
)
});
value_from_json(JsonValue::Array(items))
}
fn filter_dictsortreversed(value: Value, key: String) -> Value {
let value = to_json_value(&value);
let JsonValue::Array(mut items) = value else {
return Value::from(String::new());
};
items.sort_by(|left, right| {
compare_json(
maybe_get_key(right, &key).unwrap_or(&JsonValue::Null),
maybe_get_key(left, &key).unwrap_or(&JsonValue::Null),
)
});
value_from_json(JsonValue::Array(items))
}
fn filter_first(value: Value) -> Value {
match to_json_value(&value) {
JsonValue::Array(items) => items
.into_iter()
.next()
.map_or_else(|| Value::from(String::new()), value_from_json),
JsonValue::String(string) => Value::from(
string
.chars()
.next()
.map_or_else(String::new, |ch| ch.to_string()),
),
other => Value::from(json_to_string(&other)),
}
}
fn filter_join(value: Value, separator: String) -> Value {
match to_json_value(&value) {
JsonValue::Array(items) => Value::from(
items
.iter()
.map(json_to_string)
.collect::<Vec<_>>()
.join(&separator),
),
JsonValue::String(string) => Value::from(
string
.chars()
.map(|ch| ch.to_string())
.collect::<Vec<_>>()
.join(&separator),
),
other => Value::from(json_to_string(&other)),
}
}
fn filter_last(value: Value) -> Value {
match to_json_value(&value) {
JsonValue::Array(items) => items
.into_iter()
.last()
.map_or_else(|| Value::from(String::new()), value_from_json),
JsonValue::String(string) => Value::from(
string
.chars()
.last()
.map_or_else(String::new, |ch| ch.to_string()),
),
other => Value::from(json_to_string(&other)),
}
}
fn filter_length(value: Value) -> Value {
Value::from(json_length(&to_json_value(&value)))
}
fn filter_length_is(value: Value, expected: usize) -> Value {
Value::from(json_length(&to_json_value(&value)) == expected)
}
fn filter_random(value: Value) -> Value {
match to_json_value(&value) {
JsonValue::Array(items) => items
.get(random_index(items.len()))
.cloned()
.map_or_else(|| Value::from(String::new()), value_from_json),
JsonValue::String(string) => {
let chars: Vec<char> = string.chars().collect();
chars.get(random_index(chars.len())).map_or_else(
|| Value::from(String::new()),
|ch| Value::from(ch.to_string()),
)
}
other => Value::from(json_to_string(&other)),
}
}
fn filter_slice(value: Value, spec: String) -> Value {
let (start, stop) = parse_slice_spec(&spec);
match to_json_value(&value) {
JsonValue::Array(items) => {
let len = items.len();
let start = normalize_index(start, len, 0);
let stop = normalize_index(stop, len, len);
value_from_json(JsonValue::Array(items[start.min(stop)..stop].to_vec()))
}
JsonValue::String(string) => {
let chars: Vec<char> = string.chars().collect();
let len = chars.len();
let start = normalize_index(start, len, 0);
let stop = normalize_index(stop, len, len);
Value::from(chars[start.min(stop)..stop].iter().collect::<String>())
}
other => Value::from(json_to_string(&other)),
}
}
fn filter_unordered_list(value: Value) -> Value {
match to_json_value(&value) {
JsonValue::Array(items) => safe_value(render_unordered_items(&items)),
other => safe_value(render_unordered_items(&[other])),
}
}
fn filter_yesno(value: Value, mapping: Option<String>) -> Value {
let value = to_json_value(&value);
let mapping = mapping.unwrap_or_else(|| "yes,no,maybe".to_owned());
let parts: Vec<&str> = mapping.split(',').collect();
if parts.len() < 2 {
return Value::from(String::new());
}
let result = if matches!(value, JsonValue::Null) {
parts.get(2).copied().unwrap_or(parts[1])
} else if is_truthy(&value) {
parts[0]
} else {
parts[1]
};
Value::from(result.to_owned())
}
fn filter_date(value: Value, format: Option<String>) -> Value {
let value = to_json_value(&value);
let Some(datetime) = parse_datetime(&value) else {
return Value::from(String::new());
};
Value::from(format_django_datetime(
&datetime,
format.as_deref().unwrap_or("N j, Y"),
))
}
fn filter_time(value: Value, format: Option<String>) -> Value {
let value = to_json_value(&value);
if let Some(time) = parse_naive_time(&value)
&& let Some(date) = NaiveDate::from_ymd_opt(1970, 1, 1)
{
let datetime = DateTime::from_naive_utc_and_offset(date.and_time(time), Utc);
return Value::from(format_django_datetime(
&datetime,
format.as_deref().unwrap_or("P"),
));
}
let Some(datetime) = parse_datetime(&value) else {
return Value::from(String::new());
};
Value::from(format_django_datetime(
&datetime,
format.as_deref().unwrap_or("P"),
))
}
fn filter_timesince(value: Value, other: Option<Value>) -> Value {
let value = to_json_value(&value);
let Some(value) = parse_datetime(&value) else {
return Value::from(String::new());
};
let reference = other
.as_ref()
.map(to_json_value)
.and_then(|json| parse_datetime(&json))
.unwrap_or_else(Utc::now);
Value::from(humanize_duration(reference.signed_duration_since(value)))
}
fn filter_timeuntil(value: Value, other: Option<Value>) -> Value {
let value = to_json_value(&value);
let Some(value) = parse_datetime(&value) else {
return Value::from(String::new());
};
let reference = other
.as_ref()
.map(to_json_value)
.and_then(|json| parse_datetime(&json))
.unwrap_or_else(Utc::now);
Value::from(humanize_duration(value.signed_duration_since(reference)))
}
fn filter_default(value: Value, fallback: Value) -> Value {
let json = to_json_value(&value);
if is_truthy(&json) { value } else { fallback }
}
fn filter_default_if_none(value: Value, fallback: Value) -> Value {
if value.is_none() || value.is_undefined() {
fallback
} else {
value
}
}
fn filter_divisibleby(value: Value, divisor: i64) -> Value {
let json = to_json_value(&value);
let Some(number) = parse_i64(&json) else {
return Value::from(false);
};
Value::from(divisor != 0 && number % divisor == 0)
}
fn filter_escape(value: Value) -> Value {
safe_value(html::escape(&json_to_string(&to_json_value(&value))))
}
fn filter_force_escape(value: Value) -> Value {
safe_value(html::escape(&json_to_string(&to_json_value(&value))))
}
fn filter_linebreaks(value: Value) -> Value {
let escaped = html::escape(&json_to_string(&to_json_value(&value)));
safe_value(html::linebreaks(&escaped))
}
fn filter_linebreaksbr(value: Value) -> Value {
let escaped = html::escape(&json_to_string(&to_json_value(&value)));
safe_value(html::linebreaksbr(&escaped))
}
fn filter_safe(value: Value) -> Value {
safe_value(json_to_string(&to_json_value(&value)))
}
fn filter_striptags(value: Value) -> Value {
Value::from(html::strip_tags(&json_to_string(&to_json_value(&value))))
}
fn filter_filesizeformat(value: Value) -> Value {
let json = to_json_value(&value);
let bytes = parse_i64(&json).unwrap_or_default().max(0) as f64;
if bytes == 1.0 {
return Value::from("1 byte");
}
if bytes < 1024.0 {
return Value::from(format!("{} bytes", bytes as i64));
}
let units = ["KB", "MB", "GB", "TB", "PB"];
let mut size = bytes;
let mut unit = units[0];
for candidate in units {
size /= 1024.0;
unit = candidate;
if size < 1024.0 || candidate == "PB" {
break;
}
}
Value::from(format!("{size:.1} {unit}"))
}
fn filter_get_digit(value: Value, digit: i64) -> Value {
if digit < 1 {
return value;
}
let json = to_json_value(&value);
let Some(number) = parse_i64(&json) else {
return value;
};
let index = usize::try_from(digit - 1).unwrap_or(0);
let digits: Vec<char> = number.abs().to_string().chars().rev().collect();
digits
.get(index)
.and_then(|digit| digit.to_digit(10))
.map_or(value, Value::from)
}
fn filter_pprint(value: Value) -> Value {
let json = to_json_value(&value);
Value::from(serde_json::to_string_pretty(&json).unwrap_or_else(|_| json_to_string(&json)))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use minijinja::context;
fn mj<T: serde::Serialize>(value: T) -> Value {
Value::from_serialize(value)
}
fn deserialize_json(value: Value) -> JsonValue {
JsonValue::deserialize(value).expect("deserializes as json")
}
fn deserialize_vec(value: Value) -> Vec<String> {
Vec::<String>::deserialize(value).expect("deserializes as string vec")
}
#[test]
fn string_filters_cover_core_transformations() {
assert_eq!(
filter_addslashes(mj("quo'te\"\\")).to_string(),
"quo\\'te\\\"\\\\"
);
assert_eq!(filter_capfirst(mj("rjango")).to_string(), "Rjango");
assert_eq!(filter_center(mj("rust"), 8).to_string(), " rust ");
assert_eq!(filter_cut(mj("banana"), "na".to_owned()).to_string(), "ba");
assert_eq!(filter_ljust(mj("hi"), 4).to_string(), "hi ");
assert_eq!(filter_rjust(mj("hi"), 4).to_string(), " hi");
assert_eq!(filter_lower(mj("RuSt")).to_string(), "rust");
assert_eq!(filter_upper(mj("RuSt")).to_string(), "RUST");
assert_eq!(
filter_title(mj("hello rust-world")).to_string(),
"Hello Rust-World"
);
assert_eq!(
filter_slugify(mj(" Hello, Rust World! ")).to_string(),
"hello-rust-world"
);
}
#[test]
fn string_filters_handle_formatting_and_truncation() {
assert_eq!(
filter_floatformat(mj(34.230), Some(-3)).to_string(),
"34.23"
);
assert_eq!(filter_floatformat(mj(34.0), None).to_string(), "34");
assert_eq!(filter_linenumbers(mj("a\nb")).to_string(), "1. a\n2. b");
assert_eq!(
filter_stringformat(mj(42), "05d".to_owned()).to_string(),
"00042"
);
assert_eq!(
filter_truncatechars(mj("Здравствуйте"), 8).to_string(),
"Здрав..."
);
assert_eq!(
filter_truncatewords(mj("one two three four"), 2).to_string(),
"one two..."
);
assert_eq!(filter_wordcount(mj("one two\nthree")).to_string(), "3");
assert_eq!(
filter_wordwrap(mj("alpha beta gamma"), 10).to_string(),
"alpha beta\ngamma"
);
}
#[test]
fn list_and_collection_filters_preserve_structure() {
let items = serde_json::json!([
{"name": "beta", "rank": 2},
{"name": "alpha", "rank": 1}
]);
let sorted = deserialize_json(filter_dictsort(mj(items.clone()), "name".to_owned()));
assert_eq!(sorted[0]["name"], "alpha");
let reversed = deserialize_json(filter_dictsortreversed(mj(items), "rank".to_owned()));
assert_eq!(reversed[0]["rank"], 2);
assert_eq!(filter_first(mj(vec!["a", "b"])).to_string(), "a");
assert_eq!(filter_last(mj(vec!["a", "b"])).to_string(), "b");
assert_eq!(
filter_join(mj(vec!["a", "b", "c"]), "-".to_owned()).to_string(),
"a-b-c"
);
assert_eq!(filter_length(mj(vec![1, 2, 3])).to_string(), "3");
assert_eq!(filter_length_is(mj(vec![1, 2, 3]), 3).to_string(), "true");
assert_eq!(
deserialize_json(filter_slice(mj(vec![1, 2, 3, 4]), "1:3".to_owned())),
serde_json::json!([2, 3])
);
assert!(matches!(
filter_random(mj(vec!["x", "y", "z"])).to_string().as_str(),
"x" | "y" | "z"
));
}
#[test]
fn semantic_filters_cover_defaults_pluralization_and_yesno() {
assert_eq!(deserialize_vec(filter_make_list(mj("ab"))), vec!["a", "b"]);
assert_eq!(
filter_phone2numeric(mj("1-800-FLOWERS")).to_string(),
"1-800-3569377"
);
assert_eq!(filter_pluralize(mj(1), None).to_string(), "");
assert_eq!(
filter_pluralize(mj(2), Some("y,ies".to_owned())).to_string(),
"ies"
);
assert_eq!(
filter_yesno(mj(true), Some("yes,no,maybe".to_owned())).to_string(),
"yes"
);
assert_eq!(
filter_yesno(Value::UNDEFINED, Some("yes,no,maybe".to_owned())).to_string(),
"maybe"
);
assert_eq!(
filter_default(mj(""), mj("fallback")).to_string(),
"fallback"
);
assert_eq!(
filter_default_if_none(Value::UNDEFINED, mj("fallback")).to_string(),
"fallback"
);
assert_eq!(filter_divisibleby(mj(12), 3).to_string(), "true");
}
#[test]
fn html_filters_escape_and_render_markup_safely() {
assert_eq!(
filter_escape(mj("<tag>\"x\"</tag>")).to_string(),
"<tag>"x"</tag>"
);
assert_eq!(filter_force_escape(mj("<tag>")).to_string(), "<tag>");
assert_eq!(
filter_linebreaks(mj("a\n\nb")).to_string(),
"<p>a</p>\n\n<p>b</p>"
);
assert_eq!(filter_linebreaksbr(mj("a\nb")).to_string(), "a<br>b");
assert_eq!(filter_safe(mj("<em>ok</em>")).to_string(), "<em>ok</em>");
assert_eq!(
filter_striptags(mj("<p>Hello <strong>Rust</strong></p>")).to_string(),
"Hello Rust"
);
assert_eq!(
filter_unordered_list(mj(serde_json::json!([
"States",
["Kansas", ["Lawrence", "Topeka"]]
])))
.to_string(),
"<ul><li>States</li><li>Kansas<ul><li>Lawrence</li><li>Topeka</li></ul></li></ul>"
);
}
#[test]
fn date_and_time_filters_support_requested_subset() {
let datetime = mj("2024-03-14T15:09:26+00:00");
assert_eq!(
filter_date(datetime.clone(), Some("Y-m-d D F j".to_owned())).to_string(),
"2024-03-14 Thu March 14"
);
assert_eq!(
filter_time(datetime.clone(), Some("H:i:s A".to_owned())).to_string(),
"15:09:26 PM"
);
assert_eq!(
filter_time(mj("00:00:00"), Some("P".to_owned())).to_string(),
"midnight"
);
assert_eq!(
filter_time(mj("12:00:00"), Some("P".to_owned())).to_string(),
"noon"
);
}
#[test]
fn relative_time_filters_humanize_deltas() {
let earlier = mj("2024-03-10T10:00:00+00:00");
let later = mj("2024-03-12T13:30:00+00:00");
assert_eq!(
filter_timesince(earlier.clone(), Some(later.clone())).to_string(),
"2 days, 3 hours"
);
assert_eq!(
filter_timeuntil(later, Some(earlier)).to_string(),
"2 days, 3 hours"
);
}
#[test]
fn formatting_filters_cover_file_sizes_digits_and_debug_views() {
assert_eq!(filter_filesizeformat(mj(1)).to_string(), "1 byte");
assert_eq!(filter_filesizeformat(mj(1536)).to_string(), "1.5 KB");
assert_eq!(filter_get_digit(mj(98765), 2).to_string(), "6");
assert!(
filter_pprint(mj(serde_json::json!({"name": "rjango"})))
.to_string()
.contains("\"name\": \"rjango\"")
);
assert_eq!(filter_urlencode(mj("rust web")).to_string(), "rust%20web");
assert!(
filter_escapejs(mj("<script>\n"))
.to_string()
.contains("\\u003Cscript\\u003E\\u000A")
);
}
#[test]
fn registration_makes_filters_available_to_templates() {
let mut env = Environment::new();
register_default_filters(&mut env);
env.add_template(
"filters.txt",
"{{ title|slugify }} {{ count|filesizeformat }} {{ name|upper }} countr{{ amount|pluralize(\"y,ies\") }}",
)
.expect("template added");
let rendered = env
.get_template("filters.txt")
.expect("template exists")
.render(
context!(title => "Hello Rust World", count => 1536, name => "rjango", amount => 2),
)
.expect("template renders");
assert_eq!(rendered, "hello-rust-world 1.5 KB RJANGO countries");
}
#[test]
fn relative_date_filters_accept_chrono_serialized_values() {
let value = Utc.with_ymd_and_hms(2024, 3, 14, 15, 9, 26).unwrap();
assert_eq!(
filter_date(mj(value), Some("Y-m-d".to_owned())).to_string(),
"2024-03-14"
);
}
}