Repository: hylang/hy

Issue:
Statements pulled out of the second argument of (assert) aren't added to the AST
    $ echo '(assert (do (f1) (f2)) (do (f3) (f4)))' |  hy2py
    f1()
    assert f2(), f4()

Given [how the Python language reference describes `assert`](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement), I guess this should compile to:

```Python
if __debug__:
    f1()
    if not f2():
        f3()
        assert not 1, f4()
```



Discussion:
This seems related to the ordering problem of #1342, (and #588, #1362). Hy still has trouble compiling statements inside expressions. I'd have expected the wrong order here too, but it's strange that it gets left out completely instead.

We should probably check how Hy compiles every type of Python statement when they contain statements. In particular, what happens when sub-statements raise exceptions or have other side effects that care about ordering?

> I guess this should compile to:

That's the right track, I think. It might be easier to compile in some anon vars in a couple places. But how does this interact with Pytest? Will it still print out errors sensibly? I know that pdb will list Hy source, since Hy puts source position information in the ast. And Hy exception tracebacks print Hy source too.

```Python
        assert not 1, f4()
```
Why `not 1` instead of `False`? On Python 2, I suppose `False` (or `None`) could be shadowed by something else (a design flaw corrected in Python 3), not that anyone actually does this. But then, wouldn't `0` be simpler? Any false value will trigger a Python assert. Does it need to be the one true `False` for some reason?

If we *do* care what particular false value `assert` gets (does Pytest use this?), then it should compile like
```Python
if __debug__:
    f1()
    _hy_anon_var_1 = f2()
    if not _hy_anon_var_1:
        f3()
        assert _hy_anon_var_1, f4()
```

But, `f3()` could mutate the `[]` returned by `f2()`, preventing the `assert` from triggering at all, which does not seem correct. I suppose we could use it if possible, and fall back to `assert 0` if not.
```Python
if __debug__:
    f1()
    _hy_anon_var_1 = f2()
    if not _hy_anon_var_1:
        f3()
        _hy_anon_var_2 = f4()
        assert _hy_anon_var_1, _hy_anon_var_2
        assert 0, _hy_anon_var_2
```
This kind of ambiguity could cause confusion about the cause of that 0 during debugging, since it could either be returned by `f2()`, or just be the fallback. I don't know if we even need this though.

I looked at Pytest. It's doing some AST rewriting, so it might not work right. Hy can't be expected to support this kind of thing in all cases. But it also looks like Pytest doesn't rewrite things at all if there's a second expression (the message part) in the `assert` anyway, so I guess it doesn't matter here.
>  But then, wouldn't `0` be simpler?

Yeah, that's true.

Resolving patch:
diff --git a/hy/compiler.py b/hy/compiler.py
--- a/hy/compiler.py
+++ b/hy/compiler.py
@@ -324,6 +324,18 @@ def is_unpack(kind, x):
             and x[0] == "unpack-" + kind)
 
 
+def make_hy_model(outer, x, rest):
+   return outer(
+      [HySymbol(a) if type(a) is str else
+              a[0] if type(a) is list else a
+          for a in x] +
+      (rest or []))
+def mkexpr(*items, **kwargs):
+   return make_hy_model(HyExpression, items, kwargs.get('rest'))
+def mklist(*items, **kwargs):
+   return make_hy_model(HyList, items, kwargs.get('rest'))
+
+
 class HyASTCompiler(object):
     """A Hy-to-Python AST compiler"""
 
@@ -390,21 +402,11 @@ def imports_as_stmts(self, expr):
         ret = Result()
         for module, names in self.imports.items():
             if None in names:
-                e = HyExpression([
-                        HySymbol("import"),
-                        HySymbol(module),
-                    ]).replace(expr)
-                ret += self.compile(e)
+                ret += self.compile(mkexpr('import', module).replace(expr))
             names = sorted(name for name in names if name)
             if names:
-                e = HyExpression([
-                        HySymbol("import"),
-                        HyList([
-                            HySymbol(module),
-                            HyList([HySymbol(name) for name in names])
-                        ])
-                    ]).replace(expr)
-                ret += self.compile(e)
+                ret += self.compile(mkexpr('import',
+                    mklist(module, mklist(*names))))
         self.imports = defaultdict(set)
         return ret.stmts
 
@@ -818,11 +820,22 @@ def compile_break_or_continue_expression(self, expr, root):
 
     @special("assert", [FORM, maybe(FORM)])
     def compile_assert_expression(self, expr, root, test, msg):
-        ret = self.compile(test)
-        e = ret.force_expr
-        if msg is not None:
-            msg = self.compile(msg).force_expr
-        return ret + asty.Assert(expr, test=e, msg=msg)
+        if msg is None or type(msg) is HySymbol:
+            ret = self.compile(test)
+            return ret + asty.Assert(
+                expr,
+                test=ret.force_expr,
+                msg=(None if msg is None else self.compile(msg).force_expr))
+
+        # The `msg` part may involve statements, which we only
+        # want to be executed if the assertion fails. Rewrite the
+        # form to set `msg` to a variable.
+        msg_var = self.get_anon_var()
+        return self.compile(mkexpr(
+            'if*', mkexpr('and', '__debug__', mkexpr('not', [test])),
+                mkexpr('do',
+                    mkexpr('setv', msg_var, [msg]),
+                    mkexpr('assert', 'False', msg_var))).replace(expr))
 
     @special(["global", "nonlocal"], [oneplus(SYM)])
     def compile_global_or_nonlocal(self, expr, root, syms):
@@ -1364,19 +1377,17 @@ def compile_while_expression(self, expr, root, cond, body, else_expr):
             # We need to ensure the statements for the condition are
             # executed on every iteration. Rewrite the loop to use a
             # single anonymous variable as the condition.
-            def e(*x): return HyExpression(x)
-            s = HySymbol
-            cond_var = s(self.get_anon_var())
-            return self.compile(e(
-                s('do'),
-                e(s('setv'), cond_var, 1),
-                e(s('while'), cond_var,
+            cond_var = self.get_anon_var()
+            return self.compile(mkexpr(
+                'do',
+                mkexpr('setv', cond_var, 'True'),
+                mkexpr('while', cond_var,
                   # Cast the condition to a bool in case it's mutable and
                   # changes its truth value, but use (not (not ...)) instead of
                   # `bool` in case `bool` has been redefined.
-                  e(s('setv'), cond_var, e(s('not'), e(s('not'), cond))),
-                  e(s('if*'), cond_var, e(s('do'), *body)),
-                  *([e(s('else'), *else_expr)] if else_expr is not None else []))).replace(expr))  # noqa
+                  mkexpr('setv', cond_var, mkexpr('not', mkexpr('not', [cond]))),
+                  mkexpr('if*', cond_var, mkexpr('do', rest=body)),
+                  *([mkexpr('else', rest=else_expr)] if else_expr is not None else []))).replace(expr))  # noqa
 
         orel = Result()
         if else_expr is not None:


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
@@ -1653,16 +1653,15 @@ macros()
    (= (identify-keywords 1 "bloo" :foo)
       ["other" "other" "keyword"])))
 
-#@(pytest.mark.xfail
 (defn test-assert-multistatements []
   ; https://github.com/hylang/hy/issues/1390
-  (setv s (set))
+  (setv l [])
   (defn f [x]
-    (.add s x)
+    (.append l x)
     False)
   (with [(pytest.raises AssertionError)]
     (assert (do (f 1) (f 2)) (do (f 3) (f 4))))
-  (assert (= s #{1 2 3 4}))))
+  (assert (= l [1 2 3 4])))
 
 (defn test-underscore_variables []
   ; https://github.com/hylang/hy/issues/1340