blob: 61fc4a9ddcc549797127d24e715e5e72185bb374 [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 freemarker.core;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import freemarker.template.SimpleHash;
import freemarker.template.SimpleNumber;
import freemarker.template.SimpleScalar;
import freemarker.template.SimpleSequence;
import freemarker.template.TemplateBooleanModel;
import freemarker.template.TemplateHashModelEx2;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNumberModel;
import freemarker.template.TemplateScalarModel;
import freemarker.template.TemplateSequenceModel;
import freemarker.template._ObjectWrappers;
import freemarker.template.utility.Constants;
import freemarker.template.utility.NumberUtil;
import freemarker.template.utility.StringUtil;
/**
* JSON parser that returns a {@link TameplatModel}, similar to what FTL literals product (and so, what
* @code ?eval} would return). A notable difference compared to the result FTL literals is that this doesn't use the
* {@link ParserConfiguration#getArithmeticEngine()} to parse numbers, as JSON has its own fixed number syntax. For
* numbers this parser returns {@link SimpleNumberModel}-s, where the wrapped numbers will be {@link Integer}-s when
* they fit into that, otherwise they will be {@link Long}-s if they fit into that, otherwise they will be
* {@link BigDecimal}-s. Another difference to the result of FTL literals is that instead of
* {@code HashLiteral.SequenceHash} it uses {@link SimpleHash} with {@link LinkedHashMap} as backing store, for
* efficiency.
*
* <p>This parser allows certain things that are errors in pure JSON:
* <ul>
* <li>JavaScript comments are supported</li>
* <li>Non-breaking space (nbsp) and BOM are treated as whitespace</li>
* </ul>
*/
class JSONParser {
private static final String UNCLOSED_OBJECT_MESSAGE
= "This {...} was still unclosed when the end of the file was reached. (Look for a missing \"}\")";
private static final String UNCLOSED_ARRAY_MESSAGE
= "This [...] was still unclosed when the end of the file was reached. (Look for a missing \"]\")";
private static final BigDecimal MIN_INT_AS_BIGDECIMAL = BigDecimal.valueOf(Integer.MIN_VALUE);
private static final BigDecimal MAX_INT_AS_BIGDECIMAL = BigDecimal.valueOf(Integer.MAX_VALUE);
private static final BigDecimal MIN_LONG_AS_BIGDECIMAL = BigDecimal.valueOf(Long.MIN_VALUE);
private static final BigDecimal MAX_LONG_AS_BIGDECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
private final String src;
private final int ln;
private int p;
public static TemplateModel parse(String src) throws JSONParseException {
return new JSONParser(src).parse();
}
/**
* @param sourceLocation Only used in error messages, maybe {@code null}.
*/
private JSONParser(String src) {
this.src = src;
this.ln = src.length();
}
private TemplateModel parse() throws JSONParseException {
skipWS();
TemplateModel result = consumeValue("Empty JSON (contains no value)", p);
skipWS();
if (p != ln) {
throw newParseException("End-of-file was expected but found further non-whitespace characters.");
}
return result;
}
private TemplateModel consumeValue(String eofErrorMessage, int eofBlamePosition) throws JSONParseException {
if (p == ln) {
throw newParseException(
eofErrorMessage == null
? "A value was expected here, but end-of-file was reached." : eofErrorMessage,
eofBlamePosition == -1 ? p : eofBlamePosition);
}
TemplateModel result;
result = tryConsumeString();
if (result != null) return result;
result = tryConsumeNumber();
if (result != null) return result;
result = tryConsumeObject();
if (result != null) return result;
result = tryConsumeArray();
if (result != null) return result;
result = tryConsumeTrueFalseNull();
if (result != null) return result != TemplateNullModel.INSTANCE ? result : null;
// Better error message for a frequent mistake:
if (p < ln && src.charAt(p) == '\'') {
throw newParseException("Unexpected apostrophe-quote character. "
+ "JSON strings must be quoted with quotation mark.");
}
throw newParseException(
"Expected either the beginning of a (negative) number or the beginning of one of these: "
+ "{...}, [...], \"...\", true, false, null. Found character " + StringUtil.jQuote(src.charAt(p))
+ " instead.");
}
private TemplateModel tryConsumeTrueFalseNull() throws JSONParseException {
int startP = p;
if (p < ln && isIdentifierStart(src.charAt(p))) {
p++;
while (p < ln && isIdentifierPart(src.charAt(p))) {
p++;
}
}
if (startP == p) return null;
String keyword = src.substring(startP, p);
if (keyword.equals("true")) {
return TemplateBooleanModel.TRUE;
} else if (keyword.equals("false")) {
return TemplateBooleanModel.FALSE;
} else if (keyword.equals("null")) {
return TemplateNullModel.INSTANCE;
}
throw newParseException(
"Invalid JSON keyword: " + StringUtil.jQuote(keyword)
+ ". Should be one of: true, false, null. "
+ "If it meant to be a string then it must be quoted.", startP);
}
private TemplateNumberModel tryConsumeNumber() throws JSONParseException {
if (p >= ln) {
return null;
}
char c = src.charAt(p);
boolean negative = c == '-';
if (!(negative || isDigit(c) || c == '.')) {
return null;
}
int startP = p;
if (negative) {
if (p + 1 >= ln) {
throw newParseException("Expected a digit after \"-\", but reached end-of-file.");
}
char lookAheadC = src.charAt(p + 1);
if (!(isDigit(lookAheadC) || lookAheadC == '.')) {
return null;
}
p++; // Consume "-" only, not the digit
}
long longSum = 0;
boolean firstDigit = true;
consumeLongFittingHead: do {
c = src.charAt(p);
if (!isDigit(c)) {
if (c == '.' && firstDigit) {
throw newParseException("JSON doesn't allow numbers starting with \".\".");
}
break consumeLongFittingHead;
}
int digit = c - '0';
if (longSum == 0) {
if (!firstDigit) {
throw newParseException("JSON doesn't allow superfluous leading 0-s.", p - 1);
}
longSum = !negative ? digit : -digit;
p++;
} else {
long prevLongSum = longSum;
longSum = longSum * 10 + (!negative ? digit : -digit);
if (!negative && prevLongSum > longSum || negative && prevLongSum < longSum) {
// We had an overflow => Can't consume this digit as long-fitting
break consumeLongFittingHead;
}
p++;
}
firstDigit = false;
} while (p < ln);
if (p < ln && isBigDecimalFittingTailCharacter(c)) {
char lastC = c;
p++;
consumeBigDecimalFittingTail: while (p < ln) {
c = src.charAt(p);
if (isBigDecimalFittingTailCharacter(c)) {
p++;
} else if ((c == '+' || c == '-') && isE(lastC)) {
p++;
} else {
break consumeBigDecimalFittingTail;
}
lastC = c;
}
String numStr = src.substring(startP, p);
BigDecimal bd;
try {
bd = new BigDecimal(numStr);
} catch (NumberFormatException e) {
throw new JSONParseException("Malformed number: " + numStr, src, startP, e);
}
if (bd.compareTo(MIN_INT_AS_BIGDECIMAL) >= 0 && bd.compareTo(MAX_INT_AS_BIGDECIMAL) <= 0) {
if (NumberUtil.isIntegerBigDecimal(bd)) {
return new SimpleNumber(bd.intValue());
}
} else if (bd.compareTo(MIN_LONG_AS_BIGDECIMAL) >= 0 && bd.compareTo(MAX_LONG_AS_BIGDECIMAL) <= 0) {
if (NumberUtil.isIntegerBigDecimal(bd)) {
return new SimpleNumber(bd.longValue());
}
}
return new SimpleNumber(bd);
} else {
return new SimpleNumber(
longSum <= Integer.MAX_VALUE && longSum >= Integer.MIN_VALUE
? (Number) (int) longSum
: longSum);
}
}
private TemplateScalarModel tryConsumeString() throws JSONParseException {
int startP = p;
if (!tryConsumeChar('"')) return null;
StringBuilder sb = new StringBuilder();
char c = 0;
while (p < ln) {
c = src.charAt(p);
if (c == '"') {
p++;
return new SimpleScalar(sb.toString()); // Call normally returns here!
} else if (c == '\\') {
p++;
sb.append(consumeAfterBackslash());
} else if (c <= 0x1F) {
throw newParseException("JSON doesn't allow unescaped control characters in string literals, "
+ "but found character with code (decimal): " + (int) c);
} else {
p++;
sb.append(c);
}
}
throw newParseException("String literal was still unclosed when the end of the file was reached. "
+ "(Look for missing or accidentally escaped closing quotation mark.)", startP);
}
private TemplateSequenceModel tryConsumeArray() throws JSONParseException {
int startP = p;
if (!tryConsumeChar('[')) return null;
skipWS();
if (tryConsumeChar(']')) return Constants.EMPTY_SEQUENCE;
boolean afterComma = false;
SimpleSequence elements = new SimpleSequence(_ObjectWrappers.SAFE_OBJECT_WRAPPER);
do {
skipWS();
elements.add(consumeValue(afterComma ? null : UNCLOSED_ARRAY_MESSAGE, afterComma ? -1 : startP));
skipWS();
afterComma = true;
} while (consumeChar(',', ']', UNCLOSED_ARRAY_MESSAGE, startP) == ',');
return elements;
}
private TemplateHashModelEx2 tryConsumeObject() throws JSONParseException {
int startP = p;
if (!tryConsumeChar('{')) return null;
skipWS();
if (tryConsumeChar('}')) return Constants.EMPTY_HASH_EX2;
boolean afterComma = false;
Map<String, Object> map = new LinkedHashMap<>(); // Must keeps original order!
do {
skipWS();
int keyStartP = p;
Object key = consumeValue(afterComma ? null : UNCLOSED_OBJECT_MESSAGE, afterComma ? -1 : startP);
if (!(key instanceof TemplateScalarModel)) {
throw newParseException("Wrong key type. JSON only allows string keys inside {...}.", keyStartP);
}
String strKey = null;
try {
strKey = ((TemplateScalarModel) key).getAsString();
} catch (TemplateModelException e) {
throw new BugException(e);
}
skipWS();
consumeChar(':');
skipWS();
map.put(strKey, consumeValue(null, -1));
skipWS();
afterComma = true;
} while (consumeChar(',', '}', UNCLOSED_OBJECT_MESSAGE, startP) == ',');
return new SimpleHash(map, _ObjectWrappers.SAFE_OBJECT_WRAPPER, 0);
}
private boolean isE(char c) {
return c == 'e' || c == 'E';
}
private boolean isBigDecimalFittingTailCharacter(char c) {
return c == '.' || isE(c) || isDigit(c);
}
private char consumeAfterBackslash() throws JSONParseException {
if (p == ln) {
throw newParseException("Reached the end of the file, but the escape is unclosed.");
}
final char c = src.charAt(p);
switch (c) {
case '"':
case '\\':
case '/':
p++;
return c;
case 'b':
p++;
return '\b';
case 'f':
p++;
return '\f';
case 'n':
p++;
return '\n';
case 'r':
p++;
return '\r';
case 't':
p++;
return '\t';
case 'u':
p++;
return consumeAfterBackslashU();
}
throw newParseException("Unsupported escape: \\" + c);
}
private char consumeAfterBackslashU() throws JSONParseException {
if (p + 3 >= ln) {
throw newParseException("\\u must be followed by exactly 4 hexadecimal digits");
}
final String hex = src.substring(p, p + 4);
try {
char r = (char) Integer.parseInt(hex, 16);
p += 4;
return r;
} catch (NumberFormatException e) {
throw newParseException("\\u must be followed by exactly 4 hexadecimal digits, but was followed by "
+ StringUtil.jQuote(hex) + ".");
}
}
private boolean tryConsumeChar(char c) {
if (p < ln && src.charAt(p) == c) {
p++;
return true;
} else {
return false;
}
}
private void consumeChar(char expected) throws JSONParseException {
consumeChar(expected, (char) 0, null, -1);
}
private char consumeChar(char expected1, char expected2, String eofErrorHint, int eofErrorP) throws JSONParseException {
if (p >= ln) {
throw newParseException(eofErrorHint == null
? "Expected " + StringUtil.jQuote(expected1)
+ ( expected2 != 0 ? " or " + StringUtil.jQuote(expected2) : "")
+ " character, but reached end-of-file. "
: eofErrorHint,
eofErrorP == -1 ? p : eofErrorP);
}
char c = src.charAt(p);
if (c == expected1 || (expected2 != 0 && c == expected2)) {
p++;
return c;
}
throw newParseException("Expected " + StringUtil.jQuote(expected1)
+ ( expected2 != 0 ? " or " + StringUtil.jQuote(expected2) : "")
+ " character, but found " + StringUtil.jQuote(c) + " instead.");
}
private void skipWS() throws JSONParseException {
do {
while (p < ln && isWS(src.charAt(p))) {
p++;
}
} while (skipComment());
}
private boolean skipComment() throws JSONParseException {
if (p + 1 < ln) {
if (src.charAt(p) == '/') {
char c2 = src.charAt(p + 1);
if (c2 == '/') {
int eolP = p + 2;
while (eolP < ln && !isLineBreak(src.charAt(eolP))) {
eolP++;
}
p = eolP;
return true;
} else if (c2 == '*') {
int closerP = p + 3;
while (closerP < ln && !(src.charAt(closerP - 1) == '*' && src.charAt(closerP) == '/')) {
closerP++;
}
if (closerP >= ln) {
throw newParseException("Unclosed comment");
}
p = closerP + 1;
return true;
}
}
}
return false;
}
/**
* Whitespace as specified by JSON, plus non-breaking space (nbsp), and BOM.
*/
private static boolean isWS(char c) {
return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == 0xA0 || c == '\uFEFF';
}
private static boolean isLineBreak(char c) {
return c == '\r' || c == '\n';
}
private static boolean isIdentifierStart(char c) {
return Character.isLetter(c) || c == '_' || c == '$';
}
private static boolean isDigit(char c) {
return c >= '0' && c <= '9';
}
private static boolean isIdentifierPart(char c) {
return isIdentifierStart(c) || isDigit(c);
}
private JSONParseException newParseException(String message) {
return newParseException(message, p);
}
private JSONParseException newParseException(String message, int p) {
return new JSONParseException(message, src, p);
}
static class JSONParseException extends Exception {
public JSONParseException(String message, String src, int position) {
super(createSourceCodeErrorMessage(message, src, position));
}
public JSONParseException(String message, String src, int position,
Throwable cause) {
super(createSourceCodeErrorMessage(message, src, position), cause);
}
}
private static int MAX_QUOTATION_LENGTH = 50;
private static String createSourceCodeErrorMessage(String message, String srcCode, int position) {
int ln = srcCode.length();
if (position < 0) {
position = 0;
}
if (position >= ln) {
return message + "\n"
+ "Error location: At the end of text.";
}
int i;
char c;
int rowBegin = 0;
int rowEnd;
int row = 1;
char lastChar = 0;
for (i = 0; i <= position; i++) {
c = srcCode.charAt(i);
if (lastChar == 0xA) {
rowBegin = i;
row++;
} else if (lastChar == 0xD && c != 0xA) {
rowBegin = i;
row++;
}
lastChar = c;
}
for (i = position; i < ln; i++) {
c = srcCode.charAt(i);
if (c == 0xA || c == 0xD) {
if (c == 0xA && i > 0 && srcCode.charAt(i - 1) == 0xD) {
i--;
}
break;
}
}
rowEnd = i - 1;
if (position > rowEnd + 1) {
position = rowEnd + 1;
}
int col = position - rowBegin + 1;
if (rowBegin > rowEnd) {
return message + "\n"
+ "Error location: line "
+ row + ", column " + col + ":\n"
+ "(Can't show the line because it is empty.)";
}
String s1 = srcCode.substring(rowBegin, position);
String s2 = srcCode.substring(position, rowEnd + 1);
s1 = expandTabs(s1, 8);
int ln1 = s1.length();
s2 = expandTabs(s2, 8, ln1);
int ln2 = s2.length();
if (ln1 + ln2 > MAX_QUOTATION_LENGTH) {
int newLn2 = ln2 - ((ln1 + ln2) - MAX_QUOTATION_LENGTH);
if (newLn2 < 6) {
newLn2 = 6;
}
if (newLn2 < ln2) {
s2 = s2.substring(0, newLn2 - 3) + "...";
ln2 = newLn2;
}
if (ln1 + ln2 > MAX_QUOTATION_LENGTH) {
s1 = "..." + s1.substring((ln1 + ln2) - MAX_QUOTATION_LENGTH + 3);
}
}
StringBuilder res = new StringBuilder(message.length() + 80);
res.append(message);
res.append("\nError location: line ").append(row).append(", column ").append(col).append(":\n");
res.append(s1).append(s2).append("\n");
int x = s1.length();
while (x != 0) {
res.append(' ');
x--;
}
res.append('^');
return res.toString();
}
private static String expandTabs(String s, int tabWidth) {
return expandTabs(s, tabWidth, 0);
}
/**
* Replaces all tab-s with spaces in a single line.
*/
private static String expandTabs(String s, int tabWidth, int startCol) {
int e = s.indexOf('\t');
if (e == -1) {
return s;
}
int b = 0;
StringBuilder buf = new StringBuilder(s.length() + Math.max(16, tabWidth * 2));
do {
buf.append(s, b, e);
int col = buf.length() + startCol;
for (int i = tabWidth * (1 + col / tabWidth) - col; i > 0; i--) {
buf.append(' ');
}
b = e + 1;
e = s.indexOf('\t', b);
} while (e != -1);
buf.append(s, b, s.length());
return buf.toString();
}
}