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'" + ); + } +}