diff --git a/pom.xml b/pom.xml index 15963c10..d81d9ea0 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,14 @@ 7.0.8 test + + + com.google.re2j + re2j + 1.8 + test + org.antlr antlr4-runtime diff --git a/src/main/java/com/api/jsonata4java/Expression.java b/src/main/java/com/api/jsonata4java/Expression.java index 9a4e914f..6adf8d48 100644 --- a/src/main/java/com/api/jsonata4java/Expression.java +++ b/src/main/java/com/api/jsonata4java/Expression.java @@ -19,6 +19,7 @@ import com.api.jsonata4java.expressions.ParseException; import com.api.jsonata4java.expressions.functions.DeclaredFunction; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.ExprContext; +import com.api.jsonata4java.expressions.regex.RegexEngine; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -90,6 +91,25 @@ public static Expression jsonata(String expression) throws ParseException, IOExc return new Expression(expression); } + /** + * Generate a new Expression based on evaluating the supplied expression, + * using a custom regex engine to compile any regex literals or dynamic + * string patterns encountered (e.g. for $match/$replace/$split) instead of + * the default {@link java.util.regex.Pattern}-backed one. + * + * @param expression + * the logic to be parsed for later execution via the evaluate + * methods + * @param regexEngine + * the regex engine to use + * @return new Expression object + * @throws ParseException + * @throws IOException + */ + public static Expression jsonata(String expression, RegexEngine regexEngine) throws ParseException, IOException { + return new Expression(expression, regexEngine); + } + /** * Testing the various methods based on * https://docs.jsonata.org/embedding-extending#expressionregisterfunctionname-implementation-signature @@ -143,10 +163,11 @@ public static void main(String[] args) { ExpressionsVisitor _eval = null; Expressions _expr = null; Map _variableMap = new HashMap(); + RegexEngine _regexEngine = RegexEngine.defaultEngine(); /** * Constructor for Expression - * + * * @param expression * the logic to be parsed for later execution via evaluate * methods @@ -158,6 +179,26 @@ public Expression(String expression) throws ParseException, IOException { _eval = _expr.getExpr(); } + /** + * Constructor for Expression, using a custom regex engine to compile any + * regex literals or dynamic string patterns encountered (e.g. for + * $match/$replace/$split) instead of the default + * {@link java.util.regex.Pattern}-backed one. + * + * @param expression + * the logic to be parsed for later execution via evaluate + * methods + * @param regexEngine + * the regex engine to use + * @throws ParseException + * @throws IOException + */ + public Expression(String expression, RegexEngine regexEngine) throws ParseException, IOException { + _regexEngine = regexEngine != null ? regexEngine : RegexEngine.defaultEngine(); + _expr = Expressions.parse(expression, _regexEngine); + _eval = _expr.getExpr(); + } + /** * Assign the binding to the environment preparing for evaluation * @@ -210,7 +251,7 @@ public void clear() { * @throws ParseException */ public JsonNode evaluate(JsonNode rootContext) throws ParseException { - ExpressionsVisitor eval = new ExpressionsVisitor(rootContext, new FrameEnvironment(null)); + ExpressionsVisitor eval = new ExpressionsVisitor(rootContext, new FrameEnvironment(null), _regexEngine); // process any stored bindings for (Iterator it = _variableMap.keySet().iterator(); it.hasNext();) { String key = it.next(); @@ -359,7 +400,7 @@ public JsonNode evaluate(JsonNode rootContext, List bindings, long time * If the given device event is invalid. */ public JsonNode evaluate(JsonNode rootContext, long timeoutMS, int maxDepth) throws EvaluateException { - ExpressionsVisitor eval = new ExpressionsVisitor(rootContext, new FrameEnvironment(null)); + ExpressionsVisitor eval = new ExpressionsVisitor(rootContext, new FrameEnvironment(null), _regexEngine); // process any stored bindings for (Iterator it = _variableMap.keySet().iterator(); it.hasNext();) { String key = it.next(); diff --git a/src/main/java/com/api/jsonata4java/expressions/Expressions.java b/src/main/java/com/api/jsonata4java/expressions/Expressions.java index f0de5ad7..0f0bf5a1 100644 --- a/src/main/java/com/api/jsonata4java/expressions/Expressions.java +++ b/src/main/java/com/api/jsonata4java/expressions/Expressions.java @@ -41,6 +41,7 @@ import com.api.jsonata4java.expressions.generated.MappingExpressionParser; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.ExprContext; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.Expr_to_eofContext; +import com.api.jsonata4java.expressions.regex.RegexEngine; import com.api.jsonata4java.expressions.utils.Constants; import com.fasterxml.jackson.databind.JsonNode; @@ -75,6 +76,25 @@ public static List getRefsInExpression(Pattern refPattern, String expres // Convert a mapping expression string into a pre-processed expression ready // for evaluation public static Expressions parse(String mappingExpression) throws ParseException, IOException { + return parse(mappingExpression, RegexEngine.defaultEngine()); + } + + /** + * Convert a mapping expression string into a pre-processed expression ready + * for evaluation, using a custom regex engine to compile any regex literals + * or dynamic string patterns encountered (e.g. for $match/$replace/$split) + * instead of the default {@link java.util.regex.Pattern}-backed one. + * + * @param mappingExpression + * the expression to parse + * @param regexEngine + * the regex engine to use + * @return the parsed expression + * @throws ParseException + * @throws IOException + */ + public static Expressions parse(String mappingExpression, RegexEngine regexEngine) + throws ParseException, IOException { // Expressions can include references to properties within an // application interface ("state"), @@ -119,7 +139,7 @@ public static Expressions parse(String mappingExpression) throws ParseException, throw new ParseException(e.getMessage()); } - return new Expressions(newTree, mappingExpression); + return new Expressions(newTree, mappingExpression, regexEngine); } ExpressionsVisitor _eval = null; @@ -129,7 +149,11 @@ public static Expressions parse(String mappingExpression) throws ParseException, ParseTree _tree = null; public Expressions(ParseTree aTree, String anExpression) { - _eval = new ExpressionsVisitor(null, new FrameEnvironment(null)); + this(aTree, anExpression, RegexEngine.defaultEngine()); + } + + public Expressions(ParseTree aTree, String anExpression, RegexEngine regexEngine) { + _eval = new ExpressionsVisitor(null, new FrameEnvironment(null), regexEngine); _tree = aTree; _expression = anExpression; } diff --git a/src/main/java/com/api/jsonata4java/expressions/ExpressionsVisitor.java b/src/main/java/com/api/jsonata4java/expressions/ExpressionsVisitor.java index 3b8f0063..b51759c4 100644 --- a/src/main/java/com/api/jsonata4java/expressions/ExpressionsVisitor.java +++ b/src/main/java/com/api/jsonata4java/expressions/ExpressionsVisitor.java @@ -70,6 +70,7 @@ import com.api.jsonata4java.expressions.generated.MappingExpressionParser.SeqContext; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.StringContext; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.Var_recallContext; +import com.api.jsonata4java.expressions.regex.RegexEngine; import com.api.jsonata4java.expressions.utils.BooleanUtils; import com.api.jsonata4java.expressions.utils.Constants; import com.api.jsonata4java.expressions.utils.FunctionUtils; @@ -437,6 +438,7 @@ public static JsonNode unwrapArray(JsonNode input) { private List steps = new ArrayList(); private ParseTreeProperty values = new ParseTreeProperty(); private String lastVisited = ""; + private RegexEngine regexEngine = RegexEngine.defaultEngine(); public ExpressionsVisitor() { setEnvironment(null); @@ -448,6 +450,29 @@ public ExpressionsVisitor(JsonNode rootContext, FrameEnvironment environment) th setRootContext(rootContext); } + public ExpressionsVisitor(JsonNode rootContext, FrameEnvironment environment, RegexEngine regexEngine) + throws EvaluateRuntimeException { + setEnvironment(environment); + setRootContext(rootContext); + if (regexEngine != null) { + this.regexEngine = regexEngine; + } + } + + /** + * @return the regex engine used to compile JSONata regex literals and + * dynamic string patterns (e.g. for $match/$replace/$split). + */ + public RegexEngine getRegexEngine() { + return regexEngine; + } + + public void setRegexEngine(RegexEngine regexEngine) { + if (regexEngine != null) { + this.regexEngine = regexEngine; + } + } + public FrameEnvironment setNewEnvironment() { // generate a new frameEnvironment for life of this block FrameEnvironment oldEnvironment = _environment; @@ -2732,7 +2757,7 @@ public JsonNode visitRegular_expression(MappingExpressionParser.Regular_expressi } JsonNode result = null; if (ctx.getText() != null) { - final RegularExpression regex = new RegularExpression(ctx.getText()); + final RegularExpression regex = new RegularExpression(ctx.getText(), regexEngine); result = new POJONode(regex); lastVisited = METHOD; } @@ -2754,7 +2779,7 @@ public JsonNode visitRegular_expression_caseinsensitive( JsonNode result = null; if (ctx.getText() != null) { final RegularExpression regex = new RegularExpression(RegularExpression.Type.CASEINSENSITIVE, - ctx.getText()); + ctx.getText(), regexEngine); result = new POJONode(regex); lastVisited = METHOD; } @@ -2774,7 +2799,8 @@ public JsonNode visitRegular_expression_multiline(MappingExpressionParser.Regula } JsonNode result = null; if (ctx.getText() != null) { - final RegularExpression regex = new RegularExpression(RegularExpression.Type.MULTILINE, ctx.getText()); + final RegularExpression regex = new RegularExpression(RegularExpression.Type.MULTILINE, ctx.getText(), + regexEngine); result = new POJONode(regex); lastVisited = METHOD; } diff --git a/src/main/java/com/api/jsonata4java/expressions/RegularExpression.java b/src/main/java/com/api/jsonata4java/expressions/RegularExpression.java index 3473650d..4afb6c1c 100644 --- a/src/main/java/com/api/jsonata4java/expressions/RegularExpression.java +++ b/src/main/java/com/api/jsonata4java/expressions/RegularExpression.java @@ -22,11 +22,13 @@ package com.api.jsonata4java.expressions; -import java.util.regex.Pattern; +import com.api.jsonata4java.expressions.regex.RegexEngine; +import com.api.jsonata4java.expressions.regex.RegexFlags; +import com.api.jsonata4java.expressions.regex.RegexPattern; /** * A helper class to store information about a parsed regular expression. - * + * * @author Martin Bluemel */ public class RegularExpression { @@ -39,13 +41,21 @@ public enum Type { private String regexPattern; - private Pattern pattern; + private RegexPattern pattern; public RegularExpression(String string) { - this(Type.NORMAL, string); + this(Type.NORMAL, string, RegexEngine.defaultEngine()); } public RegularExpression(final Type type, final String regex) { + this(type, regex, RegexEngine.defaultEngine()); + } + + public RegularExpression(String string, RegexEngine engine) { + this(Type.NORMAL, string, engine); + } + + public RegularExpression(final Type type, final String regex, final RegexEngine engine) { this.type = type; switch (type) { case CASEINSENSITIVE: @@ -56,21 +66,23 @@ public RegularExpression(final Type type, final String regex) { regexPattern = regex.substring(1, regex.length() - 1); break; } - compile(); + compile(engine); } - private void compile() { + private void compile(final RegexEngine engine) { + final RegexFlags flags; switch (this.type) { case CASEINSENSITIVE: - this.pattern = Pattern.compile(regexPattern, Pattern.CASE_INSENSITIVE); + flags = new RegexFlags(true, false); break; case MULTILINE: - this.pattern = Pattern.compile(regexPattern, Pattern.MULTILINE); + flags = new RegexFlags(false, true); break; default: - this.pattern = Pattern.compile(regexPattern); + flags = new RegexFlags(false, false); break; } + this.pattern = engine.compile(regexPattern, flags); } @Override @@ -82,7 +94,11 @@ public Type getType() { return this.type; } - public Pattern getPattern() { + /** + * @return the compiled regex, using whichever {@link RegexEngine} this + * instance was constructed with. + */ + public RegexPattern getPattern() { return this.pattern; } } diff --git a/src/main/java/com/api/jsonata4java/expressions/functions/ContainsFunction.java b/src/main/java/com/api/jsonata4java/expressions/functions/ContainsFunction.java index 9a4c97e4..8531a324 100644 --- a/src/main/java/com/api/jsonata4java/expressions/functions/ContainsFunction.java +++ b/src/main/java/com/api/jsonata4java/expressions/functions/ContainsFunction.java @@ -118,7 +118,7 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex } else if (argPattern instanceof POJONode) { // Match against a regular expression final RegularExpression regex = ((RegularExpression) ((POJONode) argPattern).getPojo()); - result = regex.getPattern().matcher(str).find() ? BooleanNode.TRUE : BooleanNode.FALSE; + result = regex.getPattern().test(str) ? BooleanNode.TRUE : BooleanNode.FALSE; } else { throw new EvaluateRuntimeException(ERR_ARG2BADTYPE); } diff --git a/src/main/java/com/api/jsonata4java/expressions/functions/EvalFunction.java b/src/main/java/com/api/jsonata4java/expressions/functions/EvalFunction.java index 77222d31..406d37cf 100644 --- a/src/main/java/com/api/jsonata4java/expressions/functions/EvalFunction.java +++ b/src/main/java/com/api/jsonata4java/expressions/functions/EvalFunction.java @@ -77,7 +77,11 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex context = FunctionUtils.getValuesListExpression(expressionVisitor, ctx, useContext ? 0 : 1); } try { - Expression expr = Expression.jsonata(expression); + // Use the enclosing expression's regex engine so regex literals + // inside the eval'd expression are compiled consistently with + // the rest of the enclosing expression (e.g. RE2 instead of the + // default java.util.regex). + Expression expr = Expression.jsonata(expression, expressionVisitor.getRegexEngine()); result = expr.evaluate(context); } catch (ParseException | IOException e) { throw new EvaluateRuntimeException(ERR_ARG1BADTYPE); diff --git a/src/main/java/com/api/jsonata4java/expressions/functions/MatchFunction.java b/src/main/java/com/api/jsonata4java/expressions/functions/MatchFunction.java index be99298f..c239f4e6 100644 --- a/src/main/java/com/api/jsonata4java/expressions/functions/MatchFunction.java +++ b/src/main/java/com/api/jsonata4java/expressions/functions/MatchFunction.java @@ -22,7 +22,7 @@ package com.api.jsonata4java.expressions.functions; -import java.util.regex.Matcher; +import java.util.Optional; import java.util.regex.Pattern; import com.api.jsonata4java.expressions.EvaluateRuntimeException; import com.api.jsonata4java.expressions.ExpressionsVisitor; @@ -30,6 +30,10 @@ import com.api.jsonata4java.expressions.RegularExpression; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.ExprContext; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.Function_callContext; +import com.api.jsonata4java.expressions.regex.JdkRegexPattern; +import com.api.jsonata4java.expressions.regex.RegexFlags; +import com.api.jsonata4java.expressions.regex.RegexMatch; +import com.api.jsonata4java.expressions.regex.RegexPattern; import com.api.jsonata4java.expressions.utils.Constants; import com.api.jsonata4java.expressions.utils.FunctionUtils; import com.fasterxml.jackson.databind.JsonNode; @@ -104,12 +108,17 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex if (argPattern instanceof POJONode) { regex = (RegularExpression) ((POJONode) argPattern).getPojo(); } - // final String patternText = regex != null ? regex.toString() : Pattern.quote(argPattern.textValue()); - Pattern regexPattern; + final RegexPattern regexPattern; if (regex != null) { regexPattern = regex.getPattern(); } else { - regexPattern = Pattern.compile(Pattern.quote(argPattern.textValue())); + // A plain-string pattern is treated as an exact literal to + // search for, not as a user-authored regex, so this + // intentionally always uses the default engine's quoting + // rather than routing through a pluggable RegexEngine + // (whose escaping dialect may not understand + // Pattern.quote()'s \Q...\E syntax). + regexPattern = new JdkRegexPattern(Pattern.quote(argPattern.textValue()), new RegexFlags(false, false)); } // Check to see if the separator is just a string final String str = argString.textValue(); @@ -127,38 +136,27 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex } } - final Matcher matcher = regexPattern.matcher(str); - // Check to see if a limit was specified result = new SelectorArrayNode(JsonNodeFactory.instance); if (limit == -1) { - // No limits... match all occurrences in the string - while (matcher.find()) { - final ObjectNode obj = JsonNodeFactory.instance.objectNode(); - obj.put("match", str.substring(matcher.start(), matcher.end())); - obj.put("index", Long.valueOf(matcher.start())); - final ArrayNode groups = JsonNodeFactory.instance.arrayNode(); - obj.set("groups", groups); - final int groupCount = matcher.groupCount(); - for (int i = 1; i <= groupCount; i++) { - groups.add(matcher.group(i)); - } - result.add(obj); + // No limit... match all occurrences in the string + for (final RegexMatch m : regexPattern.findAll(str)) { + result.add(toMatchObject(m)); } } else if (limit > 0) { + // Stream matches one at a time so we can stop as soon as the + // limit is reached, instead of scanning the whole string. int count = 0; - while (matcher.find() && count < limit) { - count++; - final ObjectNode obj = JsonNodeFactory.instance.objectNode(); - obj.put("match", str.substring(matcher.start(), matcher.end())); - obj.put("index", Long.valueOf(matcher.start())); - final ArrayNode groups = JsonNodeFactory.instance.arrayNode(); - obj.set("groups", groups); - final int groupCount = matcher.groupCount(); - for (int i = 1; i <= groupCount; i++) { - groups.add(matcher.group(i)); + int pos = 0; + while (count < limit) { + final Optional found = regexPattern.findFirst(str, pos); + if (!found.isPresent()) { + break; } - result.add(obj); + final RegexMatch m = found.get(); + result.add(toMatchObject(m)); + count++; + pos = m.getIndex() + Math.max(m.getLength(), 1); } } else { return null; @@ -192,6 +190,18 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex return result; } + private static ObjectNode toMatchObject(RegexMatch m) { + final ObjectNode obj = JsonNodeFactory.instance.objectNode(); + obj.put("match", m.getMatch()); + obj.put("index", Long.valueOf(m.getIndex())); + final ArrayNode groups = JsonNodeFactory.instance.arrayNode(); + obj.set("groups", groups); + for (final String group : m.getGroups()) { + groups.add(group); + } + return obj; + } + @Override public int getMaxArgs() { return 3; diff --git a/src/main/java/com/api/jsonata4java/expressions/functions/ReplaceFunction.java b/src/main/java/com/api/jsonata4java/expressions/functions/ReplaceFunction.java index 57ac59a8..771f357a 100644 --- a/src/main/java/com/api/jsonata4java/expressions/functions/ReplaceFunction.java +++ b/src/main/java/com/api/jsonata4java/expressions/functions/ReplaceFunction.java @@ -22,11 +22,16 @@ package com.api.jsonata4java.expressions.functions; +import java.util.Optional; import java.util.regex.Pattern; import com.api.jsonata4java.expressions.EvaluateRuntimeException; import com.api.jsonata4java.expressions.ExpressionsVisitor; import com.api.jsonata4java.expressions.RegularExpression; import com.api.jsonata4java.expressions.generated.MappingExpressionParser.Function_callContext; +import com.api.jsonata4java.expressions.regex.JdkRegexPattern; +import com.api.jsonata4java.expressions.regex.RegexFlags; +import com.api.jsonata4java.expressions.regex.RegexMatch; +import com.api.jsonata4java.expressions.regex.RegexPattern; import com.api.jsonata4java.expressions.utils.Constants; import com.api.jsonata4java.expressions.utils.FunctionUtils; import com.fasterxml.jackson.databind.JsonNode; @@ -140,9 +145,15 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex final RegularExpression regex = argPattern instanceof POJONode ? (RegularExpression) ((POJONode) argPattern).getPojo() : null; - final String pattern = regex != null - ? regex.toString() - : argPattern.textValue(); + final RegexPattern pattern = regex != null + ? regex.getPattern() + // A plain-string pattern is treated as an exact literal to + // search for, not as a user-authored regex, so this + // intentionally always uses the default engine's quoting + // rather than routing through a pluggable RegexEngine + // (whose escaping dialect may not understand + // Pattern.quote()'s \Q...\E syntax). + : new JdkRegexPattern(Pattern.quote(argPattern.textValue()), new RegexFlags(false, false)); final String replacement = argReplacement.textValue(); if (argCount == 4) { @@ -159,26 +170,7 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex } } - // Check to see if a limit was specified - if (limit == -1) { - // No limits... replace all occurrences in the string - if (regex != null) { - result = new TextNode(regex.getPattern().matcher(str).replaceAll(jsonata2JavaReplacement(replacement))); - } else { - result = new TextNode(str.replaceAll(Pattern.quote(pattern), jsonata2JavaReplacement(replacement))); - } - } else { - // Only perform the replace the specified number of times - String retString = new String(str); - for (int i = 0; i < limit; i++) { - if (regex != null) { - retString = regex.getPattern().matcher(retString).replaceFirst(jsonata2JavaReplacement(replacement)); - } else { - retString = retString.replaceFirst(Pattern.quote(pattern), jsonata2JavaReplacement(replacement)); - } - } // FOR - result = new TextNode(retString); - } + result = new TextNode(replaceMatches(str, pattern, replacement, limit)); } else { throw new EvaluateRuntimeException(ERR_ARG3BADTYPE); } @@ -204,13 +196,116 @@ public JsonNode invoke(ExpressionsVisitor expressionVisitor, Function_callContex return result; } - public static String jsonata2JavaReplacement(String in) { - // In JSONata and in Java the $ in the replacement test usually starts the insertion of a capturing group - // In order to replace a simple $ in Java you have to escape the $ with "\$" - // in JSONata you do this with a '$$' - // "\$" followed any character besides '<' and a digit into $ + this character - return in.replaceAll("\\$\\$", "\\\\\\$") - .replaceAll("([^\\\\]|^)\\$([^0-9^<])", "$1\\\\\\$$2"); + /** + * Replaces up to {@code limit} (or all, if {@code limit == -1}) non-overlapping + * matches of {@code regex} in {@code str} with {@code replacement}, expanding + * any {@code $N}/{@code $$} group references along the way. Implemented + * directly against {@link RegexPattern}'s match results (rather than delegating to + * {@link java.util.regex.Matcher#replaceAll(String)}'s own replacement-string + * syntax) so that it works the same way regardless of which {@link + * com.api.jsonata4java.expressions.regex.RegexEngine} produced {@code regex}. + */ + private static String replaceMatches(String str, RegexPattern regex, String replacement, int limit) { + StringBuilder sb = new StringBuilder(); + int pos = 0; + int count = 0; + while (limit == -1 || count < limit) { + final Optional found = regex.findFirst(str, pos); + if (!found.isPresent()) { + break; + } + final RegexMatch m = found.get(); + sb.append(str, pos, m.getIndex()); + sb.append(expandReplacement(replacement, m)); + pos = m.getIndex() + m.getLength(); + if (m.getLength() == 0) { + // Avoid getting stuck on a zero-length match: copy the next + // character through unchanged and advance by one, mirroring + // java.util.regex.Matcher's own empty-match handling. + if (pos < str.length()) { + sb.append(str.charAt(pos)); + } + pos++; + } + count++; + } + if (pos < str.length()) { + sb.append(str, pos, str.length()); + } + return sb.toString(); + } + + /** + * Expands {@code $N} (Nth captured group, or the whole match for N=0) and + * {@code $$} (a literal $) references in {@code replacement} against + * {@code match}. Any other {@code $} is copied through literally. A group + * number beyond the number of captured groups expands to the empty string; + * a group that did not participate in the match also expands to the empty + * string. + */ + static String expandReplacement(String replacement, RegexMatch match) { + StringBuilder sb = new StringBuilder(); + int len = replacement.length(); + int i = 0; + while (i < len) { + char c = replacement.charAt(i); + if (c == '$' && i + 1 < len) { + char next = replacement.charAt(i + 1); + if (next == '$') { + sb.append('$'); + i += 2; + continue; + } + if (Character.isDigit(next)) { + // Cap the digit run considered for a group number to avoid + // Integer.parseInt overflowing below (e.g. "$99999999999999"): + // no real regex has anywhere near this many capture groups, so + // anything longer than this is certain to fail the "<= + // groupCount" check regardless of exactly how many digits it has. + int hardLimit = Math.min(len, i + 1 + 9); + int maxEnd = i + 1; + while (maxEnd < hardLimit && Character.isDigit(replacement.charAt(maxEnd))) { + maxEnd++; + } + // Greedily consume digits for the group number, backing off + // one digit at a time if the resulting number isn't a valid + // group, mirroring java.util.regex.Matcher's own $N group + // reference parsing (e.g. with 13 groups, "$123" means group + // 12 followed by literal "3", not a non-existent group 123). + int groupCount = match.getGroups().size(); + int end = maxEnd; + int groupNum = -1; + while (end > i + 1) { + int candidate = Integer.parseInt(replacement.substring(i + 1, end)); + if (candidate <= groupCount) { + groupNum = candidate; + break; + } + end--; + } + if (groupNum == -1) { + // Not even a single digit is a valid group number; + // copy the $ through literally rather than throwing. + sb.append(c); + i++; + continue; + } + if (groupNum == 0) { + sb.append(match.getMatch()); + } else { + String group = match.getGroups().get(groupNum - 1); + if (group != null) { + sb.append(group); + } + } + i = end; + continue; + } + } + sb.append(c); + i++; + } + return sb.toString(); } @Override diff --git a/src/main/java/com/api/jsonata4java/expressions/regex/JdkRegexPattern.java b/src/main/java/com/api/jsonata4java/expressions/regex/JdkRegexPattern.java new file mode 100644 index 00000000..d94a502b --- /dev/null +++ b/src/main/java/com/api/jsonata4java/expressions/regex/JdkRegexPattern.java @@ -0,0 +1,91 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The default {@link RegexPattern} implementation, backed by + * {@link java.util.regex.Pattern}. Reproduces the exact behavior JSONata4Java + * used prior to the introduction of the pluggable {@link RegexEngine} hook. + */ +public final class JdkRegexPattern implements RegexPattern { + + private final Pattern pattern; + + public JdkRegexPattern(String regexPattern, RegexFlags flags) { + int javaFlags = 0; + if (flags.isCaseInsensitive()) { + javaFlags |= Pattern.CASE_INSENSITIVE; + } + if (flags.isMultiline()) { + javaFlags |= Pattern.MULTILINE; + } + this.pattern = Pattern.compile(regexPattern, javaFlags); + } + + @Override + public boolean test(String input) { + return pattern.matcher(input).find(); + } + + @Override + public Optional findFirst(String input, int fromIndex) { + if (fromIndex < 0 || fromIndex > input.length()) { + return Optional.empty(); + } + Matcher matcher = pattern.matcher(input); + if (!matcher.find(fromIndex)) { + return Optional.empty(); + } + return Optional.of(toRegexMatch(matcher)); + } + + @Override + public List findAll(String input) { + List matches = new ArrayList<>(); + Matcher matcher = pattern.matcher(input); + while (matcher.find()) { + matches.add(toRegexMatch(matcher)); + } + return matches; + } + + @Override + public String[] split(String input) { + return pattern.split(input); + } + + private static RegexMatch toRegexMatch(Matcher matcher) { + List groups = new ArrayList<>(); + int groupCount = matcher.groupCount(); + for (int i = 1; i <= groupCount; i++) { + groups.add(matcher.group(i)); + } + return new RegexMatch(matcher.group(), matcher.start(), groups); + } +} diff --git a/src/main/java/com/api/jsonata4java/expressions/regex/RegexEngine.java b/src/main/java/com/api/jsonata4java/expressions/regex/RegexEngine.java new file mode 100644 index 00000000..f9da1271 --- /dev/null +++ b/src/main/java/com/api/jsonata4java/expressions/regex/RegexEngine.java @@ -0,0 +1,48 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +/** + * A pluggable regex compiler. Implement this to swap out the JVM's default + * backtracking {@link java.util.regex.Pattern} for a linear-time engine + * (e.g. RE2/J) and protect against ReDoS + * (https://en.wikipedia.org/wiki/ReDoS), mirroring the {@code RegexEngine} + * option supported by the JSONata JS reference implementation. + */ +@FunctionalInterface +public interface RegexEngine { + + /** + * Compiles {@code pattern} (the text between the {@code /../} delimiters + * of a JSONata regex literal, or a plain string pattern) according to + * {@code flags}. + */ + RegexPattern compile(String pattern, RegexFlags flags); + + /** + * @return the default engine, backed by {@link java.util.regex.Pattern}. + */ + static RegexEngine defaultEngine() { + return JdkRegexPattern::new; + } +} diff --git a/src/main/java/com/api/jsonata4java/expressions/regex/RegexFlags.java b/src/main/java/com/api/jsonata4java/expressions/regex/RegexFlags.java new file mode 100644 index 00000000..7cc3464f --- /dev/null +++ b/src/main/java/com/api/jsonata4java/expressions/regex/RegexFlags.java @@ -0,0 +1,50 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +/** + * Engine-neutral representation of the flags carried by a JSONata regex + * literal (e.g. {@code /pattern/i}). Kept separate from any one regex + * engine's native flag bits (such as {@link java.util.regex.Pattern#CASE_INSENSITIVE}) + * so that a {@link RegexEngine} implementation is free to map them onto + * whatever flag convention its underlying engine uses. + */ +public final class RegexFlags { + + private final boolean caseInsensitive; + + private final boolean multiline; + + public RegexFlags(boolean caseInsensitive, boolean multiline) { + this.caseInsensitive = caseInsensitive; + this.multiline = multiline; + } + + public boolean isCaseInsensitive() { + return caseInsensitive; + } + + public boolean isMultiline() { + return multiline; + } +} diff --git a/src/main/java/com/api/jsonata4java/expressions/regex/RegexMatch.java b/src/main/java/com/api/jsonata4java/expressions/regex/RegexMatch.java new file mode 100644 index 00000000..45389499 --- /dev/null +++ b/src/main/java/com/api/jsonata4java/expressions/regex/RegexMatch.java @@ -0,0 +1,67 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +import java.util.Collections; +import java.util.List; + +/** + * Engine-neutral representation of a single regex match, as needed by + * $match/$replace/$split/$contains. {@code groups} holds the captured + * groups in order (group 1 first); a group that did not participate in the + * match is represented as {@code null}, mirroring + * {@link java.util.regex.Matcher#group(int)}. + */ +public final class RegexMatch { + + private final String match; + + private final int index; + + private final List groups; + + public RegexMatch(String match, int index, List groups) { + this.match = match; + this.index = index; + this.groups = Collections.unmodifiableList(groups); + } + + public String getMatch() { + return match; + } + + public int getIndex() { + return index; + } + + public List getGroups() { + return groups; + } + + /** + * @return the length, in characters, consumed by the overall match. + */ + public int getLength() { + return match.length(); + } +} diff --git a/src/main/java/com/api/jsonata4java/expressions/regex/RegexPattern.java b/src/main/java/com/api/jsonata4java/expressions/regex/RegexPattern.java new file mode 100644 index 00000000..2c25905e --- /dev/null +++ b/src/main/java/com/api/jsonata4java/expressions/regex/RegexPattern.java @@ -0,0 +1,89 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * A compiled regular expression, abstracted away from any particular regex + * engine. Implementations back the four regex-consuming JSONata functions: + * $contains ({@link #test}), $match ({@link #findFirst}/{@link #findAll}), + * $replace ({@link #findFirst}/{@link #findAll}) and $split ({@link #split}). + * + * @see RegexEngine + * @see JdkRegexPattern + */ +public interface RegexPattern { + + /** + * @return true if this pattern matches anywhere within {@code input}. + */ + boolean test(String input); + + /** + * Finds the first match at or after {@code fromIndex}. + * + * @param input the string to search. + * @param fromIndex the character offset to start searching from. Callers + * may legitimately pass a {@code fromIndex} beyond the + * end of {@code input} (e.g. while streaming through + * matches after one that ends exactly at the end of the + * string); implementations must return + * {@link Optional#empty()} in that case rather than + * throwing. + * @return the match, or {@link Optional#empty()} if none is found. + */ + Optional findFirst(String input, int fromIndex); + + /** + * Finds every non-overlapping match in {@code input}, in order. + * + *

+ * The default implementation streams through {@link #findFirst} so that + * implementations only need to provide that one primitive; override this + * method if the underlying engine offers a more efficient bulk-match API. + */ + default List findAll(String input) { + List matches = new ArrayList<>(); + int pos = 0; + while (pos <= input.length()) { + Optional match = findFirst(input, pos); + if (!match.isPresent()) { + break; + } + RegexMatch m = match.get(); + matches.add(m); + pos = m.getIndex() + Math.max(m.getLength(), 1); + } + return matches; + } + + /** + * Splits {@code input} around every match of this pattern, following + * {@link java.util.regex.Pattern#split(CharSequence)} semantics (no + * trailing empty strings). + */ + String[] split(String input); +} diff --git a/src/test/java/com/api/jsonata4java/expressions/FunctionErrorTest.java b/src/test/java/com/api/jsonata4java/expressions/FunctionErrorTest.java index 6119bb53..188733d7 100644 --- a/src/test/java/com/api/jsonata4java/expressions/FunctionErrorTest.java +++ b/src/test/java/com/api/jsonata4java/expressions/FunctionErrorTest.java @@ -25,7 +25,6 @@ import static com.api.jsonata4java.expressions.utils.Utils.test; import static org.junit.Assert.assertEquals; import org.junit.Test; -import com.api.jsonata4java.expressions.functions.ReplaceFunction; /** * A JUnit test class used in order to ease debugging of particular problematic cases. @@ -179,10 +178,6 @@ public void testRegex_case012() throws Exception { .replaceAll("\\$\\$", "\\\\\\$") .replaceAll("([^\\\\]|^)\\$([^0-9^<])", "$1\\\\\\$$2")); - assertEquals("abc\\$$1xyz", ReplaceFunction.jsonata2JavaReplacement("abc$$$1xyz")); - assertEquals("abc\\$wxyz", ReplaceFunction.jsonata2JavaReplacement("abc$wxyz")); - assertEquals("abc\\$$1efg\\$wxyz", ReplaceFunction.jsonata2JavaReplacement("abc$$$1efg$wxyz")); - assertEquals("$265", "265USD".replaceAll("([0-9]+)USD", "\\$$1")); test("$replace(\"265USD\", /([0-9]+)USD/, \"$$$1\")", "\"$265\"", null, null); } diff --git a/src/test/java/com/api/jsonata4java/expressions/RegularExpressionTest.java b/src/test/java/com/api/jsonata4java/expressions/RegularExpressionTest.java index 074c02c4..3a3b54b3 100644 --- a/src/test/java/com/api/jsonata4java/expressions/RegularExpressionTest.java +++ b/src/test/java/com/api/jsonata4java/expressions/RegularExpressionTest.java @@ -42,15 +42,15 @@ public void testToString() { @Test public void find() { - assertTrue(new RegularExpression("/^.*$/").getPattern().matcher("asdfgh").find()); - assertTrue(new RegularExpression("/^ab.*ef$/").getPattern().matcher("abcdef").find()); - assertTrue(new RegularExpression("/c.*f/").getPattern().matcher("abcdefgh").find()); + assertTrue(new RegularExpression("/^.*$/").getPattern().test("asdfgh")); + assertTrue(new RegularExpression("/^ab.*ef$/").getPattern().test("abcdef")); + assertTrue(new RegularExpression("/c.*f/").getPattern().test("abcdefgh")); } @Test public void findCaseInsensitive() { - assertFalse(new RegularExpression("/^ab.*ef$/").getPattern().matcher("ABCdef").find()); + assertFalse(new RegularExpression("/^ab.*ef$/").getPattern().test("ABCdef")); assertTrue(new RegularExpression(RegularExpression.Type.CASEINSENSITIVE, "/^ab.*ef$/") - .getPattern().matcher("ABCdef").find()); + .getPattern().test("ABCdef")); } } diff --git a/src/test/java/com/api/jsonata4java/expressions/regex/RE2RegexPattern.java b/src/test/java/com/api/jsonata4java/expressions/regex/RE2RegexPattern.java new file mode 100644 index 00000000..e45438e9 --- /dev/null +++ b/src/test/java/com/api/jsonata4java/expressions/regex/RE2RegexPattern.java @@ -0,0 +1,106 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; + +/** + * Demonstration {@link RegexPattern} implementation backed by Google's RE2/J + * (https://github.com/google/re2j), a pure-Java, linear-time regex engine + * immune to ReDoS (https://en.wikipedia.org/wiki/ReDoS). Shows that + * JSONata4Java's regex engine hook (see {@link RegexPattern}/{@link RegexEngine}) + * is not tied to {@link java.util.regex.Pattern}. + * + *

+ * Lives under src/test since it's only used to demonstrate/exercise the + * hook (see {@link RE2RegexPatternTest}) and isn't part of the library's + * runtime dependencies. + */ +public final class RE2RegexPattern implements RegexPattern { + + private final Pattern pattern; + + public RE2RegexPattern(String regexPattern, RegexFlags flags) { + int re2Flags = 0; + if (flags.isCaseInsensitive()) { + re2Flags |= Pattern.CASE_INSENSITIVE; + } + if (flags.isMultiline()) { + re2Flags |= Pattern.MULTILINE; + } + this.pattern = Pattern.compile(regexPattern, re2Flags); + } + + /** + * Factory for use with {@code Expressions.parse}/{@code Expression.jsonata}, + * e.g. {@code Expression.jsonata(expr, RE2RegexPattern::new)}. + */ + public static RegexEngine engine() { + return RE2RegexPattern::new; + } + + @Override + public boolean test(String input) { + return pattern.matcher(input).find(); + } + + @Override + public Optional findFirst(String input, int fromIndex) { + if (fromIndex < 0 || fromIndex > input.length()) { + return Optional.empty(); + } + Matcher matcher = pattern.matcher(input); + if (!matcher.find(fromIndex)) { + return Optional.empty(); + } + return Optional.of(toRegexMatch(matcher)); + } + + @Override + public List findAll(String input) { + List matches = new ArrayList<>(); + Matcher matcher = pattern.matcher(input); + while (matcher.find()) { + matches.add(toRegexMatch(matcher)); + } + return matches; + } + + @Override + public String[] split(String input) { + return pattern.split(input); + } + + private static RegexMatch toRegexMatch(Matcher matcher) { + List groups = new ArrayList<>(); + int groupCount = matcher.groupCount(); + for (int i = 1; i <= groupCount; i++) { + groups.add(matcher.group(i)); + } + return new RegexMatch(matcher.group(), matcher.start(), groups); + } +} diff --git a/src/test/java/com/api/jsonata4java/expressions/regex/RE2RegexPatternTest.java b/src/test/java/com/api/jsonata4java/expressions/regex/RE2RegexPatternTest.java new file mode 100644 index 00000000..de1768fc --- /dev/null +++ b/src/test/java/com/api/jsonata4java/expressions/regex/RE2RegexPatternTest.java @@ -0,0 +1,105 @@ +/** + * (c) Copyright 2018, 2019 IBM Corporation + * 1 New Orchard Road, + * Armonk, New York, 10504-1722 + * United States + * +1 914 499 1900 + * support: Nathaniel Mills wnm3@us.ibm.com + * + * 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.api.jsonata4java.expressions.regex; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import java.io.IOException; +import com.api.jsonata4java.Expression; +import com.api.jsonata4java.expressions.EvaluateException; +import com.api.jsonata4java.expressions.ParseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +/** + * End-to-end tests demonstrating that {@link Expression#jsonata(String, RegexEngine)} + * works correctly when plugging in a regex engine other than the default + * {@link java.util.regex.Pattern}-backed one -- here, {@link RE2RegexPattern}. + */ +public class RE2RegexPatternTest { + + private static JsonNode evaluate(String expression) throws ParseException, IOException, EvaluateException { + return Expression.jsonata(expression, RE2RegexPattern.engine()).evaluate(null); + } + + @Test + public void testMatch() throws Exception { + // Note: regex literals containing whitespace hit a pre-existing, + // unrelated JSONata4Java grammar limitation (its lexer's global + // whitespace-skip rule strips spaces out of regex literals too), so + // this intentionally avoids whitespace in the pattern. + JsonNode result = evaluate("$match(\"hello-world\", /o-w/)"); + assertEquals("o-w", result.get("match").asText()); + assertEquals(4, result.get("index").asInt()); + } + + @Test + public void testMatchCaseInsensitive() throws Exception { + JsonNode result = evaluate("$match(\"HELLO\", /hello/i)"); + assertEquals("HELLO", result.get("match").asText()); + } + + @Test + public void testContains() throws Exception { + assertTrue(evaluate("$contains(\"hello\", /ell/)").asBoolean()); + assertFalse(evaluate("$contains(\"hello\", /xyz/)").asBoolean()); + } + + @Test + public void testReplaceWithGroupReference() throws Exception { + JsonNode result = evaluate("$replace(\"John Smith\", /(\\w+)\\s(\\w+)/, \"$2, $1\")"); + assertEquals("Smith, John", result.asText()); + } + + @Test + public void testSplit() throws Exception { + JsonNode result = evaluate("$split(\"a1b2c3\", /[0-9]/)"); + ObjectMapper mapper = new ObjectMapper(); + assertEquals(mapper.readTree("[\"a\",\"b\",\"c\"]"), result); + } + + @Test + public void testRejectsBackreferences() { + // RE2 (and therefore RE2/J) deliberately does not support + // backreferences, since they make linear-time matching impossible -- + // this is exactly the ReDoS-prone construct a custom RegexEngine is + // meant to let callers reject at the source. + try { + evaluate("$contains(\"aa\", /(a)\\1/)"); + org.junit.Assert.fail("Expected an exception compiling a backreference with RE2/J"); + } catch (Exception expected) { + // expected: RE2/J's Pattern.compile rejects the unsupported syntax + } + } + + @Test + public void testEvalUsesEnclosingRegexEngine() throws Exception { + // $eval()'d expressions must inherit the enclosing expression's + // regex engine rather than silently falling back to the default + // java.util.regex-backed one. + JsonNode result = evaluate("$eval(\"$match('hello-world', /o-w/).match\")"); + assertEquals("o-w", result.asText()); + } +}