/**
 * Visitor that ensures files and templates use strict autoescaping. Backends such as Miso (Python)
 * can choose to disallow all other types of autoescaping besides strict.
 */
final class AssertStrictAutoescapingVisitor extends AbstractSoyNodeVisitor<Void> {

  private static final SoyError INVALID_AUTOESCAPING =
      SoyError.of("Invalid use of non-strict when strict autoescaping is required.");
  private final ErrorReporter errorReporter;

  AssertStrictAutoescapingVisitor(ErrorReporter errorReporter) {
    this.errorReporter = errorReporter;
  }

  @Override
  public Void exec(SoyNode soyNode) {
    Preconditions.checkArgument(
        soyNode instanceof SoyFileSetNode || soyNode instanceof SoyFileNode);
    super.exec(soyNode);
    return null;
  }

  @Override
  protected void visitSoyFileNode(SoyFileNode node) {
    if (node.getDefaultAutoescapeMode() != AutoescapeMode.STRICT) {
      errorReporter.report(node.getSourceLocation(), INVALID_AUTOESCAPING);
      // If the file isn't strict, skip children to avoid spamming errors.
      return;
    }

    visitChildren(node);
  }

  @Override
  protected void visitTemplateNode(TemplateNode node) {
    if (node.getAutoescapeMode() != AutoescapeMode.STRICT) {
      errorReporter.report(node.getSourceLocation(), INVALID_AUTOESCAPING);
    }
  }

  /** Fallback implementation for all other nodes. */
  @Override
  protected void visitSoyNode(SoyNode node) {
    if (node instanceof ParentSoyNode<?>) {
      visitChildren((ParentSoyNode<?>) node);
    }
  }
}
Esempio n. 2
0
/**
 * Node representing a 'let' statement with a value expression.
 *
 * <p>Important: Do not use outside of Soy code (treat as superpackage-private).
 */
public final class LetValueNode extends LetNode implements ExprHolderNode {

  public static final SoyError SELF_ENDING_WITHOUT_VALUE =
      SoyError.of(
          "A ''let'' tag should be self-ending (with a trailing ''/'') if and only if "
              + "it also contains a value (invalid tag is '{'let {0} /'}').");
  private static final SoyError KIND_ATTRIBUTE_NOT_ALLOWED_WITH_VALUE =
      SoyError.of(
          "The ''kind'' attribute is not allowed on self-ending ''let'' tags that "
              + "contain a value (invalid tag is '{'let {0} /'}').");

  /** The value expression that the variable is set to. */
  private final ExprRootNode valueExpr;

  private LetValueNode(
      int id,
      SourceLocation sourceLocation,
      String localVarName,
      String commandText,
      ExprRootNode valueExpr) {
    super(id, sourceLocation, localVarName, commandText);
    this.valueExpr = valueExpr;
  }

  /**
   * Copy constructor.
   *
   * @param orig The node to copy.
   */
  private LetValueNode(LetValueNode orig) {
    super(orig);
    this.valueExpr = orig.valueExpr.clone();
  }

  @Override
  public Kind getKind() {
    return Kind.LET_VALUE_NODE;
  }

  /** Return The local variable name (without preceding '$'). */
  @Override
  public final String getVarName() {
    return var.name();
  }

  /** Returns the value expression that the variable is set to. */
  public ExprRootNode getValueExpr() {
    return valueExpr;
  }

  @Override
  public List<ExprUnion> getAllExprUnions() {
    return ImmutableList.of(new ExprUnion(valueExpr));
  }

  @Override
  public LetValueNode clone() {
    return new LetValueNode(this);
  }

  /** Builder for {@link LetValueNode}. */
  public static final class Builder {
    private static LetValueNode error() {
      return new Builder(-1, "$error: 1", SourceLocation.UNKNOWN)
          .build(ExplodingErrorReporter.get()); // guaranteed to be valid
    }

    private final int id;
    private final String commandText;
    private final SourceLocation sourceLocation;

    /**
     * @param id The node's id.
     * @param commandText The node's command text.
     * @param sourceLocation The node's source location.
     */
    public Builder(int id, String commandText, SourceLocation sourceLocation) {
      this.id = id;
      this.commandText = commandText;
      this.sourceLocation = sourceLocation;
    }

    /**
     * Returns a new {@link LetValueNode} built from the builder's state. If the builder's state is
     * invalid, errors are reported to the {@code errorManager} and {Builder#error} is returned.
     */
    public LetValueNode build(ErrorReporter errorReporter) {
      Checkpoint checkpoint = errorReporter.checkpoint();
      CommandTextParseResult parseResult =
          parseCommandTextHelper(commandText, errorReporter, sourceLocation);

      if (parseResult.valueExpr == null) {
        errorReporter.report(sourceLocation, SELF_ENDING_WITHOUT_VALUE, commandText);
      }

      if (parseResult.contentKind != null) {
        errorReporter.report(sourceLocation, KIND_ATTRIBUTE_NOT_ALLOWED_WITH_VALUE, commandText);
      }

      if (errorReporter.errorsSince(checkpoint)) {
        return error();
      }

      return new LetValueNode(
          id, sourceLocation, parseResult.localVarName, commandText, parseResult.valueExpr);
    }
  }
}
Esempio n. 3
0
/**
 * Node representing a call to a delegate template.
 *
 * <p>Important: Do not use outside of Soy code (treat as superpackage-private).
 */
public final class CallDelegateNode extends CallNode {

  private static final SoyError MISSING_CALLEE_NAME =
      SoyError.of(
          "The ''delcall'' command text must contain the callee name "
              + "(encountered command text \"{0}\").");
  public static final SoyError INVALID_DELEGATE_NAME =
      SoyError.of("Invalid delegate name \"{0}\" for ''delcall'' command.");
  private static final SoyError INVALID_VARIANT_EXPRESSION =
      SoyError.of(
          "Invalid variant expression \"{0}\" in ''delcall''"
              + " (variant expression must evaluate to an identifier).");

  /**
   * Private helper class used by constructors. Encapsulates all the info derived from the command
   * text.
   */
  @Immutable
  private static class CommandTextInfo extends CallNode.CommandTextInfo {

    public final String delCalleeName;
    @Nullable public final ExprRootNode delCalleeVariantExpr;
    public final Boolean allowsEmptyDefault;

    public CommandTextInfo(
        String commandText,
        String delCalleeName,
        @Nullable ExprRootNode delCalleeVariantExpr,
        Boolean allowsEmptyDefault,
        DataAttribute dataAttr,
        @Nullable String userSuppliedPlaceholderName) {
      super(commandText, dataAttr, userSuppliedPlaceholderName, null);
      this.delCalleeName = delCalleeName;
      this.delCalleeVariantExpr = delCalleeVariantExpr;
      this.allowsEmptyDefault = allowsEmptyDefault;
    }
  }

  /** Pattern for a callee name. */
  private static final Pattern NONATTRIBUTE_CALLEE_NAME =
      Pattern.compile("^\\s* ([.\\w]+) (?= \\s | $)", Pattern.COMMENTS);

  /** Parser for the command text. */
  private static final CommandTextAttributesParser ATTRIBUTES_PARSER =
      new CommandTextAttributesParser(
          "delcall",
          new Attribute("variant", Attribute.ALLOW_ALL_VALUES, null),
          new Attribute("data", Attribute.ALLOW_ALL_VALUES, null),
          new Attribute("allowemptydefault", Attribute.BOOLEAN_VALUES, null));

  /** The name of the delegate template being called. */
  private final String delCalleeName;

  /** The variant expression for the delegate being called, or null. */
  @Nullable private final ExprRootNode delCalleeVariantExpr;

  /**
   * User-specified value of whether this delegate call defaults to empty string if there's no
   * active implementation, or null if the attribute is not specified.
   */
  private Boolean allowsEmptyDefault;

  /**
   * The list of params that need to be type checked when this node is run on a per delegate basis.
   * All the params that could be statically verified will be checked up front (by the {@code
   * CheckCallingParamTypesVisitor}), this list contains the params that could not be statically
   * checked.
   *
   * <p>NOTE:This list will be a subset of the params of the callee, not a subset of the params
   * passed from this caller.
   */
  private ImmutableMap<TemplateDelegateNode, ImmutableList<TemplateParam>>
      paramsToRuntimeCheckByDelegate;

  public static final class Builder {

    private static CallDelegateNode error() {
      return new Builder(-1, SourceLocation.UNKNOWN)
          .commandText("error.error")
          .build(ExplodingErrorReporter.get()); // guaranteed to be valid
    }

    private final int id;
    private final SourceLocation sourceLocation;

    private boolean allowEmptyDefault;
    private DataAttribute dataAttribute = DataAttribute.none();
    private ImmutableList<String> escapingDirectiveNames = ImmutableList.of();

    @Nullable private String commandText;
    @Nullable private String delCalleeName;
    @Nullable private ExprRootNode delCalleeVariantExpr;
    @Nullable private String userSuppliedPlaceholderName;

    public Builder(int id, SourceLocation sourceLocation) {
      this.id = id;
      this.sourceLocation = sourceLocation;
    }

    public Builder allowEmptyDefault(boolean allowEmptyDefault) {
      this.allowEmptyDefault = allowEmptyDefault;
      return this;
    }

    public Builder commandText(String commandText) {
      this.commandText = commandText;
      return this;
    }

    public Builder delCalleeName(String delCalleeName) {
      this.delCalleeName = delCalleeName;
      return this;
    }

    public Builder delCalleeVariantExpr(ExprRootNode delCalleeVariantExpr) {
      this.delCalleeVariantExpr = delCalleeVariantExpr;
      return this;
    }

    public Builder escapingDirectiveNames(ImmutableList<String> escapingDirectiveNames) {
      this.escapingDirectiveNames = escapingDirectiveNames;
      return this;
    }

    public Builder dataAttribute(DataAttribute dataAttribute) {
      this.dataAttribute = dataAttribute;
      return this;
    }

    public Builder userSuppliedPlaceholderName(String userSuppliedPlaceholderName) {
      this.userSuppliedPlaceholderName = userSuppliedPlaceholderName;
      return this;
    }

    public CallDelegateNode build(ErrorReporter errorReporter) {
      Checkpoint checkpoint = errorReporter.checkpoint();
      CommandTextInfo commandTextInfo =
          commandText != null ? parseCommandText(errorReporter) : buildCommandText();
      if (errorReporter.errorsSince(checkpoint)) {
        return error();
      }
      CallDelegateNode callDelegateNode =
          new CallDelegateNode(id, sourceLocation, commandTextInfo, escapingDirectiveNames);
      return callDelegateNode;
    }

    private CommandTextInfo parseCommandText(ErrorReporter errorReporter) {
      String commandTextWithoutPhnameAttr = this.commandText;

      String commandText =
          commandTextWithoutPhnameAttr
              + ((userSuppliedPlaceholderName != null)
                  ? " phname=\"" + userSuppliedPlaceholderName + "\""
                  : "");

      // Handle callee name not listed as an attribute.
      Matcher ncnMatcher = NONATTRIBUTE_CALLEE_NAME.matcher(commandTextWithoutPhnameAttr);
      String delCalleeName;
      if (ncnMatcher.find()) {
        delCalleeName = ncnMatcher.group(1);
        if (!BaseUtils.isDottedIdentifier(delCalleeName)) {
          errorReporter.report(sourceLocation, INVALID_DELEGATE_NAME, delCalleeName);
        }
        commandTextWithoutPhnameAttr =
            commandTextWithoutPhnameAttr.substring(ncnMatcher.end()).trim();
      } else {
        delCalleeName = null;
        errorReporter.report(sourceLocation, MISSING_CALLEE_NAME, commandText);
      }

      Map<String, String> attributes =
          ATTRIBUTES_PARSER.parse(commandTextWithoutPhnameAttr, errorReporter, sourceLocation);

      String variantExprText = attributes.get("variant");
      ExprRootNode delCalleeVariantExpr;
      if (variantExprText == null) {
        delCalleeVariantExpr = null;
      } else {
        ExprNode expr =
            new ExpressionParser(variantExprText, sourceLocation, errorReporter).parseExpression();
        // If the variant is a fixed string, do a sanity check.
        if (expr instanceof StringNode) {
          String fixedVariantStr = ((StringNode) expr).getValue();
          if (!BaseUtils.isIdentifier(fixedVariantStr)) {
            errorReporter.report(sourceLocation, INVALID_VARIANT_EXPRESSION, variantExprText);
          }
        }
        delCalleeVariantExpr = new ExprRootNode(expr);
      }

      DataAttribute dataAttrInfo =
          parseDataAttributeHelper(attributes.get("data"), sourceLocation, errorReporter);

      String allowemptydefaultAttr = attributes.get("allowemptydefault");
      Boolean allowsEmptyDefault =
          (allowemptydefaultAttr == null) ? null : allowemptydefaultAttr.equals("true");

      return new CommandTextInfo(
          commandText,
          delCalleeName,
          delCalleeVariantExpr,
          allowsEmptyDefault,
          dataAttrInfo,
          userSuppliedPlaceholderName);
    }

    private CommandTextInfo buildCommandText() {

      Preconditions.checkArgument(BaseUtils.isDottedIdentifier(delCalleeName));
      String commandText = "";
      commandText += delCalleeName;
      if (dataAttribute.isPassingAllData()) {
        commandText += " data=\"all\"";
      } else if (dataAttribute.isPassingData()) {
        assert dataAttribute.dataExpr() != null; // suppress warnings
        commandText += " data=\"" + dataAttribute.dataExpr().toSourceString() + '"';
      }
      if (userSuppliedPlaceholderName != null) {
        commandText += " phname=\"" + userSuppliedPlaceholderName + '"';
      }

      return new CommandTextInfo(
          commandText,
          delCalleeName,
          delCalleeVariantExpr,
          allowEmptyDefault,
          dataAttribute,
          userSuppliedPlaceholderName);
    }
  }

  private CallDelegateNode(
      int id,
      SourceLocation sourceLocation,
      CommandTextInfo commandTextInfo,
      ImmutableList<String> escapingDirectiveNames) {
    super(id, sourceLocation, "delcall", commandTextInfo, escapingDirectiveNames);
    this.delCalleeName = commandTextInfo.delCalleeName;
    this.delCalleeVariantExpr = commandTextInfo.delCalleeVariantExpr;
    this.allowsEmptyDefault = commandTextInfo.allowsEmptyDefault;
  }

  /**
   * Copy constructor.
   *
   * @param orig The node to copy.
   */
  @SuppressWarnings("ConstantConditions") // for IntelliJ
  private CallDelegateNode(CallDelegateNode orig, CopyState copyState) {
    super(orig, copyState);
    this.delCalleeName = orig.delCalleeName;
    this.delCalleeVariantExpr =
        (orig.delCalleeVariantExpr != null) ? orig.delCalleeVariantExpr.copy(copyState) : null;
    this.allowsEmptyDefault = orig.allowsEmptyDefault;
    this.paramsToRuntimeCheckByDelegate = orig.paramsToRuntimeCheckByDelegate;
  }

  @Override
  public Kind getKind() {
    return Kind.CALL_DELEGATE_NODE;
  }

  /** Returns the name of the delegate template being called. */
  public String getDelCalleeName() {
    return delCalleeName;
  }

  /** Returns the variant expression for the delegate being called, or null if it's a string. */
  @Nullable
  public ExprRootNode getDelCalleeVariantExpr() {
    return delCalleeVariantExpr;
  }

  /**
   * Sets the template params that require runtime type checking for each possible delegate target.
   */
  public void setParamsToRuntimeCheck(
      ImmutableMap<TemplateDelegateNode, ImmutableList<TemplateParam>> paramsToRuntimeCheck) {
    this.paramsToRuntimeCheckByDelegate = Preconditions.checkNotNull(paramsToRuntimeCheck);
  }

  @Override
  public Collection<TemplateParam> getParamsToRuntimeCheck(TemplateNode callee) {
    if (paramsToRuntimeCheckByDelegate == null) {
      return callee.getParams();
    }
    ImmutableList<TemplateParam> params = paramsToRuntimeCheckByDelegate.get(callee);
    if (params == null) {
      // The callee was not known when we performed static type checking.  Check all params.
      return callee.getParams();
    }
    return params;
  }

  /** Returns whether this delegate call defaults to empty string if there's no active impl. */
  public boolean allowsEmptyDefault() {
    // Default to 'false' if not specified.
    if (allowsEmptyDefault == null) {
      return false;
    }
    return allowsEmptyDefault;
  }

  @Override
  public List<ExprUnion> getAllExprUnions() {
    List<ExprUnion> allExprUnions = Lists.newArrayListWithCapacity(2);
    if (delCalleeVariantExpr != null) {
      allExprUnions.add(new ExprUnion(delCalleeVariantExpr));
    }
    allExprUnions.addAll(super.getAllExprUnions());
    return Collections.unmodifiableList(allExprUnions);
  }

  @Override
  public CallDelegateNode copy(CopyState copyState) {
    return new CallDelegateNode(this, copyState);
  }
}
/**
 * Translates fragments of HTML tags, text nodes and attributes found in {@link RawTextNode}s to the
 * following nodes:
 *
 * <ul>
 *   <li>{@link HtmlAttributeNode}
 *   <li>{@link HtmlCloseTagNode}
 *   <li>{@link HtmlOpenTagEndNode}
 *   <li>{@link HtmlOpenTagStartNode}
 *   <li>{@link HtmlPrintNode}
 *   <li>{@link HtmlTextNode}
 * </ul>
 *
 * {@link RawTextNode}s not found in a place where HTML or attributes may be present, such as in a
 * {@link XidNode}, are left alone.
 */
public final class HtmlTransformVisitor extends AbstractSoyNodeVisitor<Void> {
  private static final SoyError ENDING_STATE_MISMATCH =
      SoyError.of(
          "Ending context of the content "
              + "within a Soy tag must match the starting context. Transition was from {0} to {1}");

  private static final SoyError EXPECTED_ATTRIBUTE_VALUE =
      SoyError.of("Expected to find a quoted " + "attribute value, but found \"{0}\".");

  private static final SoyError SOY_TAG_IN_ATTR_NAME =
      SoyError.of("Soy statements are not allowed " + "in an attribute name declaration.");

  private static final SoyError SOY_TAG_BEFORE_ATTR_VALUE =
      SoyError.of(
          "Soy statements are not "
              + "allowed before an attribute value. They should be moved inside a quotation mark.");

  private static final SoyError MISSING_TAG_NAME =
      SoyError.of("Found a tag with an empty tag " + "name.");

  private static final SoyError NON_STRICT_FILE =
      SoyError.of("The incremental HTML Soy backend " + "requires strict autoescape mode");

  private static final SoyError NON_STRICT_TEMPLATE =
      SoyError.of(
          "The incremental HTML Soy "
              + "backend requires strict autoescape mode for all templates.");

  private static final SoyError TEMPLATE_CALL_IN_TAG =
      SoyError.of(
          "The incremental HTML Soy "
              + "backend does not support template calls within HTML tag declarations.");

  private static final SoyError UNKNOWN_CONTENT_KIND =
      SoyError.of(
          "The incremental HTML Soy "
              + "backend requires all let statements and parameters with content to have a content kind");

  /** The last {@link HtmlState} encountered. */
  private HtmlState currentState = HtmlState.PCDATA;

  /** The name of the current tag. */
  private String currentTag = "";

  /**
   * The current 'token' being built up. This may correspond to a tag name, attribute name,
   * attribute value or text node.
   */
  private final StringBuilder currentText = new StringBuilder();

  /** The name of the current attribute being examined. */
  private String currentAttributeName = "";

  /** The {@link StandaloneNode}s that make up the value of the current attribute. */
  private List<StandaloneNode> currentAttributeValues = new ArrayList<>();

  /** Used to give newly created Nodes an id. */
  private IdGenerator idGen = null;

  /**
   * Maps a RawTextNode to nodes corresponding to one or more HTML tag pieces or attributes. This is
   * added to by {@link #visitRawTextNode(RawTextNode)} whenever the end of a piece is encountered.
   * After {@link #exec(SoyNode)} finishes, the RawTextNodes are replaced with the corresponding
   * nodes.
   */
  private final ListMultimap<RawTextNode, StandaloneNode> transformMapping =
      ArrayListMultimap.create();

  /** The {@link RawTextNode}s that have been visited and should be removed. */
  private final Set<RawTextNode> visitedRawTextNodes = new HashSet<>();

  /**
   * Used to prevent reporting an error on each token after an equals if a non-quoted attribute
   * value is used, allowing the visitor to visit the rest of the tree looking for issues without a
   * flood of errors being generated.
   */
  private boolean suppressExpectedAttributeValueError = false;

  private final ErrorReporter errorReporter;

  public HtmlTransformVisitor(ErrorReporter errorReporter) {
    this.errorReporter = errorReporter;
  }

  /**
   * Transforms all the {@link RawTextNode}s corresponding to HTML to the corresponding Html*Node.
   * Additionally, PrintNodes that occur in HTML data or attributes declarations are wrapped with an
   * {@link HtmlPrintNode}.
   *
   * @see AbstractSoyNodeVisitor#exec(com.google.template.soy.basetree.Node)
   */
  @Override
  public Void exec(SoyNode node) {
    super.exec(node);
    applyTransforms();

    return null;
  }

  /**
   * Applies the built up transforms, changing {@link RawTextNode}s to the corresponding Html*Nodes,
   * if any. If a node itself does not correspond to any new nodes, it is simply removed.
   */
  private void applyTransforms() {
    for (RawTextNode node : visitedRawTextNodes) {
      ParentSoyNode<StandaloneNode> parent = node.getParent();

      parent.addChildren(parent.getChildIndex(node), transformMapping.get(node));
      parent.removeChild(node);
    }
  }

  private HtmlState getState() {
    return currentState;
  }

  private void setState(HtmlState state) {
    currentState = state;
  }

  /**
   * Derives a {@link SourceLocation} from the given {@link RawTextNode}, for text the length of
   * charSequence ending at endOffset.
   */
  private SourceLocation deriveSourceLocation(RawTextNode node) {
    // TODO(sparhami) Since the parser strips templates and combines whitespace, including newlines
    // we can't find the correct source location based on where we are in the RawTextNode. The
    // parser needs to be modified to not strip whitespace and to not combine text before and after
    // comments. Doing the former is difficult due to commands like {\n}.
    return node.getSourceLocation();
  }

  /**
   * Creates a new {@link HtmlTextNode} and maps it to node.
   *
   * @param node The node that the mapped node comes from.
   */
  private void createTextNode(RawTextNode node) {
    // Consume text, removing unnecessary whitespace
    String currentString = consumeText(true);

    if (currentString.length() > 0) {
      SourceLocation sl = deriveSourceLocation(node);
      transformMapping.put(node, new HtmlTextNode(idGen.genId(), currentString, sl));
    }
  }

  /**
   * Creates a {@link RawTextNode} for the current part of an attribute value and adds it to the
   * pending attribute value array.
   *
   * @param node The node that the mapped node comes from.
   */
  private void createAttributeValueNode(RawTextNode node) {
    String currentString = consumeText(false);

    // Check to see if the currentText is empty. This may occur when we have something like
    // disabled="" or disabled="{$foo}" after the print tag is finished.
    if (currentString.length() > 0) {
      SourceLocation sl = deriveSourceLocation(node);
      currentAttributeValues.add(new RawTextNode(idGen.genId(), currentString, sl));
    }
  }

  /**
   * Creates a new {@link HtmlAttributeNode} and maps it to node, taking all the attribute values
   * (text, conditionals, print statements) and adding them to the new attribute node.
   *
   * @param node The node that the mapped node comes from.
   */
  private void createAttribute(RawTextNode node) {
    SourceLocation sl = deriveSourceLocation(node);
    HtmlAttributeNode htmlAttributeNode =
        new HtmlAttributeNode(idGen.genId(), currentAttributeName, sl);
    htmlAttributeNode.addChildren(currentAttributeValues);
    transformMapping.put(node, htmlAttributeNode);

    currentAttributeValues = new ArrayList<>();
  }

  /**
   * Handles a character within {@link HtmlState#PCDATA}, where either a text node may be present or
   * the start of a new tag.
   *
   * @param node The node that the current character belongs to.
   * @param c The current character being examined.
   */
  private void handleHtmlPcData(RawTextNode node, char c) {
    if (c == '<') {
      // If we are encountering the start of a new tag, check to see if a text node with data is
      // being completed.
      createTextNode(node);
      setState(HtmlState.TAG_NAME);
    } else {
      currentText.append(c);
    }
  }

  private String consumeText(boolean trim) {
    String token;

    if (trim) {
      token = CharMatcher.WHITESPACE.trimFrom(currentText);
    } else {
      token = currentText.toString();
    }

    currentText.setLength(0);
    return token;
  }

  /**
   * Handles a character within {@link HtmlState#TAG_NAME}, where the name of a tag must be present.
   *
   * @param node The node that the current character belongs to.
   * @param c The current character being examined.
   */
  private void handleHtmlTagName(RawTextNode node, char c) {
    if (CharMatcher.WHITESPACE.matches(c) || c == '>') {
      currentTag = consumeText(false);

      // No tag name, saw something like <> or <  >.
      if (currentTag.length() <= 0) {
        SourceLocation sl = deriveSourceLocation(node);
        errorReporter.report(sl, MISSING_TAG_NAME);
      }

      // Currently, closing tags and open tags are handled through the states. If this is not a
      // closing tag, then an open tag needs to be started.
      if (!currentTag.startsWith("/")) {
        SourceLocation sl = deriveSourceLocation(node);
        transformMapping.put(node, new HtmlOpenTagStartNode(idGen.genId(), currentTag, sl));
      }

      if (c == '>') {
        // Handle close tags and tags that only have a tag name (e.g. <div>).
        handleHtmlTag(node, c);
      } else {
        // Get ready to capture attributes.
        currentAttributeValues = new ArrayList<>();
        setState(HtmlState.TAG);
      }
    } else {
      currentText.append(c);
    }
  }

  /**
   * Handles a character within {@link HtmlState#TAG}, where either an attribute declaration or the
   * end of a tag may appear.
   *
   * @param node The node that the current character belongs to.
   * @param c The current character being examined.
   */
  private void handleHtmlTag(RawTextNode node, char c) {
    if (c == '>') {
      // Found the end of the tag - create the appropriate open tag or close tag node, depending
      // on which we are ending.
      SourceLocation sl = deriveSourceLocation(node);

      if (currentTag.startsWith("/")) {
        transformMapping.put(
            node, new HtmlCloseTagNode(idGen.genId(), currentTag.substring(1), sl));
      } else {
        transformMapping.put(node, new HtmlOpenTagEndNode(idGen.genId(), currentTag, sl));
      }

      setState(HtmlState.PCDATA);
    } else if (CharMatcher.WHITESPACE.matches(c)) {
      // Skip whitespace characters.
    } else {
      setState(HtmlState.ATTRIBUTE_NAME);
      currentText.append(c);
    }
  }

  /**
   * Handles the state where an attribute name is being declared. If an =, > or whitespace character
   * is encountered, then the attribute name is completed.
   *
   * @param node The node that the current character belongs to
   * @param c The current character being examined
   */
  private void handleHtmlAttributeName(RawTextNode node, char c) {
    if (c == '=') {
      // Next thing we should see is " to start the attribute value.
      currentAttributeName = consumeText(false);
      setState(HtmlState.BEFORE_ATTRIBUTE_VALUE);
      suppressExpectedAttributeValueError = false;
    } else if (c == '>') {
      // Tag ended with an attribute with no value (e.g. disabled) - create an attribute, then
      // handle the tag end.
      currentAttributeName = consumeText(false);
      createAttribute(node);
      handleHtmlTag(node, c);
    } else if (CharMatcher.WHITESPACE.matches(c)) {
      // Handle a value-less attribute, then start looking for another attribute or the end of the
      // tag.
      currentAttributeName = consumeText(false);
      createAttribute(node);
      setState(HtmlState.TAG);
    } else {
      currentText.append(c);
    }
  }

  /**
   * Handle the next character after the equals in the attribute declaration. The only allowed
   * character is a double quote.
   *
   * @param node The node that the current character belongs to.
   * @param c The current character being examined.
   */
  private void handleHtmlBeforeAttributeValue(RawTextNode node, char c) {
    if (c == '"') {
      setState(HtmlState.ATTR_VALUE);
    } else if (!suppressExpectedAttributeValueError) {
      SourceLocation sl = deriveSourceLocation(node);
      errorReporter.report(sl, EXPECTED_ATTRIBUTE_VALUE, c);
      suppressExpectedAttributeValueError = true;
    }

    // Just move on if we see a space or closing bracket so that the rest of the tree can be checked
    // for issues.
    if (c == '>') {
      handleHtmlTag(node, c);
    } else if (CharMatcher.WHITESPACE.matches(c)) {
      setState(HtmlState.TAG);
    }
  }

  /**
   * Handles an HTML attribute value. When an end quote is encountered, a new {@link
   * HtmlAttributeNode} is created with the {@link SoyNode}s that make up the value.
   *
   * @param node The node that the current character belongs to.
   * @param c The current character being examined.
   */
  private void handleHtmlNormalAttrValue(RawTextNode node, char c) {
    if (c == '"') {
      createAttributeValueNode(node);
      createAttribute(node);
      setState(HtmlState.TAG);
    } else {
      currentText.append(c);
    }
  }

  /**
   * Consumes a single character, taking action to create a node if necessary or just adding it to
   * the current pending text.
   *
   * @param node The node that the current character belongs to.
   * @param c The current character being examined.
   */
  private void consumeCharacter(RawTextNode node, char c) {
    switch (getState()) {
      case PCDATA:
        handleHtmlPcData(node, c);
        break;
      case TAG_NAME:
        handleHtmlTagName(node, c);
        break;
      case TAG:
        handleHtmlTag(node, c);
        break;
      case ATTRIBUTE_NAME:
        handleHtmlAttributeName(node, c);
        break;
      case BEFORE_ATTRIBUTE_VALUE:
        handleHtmlBeforeAttributeValue(node, c);
        break;
      case ATTR_VALUE:
        handleHtmlNormalAttrValue(node, c);
        break;
      default:
        break;
    }
  }

  /**
   * Visits a {@link RawTextNode}, going through each of the characters and building up the HTML
   * pieces (e.g. {@link HtmlOpenTagStartNode} and {@link HtmlOpenTagEndNode}). The new pieces are
   * mapped to the {@link RawTextNode} where they ended. The {@link #applyTransforms()} method
   * actually performs the replacement.
   */
  @Override
  protected void visitRawTextNode(RawTextNode node) {
    String content = node.getRawText();

    // Mark all visited RawTextNodes for removal. A single RawTextNode may not map to any Html*Nodes
    // by itself, but we still want to remove it.
    visitedRawTextNodes.add(node);

    for (int i = 0; i < content.length(); i += 1) {
      consumeCharacter(node, content.charAt(i));
    }

    switch (getState()) {
      case TAG_NAME:
        /*
         * Force the end of a tag in the case we have something like:
         * <div{if $foo}...{/if} ...>
         */
        consumeCharacter(node, ' ');
        break;
      case PCDATA:
        createTextNode(node);
        break;
      case ATTR_VALUE:
        /*
         * Reached the end of a RawTextNode with some text, for example from:
         *
         *   <div foo="bar {if $condition}...{/if}">
         *
         * Take the text up until the end of the RawTextNode, "bar ", and add it to the attribute
         * values.
         */
        createAttributeValueNode(node);
        break;
      default:
        break;
    }
  }

  /**
   * Checks to see if a given {@link SoyNode} is valid in the current context, reporting an error if
   * it is not.
   */
  private void checkForValidSoyNodeLocation(SoyNode node) {
    switch (getState()) {
      case ATTRIBUTE_NAME:
        errorReporter.report(node.getSourceLocation(), SOY_TAG_IN_ATTR_NAME);
        break;
      case BEFORE_ATTRIBUTE_VALUE:
        errorReporter.report(node.getSourceLocation(), SOY_TAG_BEFORE_ATTR_VALUE);
        break;
      default:
        break;
    }
  }

  /**
   * Visits a {@link PrintNode}, wrapping it with an HtmlPrintNode node if it occurs in {@link
   * HtmlState#TAG} or {@link HtmlState#PCDATA}. This allows the code generator to handle those
   * print statements separately and know the state in which they occurred. If the {@link PrintNode}
   * occurs in {@link HtmlState#ATTR_VALUE}, then the print node becomes a part of the current
   * attribute's value.
   */
  @Override
  protected void visitPrintNode(PrintNode node) {
    checkForValidSoyNodeLocation(node);

    HtmlPrintNode htmlPrintNode;
    switch (getState()) {
      case ATTR_VALUE:
        // A PrintNode in an attribute value, add it to the current attribute values, which will get
        // added to the attribute node once the attribute value ends.
        currentAttributeValues.add(node);
        node.getParent().removeChild(node);
        break;
      case TAG:
        // A PrintNode in the tag context - this is a print of something that has kind="attributes",
        // keep track of the context using an HtmlPrintNode so that the code generator knows what.
        // to do with it.
        htmlPrintNode =
            new HtmlPrintNode(
                idGen.genId(), node, HtmlPrintNode.Context.HTML_TAG, node.getSourceLocation());
        node.getParent().replaceChild(node, htmlPrintNode);
        break;
      case PCDATA:
        // A PrintNode in the pcdata context - this is a print of something that is the child of
        // an HTML element. This could be html, text, css, etc., just need to keep track of the
        // the context and the code generator will do the right thing with each type.
        htmlPrintNode =
            new HtmlPrintNode(
                idGen.genId(), node, HtmlPrintNode.Context.HTML_PCDATA, node.getSourceLocation());
        node.getParent().replaceChild(node, htmlPrintNode);
        break;
      default:
        break;
    }
  }

  /**
   * Visit {@link LetContentNode}s and {@link CallParamContentNode}s, transforming the {@link
   * RawTextNode}s inside to The corresponding Html* nodes.
   *
   * <ul>
   *   <li>For {@link ContentKind#HTML}, it simply visits the children and does the normal
   *       transformation.
   *   <li>For {@link ContentKind#ATTRIBUTES}, it transforms the children as if they were in the
   *       attribute declaration portion of an HTML tag.
   *   <li>All other kinds {@link ContentKind}s are ignored by this visitor, leaving content within
   *       things like kind="text" alone.
   * </ul>
   *
   * @param node A {@link LetContentNode}or {@link CallParamContentNode}
   */
  private void visitLetParamContentNode(RenderUnitNode node) {
    checkForValidSoyNodeLocation(node);

    if (node.getContentKind() == null) {
      errorReporter.report(node.getSourceLocation(), UNKNOWN_CONTENT_KIND);
    } else if (node.getContentKind() == ContentKind.HTML) {
      visitSoyNode(node, true);
    } else if (node.getContentKind() == ContentKind.ATTRIBUTES) {
      HtmlState startState = getState();
      setState(HtmlState.TAG);
      visitChildren(node);
      setState(startState);
    }
  }

  @Override
  protected void visitLetContentNode(LetContentNode node) {
    visitLetParamContentNode(node);
  }

  @Override
  protected void visitCallParamContentNode(CallParamContentNode node) {
    visitLetParamContentNode(node);
  }

  /** Visits a {@link SoyFileNode}, making sure it has strict autoescape. */
  @Override
  protected void visitSoyFileNode(SoyFileNode node) {
    if (node.getDefaultAutoescapeMode() != AutoescapeMode.STRICT) {
      errorReporter.report(node.getSourceLocation(), NON_STRICT_FILE);
    }

    visitChildren(node);
  }

  /** Visits a {@link SoyFileNode}, getting its id generator. */
  @Override
  protected void visitSoyFileSetNode(SoyFileSetNode node) {
    idGen = node.getNodeIdGenerator();

    visitChildren(node);
  }

  /**
   * Visits a {@link TemplateNode}, processing those that have kind html or attributes and making
   * sure that the autoescape mode is strict.
   */
  @Override
  protected void visitTemplateNode(TemplateNode node) {
    if (node.getContentKind() != ContentKind.HTML
        && node.getContentKind() != ContentKind.ATTRIBUTES) {
      return;
    }

    if (node.getAutoescapeMode() != AutoescapeMode.STRICT) {
      errorReporter.report(node.getSourceLocation(), NON_STRICT_TEMPLATE);
    }

    visitSoyNode(node, true);
  }

  /**
   * Visits a {@link CallNode} - makes sure that the node does not occur within an attribute state
   * (e.g. after {@code <div} and before {@code >}).
   */
  @Override
  protected void visitCallNode(CallNode node) {
    checkForValidSoyNodeLocation(node);

    if (getState().isAttributeState()) {
      errorReporter.report(node.getSourceLocation(), TEMPLATE_CALL_IN_TAG);
    }

    visitChildren(node);
  }

  @Override
  protected void visitIfCondNode(IfCondNode node) {
    visitSoyNode(node, true);
  }

  @Override
  protected void visitIfElseNode(IfElseNode node) {
    visitSoyNode(node, true);
  }

  @Override
  protected void visitSwitchCaseNode(SwitchCaseNode node) {
    visitSoyNode(node, true);
  }

  @Override
  protected void visitSwitchDefaultNode(SwitchDefaultNode node) {
    visitSoyNode(node, true);
  }

  @Override
  protected void visitLoopNode(LoopNode node) {
    visitSoyNode(node, true);
  }

  private void visitSoyNode(SoyNode node, boolean enforceState) {
    switch (getState()) {
      case ATTRIBUTE_NAME:
        errorReporter.report(node.getSourceLocation(), SOY_TAG_IN_ATTR_NAME);
        break;
      case BEFORE_ATTRIBUTE_VALUE:
        errorReporter.report(node.getSourceLocation(), SOY_TAG_BEFORE_ATTR_VALUE);
        break;
      case ATTR_VALUE:
        if (node instanceof StandaloneNode) {
          StandaloneNode standaloneNode = (StandaloneNode) node;
          standaloneNode.getParent().removeChild(standaloneNode);
          currentAttributeValues.add(standaloneNode);
        }
        break;
      case TAG:
      case PCDATA:
        if (node instanceof ParentSoyNode<?>) {
          HtmlState startState = getState();
          visitChildrenAllowingConcurrentModification((ParentSoyNode<?>) node);
          HtmlState endState = getState();

          if (enforceState && startState != endState) {
            errorReporter.report(
                node.getSourceLocation(), ENDING_STATE_MISMATCH, startState, endState);
          }

          consumeText(false);
        }
        break;
      default:
        break;
    }
  }

  // -----------------------------------------------------------------------------------------------
  // Fallback implementation.

  @Override
  protected void visitSoyNode(SoyNode node) {
    visitSoyNode(node, false);
  }
}