blob: cb94600636753640d8a413f68e8c097a1f3fe290 [file] [log] [blame]
/*
* 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.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.apache.brooklyn.core.workflow.utils.ExpressionParser;
import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNode;
import org.apache.brooklyn.core.workflow.utils.ExpressionParserImpl.ParseNodeOrValue;
import org.apache.brooklyn.util.collections.CollectionMerger;
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.QuotedStringTokenizer;
import org.apache.brooklyn.util.text.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This is the latest version of the SP which uses the EP for input, and the QST for templates,
* to keep backwards compatibility but allow better handling for internal double quotes and interpolated expressions with spaces.
* It falls back to QST when there are errors to maximize compatibility.
*
* ===
*
* Accepts a shorthand template, and converts it to a map of values,
* e.g. given template "[ ?${type_set} ${sensor.type} ] ${sensor.name} \"=\" ${value}"
* and input "integer foo=3", this will return
* { sensor: { type: integer, name: foo }, value: 3, type_set: true }.
*
* Expects space-separated TOKEN where TOKEN is either:
*
* ${VAR} - to set VAR, which should be of the regex [A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*, with dot separation used to set nested maps;
* will match a quoted string if supplied, else up to the next literal if the next token is a literal, else the next work.
* ${VAR...} - as above, but will collect multiple args if needed (if the next token is a literal matched further on, or if at end of word)
* "LITERAL" - to expect a literal expression. this must include the quotation marks and should include spaces if spaces are required.
* [ TOKEN ] - to indicate TOKEN is optional, where TOKEN is one of the above sections. parsing is attempted first with it, then without it.
* [ ?${VAR} TOKEN ] - as `[ TOKEN ]` but VAR is set true or false depending whether this optional section was matched.
*
* Would be nice to support A | B (exclusive or) for A or B but not both (where A might contain a literal for disambiguation),
* and ( X ) for X required but grouped (for use with | (exclusive or) where one option is required).
* Would also be nice to support any order, which could be ( A & B ) to allow A B or B A.
*
* But for now we've made do without it, with some compromises:
* * keywords must follow the order indicated
* * exclusive alternatives are disallowed by code subsequently or checked separately (eg Transform)
*/
public class ShorthandProcessorEpToQst {
private static final Logger log = LoggerFactory.getLogger(ShorthandProcessorEpToQst.class);
static final boolean TRY_HARDER_FOR_QST_COMPATIBILITY = true;
private final String template;
boolean finalMatchRaw = false;
boolean failOnMismatch = true;
public ShorthandProcessorEpToQst(String template) {
this.template = template;
}
public Maybe<Map<String,Object>> process(String input) {
return new ShorthandProcessorQstAttempt(this, input).call();
}
/** whether the last match should preserve quotes and spaces; default false */
public ShorthandProcessorEpToQst withFinalMatchRaw(boolean finalMatchRaw) {
this.finalMatchRaw = finalMatchRaw;
return this;
}
/** whether to fail on mismatched quotes in the input, default true */
public ShorthandProcessorEpToQst withFailOnMismatch(boolean failOnMismatch) {
this.failOnMismatch = failOnMismatch;
return this;
}
static class ShorthandProcessorQstAttempt {
private final List<String> templateTokens;
private final String inputOriginal;
private final QuotedStringTokenizer qst0;
private final String template;
private final ShorthandProcessorEpToQst options;
int optionalDepth = 0;
int optionalSkippingInput = 0;
private String inputRemaining;
Map<String, Object> result;
Consumer<String> valueUpdater;
ShorthandProcessorQstAttempt(ShorthandProcessorEpToQst proc, String input) {
this.template = proc.template;
this.options = proc;
this.qst0 = qst0(template);
this.templateTokens = qst0.remainderAsList(); // QST works fine for the template
this.inputOriginal = input;
}
private QuotedStringTokenizer qst0(String x) {
return QuotedStringTokenizer.builder().includeQuotes(true).includeDelimiters(false).expectQuotesDelimited(true).failOnOpenQuote(options.failOnMismatch).build(x);
}
public synchronized Maybe<Map<String,Object>> call() {
if (result == null) {
result = MutableMap.of();
inputRemaining = inputOriginal;
} else {
throw new IllegalStateException("Only allowed to use once");
}
Maybe<Object> error = doCall();
if (error.isAbsent()) return Maybe.Absent.castAbsent(error);
inputRemaining = Strings.trimStart(inputRemaining);
if (Strings.isNonBlank(inputRemaining)) {
if (valueUpdater!=null) {
//QuotedStringTokenizer qstInput = qst(inputRemaining);
valueUpdater.accept(getRemainderPossiblyRaw(inputRemaining));
} else {
// shouldn't come here
return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
}
}
return Maybe.of(result);
}
protected Maybe<Object> doCall() {
boolean isEndOfOptional = false;
outer: while (true) {
if (isEndOfOptional) {
if (optionalDepth <= 0) {
throw new IllegalStateException("Unexpected optional block closure");
}
optionalDepth--;
if (optionalSkippingInput>0) {
// we were in a block where we skipped something optional because it couldn't be matched; outer parser is now canonical,
// and should stop skipping
return Maybe.of(true);
}
isEndOfOptional = false;
}
if (templateTokens.isEmpty()) {
if (Strings.isNonBlank(inputRemaining) && valueUpdater==null) {
return Maybe.absent("Input has trailing characters after template is matched: '" + inputRemaining + "'");
}
if (optionalDepth>0)
return Maybe.absent("Mismatched optional marker in template");
return Maybe.of(true);
}
String t = templateTokens.remove(0);
if (t.startsWith("[")) {
t = t.substring(1);
if (!t.isEmpty()) {
templateTokens.add(0, t);
}
String optionalPresentVar = null;
if (!templateTokens.isEmpty() && templateTokens.get(0).startsWith("?")) {
String v = templateTokens.remove(0);
if (v.startsWith("?${") && v.endsWith("}")) {
optionalPresentVar = v.substring(3, v.length() - 1);
} else {
throw new IllegalStateException("? after [ should indicate optional presence variable using syntax '?${var}', not '"+v+"'");
}
}
Maybe<Object> cr;
if (optionalSkippingInput<=0) {
// make a deep copy so that valueUpdater writes get replayed
Map<String, Object> backupResult = (Map) CollectionMerger.builder().deep(true).build().merge(MutableMap.of(), result);
Consumer<String> backupValueUpdater = valueUpdater;
String backupInputRemaining = inputRemaining;
List<String> backupTemplateTokens = MutableList.copyOf(templateTokens);
int oldDepth = optionalDepth;
int oldSkippingDepth = optionalSkippingInput;
optionalDepth++;
cr = doCall();
if (cr.isPresent()) {
// succeeded
if (optionalPresentVar!=null) result.put(optionalPresentVar, true);
continue;
} else {
// restore
result = backupResult;
valueUpdater = backupValueUpdater;
if (optionalPresentVar!=null) result.put(optionalPresentVar, false);
inputRemaining = backupInputRemaining;
templateTokens.clear();
templateTokens.addAll(backupTemplateTokens);
optionalDepth = oldDepth;
optionalSkippingInput = oldSkippingDepth;
optionalSkippingInput++;
optionalDepth++;
cr = doCall();
if (cr.isPresent()) {
optionalSkippingInput--;
continue;
}
}
} else {
if (optionalPresentVar!=null) {
result.put(optionalPresentVar, false);
valueUpdater = null;
}
optionalDepth++;
cr = doCall();
if (cr.isPresent()) {
continue;
}
}
return cr;
}
isEndOfOptional = t.endsWith("]");
if (isEndOfOptional) {
t = t.substring(0, t.length() - 1);
if (t.isEmpty()) continue;
// next loop will process the end of the optionality
}
if (qst0.isQuoted(t)) {
if (optionalSkippingInput>0) continue;
String literal = qst0.unwrapIfQuoted(t);
do {
// ignore leading spaces (since the quoted string tokenizer will have done that anyway); but their _absence_ can be significant for intra-token searching when matching a var
inputRemaining = Strings.trimStart(inputRemaining);
if (inputRemaining.startsWith(Strings.trimStart(literal))) {
// literal found
inputRemaining = inputRemaining.substring(Strings.trimStart(literal).length());
continue outer;
}
if (inputRemaining.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input reached");
if (valueUpdater!=null) {
if (inputRemaining.isEmpty()) return Maybe.absent("Literal '"+literal+"' expected, when end of input tokens reached");
String value = getNextInputTokenUpToPossibleExpectedLiteral(literal);
valueUpdater.accept(value);
continue;
}
return Maybe.absent("Literal '"+literal+"' expected, when encountered '"+inputRemaining+"'");
} while (true);
}
if (t.startsWith("${") && t.endsWith("}")) {
if (optionalSkippingInput>0) continue;
t = t.substring(2, t.length()-1);
String value;
inputRemaining = inputRemaining.trim();
if (inputRemaining.isEmpty()) return Maybe.absent("End of input when looking for variable "+t);
if (!templateTokens.stream().filter(x -> !x.equals("]")).findFirst().isPresent()) {
// last word (whether optional or not) takes everything
value = getRemainderPossiblyRaw(inputRemaining);
inputRemaining = "";
} else {
value = getNextInputTokenUpToPossibleExpectedLiteral(null);
}
boolean multiMatch = t.endsWith("...");
if (multiMatch) t = Strings.removeFromEnd(t, "...");
String keys[] = t.split("\\.");
final String tt = t;
valueUpdater = v2 -> {
Map target = result;
for (int i=0; i<keys.length; i++) {
if (!Pattern.compile("[A-Za-z0-9_-]+").matcher(keys[i]).matches()) {
throw new IllegalArgumentException("Invalid variable '"+tt+"'");
}
if (i == keys.length - 1) {
target.compute(keys[i], (k, v) -> v == null ? v2 : v + " " + v2);
} else {
// need to make sure we have a map or null
target = (Map) target.compute(keys[i], (k, v) -> {
if (v == null) return MutableMap.of();
if (v instanceof Map) return v;
return Maybe.absent("Cannot process shorthand for " + Arrays.asList(keys) + " because entry '" + k + "' is not a map (" + v + ")");
});
}
}
};
valueUpdater.accept(value);
if (!multiMatch) valueUpdater = null;
continue;
}
// unexpected token
return Maybe.absent("Unexpected token in shorthand pattern '"+template+"' at position "+(template.lastIndexOf(t)+1));
}
}
private String getRemainderPossiblyRawQst(QuotedStringTokenizer qstInput) {
String value;
value = Strings.join(qstInput.remainderRaw(), "");
if (!options.finalMatchRaw) {
return qstInput.unwrapIfQuoted(value);
}
return value;
}
private String getRemainderPossiblyRaw(String inputRemaining) {
Maybe<String> mp = getRemainderPossiblyRawEp(inputRemaining);
if (TRY_HARDER_FOR_QST_COMPATIBILITY || (mp.isAbsent() && !options.failOnMismatch)) {
String qstResult = getRemainderPossiblyRawQst(qst0(inputRemaining));
if (mp.isPresent() && !mp.get().equals(qstResult)) {
log.debug("Shorthand parsing semantics change for: "+inputOriginal+"\n" +
" old qst: "+qstResult+"\n"+
" new exp: "+mp.get());
// to debug
// getRemainderPossiblyRawEp(inputRemaining);
}
if (mp.isAbsent()) {
return qstResult;
}
}
return mp.get();
}
private Maybe<String> getRemainderPossiblyRawEp(String inputRemaining) {
if (options.finalMatchRaw) {
return Maybe.of(inputRemaining);
}
Maybe<List<ParseNodeOrValue>> mp = ShorthandProcessorExprParser.tokenizer().parseEverything(inputRemaining);
return mp.map(pnl -> {
final boolean UNQUOTE_INDIVIDUAL_WORDS = false; // legacy behaviour
if (pnl.size()==1 || UNQUOTE_INDIVIDUAL_WORDS) {
return ExpressionParser.getAllUnquoted(pnl);
} else {
return ExpressionParser.getUnescapedButNotUnquoted(pnl);
}
});
}
private String getNextInputTokenUpToPossibleExpectedLiteralQst(QuotedStringTokenizer qstInput, String nextLiteral) {
String value;
if (!qstInput.hasMoreTokens()) {
return ""; // shouldn't happen
}
String v = qstInput.nextToken();
if (qstInput.isQuoted(v)) {
// input was quoted, eg "\"foo=b\" ..." -- ignore the = in "foo=b"
value = qstInput.unwrapIfQuoted(v);
inputRemaining = inputRemaining.substring(v.length());
} else {
// input not quoted, if next template token is literal, look for it
boolean isLiteralExpected;
if (nextLiteral==null) {
nextLiteral = templateTokens.get(0);
if (qstInput.isQuoted(nextLiteral)) {
nextLiteral = qstInput.unwrapIfQuoted(nextLiteral);
isLiteralExpected = true;
} else {
isLiteralExpected = false;
}
} else {
isLiteralExpected = true;
}
if (isLiteralExpected) {
int nli = v.indexOf(nextLiteral);
if (nli>0) {
// literal found in unquoted string, eg "foo=bar" when literal is =
value = v.substring(0, nli);
inputRemaining = inputRemaining.substring(value.length());
} else {
// literal not found
value = v;
inputRemaining = inputRemaining.substring(value.length());
}
} else {
// next is not a literal, so the whole token is the value
value = v;
inputRemaining = inputRemaining.substring(value.length());
}
}
return value;
}
private String getNextInputTokenUpToPossibleExpectedLiteral(String nextLiteral) {
String oi = inputRemaining;
Maybe<String> v1 = getNextInputTokenUpToPossibleExpectedLiteralEp(nextLiteral);
if (TRY_HARDER_FOR_QST_COMPATIBILITY || (v1.isAbsent() && !options.failOnMismatch)) {
String ni = inputRemaining;
inputRemaining = oi;
String qstResult = getNextInputTokenUpToPossibleExpectedLiteralQst(qst0(inputRemaining), nextLiteral);
if (v1.isPresent() && !v1.get().equals(qstResult)) {
log.debug("Shorthand parsing semantics change for literal " + nextLiteral + ": " + inputOriginal + "\n" +
" old qst: " + qstResult + "\n" +
" new exp: " + v1.get());
// // to debug differences
// inputRemaining = oi;
//// getNextInputTokenUpToPossibleExpectedLiteralQst(qst0(inputRemaining), nextLiteral);
// getNextInputTokenUpToPossibleExpectedLiteralEp(nextLiteral);
}
if (v1.isAbsent()) {
return qstResult;
}
inputRemaining = ni;
}
return v1.get();
}
private Maybe<String> getNextInputTokenUpToPossibleExpectedLiteralEp(String nextLiteral) {
String result = "";
boolean canRepeat = true;
Maybe<ParseNode> parseM = ShorthandProcessorExprParser.tokenizer().parse(inputRemaining);
while (canRepeat) {
String value;
if (parseM.isAbsent()) return Maybe.castAbsent(parseM);
List<ParseNodeOrValue> tokens = parseM.get().getContents();
ParseNodeOrValue t = tokens.iterator().next();
if (ExpressionParser.isQuotedExpressionNode(t)) {
// input was quoted, eg "\"foo=b\" ..." -- ignore the = in "foo=b"
value = ExpressionParser.getUnquoted(t);
inputRemaining = inputRemaining.substring(t.getSource().length());
} else {
// input not quoted, if next template token is literal, look for it
boolean isLiteralExpected;
if (nextLiteral == null) {
String nl = templateTokens.get(0);
if (qst0.isQuoted(nl)) {
nextLiteral = qst0.unwrapIfQuoted(nl);
isLiteralExpected = true;
} else {
isLiteralExpected = false;
}
} else {
isLiteralExpected = true;
}
if (isLiteralExpected) {
int nli =
// previously took next QST token
qst0(inputRemaining).nextToken().indexOf(nextLiteral);
// // this is simple, slightly greedier;
// // slightly too greedy, in that nextLiteral inside quotes further away will match
// // and it breaks backwards compatibility
// inputRemaining.indexOf(nextLiteral);
// // this parse node is probably too short
// t.getSource().indexOf(nextLiteral);
final boolean ALLOW_NOTHING_BEFORE_LITERAL = false;
if ((nli == 0 && ALLOW_NOTHING_BEFORE_LITERAL) || nli > 0) {
// literal found in unquoted string, eg "foo=bar" when literal is =
if (!qst0(inputRemaining).nextToken().startsWith(inputRemaining.substring(0, nli))) {
String v = qst0(inputRemaining).nextToken();
}
value = inputRemaining.substring(0, nli);
inputRemaining = inputRemaining.substring(nli);
canRepeat = false;
} else {
// literal not found
value = t.getSource(); // since we know it isn't quoted
inputRemaining = inputRemaining.substring(value.length());
}
} else {
// next is not a literal, so the whole token is the value - not unquoted
value = t.getSource();
inputRemaining = inputRemaining.substring(value.length());
}
}
result += value;
canRepeat &= !inputRemaining.isEmpty();
if (canRepeat) {
parseM = ShorthandProcessorExprParser.tokenizer().parse(inputRemaining);
if (parseM.isAbsent()) canRepeat = false;
else {
if (ExpressionParser.startsWithWhitespace(parseM.get())) canRepeat = false;
// otherwise, to act like QST, we treat non-whitespace-separated things all as one token
}
}
}
return Maybe.of(result);
}
}
}