This is an automated email from the ASF dual-hosted git repository. ddekany pushed a commit to branch 3 in repository https://gitbox.apache.org/repos/asf/freemarker.git
commit 18cfeb4aa85916d80d961b7c2fe15b1cb3bcaf00 Author: ddekany <[email protected]> AuthorDate: Tue Oct 13 21:56:31 2020 +0200 Forward ported from 2.3-gae: Added ?eval_json to evaluate JSON given as flat string. This was added as ?eval is routinely misused for the same purpose. --- .../freemarker/core/EvalJsonBuiltInTest.java | 36 ++ .../org/apache/freemarker/core/JSONParserTest.java | 170 ++++++ .../org/apache/freemarker/core/ASTExpBuiltIn.java | 4 +- .../freemarker/core/BuiltInsForStringsMisc.java | 38 +- .../org/apache/freemarker/core/JSONParser.java | 616 +++++++++++++++++++++ 5 files changed, 852 insertions(+), 12 deletions(-) diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/EvalJsonBuiltInTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/EvalJsonBuiltInTest.java new file mode 100644 index 0000000..0c733be --- /dev/null +++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/EvalJsonBuiltInTest.java @@ -0,0 +1,36 @@ +/* + * 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 org.apache.freemarker.core; + +import org.apache.freemarker.test.TemplateTest; +import org.junit.Test; + +public class EvalJsonBuiltInTest extends TemplateTest { + + @Test + public void test() throws Exception { + assertOutput("${'1'?evalJson}", "1"); + + assertOutput("${'null'?evalJson!'-'}", "-"); + + assertOutput("<#list '{\"a\": 1e2, \"b\": null}'?evalJson as k, v>${k}=${v!'NULL'}<#sep>, </#list>", "a=100, b=NULL"); + } + +} diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/JSONParserTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/JSONParserTest.java new file mode 100644 index 0000000..4336d2b --- /dev/null +++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/JSONParserTest.java @@ -0,0 +1,170 @@ +/* + * 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 org.apache.freemarker.core; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; + +import org.apache.freemarker.core.model.TemplateModel; +import org.apache.freemarker.core.util.BugException; +import org.apache.freemarker.core.util.DeepUnwrap; +import org.junit.Assert; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +public class JSONParserTest { + + @Test + public void testObjects() throws JSONParser.JSONParseException { + assertEquals(ImmutableMap.of("a", 1, "b", 2), JSONParser.parse("{\"a\": 1, \"b\": 2}")); + assertEquals(Collections.emptyMap(), JSONParser.parse("{}")); + try { + JSONParser.parse("{1: 1}"); + fail(); + } catch (JSONParser.JSONParseException e) { + assertThat(e.getMessage(), containsString("string key")); + } + } + + @Test + public void testLists() throws JSONParser.JSONParseException { + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("[1, 2]")); + assertEquals(Collections.emptyList(), JSONParser.parse("[]")); + } + + @Test + public void testStrings() throws JSONParser.JSONParseException { + assertEquals("", JSONParser.parse("\"\"")); + assertEquals(" ", JSONParser.parse("\" \"")); + assertEquals("'", JSONParser.parse("\"'\"")); + assertEquals("foo", JSONParser.parse("\"foo\"")); + assertEquals("\" \\ / \b \f \n \r \t \ufeff", + JSONParser.parse( + "\"" + + "\\\" \\\\ \\/ \\b \\f \\n \\r \\t \\uFEFF" + + "\"")); + } + + @Test + public void testNumbers() throws JSONParser.JSONParseException { + assertEquals(0, JSONParser.parse("0")); + assertEquals(123, JSONParser.parse("123")); + assertEquals(-123, JSONParser.parse("-123")); + assertNotEquals(123L, JSONParser.parse("123")); + assertEquals(2147483647, JSONParser.parse("2147483647")); + assertEquals(2147483648L, JSONParser.parse("2147483648")); + assertEquals(-2147483648, JSONParser.parse("-2147483648")); + assertEquals(-2147483649L, JSONParser.parse("-2147483649")); + assertEquals(-123, JSONParser.parse("-1.23E2")); + assertEquals(new BigDecimal("1.23"), JSONParser.parse("1.23")); + assertEquals(new BigDecimal("-1.23"), JSONParser.parse("-1.23")); + assertEquals(new BigDecimal("12.3"), JSONParser.parse("1.23E1")); + assertEquals(new BigDecimal("0.123"), JSONParser.parse("123E-3")); + } + + @Test + public void testKeywords() throws JSONParser.JSONParseException { + assertEquals(null, JSONParser.parse("null")); + assertEquals(true, JSONParser.parse("true")); + assertEquals(false, JSONParser.parse("false")); + try { + JSONParser.parse("NULL"); + fail(); + } catch (JSONParser.JSONParseException e) { + assertThat(e.getMessage(), containsString("quoted")); + } + } + + @Test + public void testBlockComments() throws JSONParser.JSONParseException { + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("/**/[/**/1/**/, /**/2/**/]/**/")); + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("/*x*/[/*x*/1/*x*/, /*x*/2/*x*/]/*x*/")); + assertEquals(ImmutableList.of(1), JSONParser.parse(" /*x*/ /**//**/ [ /*x*/ /*\n*//***/ 1 ]")); + try { + JSONParser.parse("/*"); + fail(); + } catch (JSONParser.JSONParseException e) { + assertThat(e.getMessage(), containsString("Unclosed comment")); + } + try { + JSONParser.parse("[/*]"); + fail(); + } catch (JSONParser.JSONParseException e) { + assertThat(e.getMessage(), containsString("Unclosed comment")); + } + } + + @Test + public void testLineComments() throws JSONParser.JSONParseException { + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("//c1\n[ //c2\n1, //c3\n 2//c5\n] //c4")); + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("// c1\n//\r// c2\r\n// c3\r\n[ 1, 2 ]//")); + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("[1, 2]\n//\n")); + } + + @Test + public void testWhitespace() throws JSONParser.JSONParseException { + assertEquals(ImmutableList.of(1, 2), JSONParser.parse(" [ 1 ,\n2 ] ")); + assertEquals(ImmutableList.of(1, 2), JSONParser.parse("\uFEFF[\u00A01\u00A0,2]")); + } + + @Test + public void testMixed() throws JSONParser.JSONParseException { + LinkedHashMap<String, Object> m = new LinkedHashMap<>(); + m.put("x", 1); + m.put("y", null); + assertEquals( + ImmutableList.of( + ImmutableMap.of("a", Collections.emptyMap()), + ImmutableMap.of("b", + Arrays.asList( + m, + true, + null + )) + ), + JSONParser.parse("" + + "[\n" + + "{\"a\":{}},\n" + + "{\"b\":\n" + + "[" + + "{\"x\":1, \"y\": null}," + + "true," + + "null" + + "] // comment\n" + + "}\n" + + "]")); + } + + private static void assertEquals(Object expected, TemplateModel actual) { + try { + Assert.assertEquals(expected, DeepUnwrap.unwrap(actual)); + } catch (TemplateException e) { + throw new BugException(e); + } + } + +} \ No newline at end of file diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java b/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java index 4102f3e..8500d6d 100644 --- a/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java +++ b/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java @@ -61,6 +61,7 @@ import org.apache.freemarker.core.BuiltInsForSequences.seq_index_ofBI; import org.apache.freemarker.core.BuiltInsForSequences.sortBI; import org.apache.freemarker.core.BuiltInsForSequences.sort_byBI; import org.apache.freemarker.core.BuiltInsForStringsMisc.evalBI; +import org.apache.freemarker.core.BuiltInsForStringsMisc.evalJsonBI; import org.apache.freemarker.core.model.TemplateCallableModel; import org.apache.freemarker.core.model.TemplateDateModel; import org.apache.freemarker.core.model.TemplateModelWithOriginName; @@ -75,7 +76,7 @@ abstract class ASTExpBuiltIn extends ASTExpression implements Cloneable { protected ASTExpression target; protected String key; - static final int NUMBER_OF_BIS = 271; + static final int NUMBER_OF_BIS = 272; static final HashMap<String, ASTExpBuiltIn> BUILT_INS_BY_NAME = new HashMap(NUMBER_OF_BIS * 3 / 2 + 1, 1f); static { @@ -104,6 +105,7 @@ abstract class ASTExpBuiltIn extends ASTExpression implements Cloneable { putBI("ensureStartsWith", new BuiltInsForStringsBasic.ensure_starts_withBI()); putBI("esc", new escBI()); putBI("eval", new evalBI()); + putBI("evalJson", new evalJsonBI()); putBI("first", new firstBI()); putBI("float", new floatBI()); putBI("floor", new floorBI()); diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsMisc.java b/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsMisc.java index d5bb4bf..3fcb4e2 100644 --- a/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsMisc.java +++ b/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsMisc.java @@ -72,14 +72,14 @@ class BuiltInsForStringsMisc { } static class evalBI extends OutputFormatBoundBuiltIn { - + private ParsingConfiguration pCfg; - + @Override protected TemplateModel calculateResult(Environment env) throws TemplateException { return calculateResult(BuiltInForString.getTargetString(target, env), env); } - + @Override void bindToOutputFormat(OutputFormat outputFormat, AutoEscapingPolicy autoEscapingPolicy) { super.bindToOutputFormat(outputFormat, autoEscapingPolicy); @@ -89,12 +89,12 @@ class BuiltInsForStringsMisc { pCfg = new FinalParsingConfiguration(pCfg, pCfg.getTemplateLanguage(), outputFormat, autoEscapingPolicy, template.getConfiguration()); } - this.pCfg = pCfg; + this.pCfg = pCfg; } TemplateModel calculateResult(String s, Environment env) throws TemplateException { Template parentTemplate = getTemplate(); - + ASTExpression exp; try { try { @@ -112,7 +112,7 @@ class BuiltInsForStringsMisc { parentTemplate, false, tkMan, pCfg, null); - + exp = parser.Expression(); } catch (TokenMgrError e) { throw e.toParseException(parentTemplate); @@ -136,21 +136,37 @@ class BuiltInsForStringsMisc { "\n\nThe failing expression:"); } } - + } - + + static class evalJsonBI extends BuiltInForString { + @Override + TemplateModel calculateResult(String s, Environment env) throws TemplateException { + try { + return JSONParser.parse(s); + } catch (JSONParser.JSONParseException e) { + throw new TemplateException(e, this, env, + "Failed to \"?", key, "\" string with this error:\n\n", + MessageUtils.EMBEDDED_MESSAGE_BEGIN, + new _DelayedGetMessage(e), + MessageUtils.EMBEDDED_MESSAGE_END, + "\n\nThe failing expression:"); + } + } + } + /** * A method that takes a parameter and evaluates it as a string, * then treats that string as template source code and returns a * transform model that evaluates the template in place. * The template inherits the configuration and environment of the executing - * template. By default, its name will be equal to + * template. By default, its name will be equal to * <tt>executingTemplate.getLookupName() + "$anonymous_interpreted"</tt>. You can * specify another parameter to the method call in which case the * template name suffix is the specified id instead of "anonymous_interpreted". */ static class interpretBI extends OutputFormatBoundBuiltIn { - + /** * Constructs a template on-the-fly and returns it embedded in a * {@link TemplateDirectiveModel}. @@ -372,5 +388,5 @@ class BuiltInsForStringsMisc { } } - + } diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/JSONParser.java b/freemarker-core/src/main/java/org/apache/freemarker/core/JSONParser.java new file mode 100644 index 0000000..5c5ae48 --- /dev/null +++ b/freemarker-core/src/main/java/org/apache/freemarker/core/JSONParser.java @@ -0,0 +1,616 @@ +/* + * 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 org.apache.freemarker.core; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.freemarker.core.model.TemplateBooleanModel; +import org.apache.freemarker.core.model.TemplateHashModelEx; +import org.apache.freemarker.core.model.TemplateModel; +import org.apache.freemarker.core.model.TemplateNullModel; +import org.apache.freemarker.core.model.TemplateNumberModel; +import org.apache.freemarker.core.model.TemplateSequenceModel; +import org.apache.freemarker.core.model.TemplateStringModel; +import org.apache.freemarker.core.model.impl.SimpleHash; +import org.apache.freemarker.core.model.impl.SimpleNumber; +import org.apache.freemarker.core.model.impl.SimpleString; +import org.apache.freemarker.core.util.BugException; +import org.apache.freemarker.core.util._NumberUtils; +import org.apache.freemarker.core.util._StringUtils; + +import jdk.nashorn.internal.objects.NativeNumber; + +/** + * 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. + * + * <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; + + // 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 " + + _StringUtils.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: " + _StringUtils.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 (_NumberUtils.isIntegerBigDecimal(bd)) { + return new SimpleNumber(bd.intValue()); + } + } else if (bd.compareTo(MIN_LONG_AS_BIGDECIMAL) >= 0 && bd.compareTo(MAX_LONG_AS_BIGDECIMAL) <= 0) { + if (_NumberUtils.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 TemplateStringModel 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 SimpleString(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 TemplateSequenceModel.EMPTY_SEQUENCE; + + boolean afterComma = false; + NativeSequence elements = new NativeSequence(); + 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 TemplateHashModelEx tryConsumeObject() throws JSONParseException { + int startP = p; + if (!tryConsumeChar('{')) return null; + + skipWS(); + if (tryConsumeChar('}')) return TemplateHashModelEx.EMPTY_HASH; + + boolean afterComma = false; + NativeHashEx hash = new NativeHashEx(); + do { + skipWS(); + int keyStartP = p; + Object key = consumeValue(afterComma ? null : UNCLOSED_OBJECT_MESSAGE, afterComma ? -1 : startP); + if (!(key instanceof TemplateStringModel)) { + throw newParseException("Wrong key type. JSON only allows string keys inside {...}.", keyStartP); + } + String strKey = null; + try { + strKey = ((TemplateStringModel) key).getAsString(); + } catch (TemplateException e) { + throw new BugException(e); + } + + skipWS(); + consumeChar(':'); + + skipWS(); + hash.put(strKey, consumeValue(null, -1)); + + skipWS(); + afterComma = true; + } while (consumeChar(',', '}', UNCLOSED_OBJECT_MESSAGE, startP) == ','); + return hash; + } + + 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 " + + _StringUtils.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 " + _StringUtils.jQuote(expected1) + + ( expected2 != 0 ? " or " + _StringUtils.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 " + _StringUtils.jQuote(expected1) + + ( expected2 != 0 ? " or " + _StringUtils.jQuote(expected2) : "") + + " character, but found " + _StringUtils.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(); + } + +}
