Repository: hylang/hy

Issue:
Add `|` quotes and `\` escapes to Hy symbols and keywords.
Common Lisp lets you use arbitrary strings as symbols. If you include certain characters you have to escape them, either by quoting the whole string in `||`, or with a `\` per character. 

``` Lisp
* (symbolp '|Am I a symbol?|)

T
* '|Am I a symbol?|

|Am I a symbol?|
* (symbolp 'Am\ I?)

T
* 'Am\ I?

|AM I?|
```

Common Lisp's reader uppercases symbols by default, but valid symbols can have lower case characters as demonstrated above. Hy's reader does similar shenanigans with `*`, `-`, `?`, and `!`, yet does allow them in symbols.

``` Hy
=> '*A-symbol!*
'A_SYMBOL_bang'
=> (HySymbol "*A-symbol!*")
'*A-symbol!*'
```

I propose we add the Common lisp style symbol quotes and escape to Hy, so if you _really_ want the symbol to contain a `-` or end in `?` (etc.), you can, but most of the time it will get mangled to something Python can use more easily as now. This could make Hy easier to work with when these details matter.

``` Hy
=> '|*A-symbol!*|
'*A-symbol!*'
```


Discussion:

Patch:
diff --git a/NEWS.rst b/NEWS.rst
index 7d153ba6..be54477d 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -48,6 +48,8 @@ Bug Fixes
 * `hy-repr` won't use the registerd method of a supertype anymore
 * REPL `--spy` and `--repl-output-fn` can now overwrite `HYSTARTUP` values
   when specified as a cmdline argument
+* The constructors of `Symbol` and `Keyword` now check that the input
+  would be syntactically legal
 
 .. _Toolz: https://toolz.readthedocs.io
 .. _CyToolz: https://github.com/pytoolz/cytoolz
diff --git a/hy/compiler.py b/hy/compiler.py
index 467d4c35..aa5843c7 100755
--- a/hy/compiler.py
+++ b/hy/compiler.py
@@ -1738,7 +1738,9 @@ class HyASTCompiler(object):
 
                 # Get the method name (the last named attribute
                 # in the chain of attributes)
-                attrs = [Symbol(a).replace(root) for a in root.split(".")[1:]]
+                attrs = [
+                    Symbol(a).replace(root) if a else None
+                    for a in root.split(".")[1:]]
                 if not all(attrs):
                     raise self._syntax_error(expr,
                          "cannot access empty attribute")
diff --git a/hy/lex/parser.py b/hy/lex/parser.py
index a69be035..e04ae3ce 100755
--- a/hy/lex/parser.py
+++ b/hy/lex/parser.py
@@ -19,6 +19,10 @@ from .exceptions import LexException, PrematureEndOfInput
 pg = ParserGenerator([rule.name for rule in lexer.rules] + ['$end'])
 
 
+def sym(x):
+    return Symbol(x, from_parser = True)
+
+
 def set_boundaries(fun):
     @wraps(fun)
     def wrapped(state, p):
@@ -114,31 +118,31 @@ def term_discard(state, p):
 @pg.production("term : QUOTE term")
 @set_quote_boundaries
 def term_quote(state, p):
-    return Expression([Symbol("quote"), p[1]])
+    return Expression([sym("quote"), p[1]])
 
 
 @pg.production("term : QUASIQUOTE term")
 @set_quote_boundaries
 def term_quasiquote(state, p):
-    return Expression([Symbol("quasiquote"), p[1]])
+    return Expression([sym("quasiquote"), p[1]])
 
 
 @pg.production("term : UNQUOTE term")
 @set_quote_boundaries
 def term_unquote(state, p):
-    return Expression([Symbol("unquote"), p[1]])
+    return Expression([sym("unquote"), p[1]])
 
 
 @pg.production("term : UNQUOTESPLICE term")
 @set_quote_boundaries
 def term_unquote_splice(state, p):
-    return Expression([Symbol("unquote-splice"), p[1]])
+    return Expression([sym("unquote-splice"), p[1]])
 
 
 @pg.production("term : ANNOTATION term")
 @set_quote_boundaries
 def term_annotation(state, p):
-    return Expression([Symbol("annotate*"), p[1]])
+    return Expression([sym("annotate*"), p[1]])
 
 
 @pg.production("term : HASHSTARS term")
@@ -146,22 +150,22 @@ def term_annotation(state, p):
 def term_hashstars(state, p):
     n_stars = len(p[0].getstr()[1:])
     if n_stars == 1:
-        sym = "unpack-iterable"
+        s = "unpack-iterable"
     elif n_stars == 2:
-        sym = "unpack-mapping"
+        s = "unpack-mapping"
     else:
         raise LexException.from_lexer(
             "Too many stars in `#*` construct (if you want to unpack a symbol "
             "beginning with a star, separate it with whitespace)",
             state, p[0])
-    return Expression([Symbol(sym), p[1]])
+    return Expression([sym(s), p[1]])
 
 
 @pg.production("term : HASHOTHER term")
 @set_quote_boundaries
 def hash_other(state, p):
     # p == [(Token('HASHOTHER', '#foo'), bar)]
-    return Expression([Symbol(p[0].getstr()), p[1]])
+    return Expression([sym(p[0].getstr()), p[1]])
 
 
 @pg.production("set : HLCURLY list_contents RCURLY")
@@ -367,7 +371,7 @@ def t_identifier(state, p):
             '`(. <expression> <attr>)` or `(.<attr> <expression>)`)',
             state, p[0])
 
-    return Symbol(obj)
+    return sym(obj)
 
 
 def symbol_like(obj):
@@ -381,7 +385,7 @@ def symbol_like(obj):
     if '/' in obj:
         try:
             lhs, rhs = obj.split('/')
-            return Expression([Symbol('hy._Fraction'), Integer(lhs),
+            return Expression([sym('hy._Fraction'), Integer(lhs),
                                Integer(rhs)])
         except ValueError:
             pass
@@ -398,7 +402,7 @@ def symbol_like(obj):
             pass
 
     if obj.startswith(":") and "." not in obj:
-        return Keyword(obj[1:])
+        return Keyword(obj[1:], from_parser = True)
 
 
 @pg.error
diff --git a/hy/models.py b/hy/models.py
index fe552763..33ffb686 100644
--- a/hy/models.py
+++ b/hy/models.py
@@ -4,6 +4,7 @@
 from __future__ import unicode_literals
 
 from contextlib import contextmanager
+import re
 from math import isnan, isinf
 from hy import _initialize_env_var
 from hy.errors import HyWrapperError
@@ -144,7 +145,14 @@ class Symbol(Object, str):
     Hy Symbol. Basically a string.
     """
 
-    def __new__(cls, s=None):
+    def __new__(cls, s, from_parser = False):
+        s = str(s)
+        if not from_parser:
+            # Check that the symbol is syntactically legal.
+            from hy.lex.lexer import identifier
+            from hy.lex.parser import symbol_like
+            if not re.fullmatch(identifier, s) or symbol_like(s) is not None:
+                raise ValueError(f'Syntactically illegal symbol: {s!r}')
         return super(Symbol, cls).__new__(cls, s)
 
 _wrappers[bool] = lambda x: Symbol("True") if x else Symbol("False")
@@ -156,7 +164,13 @@ class Keyword(Object):
 
     __slots__ = ['name']
 
-    def __init__(self, value):
+    def __init__(self, value, from_parser = False):
+        value = str(value)
+        if not from_parser:
+            # Check that the keyword is syntactically legal.
+            from hy.lex.lexer import identifier
+            if value and (not re.fullmatch(identifier, value) or "." in value):
+                raise ValueError(f'Syntactically illegal keyword: {":" + value!r}')
         self.name = value
 
     def __repr__(self):


Test patch:
diff --git a/tests/test_models.py b/tests/test_models.py
index daab4db6..58d4695d 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -3,13 +3,27 @@
 # license. See the LICENSE.
 
 import copy
+import pytest
 import hy
-from hy.models import (wrap_value, replace_hy_obj, String, Integer, List,
-                       Dict, Set, Expression, Complex, Float, pretty)
+from hy.models import (
+    wrap_value, replace_hy_obj, Symbol, Keyword, String, Integer,
+    List, Dict, Set, Expression, Complex, Float, pretty)
 
 hy.models.COLORED = False
 
 
+def test_symbol_or_keyword():
+    for x in ("foo", "foo-bar", "foo_bar", "✈é😂⁂"):
+        assert str(Symbol(x)) == x
+        assert Keyword(x).name == x
+    for x in ("", ":foo", "5"):
+        with pytest.raises(ValueError): Symbol(x)
+        assert Keyword(x).name == x
+    for x in ("foo bar", "fib()"):
+        with pytest.raises(ValueError): Symbol(x)
+        with pytest.raises(ValueError): Keyword(x)
+
+
 def test_wrap_int():
     """ Test conversion of integers."""
     wrapped = wrap_value(0)