From 4ad8a683ccf8bdd4243db1e51d2411fb0de5a263 Mon Sep 17 00:00:00 2001 From: "moandji.ezana" Date: Tue, 26 Feb 2013 09:57:26 +0200 Subject: [PATCH] Initial commit --- README.md | 25 +++ pom.xml | 45 ++++ src/main/java/com/moandjiezana/toml/Toml.java | 110 +++++++++ .../com/moandjiezana/toml/TomlParser.java | 209 ++++++++++++++++++ .../com/moandjiezana/toml/RealWorldTest.java | 82 +++++++ .../moandjiezana/toml/TomlDefaultsTest.java | 61 +++++ .../java/com/moandjiezana/toml/TomlTest.java | 156 +++++++++++++ .../toml/should_load_from_file.toml | 1 + src/test/resources/example.toml | 35 +++ 9 files changed, 724 insertions(+) create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/moandjiezana/toml/Toml.java create mode 100644 src/main/java/com/moandjiezana/toml/TomlParser.java create mode 100644 src/test/java/com/moandjiezana/toml/RealWorldTest.java create mode 100644 src/test/java/com/moandjiezana/toml/TomlDefaultsTest.java create mode 100644 src/test/java/com/moandjiezana/toml/TomlTest.java create mode 100644 src/test/resources/com/moandjiezana/toml/should_load_from_file.toml create mode 100644 src/test/resources/example.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..8abcf41 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# toml4j + +toml4j is a [TOML](https://github.com/mojombo/toml) parser that uses the [Parboiled](http://www.parboiled.org) PEG parser. + +## Usage + +````java +Toml toml = new Toml(getTomlFile()); // throws an Exception if the TOML is incorrect + +String title = toml.getString("title"); // if a key doesn't exist, returns null +Boolean enabled = toml.getBoolean("database.enabled"); // gets the key enabled from the key group database +Toml servers = toml.getKeyGroup("servers"); // returns a new Toml instance containing only the key group's values +```` + +### Defaults + +The constructor can be given a set of default values that will be used if necessary. + +````java +Toml toml = new Toml("a = 1", new Toml("a = 2\nb = 3"); + +Long a = toml.getLong("a"); // returns 1, not 2 +Long b = toml.getLong("b"); // returns 3 +Long c = toml.getLong("c"); // returns null +```` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3615c51 --- /dev/null +++ b/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + org.sonatype.oss + oss-parent + 7 + + com.moandjiezana.toml + toml4j + 1.0.0-SNAPSHOT + toml4j + A parser for TOML + + + UTF-8 + + + + + junit + junit + 4.11 + test + + + org.parboiled + parboiled-java + 1.1.4 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.0 + + 1.6 + 1.6 + + + + + \ No newline at end of file diff --git a/src/main/java/com/moandjiezana/toml/Toml.java b/src/main/java/com/moandjiezana/toml/Toml.java new file mode 100644 index 0000000..9170ac0 --- /dev/null +++ b/src/main/java/com/moandjiezana/toml/Toml.java @@ -0,0 +1,110 @@ +package com.moandjiezana.toml; + +import java.io.File; +import java.io.FileNotFoundException; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Scanner; + +import org.parboiled.Parboiled; +import org.parboiled.parserunners.RecoveringParseRunner; +import org.parboiled.support.ParsingResult; + +/** + * + * All getters can fall back to default values if they have been provided and will return null if no matching key exists. + * + */ +public class Toml { + + private final Map values; + private final Toml defaults; + + public Toml(File file) throws FileNotFoundException { + this(new Scanner(file).useDelimiter("\\Z").next()); + } + + public Toml(String tomlString) { + this(tomlString, null); + } + + public Toml(String tomlString, Toml defaults) { + TomlParser parser = Parboiled.createParser(TomlParser.class); + ParsingResult result = new RecoveringParseRunner(parser.Toml()).run(tomlString); +// ParsingResult parsingResult = new ReportingParseRunner(parser.Toml()).run(tomlString); +// System.out.println(ParseTreeUtils.printNodeTree(parsingResult)); + + TomlParser.Results results = (TomlParser.Results) result.valueStack.peek(result.valueStack.size() - 1); + if (results.errors.length() > 0) { + throw new IllegalStateException(results.errors.toString()); + } + + this.values = results.values; + this.defaults = defaults; + } + + public String getString(String key) { + return (String) get(key); + } + + public Long getLong(String key) { + return (Long) get(key); + } + + @SuppressWarnings("unchecked") + public List getList(String key, Class itemClass) { + return (List) get(key); + } + + public Boolean getBoolean(String key) { + return (Boolean) get(key); + } + + public Date getDate(String key) { + return (Date) get(key); + } + + public Double getDouble(String key) { + return (Double) get(key); + } + + @SuppressWarnings("unchecked") + public Toml getKeyGroup(String key) { + return new Toml((Map) get(key)); + } + + @SuppressWarnings("unchecked") + private Object get(String key) { + String[] split = key.split("\\."); + Object current = new HashMap(values); + Object currentDefaults = defaults != null ? defaults.values : null; + for (String splitKey : split) { + current = ((Map) current).get(splitKey); + if (currentDefaults != null) { + currentDefaults = ((Map) currentDefaults).get(splitKey); + if (current instanceof Map && currentDefaults instanceof Map) { + for (Map.Entry entry : ((Map) currentDefaults).entrySet()) { + if (!((Map) current).containsKey(entry.getKey())) { + ((Map) current).put(entry.getKey(), entry.getValue()); + } + } + } + } + if (current == null && currentDefaults != null) { + current = currentDefaults; + } + if (current == null) { + return null; + } + } + + return current; + } + + private Toml(Map values) { + this.values = values; + this.defaults = null; + } +} diff --git a/src/main/java/com/moandjiezana/toml/TomlParser.java b/src/main/java/com/moandjiezana/toml/TomlParser.java new file mode 100644 index 0000000..710d5bb --- /dev/null +++ b/src/main/java/com/moandjiezana/toml/TomlParser.java @@ -0,0 +1,209 @@ +package com.moandjiezana.toml; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.parboiled.BaseParser; +import org.parboiled.Rule; +import org.parboiled.annotations.BuildParseTree; +import org.parboiled.annotations.SuppressNode; + +@BuildParseTree +public class TomlParser extends BaseParser { + + static class Results { + public Map values = new HashMap(); + public StringBuilder errors = new StringBuilder(); + } + + public Rule Toml() { + return Sequence(push(new TomlParser.Results()), push(((TomlParser.Results) peek()).values), OneOrMore(FirstOf(KeyGroup(), Comment(), Key()))); + } + + Rule KeyGroup() { + return Sequence(KeyGroupDelimiter(), KeyGroupName(), addKeyGroup((String) pop()), KeyGroupDelimiter(), Spacing()); + } + + Rule Key() { + return Sequence(Spacing(), KeyName(), EqualsSign(), VariableValues(), Spacing(), swap(), addKey((String) pop(), pop())); + } + + Rule KeyGroupName() { + return Sequence(OneOrMore(FirstOf(Letter(), Digit(), '.', '_')), push(match())); + } + + Rule KeyName() { + return Sequence(OneOrMore(FirstOf(Letter(), Digit(), '_', '.')), push(match())); + } + + Rule VariableValues() { + return FirstOf(ArrayValue(), DateValue(), BooleanValue(), NumberValue(), StringValue()); + } + + Rule ArrayValue() { + return Sequence(push(ArrayList.class), '[', Spacing(), ZeroOrMore(VariableValues(), Optional(ArrayDelimiter())), Spacing(), ']', pushList()); + } + + Rule DateValue() { + return Sequence(Sequence(Year(), '-', Month(), '-', Day(), 'T', Digit(), Digit(), ':', Digit(), Digit(), ':', Digit(), Digit(), 'Z'), pushDate(match())); + } + + Rule BooleanValue() { + return Sequence(FirstOf("true", "false"), push(Boolean.valueOf(match()))); + } + + Rule NumberValue() { + return Sequence(OneOrMore(FirstOf(Digit(), '.')), pushNumber(match())); + } + + Rule StringValue() { + return Sequence('"', OneOrMore(TestNot('"'), ANY), pushString(match()), '"'); + } + + Rule Year() { + return Sequence(Digit(), Digit(), Digit(), Digit()); + } + + Rule Month() { + return Sequence(CharRange('0', '1'), Digit()); + } + + Rule Day() { + return Sequence(CharRange('0', '3'), Digit()); + } + + Rule Digit() { + return CharRange('0', '9'); + } + + Rule Letter() { + return CharRange('a', 'z'); + } + + @SuppressNode + Rule KeyGroupDelimiter() { + return AnyOf("[]"); + } + + @SuppressNode + Rule EqualsSign() { + return Sequence(Spacing(), '=', Spacing()); + } + + @SuppressNode + Rule Spacing() { + return ZeroOrMore(FirstOf(Comment(), AnyOf(" \t\r\n\f"))); + } + + @SuppressNode + Rule ArrayDelimiter() { + return Sequence(Spacing(), ',', Spacing()); + } + + @SuppressNode + Rule Comment() { + return Sequence('#', ZeroOrMore(TestNot(AnyOf("\r\n")), ANY), FirstOf("\r\n", '\r', '\n', EOI)); + } + + @SuppressWarnings("unchecked") + boolean addKeyGroup(String name) { + String[] split = name.split("\\."); + name = split[split.length - 1]; + + while (getContext().getValueStack().size() > 2) { + drop(); + } + + Map newKeyGroup = (Map) getContext().getValueStack().peek(); + for (String splitKey : split) { + if (!newKeyGroup.containsKey(splitKey)) { + newKeyGroup.put(splitKey, new HashMap()); + } + Object currentValue = newKeyGroup.get(splitKey); + if (!(currentValue instanceof Map)) { + results().errors.append("Could not create key group ").append(name).append(": key already exists!"); + + return true; + } + newKeyGroup = (Map) currentValue; + } + + push(newKeyGroup); + return true; + } + + boolean addKey(String key, Object value) { + if (key.contains(".")) { + results().errors.append(key).append(" is invalid: key names may not contain a dot!\n"); + + return true; + } + putValue(key, value); + + return true; + } + + boolean pushList() { + ArrayList list = new ArrayList(); + while (peek() != ArrayList.class) { + list.add(0, pop()); + } + + poke(list); + return true; + } + + boolean pushDate(String dateString) { + String s = dateString.replace("Z", "+00:00"); + try { + s = s.substring(0, 22) + s.substring(23); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); + dateFormat.setLenient(false); + Date date = dateFormat.parse(s); + push(date); + return true; + } catch (Exception e) { + results().errors.append("Invalid date: ").append(dateString); + return false; + } + } + + boolean pushNumber(String number) { + if (number.contains(".")) { + push(Double.valueOf(number)); + } else { + push(Long.valueOf(number)); + } + return true; + } + + boolean pushString(String line) { + StringBuilder builder = new StringBuilder(); + + String[] split = line.split("\\\\n"); + for (String string : split) { + builder.append(string).append('\n'); + } + builder.deleteCharAt(builder.length() - 1); + push(builder.toString()); + + return true; + } + + @SuppressWarnings("unchecked") + void putValue(String name, Object value) { + Map values = (Map) peek(); + if (values.containsKey(name)) { + results().errors.append("Key ").append(name).append(" already exists!"); + return; + } + values.put(name, value); + } + + TomlParser.Results results() { + return (Results) peek(getContext().getValueStack().size() - 1); + } +} diff --git a/src/test/java/com/moandjiezana/toml/RealWorldTest.java b/src/test/java/com/moandjiezana/toml/RealWorldTest.java new file mode 100644 index 0000000..f4aa1dc --- /dev/null +++ b/src/test/java/com/moandjiezana/toml/RealWorldTest.java @@ -0,0 +1,82 @@ +package com.moandjiezana.toml; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Map; +import java.util.Scanner; +import java.util.TimeZone; + +import org.junit.Test; +import org.parboiled.Parboiled; +import org.parboiled.parserunners.RecoveringParseRunner; +import org.parboiled.support.ParsingResult; + +public class RealWorldTest { + + @SuppressWarnings("unchecked") + @Test + public void should_parse_example() throws Exception { + TomlParser parser = Parboiled.createParser(TomlParser.class); + + String toml = new Scanner(new File(getClass().getResource("/example.toml").getFile())).useDelimiter("\\Z").next(); + ParsingResult result = new RecoveringParseRunner(parser.Toml()).run(toml); + + Map root = (Map) result.valueStack.peek(result.valueStack.size() - 2); + + printMap(root); + + assertEquals("TOML Example", root.get("title")); + + Map owner = get(root, "owner"); + assertEquals("Tom Preston-Werner", owner.get("name")); + assertEquals("GitHub", owner.get("organization")); + assertEquals("GitHub Cofounder & CEO\nLikes tater tots and beer.", owner.get("bio")); + + Calendar dob = Calendar.getInstance(); + dob.set(1979, Calendar.MAY, 27, 7, 32, 0); + dob.set(Calendar.MILLISECOND, 0); + dob.setTimeZone(TimeZone.getTimeZone("UTC")); + assertEquals(dob.getTime(), owner.get("dob")); + + Map database = get(root, "database"); + assertEquals("192.168.1.1", database.get("server")); + assertEquals(5000L, database.get("connection_max")); + assertTrue((Boolean) database.get("enabled")); + assertEquals(Arrays.asList(8001L, 8001L, 8002L), database.get("ports")); + + Map servers = get(root, "servers"); + Map alphaServers = get(servers, "alpha"); + assertEquals("10.0.0.1", alphaServers.get("ip")); + assertEquals("eqdc10", alphaServers.get("dc")); + Map betaServers = get(servers, "beta"); + assertEquals("10.0.0.2", betaServers.get("ip")); + assertEquals("eqdc10", betaServers.get("dc")); + + Map clients = get(root, "clients"); + assertEquals(asList(asList("gamma", "delta"), asList(1L, 2L)), clients.get("data")); + assertEquals(asList("alpha", "omega"), clients.get("hosts")); + } + + @SuppressWarnings("unchecked") + private void printMap(Map map) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() instanceof Map) { + System.out.println("[" + entry.getKey() + "]"); + printMap((Map) entry.getValue()); + System.out.println("[/" + entry.getKey() + "]"); + } else { + System.out.println(entry.getKey() + " = " + entry.getValue()); + } + } + } + + @SuppressWarnings("unchecked") + private Map get(Map map, String key) { + return (Map) map.get(key); + } +} diff --git a/src/test/java/com/moandjiezana/toml/TomlDefaultsTest.java b/src/test/java/com/moandjiezana/toml/TomlDefaultsTest.java new file mode 100644 index 0000000..0f93591 --- /dev/null +++ b/src/test/java/com/moandjiezana/toml/TomlDefaultsTest.java @@ -0,0 +1,61 @@ +package com.moandjiezana.toml; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Before; +import org.junit.Test; + + +public class TomlDefaultsTest { + + private Toml defaultToml; + + @Before + public void before() { + defaultToml = new Toml("a = \"a\"\n[group]\na=\"a\""); + } + + @Test + public void should_fall_back_to_default_value() { + Toml toml = new Toml("", defaultToml); + + assertEquals("a", toml.getString("a")); + } + + @Test + public void should_use_value_when_present_in_values_and_defaults() { + Toml toml = new Toml("a = \"b\"", defaultToml); + + assertEquals("b", toml.getString("a")); + } + + @Test + public void should_return_null_when_no_defaults_for_key() throws Exception { + Toml toml = new Toml("", defaultToml); + + assertNull(toml.getString("b")); + } + + @Test + public void should_fall_back_to_default_with_multi_key() throws Exception { + Toml toml = new Toml("", defaultToml); + + assertEquals("a", toml.getString("group.a")); + } + + @Test + public void should_fall_back_to_key_group() throws Exception { + Toml toml = new Toml("", defaultToml); + + assertEquals("a", toml.getKeyGroup("group").getString("a")); + } + + @Test + public void should_fall_back_to_key_within_key_group() throws Exception { + Toml toml = new Toml("[group]\nb=1", defaultToml); + + assertEquals(1, toml.getKeyGroup("group").getLong("b").intValue()); + assertEquals("a", toml.getKeyGroup("group").getString("a")); + } +} diff --git a/src/test/java/com/moandjiezana/toml/TomlTest.java b/src/test/java/com/moandjiezana/toml/TomlTest.java new file mode 100644 index 0000000..f209296 --- /dev/null +++ b/src/test/java/com/moandjiezana/toml/TomlTest.java @@ -0,0 +1,156 @@ +package com.moandjiezana.toml; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.Calendar; +import java.util.TimeZone; + +import org.junit.Test; + +public class TomlTest { + + @Test + public void should_get_string() throws Exception { + Toml toml = new Toml("a = \"a\""); + + assertEquals("a", toml.getString("a")); + } + + @Test + public void should_get_number() throws Exception { + Toml toml = new Toml("b = 1001"); + + assertEquals(1001, toml.getLong("b").intValue()); + } + + @Test + public void should_get_list() throws Exception { + Toml toml = new Toml("list = [\"a\", \"b\", \"c\"]"); + + assertEquals(asList("a", "b", "c"), toml.getList("list", String.class)); + } + + @Test + public void should_get_boolean() throws Exception { + Toml toml = new Toml("bool_false = false\nbool_true = true"); + + assertFalse(toml.getBoolean("bool_false")); + assertTrue(toml.getBoolean("bool_true")); + } + + @Test + public void should_get_date() throws Exception { + Toml toml = new Toml("a_date = 2011-11-10T13:12:00Z"); + + Calendar calendar = Calendar.getInstance(); + calendar.set(2011, Calendar.NOVEMBER, 10, 13, 12, 00); + calendar.set(Calendar.MILLISECOND, 0); + calendar.setTimeZone(TimeZone.getTimeZone("UTC")); + + assertEquals(calendar.getTime(), toml.getDate("a_date")); + } + + @Test + public void should_get_double() throws Exception { + Toml toml = new Toml("double = 5.25"); + + assertEquals(5.25D, toml.getDouble("double").doubleValue(), 0.0); + } + + @Test + public void should_get_key_group() throws Exception { + Toml toml = new Toml("[group]\nkey = \"value\""); + + Toml group = toml.getKeyGroup("group"); + + assertEquals("value", group.getString("key")); + } + + @Test + public void should_get_value_for_multi_key() throws Exception { + Toml toml = new Toml("[group]\nkey = \"value\""); + + assertEquals("value", toml.getString("group.key")); + } + + @Test + public void should_get_value_for_multi_key_with_no_parent_keygroup() throws Exception { + Toml toml = new Toml("[group.sub]\nkey = \"value\""); + + assertEquals("value", toml.getString("group.sub.key")); + } + + @Test + public void should_return_null_if_no_value_for_key() throws Exception { + Toml toml = new Toml(""); + + assertNull(toml.getString("a")); + } + + @Test + public void should_return_null_when_no_value_for_multi_key() throws Exception { + Toml toml = new Toml(""); + + assertNull(toml.getString("group.key")); + } + + @Test + public void should_load_from_file() throws Exception { + Toml toml = new Toml(new File(getClass().getResource("should_load_from_file.toml").getFile())); + + assertEquals("value", toml.getString("key")); + } + + @Test + public void should_support_numbers_in_key_names() throws Exception { + Toml toml = new Toml("a1 = 1"); + + assertEquals(1, toml.getLong("a1").intValue()); + } + + @Test + public void should_support_numbers_in_key_group_names() throws Exception { + Toml toml = new Toml("[group1]\na = 1"); + + assertEquals(1, toml.getLong("group1.a").intValue()); + } + + @Test + public void should_support_underscores_in_key_names() throws Exception { + Toml toml = new Toml("a_a = 1"); + + assertEquals(1, toml.getLong("a_a").intValue()); + } + + @Test + public void should_support_underscores_in_key_group_names() throws Exception { + Toml toml = new Toml("[group_a]\na = 1"); + + assertEquals(1, toml.getLong("group_a.a").intValue()); + } + + @Test(expected = IllegalStateException.class) + public void should_fail_when_dot_in_key_name() throws Exception { + new Toml("a.a = 1"); + } + + @Test(expected = IllegalStateException.class) + public void should_fail_on_invalid_date() throws Exception { + new Toml("d = 2012-13-01T15:00:00Z"); + } + + @Test(expected = IllegalStateException.class) + public void should_fail_when_key_is_overwritten_by_key_group() { + new Toml("[fruit]\ntype=\"apple\"\n[fruit.type]\napple=\"yes\""); + } + + @Test(expected = IllegalStateException.class) + public void should_fail_when_key_is_overwritten_by_another_key() { + new Toml("[fruit]\ntype=\"apple\"\ntype=\"orange\""); + } +} diff --git a/src/test/resources/com/moandjiezana/toml/should_load_from_file.toml b/src/test/resources/com/moandjiezana/toml/should_load_from_file.toml new file mode 100644 index 0000000..42863fd --- /dev/null +++ b/src/test/resources/com/moandjiezana/toml/should_load_from_file.toml @@ -0,0 +1 @@ +key = "value" \ No newline at end of file diff --git a/src/test/resources/example.toml b/src/test/resources/example.toml new file mode 100644 index 0000000..32c7a4f --- /dev/null +++ b/src/test/resources/example.toml @@ -0,0 +1,35 @@ +# This is a TOML document. Boom. + +title = "TOML Example" + +[owner] +name = "Tom Preston-Werner" +organization = "GitHub" +bio = "GitHub Cofounder & CEO\nLikes tater tots and beer." +dob = 1979-05-27T07:32:00Z # First class dates? Why not? + +[database] +server = "192.168.1.1" +ports = [ 8001, 8001, 8002 ] +connection_max = 5000 +enabled = true + +[servers] + + # You can indent as you please. Tabs or spaces. TOML don't care. + [servers.alpha] + ip = "10.0.0.1" + dc = "eqdc10" + + [servers.beta] + ip = "10.0.0.2" + dc = "eqdc10" + +[clients] +data = [ ["gamma", "delta"], [1, 2] ] # just an update to make sure parsers support it + +# Line breaks are OK when inside arrays +hosts = [ + "alpha", + "omega" +]