From 4f59b02028abacca6fb5fc037b03e849fd966dd7 Mon Sep 17 00:00:00 2001 From: Florian Meyer Date: Fri, 23 Oct 2026 12:27:47 +0200 Subject: [PATCH] Support indexed access to string characters --- .../el/JinjavaInterpreterResolver.java | 2 + .../jinjava/el/JinjavaStringELResolver.java | 103 +++++++++++++ .../jinjava/el/ExpressionResolverTest.java | 87 +++++++++++ .../el/JinjavaStringELResolverTest.java | 143 ++++++++++++++++++ .../el/ext/eager/EagerAstBracketTest.java | 78 ++++++++++ 5 files changed, 413 insertions(+) create mode 100644 src/main/java/com/hubspot/jinjava/el/JinjavaStringELResolver.java create mode 100644 src/test/java/com/hubspot/jinjava/el/JinjavaStringELResolverTest.java create mode 100644 src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracketTest.java diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java b/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java index c60372b99..58063a36d 100644 --- a/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaInterpreterResolver.java @@ -58,6 +58,7 @@ public class JinjavaInterpreterResolver extends SimpleResolver { add(new JinjavaListELResolver(true)); add(new TypeConvertingMapELResolver(true)); add(new ResourceBundleELResolver()); + add(new JinjavaStringELResolver()); add(new JinjavaBeanELResolver(true)); } }; @@ -68,6 +69,7 @@ public class JinjavaInterpreterResolver extends SimpleResolver { add(new JinjavaListELResolver(false)); add(new TypeConvertingMapELResolver(false)); add(new ResourceBundleELResolver()); + add(new JinjavaStringELResolver()); add(new JinjavaBeanELResolver(false)); } }; diff --git a/src/main/java/com/hubspot/jinjava/el/JinjavaStringELResolver.java b/src/main/java/com/hubspot/jinjava/el/JinjavaStringELResolver.java new file mode 100644 index 000000000..b37e00a1c --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/el/JinjavaStringELResolver.java @@ -0,0 +1,103 @@ +/** + * 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.hubspot.jinjava.el; + +import java.beans.FeatureDescriptor; +import java.util.Iterator; +import javax.el.ELContext; +import javax.el.ELResolver; +import javax.el.PropertyNotWritableException; + +class JinjavaStringELResolver extends ELResolver { + + @Override + public Object getValue(ELContext context, Object base, Object property) { + if (!isResolvable(base, property)) { + return null; + } + context.setPropertyResolved(true); + + int index; + try { + index = Integer.parseInt(property.toString()); + } catch (NumberFormatException e) { + return null; + } + + String value = (String) base; + if (index < 0) { + index += value.length(); + } + // Match the UTF-16 indexing used by string slices and the length filter. + return index >= 0 && index < value.length() + ? value.substring(index, index + 1) + : null; + } + + @Override + public Class getType(ELContext context, Object base, Object property) { + if (isResolvable(base, property)) { + context.setPropertyResolved(true); + return String.class; + } + return null; + } + + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + if (isResolvable(base, property)) { + context.setPropertyResolved(true); + return true; + } + return false; + } + + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + if (isResolvable(base, property)) { + context.setPropertyResolved(true); + throw new PropertyNotWritableException("String indices are read-only"); + } + } + + @Override + public Class getCommonPropertyType(ELContext context, Object base) { + return base instanceof String ? Integer.class : null; + } + + @Override + public Iterator getFeatureDescriptors( + ELContext context, + Object base + ) { + return null; + } + + private static boolean isResolvable(Object base, Object property) { + if (!(base instanceof String) || property == null) { + return false; + } + String index = property.toString(); + int start = index.startsWith("-") ? 1 : 0; + if (start == index.length()) { + return false; + } + for (int i = start; i < index.length(); i++) { + if (!Character.isDigit(index.charAt(i))) { + return false; + } + } + return true; + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java index 83347281a..40669853b 100644 --- a/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ExpressionResolverTest.java @@ -18,6 +18,7 @@ import com.hubspot.jinjava.interpret.TemplateError; import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; +import com.hubspot.jinjava.objects.SafeString; import com.hubspot.jinjava.objects.date.PyishDate; import com.hubspot.jinjava.testobjects.ExpressionResolverTestObjects; import java.math.BigDecimal; @@ -27,6 +28,8 @@ import java.util.Objects; import java.util.Set; import java.util.function.Supplier; +import javax.el.CompositeELResolver; +import javax.el.ELContext; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -102,6 +105,90 @@ public void itResolvesMathVals() { assertThat(interpreter.getContext().wasValueResolved("i_am_seven")).isTrue(); } + @Test + public void itResolvesStringIndex() { + RenderResult result = jinjava.renderForResult( + "{{ x[0] }}", + ImmutableMap.of("x", "stuff") + ); + assertThat(result.getOutput()).isEqualTo("s"); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void itResolvesStringIndicesInExpressions() { + RenderResult result = jinjava.renderForResult( + "{{ 'stuff'[1] }}|{{ x[-1] }}|{{ x['0']|upper }}|{{ x[-5] }}|{{ x[index] }}", + ImmutableMap.of("x", "stuff", "index", 2) + ); + assertThat(result.getOutput()).isEqualTo("t|f|S|s|u"); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void itResolvesMissingStringIndicesWithoutUnknownPropertyErrors() { + RenderResult result = jinjava.renderForResult( + "{{ x[5] }}{{ x[-6] }}{{ ''[0] }}{{ x['999999999999999999999999'] }}", + ImmutableMap.of("x", "stuff") + ); + assertThat(result.getOutput()).isEmpty(); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void itPreservesStringMethodsAndSlices() { + RenderResult result = jinjava.renderForResult( + "{{ x.isEmpty() }}|{{ x.length() }}|{{ x[0:1] }}", + ImmutableMap.of("x", "stuff") + ); + assertThat(result.getOutput()).isEqualTo("false|5|s"); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void itPreservesOtherIndexedValuesAndSafeStringProperties() { + RenderResult result = jinjava.renderForResult( + "{{ list[0] }}|{{ map[0] }}|{{ array[0] }}|{{ safe.value }}", + ImmutableMap.of( + "list", + Lists.newArrayList("list"), + "map", + ImmutableMap.of("0", "map"), + "array", + new String[] { "array" }, + "safe", + new SafeString("safe") + ) + ); + assertThat(result.getOutput()).isEqualTo("list|map|array|safe"); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void itUsesCustomResolversForStringIndices() { + Jinjava customJinjava = new Jinjava( + BaseJinjavaTest + .newConfigBuilder() + .withElResolver( + new CompositeELResolver() { + @Override + public Object getValue(ELContext elContext, Object base, Object property) { + if (base instanceof String && "0".equals(property)) { + elContext.setPropertyResolved(true); + return "custom"; + } + return super.getValue(elContext, base, property); + } + } + ) + .build() + ); + JinjavaInterpreter customInterpreter = customJinjava.newInterpreter(); + customInterpreter.getContext().put("x", "stuff"); + assertThat(customInterpreter.render("{{ x[0] }}")).isEqualTo("custom"); + assertThat(customInterpreter.getErrors()).isEmpty(); + } + @Test public void itResolvesListVal() { context.put("thelist", Lists.newArrayList(1L, 2L, 3L)); diff --git a/src/test/java/com/hubspot/jinjava/el/JinjavaStringELResolverTest.java b/src/test/java/com/hubspot/jinjava/el/JinjavaStringELResolverTest.java new file mode 100644 index 000000000..5524cc975 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/JinjavaStringELResolverTest.java @@ -0,0 +1,143 @@ +/** + * 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.hubspot.jinjava.el; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.objects.SafeString; +import de.odysseus.el.util.SimpleContext; +import javax.el.ELContext; +import javax.el.ELResolver; +import javax.el.PropertyNotWritableException; +import org.junit.Test; + +public class JinjavaStringELResolverTest { + + private final JinjavaStringELResolver resolver = new JinjavaStringELResolver(); + + @Test + public void itResolvesPositiveAndNegativeIndices() { + for (Object index : new Object[] { 0, 0L, "0", "-0", -5, "-5" }) { + assertThat(resolve("stuff", index)).isEqualTo("s"); + } + for (Object index : new Object[] { 4, "4", -1L, "-1" }) { + assertThat(resolve("stuff", index)).isEqualTo("f"); + } + } + + @Test + public void itReturnsNullOutsideTheString() { + for (Object index : new Object[] { + 5, + -6, + Integer.MAX_VALUE, + Integer.MIN_VALUE, + Long.MAX_VALUE, + Long.MIN_VALUE, + "999999999999999999999999999999", + "-999999999999999999999999999999", + }) { + assertThat(resolve("stuff", index)).isNull(); + } + assertThat(resolve("", 0)).isNull(); + assertThat(resolve("", -1)).isNull(); + } + + @Test + public void itUsesTheSameCharacterBoundariesAsStringSlices() { + String value = "รค\uD83D\uDE00z"; + for (int i = 0; i < value.length(); i++) { + assertThat(resolve(value, i)).isEqualTo(value.substring(i, i + 1)); + assertThat(resolve(value, i - value.length())).isEqualTo(value.substring(i, i + 1)); + } + } + + @Test + public void itLeavesNonIndexPropertiesUnresolved() { + for (Object property : new Object[] { + null, + "", + "-", + "--1", + "1-2", + "+1", + "1.5", + 1.5, + true, + "length", + "empty", + }) { + assertUnresolved("stuff", property); + } + } + + @Test + public void itLeavesOtherTypesUnresolved() { + for (Object base : new Object[] { + null, + 123, + ImmutableList.of("s"), + ImmutableMap.of("0", "s"), + new String[] { "s" }, + new StringBuilder("stuff"), + new SafeString("stuff"), + }) { + assertUnresolved(base, "0"); + } + } + + @Test + public void itKeepsStringsReadOnlyInBothDefaultResolvers() { + for (ELResolver defaultResolver : new ELResolver[] { + JinjavaInterpreterResolver.DEFAULT_RESOLVER_READ_ONLY, + JinjavaInterpreterResolver.DEFAULT_RESOLVER_READ_WRITE, + }) { + ELContext context = new SimpleContext(); + assertThat(defaultResolver.getValue(context, "stuff", "0")).isEqualTo("s"); + assertThat(context.isPropertyResolved()).isTrue(); + context.setPropertyResolved(false); + assertThat(defaultResolver.getType(context, "stuff", "0")).isEqualTo(String.class); + assertThat(context.isPropertyResolved()).isTrue(); + context.setPropertyResolved(false); + assertThat(defaultResolver.isReadOnly(context, "stuff", "0")).isTrue(); + assertThat(context.isPropertyResolved()).isTrue(); + context.setPropertyResolved(false); + assertThatThrownBy(() -> defaultResolver.setValue(context, "stuff", "0", "x")) + .isInstanceOf(PropertyNotWritableException.class); + assertThat(context.isPropertyResolved()).isTrue(); + } + } + + private Object resolve(String base, Object property) { + ELContext context = new SimpleContext(); + Object result = resolver.getValue(context, base, property); + assertThat(context.isPropertyResolved()).isTrue(); + return result; + } + + private void assertUnresolved(Object base, Object property) { + ELContext context = new SimpleContext(); + assertThat(resolver.getValue(context, base, property)).isNull(); + assertThat(context.isPropertyResolved()).isFalse(); + assertThat(resolver.getType(context, base, property)).isNull(); + assertThat(context.isPropertyResolved()).isFalse(); + assertThat(resolver.isReadOnly(context, base, property)).isFalse(); + assertThat(context.isPropertyResolved()).isFalse(); + resolver.setValue(context, base, property, "x"); + assertThat(context.isPropertyResolved()).isFalse(); + } +} diff --git a/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracketTest.java b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracketTest.java new file mode 100644 index 000000000..78a57ea55 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/el/ext/eager/EagerAstBracketTest.java @@ -0,0 +1,78 @@ +/** + * 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.hubspot.jinjava.el.ext.eager; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.DeferredValue; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.mode.EagerExecutionMode; +import org.junit.Before; +import org.junit.Test; + +public class EagerAstBracketTest extends BaseInterpretingTest { + + @Before + public void setup() { + JinjavaConfig config = BaseJinjavaTest + .newConfigBuilder() + .withExecutionMode(EagerExecutionMode.instance()) + .withNestedInterpretationEnabled(true) + .build(); + JinjavaInterpreter parentInterpreter = new JinjavaInterpreter( + jinjava, + new Context(), + config + ); + interpreter = new JinjavaInterpreter(parentInterpreter); + interpreter.getContext().put("word", "hello"); + interpreter.getContext().put("deferred", DeferredValue.instance()); + } + + @Test + public void itIndexesStringsFromTheContext() { + assertThat(interpreter.render("{{ word[1] }}")).isEqualTo("e"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itIndexesStringLiteralsWithNegativeIndices() { + assertThat(interpreter.render("{{ 'hello'[-1] }}")).isEqualTo("o"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersIndexingAnUnresolvedString() { + String partiallyRendered = interpreter.render("{{ deferred[1] }}"); + assertThat(partiallyRendered).isEqualTo("{{ deferred[1] }}"); + + interpreter.getContext().put("deferred", "hello"); + assertThat(interpreter.render(partiallyRendered)).isEqualTo("e"); + assertThat(interpreter.getErrors()).isEmpty(); + } + + @Test + public void itDefersIndexingAStringWithAnUnresolvedIndex() { + String partiallyRendered = interpreter.render("{{ word[deferred] }}"); + assertThat(partiallyRendered).isEqualTo("{{ 'hello'[deferred] }}"); + + interpreter.getContext().put("deferred", -1); + assertThat(interpreter.render(partiallyRendered)).isEqualTo("o"); + assertThat(interpreter.getErrors()).isEmpty(); + } +}