Repository: hylang/hy

Issue:
Local macros
We should implement `macrolet`. That's what Common Lisp calls it, but Clojure may end up calling it `letmacro`, so we could call it that instead.

It might work by using `defmacro` named by gensym. Anywhere the macro binding symbol appears in the `letmacro` body (and in an appropriate position for macroexpansion) `letmacro` would replace it with that gensym. It should not replace the binding symbol if it appears in an inappropriate position, such as the head of a quoted Hy expression.

This approach might litter some gensyms, but without a proper `let` most uses of `gensym` will do this anyway.

Alternatively, perhaps it could be done better at the Python level, in a way similar to how `defmacro` was implemented in the first place.



Discussion:
Likely better would be if `defmacro` itself was locally scoped. like `defn`. Consider the following:

```
(defn f []
  (defmacro g [a]
    `[~a])
  (g 1))

(print (f))
(defn g [a]
  (+ a 1))
(print (g 1))
```

It would be nice if Hy only used the definition of `g` in the lexical scope of the `defn`. The status quo is that `g` persists to elsewhere in the module, which can bite the unwary user if he later defines a function named `g`, as in the example. `(defn g …)` works, but subsequent `(g …)` forms call the macro instead of a function, since macros shadow functions.
I'm inching along on a PR to make `defmacro` and `require` local when they appear in function definitions, class definitions, and comprehensions other than `for`.

Resolving patch:
diff --git a/hy/compiler.py b/hy/compiler.py
--- a/hy/compiler.py
+++ b/hy/compiler.py
@@ -4,6 +4,7 @@
 import inspect
 import traceback
 import types
+from contextlib import contextmanager
 
 from funcparserlib.parser import NoParseError, many
 
@@ -335,6 +336,10 @@ def __init__(self, module, filename=None, source=None):
         """
         self.anon_var_count = 0
         self.temp_if = None
+        self.local_macro_stack = []
+          # A list of dictionaries that map mangled names to local
+          # macros. The last element is considered the top of the
+          # stack.
 
         if not inspect.ismodule(module):
             self.module = importlib.import_module(module)
@@ -507,6 +512,25 @@ def _nonconst(self, name):
             raise self._syntax_error(name, "Can't assign to constant")
         return name
 
+    def eval(self, model):
+        return hy_eval(
+            model,
+            locals = self.module.__dict__,
+            module = self.module,
+            filename = self.filename,
+            source = self.source,
+            import_stdlib = False)
+
+    @contextmanager
+    def local_macros(self):
+        """Make `defmacro` and `require` assign to a new element of
+        `self.local_macro_stack` instead of a module."""
+        self.local_macro_stack.append({})
+        try:
+            yield
+        finally:
+            self.local_macro_stack.pop()
+
     @builds_model(Expression)
     def compile_expression(self, expr):
         # Perform macro expansions
@@ -727,7 +751,9 @@ def hy_eval_user(model, globals = None, locals = None, module = None):
         (hy.eval '(my-test-mac))                 ; => 3
         (import hyrule)
         (hy.eval '(my-test-mac) :module hyrule)  ; NameError
-        (hy.eval '(list-n 3 1) :module hyrule)   ; => [1 1 1]"""
+        (hy.eval '(list-n 3 1) :module hyrule)   ; => [1 1 1]
+
+    N.B. Local macros are invisible to ``hy.eval``."""
 
     if locals is None:
         locals = globals
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
@@ -16,7 +16,7 @@
 from funcparserlib.parser import finished, forward_decl, many, maybe, oneplus, some
 
 from hy._compat import PY3_11, PY3_12
-from hy.compiler import Result, asty, hy_eval, mkexpr
+from hy.compiler import Result, asty, mkexpr
 from hy.errors import HyEvalError, HyInternalError, HyTypeError
 from hy.macros import pattern_macro, require, require_reader
 from hy.model_patterns import (
@@ -120,14 +120,7 @@ def compile_eval_foo_compile(compiler, expr, root, body):
     new_expr = Expression([Symbol("do").replace(expr[0])]).replace(expr)
 
     try:
-        value = hy_eval(
-            new_expr + body,
-            compiler.module.__dict__,
-            compiler.module,
-            filename=compiler.filename,
-            source=compiler.source,
-            import_stdlib=False,
-        )
+        value = compiler.eval(new_expr + body)
     except HyInternalError:
         # Unexpected "meta" compilation errors need to be treated
         # like normal (unexpected) compilation errors at this level
@@ -745,7 +738,8 @@ def compile_comprehension(compiler, expr, root, parts, final):
     is_for = root == "for"
 
     ctx = nullcontext() if is_for else compiler.scope.create(ScopeGen)
-    with ctx as scope:
+    mac_con = nullcontext() if is_for else compiler.local_macros()
+    with mac_con, ctx as scope:
 
         # Compile the parts.
         if is_for:
@@ -1444,7 +1438,7 @@ def compile_function_lambda(compiler, expr, root, tp, params, body):
         for param in (posonly or []) + args + kwonly + [rest, kwargs]
     )
     args, ret = compile_lambda_list(compiler, params)
-    with compiler.scope.create(ScopeFn, args):
+    with compiler.local_macros(), compiler.scope.create(ScopeFn, args):
         body = compiler._compile_branch(body)
 
     # Compile to lambda if we can
@@ -1472,7 +1466,7 @@ def compile_function_def(compiler, expr, root, decorators, tp, name, params, bod
     ret += ret2
     name = mangle(compiler._nonconst(name))
     compiler.scope.define(name)
-    with compiler.scope.create(ScopeFn, args):
+    with compiler.local_macros(), compiler.scope.create(ScopeFn, args):
         body = compiler._compile_branch(body)
 
     return ret + compile_function_node(
@@ -1513,31 +1507,25 @@ def compile_function_node(compiler, expr, node, decorators, tp, name, args, retu
     ],
 )
 def compile_macro_def(compiler, expr, root, name, params, body):
-    ret = Result() + compiler.compile(
-        Expression(
-            [
-                Symbol("eval-and-compile"),
-                Expression(
-                    [
-                        Expression(
-                            [
-                                dotted("hy.macros.macro"),
-                                str(name),
-                            ]
-                        ),
-                        Expression(
-                            [
-                                Symbol("fn"),
-                                List([Symbol("&compiler")] + list(expr[2])),
-                                *body,
-                            ]
-                        ),
-                    ]
-                ),
-            ]
-        ).replace(expr)
-    )
-
+    def E(*x): return Expression(x)
+    S = Symbol
+
+    ret = []
+    fn_def = E(
+        S("fn"),
+        List([S("&compiler"), *expr[2]]),
+        *body)
+    if compiler.local_macro_stack:
+        # We're in a local macro scope, so define the new macro
+        # locally.
+        compiler.local_macro_stack[-1][mangle(name)] = (
+            compiler.eval(fn_def.replace(expr)))
+        return Result()
+    # Otherwise, define the macro module-wide.
+    ret.append(E(
+        E(dotted("hy.macros.macro"), str(name)),
+        fn_def))
+    ret = compiler.compile(E(S("eval-and-compile"), *ret).replace(expr))
     return ret + ret.expr_as_stmt()
 
 
@@ -1695,7 +1683,7 @@ def compile_class_expression(compiler, expr, root, decorators, tp, name, rest):
     name = mangle(compiler._nonconst(name))
     compiler.scope.define(name)
 
-    with compiler.scope.create(ScopeFn):
+    with compiler.local_macros(), compiler.scope.create(ScopeFn):
         e = compiler._compile_branch(body)
         bodyr += e + e.expr_as_stmt()
 
@@ -1796,7 +1784,9 @@ def compile_require(compiler, expr, root, entries):
         # we don't want to import all macros as prefixed if we're specifically
         # importing readers but not macros
         # (require a-module :readers ["!"])
-        if (rest or not readers) and require(
+        if (rest or not readers) and compiler.local_macro_stack:
+            require(module_name, compiler.local_macro_stack[-1], assignments=assignments, prefix=prefix)
+        elif (rest or not readers) and require(
             module_name, compiler.module, assignments=assignments, prefix=prefix
         ):
             # Actually calling `require` is necessary for macro expansions
diff --git a/hy/macros.py b/hy/macros.py
--- a/hy/macros.py
+++ b/hy/macros.py
@@ -195,42 +195,32 @@ def enable_readers(module, reader, names):
         reader.reader_macros[name] = namespace["_hy_reader_macros"][name]
 
 
-def require(source_module, target_module, assignments, prefix="", target_module_name=None):
-    """Load macros from one module into the namespace of another.
+def require(source_module, target, assignments, prefix="", target_module_name=None):
+    """Load macros from a module. Return a `bool` indicating whether
+    macros were actually transferred.
 
-    This function is called from the macro also named `require`.
+    - `target` can be a a string (naming a module), a module object,
+      a dictionary, or `None` (meaning the calling module).
+    - `assignments` can be "ALL", "EXPORTS", or a list of (macro
+      name, alias) pairs."""
 
-    Args:
-        source_module (Union[str, ModuleType]): The module from which macros are
-            to be imported.
-        target_module (Optional[Union[str, ModuleType]]): The module into which the
-            macros will be loaded.  If `None`, then the caller's namespace.
-            The latter is useful during evaluation of generated AST/bytecode.
-        assignments (Union[str, typing.Sequence[str]]): The string "ALL", the string
-            "EXPORTS", or a list of macro name and alias pairs.
-        prefix (str): If nonempty, its value is prepended to the name of each imported macro.
-            This allows one to emulate namespaced macros, like "mymacromodule.mymacro",
-            which looks like an attribute of a module. Defaults to ""
-        target_module_name: If true, overrides the apparent name of `target_module`.
-
-    Returns:
-        bool: Whether or not macros were actually transferred.
-    """
-    target_module, target_namespace = derive_target_module(
-        target_module, inspect.stack()[1][0]
-    )
-
-    # Let's do a quick check to make sure the source module isn't actually
-    # the module being compiled (e.g. when `runpy` executes a module's code
-    # in `__main__`).
-    # We use the module's underlying filename for this (when they exist), since
-    # it's the most "fixed" attribute.
-    if _same_modules(source_module, target_module):
-        return False
+    if type(target) is dict:
+        target_module = None
+    else:
+        target_module, target_namespace = derive_target_module(
+            target, inspect.stack()[1][0]
+        )
+        # Let's do a quick check to make sure the source module isn't actually
+        # the module being compiled (e.g. when `runpy` executes a module's code
+        # in `__main__`).
+        # We use the module's underlying filename for this (when they exist), since
+        # it's the most "fixed" attribute.
+        if _same_modules(source_module, target_module):
+            return False
 
     if not inspect.ismodule(source_module):
         source_module = import_module_from_string(source_module,
-           target_module_name or target_module)
+           target_module_name or target_module or '')
 
     source_macros = source_module.__dict__.setdefault("_hy_macros", {})
     source_exports = getattr(
@@ -246,7 +236,7 @@ def require(source_module, target_module, assignments, prefix="", target_module_
             try:
                 require(
                     f"{source_module.__name__}.{mangle(name)}",
-                    target_module,
+                    target_module or target,
                     "ALL",
                     prefix=alias,
                 )
@@ -258,7 +248,7 @@ def require(source_module, target_module, assignments, prefix="", target_module_
                 )
         return True
 
-    target_macros = target_namespace.setdefault("_hy_macros", {})
+    target_macros = target_namespace.setdefault("_hy_macros", {}) if target_module else target
 
     if prefix:
         prefix += "."
@@ -348,7 +338,7 @@ def macroexpand(tree, module, compiler=None, once=False, result_ok=True):
     """Expand the toplevel macros for the given Hy AST tree.
 
     Load the macros from the given `module`, then expand the (top-level) macros
-    in `tree` until we no longer can.
+    in `tree` until we no longer can. This doesn't work on local macros.
 
     `Expression` resulting from macro expansions are assigned the module in
     which the macro function is defined (determined using `inspect.getmodule`).
@@ -388,14 +378,16 @@ def macroexpand(tree, module, compiler=None, once=False, result_ok=True):
             break
 
         # Choose the first namespace with the macro.
-        m = next(
-            (
-                mod._hy_macros[fn]
-                for mod in (module, builtins)
-                if fn in getattr(mod, "_hy_macros", ())
-            ),
-            None,
-        )
+        m = ((compiler and next(
+                (d[fn]
+                    for d in reversed(compiler.local_macro_stack)
+                    if fn in d),
+                None)) or
+            next(
+                (mod._hy_macros[fn]
+                    for mod in (module, builtins)
+                    if fn in getattr(mod, "_hy_macros", ())),
+                None))
         if not m:
             break
 


Test patch:
diff --git a/tests/native_tests/doc.hy b/tests/native_tests/doc.hy
--- a/tests/native_tests/doc.hy
+++ b/tests/native_tests/doc.hy
@@ -6,6 +6,10 @@
   "Some tag macro"
   '1)
 
+(defmacro <-mangle-> []
+  "a fancy docstring"
+  '(+ 2 2))
+
 (defn test-doc [capsys]
   ;; https://github.com/hylang/hy/issues/1970
   ;; Let's first make sure we can doc the builtin macros
@@ -18,9 +22,6 @@
   (setv [out err] (.readouterr capsys))
   (assert (in "Some tag macro" out))
 
-  (defmacro <-mangle-> []
-    "a fancy docstring"
-    '(+ 2 2))
   (doc <-mangle->)
   (setv [out err] (.readouterr capsys))
   ;; https://github.com/hylang/hy/issues/1946
diff --git a/tests/native_tests/hy_misc.hy b/tests/native_tests/hy_misc.hy
--- a/tests/native_tests/hy_misc.hy
+++ b/tests/native_tests/hy_misc.hy
@@ -28,11 +28,12 @@
              '(a b 5))))
 
 
+(defmacro m-with-named-import []
+  (import math [pow])
+  (pow 2 3))
+
 (defn test-macroexpand-with-named-import []
   ; https://github.com/hylang/hy/issues/1207
-  (defmacro m-with-named-import []
-    (import math [pow])
-    (pow 2 3))
   (assert (= (hy.macroexpand '(m-with-named-import)) (hy.models.Float (** 2 3)))))
 
 
diff --git a/tests/native_tests/import.hy b/tests/native_tests/import.hy
--- a/tests/native_tests/import.hy
+++ b/tests/native_tests/import.hy
@@ -59,100 +59,89 @@
   (assert (in "_null_fn_for_import_test" (dir tests.resources.bin))))
 
 
-(defn test-require []
-  (with [(pytest.raises NameError)]
-    (qplah 1 2 3 4))
-  (with [(pytest.raises NameError)]
-    (parald 1 2 3 4))
-  (with [(pytest.raises NameError)]
-    (✈ 1 2 3 4))
-  (with [(pytest.raises NameError)]
-    (hyx_XairplaneX 1 2 3 4))
-
-  (require tests.resources.tlib [qplah])
-  (assert (= (qplah 1 2 3) [8 1 2 3]))
-  (with [(pytest.raises NameError)]
-    (parald 1 2 3 4))
-
-  (require tests.resources.tlib)
+(require
+  tests.resources.tlib
+  tests.resources.tlib :as TL
+  tests.resources.tlib [qplah]
+  tests.resources.tlib [parald :as parald-alias]
+  tests.resources [tlib  macros :as TM  exports-none]
+  os [path])
+    ; The last one is a no-op, since the module `os.path` exists but
+    ; contains no macros.
+
+(defn test-require-global []
   (assert (= (tests.resources.tlib.parald 1 2 3) [9 1 2 3]))
   (assert (= (tests.resources.tlib.✈ "silly") "plane silly"))
   (assert (= (tests.resources.tlib.hyx_XairplaneX "foolish") "plane foolish"))
-  (with [(pytest.raises NameError)]
-    (parald 1 2 3 4))
 
-  (require tests.resources.tlib :as T)
-  (assert (= (T.parald 1 2 3) [9 1 2 3]))
-  (assert (= (T.✈ "silly") "plane silly"))
-  (assert (= (T.hyx_XairplaneX "foolish") "plane foolish"))
-  (with [(pytest.raises NameError)]
-    (parald 1 2 3 4))
+  (assert (= (TL.parald 1 2 3) [9 1 2 3]))
+  (assert (= (TL.✈ "silly") "plane silly"))
+  (assert (= (TL.hyx_XairplaneX "foolish") "plane foolish"))
 
-  (require tests.resources.tlib [parald :as p])
-  (assert (= (p 1 2 3) [9 1 2 3]))
-  (with [(pytest.raises NameError)]
-    (parald 1 2 3 4))
+  (assert (= (qplah 1 2 3) [8 1 2 3]))
 
-  (require tests.resources.tlib *)
-  (assert (= (parald 1 2 3) [9 1 2 3]))
-  (assert (= (✈ "silly") "plane silly"))
-  (assert (= (hyx_XairplaneX "foolish") "plane foolish"))
+  (assert (= (parald-alias 1 2 3) [9 1 2 3]))
 
-  (require tests.resources [tlib  macros :as m  exports-none])
   (assert (in "tlib.qplah" _hy_macros))
-  (assert (in (hy.mangle "m.test-macro") _hy_macros))
+  (assert (in (hy.mangle "TM.test-macro") _hy_macros))
   (assert (in (hy.mangle "exports-none.cinco") _hy_macros))
-  (require os [path])
-  (with [(pytest.raises hy.errors.HyRequireError)]
-    (hy.eval '(require tests.resources [does-not-exist])))
 
-  (require tests.resources.exports *)
-  (assert (= (casey 1 2 3) [11 1 2 3]))
-  (assert (= (☘ 1 2 3) [13 1 2 3]))
   (with [(pytest.raises NameError)]
-    (brother 1 2 3 4)))
+    (parald 1 2 3 4))
+
+  (with [(pytest.raises hy.errors.HyRequireError)]
+    (hy.eval '(require tests.resources [does-not-exist]))))
 
 
-(defn test-require-native []
+(require tests.resources.more-test-macros *)
+
+(defn test-require-global-star-without-exports []
+  (assert (= (bairn 1 2 3) [14 1 2 3]))
+  (assert (= (cairn 1 2 3) [15 1 2 3]))
   (with [(pytest.raises NameError)]
-    (test-macro-2))
-  (import tests.resources.macros)
+    (_dairn 1 2 3 4)))
+
+
+(require tests.resources.exports *)
+
+(defn test-require-global-star-with-exports []
+  (assert (= (casey 1 2 3) [11 1 2 3]))
+  (assert (= (☘ 1 2 3) [13 1 2 3]))
   (with [(pytest.raises NameError)]
-    (test-macro-2))
-  (require tests.resources.macros [test-macro-2])
-  (test-macro-2)
-  (assert (= qup 2)))
+    (brother 1 2 3 4)))
 
 
-(defn test-relative-require []
-  (require ..resources.macros [test-macro])
-  (assert (in "test_macro" _hy_macros))
+(require
+  ..resources.macros [test-macro-2]
+  .beside [xyzzy]
+  . [beside :as BS])
 
-  (require .beside [xyzzy])
+(defn test-require-global-relative []
+  (assert (in "test_macro_2" _hy_macros))
   (assert (in "xyzzy" _hy_macros))
+  (assert (in "BS.xyzzy" _hy_macros)))
+
 
-  (require . [beside :as b])
-  (assert (in "b.xyzzy" _hy_macros)))
+;; `remote-test-macro` is a macro used within
+;; `tests.resources.macro-with-require.test-module-macro`.
+;; Here, we introduce an equivalently named version that, when
+;; used, will expand to a different output string.
+(defmacro remote-test-macro [x]
+  "this is the home version of `remote-test-macro`!")
 
+(require tests.resources.macro-with-require *)
+(defmacro home-test-macro [x]
+  (.format "This is the home version of `remote-test-macro` returning {}!" (int x)))
 
 (defn test-macro-namespace-resolution []
-  "Confirm that local versions of macro-macro dependencies do not shadow the
+  "Confirm that new versions of macro-macro dependencies do not shadow the
 versions from the macro's own module, but do resolve unbound macro references
 in expansions."
 
-  ;; `nonlocal-test-macro` is a macro used within
-  ;; `tests.resources.macro-with-require.test-module-macro`.
-  ;; Here, we introduce an equivalently named version in local scope that, when
-  ;; used, will expand to a different output string.
-  (defmacro nonlocal-test-macro [x]
-    (print "this is the local version of `nonlocal-test-macro`!"))
-
   ;; Was the above macro created properly?
-  (assert (in "nonlocal_test_macro" _hy_macros))
+  (assert (in "remote_test_macro" _hy_macros))
 
-  (setv nonlocal-test-macro (get _hy_macros "nonlocal_test_macro"))
-
-  (require tests.resources.macro-with-require *)
+  (setv remote-test-macro (get _hy_macros "remote_test_macro"))
 
   (setv module-name-var "tests.native_tests.native_macros.test-macro-namespace-resolution")
   (assert (= (+ "This macro was created in tests.resources.macros, "
@@ -162,58 +151,10 @@ in expansions."
 
   ;; Now, let's use a `require`d macro that depends on another macro defined only
   ;; in this scope.
-  (defmacro local-test-macro [x]
-    (.format "This is the local version of `nonlocal-test-macro` returning {}!" (int x)))
-
-  (assert (= "This is the local version of `nonlocal-test-macro` returning 3!"
+  (assert (= "This is the home version of `remote-test-macro` returning 3!"
              (test-module-macro-2 3))))
 
 
-(defn test-requires-pollutes-core []
-  ;; https://github.com/hylang/hy/issues/1978
-  ;; Macros loaded from an external module should not pollute `_hy_macros`
-  ;; with macros from core.
-
-  (setv pyc-file (importlib.util.cache-from-source
-                   (os.path.realpath
-                     (os.path.join
-                       "tests" "resources" "macros.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.macros" sys.modules)
-      (del (get sys.modules "tests.resources.macros"))
-      (_hy_macros.clear)))
-
-  ;; Ensure that bytecode isn't present when we require this module.
-  (assert (not (os.path.isfile pyc-file)))
-
-  (defn require-macros []
-    (require tests.resources.macros :as m)
-
-    (assert (in (hy.mangle "m.test-macro") _hy_macros))
-    (for [macro-name _hy_macros]
-      (assert (not (and (in "with" macro-name)
-                        (!= "with" macro-name))))))
-
-  (require-macros)
-
-  ;; Now that bytecode is present, reload the module, clear the `require`d
-  ;; macros and tags, and rerun the tests.
-  (when (not PYODIDE)
-    (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.macros"))
-  (.clear _hy_macros)
-
-  (require-macros))
-
-
 (defn test-no-surprise-shadow [tmp-path monkeypatch]
   "Check that an out-of-module macro doesn't shadow a function."
   ; https://github.com/hylang/hy/issues/2451
diff --git a/tests/native_tests/macros.hy b/tests/native_tests/macros.hy
--- a/tests/native_tests/macros.hy
+++ b/tests/native_tests/macros.hy
@@ -167,10 +167,12 @@
                (wrap-error-test))))
   (assert (in "HyWrapperError" (str excinfo.value))))
 
+
+(defmacro delete-me [] "world")
+
 (defn test-delmacro
   []
   ;; test deletion of user defined macro
-  (defmacro delete-me [] "world")
   (delmacro delete-me)
   (with [exc (pytest.raises NameError)]
     (delete-me))
diff --git a/tests/native_tests/macros_local.hy b/tests/native_tests/macros_local.hy
new file mode 100644
--- /dev/null
+++ b/tests/native_tests/macros_local.hy
@@ -0,0 +1,88 @@
+"Tests of local `defmacro` and `require`."
+
+
+(defn test-nonleaking []
+  (defn fun []
+    (defmacro helper []
+      "helper macro in fun")
+    (helper))
+  (defclass C []
+    (defmacro helper []
+      "helper macro in class")
+    (setv attribute (helper)))
+  (defn helper []
+    "helper function")
+  (assert (= (helper) "helper function"))
+  (assert (= (fun) "helper macro in fun"))
+  (assert (= C.attribute "helper macro in class"))
+  (assert (=
+    (lfor
+      x [1 2 3]
+      :do (defmacro helper []
+        "helper macro in lfor")
+      y [1 2 3]
+      (if (= x y 2) (helper) (+ (* x 10) y)))
+    [11 12 13 21 "helper macro in lfor" 23 31 32 33]))
+  (assert (= (helper) "helper function")))
+
+
+(defmacro shadowable []
+  "global version")
+
+(defn test-shadowing-global []
+  (defn inner []
+    (defmacro shadowable []
+      "local version")
+    (shadowable))
+  (assert (= (shadowable) "global version"))
+  (assert (= (inner) "local version"))
+  (assert (= (shadowable) "global version")))
+
+
+(defn test-nested-local-shadowing []
+  (defn inner1 []
+    (defmacro shadowable []
+      "local version 1")
+    (defn inner2 []
+      (defmacro shadowable []
+        "local version 2")
+       (shadowable))
+    [(inner2) (shadowable)])
+  (assert (= (shadowable) "global version"))
+  (print (inner1))
+  (assert (= (inner1) ["local version 2" "local version 1"]))
+  (assert (= (shadowable) "global version")))
+
+
+(defmacro one-plus-two []
+  '(+ 1 2))
+
+(defn test-local-macro-in-expansion-of-nonlocal []
+  (defn f []
+    (defmacro + [a b]
+      "Shadow the core macro `+`. #yolo"
+      `f"zomg! {~a} {~b}")
+    (one-plus-two))
+  (assert (= (f) "zomg! 1 2"))
+  (assert (= (one-plus-two) 3)))
+
+
+(defmacro local-require-test [arg] `(do
+  (defmacro wiz []
+    "local wiz")
+
+  (defn fun []
+    (require tests.resources.local-req-example ~arg)
+    [(get-wiz) (helper)])
+  (defn helper []
+    "local helper function")
+
+  (assert (= [(wiz) (helper)] ["local wiz" "local helper function"]))
+  (assert (= (fun) ["remote wiz" "remote helper macro"]))
+  (assert (= [(wiz) (helper)] ["local wiz" "local helper function"]))))
+
+(defn test-require []
+  (local-require-test [get-wiz helper]))
+
+(defn test-require-star []
+  (local-require-test *))
diff --git a/tests/resources/local_req_example.hy b/tests/resources/local_req_example.hy
new file mode 100644
--- /dev/null
+++ b/tests/resources/local_req_example.hy
@@ -0,0 +1,6 @@
+(defmacro wiz []
+  "remote wiz")
+(defmacro get-wiz []
+  (wiz))
+(defmacro helper []
+  "remote helper macro")
diff --git a/tests/resources/macro_with_require.hy b/tests/resources/macro_with_require.hy
--- a/tests/resources/macro_with_require.hy
+++ b/tests/resources/macro_with_require.hy
@@ -4,11 +4,11 @@
 
 (defmacro test-module-macro [a]
   "The variable `macro-level-var' here should not bind to the same-named symbol
-in the expansion of `nonlocal-test-macro'."
+in the expansion of `remote-test-macro'."
   (setv macro-level-var "tests.resources.macros.macro-with-require")
-  `(nonlocal-test-macro ~a))
+  `(remote-test-macro ~a))
 
 (defmacro test-module-macro-2 [a]
-  "The macro `local-test-macro` isn't in this module's namespace, so it better
+  "The macro `home-test-macro` isn't in this module's namespace, so it better
  be in the expansion's!"
-  `(local-test-macro ~a))
+  `(home-test-macro ~a))
diff --git a/tests/resources/macros.hy b/tests/resources/macros.hy
--- a/tests/resources/macros.hy
+++ b/tests/resources/macros.hy
@@ -6,7 +6,7 @@
 (defmacro test-macro-2 []
   '(setv qup 2))
 
-(defmacro nonlocal-test-macro [x]
+(defmacro remote-test-macro [x]
   "When called from `macro-with-require`'s macro(s), the first instance of
 `module-name-var` should resolve to the value in the module where this is
 defined, then the expansion namespace/module"
diff --git a/tests/resources/more_test_macros.hy b/tests/resources/more_test_macros.hy
new file mode 100644
--- /dev/null
+++ b/tests/resources/more_test_macros.hy
@@ -0,0 +1,8 @@
+(defmacro bairn [#* tree]
+  `[14 ~@tree])
+
+(defmacro cairn [ #* tree]
+  `[15 ~@tree])
+
+(defmacro _dairn []
+  `[16 ~@tree])
diff --git a/tests/test_bin.py b/tests/test_bin.py
--- a/tests/test_bin.py
+++ b/tests/test_bin.py
@@ -663,6 +663,29 @@ def test_relative_require(case, monkeypatch, tmp_path):
     assert 'HELLO' in output
 
 
+def test_require_doesnt_pollute_core(monkeypatch, tmp_path):
+    # https://github.com/hylang/hy/issues/1978
+    """Macros loaded from an external module should not pollute
+    `_hy_macros` with macros from core."""
+
+    (tmp_path / 'aaa.hy').write_text('''
+        (defmacro foo []
+          '(setv x (.upper "argelfraster")))''')
+    (tmp_path / 'bbb.hy').write_text('''
+        (require aaa :as A)
+        (A.foo)
+        (print
+          x
+          (not-in "if" _hy_macros)
+          (not-in "cond" _hy_macros))''')
+            # `if` is a result macro; `cond` is a regular macro.
+    monkeypatch.chdir(tmp_path)
+
+    # Try it without and then with bytecode.
+    for _ in (1, 2):
+        assert 'ARGELFRASTER True True' in run_cmd('hy bbb.hy')[0]
+
+
 def test_run_dir_or_zip(tmp_path):
 
     (tmp_path / 'dir').mkdir()