Repository: hylang/hy

Issue:
Attributes of literals
TL;DR: the way attributes, integers, and identifiers are parsed is a hot, hot mess.

So, right now, parsing a number or symbol looks like this:

- Lexer sees token and tags it as an identifier.
- Parser grabs token, tries to covert it to an integer, float, or fraction, and, failing that, compiles it as a symbol.

The second step means that there are a lot of corner cases that are currently quite bizarre:

```
ryan@DevPC-LX /media/ryan/stuff/hy attrerr $ ./run --spy
hy 0.11.0 using CPython(default) 2.7.6 on Linux
=> (defn f [&rest args &kwargs kw])
def f(*args, **kw):
    pass
=> 

=> 2.2  ; This is a float.
2.2
2.2
=> a.2  ; This is interpreted as getting the attribute '2' on 'a'.
a.2
Traceback (most recent call last):
  File "<input>", line 1, in <module>
NameError: name 'a' is not defined
=> 2.a  ; This is interpreted as getting the attribute 'a' on an identifier '2'.
2.a
Traceback (most recent call last):
  File "<input>", line 1, in <module>
NameError: name '2' is not defined
=> a/b  ; This is parsed as a whole identifier 'a/b'...
a/b
Traceback (most recent call last):
  File "<input>", line 1, in <module>
NameError: name 'a/b' is not defined
=> 1/2  ; ...but this is a fraction.
from hy.core.language import fraction
fraction(1L, 2L)
Fraction(1, 2)
=> (f :x? 1)  ; Here, the ? isn't converted to is_ like usual.
f(x?=1L)
=> (f :*x* 1)  ; The two stars stay uncoverted.
f(*x*=1L)
=> 
ryan@DevPC-LX /media/ryan/stuff/hy attrerr $ 
```


Discussion:
`a.2` seems to be handled correctly.

By contrast, `2.a` should probably treat `2` as a literal instead of an identifier. In particular, `4j.imag` doesn't work. Although, `"a".join` yields the error `cannot access attribute on anything other than a name (in order to get attributes of expressions, use…)`, so maybe this is intentional.

`a/b` and `1/2` seem correct.

The other cases are discussed in #1168.

Resolving patch:
diff --git a/hy/lex/parser.py b/hy/lex/parser.py
--- a/hy/lex/parser.py
+++ b/hy/lex/parser.py
@@ -288,6 +288,24 @@ def t_partial_string(p):
 def t_identifier(p):
     obj = p[0].value
 
+    val = symbol_like(obj)
+    if val is not None:
+        return val
+
+    if "." in obj and symbol_like(obj.split(".", 1)[0]) is not None:
+        # E.g., `5.attr` or `:foo.attr`
+        raise LexException(
+            'Cannot access attribute on anything other than a name (in '
+            'order to get attributes of expressions, use '
+            '`(. <expression> <attr>)` or `(.<attr> <expression>)`)',
+            p[0].source_pos.lineno, p[0].source_pos.colno)
+
+    return HySymbol(".".join(hy_symbol_mangle(x) for x in obj.split(".")))
+
+
+def symbol_like(obj):
+    "Try to interpret `obj` as a number or keyword."
+
     try:
         return HyInteger(obj)
     except ValueError:
@@ -312,13 +330,9 @@ def t_identifier(p):
         except ValueError:
             pass
 
-    if obj.startswith(":"):
+    if obj.startswith(":") and "." not in obj:
         return HyKeyword(obj)
 
-    obj = ".".join([hy_symbol_mangle(part) for part in obj.split(".")])
-
-    return HySymbol(obj)
-
 
 @pg.error
 def error_handler(token):


Test patch:
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
@@ -1446,7 +1446,6 @@
   (assert (= (keyword 'foo) :foo))
   (assert (= (keyword 'foo-bar) :foo-bar))
   (assert (= (keyword 1) :1))
-  (assert (= (keyword 1.0) :1.0))
   (assert (= (keyword :foo_bar) :foo-bar)))
 
 (defn test-name-conversion []
diff --git a/tests/test_lex.py b/tests/test_lex.py
--- a/tests/test_lex.py
+++ b/tests/test_lex.py
@@ -5,56 +5,31 @@
 from hy.models import (HyExpression, HyInteger, HyFloat, HyComplex, HySymbol,
                        HyString, HyDict, HyList, HySet, HyCons)
 from hy.lex import LexException, PrematureEndOfInput, tokenize
+import pytest
+
+def peoi(): return pytest.raises(PrematureEndOfInput)
+def lexe(): return pytest.raises(LexException)
 
 
 def test_lex_exception():
     """ Ensure tokenize throws a fit on a partial input """
-    try:
-        tokenize("(foo")
-        assert True is False
-    except PrematureEndOfInput:
-        pass
-    try:
-        tokenize("{foo bar")
-        assert True is False
-    except PrematureEndOfInput:
-        pass
-    try:
-        tokenize("(defn foo [bar]")
-        assert True is False
-    except PrematureEndOfInput:
-        pass
-    try:
-        tokenize("(foo \"bar")
-        assert True is False
-    except PrematureEndOfInput:
-        pass
+    with peoi(): tokenize("(foo")
+    with peoi(): tokenize("{foo bar")
+    with peoi(): tokenize("(defn foo [bar]")
+    with peoi(): tokenize("(foo \"bar")
 
 
 def test_unbalanced_exception():
     """Ensure the tokenization fails on unbalanced expressions"""
-    try:
-        tokenize("(bar))")
-        assert True is False
-    except LexException:
-        pass
-
-    try:
-        tokenize("(baz [quux]])")
-        assert True is False
-    except LexException:
-        pass
+    with lexe(): tokenize("(bar))")
+    with lexe(): tokenize("(baz [quux]])")
 
 
 def test_lex_single_quote_err():
     "Ensure tokenizing \"' \" throws a LexException that can be stringified"
     # https://github.com/hylang/hy/issues/1252
-    try:
-        tokenize("' ")
-    except LexException as e:
-        assert "Could not identify the next token" in str(e)
-    else:
-        assert False
+    with lexe() as e: tokenize("' ")
+    assert "Could not identify the next token" in str(e.value)
 
 
 def test_lex_expression_symbols():
@@ -154,6 +129,21 @@ def test_lex_digit_separators():
             [HySymbol(",,,,___,__1__,,__,,2__,q,__")])
 
 
+def test_lex_bad_attrs():
+    with lexe(): tokenize("1.foo")
+    with lexe(): tokenize("0.foo")
+    with lexe(): tokenize("1.5.foo")
+    with lexe(): tokenize("1e3.foo")
+    with lexe(): tokenize("5j.foo")
+    with lexe(): tokenize("3+5j.foo")
+    with lexe(): tokenize("3.1+5.1j.foo")
+    assert tokenize("j.foo")
+    with lexe(): tokenize("3/4.foo")
+    assert tokenize("a/1.foo")
+    assert tokenize("1/a.foo")
+    with lexe(): tokenize(":hello.foo")
+
+
 def test_lex_line_counting():
     """ Make sure we can count lines / columns """
     entry = tokenize("(foo (one two))")[0]