diff --git a/src/main/java/ch/njol/skript/expressions/ExprSortedList.java b/src/main/java/ch/njol/skript/expressions/ExprSortedList.java index 57b08b66e44..3714f36ba88 100644 --- a/src/main/java/ch/njol/skript/expressions/ExprSortedList.java +++ b/src/main/java/ch/njol/skript/expressions/ExprSortedList.java @@ -1,29 +1,36 @@ package ch.njol.skript.expressions; import ch.njol.skript.Skript; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Example; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; +import ch.njol.skript.doc.*; import ch.njol.skript.lang.*; import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.parser.ParserInstance; import ch.njol.skript.lang.simplification.SimplifiedLiteral; import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.skript.util.LiteralUtils; import ch.njol.util.Kleenean; import ch.njol.util.coll.CollectionUtils; import ch.njol.util.coll.iterator.EmptyIterator; +import com.google.common.collect.Iterators; import org.bukkit.event.Event; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; import org.skriptlang.skript.lang.comparator.Comparator; import org.skriptlang.skript.lang.comparator.Comparators; import org.skriptlang.skript.lang.comparator.Relation; import java.lang.reflect.Array; -import java.util.Iterator; +import java.util.*; @Name("Sorted List") -@Description("Sorts given list in natural order. All objects in list must be comparable; if they're not, this expression will return nothing.") +@Description(""" + Sorts given list in natural order. All objects in list must be comparable; + if they're not, this expression will return nothing. + + When using the sorted by pattern, + the input expression can be used to refer to the current element being sorted. + (See input expression for more information.)""") @Example("set {_sorted::*} to sorted {_players::*}") @Example(""" command /leaderboard: @@ -31,22 +38,42 @@ loop reversed sorted {most-kills::*}: send "%loop-counter%. %loop-index% with %loop-value% kills" to sender """) -@Since("2.2-dev19, 2.14 (retain indices when looping)") -public class ExprSortedList extends SimpleExpression implements KeyedIterableExpression { +@Example("set {_sorted::*} to {_words::*} sorted in descending order by (length of input)") +@Since("2.2-dev19, 2.14 (retain indices when looping), INSERT_VERSION (sort by)") +@Keywords("input") +public class ExprSortedList extends SimpleExpression implements KeyedIterableExpression, InputSource { + + private record MappedValue(Object original, Object mapped) { } + private record KeyedMappedValue(KeyedValue keyed, Object mapped) { } static { - Skript.registerExpression(ExprSortedList.class, Object.class, ExpressionType.PROPERTY, "sorted %objects%"); + Skript.registerExpression(ExprSortedList.class, Object.class, ExpressionType.PROPERTY, + "sorted %objects%", + "%objects% sorted [in (:descending|ascending) order] [(by|based on) \\(<.+>\\)]", + "%objects% sorted [in (:descending|ascending) order] [(by|based on) \\[<.+>\\]]" + ); + if (!ParserInstance.isRegistered(InputData.class)) + ParserInstance.registerData(InputData.class, InputData::new); } private Expression list; private boolean keyed; + private @Nullable Expression mappingExpr; + private boolean descendingOrder; + + private final Set> dependentInputs = new HashSet<>(); + + private @Nullable Object currentValue; + private @UnknownNullability String currentIndex; public ExprSortedList() { } - public ExprSortedList(Expression list) { + private ExprSortedList(Expression list, @Nullable Expression mappingExpr, boolean descendingOrder) { this.list = list; this.keyed = KeyedIterableExpression.canIterateWithKeys(list); + this.mappingExpr = mappingExpr; + this.descendingOrder = descendingOrder; } @Override @@ -57,20 +84,75 @@ public boolean init(Expression[] expressions, int matchedPattern, Kleenean is return false; } keyed = KeyedIterableExpression.canIterateWithKeys(list); + descendingOrder = parseResult.hasTag("descending"); + + //noinspection DuplicatedCode + if (!parseResult.regexes.isEmpty()) { + @Nullable String unparsedExpression = parseResult.regexes.get(0).group(); + assert unparsedExpression != null; + mappingExpr = parseExpression(unparsedExpression, getParser(), SkriptParser.PARSE_EXPRESSIONS); + if (mappingExpr == null) + return false; + if (!mappingExpr.isSingle()) { + Skript.error("The mapping expression in the sort expression must only return a single value for a single input."); + return false; + } + } return LiteralUtils.canInitSafely(list); } @Override - protected Object @Nullable [] get(Event event) { + public @NotNull Iterator iterator(Event event) { + if (keyed) + return Iterators.transform(keyedIterator(event), KeyedValue::value); + + currentIndex = null; + int sortingMultiplier = descendingOrder ? -1 : 1; + + if (mappingExpr == null) { + try { + // toList() forces eager evaluation so the comparator exceptions are caught here + return list.stream(event) + .sorted((o1, o2) -> compare(o1, o2) * sortingMultiplier) + .toList().iterator(); + } catch (IllegalArgumentException | ClassCastException e) { + error("Sorting failed because the elements in the list could not be compared with each other."); + return Collections.emptyIterator(); + } + } + + List mappedValues = new ArrayList<>(); + Iterator it = list.iterator(event); + if (it == null) + return Collections.emptyIterator(); + while (it.hasNext()) { + currentValue = it.next(); + Object mappedValue = mappingExpr.getSingle(event); + if (mappedValue == null) { + error("Sorting failed because Skript cannot sort null values. " + + "The mapping expression '" + mappingExpr.toString(event, false) + + "' returned a null value when given the input '" + currentValue + "'."); + return Collections.emptyIterator(); + } + mappedValues.add(new MappedValue(currentValue, mappedValue)); + } try { - return list.stream(event) - .sorted(ExprSortedList::compare) - .toArray(); + return mappedValues.stream() + .sorted((o1, o2) -> compare(o1.mapped(), o2.mapped()) * sortingMultiplier) + .map(MappedValue::original) + .toList().iterator(); } catch (IllegalArgumentException | ClassCastException e) { - return (Object[]) Array.newInstance(getReturnType(), 0); + error("Sorting failed because the mapped values could not be compared with each other."); + return Collections.emptyIterator(); } } + @Override + @SuppressWarnings("unchecked") + protected Object @Nullable [] get(Event event) { + return Iterators.toArray(iterator(event), (Class) getReturnType()); + } + @Override public boolean canIterateWithKeys() { return keyed; @@ -80,12 +162,41 @@ public boolean canIterateWithKeys() { public Iterator> keyedIterator(Event event) { if (!keyed) throw new UnsupportedOperationException(); + int sortingMultiplier = descendingOrder ? -1 : 1; + if (mappingExpr == null) { + try { + //noinspection unchecked,rawtypes + return (Iterator) ((KeyedIterableExpression) list).keyedStream(event) + .sorted((a, b) -> compare(a.value(), b.value()) * sortingMultiplier) + .toList().iterator(); + } catch (IllegalArgumentException | ClassCastException e) { + error("Sorting failed because the elements in the list could not be compared with each other."); + return EmptyIterator.get(); + } + } + + List keyedMappedValues = new ArrayList<>(); + for (Iterator> it = ((KeyedIterableExpression) list).keyedIterator(event); it.hasNext(); ) { + KeyedValue keyedValue = it.next(); + currentIndex = keyedValue.key(); + currentValue = keyedValue.value(); + Object mappedValue = mappingExpr.getSingle(event); + if (mappedValue == null) { + error("Sorting failed because Skript cannot sort null values. " + + "The mapping expression '" + mappingExpr.toString(event, false) + + "' returned a null value when given the input '" + currentValue + "'."); + return EmptyIterator.get(); + } + keyedMappedValues.add(new KeyedMappedValue(keyedValue, mappedValue)); + } try { //noinspection unchecked,rawtypes - return (Iterator) ((KeyedIterableExpression) list).keyedStream(event) - .sorted((a, b) -> compare(a.value(), b.value())) - .iterator(); + return (Iterator) keyedMappedValues.stream() + .sorted((a, b) -> compare(a.mapped(), b.mapped()) * sortingMultiplier) + .map(KeyedMappedValue::keyed) + .toList().iterator(); } catch (IllegalArgumentException | ClassCastException e) { + error("Sorting failed because the mapped values could not be compared with each other."); return EmptyIterator.get(); } } @@ -110,10 +221,14 @@ public static int compare(A a, B b) throws IllegalArgumentException, Clas //noinspection unchecked return (Expression) this; + // when a mapping expression is present, InputSource wiring prevents safe shallow-copying + if (mappingExpr != null) + return super.getConvertedExpression(to); + Expression convertedList = list.getConvertedExpression(to); if (convertedList != null) //noinspection unchecked - return (Expression) new ExprSortedList(convertedList); + return (Expression) new ExprSortedList(convertedList, mappingExpr, descendingOrder); return null; } @@ -152,14 +267,40 @@ public boolean isLoopOf(String input) { @Override public Expression simplify() { - if (list instanceof Literal) + if (list instanceof Literal && mappingExpr == null) return SimplifiedLiteral.fromExpression(this); return this; } + @Override + public Set> getDependentInputs() { + return dependentInputs; + } + + @Override + public @Nullable Object getCurrentValue() { + return currentValue; + } + + @Override + public boolean hasIndices() { + return keyed; + } + + @Override + public @UnknownNullability String getCurrentIndex() { + return currentIndex; + } + @Override public String toString(@Nullable Event event, boolean debug) { - return "sorted " + list.toString(event, debug); + SyntaxStringBuilder builder = new SyntaxStringBuilder(event, debug); + if (mappingExpr == null && !descendingOrder) + return builder.append("sorted", list).toString(); + builder.append(list, "sorted", "in", descendingOrder ? "descending" : "ascending", "order"); + if (mappingExpr != null) + builder.append("by", mappingExpr); + return builder.toString(); } } diff --git a/src/test/skript/tests/syntaxes/expressions/ExprSortedList.sk b/src/test/skript/tests/syntaxes/expressions/ExprSortedList.sk index 33bd67d3867..35c0ce16f9c 100644 --- a/src/test/skript/tests/syntaxes/expressions/ExprSortedList.sk +++ b/src/test/skript/tests/syntaxes/expressions/ExprSortedList.sk @@ -1,3 +1,7 @@ +using error catching + +# --- Pattern 0: sorted %objects% (natural order) --- + test "sort": # Populate list set {_i} to 0 @@ -17,4 +21,248 @@ test "sort": assert loop-value >= {_prev} with "Couldn't sort correctly" set {_prev} to loop-value - assert (sorted 1 and "test") is not set with "Sorting incomparable values returned a value" + catch runtime errors: + assert (sorted 1 and "test") is not set with "Sorting incomparable values returned a value" + +# --- Patterns 1 & 2: ascending/descending order --- + +test "sorted expression with sort order": + set {_numbers::*} to shuffled integers from 1 to 50 + assert ({_numbers::*} sorted in ascending order) is integers from 1 to 50 with "ascending sorted expression failed" + + set {_numbers::*} to shuffled integers from 1 to 50 + assert ({_numbers::*} sorted in descending order) is integers from 50 to 1 with "descending sorted expression failed" + +# --- Patterns 1 & 2: sort by with InputSource --- + +test "sorted expression with sort by": + # identity mapping + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted by (input)) is integers from 1 to 5 with "sort by input (identity) failed" + + # ascending + sort by + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted in ascending order by (input)) is integers from 1 to 5 with "ascending sort by input failed" + + # descending + sort by + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted in descending order by (input)) is integers from 5 to 1 with "descending sort by input failed" + + # linear transform preserves order + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted by (input * 20 + 4 - 3)) is integers from 1 to 5 with "sort by linear transform failed" + + # codepoint-based sort + set {_chars::*} to shuffled characters between "a" and "f" + assert ({_chars::*} sorted based on (codepoint of input)) is characters between "a" and "f" with "sort by codepoint failed" + + # mixed types sorted by string conversion + set {_mixed::*} to shuffled (characters between "a" and "f", integers from 1 to 5) + assert ({_mixed::*} sorted by ("%input%")) is 1, 2, 3, 4, 5, and characters between "a" and "f" with "sort by string conversion failed" + + # null mapped value produces runtime error + catch runtime errors: + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted by ("%input%" parsed as time)) is not set with "sort by null mapping returned a value" + + # empty list + assert ({_empty::*} sorted by (input)) is not set with "sort by on empty list returned a value" + + # sort by index (keyed mapped sort) + set {_list::x} to 1 + set {_list::aa} to 2 + set {_list::bxs} to 3 + set {_list::zysa} to 4 + set {_list::aaaaa} to 5 + set {_sorted::*} to {_list::*} sorted by (length of input index) + assert {_sorted::*} is integers from 1 to 5 with "sort by index length failed" + +# --- bracket delimiter (pattern 2) --- + +test "sorted by bracket delimiter": + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted by [input]) is integers from 1 to 5 with "bracket delimiter sort failed" + + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted in descending order by [input]) is integers from 5 to 1 with "bracket delimiter descending sort failed" + +# --- edge cases: single element, duplicates, negatives, already sorted --- + +test "sorted edge cases": + # single element list + set {_one::*} to 42 + assert (sorted {_one::*}) is 42 with "single element sort failed" + + # already sorted + set {_sorted::*} to 1, 2, 3, 4 and 5 + assert (sorted {_sorted::*}) is 1, 2, 3, 4 and 5 with "already sorted list changed" + + # reverse sorted input + set {_rev::*} to 5, 4, 3, 2 and 1 + assert (sorted {_rev::*}) is 1, 2, 3, 4 and 5 with "reverse sorted input failed" + + # duplicates preserved + set {_dups::*} to 3, 1, 2, 1 and 3 + assert (sorted {_dups::*}) is 1, 1, 2, 3 and 3 with "sort with duplicates failed" + + # negative numbers + set {_neg::*} to shuffled (-5, -3, -1, 0, 2 and 4) + assert (sorted {_neg::*}) is -5, -3, -1, 0, 2 and 4 with "sort with negatives failed" + + # decimals + set {_dec::*} to shuffled (0.1, 0.5, 0.3 and 0.9) + assert (sorted {_dec::*}) is 0.1, 0.3, 0.5 and 0.9 with "sort with decimals failed" + + # empty list + assert (sorted {_empty::*}) is not set with "sort empty list returned a value" + +# --- string sorting (case-insensitive) --- + +test "sorted strings case insensitive": + set {_strs::*} to "Banana", "apple", "Cherry" and "date" + assert (sorted {_strs::*}) is "apple", "Banana", "Cherry" and "date" with "case-insensitive sort failed" + + assert ({_strs::*} sorted in descending order) is "date", "Cherry", "Banana" and "apple" with "case-insensitive descending sort failed" + +# --- keyed iteration: loop-index retention --- + +test "sorted keyed iteration": + set {_scores::alice} to 30 + set {_scores::bob} to 10 + set {_scores::charlie} to 20 + + set {_i} to 0 + loop sorted {_scores::*}: + add 1 to {_i} + if {_i} is 1: + assert loop-index is "bob" with "keyed sort: first index wrong" + assert loop-value is 10 with "keyed sort: first value wrong" + else if {_i} is 2: + assert loop-index is "charlie" with "keyed sort: second index wrong" + assert loop-value is 20 with "keyed sort: second value wrong" + else if {_i} is 3: + assert loop-index is "alice" with "keyed sort: third index wrong" + assert loop-value is 30 with "keyed sort: third value wrong" + +test "sorted keyed iteration descending": + set {_scores::alice} to 30 + set {_scores::bob} to 10 + set {_scores::charlie} to 20 + + set {_i} to 0 + loop {_scores::*} sorted in descending order: + add 1 to {_i} + if {_i} is 1: + assert loop-index is "alice" with "keyed desc sort: first index wrong" + assert loop-value is 30 with "keyed desc sort: first value wrong" + else if {_i} is 2: + assert loop-index is "charlie" with "keyed desc sort: second index wrong" + assert loop-value is 20 with "keyed desc sort: second value wrong" + else if {_i} is 3: + assert loop-index is "bob" with "keyed desc sort: third index wrong" + assert loop-value is 10 with "keyed desc sort: third value wrong" + +# --- keyed sort by (keyed + mapped) --- + +test "sorted keyed with sort by": + set {_data::alice} to 30 + set {_data::bob} to 10 + set {_data::charlie} to 20 + + # sort by index length (keyed mapped sort path) + set {_sorted::*} to {_data::*} sorted by (length of input index) + assert {_sorted::*} is 10, 30 and 20 with "keyed sort by index length: values wrong" + + # sort by value with descending + set {_i} to 0 + loop {_data::*} sorted in descending order by (input): + add 1 to {_i} + if {_i} is 1: + assert loop-index is "alice" with "keyed desc sort by: first index wrong" + else if {_i} is 2: + assert loop-index is "charlie" with "keyed desc sort by: second index wrong" + else if {_i} is 3: + assert loop-index is "bob" with "keyed desc sort by: third index wrong" + +# --- keyed sort by with null mapped value --- + +test "sorted keyed with null mapping": + set {_data::a} to 1 + set {_data::b} to 2 + set {_data::c} to 3 + catch runtime errors: + assert ({_data::*} sorted by ("%input%" parsed as time)) is not set with "keyed null mapping returned a value" + +# --- duplicates with sort by --- + +test "sorted by preserves duplicates": + set {_levels::a} to 1 + set {_levels::b} to 2 + set {_items::*} to shuffled "a", "a" and "b" + set {_sorted::*} to {_items::*} sorted in descending order by ({_levels::%input%}) + assert {_sorted::*} is "b", "a" and "a" with "sort by with duplicates collapsed or reordered" + +# --- sort by with complex expressions --- + +test "sorted by complex mapping": + # negative mapping reverses order + set {_numbers::*} to shuffled integers from 1 to 5 + assert ({_numbers::*} sorted by (input * -1)) is integers from 5 to 1 with "sort by negation failed" + + # absolute value mapping + set {_mixed::*} to shuffled (-3, -1, 0, 2 and 4) + set {_sorted::*} to {_mixed::*} sorted by (abs(input)) + assert first element of {_sorted::*} is 0 with "sort by abs: first element wrong" + assert last element of {_sorted::*} is 4 with "sort by abs: last element wrong" + + # modulo mapping + set {_numbers::*} to 10, 7, 3, 8 and 5 + set {_sorted::*} to {_numbers::*} sorted by (mod(input, 3)) + assert first element of {_sorted::*} is 3 with "sort by mod: first element wrong (3 mod 3 = 0)" + +# --- type conversion: getConvertedExpression with mappingExpr --- + +test "sorted by with type conversion": + # 'health of' requires %living entities%; 'all entities' returns %entities%, + # forcing getConvertedExpression(LivingEntity.class) on the sorted-by expression. + delete all entities in radius 50 around test-location + spawn a zombie at test-location: + set {_z1} to entity + set entity's name to "charlie" + spawn a zombie at test-location: + set {_z2} to entity + set entity's name to "alpha" + spawn a zombie at test-location: + set {_z3} to entity + set entity's name to "bravo" + + set {_names::*} to names of ((all entities in radius 2 around test-location) sorted by (input's name)) + assert {_names::*} is "alpha", "bravo" and "charlie" with "sorted-by input name failed" + set {_healths::*} to health of ((all entities in radius 2 around test-location) sorted by (input's name)) + assert size of {_healths::*} is 3 with "sorted-by entity conversion failed" + + delete entity within {_z1} + delete entity within {_z2} + delete entity within {_z3} + +# --- original list is not mutated --- + +test "sorted does not mutate source": + set {_original::*} to 5, 3, 1, 4 and 2 + set {_sorted::*} to sorted {_original::*} + assert {_sorted::*} is 1, 2, 3, 4 and 5 with "sorted result wrong" + assert {_original::*} is 5, 3, 1, 4 and 2 with "original list was mutated by sorted expression" + + set {_original2::*} to 5, 3, 1, 4 and 2 + set {_sorted2::*} to {_original2::*} sorted in descending order by (input) + assert {_sorted2::*} is 5, 4, 3, 2 and 1 with "sorted by result wrong" + assert {_original2::*} is 5, 3, 1, 4 and 2 with "original list was mutated by sorted by expression" + +# --- large list --- + +test "sorted large list": + set {_big::*} to shuffled integers from 1 to 500 + assert (sorted {_big::*}) is integers from 1 to 500 with "large list sort failed" + + set {_big::*} to shuffled integers from 1 to 500 + assert ({_big::*} sorted in descending order) is integers from 500 to 1 with "large list descending sort failed"