Repository: hylang/hy

Issue:
Decorating anonymous functions is inconsistent
Attempting to decorate an `fn` that can compile down to a `lambda` fails, but it won't fail if the `fn` contains statements that force compilation to an anonymous `def`

```clojure
=> (with-decorator cache (fn [x] x))
Traceback (most recent call last):
  File "stdin-ecb69bbf92e18e5e87b4bc8c6fd3222c5969d4a8", line 1, in <module>
    (with-decorator cache (fn [x] x))
  File "<stdin>", line 1
    (with-decorator cache (fn [x] x))
                          ^
hy.errors.HySyntaxError: Decorated a non-function
```

```clojure
=> (with-decorator cache (fn [x] (print 1) x))
@cache
def _hy_anon_var_2(x):
    print(1)
    return x
_hy_anon_var_2
```

I think it makes the most sense to force the anon `def` when an `fn` uses a decorator like it already does when it contains statements or type annotations. 


Discussion:

Resolving patch:
diff --git a/hy/core/result_macros.py b/hy/core/result_macros.py
--- a/hy/core/result_macros.py
+++ b/hy/core/result_macros.py
@@ -1398,25 +1398,32 @@ def compile_function_lambda(compiler, expr, root, returns, params, body):
     # Otherwise create a standard function
     node = asty.AsyncFunctionDef if root == "fn/a" else asty.FunctionDef
     name = compiler.get_anon_var()
-    ret += compile_function_node(compiler, expr, node, name, args, returns, body)
+    ret += compile_function_node(compiler, expr, node, [], name, args, returns, body)
 
     # return its name as the final expr
     return ret + Result(expr=ret.temp_variables[0])
 
 
-@pattern_macro(["defn", "defn/a"], [OPTIONAL_ANNOTATION, SYM, lambda_list, many(FORM)])
-def compile_function_def(compiler, expr, root, returns, name, params, body):
+@pattern_macro(
+    ["defn", "defn/a"],
+    [maybe(brackets(many(FORM))), OPTIONAL_ANNOTATION, SYM, lambda_list, many(FORM)],
+)
+def compile_function_def(compiler, expr, root, decorators, returns, name, params, body):
     node = asty.FunctionDef if root == "defn" else asty.AsyncFunctionDef
-    args, ret = compile_lambda_list(compiler, params)
+    decorators, ret, _ = compiler._compile_collect(decorators[0] if decorators else [])
+    args, ret2 = compile_lambda_list(compiler, params)
+    ret += ret2
     name = mangle(compiler._nonconst(name))
     compiler.scope.define(name)
     with compiler.scope.create(ScopeFn, args):
         body = compiler._compile_branch(body)
 
-    return ret + compile_function_node(compiler, expr, node, name, args, returns, body)
+    return ret + compile_function_node(
+        compiler, expr, node, decorators, name, args, returns, body
+    )
 
 
-def compile_function_node(compiler, expr, node, name, args, returns, body):
+def compile_function_node(compiler, expr, node, decorators, name, args, returns, body):
     ret = Result()
 
     if body.expr:
@@ -1427,7 +1434,7 @@ def compile_function_node(compiler, expr, node, name, args, returns, body):
         name=name,
         args=args,
         body=body.stmts or [asty.Pass(expr)],
-        decorator_list=[],
+        decorator_list=decorators,
         returns=compiler.compile(returns).force_expr if returns is not None else None,
     )
 
@@ -1573,19 +1580,6 @@ def compile_arguments_set(compiler, decls, ret, is_kwonly=False):
     return args_ast, args_defaults, ret
 
 
-_decoratables = (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef)
-
-
-@pattern_macro("with-decorator", [oneplus(FORM)])
-def compile_decorate_expression(compiler, expr, name, args):
-    decs, fn = args[:-1], compiler.compile(args[-1])
-    if not fn.stmts or not isinstance(fn.stmts[-1], _decoratables):
-        raise compiler._syntax_error(args[-1], "Decorated a non-function")
-    decs, ret, _ = compiler._compile_collect(decs)
-    fn.stmts[-1].decorator_list = decs + fn.stmts[-1].decorator_list
-    return ret + fn
-
-
 @pattern_macro("return", [maybe(FORM)])
 def compile_return(compiler, expr, root, arg):
     ret = Result()
@@ -1615,13 +1609,22 @@ def compile_yield_from_or_await_expression(compiler, expr, root, arg):
 # ------------------------------------------------
 
 
-@pattern_macro("defclass", [SYM, maybe(brackets(many(FORM)) + maybe(STR) + many(FORM))])
-def compile_class_expression(compiler, expr, root, name, rest):
+@pattern_macro(
+    "defclass",
+    [
+        maybe(brackets(many(FORM))),
+        SYM,
+        maybe(brackets(many(FORM)) + maybe(STR) + many(FORM)),
+    ],
+)
+def compile_class_expression(compiler, expr, root, decorators, name, rest):
     base_list, docstring, body = rest or ([[]], None, [])
 
-    bases_expr, bases, keywords = compiler._compile_collect(
+    decorators, ret, _ = compiler._compile_collect(decorators[0] if decorators else [])
+    bases_expr, ret2, keywords = compiler._compile_collect(
         base_list[0], with_kwargs=True
     )
+    ret += ret2
 
     bodyr = Result()
 
@@ -1635,9 +1638,9 @@ def compile_class_expression(compiler, expr, root, name, rest):
         e = compiler._compile_branch(body)
         bodyr += e + e.expr_as_stmt()
 
-    return bases + asty.ClassDef(
+    return ret + asty.ClassDef(
         expr,
-        decorator_list=[],
+        decorator_list=decorators,
         name=name,
         keywords=keywords,
         starargs=None,


Test patch:
diff --git a/tests/compilers/test_ast.py b/tests/compilers/test_ast.py
--- a/tests/compilers/test_ast.py
+++ b/tests/compilers/test_ast.py
@@ -381,11 +381,6 @@ def test_ast_anon_fns_basics():
     cant_compile("(fn)")
 
 
-def test_ast_non_decoratable():
-    """Ensure decorating garbage breaks"""
-    cant_compile("(with-decorator (foo) (* x x))")
-
-
 def test_ast_lambda_lists():
     """Ensure the compiler chokes on invalid lambda-lists"""
     cant_compile("(fn [[a b c]] a)")
diff --git a/tests/native_tests/comprehensions.hy b/tests/native_tests/comprehensions.hy
--- a/tests/native_tests/comprehensions.hy
+++ b/tests/native_tests/comprehensions.hy
@@ -18,9 +18,10 @@
   (assert (is (type (gfor x "abc" :do (setv y 1) x)) types.GeneratorType)))
 
 
-(with-decorator (pytest.mark.parametrize "specialop"
-  ["for" "lfor" "sfor" "gfor" "dfor"])
-(defn test-fors [specialop]
+(defn
+  [(pytest.mark.parametrize "specialop"
+    ["for" "lfor" "sfor" "gfor" "dfor"])]
+  test-fors [specialop]
 
   (setv cases [
     ['(f x [] x)
@@ -97,7 +98,7 @@
     (setv result (hy.eval expr))
     (when (= specialop "dfor")
       (setv result (.keys result)))
-    (assert (= (sorted result) answer) (str expr)))))
+    (assert (= (sorted result) answer) (str expr))))
 
 
 (defn test-fors-no-loopers []
diff --git a/tests/native_tests/decorators.hy b/tests/native_tests/decorators.hy
new file mode 100644
--- /dev/null
+++ b/tests/native_tests/decorators.hy
@@ -0,0 +1,48 @@
+(defn test-decorated-1line-function []
+  (defn foodec [func]
+    (fn [] (+ (func) 1)))
+  (defn [foodec] tfunction []
+    (* 2 2))
+  (assert (= (tfunction) 5)))
+
+
+(defn test-decorated-multiline-function []
+  (defn bazdec [func]
+    (fn [] (+ (func) "x")))
+  (defn [bazdec] f []
+    (setv intermediate "i")
+    (+ intermediate "b"))
+  (assert (= (f) "ibx")))
+
+
+(defn test-decorated-class []
+  (defn bardec [cls]
+    (setv cls.attr2 456)
+    cls)
+  (defclass [bardec] cls []
+    (setv attr1 123))
+  (assert (= cls.attr1 123))
+  (assert (= cls.attr2 456)))
+
+
+(defn test-stacked-decorators []
+  (defn dec1 [f] (fn [] (+ (f) "a")))
+  (defn dec2 [f] (fn [] (+ (f) "b")))
+  (defn [dec1 dec2] f [] "c")
+  (assert (= (f) "cba")))
+
+
+(defn test-evaluation-order []
+  (setv l [])
+  (defn foo [f]
+    (.append l "foo")
+    (fn []
+      (.append l "foo fn")
+      (f)))
+  (defn
+    [(do (.append l "dec") foo)]     ; Decorator list
+    bar                              ; Function name
+    [[arg (do (.append l "arg") 1)]] ; Lambda list
+    (.append l "bar body") arg)      ; Body
+  (.append l (bar))
+  (assert (= l ["dec" "arg" "foo" "foo fn" "bar body" 1])))
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
@@ -1704,11 +1704,9 @@ cee"} dee" "ey bee\ncee dee"))
 (defn test-decorated-defn/a []
   (defn decorator [func] (fn/a [] (/ (await (func)) 2)))
 
-  (with-decorator
-    decorator
-    (defn/a coro-test []
-      (await (asyncio.sleep 0))
-      42))
+  (defn/a [decorator] coro-test []
+    (await (asyncio.sleep 0))
+    42)
   (assert (= (asyncio.run (coro-test)) 21)))
 
 
diff --git a/tests/native_tests/native_macros.hy b/tests/native_tests/native_macros.hy
--- a/tests/native_tests/native_macros.hy
+++ b/tests/native_tests/native_macros.hy
@@ -244,77 +244,75 @@ in expansions."
 
   (require-macros))
 
-(with-decorator
-  pytest.mark.xfail
-  (defn test-macro-from-module []
-    "
-    Macros loaded from an external module, which itself `require`s macros, should
-    work without having to `require` the module's macro dependencies (due to
-    [minimal] macro namespace resolution).
-
-    In doing so we also confirm that a module's `__macros__` attribute is correctly
-    loaded and used.
-
-    Additionally, we confirm that `require` statements are executed via loaded bytecode.
-    "
-
-    (setv pyc-file (importlib.util.cache-from-source
-                     (os.path.realpath
-                       (os.path.join
-                         "tests" "resources" "macro_with_require.hy"))))
-
-    ;; Remove any cached byte-code, so that this runs from source and
-    ;; gets evaluated in this module.
-    (when (os.path.isfile pyc-file)
-      (os.unlink pyc-file)
-      (.clear sys.path_importer_cache)
-      (when (in  "tests.resources.macro_with_require" sys.modules)
-        (del (get sys.modules "tests.resources.macro_with_require"))
-        (__macros__.clear)))
-
-    ;; Ensure that bytecode isn't present when we require this module.
-    (assert (not (os.path.isfile pyc-file)))
-
-    (defn test-requires-and-macros []
-      (require tests.resources.macro-with-require
-               [test-module-macro])
-
-      ;; Make sure that `require` didn't add any of its `require`s
-      (assert (not (in (hy.mangle "nonlocal-test-macro") __macros__)))
-      ;; and that it didn't add its tags.
-      (assert (not (in (hy.mangle "#test-module-tag") __macros__)))
-
-      ;; Now, require everything.
-      (require tests.resources.macro-with-require *)
-
-      ;; Again, make sure it didn't add its required macros and/or tags.
-      (assert (not (in (hy.mangle "nonlocal-test-macro") __macros__)))
-
-      ;; Its tag(s) should be here now.
-      (assert (in (hy.mangle "#test-module-tag") __macros__))
-
-      ;; The test macro expands to include this symbol.
-      (setv module-name-var "tests.native_tests.native_macros")
-      (assert (= (+ "This macro was created in tests.resources.macros, "
-                    "expanded in tests.native_tests.native_macros "
-                    "and passed the value 1.")
-                 (test-module-macro 1))))
-
-    (test-requires-and-macros)
-
-    ;; Now that bytecode is present, reload the module, clear the `require`d
-    ;; macros and tags, and rerun the tests.
-    (assert (os.path.isfile pyc-file))
-
-    ;; Reload the module and clear the local macro context.
+(defn [(pytest.mark.xfail)] test-macro-from-module []
+  "
+  Macros loaded from an external module, which itself `require`s macros, should
+  work without having to `require` the module's macro dependencies (due to
+  [minimal] macro namespace resolution).
+
+  In doing so we also confirm that a module's `__macros__` attribute is correctly
+  loaded and used.
+
+  Additionally, we confirm that `require` statements are executed via loaded bytecode.
+  "
+
+  (setv pyc-file (importlib.util.cache-from-source
+                   (os.path.realpath
+                     (os.path.join
+                       "tests" "resources" "macro_with_require.hy"))))
+
+  ;; Remove any cached byte-code, so that this runs from source and
+  ;; gets evaluated in this module.
+  (when (os.path.isfile pyc-file)
+    (os.unlink pyc-file)
     (.clear sys.path_importer_cache)
-    (del (get sys.modules "tests.resources.macro_with_require"))
-    (.clear __macros__)
+    (when (in  "tests.resources.macro_with_require" sys.modules)
+      (del (get sys.modules "tests.resources.macro_with_require"))
+      (__macros__.clear)))
+
+  ;; Ensure that bytecode isn't present when we require this module.
+  (assert (not (os.path.isfile pyc-file)))
+
+  (defn test-requires-and-macros []
+    (require tests.resources.macro-with-require
+             [test-module-macro])
+
+    ;; Make sure that `require` didn't add any of its `require`s
+    (assert (not (in (hy.mangle "nonlocal-test-macro") __macros__)))
+    ;; and that it didn't add its tags.
+    (assert (not (in (hy.mangle "#test-module-tag") __macros__)))
+
+    ;; Now, require everything.
+    (require tests.resources.macro-with-require *)
+
+    ;; Again, make sure it didn't add its required macros and/or tags.
+    (assert (not (in (hy.mangle "nonlocal-test-macro") __macros__)))
+
+    ;; Its tag(s) should be here now.
+    (assert (in (hy.mangle "#test-module-tag") __macros__))
+
+    ;; The test macro expands to include this symbol.
+    (setv module-name-var "tests.native_tests.native_macros")
+    (assert (= (+ "This macro was created in tests.resources.macros, "
+                  "expanded in tests.native_tests.native_macros "
+                  "and passed the value 1.")
+               (test-module-macro 1))))
+
+  (test-requires-and-macros)
+
+  ;; Now that bytecode is present, reload the module, clear the `require`d
+  ;; macros and tags, and rerun the tests.
+  (assert (os.path.isfile pyc-file))
+
+  ;; Reload the module and clear the local macro context.
+  (.clear sys.path_importer_cache)
+  (del (get sys.modules "tests.resources.macro_with_require"))
+  (.clear __macros__)
 
-    ;; There doesn't seem to be a way--via standard import mechanisms--to
-    ;; ensure that an imported module used the cached bytecode.  We'll simply have
-    ;; to trust that the .pyc loading convention was followed.
-    (test-requires-and-macros)))
+  ;; There doesn't seem to be a way--via standard import mechanisms--to
+  ;; ensure that an imported module used the cached bytecode.  We'll simply have
+  ;; to trust that the .pyc loading convention was followed.
+  (test-requires-and-macros))
 
 
 (defn test-recursive-require-star []
diff --git a/tests/native_tests/py3_10_only_tests.hy b/tests/native_tests/py3_10_only_tests.hy
--- a/tests/native_tests/py3_10_only_tests.hy
+++ b/tests/native_tests/py3_10_only_tests.hy
@@ -75,11 +75,9 @@
        0)
     0))
 
-  (with-decorator
-    dataclass
-    (defclass Point []
-      (^int x)
-      (^int y)))
+  (defclass [dataclass] Point []
+    (^int x)
+    (^int y))
 
   (assert (= 0 (match (Point 1 0) (Point 1 :y var) var)))
   (assert (is None (match (Point 0 0) (Point 1 :y var) var)))
@@ -164,11 +162,9 @@
     (assert (= [x y] [5 6])))
   (assert (= [x y] [3 4])))
 
-(with-decorator
-  dataclass
-  (defclass Point []
-    (^int x)
-    (^int y)))
+(defclass [dataclass] Point []
+  (^int x)
+  (^int y))
 
 (defn test-let-match-pattern []
   (setv [x y] [1 2]
diff --git a/tests/native_tests/reader_macros.hy b/tests/native_tests/reader_macros.hy
--- a/tests/native_tests/reader_macros.hy
+++ b/tests/native_tests/reader_macros.hy
@@ -6,18 +6,16 @@
 
   pytest)
 
-(with-decorator
-  contextmanager
-  (defn temp-module [module-name]
-    (let [module (types.ModuleType module-name)
-          old-module (sys.modules.get module-name)]
-      (setv (get sys.modules module-name) module)
-      (try
-        (yield module)
-        (finally
-          (if old-module
-              (setv (get sys.modules module-name) module)
-              (sys.modules.pop module-name)))))))
+(defn [contextmanager] temp-module [module-name]
+  (let [module (types.ModuleType module-name)
+        old-module (sys.modules.get module-name)]
+    (setv (get sys.modules module-name) module)
+    (try
+      (yield module)
+      (finally
+        (if old-module
+            (setv (get sys.modules module-name) module)
+            (sys.modules.pop module-name))))))
 
 
 (defn eval-isolated [tree [module None]]
diff --git a/tests/native_tests/with_decorator.hy b/tests/native_tests/with_decorator.hy
deleted file mode 100644
--- a/tests/native_tests/with_decorator.hy
+++ /dev/null
@@ -1,38 +0,0 @@
-(defn test-decorated-1line-function []
-  (defn foodec [func]
-    (fn [] (+ (func) 1)))
-  (with-decorator foodec
-    (defn tfunction []
-      (* 2 2)))
-  (assert (= (tfunction) 5)))
-
-
-(defn test-decorated-multiline-function []
-  (defn bazdec [func]
-    (fn [] (+ (func) "x")))
-  (with-decorator bazdec
-    (defn f []
-      (setv intermediate "i")
-      (+ intermediate "b")))
-  (assert (= (f) "ibx")))
-
-
-(defn test-decorated-class []
-  (defn bardec [cls]
-    (setv cls.attr2 456)
-    cls)
-  (with-decorator bardec
-    (defclass cls []
-      (setv attr1 123)))
-  (assert (= cls.attr1 123))
-  (assert (= cls.attr2 456)))
-
-
-(defn test-nested-decorators []
-  (do
-    (defn dec1 [f] (fn [] (+ (f) 1)))
-    (defn dec2 [f] (fn [] (+ (f) 2)))
-    (with-decorator dec1
-      (with-decorator dec2
-        (defn f [] 1)))
-    (assert (= (f) 4))))
diff --git a/tests/resources/pydemo.hy b/tests/resources/pydemo.hy
--- a/tests/resources/pydemo.hy
+++ b/tests/resources/pydemo.hy
@@ -139,8 +139,7 @@ Call me Ishmael. Some years ago—never mind how long precisely—having little
     (yield x)))
 (setv myyield (list (generator)))
 
-(with-decorator (fn [f] (setv f.newattr "hello") f)
-  (defn mydecorated []))
+(defn [(fn [f] (setv f.newattr "hello") f)] mydecorated [])
 
 (setv myglobal 102)
 (defn set-global []