/* * 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.soytree; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkElementIndex; import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; import com.google.template.soy.base.SourceLocation; import com.google.template.soy.base.SourceLocation.Point; import com.google.template.soy.basetree.CopyState; import com.google.template.soy.soytree.SoyNode.StandaloneNode; import java.util.Arrays; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; /** * Node representing a contiguous raw text section. * * <p>Important: Do not use outside of Soy code (treat as superpackage-private). * */ public final class RawTextNode extends AbstractSoyNode implements StandaloneNode { /** The special chars we need to re-escape for toSourceString(). */ private static final Pattern SPECIAL_CHARS_TO_ESCAPE = Pattern.compile("[\n\r\t{}]"); /** Map from special char to be re-escaped to its special char tag (for toSourceString()). */ private static final ImmutableMap<String, String> SPECIAL_CHAR_TO_TAG = ImmutableMap.<String, String>builder() .put("\n", "{\\n}") .put("\r", "{\\r}") .put("\t", "{\\t}") .put("{", "{lb}") .put("}", "{rb}") .build(); /** The raw text string (after processing of special chars and literal blocks). */ private final String rawText; @Nullable private final SourceOffsets offsets; @Nullable private HtmlContext htmlContext; /** * @param id The id for this node. * @param rawText The raw text string. * @param sourceLocation The node's source location. */ public RawTextNode(int id, String rawText, SourceLocation sourceLocation) { this(id, rawText, sourceLocation, SourceOffsets.fromLocation(sourceLocation, rawText.length())); } public RawTextNode( int id, String rawText, SourceLocation sourceLocation, HtmlContext htmlContext) { super(id, sourceLocation); this.rawText = checkNotNull(rawText); this.htmlContext = htmlContext; this.offsets = SourceOffsets.fromLocation(sourceLocation, rawText.length()); } public RawTextNode(int id, String rawText, SourceLocation sourceLocation, SourceOffsets offsets) { super(id, sourceLocation); this.rawText = checkNotNull(rawText); this.offsets = offsets; } /** * Copy constructor. * * @param orig The node to copy. */ private RawTextNode(RawTextNode orig, CopyState copyState) { super(orig, copyState); this.rawText = orig.rawText; this.htmlContext = orig.htmlContext; this.offsets = orig.offsets; } /** * Gets the HTML source context (typically tag, attribute value, HTML PCDATA, or plain text) which * this node emits in. This is used for incremental DOM codegen. */ public HtmlContext getHtmlContext() { return Preconditions.checkNotNull( htmlContext, "Cannot access HtmlContext before HtmlTransformVisitor"); } @Override public Kind getKind() { return Kind.RAW_TEXT_NODE; } public void setHtmlContext(HtmlContext value) { this.htmlContext = value; } /** Returns the raw text string (after processing of special chars and literal blocks). */ public String getRawText() { return rawText; } public boolean isEmpty() { return rawText.isEmpty(); } /** * Returns true if there was whitespace deleted immediately prior to {@code index} * * @param index the index in the raw text, this value should be in the range {@code [0, * rawText.length()]} if {@code rawText.length()} is passed, then this is equivalent to asking * if there is trailing whitespace missing. * @throws IndexOutOfBoundsException if index is out of range. * @return {@code true} if whitespace was dropped */ public boolean missingWhitespaceAt(int index) { return offsets == null ? false : offsets.getReasonAt(index) == SourceOffsets.Reason.WHITESPACE; } public Point locationOf(int i) { checkElementIndex(i, rawText.length(), "index"); if (offsets == null) { return Point.UNKNOWN_POINT; } return offsets.getPoint(rawText, i); } /** Returns the source location of the given substring. */ public SourceLocation substringLocation(int start, int end) { checkElementIndex(start, rawText.length(), "start"); checkArgument(start < end); checkArgument(end <= rawText.length()); if (offsets == null) { return getSourceLocation(); } return new SourceLocation( getSourceLocation().getFilePath(), offsets.getPoint(rawText, start), // source locations are inclusive, the end locations should point at the last character // in the string, whereas substring is usually specified exclusively, so subtract 1 to make // up the difference offsets.getPoint(rawText, end - 1)); } /** * Returns a new RawTextNode that represents the given {@link String#substring(int, int)} of this * raw text node. * * <p>Unlike {@link String#substring(int, int)} the range must be non-empty * * @param newId the new node id to use * @param start the start location * @param end the end location */ public RawTextNode substring(int newId, int start, int end) { checkArgument(start >= 0); checkArgument(start < end); checkArgument(end <= rawText.length()); if (start == 0 && end == rawText.length()) { return this; } String newText = rawText.substring(start, end); SourceOffsets newOffsets = null; SourceLocation newLocation = getSourceLocation(); if (offsets != null) { newOffsets = offsets.substring(start, end, rawText); newLocation = newOffsets.getSourceLocation(getSourceLocation().getFilePath()); } return new RawTextNode(newId, newText, newLocation, newOffsets); } /** * Concatenates this RawTextNode with the given node (like {@link String#concat}), preserving * source location information. */ public RawTextNode concat(int newId, RawTextNode node) { checkNotNull(node); String newText = rawText.concat(node.getRawText()); SourceOffsets newOffsets = null; SourceLocation newLocation = getSourceLocation().extend(node.getSourceLocation()); if (offsets != null && node.offsets != null) { newOffsets = offsets.concat(node.offsets); } return new RawTextNode(newId, newText, newLocation, newOffsets); } @Override public String toSourceString() { StringBuffer sb = new StringBuffer(); // Must escape special chars to create valid source text. Matcher matcher = SPECIAL_CHARS_TO_ESCAPE.matcher(rawText); while (matcher.find()) { String specialCharTag = SPECIAL_CHAR_TO_TAG.get(matcher.group()); matcher.appendReplacement(sb, Matcher.quoteReplacement(specialCharTag)); } matcher.appendTail(sb); return sb.toString(); } @SuppressWarnings("unchecked") @Override public ParentSoyNode<StandaloneNode> getParent() { return (ParentSoyNode<StandaloneNode>) super.getParent(); } @Override public RawTextNode copy(CopyState copyState) { return new RawTextNode(this, copyState); } /** * A helper object to calculate source location offsets inside of RawTextNodes. * * <p>Due to how Soy collapses whitespace and uses non-literal tokens for textual content (e.g. * {@code literal} commands and formatting commands like {@code {\n}}). It isn't possible to * reconstruct the source location of any given character within a sequence of raw text based * purely on start/end locations. This class fulfils the gap by tracking offsets where the * sourcelocation changes discontinuously. */ public static final class SourceOffsets { /** Records the reason there is an offset at a particular location. */ public enum Reason { /** There is an offset because of a textual command like <code>{sp}</code>. */ COMMAND, /** There is an offset because of a <code>{literal}</code> block. */ LITERAL, /** There is an offset because of a comment. */ COMMENT, /** There is an offset because we performed whitespace joining. */ WHITESPACE, /** * There is no offset. This will happen for initial points and final points and possibly * others due to {@link #concat} because we performed whitespace joining. */ NONE; } @Nullable static SourceOffsets fromLocation(SourceLocation location, int length) { if (!location.isKnown()) { // this is lame but a lot of tests construct 'unknown' rawtextnodes return null; } Builder builder = new Builder(); if (length > 0) { builder.add(0, location.getBeginLine(), location.getBeginColumn(), Reason.NONE); } return builder .setEndLocation(location.getEndLine(), location.getEndColumn()) .build(length, Reason.NONE); } // These arrays are parallel. /** The indexes into the raw text. */ private final int[] indexes; /** The source column associated with the corresponding index in indexes. */ private final int[] columns; /** The source line numbers associated with the corresponding index in indexes. */ private final int[] lines; /** Records the reason why there is a discontinuity in the line numbers at this offset. */ private final Reason[] reasons; private SourceOffsets(int[] indexes, int[] lines, int[] columns, Reason[] reasons) { this.indexes = checkNotNull(indexes); int prev = -1; for (int index : indexes) { if (index <= prev) { throw new IllegalArgumentException( "expected indexes to be monotonically increasing, got: " + Arrays.toString(indexes)); } prev = index; } this.lines = checkNotNull(lines); this.columns = checkNotNull(columns); this.reasons = checkNotNull(reasons); } /** Returns the {@link Point} of the given offset within the given text. */ Point getPoint(String text, int textIndex) { // the returned location is the place in the array where index would be inserted, so in // practice it is pointing at the smallest item in the array >= index. int location = Arrays.binarySearch(indexes, textIndex); // if 'textIndex' isn't in the list it returns (-insertion_point - 1) so if we want to know // the insertion point we need to do this transformation if (location < 0) { location = -(location + 1); } if (indexes[location] == textIndex) { // direct hit! return Point.create(lines[location], columns[location]); } // if it isn't a direct hit, we start at the previous item and walk forward through the array // counting character and newlines. return getLocationOf(text, location - 1, textIndex); } /** Returns the reason for a location discontinuity at the given index in the text. */ Reason getReasonAt(int index) { checkElementIndex(index, indexes[indexes.length - 1] + 1); int location = Arrays.binarySearch(indexes, index); // if 'index' isn't in the list it returns (-insertion_point - 1) in which case we know the // reason is NONE if (location < 0) { return Reason.NONE; } return reasons[location]; } /** * Returns the Point where the character at {@code textIndex} is within the text. The scan * starts from {@code lines[startLocation]} which is guaranteed to be < textIndex. */ private Point getLocationOf(String text, int startLocation, int textIndex) { int line = lines[startLocation]; int column = columns[startLocation]; int start = indexes[startLocation]; for (int i = start; i < textIndex; i++) { char c = text.charAt(i); if (c == '\n') { line++; // N.B. we use 1 based indexes for columns (and lines, though that isn't relevant here) column = 1; } else if (c == '\r') { // look for \n as the next char to handled both \r and \r\n if (i + 1 < text.length() && text.charAt(i + 1) == '\n') { i++; } line++; column = 1; } else { column++; } } return Point.create(line, column); } /** Returns a new SourceOffsets object for the given subrange of the text. */ SourceOffsets substring(int startTextIndex, int endTextIndex, String text) { checkArgument(startTextIndex >= 0); checkArgument(startTextIndex < endTextIndex); checkArgument(endTextIndex <= text.length()); int substringLength = endTextIndex - startTextIndex; // subtract 1 from end since we want the endLocation to point at the last character rather // than just beyond it. endTextIndex--; int startLocation = Arrays.binarySearch(indexes, startTextIndex); // if 'startLocation' isn't in the list it returns (-insertion_point -1) so if we want to know // the insertion point we need to do this transformation if (startLocation < 0) { startLocation = -(startLocation + 1); } // calculate the initial point SourceOffsets.Builder builder = new SourceOffsets.Builder(); int startLine; int startColumn; Reason startReason; // if the index of the startlocation is the start index, set the startLine and startColumn // appropriately if (indexes[startLocation] == startTextIndex) { startLine = lines[startLocation]; startColumn = columns[startLocation]; startReason = reasons[startLocation]; } else { // otherwise scan from the previous location forward to 'start' startLocation--; Point startPoint = getLocationOf(text, startLocation, startTextIndex); startLine = startPoint.line(); startColumn = startPoint.column(); startReason = Reason.NONE; } builder.doAdd(0, startLine, startColumn, startReason); if (startTextIndex == endTextIndex) { // special case builder.setEndLocation(startLine, startColumn); return builder.build(substringLength, startReason); } // copy over all offsets, taking care to modify the indexes int i = startLocation + 1; Reason endReason = Reason.NONE; while (true) { int index = indexes[i]; if (index < endTextIndex) { builder.doAdd(index - startTextIndex, lines[i], columns[i], reasons[i]); } else if (index == endTextIndex) { builder.setEndLocation(lines[i], columns[i]); endReason = reasons[i]; break; } else if (index > endTextIndex) { // to find the end location we need to scan from the previous index Point endPoint = getLocationOf(text, i - 1, endTextIndex); builder.setEndLocation(endPoint.line(), endPoint.column()); break; } i++; } return builder.build(substringLength, endReason); } /** Concatenates this SourceOffsets array with {@code other}. */ SourceOffsets concat(SourceOffsets other) { // To concatenate we need to approximately just cat the arrays // since the last slot in the array corresponds to a psuedo location (the end of the string) // we should drop it when splicing. // we also need to modify all the indexes in other to be offset by the length of this offset's // string. int sizeToPreserve = indexes.length - 1; int lengthOfThis = indexes[indexes.length - 1]; int newSize = sizeToPreserve + other.indexes.length; int[] newIndexes = Arrays.copyOf(indexes, newSize); int[] newLines = Arrays.copyOf(lines, newSize); int[] newColumns = Arrays.copyOf(columns, newSize); Reason[] newReasons = Arrays.copyOf(reasons, newSize); System.arraycopy(other.lines, 0, newLines, sizeToPreserve, other.lines.length); System.arraycopy(other.columns, 0, newColumns, sizeToPreserve, other.columns.length); System.arraycopy(other.reasons, 0, newReasons, sizeToPreserve, other.reasons.length); // do some special handling for the 'reason' of the join point. // If the new begin reason is NONE, then use the old end reason. Reason newBeginReason = other.reasons[0]; if (newBeginReason == Reason.NONE) { newReasons[sizeToPreserve] = reasons[sizeToPreserve]; } // manually copy the indexes over so we can apply the offset for (int i = 0; i < other.indexes.length; i++) { newIndexes[i + sizeToPreserve] = other.indexes[i] + lengthOfThis; } return new SourceOffsets(newIndexes, newLines, newColumns, newReasons); } /** Returns the sourcelocation for the whole span. */ public SourceLocation getSourceLocation(String filePath) { return new SourceLocation( filePath, lines[0], columns[0], lines[lines.length - 1], columns[columns.length - 1]); } @Override public String toString() { return String.format( "SourceOffsets{\n index:\t%s\n lines:\t%s\n cols:\t%s\n}", Arrays.toString(indexes), Arrays.toString(lines), Arrays.toString(columns)); } /** Builder for SourceOffsets. */ public static final class Builder { private int size; private int[] indexes = new int[16]; private int[] lines = new int[16]; private int[] columns = new int[16]; private Reason[] reasons = new Reason[16]; private int endLine = -1; private int endCol = -1; public Builder add(int index, int startLine, int startCol, Reason reason) { checkArgument(index >= 0, "expected index to be non-negative: %s", index); checkArgument(startLine > 0, "expected startLine to be positive: %s", startLine); checkArgument(startCol > 0, "expected startCol to be positive: %s", startCol); if (size != 0 && index <= indexes[size - 1]) { throw new IllegalArgumentException( String.format( "expected indexes to be added in increasing order: %d vs %d at %d:%d - %d:%d", index, indexes[size - 1], startLine, startCol, endLine, endCol)); } doAdd(index, startLine, startCol, reason); return this; } /** Update the end location only. */ public Builder setEndLocation(int endLine, int endCol) { checkArgument(endLine > 0, "expected endLine to be positive: %s", endLine); checkArgument(endCol > 0, "expected endCol to be positive: %s", endCol); this.endLine = endLine; this.endCol = endCol; return this; } /** Delete all the offsets starting from the {@code from} index. */ public Builder delete(int from) { // since we store end indexes in the list, we really just want to delete everything stricly // after 'from', this way if we leave 'from' as an end point int location = Arrays.binarySearch(indexes, 0, size, from); // if 'from' isn't in the list it returns (-insertion_point -1) so if we want to know the // insertion point we need to do this transformation if (location < 0) { location = -(location + 1); } size = location; return this; } public boolean isEmpty() { return size == 0; } /** Returns the ending line number or {@code -1} if it hasn't been set yet. */ public int endLine() { return endLine; } /** Returns the ending column number or {@code -1} if it hasn't been set yet. */ public int endColumn() { return endCol; } private void doAdd(int index, int line, int col, Reason reason) { if (size == indexes.length) { // expand by 1.5x each time int newCapacity = size + (size >> 1); indexes = Arrays.copyOf(indexes, newCapacity); lines = Arrays.copyOf(lines, newCapacity); columns = Arrays.copyOf(columns, newCapacity); reasons = Arrays.copyOf(reasons, newCapacity); } indexes[size] = index; lines[size] = line; columns[size] = col; reasons[size] = reason; size++; } /** * Builds the {@link SourceOffsets}. * * @param length the final length of the text. */ public SourceOffsets build(int length, Reason reason) { // Set the last index as the length of the string and put the endLine/endCol there. // This simplifies some of the logic in SourceOffsets since it allows us to avoid // considering the end of the string as a special case. doAdd(length, endLine, endCol, reason); checkArgument(size > 0, "The builder should be non-empty"); checkArgument(indexes[0] == 0, "expected first index to be zero, got: %s", indexes[0]); SourceOffsets built = new SourceOffsets( Arrays.copyOf(indexes, size), Arrays.copyOf(lines, size), Arrays.copyOf(columns, size), Arrays.copyOf(reasons, size)); // by resetting size by 1 we undo the 'doAdd' of the endLine and endCol above and thus this // method becomes safe to call multiple times. size--; return built; } } } }