diff --git a/hy/__init__.py b/hy/__init__.py index da1c8b232..827e4295d 100644 --- a/hy/__init__.py +++ b/hy/__init__.py @@ -13,7 +13,7 @@ def _initialize_env_var(env_var, default_val): # Import for side-effects. -import hy.importer, hy.hy_inspect +import hy.importer, hy.hy_inspect, hy.hy_pdb hy.importer._inject_builtins() diff --git a/hy/hy_pdb.py b/hy/hy_pdb.py new file mode 100644 index 000000000..fedea3946 --- /dev/null +++ b/hy/hy_pdb.py @@ -0,0 +1,149 @@ +"""Hy-aware debugger extension for pdb. + +Provides Hy-specific commands in pdb for macro debugging. +Monkey-patches sys.breakpointhook to use HyPdb automatically. +""" + +import pdb +import sys +import hy + +__all__ = ['HyPdb', 'set_trace'] + + +def _decode_local_macro_name(key): + """Reverse hy.macros.local_macro_name encoding. + + _hy_local_macro__fooDDbar -> 'foo.bar' (original Hy name). + """ + prefix = '_hy_local_macro__' + assert key.startswith(prefix), f"Expected prefix {prefix}, got {key}" + mangled = key[len(prefix):] + # Encoding order in local_macro_name: mangle, then D->DN, then .->DD. + # Reverse: DD->sentinel, DN->D, sentinel->. + decoded = mangled.replace('DD', '\x00').replace('DN', 'D').replace('\x00', '.') + return hy.unmangle(decoded) + + +class HyPdb(pdb.Pdb): + """Pdb subclass with Hy-aware debugging commands.""" + + def _hy_expand(self, arg, expand_fn): + """Helper to expand a Hy form. + + Args: + arg: The Hy form to expand as a string. + expand_fn: Either hy.macroexpand or hy.macroexpand_1. + + Raises: + Displays error via self.error() on failure. + """ + if not arg.strip(): + self.error("Usage: (form ...)") + return + + try: + # Use as filename to avoid confusing error messages + form = hy.read(arg, filename="") + except hy.PrematureEndOfInput as e: + self.error(f"Incomplete form: {e}") + return + except hy.HySyntaxError as e: + self.error(f"Syntax error: {e}") + return + + module_name = self.curframe.f_globals.get('__name__') + if module_name is None: + self.error("Cannot determine module name") + return + + try: + expanded = expand_fn(form, module_name) + self.message(f"{hy.repr(expanded)}") + except Exception as e: + self.error(f"Expansion failed: {e}") + + def do_macroexpand(self, arg): + """Expand a Hy form fully: macroexpand (my-macro 1 2)""" + self._hy_expand(arg, hy.macroexpand) + + def do_macroexpand_1(self, arg): + """Single-step macro expansion: macroexpand_1 (my-macro 1 2)""" + self._hy_expand(arg, hy.macroexpand_1) + + def do_macros(self, arg): + """List macros defined in the current module and local scope.""" + module_name = self.curframe.f_globals.get('__name__') + + # Module-level macros + macros = [] + if module_name is not None and module_name in sys.modules: + module = sys.modules[module_name] + if hasattr(module, '_hy_macros'): + for name in module._hy_macros: + macros.append(hy.unmangle(name)) + + # Local macros (defmacro or require in function body) + local = [] + for k in self.curframe.f_locals: + if k.startswith('_hy_local_macro__'): + try: + original = _decode_local_macro_name(k) + local.append(original) + except Exception: + pass + + if not macros and not local: + self.message("No macros found.") + return + + if macros: + self.message(f"Macros ({len(macros)}):") + for name in sorted(macros): + self.message(f" {name}") + if local: + self.message(f"Local macros ({len(local)}):") + for name in sorted(local): + self.message(f" {name}") + + def do_hy_repr(self, arg): + """Show Hy representation: hy_repr """ + if not arg.strip(): + self.error("Usage: hy_repr ") + return + + try: + result = eval(arg, self.curframe.f_globals, self.curframe_locals) + except NameError as e: + self.error(f"Name not found: {e}") + return + except Exception as e: + self.error(f"Error evaluating expression: {e}") + return + + try: + self.message(f"{hy.repr(result)}") + except Exception as e: + self.error(f"Cannot represent as Hy: {e}") + + # Aliases + do_me = do_macroexpand + do_me1 = do_macroexpand_1 + do_m = do_macros + do_hy = do_hy_repr + + +def set_trace(*, header=None): + """Enter the debugger using HyPdb. + + This function is installed as sys.breakpointhook to make + breakpoint() use Hy-aware debugging automatically (PEP 553). + """ + debugger = HyPdb() + if header is not None: + debugger.message(header) + debugger.set_trace(sys._getframe().f_back) + + +# Install as breakpoint hook (PEP 553) +sys.breakpointhook = set_trace diff --git a/tests/test_bin.py b/tests/test_bin.py index 6d2818a29..88cbbe414 100644 --- a/tests/test_bin.py +++ b/tests/test_bin.py @@ -487,6 +487,60 @@ def clean(x): in clean(o)) +def test_hy_pdb_commands(tmp_path): + # Test Hy-specific pdb commands. + + p = tmp_path / 'ex.hy' + p.write_text(str.strip(dedent(''' + (defmacro my-add [a b] `(+ ~a ~b)) + (defn f [] + (breakpoint) + (my-add 1 2)) + (f)'''))) + + # Test macroexpand (me) command + o, _ = run_cmd(['hy', p], 'me (my-add 3 4)\nquit\n', expect=1) + assert "'(+ 3 4)" in o + + # Test macroexpand_1 (me1) command + o, _ = run_cmd(['hy', p], 'me1 (when True (print "hi"))\nquit\n', expect=1) + assert "'(if True (do (print \"hi\")) None)" in o + + # Test macros (m) command + o, _ = run_cmd(['hy', p], 'm\nquit\n', expect=1) + assert "my-add" in o + + # Test hy_repr (hy) command + o, _ = run_cmd(['hy', p], 'n\nhy result\nquit\n', expect=1) + assert "3" in o # result of (+ 1 2) + + +def test_hy_pdb_local_macros(tmp_path): + # Test that `m` command also shows local macros (defmacro and + # require inside function bodies). + + p = tmp_path / 'ex.hy' + p.write_text(str.strip(dedent(''' + (defmacro my-add [a b] `(+ ~a ~b)) + (defn f [] + (defmacro helper [] + "local helper") + (require tests.resources.local-req-example [wiz]) + (breakpoint) + (my-add 1 2)) + (f)'''))) + + o, _ = run_cmd(['hy', p], 'm\nquit\n', expect=1) + # module-level + assert "my-add" in o + # local defmacro + assert "helper" in o + # local require + assert "wiz" in o + assert "Local macros" in o + assert "Macros" in o + + def test_hystartup(): # spy == True and custom repl-output-fn env = dict(HYSTARTUP = "tests/resources/hystartup.hy")