From 05fa3fd56ac3c68585f918da4c28d35d385f0b4a Mon Sep 17 00:00:00 2001 From: Craig Ayre Date: Wed, 29 Jul 2026 17:15:31 +0100 Subject: [PATCH 1/2] feat: add matches_regex filter and string_matching_regex expression test There's no direct way to test whether a string matches a regex. We've been working around it with things like: {% if var and var|regex_replace('^pattern$', '') == '' %} This adds a matches_regex filter and a string_matching_regex expression test so you can write: {{ 'hello'|matches_regex('^[a-z]+$') }} {% if var is string_matching_regex '[0-9]+' %} {{ items|selectattr('name', 'string_matching_regex', '^foo') }} Both use RE2 (com.google.re2j) consistent with the existing regex_replace filter. Both use partial matching (Matcher.find), anchor with ^...$ for full-string matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jinjava/lib/exptest/ExpTestLibrary.java | 1 + .../exptest/IsStringMatchingRegexExpTest.java | 79 +++++++++++++ .../jinjava/lib/filter/FilterLibrary.java | 1 + .../lib/filter/MatchesRegexFilter.java | 108 ++++++++++++++++++ .../IsStringMatchingRegexExpTestTest.java | 74 ++++++++++++ .../lib/filter/MatchesRegexFilterTest.java | 87 ++++++++++++++ 6 files changed, 350 insertions(+) create mode 100644 src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java create mode 100644 src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java create mode 100644 src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java create mode 100644 src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java index b14dc8451..087ef2ed5 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java @@ -43,6 +43,7 @@ protected void registerDefaults() { IsFloatExpTest.class, IsStringExpTest.class, IsStringContainingExpTest.class, + IsStringMatchingRegexExpTest.class, IsStringStartingWithExpTest.class, IsTrueExpTest.class, IsFalseExpTest.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java new file mode 100644 index 000000000..3a90be46d --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java @@ -0,0 +1,79 @@ +package com.hubspot.jinjava.lib.exptest; + +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; + +@JinjavaDoc( + value = "Return true if object is a string which matches a specified regular expression " + + "(Java RE2 syntax). Uses partial matching (the pattern can match anywhere in the string). " + + "Anchor with ^...$ for full-string matching.", + input = @JinjavaParam(value = "string", type = "string", required = true), + params = @JinjavaParam( + value = "regex", + type = "string", + desc = "The regular expression to match against the string", + required = true + ), + snippets = { + @JinjavaSnippet( + code = "{% if variable is string_matching_regex '[0-9]+' %}\n" + + " \n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "Use with selectattr to filter a list by regex", + code = "{{ items|selectattr('name', 'string_matching_regex', '^foo') }}" + ), + } +) +public class IsStringMatchingRegexExpTest extends IsStringExpTest { + + @Override + public String getName() { + return super.getName() + "_matching_regex"; + } + + @Override + public boolean evaluate(Object var, JinjavaInterpreter interpreter, Object... args) { + if (!super.evaluate(var, interpreter, args)) { + return false; + } + + if (args.length == 0) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (regex string)" + ); + } + + if (args[0] == null) { + return false; + } + + String regex = args[0].toString(); + + try { + Pattern p = Pattern.compile(regex); + Matcher matcher = p.matcher((String) var); + + return matcher.find(); + } catch (PatternSyntaxException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.REGEX, + 0, + regex + ); + } + } +} diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java b/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java index f5dfc30e3..17cb46bd2 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java @@ -87,6 +87,7 @@ protected void registerDefaults() { PlusTimeFilter.class, PrettyPrintFilter.class, RandomFilter.class, + MatchesRegexFilter.class, RegexReplaceFilter.class, RejectFilter.class, RejectAttrFilter.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java new file mode 100644 index 000000000..39a6f3ffe --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java @@ -0,0 +1,108 @@ +package com.hubspot.jinjava.lib.filter; + +import static com.hubspot.jinjava.lib.filter.ReplaceFilter.checkLength; + +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import com.hubspot.jinjava.doc.annotations.JinjavaDoc; +import com.hubspot.jinjava.doc.annotations.JinjavaParam; +import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.objects.SafeString; + +@JinjavaDoc( + value = "Returns true if the value matches the given regular expression (Java RE2 syntax), " + + "false otherwise. Uses partial matching (the pattern can match anywhere in the string). " + + "Anchor with ^...$ for full-string matching.", + input = @JinjavaParam( + value = "s", + desc = "Base string to test against", + required = true + ), + params = { + @JinjavaParam( + value = "regex", + desc = "The regular expression to match against the string", + required = true + ), + }, + snippets = { + @JinjavaSnippet( + code = "{% if \"It costs $300\"|matches_regex(\"[0-9]+\") %}\n" + + " Contains a number\n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "Anchor the pattern to match the entire string", + code = "{% if \"hello\"|matches_regex(\"^[a-z]+$\") %}\n" + + " All lowercase letters\n" + + "{% endif %}" + ), + } +) +public class MatchesRegexFilter implements Filter { + + @Override + public String getName() { + return "matches_regex"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (args.length < 1) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires 1 argument (regex string)" + ); + } + + if (args[0] == null) { + throw new TemplateSyntaxException( + interpreter, + getName(), + "requires a valid regex param (not null)" + ); + } + + if (var == null) { + return false; + } + + String s; + if (var instanceof String) { + s = (String) var; + } else if (var instanceof SafeString) { + s = ((SafeString) var).getValue(); + } else { + throw new InvalidInputException(interpreter, this, InvalidReason.STRING); + } + + // Minor optimization, avoid checking short strings + if (s.length() > 100) { + checkLength(interpreter, s, this); + } + + String regex = args[0]; + + try { + Pattern p = Pattern.compile(regex); + Matcher matcher = p.matcher(s); + + return matcher.find(); + } catch (PatternSyntaxException e) { + throw new InvalidArgumentException( + interpreter, + this, + InvalidReason.REGEX, + 0, + regex + ); + } + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java new file mode 100644 index 000000000..f39406c87 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java @@ -0,0 +1,74 @@ +package com.hubspot.jinjava.lib.exptest; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.collect.ImmutableMap; +import com.hubspot.jinjava.BaseJinjavaTest; +import com.hubspot.jinjava.objects.SafeString; +import java.util.Arrays; +import org.junit.Test; + +public class IsStringMatchingRegexExpTestTest extends BaseJinjavaTest { + + private static final String MATCHING_TEMPLATE = + "{{ var is string_matching_regex arg }}"; + + @Test + public void itReturnsTrueForMatchingRegex() { + assertThat( + jinjava.render( + MATCHING_TEMPLATE, + ImmutableMap.of("var", "It costs $300", "arg", "[0-9]+") + ) + ) + .isEqualTo("true"); + } + + @Test + public void itReturnsFalseForNonMatchingRegex() { + assertThat( + jinjava.render( + MATCHING_TEMPLATE, + ImmutableMap.of("var", "hello world", "arg", "[0-9]+") + ) + ) + .isEqualTo("false"); + } + + @Test + public void itReturnsFalseForNull() { + assertThat(jinjava.render(MATCHING_TEMPLATE, ImmutableMap.of("var", "testing"))) + .isEqualTo("false"); + } + + @Test + public void itWorksForSafeString() { + assertThat( + jinjava.render( + MATCHING_TEMPLATE, + ImmutableMap.of("var", "testing", "arg", new SafeString("^test")) + ) + ) + .isEqualTo("true"); + } + + @Test + public void itWorksWithSelectattr() { + String template = + "{% for item in items|selectattr('name', 'string_matching_regex', '^foo') %}{{ item.name }},{% endfor %}"; + assertThat( + jinjava.render( + template, + ImmutableMap.of( + "items", + Arrays.asList( + ImmutableMap.of("name", "foobar"), + ImmutableMap.of("name", "baz"), + ImmutableMap.of("name", "foobaz") + ) + ) + ) + ) + .isEqualTo("foobar,foobaz,"); + } +} diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java new file mode 100644 index 000000000..cfee8cfc5 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java @@ -0,0 +1,87 @@ +package com.hubspot.jinjava.lib.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.objects.SafeString; +import org.junit.Before; +import org.junit.Test; + +public class MatchesRegexFilterTest extends BaseInterpretingTest { + + MatchesRegexFilter filter; + + @Before + public void setup() { + filter = new MatchesRegexFilter(); + } + + @Test + public void expects1Arg() { + assertThatThrownBy(() -> filter.filter("foo", interpreter)) + .hasMessageContaining("requires 1 argument"); + } + + @Test + public void expectsNotNullArg() { + assertThatThrownBy(() -> filter.filter("foo", interpreter, new String[] { null })) + .hasMessageContaining("a valid regex"); + } + + @Test + public void itReturnsFalseOnNullInput() { + assertThat(filter.filter(null, interpreter, "foo")).isEqualTo(false); + } + + @Test + public void itMatchesRegex() { + assertThat(filter.filter("It costs $300", interpreter, "[0-9]+")).isEqualTo(true); + } + + @Test + public void itDoesNotMatchRegex() { + assertThat(filter.filter("hello world", interpreter, "[0-9]+")).isEqualTo(false); + } + + @Test + public void itMatchesAnchoredRegex() { + assertThat(filter.filter("hello", interpreter, "^[a-z]+$")).isEqualTo(true); + } + + @Test + public void itDoesNotMatchAnchoredRegex() { + assertThat(filter.filter("hello123", interpreter, "^[a-z]+$")).isEqualTo(false); + } + + @Test(expected = InvalidArgumentException.class) + public void itThrowsExceptionOnInvalidRegex() { + filter.filter("It costs $300", interpreter, "["); + } + + @Test + public void itMatchesRegexForSafeString() { + assertThat(filter.filter(new SafeString("It costs $300"), interpreter, "[0-9]+")) + .isEqualTo(true); + } + + @Test + public void itLimitsLongInput() { + assertThatThrownBy(() -> + filter.filter( + "a".repeat(101), + new Jinjava(JinjavaConfig.newBuilder().withMaxStringLength(10).build()) + .newInterpreter(), + "O" + ) + ) + .isInstanceOf(InvalidInputException.class) + .hasMessageContaining( + "Invalid input for 'matches_regex': input with length '101' exceeds maximum allowed length of '10'" + ); + } +} From cc468e8f43d7e837fd09633a189b76a8aa81bc80 Mon Sep 17 00:00:00 2001 From: Craig Ayre Date: Fri, 31 Jul 2026 22:39:01 +0100 Subject: [PATCH 2/2] refactor: rename matches_regex filter to regex_match and string_matching_regex test to search Co-Authored-By: Claude Sonnet 4.6 --- .../com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java | 2 +- ...tchingRegexExpTest.java => IsStringSearchExpTest.java} | 8 ++++---- .../com/hubspot/jinjava/lib/filter/FilterLibrary.java | 2 +- .../{MatchesRegexFilter.java => RegexMatchFilter.java} | 8 ++++---- ...gexExpTestTest.java => IsStringSearchExpTestTest.java} | 7 +++---- ...chesRegexFilterTest.java => RegexMatchFilterTest.java} | 8 ++++---- 6 files changed, 17 insertions(+), 18 deletions(-) rename src/main/java/com/hubspot/jinjava/lib/exptest/{IsStringMatchingRegexExpTest.java => IsStringSearchExpTest.java} (88%) rename src/main/java/com/hubspot/jinjava/lib/filter/{MatchesRegexFilter.java => RegexMatchFilter.java} (92%) rename src/test/java/com/hubspot/jinjava/lib/exptest/{IsStringMatchingRegexExpTestTest.java => IsStringSearchExpTestTest.java} (84%) rename src/test/java/com/hubspot/jinjava/lib/filter/{MatchesRegexFilterTest.java => RegexMatchFilterTest.java} (90%) diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java index 087ef2ed5..fda8701f1 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/ExpTestLibrary.java @@ -43,7 +43,7 @@ protected void registerDefaults() { IsFloatExpTest.class, IsStringExpTest.class, IsStringContainingExpTest.class, - IsStringMatchingRegexExpTest.class, + IsStringSearchExpTest.class, IsStringStartingWithExpTest.class, IsTrueExpTest.class, IsFalseExpTest.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTest.java similarity index 88% rename from src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java rename to src/main/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTest.java index 3a90be46d..e47e23ab6 100644 --- a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTest.java +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTest.java @@ -24,21 +24,21 @@ ), snippets = { @JinjavaSnippet( - code = "{% if variable is string_matching_regex '[0-9]+' %}\n" + + code = "{% if variable is search '[0-9]+' %}\n" + " \n" + "{% endif %}" ), @JinjavaSnippet( desc = "Use with selectattr to filter a list by regex", - code = "{{ items|selectattr('name', 'string_matching_regex', '^foo') }}" + code = "{{ items|selectattr('name', 'search', '^foo') }}" ), } ) -public class IsStringMatchingRegexExpTest extends IsStringExpTest { +public class IsStringSearchExpTest extends IsStringExpTest { @Override public String getName() { - return super.getName() + "_matching_regex"; + return "search"; } @Override diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java b/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java index 17cb46bd2..341fc149f 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/FilterLibrary.java @@ -87,7 +87,7 @@ protected void registerDefaults() { PlusTimeFilter.class, PrettyPrintFilter.class, RandomFilter.class, - MatchesRegexFilter.class, + RegexMatchFilter.class, RegexReplaceFilter.class, RejectFilter.class, RejectAttrFilter.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RegexMatchFilter.java similarity index 92% rename from src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java rename to src/main/java/com/hubspot/jinjava/lib/filter/RegexMatchFilter.java index 39a6f3ffe..5fea0ea39 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RegexMatchFilter.java @@ -33,23 +33,23 @@ }, snippets = { @JinjavaSnippet( - code = "{% if \"It costs $300\"|matches_regex(\"[0-9]+\") %}\n" + + code = "{% if \"It costs $300\"|regex_match(\"[0-9]+\") %}\n" + " Contains a number\n" + "{% endif %}" ), @JinjavaSnippet( desc = "Anchor the pattern to match the entire string", - code = "{% if \"hello\"|matches_regex(\"^[a-z]+$\") %}\n" + + code = "{% if \"hello\"|regex_match(\"^[a-z]+$\") %}\n" + " All lowercase letters\n" + "{% endif %}" ), } ) -public class MatchesRegexFilter implements Filter { +public class RegexMatchFilter implements Filter { @Override public String getName() { - return "matches_regex"; + return "regex_match"; } @Override diff --git a/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTestTest.java similarity index 84% rename from src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java rename to src/test/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTestTest.java index f39406c87..dfa7d2947 100644 --- a/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringMatchingRegexExpTestTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTestTest.java @@ -8,10 +8,9 @@ import java.util.Arrays; import org.junit.Test; -public class IsStringMatchingRegexExpTestTest extends BaseJinjavaTest { +public class IsStringSearchExpTestTest extends BaseJinjavaTest { - private static final String MATCHING_TEMPLATE = - "{{ var is string_matching_regex arg }}"; + private static final String MATCHING_TEMPLATE = "{{ var is search arg }}"; @Test public void itReturnsTrueForMatchingRegex() { @@ -55,7 +54,7 @@ public void itWorksForSafeString() { @Test public void itWorksWithSelectattr() { String template = - "{% for item in items|selectattr('name', 'string_matching_regex', '^foo') %}{{ item.name }},{% endfor %}"; + "{% for item in items|selectattr('name', 'search', '^foo') %}{{ item.name }},{% endfor %}"; assertThat( jinjava.render( template, diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RegexMatchFilterTest.java similarity index 90% rename from src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java rename to src/test/java/com/hubspot/jinjava/lib/filter/RegexMatchFilterTest.java index cfee8cfc5..6f5d7fc1b 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/MatchesRegexFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RegexMatchFilterTest.java @@ -12,13 +12,13 @@ import org.junit.Before; import org.junit.Test; -public class MatchesRegexFilterTest extends BaseInterpretingTest { +public class RegexMatchFilterTest extends BaseInterpretingTest { - MatchesRegexFilter filter; + RegexMatchFilter filter; @Before public void setup() { - filter = new MatchesRegexFilter(); + filter = new RegexMatchFilter(); } @Test @@ -81,7 +81,7 @@ public void itLimitsLongInput() { ) .isInstanceOf(InvalidInputException.class) .hasMessageContaining( - "Invalid input for 'matches_regex': input with length '101' exceeds maximum allowed length of '10'" + "Invalid input for 'regex_match': input with length '101' exceeds maximum allowed length of '10'" ); } }