Add ConfigDocument tests

Add ConfigDocument tests that parse a String or a file, ensure
that the original text can be rendered, and test value
replacement.
This commit is contained in:
Preben Ingvaldsen 2015-03-17 15:25:58 -07:00
parent c44ef1c6f7
commit 639a3eae5b
9 changed files with 521 additions and 16 deletions

View File

@ -0,0 +1,38 @@
package com.typesafe.config;
/**
* An object parsed from the original input text, which can be used to
* replace individual values and exactly render the original text of the
* input.
*
* <p>
* Because this object is immutable, it is safe to use from multiple threads and
* there's no need for "defensive copies."
*
* <p>
* <em>Do not implement interface {@code ConfigNode}</em>; it should only be
* implemented by the config library. Arbitrary implementations will not work
* because the library internals assume a specific concrete implementation.
* Also, this interface is likely to grow new methods over time, so third-party
* implementations will break.
*/
public interface ConfigDocument {
/**
* Returns a new ConfigDocument that is a copy of the current ConfigDocument,
* but with the desired value set at the desired path. If the path exists, it will
* remove all duplicates before the final occurrence of the path, and replace the value
* at the final occurrence of the path. If the path does not exist, it will be added.
*
* @param path the path at which to set the desired value
* @param newValue the value to set at the desired path
* @return a copy of the ConfigDocument with the desired value at the desired path
*/
ConfigDocument setValue(String path, String newValue);
/**
* The original text of the input, modified if necessary with
* any replaced or added values.
* @return the modified original text
*/
String render();
}

View File

@ -0,0 +1,63 @@
package com.typesafe.config;
import com.typesafe.config.impl.ConfigImpl;
import com.typesafe.config.impl.Parseable;
import java.io.File;
/**
* Factory for automatically creating a ConfigDocument from a given input. Currently
* only supports files and strings.
*/
public final class ConfigDocumentFactory {
/**
* Parses a file into a ConfigDocument instance.
*
* @param file
* the file to parse
* @param options
* parse options to control how the file is interpreted
* @return the parsed configuration
* @throws ConfigException on IO or parse errors
*/
public static ConfigDocument parseFile(File file, ConfigParseOptions options) {
return Parseable.newFile(file, options).parseConfigDocument();
}
/**
* Parses a file into a ConfigDocument instance as with
* {@link #parseFile(File,ConfigParseOptions)} but always uses the
* default parse options.
*
* @param file
* the file to parse
* @return the parsed configuration
* @throws ConfigException on IO or parse errors
*/
public static ConfigDocument parseFile(File file) {
return parseFile(file, ConfigParseOptions.defaults());
}
/**
* Parses a string which should be valid HOCON or JSON.
*
* @param s string to parse
* @param options parse options
* @return the parsed configuration
*/
public static ConfigDocument parseString(String s, ConfigParseOptions options) {
return Parseable.newString(s, options).parseConfigDocument();
}
/**
* Parses a string (which should be valid HOCON or JSON). Uses the
* default parse options.
*
* @param s string to parse
* @return the parsed configuration
*/
public static ConfigDocument parseString(String s) {
return parseString(s, ConfigParseOptions.defaults());
}
}

View File

@ -11,16 +11,21 @@ import com.typesafe.config.ConfigSyntax;
import com.typesafe.config.ConfigValueType;
final class ConfigDocumentParser {
static AbstractConfigNodeValue parse(Iterator<Token> tokens, ConfigParseOptions options) {
static ConfigNodeComplexValue parse(Iterator<Token> tokens, ConfigParseOptions options) {
ParseContext context = new ParseContext(options.getSyntax(), tokens);
return context.parse();
}
static AbstractConfigNodeValue parse(Iterator<Token> tokens) {
static ConfigNodeComplexValue parse(Iterator<Token> tokens) {
ParseContext context = new ParseContext(ConfigSyntax.CONF, tokens);
return context.parse();
}
static AbstractConfigNodeValue parseValue(Iterator<Token> tokens, ConfigParseOptions options) {
ParseContext context = new ParseContext(options.getSyntax(), tokens);
return context.parseSingleValue();
}
static private final class ParseContext {
private int lineNumber;
final private Stack<Token> buffer;
@ -625,5 +630,31 @@ final class ConfigDocumentParser {
+ t);
}
}
// Parse a given input stream into a single value node. Used when doing a replace inside a ConfigDocument.
AbstractConfigNodeValue parseSingleValue() {
Token t = nextToken();
if (t == Tokens.START) {
// OK
} else {
throw new ConfigException.BugOrBroken(
"token stream did not begin with START, had " + t);
}
t = nextToken();
while (Tokens.isIgnoredWhitespace(t) || Tokens.isNewline(t) || isUnquotedWhitespace(t)) {
t = nextToken();
}
if (t == Tokens.END) {
throw parseError("Empty value");
}
if (flavor == ConfigSyntax.JSON) {
return parseValue(t);
} else {
putBack(t);
ArrayList<AbstractConfigNode> nodes = new ArrayList();
return consolidateValues(nodes);
}
}
}
}

View File

@ -1,7 +1,10 @@
package com.typesafe.config.impl;
import com.typesafe.config.ConfigException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
final class ConfigNodeObject extends ConfigNodeComplexValue {
ConfigNodeObject(Collection<AbstractConfigNode> children) {
@ -47,17 +50,33 @@ final class ConfigNodeObject extends ConfigNodeComplexValue {
// If the desired Path did not exist, add it
if (node.render().equals(render())) {
boolean startsWithBrace = super.children.get(0) instanceof ConfigNodeSingleToken &&
((ConfigNodeSingleToken) super.children.get(0)).token() == Tokens.OPEN_CURLY;
ArrayList<AbstractConfigNode> childrenCopy = new ArrayList<AbstractConfigNode>(super.children);
ArrayList<AbstractConfigNode> newNodes = new ArrayList();
newNodes.add(new ConfigNodeSingleToken(Tokens.newLine(null)));
if (startsWithBrace)
newNodes.add(new ConfigNodeSingleToken(Tokens.newIgnoredWhitespace(null, "\t")));
newNodes.add(desiredPath);
newNodes.add(new ConfigNodeSingleToken(Tokens.newIgnoredWhitespace(null, " ")));
newNodes.add(new ConfigNodeSingleToken(Tokens.COLON));
newNodes.add(new ConfigNodeSingleToken(Tokens.newIgnoredWhitespace(null, " ")));
newNodes.add(value);
newNodes.add(new ConfigNodeSingleToken(Tokens.newLine(null)));
childrenCopy.add(new ConfigNodeField(newNodes));
node = new ConfigNodeObject(childrenCopy);
if (startsWithBrace) {
for (int i = childrenCopy.size() - 1; i >= 0; i--) {
if (childrenCopy.get(i) instanceof ConfigNodeSingleToken &&
((ConfigNodeSingleToken) childrenCopy.get(i)).token == Tokens.CLOSE_CURLY) {
childrenCopy.add(i, new ConfigNodeField(newNodes));
return new ConfigNodeObject(childrenCopy);
}
}
throw new ConfigException.BugOrBroken("Object had an opening brace, but no closing brace");
} else {
childrenCopy.add(new ConfigNodeField(newNodes));
node = new ConfigNodeObject(childrenCopy);
}
}
return node;
}

View File

@ -16,4 +16,6 @@ final class ConfigNodeSingleToken extends AbstractConfigNode{
protected Collection<Token> tokens() {
return Collections.singletonList(token);
}
protected Token token() { return token; }
}

View File

@ -19,19 +19,9 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Properties;
import java.util.*;
import com.typesafe.config.ConfigException;
import com.typesafe.config.ConfigIncludeContext;
import com.typesafe.config.ConfigObject;
import com.typesafe.config.ConfigOrigin;
import com.typesafe.config.ConfigParseOptions;
import com.typesafe.config.ConfigParseable;
import com.typesafe.config.ConfigSyntax;
import com.typesafe.config.ConfigValue;
import com.typesafe.config.*;
/**
* Internal implementation detail, not ABI stable, do not touch.
@ -199,6 +189,38 @@ public abstract class Parseable implements ConfigParseable {
}
}
final ConfigDocument parseDocument(ConfigParseOptions baseOptions) {
// note that we are NOT using our "initialOptions",
// but using the ones from the passed-in options. The idea is that
// callers can get our original options and then parse with different
// ones if they want.
ConfigParseOptions options = fixupOptions(baseOptions);
// passed-in options can override origin
ConfigOrigin origin;
if (options.getOriginDescription() != null)
origin = SimpleConfigOrigin.newSimple(options.getOriginDescription());
else
origin = initialOrigin;
return parseDocument(origin, options);
}
final private ConfigDocument parseDocument(ConfigOrigin origin,
ConfigParseOptions finalOptions) {
try {
return rawParseDocument(origin, finalOptions);
} catch (IOException e) {
if (finalOptions.getAllowMissing()) {
return new SimpleConfigDocument(new ConfigNodeObject(new ArrayList<AbstractConfigNode>()), finalOptions);
} else {
trace("exception loading " + origin.description() + ": " + e.getClass().getName()
+ ": " + e.getMessage());
throw new ConfigException.IO(origin,
e.getClass().getName() + ": " + e.getMessage(), e);
}
}
}
// this is parseValue without post-processing the IOException or handling
// options.getAllowMissing()
protected AbstractConfigValue rawParseValue(ConfigOrigin origin, ConfigParseOptions finalOptions)
@ -236,10 +258,47 @@ public abstract class Parseable implements ConfigParseable {
}
}
// this is parseValue without post-processing the IOException or handling
// options.getAllowMissing()
protected ConfigDocument rawParseDocument(ConfigOrigin origin, ConfigParseOptions finalOptions)
throws IOException {
Reader reader = reader(finalOptions);
// after reader() we will have loaded the Content-Type.
ConfigSyntax contentType = contentType();
ConfigParseOptions optionsWithContentType;
if (contentType != null) {
if (ConfigImpl.traceLoadsEnabled() && finalOptions.getSyntax() != null)
trace("Overriding syntax " + finalOptions.getSyntax()
+ " with Content-Type which specified " + contentType);
optionsWithContentType = finalOptions.setSyntax(contentType);
} else {
optionsWithContentType = finalOptions;
}
try {
return rawParseDocument(reader, origin, optionsWithContentType);
} finally {
reader.close();
}
}
private ConfigDocument rawParseDocument(Reader reader, ConfigOrigin origin,
ConfigParseOptions finalOptions) throws IOException {
Iterator<Token> tokens = Tokenizer.tokenize(origin, reader, finalOptions.getSyntax());
return new SimpleConfigDocument(ConfigDocumentParser.parse(tokens, finalOptions), finalOptions);
}
public ConfigObject parse() {
return forceParsedToObject(parseValue(options()));
}
public ConfigDocument parseConfigDocument() {
return parseDocument(options());
}
AbstractConfigValue parseValue() {
return parseValue(options());
}

View File

@ -0,0 +1,36 @@
package com.typesafe.config.impl;
import com.typesafe.config.ConfigDocument;
import com.typesafe.config.ConfigException;
import com.typesafe.config.ConfigParseOptions;
import com.typesafe.config.ConfigValue;
import java.io.StringReader;
import java.util.Iterator;
final class SimpleConfigDocument implements ConfigDocument {
private ConfigNodeComplexValue configNodeTree;
private ConfigParseOptions parseOptions;
SimpleConfigDocument(ConfigNodeComplexValue parsedNode, ConfigParseOptions parseOptions) {
configNodeTree = parsedNode;
this.parseOptions = parseOptions;
}
public ConfigDocument setValue(String path, String newValue) {
if (configNodeTree instanceof ConfigNodeArray) {
throw new ConfigException.Generic("The ConfigDocument had an array at the root level, and values cannot be replaced inside an array.");
}
SimpleConfigOrigin origin = SimpleConfigOrigin.newSimple("single value parsing");
StringReader reader = new StringReader(newValue);
Iterator<Token> tokens = Tokenizer.tokenize(origin, reader, parseOptions.getSyntax());
AbstractConfigNodeValue parsedValue = ConfigDocumentParser.parseValue(tokens, parseOptions);
reader.close();
return new SimpleConfigDocument(((ConfigNodeObject)configNodeTree).setValueOnPath(path, parsedValue), parseOptions);
}
public String render() {
return configNodeTree.render();
}
}

View File

@ -24,6 +24,44 @@ class ConfigDocumentParserTest extends TestUtils {
assertTrue(exceptionThrown)
}
private def parseSimpleValueTest(origText: String, finalText: String = null) {
val expectedRenderedText = if (finalText == null) origText else finalText
val node = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults())
assertEquals(expectedRenderedText, node.render())
assertTrue(node.isInstanceOf[AbstractConfigNodeValue])
val nodeJSON = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults().setSyntax(ConfigSyntax.JSON))
assertEquals(expectedRenderedText, nodeJSON.render())
assertTrue(nodeJSON.isInstanceOf[AbstractConfigNodeValue])
}
private def parseComplexValueTest(origText: String) {
val node = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults())
assertEquals(origText, node.render())
assertTrue(node.isInstanceOf[AbstractConfigNodeValue])
val nodeJSON = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults().setSyntax(ConfigSyntax.JSON))
assertEquals(origText, nodeJSON.render())
assertTrue(nodeJSON.isInstanceOf[AbstractConfigNodeValue])
}
private def parseSingleValueInvalidJSONTest(origText: String, containsMessage: String) {
val node = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults())
assertEquals(origText, node.render())
var exceptionThrown = false
try {
ConfigDocumentParser.parse(tokenize(origText), ConfigParseOptions.defaults().setSyntax(ConfigSyntax.JSON))
} catch {
case e: Exception =>
exceptionThrown = true
assertTrue(e.isInstanceOf[ConfigException])
assertTrue(e.getMessage.contains(containsMessage))
}
assertTrue(exceptionThrown)
}
@Test
def parseSuccess {
parseTest("foo:bar")
@ -184,4 +222,37 @@ class ConfigDocumentParserTest extends TestUtils {
parseJSONFailuresTest("", "Empty document")
}
@Test
def parseSingleValues() {
// Parse simple values
parseSimpleValueTest("123")
parseSimpleValueTest("123.456")
parseSimpleValueTest(""""a string"""")
parseSimpleValueTest("true")
parseSimpleValueTest("false")
parseSimpleValueTest("null")
// Parse Simple Value throws out trailing and leading whitespace
parseSimpleValueTest(" 123", "123")
parseSimpleValueTest("123 ", "123")
parseSimpleValueTest(" 123 ", "123")
// Can parse complex values
parseComplexValueTest("""{"a": "b"}""")
parseComplexValueTest("""["a","b","c"]""")
parseSingleValueInvalidJSONTest("unquotedtext", "Token not allowed in valid JSON")
parseSingleValueInvalidJSONTest("${a.b}", "Substitutions (${} syntax) not allowed in JSON")
// Check that concatenations are handled by CONF parsing
var origText = "123 456 unquotedtext abc"
var node = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults())
assertEquals(origText, node.render())
// Check that concatenations in JSON will only return the first value passed in
origText = "123 456 789"
node = ConfigDocumentParser.parseValue(tokenize(origText), ConfigParseOptions.defaults().setSyntax(ConfigSyntax.JSON))
assertEquals("123", node.render())
}
}

View File

@ -0,0 +1,186 @@
package com.typesafe.config.impl
import java.io.{BufferedReader, FileReader}
import java.nio.charset.StandardCharsets
import java.nio.file.{Paths, Files}
import com.typesafe.config.{ConfigException, ConfigSyntax, ConfigParseOptions, ConfigDocumentFactory}
import org.junit.Assert._
import org.junit.Test
class ConfigDocumentTest extends TestUtils {
private def configDocumentReplaceJsonTest(origText: String, finalText: String, newValue: String, replacePath: String) {
val configDocument = ConfigDocumentFactory.parseString(origText, ConfigParseOptions.defaults().setSyntax(ConfigSyntax.JSON))
assertEquals(origText, configDocument.render())
val newDocument = configDocument.setValue(replacePath, newValue)
assertTrue(newDocument.isInstanceOf[SimpleConfigDocument])
assertEquals(finalText, newDocument.render())
}
private def configDocumentReplaceConfTest(origText: String, finalText: String, newValue: String, replacePath: String) {
val configDocument = ConfigDocumentFactory.parseString(origText)
assertEquals(origText, configDocument.render())
val newDocument = configDocument.setValue(replacePath, newValue)
assertTrue(newDocument.isInstanceOf[SimpleConfigDocument])
assertEquals(finalText, newDocument.render())
}
@Test
def configDocumentReplace() {
// Can handle parsing/replacement with a very simple map
configDocumentReplaceConfTest("""{"a":1}""", """{"a":2}""", "2", "a")
configDocumentReplaceJsonTest("""{"a":1}""", """{"a":2}""", "2", "a")
// Can handle parsing/replacement with a map without surrounding braces
configDocumentReplaceConfTest("a: b\nc = d", "a: b\nc = 12", "12", "c")
// Can handle parsing/replacement with a complicated map
var origText =
"""{
"a":123,
"b": 123.456,
"c": true,
"d": false,
"e": null,
"f": "a string",
"g": [1,2,3,4,5],
"h": {
"a": 123,
"b": {
"a": 12
},
"c": [1, 2, 3, {"a": "b"}, [1,2,3]]
}
}"""
var finalText =
"""{
"a":123,
"b": 123.456,
"c": true,
"d": false,
"e": null,
"f": "a string",
"g": [1,2,3,4,5],
"h": {
"a": 123,
"b": {
"a": "i am now a string"
},
"c": [1, 2, 3, {"a": "b"}, [1,2,3]]
}
}"""
configDocumentReplaceConfTest(origText, finalText, """"i am now a string"""", "h.b.a")
configDocumentReplaceJsonTest(origText, finalText, """"i am now a string"""", "h.b.a")
// Can handle replacing values with maps
finalText =
"""{
"a":123,
"b": 123.456,
"c": true,
"d": false,
"e": null,
"f": "a string",
"g": [1,2,3,4,5],
"h": {
"a": 123,
"b": {
"a": {"a":"b", "c":"d"}
},
"c": [1, 2, 3, {"a": "b"}, [1,2,3]]
}
}"""
configDocumentReplaceConfTest(origText, finalText, """{"a":"b", "c":"d"}""", "h.b.a")
configDocumentReplaceJsonTest(origText, finalText, """{"a":"b", "c":"d"}""", "h.b.a")
// Can handle replacing values with arrays
finalText =
"""{
"a":123,
"b": 123.456,
"c": true,
"d": false,
"e": null,
"f": "a string",
"g": [1,2,3,4,5],
"h": {
"a": 123,
"b": {
"a": [1,2,3,4,5]
},
"c": [1, 2, 3, {"a": "b"}, [1,2,3]]
}
}"""
configDocumentReplaceConfTest(origText, finalText, "[1,2,3,4,5]", "h.b.a")
configDocumentReplaceJsonTest(origText, finalText, "[1,2,3,4,5]", "h.b.a")
finalText =
"""{
"a":123,
"b": 123.456,
"c": true,
"d": false,
"e": null,
"f": "a string",
"g": [1,2,3,4,5],
"h": {
"a": 123,
"b": {
"a": this is a concatenation 123 456 {a:b} [1,2,3] {a: this is another 123 concatenation null true}
},
"c": [1, 2, 3, {"a": "b"}, [1,2,3]]
}
}"""
configDocumentReplaceConfTest(origText, finalText,
"this is a concatenation 123 456 {a:b} [1,2,3] {a: this is another 123 concatenation null true}", "h.b.a")
}
@Test
def configDocumentSetNewValueBraceRoot {
val origText = "{\n\t\"a\":\"b\",\n\t\"c\":\"d\"\n}"
val finalText = "{\n\t\"a\":\"b\",\n\t\"c\":\"d\"\n\n\t\"e\" : \"f\"\n}"
configDocumentReplaceConfTest(origText, finalText, "\"f\"", "\"e\"")
configDocumentReplaceJsonTest(origText, finalText, "\"f\"", "\"e\"")
}
@Test
def configDocumentSetNewValueNoBraces {
val origText = "\"a\":\"b\",\n\"c\":\"d\"\n"
val finalText = "\"a\":\"b\",\n\"c\":\"d\"\n\n\"e\" : \"f\"\n"
configDocumentReplaceConfTest(origText, finalText, "\"f\"", "\"e\"")
}
@Test
def configDocumentReplaceFailure {
// Attempting a replace on a ConfigDocument parsed from an array throws an error
val origText = "[1, 2, 3, 4, 5]"
val document = ConfigDocumentFactory.parseString(origText)
var exceptionThrown = false
try {
document.setValue("a", "1")
} catch {
case e: Exception =>
exceptionThrown = true
assertTrue(e.isInstanceOf[ConfigException])
assertTrue(e.getMessage.contains("ConfigDocument had an array at the root level"))
}
assertTrue(exceptionThrown)
}
@Test
def configDocumentFileParse {
val configDocument = ConfigDocumentFactory.parseFile(resourceFile("/test03.conf"))
val fileReader = new BufferedReader(new FileReader("config/src/test/resources/test03.conf"))
var line = fileReader.readLine()
var sb = new StringBuilder()
while (line != null) {
sb.append(line)
sb.append("\n")
line = fileReader.readLine()
}
fileReader.close()
val fileText = sb.toString()
assertEquals(fileText, configDocument.render())
}
}