Skip to main content

Debugging

5 min read Last updated
On this page

This page takes you from the quickest print to a full step debugger. Start at the top and move down only when the simpler tool does not answer your question.

QuestionTool
What does this expression return?dbg
What values flow through the program?tap>
Which calls happen, with which arguments?phel.trace
Why did it crash?The stack trace
What are the locals at this line?(break)
I need IDE breakpoints and steppingXdebug
What PHP does my Phel become?phel compile
It works, but it is slowphel profile

dbg prints [file:line] form => value to stderr and returns the value unchanged. Wrap any subexpression without changing your code's shape:

(defn area [w h]
  (* (dbg w) h))

(area 3 4)
;; stderr: [src/main.phel:2] w => 3
;; => 12

Because the value passes through, dbg works inside threading macros:

(->> (range 10)
     (map inc)
     (dbg)          ; the mapped sequence
     (filter even?)
     (dbg))         ; the filtered result

With no argument, dbg prints only [file:line] and returns nil. Use it as a "reached here" marker:

(when (some-condition? x)
  (dbg)
  (handle x))

Output goes to stderr, so it never mixes with stdout. Hide it with 2>/dev/null. In tests, capture it by redefining dbg-write with with-redefs.

For large nested data, use phel.pprint:

(ns my-app
  (:require phel.pprint :refer [pprint]))

(pprint {:users [{:name "Alice" :roles [:admin :editor]}
                 {:name "Bob" :roles [:viewer]}]
         :count 2})

PHP dump functions

Every PHP function works through the php/ prefix, so var_dump is always available:

(php/var_dump (+ 3 3))
;; stdout: int(6)

For richer output (colors, collapsible HTML in the browser, circular references), add Symfony VarDumper as a dev dependency:

"require-dev": {
    "symfony/var-dumper": "^7.4"
}

Then dump prints a value and continues, and dd prints it and stops the script:

(php/dump (+ 4 4))   ; prints 8, keeps running
(php/dd (+ 5 5))     ; prints 10, then exits

These PHP tools print Phel collections as PHP objects, with all their internal fields. Use them for PHP objects from interop code, and dbg or pprint for Phel data.

Watch values with tap>

tap> sends a value to every handler registered with add-tap. The code that calls tap> does not know who listens, so you can leave the calls in place and attach a handler only when you need it:

(add-tap println)                    ; attach a handler
(tap> {:event :login :user "alice"}) ; anywhere in your code
(remove-tap println)                 ; detach it

The REPL registers a printing tap on start, so (tap> x) shows up there with no setup. Remove it with (remove-tap phel.repl/print-tap). The REPL guide shows how to collect tapped values in an atom.

Trace function calls

phel.trace shows how a function is called: arguments, results, and recursion depth. deftrace defines a function that prints every call to stderr:

(ns my-app
  (:require phel.trace :refer [deftrace dotrace]))

(deftrace fact [n]
  (if (<= n 1) 1 (* n (fact (dec n)))))

(fact 3)
;; TRACE t1: (fact 3)
;; TRACE t2: | (fact 2)
;; TRACE t3: | | (fact 1)
;; TRACE t3: | | => 1
;; TRACE t2: | => 2
;; TRACE t1: => 6

To trace existing functions without editing them, use dotrace. It traces the named functions inside the body and restores them afterwards:

(dotrace [parse-row normalize]
  (import-csv "data.csv"))

Two smaller helpers are public too: (trace :tag value) prints a tagged value and returns it, and (trace-fn "name" f) returns a traced wrapper for any function.

Read a stack trace

Errors point at your .phel files and lines, not at the generated PHP. This script divides by zero:

(ns my-app.main)

(defn div-all [nums d]
  (map (fn [n] (/ n d)) nums))

(println (first (div-all [1 2] 0)))

Output (paths shortened):

[PHEL404] Division by zero
  at src/main.phel:4
#1 vendor/phel-lang/phel-lang/src/phel/core/math.phel:302 : (phel\core\/ 1 0)
#2 src/main.phel:4 : (phel\core\/ 1 0)
#3 vendor/phel-lang/phel-lang/src/php/Lang/Generators/TransformGenerator.php:53 : (my-app\main\div-all 1)
#10 src/main.phel:6 : (phel\core\first ())
   ... 30 internal frames (--stack-trace to show, full trace in .phel/error.log)
  • The first line has the error code. vendor/bin/phel explain PHEL404 explains it and shows the smallest program that raises it. All codes are listed in Error codes.
  • Each #N file.phel:line : (fn args...) frame is your code or a core function it called, with the real arguments.
  • Runtime internals collapse into ... N internal frames. Pass --stack-trace to see them. The full trace is always in .phel/error.log.
  • Many errors end with a hint: line, for example a missing (:require ...) for an undefined symbol.

Pause with break

Put (break) in a function and execution stops there. You get a sub-REPL with every local in scope:

(defn checkout [cart user]
  (let [total (cart-total cart)]
    (break)
    (charge user total)))
--- breakpoint ---
  cart = {:items [...]}
  user = {:id 42, :name "alice"}
  total = 99.5
type an expression to eval it with locals in scope; (continue) to resume
break>

At the break> prompt, evaluate any expression with those locals. Commands:

CommandEffect
(continue), continue, c, or Ctrl-DResume
:locals or lPrint the locals again

Without a terminal (CI, pipes, parallel test workers, cron), (break) prints --- breakpoint skipped --- and continues, so a forgotten one never hangs a job.

In the plain REPL, *1, *2, *3 hold recent results and *e holds the last exception. Use macroexpand-1 and macroexpand to see what a macro produces:

(macroexpand-1 '(when x y))
; => (if x (do y))

Step through with Xdebug

For IDE breakpoints, watches, and stepping, use Xdebug. With the VS Code Phel extension you set breakpoints in .phel files and see Phel values. Other editors (PhpStorm, Emacs, Neovim) debug the compiled PHP. Xdebug Setup covers install and editor config.

To stop the connected debugger at a line from code (a no-op without Xdebug):

(when (php/function_exists "xdebug_break")
  (php/xdebug_break))

Inspect the compiled PHP

phel compile prints the PHP for a snippet or a file without running it, so it is safe on code with side effects:

vendor/bin/phel compile '(defn greet [name] (str "Hello, " name "!"))'

Output, trimmed:

\Phel::addDefinition(
  "user",
  "greet",
  new class() extends \Phel\Lang\AbstractFn {
    public const BOUND_TO = "user\\greet";

    public function __invoke($name): string {
      return (\Phel\Lang\Registry::readRoot("phel.core", "str"))->__invoke("Hello, ", $name, "!");
    }
  },
  // ... location and metadata
);

Every defn becomes a class that extends AbstractFn, registered under its namespace. Core functions are looked up through the registry. Use this to debug interop, report a compiler bug, or see why something is slow.

Keep the generated files

phel run and the REPL compile to temp files such as $TMPDIR/phel/tmp/__phel_<hash>.php and delete them afterwards. An error that names one of those files then points at a file that is gone. Keep them in a local config file:

<?php # phel-config-local.php

return (require __DIR__ . '/phel-config.php')
    ->withKeepGeneratedTempFiles(true)
;

Add phel-config-local.php to .gitignore so your dev settings stay out of the shared config. See Configuration for other dev settings.

Show all PHP errors

To see every PHP warning, notice, and deprecation during development, turn on error reporting in the same file:

<?php # phel-config-local.php

error_reporting(E_ALL);
ini_set('display_errors', '1');

return (require __DIR__ . '/phel-config.php')
    ->withKeepGeneratedTempFiles(true)
;

Find the slow part

When the code is correct but slow, measure before you change anything:

vendor/bin/phel profile src/main.phel

It reports call counts and self and total time per function, plus compile phase costs. Sort with --sort=total|self|calls|avg and export with --format=json. Performance explains what to do with the results.

Keep the loop short

  • vendor/bin/phel watch reloads namespaces when files change, so you skip startup cost between tries.
  • vendor/bin/phel test --filter <name> reruns one test. Once you find the bug, pin it with a test.