All Downloads are FREE. Search and download functionalities are using the official Maven repository.

freemarker.core.JSONParser Maven / Gradle / Ivy

There is a newer version: 7.0.58
Show newest version
/*
 * 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.
 *
 * 

This parser allows certain things that are errors in pure JSON: *

    *
  • JavaScript comments are supported
  • *
  • Non-breaking space (nbsp) and BOM are treated as whitespace
  • *
*/ 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 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(); } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy