From 7ac81ecb5eed88a458e5d2b9d6da53eaba73e1de Mon Sep 17 00:00:00 2001
From: Havoc Pennington <hp@pobox.com>
Date: Fri, 6 Dec 2013 12:20:43 -0500
Subject: [PATCH] Allow unquoted strings consisting entirely of number chars

Previously, a string which was an invalid number (such as
"1.0.0" or "1e3e3") but consisted entirely of chars allowed
in numbers would be an error and require quoting. Now, we
allow such strings to be unquoted.
---
 HOCON.md                                      |  1 +
 .../com/typesafe/config/impl/Tokenizer.java   | 10 ++++++-
 .../typesafe/config/impl/ConfParserTest.scala | 26 ++++++++++++-------
 .../com/typesafe/config/impl/PathTest.scala   |  3 +++
 .../com/typesafe/config/impl/TestUtils.scala  | 12 ++++++---
 5 files changed, 37 insertions(+), 15 deletions(-)

diff --git a/HOCON.md b/HOCON.md
index dfe46799..e85f1b4b 100644
--- a/HOCON.md
+++ b/HOCON.md
@@ -441,6 +441,7 @@ number-to-string library function).
    be a two-element path with `foo10` and `0` as the elements.
  - `foo"10.0"` is an unquoted then a quoted string which are
    concatenated, so this is a single-element path.
+ - `1.2.3` is the three-element path with `1`,`2`,`3`
 
 Unlike value concatenations, path expressions are _always_
 converted to a string, even if they are just a single value.
diff --git a/config/src/main/java/com/typesafe/config/impl/Tokenizer.java b/config/src/main/java/com/typesafe/config/impl/Tokenizer.java
index 4332aa7b..0da23070 100644
--- a/config/src/main/java/com/typesafe/config/impl/Tokenizer.java
+++ b/config/src/main/java/com/typesafe/config/impl/Tokenizer.java
@@ -355,7 +355,15 @@ final class Tokenizer {
                     return Tokens.newLong(lineOrigin, Long.parseLong(s), s);
                 }
             } catch (NumberFormatException e) {
-                throw problem(s, "Invalid number: '" + s + "'", true /* suggestQuotes */, e);
+                // not a number after all, see if it's an unquoted string.
+                for (char u : s.toCharArray()) {
+                    if (notInUnquotedText.indexOf(u) >= 0)
+                        throw problem(asString(u), "Reserved character '" + asString(u)
+                                      + "' is not allowed outside quotes", true /* suggestQuotes */);
+                }
+                // no evil chars so we just decide this was a string and
+                // not a number.
+                return Tokens.newUnquotedText(lineOrigin, s);
             }
         }
 
diff --git a/config/src/test/scala/com/typesafe/config/impl/ConfParserTest.scala b/config/src/test/scala/com/typesafe/config/impl/ConfParserTest.scala
index c865c651..566d50a7 100644
--- a/config/src/test/scala/com/typesafe/config/impl/ConfParserTest.scala
+++ b/config/src/test/scala/com/typesafe/config/impl/ConfParserTest.scala
@@ -124,6 +124,7 @@ class ConfParserTest extends TestUtils {
         assertEquals(path("a  b"), parsePath(" a  b"))
         assertEquals(path("a", "b.c", "d"), parsePath("a.\"b.c\".d"))
         assertEquals(path("3", "14"), parsePath("3.14"))
+        assertEquals(path("3", "14", "159"), parsePath("3.14.159"))
         assertEquals(path("a3", "14"), parsePath("a3.14"))
         assertEquals(path(""), parsePath("\"\""))
         assertEquals(path("a", "", "b"), parsePath("a.\"\".b"))
@@ -133,6 +134,9 @@ class ConfParserTest extends TestUtils {
         assertEquals(path("a-c"), parsePath("a-c"))
         assertEquals(path("a_c"), parsePath("a_c"))
         assertEquals(path("-"), parsePath("\"-\""))
+        assertEquals(path("-"), parsePath("-"))
+        assertEquals(path("-foo"), parsePath("-foo"))
+        assertEquals(path("-10"), parsePath("-10"))
 
         // here 10.0 is part of an unquoted string
         assertEquals(path("foo10", "0"), parsePath("foo10.0"))
@@ -140,6 +144,8 @@ class ConfParserTest extends TestUtils {
         assertEquals(path("10", "0foo"), parsePath("10.0foo"))
         // just a number
         assertEquals(path("10", "0"), parsePath("10.0"))
+        // multiple-decimal number
+        assertEquals(path("1", "2", "3", "4"), parsePath("1.2.3.4"))
 
         for (invalid <- Seq("", " ", "  \n   \n  ", "a.", ".b", "a..b", "a${b}c", "\"\".", ".\"\"")) {
             try {
@@ -152,11 +158,6 @@ class ConfParserTest extends TestUtils {
                     throw e;
             }
         }
-
-        intercept[ConfigException.Parse] {
-            // this gets parsed as a number since it starts with '-'
-            parsePath("-")
-        }
     }
 
     @Test
@@ -339,11 +340,6 @@ class ConfParserTest extends TestUtils {
         lineNumberTest(2, "\n\"foo\"")
         lineNumberTest(3, "\n\n\"foo\"")
 
-        // newline in middle of number uses the line the number was on
-        lineNumberTest(1, "1e\n")
-        lineNumberTest(2, "\n1e\n")
-        lineNumberTest(3, "\n\n1e\n")
-
         // newlines in triple-quoted string should not hose up the numbering
         lineNumberTest(1, "a : \"\"\"foo\"\"\"}")
         lineNumberTest(2, "a : \"\"\"foo\n\"\"\"}")
@@ -813,4 +809,14 @@ class ConfParserTest extends TestUtils {
         val conf = ConfigFactory.parseString("foo= \uFEFFbar\uFEFF")
         assertEquals("bar", conf.getString("foo"))
     }
+
+    @Test
+    def acceptMultiPeriodNumericPath() {
+        val conf1 = ConfigFactory.parseString("0.1.2.3=foobar1")
+        assertEquals("foobar1", conf1.getString("0.1.2.3"))
+        val conf2 = ConfigFactory.parseString("0.1.2.3.ABC=foobar2")
+        assertEquals("foobar2", conf2.getString("0.1.2.3.ABC"))
+        val conf3 = ConfigFactory.parseString("ABC.0.1.2.3=foobar3")
+        assertEquals("foobar3", conf3.getString("ABC.0.1.2.3"))
+    }
 }
diff --git a/config/src/test/scala/com/typesafe/config/impl/PathTest.scala b/config/src/test/scala/com/typesafe/config/impl/PathTest.scala
index b8a28aea..ccf05a94 100644
--- a/config/src/test/scala/com/typesafe/config/impl/PathTest.scala
+++ b/config/src/test/scala/com/typesafe/config/impl/PathTest.scala
@@ -67,6 +67,9 @@ class PathTest extends TestUtils {
             RenderTest("\" foo \"", path(" foo ")),
             // trailing space only
             RenderTest("\"foo \"", path("foo ")))
+            // numbers with decimal points
+            RenderTest("1.2", path("1", "2"))
+            RenderTest("1.2.3.4", path("1", "2", "3", "4"))
 
         for (t <- tests) {
             assertEquals(t.expected, t.path.render())
diff --git a/config/src/test/scala/com/typesafe/config/impl/TestUtils.scala b/config/src/test/scala/com/typesafe/config/impl/TestUtils.scala
index 440851df..eea01ba8 100644
--- a/config/src/test/scala/com/typesafe/config/impl/TestUtils.scala
+++ b/config/src/test/scala/com/typesafe/config/impl/TestUtils.scala
@@ -342,8 +342,6 @@ abstract trait TestUtils {
         // 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.
-        "[ 1-e3 ]", // malformed number but all chars can appear in a number
         "[ \"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
@@ -380,6 +378,7 @@ abstract trait TestUtils {
         "[ += ]",
         "+= 10",
         "10 +=",
+        "[ 10e+3e ]", // "+" not allowed in unquoted strings, and not a valid number
         ParseTest(true, "[ \"foo\nbar\" ]"), // unescaped newline in quoted string, lift doesn't care
         "[ # comment ]",
         "${ #comment }",
@@ -412,7 +411,8 @@ abstract trait TestUtils {
         "[ \"//comment\" ]", // quoted // comment
         // 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 } }""",
-        "{}")
+        "{}",
+        ParseTest(true, "[ 10e+3 ]")) // "+" in a number (lift doesn't handle)
 
     private val validConfInvalidJson = List[ParseTest]("", // empty document
         " ", // empty document single space
@@ -504,7 +504,11 @@ abstract trait TestUtils {
         "a = [], a += b", // += operator with previous init
         "{ a = [], a += 10 }", // += in braces object with previous init
         "a += b", // += operator without previous init
-        "{ a += 10 }") // += in braces object without previous init
+        "{ a += 10 }", // += in braces object without previous init
+        "[ 10e3e3 ]", // two exponents. this should parse to a number plus string "e3"
+        "[ 1-e3 ]", // malformed number should end up as a string instead
+        "[ 1.0.0 ]", // two decimals, should end up as a string
+        "[ 1.0. ]")  // trailing decimal should end up as a string
 
     protected val invalidJson = validConfInvalidJson ++ invalidJsonInvalidConf;