Repository: hylang/hy

Issue:
Perplexing keyword behavior in splice
```Hy
(defn print-args [&rest args &kwargs kwargs]
  (print args kwargs))
```
```Python
=> (print-args :key 42)  ; normal keyword magic
print_args(key=42)
() {'key': 42}
=> (print-args ':key 42)  ; quote to pass keyword as data
from hy import HyKeyword
print_args(HyKeyword('\ufdd0:key'), 42)
('\ufdd0:key', 42) {}
```
That was strange for a Lisp, but easy enough to understand.

```Python
=> (eval `(print-args ~@[:key 42]))  ; raw keyword
from hy.core.language import eval
from hy import HyExpression, HySymbol
eval(HyExpression((([] + [HySymbol('print_args')]) + list(['\ufdd0:key', 42]))))
('\ufdd0:key', 42) {}
=> (eval `(print-args ~@[':key 42]))  ; quoted keyword
from hy.core.language import eval
from hy import HyExpression, HyKeyword, HySymbol
eval(HyExpression((([] + [HySymbol('print_args')]) + list([HyKeyword('\ufdd0:key'), 42]))))
() {'key': 42}
```
Somehow it became the opposite? Is this how it's supposed to work?


Discussion:
I think so. The trick is that in Hy, unlike most Lisps, keywords don't evaluate to themselves. They evaluate to strings, not `HyKeyword`s. So the expression `` `(print-args ~@[:key 42])`` evaluates to `(print-args "\\ufdd0:key" 42)`; if you evaluate this, the compiler will see the string `"\\ufdd0:key"` instead of a literal keyword. By contrast, `` `(print-args ~@[':key 42])`` evaluates to `(print-args :key 42)`. If you want to pass a real `HyKeyword` to `print-args` here, you need **two** single quotes (i.e., `` `(print-args ~@['':key 42])``) because you're evaluating `:key` twice, once with `~@` and once with `eval`.

Resolving patch:
diff --git a/hy/compiler.py b/hy/compiler.py
--- a/hy/compiler.py
+++ b/hy/compiler.py
@@ -500,19 +500,18 @@ def _compile_collect(self, exprs, with_kwargs=False, dict_display=False,
                 except StopIteration:
                     raise HyTypeError(expr,
                                       "Keyword argument {kw} needs "
-                                      "a value.".format(kw=str(expr[1:])))
+                                      "a value.".format(kw=expr))
 
-                compiled_value = self.compile(value)
-                ret += compiled_value
-
-                keyword = expr[2:]
-                if not keyword:
+                if not expr:
                     raise HyTypeError(expr, "Can't call a function with the "
                                             "empty keyword")
-                keyword = ast_str(keyword)
 
+                compiled_value = self.compile(value)
+                ret += compiled_value
+
+                arg = str_type(expr)[1:]
                 keywords.append(asty.keyword(
-                    expr, arg=keyword, value=compiled_value.force_expr))
+                    expr, arg=ast_str(arg), value=compiled_value.force_expr))
 
             else:
                 ret += self.compile(expr)
@@ -742,10 +741,13 @@ def _render_quoted_form(self, form, level):
             return imports, HyExpression([HySymbol(name),
                                           HyString(form)]).replace(form), False
 
+        elif isinstance(form, HyKeyword):
+            return imports, form, False
+
         elif isinstance(form, HyString):
             x = [HySymbol(name), form]
             if form.brackets is not None:
-                x.extend([HyKeyword(":brackets"), form.brackets])
+                x.extend([HyKeyword("brackets"), form.brackets])
             return imports, HyExpression(x).replace(form), False
 
         return imports, HyExpression([HySymbol(name),
@@ -809,7 +811,7 @@ def compile_raise_expression(self, expr):
             ret += self.compile(expr.pop(0))
 
         cause = None
-        if len(expr) == 2 and expr[0] == HyKeyword(":from"):
+        if len(expr) == 2 and expr[0] == HyKeyword("from"):
             if not PY3:
                 raise HyCompileError(
                     "raise from only supported in python 3")
@@ -1168,7 +1170,7 @@ def _compile_import(expr, module, names=None, importer=asty.Import):
             if isinstance(iexpr, HyList) and iexpr:
                 module = iexpr.pop(0)
                 entry = iexpr[0]
-                if isinstance(entry, HyKeyword) and entry == HyKeyword(":as"):
+                if entry == HyKeyword("as"):
                     if not len(iexpr) == 2:
                         raise HyTypeError(iexpr,
                                           "garbage after aliased import")
@@ -1462,7 +1464,7 @@ def compile_require(self, expression):
                 else:
                     assignments = {}
                     while names:
-                        if len(names) > 1 and names[1] == HyKeyword(":as"):
+                        if len(names) > 1 and names[1] == HyKeyword("as"):
                             k, _, v = names[:3]
                             del names[:3]
                             assignments[k] = v
@@ -1471,7 +1473,7 @@ def compile_require(self, expression):
                             assignments[symbol] = symbol
                     require(module, self.module_name, assignments=assignments)
             elif (isinstance(entry, HyList) and len(entry) == 3
-                    and entry[1] == HyKeyword(":as")):
+                    and entry[1] == HyKeyword("as")):
                 # e.g., (require [foo :as bar])
                 module, _, prefix = entry
                 __import__(module)
@@ -1764,7 +1766,7 @@ def compile_expression(self, expression):
         # An exception for pulling together keyword args is if we're doing
         # a typecheck, eg (type :foo)
         with_kwargs = fn not in (
-            "type", "HyKeyword", "keyword", "name", "keyword?")
+            "type", "HyKeyword", "keyword", "name", "keyword?", "identity")
         args, ret, keywords, oldpy_star, oldpy_kw = self._compile_collect(
             expression[1:], with_kwargs, oldpy_unpack=True)
 
@@ -2187,7 +2189,18 @@ def compile_symbol(self, symbol):
 
         return asty.Name(symbol, id=ast_str(symbol), ctx=ast.Load())
 
-    @builds(HyString, HyKeyword, HyBytes)
+    @builds(HyKeyword)
+    def compile_keyword(self, obj):
+        ret = Result()
+        ret += asty.Call(
+            obj,
+            func=asty.Name(obj, id="HyKeyword", ctx=ast.Load()),
+            args=[asty.Str(obj, s=obj.name)],
+            keywords=[])
+        ret.add_imports("hy", {"HyKeyword"})
+        return ret
+
+    @builds(HyString, HyBytes)
     def compile_string(self, string, building):
         node = asty.Bytes if PY3 and building is HyBytes else asty.Str
         f = bytes_type if building is HyBytes else str_type
diff --git a/hy/lex/parser.py b/hy/lex/parser.py
--- a/hy/lex/parser.py
+++ b/hy/lex/parser.py
@@ -380,7 +380,7 @@ def symbol_like(obj):
             pass
 
     if obj.startswith(":") and "." not in obj:
-        return HyKeyword(obj)
+        return HyKeyword(obj[1:])
 
 
 @pg.error
diff --git a/hy/models.py b/hy/models.py
--- a/hy/models.py
+++ b/hy/models.py
@@ -121,22 +121,35 @@ def __init__(self, string):
 _wrappers[type(None)] = lambda foo: HySymbol("None")
 
 
-class HyKeyword(HyObject, str_type):
-    """Generic Hy Keyword object. It's either a ``str`` or a ``unicode``,
-    depending on the Python version.
-    """
-
-    PREFIX = "\uFDD0"
+class HyKeyword(HyObject):
+    """Generic Hy Keyword object."""
 
-    def __new__(cls, value):
-        if not value.startswith(cls.PREFIX):
-            value = cls.PREFIX + value
+    __slots__ = ['name']
 
-        obj = str_type.__new__(cls, value)
-        return obj
+    def __init__(self, value):
+        self.name = value
 
     def __repr__(self):
-        return "%s(%s)" % (self.__class__.__name__, repr(self[1:]))
+        return "%s(%r)" % (self.__class__.__name__, self.name)
+
+    def __str__(self):
+        return ":%s" % self.name
+
+    def __hash__(self):
+        return hash(self.name)
+
+    def __eq__(self, other):
+        if not isinstance(other, HyKeyword):
+            return NotImplemented
+        return self.name == other.name
+
+    def __ne__(self, other):
+        if not isinstance(other, HyKeyword):
+            return NotImplemented
+        return self.name != other.name
+
+    def __bool__(self):
+        return bool(self.name)
 
 
 def strip_digit_separators(number):


Test patch:
diff --git a/tests/native_tests/contrib/hy_repr.hy b/tests/native_tests/contrib/hy_repr.hy
--- a/tests/native_tests/contrib/hy_repr.hy
+++ b/tests/native_tests/contrib/hy_repr.hy
@@ -79,14 +79,6 @@
   (assert (is (type (get orig 1)) float))
   (assert (is (type (get result 1)) HyFloat)))
 
-(when PY3 (defn test-bytes-keywords []
-  ; Make sure that keyword-like bytes objects aren't hy-repred as if
-  ; they were real keywords.
-  (setv kw :mykeyword)
-  (assert (= (hy-repr kw) ":mykeyword"))
-  (assert (= (hy-repr (str ':mykeyword)) ":mykeyword"))
-  (assert (= (hy-repr (.encode kw "UTF-8") #[[b"\xef\xb7\x90:hello"]])))))
-
 (when PY3 (defn test-dict-views []
   (assert (= (hy-repr (.keys {1 2})) "(dict-keys [1])"))
   (assert (= (hy-repr (.values {1 2})) "(dict-values [2])"))
diff --git a/tests/native_tests/language.hy b/tests/native_tests/language.hy
--- a/tests/native_tests/language.hy
+++ b/tests/native_tests/language.hy
@@ -554,7 +554,7 @@
   (assert (= (.a.b.meth :b "1" :a "2" x "foo" "bar") "meth foo bar 2 1"))
   (assert (= (.a.b.meth x #* ["foo" "bar"]) "meth foo bar"))
 
-  (assert (is (.isdigit :foo) False)))
+  (assert (= (.__str__ :foo) ":foo")))
 
 
 (defn test-do []
@@ -1221,9 +1221,12 @@
   "NATIVE: test if keywords are recognised"
 
   (assert (= :foo :foo))
+  (assert (= :foo ':foo))
+  (assert (is (type :foo) (type ':foo)))
   (assert (= (get {:foo "bar"} :foo) "bar"))
   (assert (= (get {:bar "quux"} (get {:foo :bar} :foo)) "quux")))
 
+
 (defn test-keyword-clash []
   "NATIVE: test that keywords do not clash with normal strings"
 
@@ -1649,11 +1652,6 @@ macros()
   (setv (. foo [1] test) "hello")
   (assert (= (getattr (. foo [1]) "test") "hello")))
 
-(defn test-keyword-quoting []
-  "NATIVE: test keyword quoting magic"
-  (assert (= :foo "\ufdd0:foo"))
-  (assert (= `:foo "\ufdd0:foo")))
-
 (defn test-only-parse-lambda-list-in-defn []
   "NATIVE: test lambda lists are only parsed in defn"
   (try
diff --git a/tests/test_lex.py b/tests/test_lex.py
--- a/tests/test_lex.py
+++ b/tests/test_lex.py
@@ -434,9 +434,9 @@ def test_discard():
     assert tokenize("(#_foo)") == [HyExpression()]
     assert tokenize("(#_foo bar)") == [HyExpression([HySymbol("bar")])]
     assert tokenize("(foo #_bar)") == [HyExpression([HySymbol("foo")])]
-    assert tokenize("(foo :bar 1)") == [HyExpression([HySymbol("foo"), HyKeyword(":bar"), HyInteger(1)])]
+    assert tokenize("(foo :bar 1)") == [HyExpression([HySymbol("foo"), HyKeyword("bar"), HyInteger(1)])]
     assert tokenize("(foo #_:bar 1)") == [HyExpression([HySymbol("foo"), HyInteger(1)])]
-    assert tokenize("(foo :bar #_1)") == [HyExpression([HySymbol("foo"), HyKeyword(":bar")])]
+    assert tokenize("(foo :bar #_1)") == [HyExpression([HySymbol("foo"), HyKeyword("bar")])]
     # discard term with nesting
     assert tokenize("[1 2 #_[a b c [d e [f g] h]] 3 4]") == [
         HyList([HyInteger(1), HyInteger(2), HyInteger(3), HyInteger(4)])