use std::sync::Arc;
use indexmap::IndexMap;
use crate::ast::{Function, Value};
use crate::tags::truthy;
use crate::validate::{Config, ConfigFunction};
#[must_use]
pub fn builtin() -> IndexMap<String, ConfigFunction> {
let mut functions = IndexMap::new();
functions.insert("and".to_string(), pure(and));
functions.insert("or".to_string(), pure(or));
functions.insert("not".to_string(), pure(not));
functions.insert("equals".to_string(), pure(equals));
functions.insert("default".to_string(), pure(default));
functions.insert("debug".to_string(), pure(debug));
functions
}
fn pure(transform: fn(&Parameters, &Config<'_>) -> Option<Value>) -> ConfigFunction {
ConfigFunction {
transform: Some(Arc::new(transform)),
..ConfigFunction::default()
}
}
type Parameters = IndexMap<String, Option<Value>>;
fn positional(parameters: &Parameters, index: usize) -> Option<&Value> {
parameters.get(&Function::positional_key(index))?.as_ref()
}
#[allow(
clippy::unnecessary_wraps,
reason = "the `Option` is the hook's signature, not this function's choice: \
a built-in that cannot fail still has to say so in the type every \
function shares"
)]
fn and(parameters: &Parameters, _config: &Config<'_>) -> Option<Value> {
Some(Value::Boolean(
parameters.values().all(|value| truthy(value.as_ref())),
))
}
#[allow(
clippy::unnecessary_wraps,
reason = "the `Option` is the hook's signature; see `and`"
)]
fn or(parameters: &Parameters, _config: &Config<'_>) -> Option<Value> {
Some(Value::Boolean(
parameters.values().any(|value| truthy(value.as_ref())),
))
}
#[allow(
clippy::unnecessary_wraps,
reason = "the `Option` is the hook's signature; see `and`"
)]
fn not(parameters: &Parameters, _config: &Config<'_>) -> Option<Value> {
Some(Value::Boolean(!truthy(positional(parameters, 0))))
}
#[allow(
clippy::unnecessary_wraps,
reason = "the `Option` is the hook's signature; see `and`"
)]
fn equals(parameters: &Parameters, _config: &Config<'_>) -> Option<Value> {
let mut values = parameters.values();
let Some(first) = values.next() else {
return Some(Value::Boolean(true));
};
Some(Value::Boolean(values.all(|value| value == first)))
}
fn default(parameters: &Parameters, _config: &Config<'_>) -> Option<Value> {
match positional(parameters, 0) {
Some(value) => Some(value.clone()),
None => positional(parameters, 1).cloned(),
}
}
fn debug(parameters: &Parameters, _config: &Config<'_>) -> Option<Value> {
let value = positional(parameters, 0)?;
let mut out = String::new();
write_json(value, 0, &mut out);
Some(Value::String(out))
}
fn write_json(value: &Value, indent: usize, out: &mut String) {
use std::fmt::Write as _;
let pad = |out: &mut String, level: usize| {
for _ in 0..level * 2 {
out.push(' ');
}
};
match value {
Value::Null | Value::Variable(_) | Value::Function(_) => out.push_str("null"),
Value::Boolean(boolean) => out.push_str(if *boolean { "true" } else { "false" }),
Value::Number(number) => {
if number.is_finite() {
let _ = write!(out, "{number}");
} else {
out.push_str("null");
}
}
Value::String(text) => write_json_string(text, out),
Value::Array(items) if items.is_empty() => out.push_str("[]"),
Value::Array(items) => {
out.push_str("[\n");
for (position, item) in items.iter().enumerate() {
if position > 0 {
out.push_str(",\n");
}
pad(out, indent + 1);
write_json(item, indent + 1, out);
}
out.push('\n');
pad(out, indent);
out.push(']');
}
Value::Hash(entries) if entries.is_empty() => out.push_str("{}"),
Value::Hash(entries) => {
out.push_str("{\n");
for (position, (key, value)) in entries.iter().enumerate() {
if position > 0 {
out.push_str(",\n");
}
pad(out, indent + 1);
write_json_string(key, out);
out.push_str(": ");
write_json(value, indent + 1, out);
}
out.push('\n');
pad(out, indent);
out.push('}');
}
}
}
fn write_json_string(text: &str, out: &mut String) {
use std::fmt::Write as _;
out.push('"');
for character in text.chars() {
match character {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{8}' => out.push_str("\\b"),
'\u{c}' => out.push_str("\\f"),
control if control < ' ' => {
let _ = write!(out, "\\u{:04x}", control as u32);
}
other => out.push(other),
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use super::*;
fn call(name: &str, arguments: &[Option<Value>]) -> Option<Value> {
let config = crate::builtins::config();
let mut parameters = Parameters::new();
for (index, argument) in arguments.iter().enumerate() {
parameters.insert(Function::positional_key(index), argument.clone());
}
let function = config.functions.get(name).expect("a built-in function");
let transform = function.transform.as_ref().expect("a transform");
transform(¶meters, &config)
}
#[allow(
clippy::unnecessary_wraps,
reason = "the wrapping is the point: it names the argument as defined"
)]
fn some(value: Value) -> Option<Value> {
Some(value)
}
#[allow(
clippy::unnecessary_wraps,
reason = "the wrapping is the point; see `some`"
)]
fn string(text: &str) -> Option<Value> {
Some(Value::String(text.to_string()))
}
#[test]
fn every_upstream_function_is_registered() {
let functions = builtin();
let names: Vec<&str> = functions.keys().map(String::as_str).collect();
assert_eq!(names, ["and", "or", "not", "equals", "default", "debug"]);
}
#[test]
fn truthiness_is_markdocs_rather_than_javascripts() {
assert_eq!(
call("and", &[some(Value::Number(0.0)), string("")]),
some(Value::Boolean(true))
);
assert_eq!(
call(
"or",
&[some(Value::Boolean(false)), some(Value::Number(0.0))]
),
some(Value::Boolean(true))
);
assert_eq!(call("not", &[string("")]), some(Value::Boolean(false)));
}
#[test]
fn null_and_undefined_are_both_untruthy() {
assert_eq!(
call("and", &[some(Value::Null), some(Value::Boolean(true))]),
some(Value::Boolean(false))
);
assert_eq!(
call("and", &[None, some(Value::Boolean(true))]),
some(Value::Boolean(false))
);
assert_eq!(call("not", &[None]), some(Value::Boolean(true)));
}
#[test]
fn an_empty_call_follows_javascript_array_semantics() {
assert_eq!(call("and", &[]), some(Value::Boolean(true)));
assert_eq!(call("or", &[]), some(Value::Boolean(false)));
assert_eq!(call("equals", &[]), some(Value::Boolean(true)));
}
#[test]
fn equals_compares_every_argument_against_the_first() {
assert_eq!(
call("equals", &[string("a"), string("a"), string("a")]),
some(Value::Boolean(true))
);
assert_eq!(
call("equals", &[string("a"), string("b")]),
some(Value::Boolean(false))
);
assert_eq!(call("equals", &[None, None]), some(Value::Boolean(true)));
assert_eq!(
call("equals", &[None, some(Value::Null)]),
some(Value::Boolean(false))
);
assert_eq!(
call("equals", &[None, string("test")]),
some(Value::Boolean(false))
);
}
#[test]
fn default_falls_through_undefined_but_not_null() {
assert_eq!(call("default", &[None, string("x")]), string("x"));
assert_eq!(
call("default", &[some(Value::Null), string("x")]),
some(Value::Null)
);
assert_eq!(call("default", &[string("a"), string("x")]), string("a"));
assert_eq!(call("default", &[]), None);
}
#[test]
fn debug_prints_indented_json() {
let value = Value::Hash(IndexMap::from([
("a".to_string(), Value::Number(1.0)),
(
"b".to_string(),
Value::Array(vec![Value::Boolean(true), Value::Null]),
),
]));
assert_eq!(
call("debug", &[Some(value)]),
string("{\n \"a\": 1,\n \"b\": [\n true,\n null\n ]\n}")
);
assert_eq!(call("debug", &[]), None);
assert_eq!(call("debug", &[string("x\"y")]), string("\"x\\\"y\""));
}
}