Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lib): Implement Jekyll's version of sort #438

Merged
merged 7 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions crates/core/src/parser/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ pub fn parse(text: &str, options: &Language) -> Result<Vec<Box<dyn Renderable>>>
Ok(renderables)
}

/// Given a `Variable` as a string, parses it into a `Variable`.
pub fn parse_variable(text: &str) -> Result<Variable> {
let variable = LiquidParser::parse(Rule::Variable, text)
.map_err(convert_pest_error)?
.next()
.expect("Parsing a variable failed.");

Ok(parse_variable_pair(variable))
}

/// Parses a `Scalar` from a `Pair` with a literal value.
/// This `Pair` must be `Rule::Literal`.
fn parse_literal(literal: Pair) -> Value {
Expand Down Expand Up @@ -124,7 +134,7 @@ fn parse_literal(literal: Pair) -> Value {

/// Parses a `Variable` from a `Pair` with a variable.
/// This `Pair` must be `Rule::Variable`.
fn parse_variable(variable: Pair) -> Variable {
fn parse_variable_pair(variable: Pair) -> Variable {
if variable.as_rule() != Rule::Variable {
panic!("Expected variable.");
}
Expand Down Expand Up @@ -163,7 +173,7 @@ fn parse_value(value: Pair) -> Expression {

match value.as_rule() {
Rule::Literal => Expression::Literal(parse_literal(value)),
Rule::Variable => Expression::Variable(parse_variable(value)),
Rule::Variable => Expression::Variable(parse_variable_pair(value)),
_ => unreachable!(),
}
}
Expand Down Expand Up @@ -971,7 +981,7 @@ impl<'a> TagToken<'a> {
/// Tries to obtain a `Variable` from this token.
pub fn expect_variable(mut self) -> TryMatchToken<'a, Variable> {
match self.unwrap_variable() {
Ok(t) => TryMatchToken::Matches(parse_variable(t)),
Ok(t) => TryMatchToken::Matches(parse_variable_pair(t)),
Err(_) => {
self.expected.push(Rule::Variable);
TryMatchToken::Fails(self)
Expand Down Expand Up @@ -1121,7 +1131,7 @@ mod test {
}

#[test]
fn test_parse_variable() {
fn test_parse_variable_pair() {
let variable = LiquidParser::parse(Rule::Variable, "foo[0].bar.baz[foo.bar]")
.unwrap()
.next()
Expand All @@ -1137,7 +1147,7 @@ mod test {
let mut expected = Variable::with_literal("foo");
expected.extend(indexes);

assert_eq!(parse_variable(variable), expected);
assert_eq!(parse_variable_pair(variable), expected);
}

#[test]
Expand Down
137 changes: 137 additions & 0 deletions crates/lib/src/jekyll/array.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,142 @@
use std::cmp;
use std::fmt::Write;

use liquid_core::model::try_find;
use liquid_core::model::KStringCow;
use liquid_core::model::ValueViewCmp;
use liquid_core::parser::parse_variable;
use liquid_core::Expression;
use liquid_core::Result;
use liquid_core::Runtime;
use liquid_core::ValueCow;
use liquid_core::{
Display_filter, Filter, FilterParameters, FilterReflection, FromFilterParameters, ParseFilter,
};
use liquid_core::{Value, ValueView};

use crate::invalid_input;

#[derive(Debug, Default, FilterParameters)]
struct SortArgs {
#[parameter(description = "The property accessed by the filter.", arg_type = "str")]
property: Option<Expression>,
#[parameter(
description = "nils appear before or after non-nil values, either ('first' | 'last')",
arg_type = "str"
)]
nils: Option<Expression>,
}

#[derive(Clone, ParseFilter, FilterReflection)]
#[filter(
name = "sort",
description = "Sorts items in an array. The order of the sorted array is case-sensitive.",
parameters(SortArgs),
parsed(SortFilter)
)]
pub struct Sort;

#[derive(Debug, Default, FromFilterParameters, Display_filter)]
#[name = "sort"]
struct SortFilter {
#[parameters]
args: SortArgs,
}

#[derive(Copy, Clone)]
enum NilsOrder {
First,
Last,
}

fn safe_property_getter<'v>(
value: &'v Value,
property: &KStringCow,
runtime: &dyn Runtime,
) -> ValueCow<'v> {
let variable = parse_variable(property).expect("Failed to parse variable");
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed
if let Some(path) = variable.try_evaluate(runtime) {
try_find(value, path.as_slice()).unwrap_or(ValueCow::Borrowed(&Value::Nil))
} else {
ValueCow::Borrowed(&Value::Nil)
}
}

fn nil_safe_compare(
a: &dyn ValueView,
b: &dyn ValueView,
nils: NilsOrder,
) -> Option<cmp::Ordering> {
if a.is_nil() && b.is_nil() {
Some(cmp::Ordering::Equal)
} else if a.is_nil() {
match nils {
NilsOrder::First => Some(cmp::Ordering::Less),
NilsOrder::Last => Some(cmp::Ordering::Greater),
}
} else if b.is_nil() {
match nils {
NilsOrder::First => Some(cmp::Ordering::Greater),
NilsOrder::Last => Some(cmp::Ordering::Less),
}
} else {
ValueViewCmp::new(a).partial_cmp(&ValueViewCmp::new(b))
}
}

fn as_sequence<'k>(input: &'k dyn ValueView) -> Box<dyn Iterator<Item = &'k dyn ValueView> + 'k> {
if let Some(array) = input.as_array() {
array.values()
} else if input.is_nil() {
Box::new(vec![].into_iter())
} else {
Box::new(std::iter::once(input))
}
}

impl Filter for SortFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result<Value> {
let args = self.args.evaluate(runtime)?;

let input: Vec<_> = as_sequence(input).collect();
if input.is_empty() {
return Err(invalid_input("Non-empty array expected"));
}
if args.property.is_some() && !input.iter().all(|v| v.is_object()) {
return Err(invalid_input("Array of objects expected"));
}
let nils = if let Some(nils) = &args.nils {
match nils.to_kstr().as_str() {
"first" => NilsOrder::First,
"last" => NilsOrder::Last,
_ => {
return Err(invalid_input(
"Invalid nils order. Must be \"first\" or \"last\".",
))
}
}
} else {
NilsOrder::First
};

let mut sorted: Vec<Value> = input.iter().map(|v| v.to_value()).collect();
if let Some(property) = &args.property {
// Using unwrap is ok since all of the elements are objects
sorted.sort_by(|a, b| {
nil_safe_compare(
safe_property_getter(a, property, runtime).as_view(),
safe_property_getter(b, property, runtime).as_view(),
nils,
)
.unwrap_or(cmp::Ordering::Equal)
});
} else {
sorted.sort_by(|a, b| nil_safe_compare(a, b, nils).unwrap_or(cmp::Ordering::Equal));
}
Ok(Value::array(sorted))
}
}

#[derive(Debug, FilterParameters)]
struct PushArgs {
#[parameter(description = "The element to append to the array.")]
Expand Down Expand Up @@ -195,6 +322,16 @@ impl Filter for ArrayToSentenceStringFilter {
mod tests {
use super::*;

#[test]
fn unit_sort() {
let input = &liquid_core::value!(["Z", "b", "c", "a"]);
let desired_result = liquid_core::value!(["Z", "a", "b", "c"]);
assert_eq!(
liquid_core::call_filter!(Sort, input).unwrap(),
desired_result
);
}

#[test]
fn unit_push() {
let input = liquid_core::value!(["Seattle", "Tacoma"]);
Expand Down
1 change: 1 addition & 0 deletions crates/lib/tests/conformance_jekyll/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mod slugify_test;
mod test_filters;
142 changes: 142 additions & 0 deletions crates/lib/tests/conformance_jekyll/test_filters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use liquid_lib::jekyll;

mod sort_filter {
use super::*;

#[test]
fn raise_exception_when_input_is_nil() {
let input = liquid_core::Value::Nil;
assert!(liquid_core::call_filter!(jekyll::Sort, input).is_err());
}
#[test]
fn return_sorted_numbers() {
assert_eq!(
v!([1, 2, 2.2, 3]),
liquid_core::call_filter!(jekyll::Sort, v!([3, 2.2, 2, 1])).unwrap()
);
}

#[test]
fn return_sorted_strings() {
assert_eq!(
v!(["10", "2"]),
liquid_core::call_filter!(jekyll::Sort, v!(["10", "2"])).unwrap()
);
assert_eq!(
v!(["FOO", "Foo", "foo"]),
liquid_core::call_filter!(jekyll::Sort, v!(["foo", "Foo", "FOO"])).unwrap()
);
assert_eq!(
v!(["_foo", "foo", "foo_"]),
liquid_core::call_filter!(jekyll::Sort, v!(["foo_", "_foo", "foo"])).unwrap()
);
// Cyrillic
assert_eq!(
v!(["ВУЗ", "Вуз", "вуз"]),
liquid_core::call_filter!(jekyll::Sort, v!(["Вуз", "вуз", "ВУЗ"])).unwrap()
);
assert_eq!(
v!(["_вуз", "вуз", "вуз_"]),
liquid_core::call_filter!(jekyll::Sort, v!(["вуз_", "_вуз", "вуз"])).unwrap()
);
// Hebrew
assert_eq!(
v!(["אלף", "בית"]),
liquid_core::call_filter!(jekyll::Sort, v!(["בית", "אלף"])).unwrap()
);
}

#[test]
fn return_sorted_by_property_array() {
assert_eq!(
liquid_core::value!([{ "a": 1 }, { "a": 2 }, { "a": 3 }, { "a": 4 }]),
liquid_core::call_filter!(
jekyll::Sort,
liquid_core::value!([{ "a": 4 }, { "a": 3 }, { "a": 1 }, { "a": 2 }]),
"a"
)
.unwrap()
);
}

#[test]
fn return_sorted_by_property_array_with_numeric_strings_sorted_as_numbers() {
assert_eq!(
liquid_core::value!([{ "a": ".5" }, { "a": "0.65" }, { "a": "10" }]),
liquid_core::call_filter!(
jekyll::Sort,
liquid_core::value!([{ "a": "10" }, { "a": ".5" }, { "a": "0.65" }]),
"a"
)
.unwrap(),
);
}

#[test]
fn return_sorted_by_property_array_with_numeric_strings_first() {
assert_eq!(
liquid_core::value!([{ "a": ".5" }, { "a": "0.6" }, { "a": "twelve" }]),
liquid_core::call_filter!(
jekyll::Sort,
liquid_core::value!([{ "a": "twelve" }, { "a": ".5" }, { "a": "0.6" }]),
"a"
)
.unwrap()
);
}

#[test]
fn return_sorted_by_property_array_with_numbers_and_strings() {
assert_eq!(
liquid_core::value!([{ "a": "1" }, { "a": "1abc" }, { "a": "20" }]),
liquid_core::call_filter!(
jekyll::Sort,
liquid_core::value!([{ "a": "20" }, { "a": "1" }, { "a": "1abc" }]),
"a"
)
.unwrap()
);
}

#[test]
fn return_sorted_by_property_array_with_nils_first() {
let ary = liquid_core::value!([{ "a": 2 }, { "b": 1 }, { "a": 1 }]);
assert_eq!(
liquid_core::value!([{ "b": 1 }, { "a": 1 }, { "a": 2 }]),
liquid_core::call_filter!(jekyll::Sort, ary, "a").unwrap()
);
assert_eq!(
liquid_core::value!([{ "b": 1 }, { "a": 1 }, { "a": 2 }]),
liquid_core::call_filter!(jekyll::Sort, ary, "a", "first").unwrap()
);
}

#[test]
fn return_sorted_by_property_array_with_nils_last() {
assert_eq!(
liquid_core::value!([{ "a": 1 }, { "a": 2 }, { "b": 1 }]),
liquid_core::call_filter!(
jekyll::Sort,
liquid_core::value!([{ "a": 2 }, { "b": 1 }, { "a": 1 }]),
"a",
"last"
)
.unwrap()
);
}

#[test]
fn return_sorted_by_subproperty_array() {
assert_eq!(
liquid_core::value!([{ "a": { "b": 1 } }, { "a": { "b": 2 } },
{ "a": { "b": 3 } },]),
liquid_core::call_filter!(
jekyll::Sort,
liquid_core::value!([{ "a": { "b": 2 } }, { "a": { "b": 1 } },
{ "a": { "b": 3 } },]),
"a.b"
)
.unwrap()
);
}
}
Loading