/*
* Copyright 2008 Google Inc.
*
* 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.google.template.soy.jssrc.internal;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.template.soy.base.SourceLocation;
import com.google.template.soy.error.ErrorReporter;
import com.google.template.soy.error.SoyErrorKind;
import com.google.template.soy.exprtree.Operator;
import com.google.template.soy.jssrc.dsl.CodeChunk;
import com.google.template.soy.jssrc.restricted.JsExpr;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* Translator of Soy V1 expressions to their equivalent JS expressions. Needed in order to provide
* the semi backwards compatibility with Soy V1.
*
* <p> Adapted from Soy V1 code.
*
*/
final class V1JsExprTranslator {
private static final SoyErrorKind MALFORMED_FUNCTION_CALL =
SoyErrorKind.of("Malformed function call.");
/** Regex for a template variable or data reference. */
// 2 capturing groups: first part (excluding '$'), rest
// Example: $boo.foo.goo ==> group(1) == "boo", group(2) == ".foo.goo"
public static final String VAR_OR_REF_RE = "\\$([a-zA-Z0-9_]+)((?:\\.[a-zA-Z0-9_]+)*)";
/** Regex pattern for a template variable or data reference. */
public static final Pattern VAR_OR_REF = Pattern.compile(VAR_OR_REF_RE);
/** Regex for a special function ({@code isFirst()}, {@code isLast()}, or {@code index()}). */
// 2 capturing groups: function name, variable name (excluding '$').
private static final String SOY_FUNCTION_RE =
"(isFirst|isLast|index)\\(\\s*\\$([a-zA-Z0-9_]+)\\s*\\)";
/** Regex pattern for a Soy function. */
// 2 capturing groups: function name, variable name.
private static final Pattern SOY_FUNCTION = Pattern.compile(SOY_FUNCTION_RE);
/** Regex pattern for operators to translate: 'not', 'and', 'or'. */
private static final Pattern BOOL_OP_RE = Pattern.compile("\\b(not|and|or)\\b");
/** Regex pattern for a data reference or a Soy function. */
private static final Pattern VAR_OR_REF_OR_BOOL_OP_OR_SOY_FUNCTION =
Pattern.compile(VAR_OR_REF_RE + "|" + BOOL_OP_RE + "|" + SOY_FUNCTION_RE);
/** Regex pattern for a number. */
private static final Pattern NUMBER = Pattern.compile("[0-9]+");
/** Regex pattern for chars that appear in operator tokens (some appear in multiple tokens). */
private static final Pattern OP_TOKEN_CHAR = Pattern.compile("[-?|&=!<>+*/%]");
/**
* Helper function to generate code for a JS expression found in a Soy tag.
* Replaces all variables, data references, and special function calls in
* the given expression text with the appropriate generated code. E.g.
* <pre>
* $boo.foo + ($goo.moo).doIt()
* </pre>
* might become
* <pre>
* opt_data.boo.foo + (gooData0.moo).doIt()
* </pre>
*
* @param soyExpr The expression text to generate code for.
* @param sourceLocation Source location of the expression text.
* @param variableMappings -
* @param errorReporter For reporting syntax errors.
* @return The resulting expression code after the necessary substitutions.
*/
@VisibleForTesting
static JsExpr translateToJsExpr(
String soyExpr,
SourceLocation sourceLocation,
SoyToJsVariableMappings variableMappings,
ErrorReporter errorReporter) {
soyExpr = CharMatcher.whitespace().collapseFrom(soyExpr, ' ');
StringBuffer jsExprTextSb = new StringBuffer();
Matcher matcher = VAR_OR_REF_OR_BOOL_OP_OR_SOY_FUNCTION.matcher(soyExpr);
while (matcher.find()) {
String group = matcher.group();
Matcher varOrRef = VAR_OR_REF.matcher(group);
if (varOrRef.matches()) {
matcher.appendReplacement(
jsExprTextSb,
Matcher.quoteReplacement(
translateVarOrRef(variableMappings, varOrRef)));
} else if (BOOL_OP_RE.matcher(group).matches()) {
matcher.appendReplacement(
jsExprTextSb,
Matcher.quoteReplacement(translateBoolOp(group)));
} else {
String translation =
translateFunction(group, variableMappings, sourceLocation, errorReporter);
if (translation != null) {
matcher.appendReplacement(jsExprTextSb, Matcher.quoteReplacement(translation));
}
}
}
matcher.appendTail(jsExprTextSb);
String jsExprText = jsExprTextSb.toString();
// Note: There is a JavaScript language quirk that requires all Unicode Foramt characters
// (Unicode category "Cf") to be escaped in JS strings. Therefore, we call
// JsSrcUtils.escapeUnicodeFormatChars() on the expression text in case it contains JS strings.
jsExprText = JsSrcUtils.escapeUnicodeFormatChars(jsExprText);
int jsExprPrec = guessJsExprPrecedence(jsExprText);
return new JsExpr(jsExprText, jsExprPrec);
}
/**
* Helper function to translate a variable or data reference.
*
* Examples:
* <pre>
* $boo.foo --> opt_data.boo.foo (data ref)
* $boo.3.foo --> opt_data.boo[3].foo (data ref)
* $boo --> booData2 (data ref with foreach var)
* $boo.foo --> booData2.Foo (data ref with foreach var)
* $i --> i3 (for var)
* </pre>
*
* @param variableMappings The current replacement JS expressions for the local variables
* (and foreach-loop special functions) current in scope.
* @param matcher Matcher formed from {@link V1JsExprTranslator#VAR_OR_REF}.
* @return Generated translation for the variable or data reference.
*/
private static String translateVarOrRef(
SoyToJsVariableMappings variableMappings, Matcher matcher) {
Preconditions.checkArgument(matcher.matches());
String firstPart = matcher.group(1);
String rest = matcher.group(2);
StringBuilder exprTextSb = new StringBuilder();
// ------ Translate the first key, which may be a variable or a data key ------
String translation = getLocalVarTranslation(firstPart, variableMappings);
if (translation != null) {
// Case 1: In-scope local var.
exprTextSb.append(translation);
} else {
// Case 2: Data reference.
exprTextSb.append("opt_data.").append(firstPart);
}
// ------ Translate the rest of the keys, if any ------
if (rest != null && rest.length() > 0) {
for (String part : Splitter.on('.').split(rest.substring(1))) {
if (NUMBER.matcher(part).matches()) {
exprTextSb.append("[").append(part).append("]");
} else {
exprTextSb.append(".").append(part);
}
}
}
return exprTextSb.toString();
}
/**
* Helper function to translate a boolean operator from Soy to JS.
* @param boolOp The Soy boolean operator.
* @return The translated string.
*/
private static String translateBoolOp(String boolOp) {
switch (boolOp) {
case "not":
return "!";
case "and":
return "&&";
case "or":
return "||";
default:
throw new AssertionError();
}
}
/**
* Private helper for {@code genExpressionCode} to generate code for a
* special function call.
*
* @param functionText The text of the special function call.
* @param variableMappings The current replacement JS expressions for the local
* variables (and foreach-loop special functions) current in scope.
* @param errorReporter For reporting syntax errors.
* @return The translated string, or null if the function text was malformed or the translation
* couldn't be found.
*/
@Nullable private static String translateFunction(
String functionText,
SoyToJsVariableMappings variableMappings,
SourceLocation sourceLocation,
ErrorReporter errorReporter) {
Matcher matcher = SOY_FUNCTION.matcher(functionText);
if (!matcher.matches()) {
errorReporter.report(sourceLocation, MALFORMED_FUNCTION_CALL);
return null;
}
String funcName = matcher.group(1);
String varName = matcher.group(2);
return getLocalVarTranslation(varName + "__" + funcName, variableMappings);
}
// We guess the precedence of the expression by searching for characters that appear in
// operator tokens. This is of course far from accurate, but it's a reasonable effort.
private static int guessJsExprPrecedence(String jsExprText) {
// We guess the precedence of the expression by searching for characters that appear in
// operator tokens. This is of course far from accurate, but it's a reasonable effort.
int prec = Integer.MAX_VALUE; // to be adjusted below
Matcher matcher = OP_TOKEN_CHAR.matcher(jsExprText);
while (matcher.find()) {
switch(matcher.group().charAt(0)) {
case '?':
prec = Math.min(prec, Operator.CONDITIONAL.getPrecedence());
break;
case '|':
prec = Math.min(prec, Operator.OR.getPrecedence());
break;
case '&':
prec = Math.min(prec, Operator.AND.getPrecedence());
break;
case '=':
// Could be any of "==", "!=", "<=", ">=". Instead of wasting time checking, we simply
// set the precedence to the lowest possible value.
prec = Math.min(prec, Operator.EQUAL.getPrecedence());
break;
case '!':
if (jsExprText.contains("!=")) {
prec = Math.min(prec, Operator.NOT_EQUAL.getPrecedence());
} else { // must be "!"
prec = Math.min(prec, Operator.NOT.getPrecedence());
}
break;
case '<':
case '>':
prec = Math.min(prec, Operator.LESS_THAN.getPrecedence());
break;
case '+':
prec = Math.min(prec, Operator.PLUS.getPrecedence());
break;
case '-':
if (matcher.start() == 0) {
// Matched at beginning of expression, so it must be the unary "-"
prec = Math.min(prec, Operator.NEGATIVE.getPrecedence());
} else {
// Could be binary or unary "-". Since we're not sure, set the precedence to the lowest
// possible value.
prec = Math.min(prec, Operator.MINUS.getPrecedence());
}
break;
case '*':
case '/':
case '%':
prec = Math.min(prec, Operator.TIMES.getPrecedence());
break;
default:
throw new AssertionError();
}
}
return prec;
}
/**
* Gets the translated expression for an in-scope local variable (or special "variable" derived
* from a foreach-loop var), or null if not found.
*
* @param ident The Soy local variable to translate.
* @param mappings The replacement JS expressions for the local variables
* (and foreach-loop special functions) current in scope.
* @return The translated string for the given variable, or null if not found.
*
* TODO(user): change the return type to CodeChunk.WithValue.
*/
@Nullable
private static String getLocalVarTranslation(String ident, SoyToJsVariableMappings mappings) {
CodeChunk.WithValue translation = mappings.maybeGet(ident);
if (translation == null) {
return null;
}
JsExpr asExpr = translation.assertExpr();
return asExpr.getPrecedence() != Integer.MAX_VALUE
? "(" + asExpr.getText() + ")"
: asExpr.getText();
}
}