This is an automated email from the ASF dual-hosted git repository. heneveld pushed a commit to branch expression-parsing in repository https://gitbox.apache.org/repos/asf/brooklyn-server.git
commit 95271552c3829afba3f058a56ecd4258fbc6a802 Author: Alex Heneveld <a...@cloudsoft.io> AuthorDate: Tue Feb 6 07:56:02 2024 +0000 add an expresson parser which understands interpolated strings etc --- .../core/workflow/utils/ExpressionParser.java | 230 +++++++++++ .../core/workflow/utils/ExpressionParserImpl.java | 444 +++++++++++++++++++++ .../core/workflow/ExpressionParserTest.java | 97 +++++ .../java/org/apache/brooklyn/test/Asserts.java | 7 + .../java/org/apache/brooklyn/util/guava/Maybe.java | 6 + 5 files changed, 784 insertions(+) diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParser.java b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParser.java new file mode 100644 index 0000000000..e3c5f53ac0 --- /dev/null +++ b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParser.java @@ -0,0 +1,230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.brooklyn.core.workflow.utils; + +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.BackslashParseMode; +import org.apache.brooklyn.util.collections.MutableList; +import org.apache.brooklyn.util.collections.MutableMap; +import org.apache.brooklyn.util.guava.Maybe; +import org.apache.brooklyn.util.text.Strings; + +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.CharactersCollectingParseMode; +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.CommonParseMode; +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseMode; +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNode; +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue; +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseValue; +import static org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.TopLevelParseMode; + +/** simplistic parser for workflow expressions and strings, recognizing single and double quotes, backslash escapes, and interpolated strings with ${...} */ +public abstract class ExpressionParser { + + public abstract Maybe<ParseNode> parse(String input); + public abstract Maybe<List<ParseNodeOrValue>> parseEverything(String inputRemaining); + + + public static final ParseMode BACKSLASH_ESCAPE = new BackslashParseMode(); + public static final ParseMode WHITESPACE = new CharactersCollectingParseMode("whitespace", Character::isWhitespace); + + public static final ParseMode DOUBLE_QUOTE = CommonParseMode.transitionNested("double_quote", "\"", "\""); + public static final ParseMode SINGLE_QUOTE = CommonParseMode.transitionNested("single_quote", "\'", "\'"); + public static final ParseMode INTERPOLATED = CommonParseMode.transitionNested("interpolated_expression", "${", "}"); + + public static final ParseMode SQUARE_BRACKET = CommonParseMode.transitionNested("square_bracket", "[", "]"); + public static final ParseMode PARENTHESES = CommonParseMode.transitionNested("parenthesis", "(", ")"); + public static final ParseMode CURLY_BRACES = CommonParseMode.transitionNested("curly_brace", "{", "}"); + + + private static Multimap<ParseMode,ParseMode> getCommonInnerTransitions() { + ListMultimap<ParseMode,ParseMode> m = Multimaps.newListMultimap(MutableMap.of(), MutableList::of); + m.putAll(BACKSLASH_ESCAPE, MutableList.of()); + m.putAll(SINGLE_QUOTE, MutableList.of(BACKSLASH_ESCAPE, INTERPOLATED)); + m.putAll(DOUBLE_QUOTE, MutableList.of(BACKSLASH_ESCAPE, INTERPOLATED)); + m.putAll(INTERPOLATED, MutableList.of(BACKSLASH_ESCAPE, DOUBLE_QUOTE, SINGLE_QUOTE, INTERPOLATED)); + return Multimaps.unmodifiableMultimap(m); + } + public static final Multimap<ParseMode,ParseMode> COMMON_INNER_TRANSITIONS = getCommonInnerTransitions(); + public static final List<ParseMode> COMMON_TOP_LEVEL_TRANSITIONS = MutableList.of(BACKSLASH_ESCAPE, DOUBLE_QUOTE, SINGLE_QUOTE, INTERPOLATED).asUnmodifiable(); + + + public static ExpressionParserImpl newDefaultAllowingUnquotedAndSplittingOnWhitespace() { + return new ExpressionParserImpl(new TopLevelParseMode(true), + MutableList.copyOf(COMMON_TOP_LEVEL_TRANSITIONS).append(WHITESPACE), + COMMON_INNER_TRANSITIONS); + } + public static ExpressionParserImpl newDefaultAllowingUnquotedLiteralValues() { + return new ExpressionParserImpl(new TopLevelParseMode(true), + MutableList.copyOf(COMMON_TOP_LEVEL_TRANSITIONS), + COMMON_INNER_TRANSITIONS); + } + public static ExpressionParserImpl newDefaultRequiringQuotingOrExpressions() { + return new ExpressionParserImpl(new TopLevelParseMode(false), + MutableList.copyOf(COMMON_TOP_LEVEL_TRANSITIONS), + COMMON_INNER_TRANSITIONS); + } + + public static ExpressionParserImpl newEmptyDefault(boolean requiresQuoting) { + return newEmpty(new TopLevelParseMode(!requiresQuoting)); + } + public static ExpressionParserImpl newEmpty(TopLevelParseMode topLevel) { + return new ExpressionParserImpl(topLevel, MutableList.of()); + } + + + public static boolean isQuotedExpressionNode(ParseNodeOrValue next) { + if (next==null) return false; + return next.isParseNodeMode(SINGLE_QUOTE) || next.isParseNodeMode(DOUBLE_QUOTE); + } + + public static String getAllUnquoted(List<ParseNodeOrValue> mp) { + return join(mp, ExpressionParser::getUnquoted); + } + + public static String getAllSource(List<ParseNodeOrValue> mp) { + return join(mp, ParseNodeOrValue::getSource); + } + + public static String getUnescapedButNotUnquoted(List<ParseNodeOrValue> mp) { + return join(mp, ExpressionParser::getUnescapedButNotUnquoted); + } + + public static String getUnescapedButNotUnquoted(ParseNodeOrValue mp) { + if (mp instanceof ParseValue) return ((ParseValue)mp).getContents(); + + // backslashes are unescaped + if (mp.isParseNodeMode(BACKSLASH_ESCAPE)) return getAllSource(((ParseNode)mp).getContents()); + + // in interpolations we don't unescape + if (mp.isParseNodeMode(INTERPOLATED)) return mp.getSource(); + // in double quotes we don't unescape + if (isQuotedExpressionNode(mp)) return mp.getSource(); + + // everything else is recursed + return getContentsWithStartAndEnd((ParseNode) mp, ExpressionParser::getUnescapedButNotUnquoted); + } + + public static String getContentsWithStartAndEnd(ParseNode mp, Function<List<ParseNodeOrValue>,String> fn) { + String v = fn.apply(mp.getContents()); + ParseMode m = mp.getParseNodeModeClass(); + if (m instanceof CommonParseMode) { + return Strings.toStringWithValueForNull(((CommonParseMode)m).enterOnString, "") + + v + + Strings.toStringWithValueForNull(((CommonParseMode)m).exitOnString, ""); + } + return v; + } + + public static boolean startsWithWhitespace(ParseNodeOrValue pn) { + if (pn.isParseNodeMode(WHITESPACE)) return true; + if (pn instanceof ParseValue) return ((ParseValue)pn).getContents().startsWith(" "); + return ((ParseNode)pn).getStartingContent().map(ExpressionParser::startsWithWhitespace).or(false); + } + + + public static String join(List<ParseNodeOrValue> mp, Function<ParseNodeOrValue,String> fn) { + return mp.stream().map(fn).collect(Collectors.joining()); + } + + public static String getUnquoted(ParseNodeOrValue mp) { + if (mp instanceof ParseValue) return ((ParseValue)mp).getContents(); + ParseNode mpn = (ParseNode) mp; + + // unquote, unescaping what's inside + if (isQuotedExpressionNode(mp)) return getUnescapedButNotUnquoted(((ParseNode) mp).getContents()); + + // source for anything else + return mp.getSource(); + } + + /** removes whitespace, either WHITESPACE nodes, or values containing whitespace */ + public static boolean isBlank(ParseNodeOrValue n) { + if (n.isParseNodeMode(WHITESPACE)) return true; + if (n instanceof ParseValue) return Strings.isBlank(((ParseValue) n).getContents()); + return false; + } + + /** removes whitespace, either WHITESPACE or empty/blank ParseValue nodes, + * and changing ParseValues starting or ending with whitespace and the start or end (once other whitespace removed) */ + public static List<ParseNodeOrValue> trimWhitespace(List<ParseNodeOrValue> contents) { + return trimWhitespace(contents, true, true); + } + public static List<ParseNodeOrValue> trimWhitespace(List<ParseNodeOrValue> contents, boolean removeFromStart, boolean removeFromEnd) { + List<ParseNodeOrValue> result = MutableList.of(); + boolean changed = false; + Iterator<ParseNodeOrValue> ni = contents.iterator(); + while (ni.hasNext()) { + ParseNodeOrValue n = ni.next(); + if (isBlank(n)) { + changed = true; + continue; + + } else { + if (n instanceof ParseValue) { + String c = ((ParseValue) n).getContents(); + if (Character.isWhitespace(c.charAt(0))) { + changed = true; + while (!c.isEmpty() && Character.isWhitespace(c.charAt(0))) c = c.substring(1); + n = new ParseValue(c); + } + } + result.add(n); + break; + } + } + if (ni.hasNext()) { + // did the start - now need to identify the last non-whitespace thing, and then will need to replay it + ParseNodeOrValue lastNonWhite = null; + for (ParseNodeOrValue pn: contents) { + if (!isBlank(pn)) lastNonWhite = pn; + } + if (lastNonWhite==null) throw new IllegalStateException("Non-whitespace was found but then not found"); // shouldn't happen + + ParseNodeOrValue n; + do { + n = ni.next(); + if (n == lastNonWhite) break; + result.add(n); + } while (ni.hasNext()); + + if (n instanceof ParseValue) { + String c = ((ParseValue) n).getContents(); + if (Character.isWhitespace(c.charAt(c.length()-1))) { + changed = true; + while (!c.isEmpty() && Character.isWhitespace(c.charAt(c.length()-1))) c = c.substring(0, c.length()-1); + n = new ParseValue(c); + } + } + result.add(n); + + // and ignore the rest + } + + if (!changed) return contents; + return result; + } + +} diff --git a/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParserImpl.java b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParserImpl.java new file mode 100644 index 0000000000..5a66648b28 --- /dev/null +++ b/core/src/main/java/org/apache/brooklyn/core/workflow/utils/ExpressionParserImpl.java @@ -0,0 +1,444 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.brooklyn.core.workflow.utils; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import javax.annotation.Nullable; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import org.apache.brooklyn.util.collections.MutableList; +import org.apache.brooklyn.util.collections.MutableMap; +import org.apache.brooklyn.util.guava.Maybe; + +/** simplistic parser for workflow expressions and strings, recognizing single and double quotes, backslash escapes, and interpolated strings with ${...} */ +public class ExpressionParserImpl extends ExpressionParser { + + public interface ParseNodeOrValue { + String getParseNodeMode(); + default boolean isParseNodeMode(String pm, String ...pmi) { + if (Objects.equals(getParseNodeMode(), pm)) return true; + for (String pmx: pmi) { + if (Objects.equals(getParseNodeMode(), pmx)) return true; + } + return false; + } + default boolean isParseNodeMode(ParseMode pm, ParseMode ...pmi) { + if (Objects.equals(getParseNodeMode(), pm.getModeName())) return true; + for (ParseMode pmx: pmi) { + if (Objects.equals(getParseNodeMode(), pmx.getModeName())) return true; + } + return false; + } + default boolean isParseNodeMode(Collection<ParseMode> pm) { + return pm.stream().anyMatch(pmx -> Objects.equals(getParseNodeMode(), pmx.getModeName())); + } + String getSource(); + Object getContents(); + } + public static class ParseValue implements ParseNodeOrValue { + public final static String MODE = "value"; + final String value; + + public ParseValue(String value) { this.value = value; } + + @Override + public String getSource() { + return value; + } + + @Override public String getContents() { return value; } + + @Override + public String toString() { + return "["+value+"]"; + } + + @Override + public String getParseNodeMode() { + return MODE; + } + } + public static class ParseNode implements ParseNodeOrValue { + String source; + ParseMode mode; + public ParseNode(ParseMode mode, String source) { + this.mode = mode; + this.source = source; + } + public static ParseNode ofValue(ParseMode mode, String source, String value) { + ParseNode pr = new ParseNode(mode, source); + pr.contents = MutableList.of(new ParseValue(value)); + return pr; + } + List<ParseNodeOrValue> contents = null; + + public String getSource() { + return source; + } + @Override public String getParseNodeMode() { + return mode.name; + } + public ParseMode getParseNodeModeClass() { + return mode; + } + + public List<ParseNodeOrValue> getContents() { + return contents; + } + public Maybe<ParseNodeOrValue> getOnlyContent() { + if (contents==null) return Maybe.absent("no contents set"); + if (contents.size()==1) return Maybe.of(contents.iterator().next()); + return Maybe.absent(contents.isEmpty() ? "no items" : contents.size()+" items"); + } + public Maybe<ParseNodeOrValue> getFinalContent() { + if (contents==null) return Maybe.absent("no contents set"); + if (contents.isEmpty()) return Maybe.absent("contents empty"); + return Maybe.of(contents.get(contents.size() - 1)); + } + public Maybe<ParseNodeOrValue> getStartingContent() { + if (contents==null) return Maybe.absent("no contents set"); + if (contents.isEmpty()) return Maybe.absent("contents empty"); + return Maybe.of(contents.get(0)); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(mode.name); + if (contents==null) sb.append("?"); + + if (contents!=null && contents.size()==1 && getOnlyContent().get() instanceof ParseValue) sb.append(contents.iterator().next().toString()); + else { + sb.append("["); + if (contents == null) sb.append(source); + else sb.append(contents.stream().map(c -> c.toString()).collect(Collectors.joining())); + sb.append("]"); + } + return sb.toString(); + } + } + + public static class InternalParseResult { + public final boolean skip; + public boolean earlyTerminationRequested; + @Nullable /** null means no error and no result */ + public final Maybe<ParseNode> resultOrError; + protected InternalParseResult(boolean skip, Maybe<ParseNode> resultOrError) { + this.skip = skip; + this.resultOrError = resultOrError; + } + public static final InternalParseResult SKIP = new InternalParseResult(true, null); + + public static InternalParseResult of(ParseNode parseModeResult) { + return new InternalParseResult(false, Maybe.of(parseModeResult)); + } + public static InternalParseResult ofValue(ParseMode mode, String source, String value) { + return of(ParseNode.ofValue(mode, source, value)); + } + public static InternalParseResult ofError(String message) { + return new InternalParseResult(false, Maybe.absent(message)); + } + public static InternalParseResult ofError(Throwable t) { + return new InternalParseResult(false, Maybe.absent(t)); + } + } + + public abstract static class ParseMode { + final String name; + + public ParseMode(String name) { + this.name = name; + } + + public String getModeName() { + return name; + } + + @Override + public String toString() { + return name; + } + + public abstract InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, @Nullable Collection<ParseMode> transitionsAllowedHereOverride); + } + + public static class CommonParseMode extends ParseMode { + public final String enterOnString; + public final String exitOnString; + + /** parses anything starting with 'enterOnString'; if exitOnString is not null, it does so until 'exitOnString' is encountered, then reverts to previous mode */ + protected CommonParseMode(String name, String enterOnString, @Nullable String exitOnString) { + super(name); + Preconditions.checkNotNull(enterOnString); + this.enterOnString = enterOnString; + this.exitOnString = exitOnString; + } + + /** parses anything starting with 'enterOnString', until another mode is entered */ + public static CommonParseMode transitionSimple(String name, String enterOnString) { + Preconditions.checkNotNull(enterOnString); + Preconditions.checkArgument(!enterOnString.isEmpty()); + return new CommonParseMode(name, enterOnString, null); + } + + /** parses anything starting with 'enterOnString', until 'exitOnString' is reached when it reverts to previous mode */ + public static CommonParseMode transitionNested(String name, String enterOnString, String exitOnString) { + Preconditions.checkNotNull(exitOnString); + Preconditions.checkArgument(!exitOnString.isEmpty()); + return new CommonParseMode(name, enterOnString, exitOnString); + } + + public boolean allowedToTakeUnmatchedCharsAsLiteralValue() { return true; /** currently all non-top-level custom parse modes do this; but could be overridden */ } + public boolean allowedToExitBeforeExitStringEncountered() { return false; /** currently all non-top-level custom parse modes do not this; but could be overridden */ } + + public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, Collection<ParseMode> transitionsAllowedHereOverride) { + if (!input.substring(offset).startsWith(enterOnString)) return InternalParseResult.SKIP; + + ParseMode currentLevelMode = this; + ParseNode result = new ParseNode(currentLevelMode, input); + result.contents = MutableList.of(); + int i=offset; + if (!input.substring(i).startsWith(enterOnString)) + return InternalParseResult.ofError("wrong start sequence for " + currentLevelMode + " at position " + i); + i+= enterOnString.length(); + + int last = i; + boolean exitStringEncountered = false; + boolean earlyTerminationRequested = false; + if (transitionsAllowedHereOverride==null) transitionsAllowedHereOverride = allowedTransitions.get(currentLevelMode); + if (transitionsAllowedHereOverride==null || transitionsAllowedHereOverride.isEmpty()) throw new IllegalStateException("Mode '"+currentLevelMode+"' is not configured with any transitions"); + input: while (i<input.length()) { + if (exitOnString !=null && input.substring(i).startsWith(exitOnString)) { + if (i>last) result.contents.add(new ParseValue(input.substring(last, i))); + i+= exitOnString.length(); + last = i; + exitStringEncountered = true; + break; + } + Maybe<ParseNode> error = null; + for (ParseMode candidate: transitionsAllowedHereOverride) { + InternalParseResult cpr = candidate.parse(input, i, allowedTransitions, null); + if (cpr.skip) continue; + if (cpr.resultOrError.isPresent()) { + if (i>last) result.contents.add(new ParseValue(input.substring(last, i))); + result.contents.add(cpr.resultOrError.get()); + i += cpr.resultOrError.get().source.length(); + last = i; + if (cpr.earlyTerminationRequested) { + earlyTerminationRequested = true; + break input; + } + continue input; + } + if (error == null) { + error = cpr.resultOrError; + } else { + // multiple possible modes, not used currently; + // only take error from first mode + } + } + if (!allowedToTakeUnmatchedCharsAsLiteralValue() && error==null) { + if (allowedToExitBeforeExitStringEncountered()) break; + error = Maybe.absent("Characters starting at " + i + " not permitted for " + currentLevelMode); + } + if (error!=null) return InternalParseResult.ofError(Maybe.Absent.getException(error)); + i++; + } + + if (!exitStringEncountered && !allowedToExitBeforeExitStringEncountered()) return InternalParseResult.ofError("Non-terminated "+currentLevelMode.name); + if (i>last) result.contents.add(new ParseValue(input.substring(last, i))); + last = i; + result.source = input.substring(offset, last); + InternalParseResult resultIPR = InternalParseResult.of(result); + Maybe<ParseNodeOrValue> lastPMR = result.getFinalContent(); + if (earlyTerminationRequested) { + resultIPR.earlyTerminationRequested = true; + } + return resultIPR; + } + } + + public static class BackslashParseMode extends ParseMode { + public static final Map<Character,String> COMMON_ESAPES = MutableMap.of( + 'n', "\n", + 'r', "\r", + 't', "\t", + '0', "\0" + // these are supported because by default we support the same character +// '\'', "\'", +// '\"', "\"" + ).asUnmodifiable(); + + public BackslashParseMode() { super(MODE); } + final static String MODE = "backslash_escape"; + final static String START = "\\"; + @Override + public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> _allowedTransitions, Collection<ParseMode> _transitionsAllowedHereOverride) { + if (!input.substring(offset).startsWith(START)) return InternalParseResult.SKIP; + if (input.substring(offset).length()>=2) { + char c = input.charAt(offset+1); + return InternalParseResult.ofValue(this, input.substring(offset, offset+2), Maybe.ofDisallowingNull(COMMON_ESAPES.get(c)).or(() -> ""+c)); + } else { + return InternalParseResult.ofError("Backslash escape character not permitted at end of string"); + } + } + } + + public static class CharactersCollectingParseMode extends ParseMode { + final Predicate<Character> charactersAcceptable; + public CharactersCollectingParseMode(String name, Predicate<Character> charactersAcceptable) { + super(name); + this.charactersAcceptable = charactersAcceptable; + } + public CharactersCollectingParseMode(String name, char c) { + this(name, cx -> Objects.equals(cx, c)); + } + @Override + public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> _allowedTransitions, Collection<ParseMode> _transitionsAllowedHereOverride) { + int i=offset; + while (i<input.length() && charactersAcceptable.test(input.charAt(i))) { + i++; + } + if (i>offset) { + String v = input.substring(offset, i); + return InternalParseResult.ofValue(this, v, v); + } else { + return InternalParseResult.SKIP; + } + } + } + + public static class TopLevelParseMode extends CommonParseMode { + private final boolean allowsValues; + private final List<ParseMode> allowedTransitions = MutableList.of(); + public List<String> mustEndWithOneOfTheseModes; + + protected TopLevelParseMode(boolean allowsUnquotedLiteralValues) { + super("top-level", "", null); + this.allowsValues = allowsUnquotedLiteralValues; + } + + @Override + public boolean allowedToTakeUnmatchedCharsAsLiteralValue() { + return allowsValues; + } + + @Override + public boolean allowedToExitBeforeExitStringEncountered() { + return true; + } + + @Override + public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, Collection<ParseMode> transitionsAllowedHereOverride) { + InternalParseResult result = super.parse(input, offset, allowedTransitions, transitionsAllowedHereOverride); + if (mustEndWithOneOfTheseModes!=null && result.resultOrError.isPresent()) { + String error = result.resultOrError.get().getFinalContent().map(pn -> + mustEndWithOneOfTheseModes.contains(pn.getParseNodeMode()) + ? null + : "Expression ends with " + pn.getParseNodeMode() + " but should have ended with required token: " + mustEndWithOneOfTheseModes) + .or("Expression is empty but was expected to end with required token: " + mustEndWithOneOfTheseModes); + if (error!=null) return InternalParseResult.ofError(error); + } + return result; + } + } + + private final Multimap<ParseMode,ParseMode> allowedTransitions = Multimaps.newListMultimap(MutableMap.of(), MutableList::of); + private final TopLevelParseMode topLevel; + + protected ExpressionParserImpl(TopLevelParseMode topLevel, List<ParseMode> allowedAtTopLevel, Multimap<ParseMode,ParseMode> allowedTransitions) { + this(topLevel, allowedAtTopLevel); + this.allowedTransitions.putAll(allowedTransitions); + } + protected ExpressionParserImpl(TopLevelParseMode topLevel, List<ParseMode> allowedAtTopLevel) { + this.topLevel = topLevel; + this.topLevel.allowedTransitions.addAll(allowedAtTopLevel); + } + + public ExpressionParserImpl includeAllowedSubmodeTransition(ParseMode parent, ParseMode allowedSubmode, ParseMode ...allowedOtherSubmodes) { + allowedTransitions.put(parent, allowedSubmode); + for (ParseMode m: allowedOtherSubmodes) allowedTransitions.put(parent, m); + return this; + } + public ExpressionParserImpl includeGroupingBracketsAtUsualPlaces(ParseMode ...optionalExplicitBrackets) { + List<ParseMode> brackets = optionalExplicitBrackets==null ? MutableList.of() : + Arrays.asList(optionalExplicitBrackets).stream().filter(b -> b!=null).collect(Collectors.toList()); + if (brackets.isEmpty()) brackets = MutableList.of(SQUARE_BRACKET, CURLY_BRACES, PARENTHESES); + + brackets.forEach(b -> includeAllowedTopLevelTransition(b)); + allowedTransitions.putAll(INTERPOLATED, brackets); + for (ParseMode b : brackets) { + allowedTransitions.putAll(b, brackets); + includeAllowedSubmodeTransition(b, INTERPOLATED); + includeAllowedSubmodeTransition(b, SINGLE_QUOTE); + includeAllowedSubmodeTransition(b, DOUBLE_QUOTE); + brackets.forEach(bb -> includeAllowedSubmodeTransition(b, bb)); + } + return this; + } + public ExpressionParserImpl includeAllowedTopLevelTransition(ParseMode allowedAtTopLevel) { + topLevel.allowedTransitions.add(allowedAtTopLevel); + return this; + } + public ExpressionParserImpl removeAllowedSubmodeTransition(ParseMode parent, ParseMode disallowedSubmode) { + allowedTransitions.remove(parent, disallowedSubmode); + return this; + } + public ExpressionParserImpl removeAllowedTopLevelTransition(ParseMode disallowedSubmode) { + topLevel.allowedTransitions.remove(disallowedSubmode); + return this; + } + + public Maybe<ParseNode> parse(String input) { + return topLevel.parse(input, 0, allowedTransitions, topLevel.allowedTransitions).resultOrError; + } + public Maybe<List<ParseNodeOrValue>> parseEverything(String input) { + return parse(input).mapMaybe(pr -> { + if (!Objects.equals(pr.source, input)) return Maybe.absent("Could not parse everything"); + return Maybe.of(pr.contents); + }); + } + + public ExpressionParserImpl stoppingAt(String id, Predicate<String> earlyTermination, boolean requireTopLevelToEndWithThisOrAnotherRequiredMode) { + if (requireTopLevelToEndWithThisOrAnotherRequiredMode) { + if (topLevel.mustEndWithOneOfTheseModes == null) topLevel.mustEndWithOneOfTheseModes = MutableList.of(); + topLevel.mustEndWithOneOfTheseModes.add(id); + } + return includeAllowedTopLevelTransition(new ParseMode(id) { + @Override + public InternalParseResult parse(String input, int offset, Multimap<ParseMode, ParseMode> allowedTransitions, @Nullable Collection<ParseMode> transitionsAllowedHereOverride) { + if (earlyTermination.test(input.substring(offset))) { + InternalParseResult ipr = InternalParseResult.ofValue(this, "", ""); + ipr.earlyTerminationRequested = true; + return ipr; + } + return InternalParseResult.SKIP; + } + }); + } + +} diff --git a/core/src/test/java/org/apache/brooklyn/core/workflow/ExpressionParserTest.java b/core/src/test/java/org/apache/brooklyn/core/workflow/ExpressionParserTest.java new file mode 100644 index 0000000000..d3d4fade44 --- /dev/null +++ b/core/src/test/java/org/apache/brooklyn/core/workflow/ExpressionParserTest.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.brooklyn.core.workflow; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.brooklyn.core.workflow.utils.ExpressionParser; +import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue; +import org.apache.brooklyn.test.Asserts; +import org.apache.brooklyn.util.guava.Maybe; +import org.testng.annotations.Test; + +public class ExpressionParserTest { + + static String s(List<ParseNodeOrValue> pr) { + return pr.stream().map(ParseNodeOrValue::toString).collect(Collectors.joining("")); + } + static String parseFull(ExpressionParser ep, String expression) { + return getParseTreeString(ep.parseEverything(expression).get()); + } + static String parseFull(String expression) { + return parseFull(ExpressionParser.newDefaultAllowingUnquotedLiteralValues(), expression); + } + static String parseFullWhitespace(String expression) { + return parseFull(ExpressionParser.newDefaultAllowingUnquotedAndSplittingOnWhitespace(), expression); + } + static String parsePartial(ExpressionParser ep, String expression) { + return getParseTreeString(ep.parse(expression).get().getContents()); + } + + static String getParseTreeString(List<ParseNodeOrValue> result) { + return result.stream().map(ParseNodeOrValue::toString).collect(Collectors.joining("")); + } + + @Test + public void testCommon() { + Asserts.assertEquals(parseFull("hello"), "[hello]"); + Asserts.assertEquals(parseFull("\"hello\""), "double_quote[hello]"); + Asserts.assertEquals(parseFull("${a}"), "interpolated_expression[a]"); + + Asserts.assertEquals(parseFull("\"hello\"world"), "double_quote[hello][world]"); + Asserts.assertEquals(parseFull("\"hello\" with 'sq \"'"), "double_quote[hello][ with ]single_quote[sq \"]"); + Asserts.assertEquals(parseFullWhitespace("\"hello\" with 'sq \"'"), "double_quote[hello]whitespace[ ][with]whitespace[ ]single_quote[sq \"]"); + + Asserts.assertEquals(parseFull("\"hello ${a}\""), "double_quote[[hello ]interpolated_expression[a]]"); + Asserts.assertEquals(parseFull("x[\"v\"] =1"), "[x[]double_quote[v][] =1]"); + } + + @Test + public void testError() { + Asserts.expectedFailureContains(Maybe.Absent.getException(ExpressionParser.newDefaultAllowingUnquotedLiteralValues().parseEverything("\"non-quoted string")), + "Non-terminated double_quote"); + } + + @Test + public void testPartial() { + ExpressionParser ep = ExpressionParser.newDefaultAllowingUnquotedLiteralValues(). + stoppingAt("equals", s -> s.startsWith("="), true); + Asserts.assertEquals(parsePartial(ep, "x=1"), "[x]equals[]"); + Asserts.assertEquals(parsePartial(ep, "x[\"v\"] =1"), "[x[]double_quote[v][] ]equals[]"); + // equals not matched in expression + Asserts.assertFailsWith(() -> parsePartial(ep, "x[\"=\"] is 1"), + Asserts.expectedFailureContains("value", "should", "end", "with", "required", "equals")); + } + + @Test + public void testBrackets() { + ExpressionParser ep1 = ExpressionParser.newDefaultAllowingUnquotedLiteralValues(). + includeGroupingBracketsAtUsualPlaces(); + Asserts.assertEquals(parseFull(ep1, "x[\"v\"] =1"), "[x]square_bracket[double_quote[v]][ =1]"); + + ExpressionParser ep2 = ExpressionParser.newDefaultAllowingUnquotedLiteralValues(). + includeGroupingBracketsAtUsualPlaces(). + stoppingAt("equals", s -> s.startsWith("="), true); + Asserts.assertEquals(parsePartial(ep2, "x[\"v\"] =1"), "[x]square_bracket[double_quote[v]][ ]equals[]"); + Asserts.assertFailsWith(() -> parsePartial(ep2, "x[\"=\"] is 1"), + Asserts.expectedFailureContains("value", "should", "end", "with", "required", "equals")); + } + +} diff --git a/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java b/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java index 4aba9f1025..fdfe11ab96 100644 --- a/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java +++ b/utils/common/src/main/java/org/apache/brooklyn/test/Asserts.java @@ -32,6 +32,7 @@ import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import com.google.common.annotations.Beta; import com.google.common.base.Predicates; @@ -1337,6 +1338,12 @@ public class Asserts { } return true; } + public static Predicate<Throwable> expectedFailureContains(String phrase1ToContain, String ...optionalOtherPhrasesToContain) { + return e -> expectedFailureContains(e, phrase1ToContain, optionalOtherPhrasesToContain); + } + public static Predicate<Throwable> expectedFailureContainsIgnoreCase(String phrase1ToContain, String ...optionalOtherPhrasesToContain) { + return e -> expectedFailureContainsIgnoreCase(e, phrase1ToContain, optionalOtherPhrasesToContain); + } /** As {@link #expectedFailureContains(Throwable, String, String...)} but case insensitive */ public static boolean expectedCompoundExceptionContainsIgnoreCase(CompoundRuntimeException e, String phrase1ToContain, String ...optionalOtherPhrasesToContain) { diff --git a/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java b/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java index ff807dd962..0cd3d03746 100644 --- a/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java +++ b/utils/common/src/main/java/org/apache/brooklyn/util/guava/Maybe.java @@ -238,6 +238,12 @@ public abstract class Maybe<T> implements Serializable, Supplier<T> { }; } + public <T2> Maybe<T2> asType(Class<T2> requiredClass) { + if (isAbsent()) return Maybe.castAbsent(this); + if (requiredClass.isInstance(get())) return Maybe.<T2>cast((Maybe)this); + return Maybe.absent(() -> new IllegalArgumentException("Value is not of required type "+requiredClass)); + } + public static class MaybeGuavaOptional<T> extends Maybe<T> { private static final long serialVersionUID = -823731500051341455L; private final Optional<T> value;