/*
* Copyright 2013 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.soytree;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.template.soy.base.SourceLocation;
import com.google.template.soy.base.internal.Identifier;
import com.google.template.soy.basetree.SyntaxVersion;
import com.google.template.soy.basetree.SyntaxVersionUpperBound;
import com.google.template.soy.data.SanitizedContent.ContentKind;
import com.google.template.soy.error.ErrorReporter;
import com.google.template.soy.error.SoyErrorKind;
import com.google.template.soy.soytree.TemplateNode.SoyFileHeaderInfo;
import com.google.template.soy.soytree.defn.SoyDocParam;
import com.google.template.soy.soytree.defn.TemplateParam;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* Builder for TemplateNode.
*
* <p>Important: Do not use outside of Soy code (treat as superpackage-private).
*
*/
public abstract class TemplateNodeBuilder {
private static final SoyErrorKind INVALID_SOYDOC_PARAM =
SoyErrorKind.of("Found invalid soydoc param name ''{0}''");
private static final SoyErrorKind INVALID_PARAM_NAMED_IJ =
SoyErrorKind.of("Invalid param name ''ij'' (''ij'' is for injected data).");
private static final SoyErrorKind KIND_BUT_NOT_STRICT =
SoyErrorKind.of("kind=\"...\" attribute is only valid with autoescape=\"strict\".");
private static final SoyErrorKind LEGACY_COMPATIBLE_PARAM_TAG =
SoyErrorKind.of(
"Found invalid SoyDoc param tag ''{0}'', tags like this are only allowed in "
+ "legacy templates marked ''deprecatedV1=\"true\"''. The proper soydoc @param "
+ "syntax is: ''@param <name> <optional comment>''. Soy does not understand JsDoc "
+ "style type declarations in SoyDoc.");
private static final SoyErrorKind PARAM_ALREADY_DECLARED =
SoyErrorKind.of("Param ''{0}'' already declared");
/** Info from the containing Soy file's header declarations. */
protected final SoyFileHeaderInfo soyFileHeaderInfo;
/** For reporting parse errors. */
protected final ErrorReporter errorReporter;
/** The id for this node. */
protected Integer id;
/** The lowest known syntax version bound. Value may be adjusted multiple times. */
@Nullable protected SyntaxVersionUpperBound syntaxVersionBound;
/** The command text. */
protected String cmdText;
/**
* This template's name. This is private instead of protected to enforce use of
* setTemplateNames().
*/
private String templateName;
/**
* This template's partial name. Only applicable for V2. This is private instead of protected to
* enforce use of setTemplateNames().
*/
private String partialTemplateName;
/** A string suitable for display in user msgs as the template name. */
protected String templateNameForUserMsgs;
/** This template's visibility level. */
protected Visibility visibility;
/**
* The mode of autoescaping for this template. This is private instead of protected to enforce use
* of setAutoescapeInfo().
*/
private AutoescapeMode autoescapeMode;
/** Required CSS namespaces. */
private ImmutableList<String> requiredCssNamespaces = ImmutableList.of();
/** Base CSS namespace for package-relative CSS selectors. */
private String cssBaseNamespace;
/**
* Strict mode context. Nonnull iff autoescapeMode is strict. This is private instead of protected
* to enforce use of setAutoescapeInfo().
*/
private ContentKind contentKind;
/** The full SoyDoc, including the start/end tokens, or null. */
protected String soyDoc;
/** The description portion of the SoyDoc (before declarations), or null. */
protected String soyDocDesc;
/** The params from template header and/or SoyDoc. Null if no decls and no SoyDoc. */
@Nullable protected ImmutableList<TemplateParam> params;
protected boolean isMarkedV1;
protected StrictHtmlMode strictHtmlMode;
SourceLocation sourceLocation;
/** @param soyFileHeaderInfo Info from the containing Soy file's header declarations. */
protected TemplateNodeBuilder(SoyFileHeaderInfo soyFileHeaderInfo, ErrorReporter errorReporter) {
this.soyFileHeaderInfo = soyFileHeaderInfo;
this.errorReporter = errorReporter;
this.syntaxVersionBound = null;
this.strictHtmlMode = StrictHtmlMode.UNSET;
// All other fields default to null.
}
/**
* Sets the id for the node to be built.
*
* @return This builder.
*/
public TemplateNodeBuilder setId(int id) {
Preconditions.checkState(this.id == null);
this.id = id;
return this;
}
/** Sets the source location. */
public TemplateNodeBuilder setSourceLocation(SourceLocation location) {
checkState(sourceLocation == null);
this.sourceLocation = checkNotNull(location);
return this;
}
/**
* Set the parsed data from the command tag.
*
* @param name The template name
* @param attrs The attributes that are set on the tag {e.g. {@code kind="strict"}}
*/
public abstract TemplateNodeBuilder setCommandValues(
Identifier name, List<CommandTagAttribute> attrs);
/**
* Returns a template name suitable for display in user msgs.
*
* <p>Note: This public getter exists because this info is needed by SoyFileParser for error
* reporting before the TemplateNode is ready to be built.
*/
public String getTemplateNameForUserMsgs() {
return templateNameForUserMsgs;
}
/**
* Sets the SoyDoc for the node to be built. The SoyDoc will be parsed to fill in SoyDoc param
* info.
*
* @return This builder.
*/
public TemplateNodeBuilder setSoyDoc(String soyDoc, SourceLocation soyDocLocation) {
Preconditions.checkState(this.soyDoc == null);
Preconditions.checkState(cmdText != null);
this.soyDoc = soyDoc;
Preconditions.checkArgument(soyDoc.startsWith("/**") && soyDoc.endsWith("*/"));
String cleanedSoyDoc = cleanSoyDocHelper(soyDoc);
this.soyDocDesc = parseSoyDocDescHelper(cleanedSoyDoc);
this.addParams(parseSoyDocDeclsHelper(soyDoc, cleanedSoyDoc, soyDocLocation));
return this;
}
/**
* Helper for {@code setSoyDoc()} and {@code setHeaderDecls()}. This method is intended to be
* called at most once for SoyDoc params and at most once for header params.
*
* @param params The params to add.
*/
public TemplateNodeBuilder addParams(Iterable<? extends TemplateParam> params) {
Set<String> seenParamKeys = new HashSet<>();
if (this.params == null) {
this.params = ImmutableList.copyOf(params);
} else {
for (TemplateParam oldParam : this.params) {
seenParamKeys.add(oldParam.name());
}
this.params =
ImmutableList.<TemplateParam>builder().addAll(this.params).addAll(params).build();
}
// Check new params.
for (TemplateParam param : params) {
if (param.name().equals("ij")) {
errorReporter.report(param.nameLocation(), INVALID_PARAM_NAMED_IJ);
}
if (!seenParamKeys.add(param.name())) {
errorReporter.report(param.nameLocation(), PARAM_ALREADY_DECLARED, param.name());
}
}
return this;
}
/** Builds the template node. Will error if not enough info as been set on this builder. */
public abstract TemplateNode build();
// -----------------------------------------------------------------------------------------------
// Protected helpers for fields that need extra logic when being set.
protected final void markDeprecatedV1(boolean isDeprecatedV1) {
isMarkedV1 = isDeprecatedV1;
if (isDeprecatedV1) {
SyntaxVersionUpperBound newSyntaxVersionBound =
new SyntaxVersionUpperBound(SyntaxVersion.V2_0, "Template is marked as deprecatedV1.");
this.syntaxVersionBound =
SyntaxVersionUpperBound.selectLower(this.syntaxVersionBound, newSyntaxVersionBound);
}
}
protected void setAutoescapeInfo(
AutoescapeMode autoescapeMode,
@Nullable ContentKind contentKind,
@Nullable SourceLocation kindLocation) {
Preconditions.checkArgument(autoescapeMode != null);
this.autoescapeMode = autoescapeMode;
if (contentKind == null && autoescapeMode == AutoescapeMode.STRICT) {
// Default mode is HTML.
contentKind = ContentKind.HTML;
} else if (contentKind != null && autoescapeMode != AutoescapeMode.STRICT) {
// TODO: Perhaps this could imply strict escaping?
errorReporter.report(kindLocation, KIND_BUT_NOT_STRICT);
}
this.contentKind = contentKind;
}
/** @return the id for this node. */
Integer getId() {
return id;
}
/** @return The lowest known syntax version bound. */
SyntaxVersionUpperBound getSyntaxVersionBound() {
return syntaxVersionBound;
}
/** @return The command text. */
String getCmdText() {
return cmdText;
}
/** @return The full SoyDoc, including the start/end tokens, or null. */
String getSoyDoc() {
return soyDoc;
}
/** @return The description portion of the SoyDoc (before declarations), or null. */
String getSoyDocDesc() {
return soyDocDesc;
}
/** @return The mode of autoescaping for this template. */
protected AutoescapeMode getAutoescapeMode() {
Preconditions.checkState(autoescapeMode != null);
return autoescapeMode;
}
/** @return Strict mode context. Nonnull iff autoescapeMode is strict. */
@Nullable
public ContentKind getContentKind() {
checkState(autoescapeMode != null); // make sure setAutoescapeInfo was called
return contentKind;
}
/** @return Required CSS namespaces. */
protected ImmutableList<String> getRequiredCssNamespaces() {
return Preconditions.checkNotNull(requiredCssNamespaces);
}
protected void setRequiredCssNamespaces(ImmutableList<String> requiredCssNamespaces) {
this.requiredCssNamespaces = Preconditions.checkNotNull(requiredCssNamespaces);
}
/** @return Base CSS namespace for package-relative CSS selectors. */
protected String getCssBaseNamespace() {
return cssBaseNamespace;
}
protected void setCssBaseNamespace(String cssBaseNamespace) {
this.cssBaseNamespace = cssBaseNamespace;
}
protected final void setTemplateNames(
String templateName, SourceLocation nameLocation, @Nullable String partialTemplateName) {
this.templateName = templateName;
this.partialTemplateName = partialTemplateName;
}
protected StrictHtmlMode getStrictHtmlMode() {
return strictHtmlMode;
}
protected String getTemplateName() {
return templateName;
}
@Nullable
protected String getPartialTemplateName() {
return partialTemplateName;
}
// -----------------------------------------------------------------------------------------------
// Private static helpers for parsing template SoyDoc.
/** Pattern for a newline. */
private static final Pattern NEWLINE = Pattern.compile("\\n|\\r\\n?");
/** Pattern for a SoyDoc start token, including spaces up to the first newline. */
private static final Pattern SOY_DOC_START =
Pattern.compile("^ [/][*][*] [\\ ]* \\r?\\n?", Pattern.COMMENTS);
/** Pattern for a SoyDoc end token, including preceding spaces up to the last newline. */
private static final Pattern SOY_DOC_END =
Pattern.compile("\\r?\\n? [\\ ]* [*][/] $", Pattern.COMMENTS);
/** Pattern for a SoyDoc declaration. */
// group(1) = declaration keyword; group(2) = declaration text.
private static final Pattern SOY_DOC_DECL_PATTERN =
Pattern.compile("( @param[?]? ) \\s+ ( \\S+ )", Pattern.COMMENTS);
/** Pattern for SoyDoc parameter declaration text. */
private static final Pattern SOY_DOC_PARAM_TEXT_PATTERN =
Pattern.compile("[a-zA-Z_]\\w*", Pattern.COMMENTS);
/**
* Private helper for the constructor to clean the SoyDoc. (1) Changes all newlines to "\n". (2)
* Escapes deprecated javadoc tags. (3) Strips the start/end tokens and spaces (including newlines
* if they occupy their own lines). (4) Removes common indent from all lines (e.g.
* space-star-space).
*
* @param soyDoc The SoyDoc to clean.
* @return The cleaned SoyDoc.
*/
private static String cleanSoyDocHelper(String soyDoc) {
// Change all newlines to "\n".
soyDoc = NEWLINE.matcher(soyDoc).replaceAll("\n");
// Escape all @deprecated javadoc tags.
// TODO(cushon): add this to the specification and then also generate @Deprecated annotations
soyDoc = soyDoc.replace("@deprecated", "@deprecated");
// Strip start/end tokens and spaces (including newlines if they occupy their own lines).
soyDoc = SOY_DOC_START.matcher(soyDoc).replaceFirst("");
soyDoc = SOY_DOC_END.matcher(soyDoc).replaceFirst("");
// Split into lines.
List<String> lines = Lists.newArrayList(Splitter.on(NEWLINE).split(soyDoc));
// Remove indent common to all lines. Note that SoyDoc indents often include a star
// (specifically the most common indent is space-star-space). Thus, we first remove common
// spaces, then remove one common star, and finally, if we did remove a star, then we once again
// remove common spaces.
removeCommonStartCharHelper(lines, ' ', true);
if (removeCommonStartCharHelper(lines, '*', false) == 1) {
removeCommonStartCharHelper(lines, ' ', true);
}
return Joiner.on('\n').join(lines);
}
/**
* Private helper for {@code cleanSoyDocHelper()}. Removes a common character at the start of all
* lines, either once or as many times as possible.
*
* <p>Special case: Empty lines count as if they do have the common character for the purpose of
* deciding whether all lines have the common character.
*
* @param lines The list of lines. If removal happens, then the list elements will be modified.
* @param charToRemove The char to remove from the start of all lines.
* @param shouldRemoveMultiple Whether to remove the char as many times as possible.
* @return The number of chars removed from the start of each line.
*/
private static int removeCommonStartCharHelper(
List<String> lines, char charToRemove, boolean shouldRemoveMultiple) {
int numCharsToRemove = 0;
// Count num chars to remove.
boolean isStillCounting = true;
do {
boolean areAllLinesEmpty = true;
for (String line : lines) {
if (line.length() == 0) {
continue; // empty lines are okay
}
areAllLinesEmpty = false;
if (line.length() <= numCharsToRemove || line.charAt(numCharsToRemove) != charToRemove) {
isStillCounting = false;
break;
}
}
if (areAllLinesEmpty) {
isStillCounting = false;
}
if (isStillCounting) {
numCharsToRemove += 1;
}
} while (isStillCounting && shouldRemoveMultiple);
// Perform the removal.
if (numCharsToRemove > 0) {
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (line.length() == 0) {
continue; // don't change empty lines
}
lines.set(i, line.substring(numCharsToRemove));
}
}
return numCharsToRemove;
}
/**
* Private helper for the constructor to parse the SoyDoc description.
*
* @param cleanedSoyDoc The cleaned SoyDoc text. Must not be null.
* @return The description (with trailing whitespace removed).
*/
private static String parseSoyDocDescHelper(String cleanedSoyDoc) {
Matcher paramMatcher = SOY_DOC_DECL_PATTERN.matcher(cleanedSoyDoc);
int endOfDescPos = (paramMatcher.find()) ? paramMatcher.start() : cleanedSoyDoc.length();
String soyDocDesc = cleanedSoyDoc.substring(0, endOfDescPos);
return CharMatcher.whitespace().trimTrailingFrom(soyDocDesc);
}
/**
* Private helper for the constructor to parse the SoyDoc declarations.
*
* @param cleanedSoyDoc The cleaned SoyDoc text. Must not be null.
* @return A SoyDocDeclsInfo object with the parsed info.
*/
private List<SoyDocParam> parseSoyDocDeclsHelper(
String originalSoyDoc, String cleanedSoyDoc, SourceLocation soyDocSourceLocation) {
List<SoyDocParam> params = new ArrayList<>();
RawTextNode originalSoyDocAsNode = new RawTextNode(-1, originalSoyDoc, soyDocSourceLocation);
Matcher matcher = SOY_DOC_DECL_PATTERN.matcher(cleanedSoyDoc);
// Important: This statement finds the param for the first iteration of the loop.
boolean isFound = matcher.find();
while (isFound) {
// Save the match groups.
String declKeyword = matcher.group(1);
String declText = matcher.group(2);
String fullMatch = matcher.group();
// find the param in the original soy doc and use the RawTextNode support for
// calculating substring locations to get a more accurate location
int indexOfParamName = originalSoyDoc.indexOf(declText, originalSoyDoc.indexOf(fullMatch));
SourceLocation paramLocation =
originalSoyDocAsNode.substringLocation(
indexOfParamName, indexOfParamName + declText.length());
// Find the next declaration in the SoyDoc and extract this declaration's desc string.
int descStart = matcher.end();
// Important: This statement finds the param for the next iteration of the loop.
// We must find the next param now in order to know where the current param's desc ends.
isFound = matcher.find();
int descEnd = (isFound) ? matcher.start() : cleanedSoyDoc.length();
String desc = cleanedSoyDoc.substring(descStart, descEnd).trim();
if (declKeyword.equals("@param") || declKeyword.equals("@param?")) {
if (SOY_DOC_PARAM_TEXT_PATTERN.matcher(declText).matches()) {
params.add(new SoyDocParam(declText, declKeyword.equals("@param"), desc, paramLocation));
} else {
if (declText.startsWith("{")) {
// v1 is allowed for compatibility reasons
if (!isMarkedV1) {
errorReporter.report(paramLocation, LEGACY_COMPATIBLE_PARAM_TAG, declText);
}
} else {
errorReporter.report(paramLocation, INVALID_SOYDOC_PARAM, declText);
}
}
} else {
throw new AssertionError();
}
}
return params;
}
}