Repository: chimpler/pyhocon

Issue:
pyhocon does not handle backslashes the same way json does
```
In [1]: import json, pyhocon

In [2]: single = r'{"a": "b\.c"}'

In [3]: double = r'{"a": "b\\.c"}'

In [4]: json.loads(single)
---------------------------------------------------------------------------
JSONDecodeError...

In [5]: json.loads(double)
Out[5]: {'a': 'b\\.c'}

In [6]: pyhocon.ConfigFactory.parse_string(single)
Out[6]: ConfigTree([('a', 'b\\.c')])

In [7]: pyhocon.ConfigFactory.parse_string(double)
Out[7]: ConfigTree([('a', 'b\\\\.c')])

In [8]: json.dumps(json.loads(double))
Out[8]: '{"a": "b\\\\.c"}'

In [9]: json.dumps(json.loads(double)) == double
Out[9]: True

In [10]: json.dumps(pyhocon.ConfigFactory.parse_string(double))
Out[10]: '{"a": "b\\\\\\\\.c"}'

In [11]: json.dumps(pyhocon.ConfigFactory.parse_string(double)) == double
Out[11]: False
```

I looked at the HOCON documentation and I couldn't find explicit guidance, but I'd expect that my `single` example would be invalid HOCON (it's invalid JSON?) and that my `double` example would get parsed the same way that `json.loads` does it? 


Discussion:

Patch:
diff --git a/pyhocon/config_parser.py b/pyhocon/config_parser.py
index 0257199..f68839b 100644
--- a/pyhocon/config_parser.py
+++ b/pyhocon/config_parser.py
@@ -3,6 +3,7 @@ import os
 import socket
 import contextlib
 import codecs
+
 from pyparsing import Forward, Keyword, QuotedString, Word, Literal, Suppress, Regex, Optional, SkipTo, ZeroOrMore, \
     Group, lineno, col, TokenConverter, replaceWith, alphanums, alphas8bit, ParseSyntaxException, StringEnd
 from pyparsing import ParserElement
@@ -156,6 +157,7 @@ class ConfigParser(object):
     """
 
     REPLACEMENTS = {
+        '\\\\': '\\',
         '\\\n': '\n',
         '\\n': '\n',
         '\\r': '\r',
@@ -163,7 +165,7 @@ class ConfigParser(object):
         '\\=': '=',
         '\\#': '#',
         '\\!': '!',
-        '\\"': '"'
+        '\\"': '"',
     }
 
     @classmethod
@@ -181,10 +183,14 @@ class ConfigParser(object):
         :return: a ConfigTree or a list
         """
 
+        unescape_pattern = re.compile(r'\\.')
+
+        def replace_escape_sequence(match):
+            value = match.group(0)
+            return cls.REPLACEMENTS.get(value, value)
+
         def norm_string(value):
-            for k, v in cls.REPLACEMENTS.items():
-                value = value.replace(k, v)
-            return value
+            return unescape_pattern.sub(replace_escape_sequence, value)
 
         def unescape_string(tokens):
             return ConfigUnquotedString(norm_string(tokens[0]))


Test patch:
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
index b7ace1b..c580e23 100644
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -1,5 +1,6 @@
 # -*- encoding: utf-8 -*-
 
+import json
 import os
 import mock
 import tempfile
@@ -2104,3 +2105,34 @@ www.example-ö.com {
         assert 'abc' == config['/abc/cde1']
         assert 'cde' == config['/abc/cde2']
         assert 'fgh' == config['/abc/cde3']
+
+    def test_escape_sequences_json_equivalence(self):
+        """
+        Quoted strings are in the same format as JSON strings,
+        See: https://github.com/lightbend/config/blob/master/HOCON.md#unchanged-from-json
+        """
+        source = r"""
+        {
+            "plain-backslash": "\\",
+            "tab": "\t",
+            "no-tab": "\\t",
+            "newline": "\n",
+            "no-newline": "\\n",
+            "cr": "\r",
+            "no-cr": "\\r",
+            "windows": "c:\\temp"
+        }
+        """
+        expected = {
+            'plain-backslash': '\\',
+            'tab': '\t',
+            'no-tab': '\\t',
+            'newline': '\n',
+            'no-newline': '\\n',
+            'cr': '\r',
+            'no-cr': '\\r',
+            'windows': 'c:\\temp',
+        }
+        config = ConfigFactory.parse_string(source)
+        assert config == expected
+        assert config == json.loads(source)