Repository: hylang/hy

Issue:
hy-repr mishandles lexically illegal symbols and keywords
Currently, the `hy-repr` of `(HySymbol "a b")` is `'a b`, which obviously doesn't work. I initially thought we could use something like `(HySymbol "a b")`, but that's not correct in general; e.g., `((HySymbol "foo") x)` is not equivalent to `(foo x)`.

The most obvious solution to this is to implement a way to quote symbols (#1117) and use that. Alternatively, it may be possible to solve this with quasiquoting.

The good news is that this is an incredibly obscure bug, because what reason would one have to construct a lexically illegal symbol or keyword in the first place?


Discussion:

Resolving patch:
diff --git a/hy/compiler.py b/hy/compiler.py
--- a/hy/compiler.py
+++ b/hy/compiler.py
@@ -1738,7 +1738,9 @@ def compile_expression(self, expr, *, allow_annotation_expression=False):
 
                 # 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
--- a/hy/lex/parser.py
+++ b/hy/lex/parser.py
@@ -19,6 +19,10 @@
 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
--- 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
--- 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)