diff --git a/java/src/com/google/template/soy/basicfunctions/BasicFunctions.java b/java/src/com/google/template/soy/basicfunctions/BasicFunctions.java index 7ae6b63ad6..036fe765b8 100644 --- a/java/src/com/google/template/soy/basicfunctions/BasicFunctions.java +++ b/java/src/com/google/template/soy/basicfunctions/BasicFunctions.java @@ -56,6 +56,7 @@ public static ImmutableList functions() { new ListFlatMethod(), new ListIncludesFunction(), new ListIndexOfFunction(), + new ListLastIndexOfFunction(), new ListReverseMethod(), new ListSliceMethod(), new ListUniqMethod(), diff --git a/java/src/com/google/template/soy/basicfunctions/BasicFunctionsRuntime.java b/java/src/com/google/template/soy/basicfunctions/BasicFunctionsRuntime.java index ea7a16c16f..e84be2b731 100644 --- a/java/src/com/google/template/soy/basicfunctions/BasicFunctionsRuntime.java +++ b/java/src/com/google/template/soy/basicfunctions/BasicFunctionsRuntime.java @@ -167,6 +167,21 @@ public static int listIndexOf( return indexInSubList == -1 ? -1 : indexInSubList + clampedStartIndex; } + /** Returns the last index of value in list or -1. */ + public static int listLastIndexOf( + List list, SoyValue value, NumberData startIndex) { + int clampedStartIndex = clampListIndex(list, startIndex); + if (clampedStartIndex == list.size()) { + clampedStartIndex = list.size() - 1; + } + for (int i = clampedStartIndex; i >= 0; i--) { + if (list.get(i).resolve().equals(value)) { + return i; + } + } + return -1; + } + /** Joins the list elements by a separator. */ @Nonnull public static String join(List list, String separator) { diff --git a/java/src/com/google/template/soy/basicfunctions/ListLastIndexOfFunction.java b/java/src/com/google/template/soy/basicfunctions/ListLastIndexOfFunction.java new file mode 100644 index 0000000000..624393c3f5 --- /dev/null +++ b/java/src/com/google/template/soy/basicfunctions/ListLastIndexOfFunction.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.template.soy.basicfunctions; + +import com.google.template.soy.data.SoyValue; +import com.google.template.soy.data.restricted.NumberData; +import com.google.template.soy.plugin.java.restricted.JavaPluginContext; +import com.google.template.soy.plugin.java.restricted.JavaValue; +import com.google.template.soy.plugin.java.restricted.JavaValueFactory; +import com.google.template.soy.plugin.java.restricted.SoyJavaSourceFunction; +import com.google.template.soy.plugin.javascript.restricted.JavaScriptPluginContext; +import com.google.template.soy.plugin.javascript.restricted.JavaScriptValue; +import com.google.template.soy.plugin.javascript.restricted.JavaScriptValueFactory; +import com.google.template.soy.plugin.javascript.restricted.SoyJavaScriptSourceFunction; +import com.google.template.soy.plugin.python.restricted.PythonPluginContext; +import com.google.template.soy.plugin.python.restricted.PythonValue; +import com.google.template.soy.plugin.python.restricted.PythonValueFactory; +import com.google.template.soy.plugin.python.restricted.SoyPythonSourceFunction; +import com.google.template.soy.shared.restricted.Signature; +import com.google.template.soy.shared.restricted.SoyMethodSignature; +import com.google.template.soy.shared.restricted.SoyPureFunction; +import java.lang.reflect.Method; +import java.util.List; + +/** + * Soy function for checking the last index an item is contained in a list. + * + *

Usage: {@code list.lastIndexOf(item)} + * + *

+ */ +@SoyMethodSignature( + name = "lastIndexOf", + baseType = "list", + value = { + @Signature(parameterTypes = "any", returnType = "int"), + @Signature( + parameterTypes = {"any", "float|int"}, + returnType = "int") + }) +@SoyPureFunction +public class ListLastIndexOfFunction + implements SoyJavaSourceFunction, SoyJavaScriptSourceFunction, SoyPythonSourceFunction { + + @Override + public JavaScriptValue applyForJavaScriptSource( + JavaScriptValueFactory factory, List args, JavaScriptPluginContext context) { + return factory.callNamespaceFunction("soy", "soy.$$listLastIndexOf", args); + } + + @Override + public PythonValue applyForPythonSource( + PythonValueFactory factory, List args, PythonPluginContext context) { + return factory.global("runtime.list_lastindexof").call(args); + } + + // lazy singleton pattern, allows other backends to avoid the work. + private static final class Methods { + static final Method LIST_CONTAINS_FN = + JavaValueFactory.createMethod( + BasicFunctionsRuntime.class, + "listLastIndexOf", + List.class, + SoyValue.class, + NumberData.class); + } + + @Override + public JavaValue applyForJavaSource( + JavaValueFactory factory, List args, JavaPluginContext context) { + return factory.callStaticMethod( + Methods.LIST_CONTAINS_FN, + args.get(0), + args.get(1), + args.size() == 3 ? args.get(2) : factory.constant(Integer.MAX_VALUE)); + } +} diff --git a/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java b/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java index bff56d8758..bbe5d59ae8 100644 --- a/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java +++ b/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java @@ -72,6 +72,7 @@ import com.google.template.soy.basicfunctions.ListFlatMethod; import com.google.template.soy.basicfunctions.ListIncludesFunction; import com.google.template.soy.basicfunctions.ListIndexOfFunction; +import com.google.template.soy.basicfunctions.ListLastIndexOfFunction; import com.google.template.soy.basicfunctions.ListReverseMethod; import com.google.template.soy.basicfunctions.ListSliceMethod; import com.google.template.soy.basicfunctions.ListUniqMethod; @@ -2165,7 +2166,8 @@ private void finishMethodCallNode(MethodCallNode node, boolean nullSafe) { node.setType(returnType); } } else if (sourceFunction instanceof ListIncludesFunction - || sourceFunction instanceof ListIndexOfFunction) { + || sourceFunction instanceof ListIndexOfFunction + || sourceFunction instanceof ListLastIndexOfFunction) { node.setType(sourceMethod.getReturnType()); if (node.getParam(0) instanceof RecordLiteralNode) { errorReporter.report(node.getParam(0).getSourceLocation(), RECORD_LITERAL_NOT_ALLOWED); diff --git a/javascript/soyutils_usegoog.js b/javascript/soyutils_usegoog.js index b63f573ad9..485c607f4b 100644 --- a/javascript/soyutils_usegoog.js +++ b/javascript/soyutils_usegoog.js @@ -2067,6 +2067,17 @@ const $$listIndexOf = function(list, val, startIndex = 0) { return indexInSublist === -1 ? -1 : indexInSublist + clampedStartIndex; }; +/** + * Returns the last index of val in list or -1 + * @param {!ReadonlyArray} list + * @param {*} val + * @param {number=} startIndex + * @return {number} + */ +const $$listLastIndexOf = function(list, val, startIndex = list.length - 1) { + return list.lastIndexOf(val, startIndex); +}; + /** * Reverses a list and returns it. The original list passed is unaffected. * @param {!ReadonlyArray} list @@ -3104,6 +3115,7 @@ exports = { $$truncate, $$listContains, $$listIndexOf, + $$listLastIndexOf, $$listReverse, $$listUniq, $$listFlat, diff --git a/python/runtime.py b/python/runtime.py index d12a7b6f1f..dc7d807e96 100644 --- a/python/runtime.py +++ b/python/runtime.py @@ -427,6 +427,17 @@ def list_indexof(l, item, start_index=0): return -1 +def list_lastindexof(l, item, start_index=None): + """Equivalent getting the last index of `item in l` but using soy's equality algorithm.""" + if start_index is None or start_index >= len(l): + start_index = len(l) - 1 + clamped_start_index = clamp_list_start_index(l, start_index) + for i in range(clamped_start_index, -1, -1): + if strict_eq(l[i], item): + return i + return -1 + + def strict_eq(first, second): """An equality function that handles JS primitive types as primitives.""" if (