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..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,6 +43,7 @@ protected void registerDefaults() { IsFloatExpTest.class, IsStringExpTest.class, IsStringContainingExpTest.class, + IsStringSearchExpTest.class, IsStringStartingWithExpTest.class, IsTrueExpTest.class, IsFalseExpTest.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTest.java b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTest.java new file mode 100644 index 000000000..e47e23ab6 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTest.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 search '[0-9]+' %}\n" + + " \n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "Use with selectattr to filter a list by regex", + code = "{{ items|selectattr('name', 'search', '^foo') }}" + ), + } +) +public class IsStringSearchExpTest extends IsStringExpTest { + + @Override + public String getName() { + return "search"; + } + + @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..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,6 +87,7 @@ protected void registerDefaults() { PlusTimeFilter.class, PrettyPrintFilter.class, RandomFilter.class, + RegexMatchFilter.class, RegexReplaceFilter.class, RejectFilter.class, RejectAttrFilter.class, diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/RegexMatchFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/RegexMatchFilter.java new file mode 100644 index 000000000..5fea0ea39 --- /dev/null +++ b/src/main/java/com/hubspot/jinjava/lib/filter/RegexMatchFilter.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\"|regex_match(\"[0-9]+\") %}\n" + + " Contains a number\n" + + "{% endif %}" + ), + @JinjavaSnippet( + desc = "Anchor the pattern to match the entire string", + code = "{% if \"hello\"|regex_match(\"^[a-z]+$\") %}\n" + + " All lowercase letters\n" + + "{% endif %}" + ), + } +) +public class RegexMatchFilter implements Filter { + + @Override + public String getName() { + return "regex_match"; + } + + @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/IsStringSearchExpTestTest.java b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTestTest.java new file mode 100644 index 000000000..dfa7d2947 --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/exptest/IsStringSearchExpTestTest.java @@ -0,0 +1,73 @@ +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 IsStringSearchExpTestTest extends BaseJinjavaTest { + + private static final String MATCHING_TEMPLATE = "{{ var is search 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', 'search', '^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/RegexMatchFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/RegexMatchFilterTest.java new file mode 100644 index 000000000..6f5d7fc1b --- /dev/null +++ b/src/test/java/com/hubspot/jinjava/lib/filter/RegexMatchFilterTest.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 RegexMatchFilterTest extends BaseInterpretingTest { + + RegexMatchFilter filter; + + @Before + public void setup() { + filter = new RegexMatchFilter(); + } + + @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 'regex_match': input with length '101' exceeds maximum allowed length of '10'" + ); + } +}