Add support for unquoted string values

This commit is contained in:
Havoc Pennington 2011-11-07 23:02:38 -05:00
parent fda3f7eead
commit 01cc2c755c
23 changed files with 590 additions and 178 deletions

View File

@ -70,10 +70,18 @@ public class ConfigException extends RuntimeException {
public static class Null extends WrongType {
private static final long serialVersionUID = 1L;
private static String makeMessage(String path, String expected) {
if (expected != null) {
return "Configuration key '" + path
+ "' is set to null but expected " + expected;
} else {
return "Configuration key '" + path + "' is null";
}
}
public Null(ConfigOrigin origin, String path, String expected,
Throwable cause) {
super(origin, "Configuration key '" + path
+ "' is set to null but expected " + expected, cause);
super(origin, makeMessage(path, expected), cause);
}
public Null(ConfigOrigin origin, String path, String expected) {

View File

@ -66,7 +66,7 @@ abstract class AbstractConfigObject extends AbstractConfigValue implements
if (v.valueType() == ConfigValueType.NULL)
throw new ConfigException.Null(v.origin(), originalPath,
expected.name());
expected != null ? expected.name() : null);
else if (expected != null && v.valueType() != expected)
throw new ConfigException.WrongType(v.origin(), originalPath,
expected.name(), v.valueType().name());
@ -361,7 +361,7 @@ abstract class AbstractConfigObject extends AbstractConfigValue implements
for (String k : keySet()) {
sb.append(k);
sb.append("->");
sb.append(get(k).toString());
sb.append(peek(k).toString());
sb.append(",");
}
if (!keySet().isEmpty())

View File

@ -55,4 +55,13 @@ abstract class AbstractConfigValue implements ConfigValue {
public String toString() {
return valueType().name() + "(" + unwrapped() + ")";
}
// toString() is a debugging-oriented string but this is defined
// to create a string that would parse back to the value in JSON.
// It only works for primitive values (that would be a single token)
// which are auto-converted to strings when concatenating with
// other strings or by the DefaultTransformer.
String transformToString() {
return null;
}
}

View File

@ -21,4 +21,9 @@ final class ConfigBoolean extends AbstractConfigValue {
public Boolean unwrapped() {
return value;
}
@Override
String transformToString() {
return value ? "true" : "false";
}
}

View File

@ -21,4 +21,9 @@ final class ConfigDouble extends AbstractConfigValue {
public Double unwrapped() {
return value;
}
@Override
String transformToString() {
return Double.toString(value);
}
}

View File

@ -44,4 +44,9 @@ final class ConfigInt extends AbstractConfigValue {
// note that "origin" is deliberately NOT part of equality
return value;
}
@Override
String transformToString() {
return Integer.toString(value);
}
}

View File

@ -49,4 +49,9 @@ final class ConfigLong extends AbstractConfigValue {
else
return unwrapped().hashCode(); // use Long.hashCode()
}
@Override
String transformToString() {
return Long.toString(value);
}
}

View File

@ -26,4 +26,9 @@ final class ConfigNull extends AbstractConfigValue {
public Object unwrapped() {
return null;
}
@Override
String transformToString() {
return "null";
}
}

View File

@ -21,4 +21,9 @@ final class ConfigString extends AbstractConfigValue {
public String unwrapped() {
return value;
}
@Override
String transformToString() {
return value;
}
}

View File

@ -38,15 +38,17 @@ class DefaultTransformer implements ConfigTransformer {
break;
}
} else if (requested == ConfigValueType.STRING) {
// if we converted null to string here, then you wouldn't properly
// get a missing-value error if you tried to get a null value
// as a string.
switch (value.valueType()) {
case NUMBER: // FALL THROUGH
case BOOLEAN:
return new ConfigString(value.origin(), value.unwrapped()
.toString());
return new ConfigString(value.origin(),
((AbstractConfigValue) value).transformToString());
}
}
return value;
}
}

View File

@ -15,6 +15,7 @@ import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import com.typesafe.config.ConfigException;
import com.typesafe.config.ConfigOrigin;
@ -54,9 +55,15 @@ final class Parser {
if (f.getName().endsWith(".json"))
flavor = SyntaxFlavor.JSON;
else if (f.getName().endsWith(".conf"))
flavor = SyntaxFlavor.HOCON;
flavor = SyntaxFlavor.CONF;
else
throw new ConfigException.IO(origin, "Unknown filename extension");
return parse(flavor, f);
}
static AbstractConfigValue parse(SyntaxFlavor flavor, File f) {
ConfigOrigin origin = new SimpleConfigOrigin(f.getPath());
AbstractConfigValue result = null;
try {
InputStream stream = new BufferedInputStream(new FileInputStream(f));
@ -70,24 +77,104 @@ final class Parser {
static private final class ParseContext {
private int lineNumber;
private Stack<Token> buffer;
private Iterator<Token> tokens;
private SyntaxFlavor flavor;
private ConfigOrigin baseOrigin;
ParseContext(SyntaxFlavor flavor, ConfigOrigin origin) {
ParseContext(SyntaxFlavor flavor, ConfigOrigin origin,
Iterator<Token> tokens) {
lineNumber = 0;
buffer = new Stack<Token>();
this.tokens = tokens;
this.flavor = flavor;
this.baseOrigin = origin;
}
private Token nextTokenIgnoringNewline(Iterator<Token> tokens) {
Token t = tokens.next();
private Token nextToken() {
Token t = null;
if (buffer.isEmpty()) {
t = tokens.next();
} else {
t = buffer.pop();
}
if (Tokens.isUnquotedText(t) && flavor == SyntaxFlavor.JSON) {
throw parseError("Token not allowed in valid JSON: '"
+ Tokens.getUnquotedText(t) + "'");
} else {
return t;
}
}
private void putBack(Token token) {
buffer.push(token);
}
private Token nextTokenIgnoringNewline() {
Token t = nextToken();
while (Tokens.isNewline(t)) {
lineNumber = Tokens.getLineNumber(t);
t = tokens.next();
t = nextToken();
}
return t;
}
// merge a bunch of adjacent values into one
// value; change unquoted text into a string
// value.
private void consolidateValueTokens() {
// this trick is not done in JSON
if (flavor == SyntaxFlavor.JSON)
return;
List<Token> values = null; // create only if we have value tokens
Token t = nextTokenIgnoringNewline(); // ignore a newline up front
while (Tokens.isValue(t)
|| Tokens.isUnquotedText(t)) {
if (values == null)
values = new ArrayList<Token>();
values.add(t);
t = nextToken(); // but don't consolidate across a newline
}
// the last one wasn't a value token
putBack(t);
if (values == null)
return;
if (values.size() == 1 && !Tokens.isUnquotedText(values.get(0))) {
// a single value token requires no consolidation
putBack(values.get(0));
return;
}
// we have multiple value tokens or one unquoted text token;
// collapse into a string token.
StringBuilder sb = new StringBuilder();
ConfigOrigin firstOrigin = null;
for (Token valueToken : values) {
if (Tokens.isValue(valueToken)) {
AbstractConfigValue v = Tokens.getValue(valueToken);
sb.append(v.transformToString());
if (firstOrigin == null)
firstOrigin = v.origin();
} else if (Tokens.isUnquotedText(valueToken)) {
String text = Tokens.getUnquotedText(valueToken);
if (firstOrigin == null)
firstOrigin = Tokens.getUnquotedTextOrigin(valueToken);
sb.append(text);
} else {
throw new ConfigException.BugOrBroken(
"should not be trying to consolidate token: "
+ valueToken);
}
}
Token consolidated = Tokens.newString(firstOrigin, sb.toString());
putBack(consolidated);
}
private ConfigOrigin lineOrigin() {
return new SimpleConfigOrigin(baseOrigin.description() + ": line "
+ lineNumber);
@ -101,48 +188,49 @@ final class Parser {
return new ConfigException.Parse(lineOrigin(), message, cause);
}
private AbstractConfigValue parseValue(Token token,
Iterator<Token> tokens) {
private AbstractConfigValue parseValue(Token token) {
if (Tokens.isValue(token)) {
return Tokens.getValue(token);
} else if (token == Tokens.OPEN_CURLY) {
return parseObject(tokens);
return parseObject();
} else if (token == Tokens.OPEN_SQUARE) {
return parseArray(tokens);
return parseArray();
} else {
throw parseError("Expecting a value but got wrong token: "
+ token);
}
}
private AbstractConfigObject parseObject(Iterator<Token> tokens) {
private AbstractConfigObject parseObject() {
// invoked just after the OPEN_CURLY
Map<String, ConfigValue> values = new HashMap<String, ConfigValue>();
ConfigOrigin objectOrigin = lineOrigin();
while (true) {
Token t = nextTokenIgnoringNewline(tokens);
Token t = nextTokenIgnoringNewline();
if (Tokens.isValueWithType(t, ConfigValueType.STRING)) {
String key = (String) Tokens.getValue(t).unwrapped();
Token afterKey = nextTokenIgnoringNewline(tokens);
Token afterKey = nextTokenIgnoringNewline();
if (afterKey != Tokens.COLON) {
throw parseError("Key not followed by a colon, followed by token "
+ afterKey);
}
Token valueToken = nextTokenIgnoringNewline(tokens);
consolidateValueTokens();
Token valueToken = nextTokenIgnoringNewline();
// note how we handle duplicate keys: the last one just
// wins.
// FIXME in strict JSON, dups should be an error; while in
// our custom config language, they should be merged if the
// value is an object.
values.put(key, parseValue(valueToken, tokens));
values.put(key, parseValue(valueToken));
} else if (t == Tokens.CLOSE_CURLY) {
break;
} else {
throw parseError("Expecting close brace } or a field name, got "
+ t);
}
t = nextTokenIgnoringNewline(tokens);
t = nextTokenIgnoringNewline();
if (t == Tokens.CLOSE_CURLY) {
break;
} else if (t == Tokens.COMMA) {
@ -155,22 +243,25 @@ final class Parser {
return new SimpleConfigObject(objectOrigin, null, values);
}
private ConfigList parseArray(Iterator<Token> tokens) {
private ConfigList parseArray() {
// invoked just after the OPEN_SQUARE
ConfigOrigin arrayOrigin = lineOrigin();
List<ConfigValue> values = new ArrayList<ConfigValue>();
Token t = nextTokenIgnoringNewline(tokens);
consolidateValueTokens();
Token t = nextTokenIgnoringNewline();
// special-case the first element
if (t == Tokens.CLOSE_SQUARE) {
return new ConfigList(arrayOrigin,
Collections.<ConfigValue> emptyList());
} else if (Tokens.isValue(t)) {
values.add(parseValue(t, tokens));
values.add(parseValue(t));
} else if (t == Tokens.OPEN_CURLY) {
values.add(parseObject(tokens));
values.add(parseObject());
} else if (t == Tokens.OPEN_SQUARE) {
values.add(parseArray(tokens));
values.add(parseArray());
} else {
throw parseError("List should have ] or a first element after the open [, instead had token: "
+ t);
@ -179,7 +270,7 @@ final class Parser {
// now remaining elements
while (true) {
// just after a value
t = nextTokenIgnoringNewline(tokens);
t = nextTokenIgnoringNewline();
if (t == Tokens.CLOSE_SQUARE) {
return new ConfigList(arrayOrigin, values);
} else if (t == Tokens.COMMA) {
@ -190,13 +281,15 @@ final class Parser {
}
// now just after a comma
t = nextTokenIgnoringNewline(tokens);
consolidateValueTokens();
t = nextTokenIgnoringNewline();
if (Tokens.isValue(t)) {
values.add(parseValue(t, tokens));
values.add(parseValue(t));
} else if (t == Tokens.OPEN_CURLY) {
values.add(parseObject(tokens));
values.add(parseObject());
} else if (t == Tokens.OPEN_SQUARE) {
values.add(parseArray(tokens));
values.add(parseArray());
} else {
throw parseError("List should have had new element after a comma, instead had token: "
+ t);
@ -204,8 +297,8 @@ final class Parser {
}
}
AbstractConfigValue parse(Iterator<Token> tokens) {
Token t = nextTokenIgnoringNewline(tokens);
AbstractConfigValue parse() {
Token t = nextTokenIgnoringNewline();
if (t == Tokens.START) {
// OK
} else {
@ -213,12 +306,12 @@ final class Parser {
"token stream did not begin with START, had " + t);
}
t = nextTokenIgnoringNewline(tokens);
t = nextTokenIgnoringNewline();
AbstractConfigValue result = null;
if (t == Tokens.OPEN_CURLY) {
result = parseObject(tokens);
result = parseObject();
} else if (t == Tokens.OPEN_SQUARE) {
result = parseArray(tokens);
result = parseArray();
} else if (t == Tokens.END) {
throw parseError("Empty document");
} else {
@ -226,7 +319,7 @@ final class Parser {
+ t);
}
t = nextTokenIgnoringNewline(tokens);
t = nextTokenIgnoringNewline();
if (t == Tokens.END) {
return result;
} else {
@ -239,7 +332,7 @@ final class Parser {
private static AbstractConfigValue parse(SyntaxFlavor flavor,
ConfigOrigin origin,
Iterator<Token> tokens) {
ParseContext context = new ParseContext(flavor, origin);
return context.parse(tokens);
ParseContext context = new ParseContext(flavor, origin, tokens);
return context.parse();
}
}

View File

@ -1,5 +1,5 @@
package com.typesafe.config.impl;
enum SyntaxFlavor {
JSON, HOCON
JSON, CONF
}

View File

@ -1,5 +1,5 @@
package com.typesafe.config.impl;
enum TokenType {
START, END, COMMA, COLON, OPEN_CURLY, CLOSE_CURLY, OPEN_SQUARE, CLOSE_SQUARE, VALUE, NEWLINE;
START, END, COMMA, COLON, OPEN_CURLY, CLOSE_CURLY, OPEN_SQUARE, CLOSE_SQUARE, VALUE, NEWLINE, UNQUOTED_TEXT;
}

View File

@ -73,48 +73,58 @@ final class Tokenizer {
return new ConfigException.Parse(lineOrigin(), message, cause);
}
private void checkNextOrThrow(String expectedBefore, String expectedNow) {
int i = 0;
while (i < expectedNow.length()) {
int expected = expectedNow.charAt(i);
int actual = nextChar();
if (actual == -1)
throw parseError(String.format(
"Expecting '%s%s' but input data ended",
expectedBefore, expectedNow));
if (actual != expected)
throw parseError(String
.format("Expecting '%s%s' but got char '%c' rather than '%c'",
expectedBefore, expectedNow, actual,
expected));
++i;
}
}
private ConfigOrigin lineOrigin() {
return new SimpleConfigOrigin(origin.description() + ": line "
+ lineNumber);
}
private Token pullTrue() {
// "t" has been already seen
checkNextOrThrow("t", "rue");
return Tokens.newBoolean(lineOrigin(), true);
}
// chars JSON allows a number to start with
static final String firstNumberChars = "0123456789-";
// chars JSON allows to be part of a number
static final String numberChars = "0123456789eE+-.";
// chars that stop an unquoted string
static final String notInUnquotedText = "$\"{}[]:=\n,";
private Token pullFalse() {
// "f" has been already seen
checkNextOrThrow("f", "alse");
return Tokens.newBoolean(lineOrigin(), false);
}
// The rules here are intended to maximize convenience while
// avoiding confusion with real valid JSON. Basically anything
// that parses as JSON is treated the JSON way and otherwise
// we assume it's a string and let the parser sort it out.
private Token pullUnquotedText() {
ConfigOrigin origin = lineOrigin();
StringBuilder sb = new StringBuilder();
int c = nextChar();
while (true) {
if (c == -1) {
break;
} else if (notInUnquotedText.indexOf(c) >= 0) {
break;
} else {
sb.append((char) c);
}
private Token pullNull() {
// "n" has been already seen
checkNextOrThrow("n", "ull");
return Tokens.newNull(lineOrigin());
// we parse true/false/null tokens as such no matter
// what is after them.
if (sb.length() == 4) {
String s = sb.toString();
if (s.equals("true"))
return Tokens.newBoolean(origin, true);
else if (s.equals("null"))
return Tokens.newNull(origin);
} else if (sb.length() == 5) {
String s = sb.toString();
if (s.equals("false"))
return Tokens.newBoolean(origin, false);
}
c = nextChar();
}
// put back the char that ended the unquoted text
putBack(c);
// chop trailing whitespace; have to quote to have trailing spaces.
String s = sb.toString().trim();
return Tokens.newUnquotedText(origin, s);
}
private Token pullNumber(int firstChar) {
@ -122,7 +132,7 @@ final class Tokenizer {
sb.append((char) firstChar);
boolean containedDecimalOrE = false;
int c = nextChar();
while (c != -1 && "0123456789eE+-.".indexOf(c) >= 0) {
while (c != -1 && numberChars.indexOf(c) >= 0) {
if (c == '.' || c == 'e' || c == 'E')
containedDecimalOrE = true;
sb.append((char) c);
@ -255,25 +265,21 @@ final class Tokenizer {
case ']':
t = Tokens.CLOSE_SQUARE;
break;
case 't':
t = pullTrue();
break;
case 'f':
t = pullFalse();
break;
case 'n':
t = pullNull();
break;
}
if (t == null) {
if ("-0123456789".indexOf(c) >= 0) {
if (firstNumberChars.indexOf(c) >= 0) {
t = pullNumber(c);
} else {
} else if (notInUnquotedText.indexOf(c) >= 0) {
throw parseError(String
.format("Character '%c' is not the start of any valid token",
c));
} else {
putBack(c);
t = pullUnquotedText();
}
}
if (t == null)
throw new ConfigException.BugOrBroken(
"bug: failed to generate next token");

View File

@ -80,6 +80,47 @@ final class Tokens {
}
}
// This is not a Value, because it requires special processing
static private class UnquotedText extends Token {
private ConfigOrigin origin;
private String value;
UnquotedText(ConfigOrigin origin, String s) {
super(TokenType.UNQUOTED_TEXT);
this.origin = origin;
this.value = s;
}
ConfigOrigin origin() {
return origin;
}
String value() {
return value;
}
@Override
public String toString() {
return tokenType().name() + "(" + value + ")";
}
@Override
protected boolean canEqual(Object other) {
return other instanceof UnquotedText;
}
@Override
public boolean equals(Object other) {
return super.equals(other)
&& ((UnquotedText) other).value.equals(value);
}
@Override
public int hashCode() {
return 41 * (41 + super.hashCode()) + value.hashCode();
}
}
static boolean isValue(Token token) {
return token instanceof Value;
}
@ -89,7 +130,7 @@ final class Tokens {
return ((Value) token).value();
} else {
throw new ConfigException.BugOrBroken(
"tried to get value of non-value token");
"tried to get value of non-value token " + token);
}
}
@ -106,10 +147,43 @@ final class Tokens {
return ((Line) token).lineNumber();
} else {
throw new ConfigException.BugOrBroken(
"tried to get line number from non-newline");
"tried to get line number from non-newline " + token);
}
}
static boolean isUnquotedText(Token token) {
return token instanceof UnquotedText;
}
static String getUnquotedText(Token token) {
if (token instanceof UnquotedText) {
return ((UnquotedText) token).value();
} else {
throw new ConfigException.BugOrBroken(
"tried to get unquoted text from " + token);
}
}
static ConfigOrigin getUnquotedTextOrigin(Token token) {
if (token instanceof UnquotedText) {
return ((UnquotedText) token).origin();
} else {
throw new ConfigException.BugOrBroken(
"tried to get unquoted text from " + token);
}
}
/*
* static ConfigString newStringValueFromTokens(Token... tokens) {
* StringBuilder sb = new StringBuilder(); for (Token t : tokens) { if
* (isValue(t)) { ConfigValue v = getValue(t); if (v instanceof
* ConfigString) { sb.append(((ConfigString) v).unwrapped()); } else { //
* FIXME convert non-strings to string throw new
* ConfigException.BugOrBroken( "not handling non-strings here"); } } else
* if (isUnquotedText(t)) { String s = getUnquotedText(t); sb.append(s); }
* else { throw new ConfigException. } } }
*/
static Token START = new Token(TokenType.START);
static Token END = new Token(TokenType.END);
static Token COMMA = new Token(TokenType.COMMA);
@ -123,6 +197,10 @@ final class Tokens {
return new Line(lineNumberJustEnded);
}
static Token newUnquotedText(ConfigOrigin origin, String s) {
return new UnquotedText(origin, s);
}
static Token newValue(AbstractConfigValue value) {
return new Value(value);
}

View File

@ -1 +1 @@
{"ints":{"fortyTwo":42,"fortyTwoAgain":42},"floats":{"fortyTwoPointOne":42.1,"fortyTwoPointOneAgain":42.1},"strings":{"abcd":"abcd","abcdAgain":"abcd","a":"a","b":"b","c":"c","d":"d"},"arrays":{"empty":[],"1":[1],"12":[1,2],"123":[1,2,3]},"booleans":{"true":true,"trueAgain":true,"false":false,"falseAgain":false},"nulls":{"null":null,"nullAgain":null}}
{"ints":{"fortyTwo":42,"fortyTwoAgain":42},"floats":{"fortyTwoPointOne":42.1,"fortyTwoPointOneAgain":42.1},"strings":{"abcd":"abcd","abcdAgain":"abcd","a":"a","b":"b","c":"c","d":"d","concatenated":"null bar 42 baz true 3.14 hi"},"arrays":{"empty":[],"1":[1],"12":[1,2],"123":[1,2,3],"ofString":["a","b","c"]},"booleans":{"true":true,"trueAgain":true,"false":false,"falseAgain":false},"nulls":{"null":null,"nullAgain":null}}

View File

@ -15,14 +15,16 @@
"a" : "a",
"b" : "b",
"c" : "c",
"d" : "d"
"d" : "d",
"concatenated" : "null bar 42 baz true 3.14 hi"
},
"arrays" : {
"empty" : [],
"1" : [ 1 ],
"12" : [1, 2],
"123" : [1, 2, 3]
"123" : [1, 2, 3],
"ofString" : [ "a", "b", "c" ]
},
"booleans" : {

View File

@ -0,0 +1,41 @@
{
"ints" : {
"fortyTwo" : 42,
"fortyTwoAgain" : 42
},
"floats" : {
"fortyTwoPointOne" : 42.1,
"fortyTwoPointOneAgain" : 42.1
},
"strings" : {
"abcd" : abcd,
"abcdAgain" : abcd,
"a" : a,
"b" : b,
"c" : c,
"d" : d,
"concatenated" : null bar 42 "baz" true 3.14 hi
},
"arrays" : {
"empty" : [],
"1" : [ 1 ],
"12" : [1, 2],
"123" : [1, 2, 3],
"ofString" : [a, b, c]
},
"booleans" : {
"true" : true,
"trueAgain" : true,
"false" : false,
"falseAgain" : false
},
"nulls" : {
"null" : null,
"nullAgain" : null
}
}

View File

@ -0,0 +1,51 @@
package com.typesafe.config.impl
import org.junit.Assert._
import org.junit._
import java.io.Reader
import java.io.StringReader
import com.typesafe.config._
import java.util.HashMap
class ConfParserTest extends TestUtils {
@org.junit.Before
def setup() {
}
def parse(s: String): ConfigValue = {
Parser.parse(SyntaxFlavor.CONF, new SimpleConfigOrigin("test conf string"), s)
}
private def addOffendingJsonToException[R](parserName: String, s: String)(body: => R) = {
try {
body
} catch {
case t: Throwable =>
throw new AssertionError(parserName + " parser did wrong thing on '" + s + "'", t)
}
}
@Test
def invalidConfThrows(): Unit = {
// be sure we throw
for (invalid <- whitespaceVariations(invalidConf)) {
addOffendingJsonToException("config", invalid.test) {
intercept[ConfigException] {
parse(invalid.test)
}
}
}
}
@Test
def validConfWorks(): Unit = {
// all we're checking here unfortunately is that it doesn't throw.
// for a more thorough check, use the EquivalentsTest stuff.
for (valid <- whitespaceVariations(validConf)) {
val ourAST = addOffendingJsonToException("config-conf", valid.test) {
parse(valid.test)
}
}
}
}

View File

@ -24,7 +24,7 @@ class EquivalentsTest extends TestUtils {
private def filesForEquiv(equiv: File) = {
val rawFiles = equiv.listFiles()
val files = rawFiles.filter({ f => f.getName().endsWith(".json") })
val files = rawFiles.filter({ f => f.getName().endsWith(".json") || f.getName().endsWith(".conf") })
files
}
@ -51,6 +51,15 @@ class EquivalentsTest extends TestUtils {
describeFailure(testFile.getPath()) {
assertEquals(original, value)
}
// check that all .json files can be parsed as .conf,
// i.e. .conf must be a superset of JSON
if (testFile.getName().endsWith(".json")) {
val parsedAsConf = Parser.parse(SyntaxFlavor.CONF, testFile)
describeFailure(testFile.getPath() + " parsed as .conf") {
assertEquals(original, parsedAsConf)
}
}
}
}

View File

@ -8,14 +8,18 @@ import java.io.StringReader
import com.typesafe.config._
import java.util.HashMap
class JsonTest extends TestUtils {
class ParseTest extends TestUtils {
@org.junit.Before
def setup() {
}
def parse(s: String): ConfigValue = {
Parser.parse(SyntaxFlavor.JSON, new SimpleConfigOrigin("test string"), s)
Parser.parse(SyntaxFlavor.JSON, new SimpleConfigOrigin("test json string"), s)
}
def parseAsConf(s: String): ConfigValue = {
Parser.parse(SyntaxFlavor.CONF, new SimpleConfigOrigin("test conf string"), s)
}
private[this] def toLift(value: ConfigValue): lift.JValue = {
@ -88,90 +92,15 @@ class JsonTest extends TestUtils {
withLiftExceptionsConverted(fromLift(lift.JsonParser.parse(json)))
}
case class JsonTest(liftBehaviorUnexpected: Boolean, test: String)
implicit def string2jsontest(test: String): JsonTest = JsonTest(false, test)
private val invalidJson = List[JsonTest]("", // empty document
"{",
"}",
"[",
"]",
"10", // value not in array or object
"\"foo\"", // value not in array or object
"\"", // single quote by itself
"{ \"foo\" : }", // no value in object
"{ : 10 }", // no key in object
// these two problems are ignored by the lift tokenizer
"[:\"foo\", \"bar\"]", // colon in an array; lift doesn't throw (tokenizer erases it)
"[\"foo\" : \"bar\"]", // colon in an array another way, lift ignores (tokenizer erases it)
"[ foo ]", // not a known token
"[ t ]", // start of "true" but ends wrong
"[ tx ]",
"[ tr ]",
"[ trx ]",
"[ tru ]",
"[ trux ]",
"[ truex ]",
"[ 10x ]", // number token with trailing junk
"[ 10e3e3 ]", // two exponents
"[ \"hello ]", // unterminated string
JsonTest(true, "{ \"foo\" , true }"), // comma instead of colon, lift is fine with this
JsonTest(true, "{ \"foo\" : true \"bar\" : false }"), // missing comma between fields, lift fine with this
"[ 10, }]", // array with } as an element
"[ 10, {]", // array with { as an element
"{}x", // trailing invalid token after the root object
"[]x", // trailing invalid token after the root array
JsonTest(true, "{}{}"), // trailing token after the root object - lift OK with it
"{}true", // trailing token after the root object
JsonTest(true, "[]{}"), // trailing valid token after the root array
"[]true", // trailing valid token after the root array
"") // empty document again, just for clean formatting of this list ;-)
// We'll automatically try each of these with whitespace modifications
// so no need to add every possible whitespace variation
private val validJson = List[JsonTest]("{}",
"[]",
"""{ "foo" : "bar" }""",
"""["foo", "bar"]""",
"""{ "foo" : 42 }""",
"""[10, 11]""",
"""[10,"foo"]""",
"""{ "foo" : "bar", "baz" : "boo" }""",
"""{ "foo" : { "bar" : "baz" }, "baz" : "boo" }""",
"""{ "foo" : { "bar" : "baz", "woo" : "w00t" }, "baz" : "boo" }""",
"""{ "foo" : [10,11,12], "baz" : "boo" }""",
JsonTest(true, """{ "foo" : "bar", "foo" : "bar2" }"""), // dup keys - lift just returns both, we use last one
"""[{},{},{},{}]""",
"""[[[[[[]]]]]]""",
"""{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":42}}}}}}}}""",
// this long one is mostly to test rendering
"""{ "foo" : { "bar" : "baz", "woo" : "w00t" }, "baz" : { "bar" : "baz", "woo" : [1,2,3,4], "w00t" : true, "a" : false, "b" : 3.14, "c" : null } }""",
"{}")
// For string quoting, check behavior of escaping a random character instead of one on the list;
// lift-json seems to oddly treat that as a \ literal
private def whitespaceVariations(tests: Seq[JsonTest]): Seq[JsonTest] = {
val variations = List({ s: String => s }, // identity
{ s: String => " " + s },
{ s: String => s + " " },
{ s: String => " " + s + " " },
{ s: String => s.replace(" ", "") }, // this would break with whitespace in a key or value
{ s: String => s.replace(":", " : ") }, // could break with : in a key or value
{ s: String => s.replace(",", " , ") } // could break with , in a key or value
)
for {
t <- tests
v <- variations
} yield JsonTest(t.liftBehaviorUnexpected, v(t.test))
}
private def addOffendingJsonToException[R](parserName: String, s: String)(body: => R) = {
try {
body
} catch {
case t: Throwable =>
throw new AssertionError(parserName + " parser failed on '" + s + "'", t)
throw new AssertionError(parserName + " parser did wrong thing on '" + s + "'", t)
}
}
@ -211,9 +140,12 @@ class JsonTest extends TestUtils {
val liftAST = addOffendingJsonToException("lift", valid.test) {
fromJsonWithLiftParser(valid.test)
}
val ourAST = addOffendingJsonToException("config", valid.test) {
val ourAST = addOffendingJsonToException("config-json", valid.test) {
parse(valid.test)
}
val ourConfAST = addOffendingJsonToException("config-conf", valid.test) {
parseAsConf(valid.test)
}
if (valid.liftBehaviorUnexpected) {
// ignore this for now
} else {
@ -221,6 +153,12 @@ class JsonTest extends TestUtils {
assertEquals(liftAST, ourAST)
}
}
// check that our parser gives the same result in JSON mode and ".conf" mode.
// i.e. this tests that ".conf" format is a superset of JSON.
addOffendingJsonToException("config", valid.test) {
assertEquals(ourAST, ourConfAST)
}
}
}
}

View File

@ -47,4 +47,93 @@ abstract trait TestUtils {
def fakeOrigin() = {
new SimpleConfigOrigin("fake origin")
}
case class ParseTest(liftBehaviorUnexpected: Boolean, test: String)
implicit def string2jsontest(test: String): ParseTest = ParseTest(false, test)
private val invalidJsonInvalidConf = List[ParseTest]("", // empty document
"{",
"}",
"[",
"]",
"10", // value not in array or object
"\"foo\"", // value not in array or object
"\"", // single quote by itself
"{ \"foo\" : }", // no value in object
"{ : 10 }", // no key in object
"{ foo : \"bar\" }", // no quotes on key
"{ foo : bar }", // no quotes on key or value
// these two problems are ignored by the lift tokenizer
"[:\"foo\", \"bar\"]", // colon in an array; lift doesn't throw (tokenizer erases it)
"[\"foo\" : \"bar\"]", // colon in an array another way, lift ignores (tokenizer erases it)
"[ 10e3e3 ]", // two exponents. ideally this might parse to a number plus string "e3" but it's hard to implement.
"[ \"hello ]", // unterminated string
ParseTest(true, "{ \"foo\" , true }"), // comma instead of colon, lift is fine with this
ParseTest(true, "{ \"foo\" : true \"bar\" : false }"), // missing comma between fields, lift fine with this
"[ 10, }]", // array with } as an element
"[ 10, {]", // array with { as an element
"{}x", // trailing invalid token after the root object
"[]x", // trailing invalid token after the root array
ParseTest(true, "{}{}"), // trailing token after the root object - lift OK with it
"{}true", // trailing token after the root object
ParseTest(true, "[]{}"), // trailing valid token after the root array
"[]true", // trailing valid token after the root array
"") // empty document again, just for clean formatting of this list ;-)
// We'll automatically try each of these with whitespace modifications
// so no need to add every possible whitespace variation
protected val validJson = List[ParseTest]("{}",
"[]",
"""{ "foo" : "bar" }""",
"""["foo", "bar"]""",
"""{ "foo" : 42 }""",
"""[10, 11]""",
"""[10,"foo"]""",
"""{ "foo" : "bar", "baz" : "boo" }""",
"""{ "foo" : { "bar" : "baz" }, "baz" : "boo" }""",
"""{ "foo" : { "bar" : "baz", "woo" : "w00t" }, "baz" : "boo" }""",
"""{ "foo" : [10,11,12], "baz" : "boo" }""",
ParseTest(true, """{ "foo" : "bar", "foo" : "bar2" }"""), // dup keys - lift just returns both, we use last one
"""[{},{},{},{}]""",
"""[[[[[[]]]]]]""",
"""{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":42}}}}}}}}""",
// this long one is mostly to test rendering
"""{ "foo" : { "bar" : "baz", "woo" : "w00t" }, "baz" : { "bar" : "baz", "woo" : [1,2,3,4], "w00t" : true, "a" : false, "b" : 3.14, "c" : null } }""",
"{}")
private val validConfInvalidJson = List[ParseTest](
"""{ "foo" : bar }""", // no quotes on value
"""{ "foo" : null bar 42 baz true 3.14 "hi" }""", // bunch of values to concat into a string
"[ foo ]", // not a known token in JSON
"[ t ]", // start of "true" but ends wrong in JSON
"[ tx ]",
"[ tr ]",
"[ trx ]",
"[ tru ]",
"[ trux ]",
"[ truex ]",
"[ 10x ]") // number token with trailing junk
protected val invalidJson = validConfInvalidJson ++ invalidJsonInvalidConf;
protected val invalidConf = invalidJsonInvalidConf;
// .conf is a superset of JSON so validJson just goes in here
protected val validConf = validConfInvalidJson ++ validJson;
protected def whitespaceVariations(tests: Seq[ParseTest]): Seq[ParseTest] = {
val variations = List({ s: String => s }, // identity
{ s: String => " " + s },
{ s: String => s + " " },
{ s: String => " " + s + " " },
{ s: String => s.replace(" ", "") }, // this would break with whitespace in a key or value
{ s: String => s.replace(":", " : ") }, // could break with : in a key or value
{ s: String => s.replace(",", " , ") } // could break with , in a key or value
)
for {
t <- tests
v <- variations
} yield ParseTest(t.liftBehaviorUnexpected, v(t.test))
}
}

View File

@ -44,12 +44,12 @@ class TokenizerTest extends TestUtils {
def tokenizeAllTypesNoSpaces() {
// all token types with no spaces (not sure JSON spec wants this to work,
// but spec is unclear to me when spaces are required, and banning them
// is actually extra work)
// is actually extra work).
val expected = List(Tokens.START, Tokens.COMMA, Tokens.COLON, Tokens.CLOSE_CURLY,
Tokens.OPEN_CURLY, Tokens.CLOSE_SQUARE, Tokens.OPEN_SQUARE, Tokens.newString(fakeOrigin(), "foo"),
Tokens.newLong(fakeOrigin(), 42), Tokens.newBoolean(fakeOrigin(), true), Tokens.newDouble(fakeOrigin(), 3.14),
Tokens.newBoolean(fakeOrigin(), false), Tokens.newNull(fakeOrigin()), Tokens.newLine(0), Tokens.END)
assertEquals(expected, tokenizeAsList(""",:}{]["foo"42true3.14falsenull""" + "\n"))
Tokens.newBoolean(fakeOrigin(), true), Tokens.newDouble(fakeOrigin(), 3.14), Tokens.newBoolean(fakeOrigin(), false),
Tokens.newLong(fakeOrigin(), 42), Tokens.newNull(fakeOrigin()), Tokens.newLine(0), Tokens.END)
assertEquals(expected, tokenizeAsList(""",:}{]["foo"true3.14false42null""" + "\n"))
}
@Test
@ -76,6 +76,62 @@ class TokenizerTest extends TestUtils {
assertEquals(expected, tokenizeAsList(""" , : } { ] [ "foo" 42 true 3.14 false null """ + "\n "))
}
@Test
def tokenizeTrueAndUnquotedText() {
val expected = List(Tokens.START, Tokens.newBoolean(fakeOrigin(), true), Tokens.newUnquotedText(fakeOrigin(), "foo"), Tokens.END)
assertEquals(expected, tokenizeAsList("""truefoo"""))
}
@Test
def tokenizeFalseAndUnquotedText() {
val expected = List(Tokens.START, Tokens.newBoolean(fakeOrigin(), false), Tokens.newUnquotedText(fakeOrigin(), "foo"), Tokens.END)
assertEquals(expected, tokenizeAsList("""falsefoo"""))
}
@Test
def tokenizeNullAndUnquotedText() {
val expected = List(Tokens.START, Tokens.newNull(fakeOrigin()), Tokens.newUnquotedText(fakeOrigin(), "foo"), Tokens.END)
assertEquals(expected, tokenizeAsList("""nullfoo"""))
}
@Test
def tokenizeUnquotedTextContainingTrue() {
val expected = List(Tokens.START, Tokens.newUnquotedText(fakeOrigin(), "footrue"), Tokens.END)
assertEquals(expected, tokenizeAsList("""footrue"""))
}
@Test
def tokenizeUnquotedTextContainingSpaceTrue() {
val expected = List(Tokens.START, Tokens.newUnquotedText(fakeOrigin(), "foo true"), Tokens.END)
assertEquals(expected, tokenizeAsList("""foo true"""))
}
@Test
def tokenizeTrueAndSpaceAndUnquotedText() {
val expected = List(Tokens.START, Tokens.newBoolean(fakeOrigin(), true), Tokens.newUnquotedText(fakeOrigin(), "foo"), Tokens.END)
assertEquals(expected, tokenizeAsList("""true foo"""))
}
@Test
def tokenizeUnquotedTextTrimsSpaces() {
val expected = List(Tokens.START, Tokens.newUnquotedText(fakeOrigin(), "foo"), Tokens.newLine(0), Tokens.END)
assertEquals(expected, tokenizeAsList(" foo \n"))
}
@Test
def tokenizeUnquotedTextKeepsInternalSpaces() {
val expected = List(Tokens.START, Tokens.newUnquotedText(fakeOrigin(), "foo bar baz"), Tokens.newLine(0), Tokens.END)
assertEquals(expected, tokenizeAsList(" foo bar baz \n"))
}
@Test
def tokenizeMixedUnquotedQuoted() {
val expected = List(Tokens.START, Tokens.newUnquotedText(fakeOrigin(), "foo"),
Tokens.newString(fakeOrigin(), "bar"), Tokens.newUnquotedText(fakeOrigin(), "baz"),
Tokens.newLine(0), Tokens.END)
assertEquals(expected, tokenizeAsList(" foo\"bar\"baz \n"))
}
@Test
def tokenizerUnescapeStrings(): Unit = {
case class UnescapeTest(escaped: String, result: ConfigString)