Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
149 changes: 149 additions & 0 deletions hy/hy_pdb.py
Original file line number Diff line number Diff line change
@@ -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: <command> (form ...)")
return

try:
# Use <pdb> as filename to avoid confusing error messages
form = hy.read(arg, filename="<pdb>")
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 <python_expression>"""
if not arg.strip():
self.error("Usage: hy_repr <expression>")
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
54 changes: 54 additions & 0 deletions tests/test_bin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading