Haskell con debugger interactivo en Doom Emacs Y en IntelliJ Ultimate. Hito doble.


5 de mayo de 2026

El lunes lo anuncie, hoy lo he hecho. Dos veces.

Hace tres dias publique un post celebrando que Well-Typed habia sacado hdb, el debugger DAP para Haskell, y prometiendole a Pascual que el lunes lo arrancabamos. Pues eso. Hoy, martes 5 de mayo, lo tenemos funcionando en dos editores: Doom Emacs (lo previsto) y IntelliJ Ultimate 2026.1 (lo que paso de bonus a hito propio cuando Pascual dijo "aventura, hombre, aventura").

Breakpoint, ejecutar, pausa en el thunk, panel de variables, step over. Las dos.

Y como todo en NixOS, el dia ha sido una ofrenda de tropezones hilarantes que merecen estar escritos para el proximo que se meta. Esto es el postmortem alegre.

hdb pausando en IntelliJ Ultimate, panel Locals con thunks sin evaluar
IntelliJ Ultimate 2026.1 pausado en el breakpoint. Mira la linea primerosCincoParesAlCuadrado = {[Int]} _: ese guion bajo es un thunk SIN EVALUAR, en vivo, en el panel del IDE.

Que es lo que se ve cuando funciona

Para que el lector que no haya pisado Doom todavia se haga una idea:

+--------------------------------------+ +-----------------------+
| Debugging101.hs                      | |  DAP Variables        |
|                                      | |                       |
|   primerosCincoParesAlCuadrado :: ...| |  > xs :        |
|   primerosCincoParesAlCuadrado =     | |    casos :     |
|     take 5 $                         | |    n : 16             |
|       map (^ (2 :: Int)) $           | |                       |
|         filter even [1..1000000]     | +-----------------------+
|                                      |
|   foldrLazyDemo :: IO ()             | +-----------------------+
|   foldrLazyDemo = do                 | |  Stack frames         |
|  >>>let xs = primerosCinc...  | |                       |
|     putStrLn $ "Resultado: " ++...   | |  -> foldrLazyDemo     |
|                                      | |     main              |
+--------------------------------------+ +-----------------------+

         F10 step over    F11 step in    F12 step out
                  Espacio-d-d para arrancar

Lo magico no es la UI. Es lo que pone en el panel de variables: xs : <thunk>. Estas viendo, en tiempo real, una expresion sin evaluar. Si haces step over a la siguiente linea, xs se convierte en [4,16,36,64,100] delante de tus ojos. Eso es Haskell, y eso ahora se puede MIRAR.

Por que esto era dificil hasta esta semana

Tres condiciones tenian que cumplirse a la vez:

  1. GHC 9.14, que lleva los stop points semanticos para lazy code. Salio hace pocas semanas, no esta en ninguna distribucion estable todavia.
  2. haskell-debugger compilado contra esa misma version exacta de GHC. No vale 9.14.0 vs 9.14.1: tiene que ser bit a bit la misma.
  3. Un cliente DAP en tu editor que sepa hablar con el. Para Doom eso es dape, el cliente DAP nativo en Emacs Lisp.

Cualquiera de los tres descuadrado y nada arranca. Yo me he tropezado con los tres.

Trampa numero 1: el flake no tenia GHC 9.14

El proyecto pensando-en-haskell que abri hace meses tenia esto en su flake.nix:

ghcWithPackages = pkgs.haskell.packages.ghc98.ghcWithPackages

GHC 9.8. Insuficiente. Cambio a 9.14:

ghcWithPackages = (pkgs.haskell.compiler.ghc914.override {
  # ...
})

nix develop y a esperar. Aurin, Dual Xeon, 72 hilos, ciento veintiocho gigas de RAM, se peta compilando servant-server. Que pasa?

Trampa numero 2: servant-server bloquea base 4.22

GHC 9.14 trae base-4.22.0.0. servant-server todavia tiene un build-depends: base < 4.22 en su cabal. Cabal solver se come la restriccion, intenta downgradearlo todo, no encuentra plan, explota. Mensaje de error: una pared de texto de cuatrocientos paquetes con dependencias circulares.

Solucion brutalista: si yo solo necesito el Debugging101.hs que ESCRIBI HACE 10 MINUTOS, no necesito servant. Comento el target executable pensando-en-haskell-exe en pensando-en-haskell.cabal, añado un target nuevo:

executable debug101
  main-is:        Debugging101.hs
  hs-source-dirs: app
  build-depends:  base
  default-language: Haskell2010

Solo base. Compila en seis segundos.

Trampa numero 3: HLS no soporta 9.14

haskell-language-server (el LSP normal de Haskell, lo que te da autocompletado y errores en linea) NO soporta GHC 9.14 todavia. Sus dependencias tampoco. Asi que durante esta epoca de transicion, si quieres usar 9.14 para el debugger, renuncias a HLS.

Es un trade que tienes que aceptar. Te quedas sin autocompletado pero ganas el debugger. En unos meses HLS se actualizara y volvera todo a la normalidad. Mientras tanto, haskell-mode bruto, font-lock, y fourmolu como formatter para no llorar.

Trampa numero 4: el GHC que ve hdb tiene que ser el mismo

Compilo hdb dentro del nix develop (donde GHC 9.14.1 esta en PATH). Lo instalo en ~/.local/bin/hdb. Vale.

Doom arranca hdb a traves de dape. El proceso Emacs hereda el PATH de mi sesion grafica, NO el PATH del shell de nix develop. Resultado: hdb se lanza, busca ghc, encuentra el ghc 9.8 que tengo instalado globalmente, detecta que no es 9.14, y aborta con error.

La solucion es un wrapper:

#!/usr/bin/env bash
# ~/.local/bin/hdb-dap
set -euo pipefail

# Sube buscando flake.nix
dir="$(pwd)"
while [ "$dir" != "/" ]; do
  [ -f "$dir/flake.nix" ] && { cd "$dir"; break; }
  dir="$(dirname "$dir")"
done

# Entra en nix develop antes de exec hdb
exec nix develop --command hdb "$@"

Doom llama a hdb-dap en lugar de hdb. El wrapper sube hasta encontrar el flake.nix, entra en nix develop (que pone GHC 9.14 en PATH), y exec hdb. Magia transparente.

Trampa numero 5: dape timeout default es 10 segundos

Configurado el adaptador, hago espacio-d-d. Aparece el menu, elijo haskell-hdb, le pongo Enter, y a los DIEZ SEGUNDOS exactos:

Command "launch" timed out after 10 seconds

hdb en su primer arranque tarda quince a treinta segundos descubriendo flags de compilacion. Llama a cabal path, llama a ghc --print-libdir, parsea el cabal del proyecto. Eso es legitimo. Pero dape por defecto solo le da diez segundos antes de matar la conexion.

Una linea:

(setq dape-request-timeout 60)

Y arrancado.

Trampa numero 6: flycheck-haskell-stack-ghc petando con unicode

Mi Debugging101.hs tiene comentarios en español sin tildes pero con ñ. flycheck, que viene activado por defecto en Doom para haskell-mode, intenta correr el checker haskell-stack-ghc sobre el buffer cada vez que pulso una tecla. stack no existe en mi flake (yo uso cabal), asi que falla mil veces por segundo, llenando el *Messages* buffer y comiendo CPU.

Apaga y vamonos:

(after! flycheck
  (add-to-list 'flycheck-disabled-checkers 'haskell-stack-ghc))

La config final de Doom

Esto es lo que va en ~/.config/doom/config.el si quieres replicar el montaje:

(after! flycheck
  (add-to-list 'flycheck-disabled-checkers 'haskell-stack-ghc))

(use-package! dape
  :config
  (setq dape-request-timeout 60)

  (add-to-list 'dape-configs
               `(haskell-hdb
                 modes (haskell-mode haskell-ts-mode haskell-cabal-mode)
                 ensure (lambda (config)
                          (unless (executable-find "hdb")
                            (user-error "hdb no encontrado en PATH")))
                 fn (dape-config-autoport)
                 host "localhost"
                 port :autoport
                 command "hdb"
                 command-args ("server" "--port" :autoport)
                 :type "haskell-debugger"
                 :request "launch"
                 :projectRoot dape-cwd-fn
                 :entryFile dape-buffer-default
                 :entryPoint "main"
                 :entryArgs []
                 :extraGhcArgs []
                 :internalInterpreter t)))

Tres detalles:

  1. command-args ("server" "--port" :autoport). hdb por defecto arranca en modo CLI. Le pasas server --port <n> para que escuche TCP. :autoport es un placeholder de dape que se sustituye por un puerto libre en runtime.

  2. :internalInterpreter t. hdb puede arrancar el interprete GHCi en su mismo proceso (rapido) o spawnar un subprocess (mas aislado, mas lento). Para programas pequeños el internal va sobrado y arranca antes.

  3. Si no tienes wrapper hdb-dap, cambia command "hdb" por command "hdb-dap". Yo prefiero la indireccion porque me asegura que el flake del proyecto se carga.

El precio: cada arranque recompila

Honestidad brutal: cada espacio-d-d tarda quince a veinte segundos. hdb arranca un GHC fresco con bytecode interpreter para poder pausar dentro de codigo perezoso. No hay cache. Ese es el precio de tener debugger en un lenguaje lazy.

Es lo mismo en VSCode con la extension oficial. Ese precio se paga en todos los editores. No es una limitacion de Doom.

Truco: en vez de cerrar y abrir el debugger constantemente, deja la sesion abierta y usa dape-evaluate-expression o el REPL embebido para probar cosas sin reiniciar.

Lo que sigue

Ahora viene lo divertido: meterme en sesiones reales de aprendizaje con esto encendido.

Todo eso esta ya escrito en app/Debugging101.hs del repo pensando-en-haskell. Tres ejercicios, tres conceptos, todo con breakpoint listo. Cuando termine las sesiones escribire un post por cada una.

La aventura IntelliJ Ultimate (lo que iba a ser un punto y coma)

Cuando le ensene a Pascual el debugger en Doom, se quedo pensando: "perfecto, pero IntelliJ Ultimate es donde brilla la depuracion para PHP, ¿por que no Haskell?". Y dijo lo magico: **"aventura, hombre, aventura"**.

Le advierto: Haskell e IntelliJ no se llevan. El plugin clasico de Rik van der Kleij esta congelado desde 2021 y NO aparece en el Marketplace de IntelliJ 2026.1 (Marketplace lo oculta cuando un plugin no actualiza compatibilidad). Pero IntelliJ Ultimate trae plugin MCP server y JetBrains tiene LSP/DAP via plugins externos. Habia camino. Solo habia que pelearlo.

Asi que peleamos. Aqui van las trampas en orden cronologico, para que el siguiente que se meta no pierda las dos horas que perdimos nosotros.

Trampa IntelliJ uno: LSP4IJ es el plugin que necesitas

JetBrains publica un plugin oficial llamado "Debug Adapter Protocol" (Fleet/IntelliJ shared) pero es para developers de plugins, no user-facing. Si lo instalas y buscas "Debug Adapter" en Run/Debug Configurations, no aparece.

Lo que necesitas es LSP4IJ de Red Hat. Marketplace ID 23257-lsp4ij. Plugin distinto. Trae cliente LSP y cliente DAP user-defined: te deja registrar adapters arbitrarios desde la UI.

Settings → Plugins → Marketplace → busca "lsp4ij" → Install + restart.

Trampa IntelliJ dos: PATH no incluye ~/.local/bin

Configurada la primera version del adapter en LSP4IJ:

Server tab:
  Name: Haskell hdb
  Command: hdb-dap server --port ${port}
  Connect by waiting: Log pattern: "Running DAP server on"

Pulso Debug. Cannot run program "hdb-dap": No such file.

IntelliJ Ultimate se lanza desde el JetBrains Toolbox y NO hereda el PATH del shell de usuario. ~/.local/bin no esta. Solucion: ruta absoluta:

Command: /home/passh/.local/bin/hdb-dap server --port ${port}

Trampa IntelliJ tres: el cwd es el de IntelliJ, no el del proyecto

Pulso Debug. Ahora hdb-dap si arranca, pero falla con "no flake.nix encontrado subiendo desde /home/passh/.local/share/JetBrains/Toolbox/ apps/intellij-idea-ultimate/bin".

LSP4IJ NO respeta el "Working directory" del Configuration tab para el server (solo para el debuggee). Lanza el adapter en el cwd de IntelliJ. El wrapper hdb-dap sube buscando flake.nix y obviamente no lo encuentra alli.

Solucion: bash -c con cd explicito:

Command: bash -c "cd /home/passh/src/pensando-en-haskell && exec /home/passh/.local/bin/hdb-dap server --port ${port}"

Comillas dobles para que LSP4IJ siga sustituyendo ${port} antes de pasar el string a bash.

Trampa IntelliJ cuatro: dentro de nix develop, hdb tampoco esta

Pulso Debug. Avanza, entra en el flake, banner del shell, y peta:

/tmp/nix-shell.79PY8I: line 2188: exec: hdb: not found

El wrapper hdb-dap hacia nix develop --command hdb. Pero el shell de nix develop NO incluye ~/.local/bin en PATH (donde cabal install deja hdb). En Doom funcionaba porque direnv ya cargaba el env del flake en el buffer Emacs y hdb se resolvia con el PATH del padre. Aqui no.

Solucion: pasar la ruta absoluta de hdb al nix develop. Modifico el wrapper:

HDB_BIN="${HDB_BIN:-$HOME/.local/bin/hdb}"
exec nix develop --command "$HDB_BIN" "$@"

Trampa IntelliJ cinco: ${file} no se sustituye

Pulso Debug. hdb arranca, conecta, lee el JSON de launch… y peta con:

filepath: /home/passh/src/pensando-en-haskell/${file}

LSP4IJ NO expande ${file} como hace VSCode. Pasa el placeholder literal al adapter. Workaround: ruta absoluta hardcodeada en el JSON del Configuration tab:

{
  "type": "haskell-debugger",
  "request": "launch",
  "projectRoot": "/home/passh/src/pensando-en-haskell",
  "entryFile": "/home/passh/src/pensando-en-haskell/app/Debugging101.hs",
  "entryPoint": "main",
  "internalInterpreter": true
}

Trampa IntelliJ seis: el programa corre entero sin pausar

Pulso Debug. hdb arranca, compila el modulo, ejecuta foldrLazyDemo, maybeChainDemo, eitherChainDemo, imprime "Fin." y termina. Sin parar en el breakpoint que puse en linea 47.

Activo Trace verbose en LSP4IJ y miro el wire protocol DAP. Veo:

[Trace] Received notification 'initialized'
[Trace] Sending request 'configurationDone'

No hay setBreakpoints entre medias. El cliente envio configurationDone sin enviar breakpoints. Por eso hdb corre el programa entero.

Causa: IntelliJ NO reconoce .hs como un fileType (no tengo plugin Haskell instalado). Sin fileType reconocido, los breakpoints quedan huerfanos: se crean en la UI pero LSP4IJ no los asocia al adapter.

Trampa IntelliJ siete: el plugin de Rik no esta y Flexible Haskell

pide licencia

Marketplace de IntelliJ 2026.1 NO tiene intellij-haskell de Rik (plugin congelado). Flexible Haskell (alternativa moderna) requiere licencia comercial. Haskell LSP de Rockofox se basa en HLS, y HLS NO soporta GHC 9.14.

Camino alternativo: registrar el fileType a mano.

Settings → Editor → File Types → "+"
  Name: Haskell
  Description: Haskell source
  Line comment: --
  Block comment start: {-
  Block comment end: -}
  Add file name patterns: *.hs, *.lhs
Crear fileType Haskell a mano en Settings
El dialogo Settings → Editor → File Types al crear el fileType "Haskell" desde cero: nombre, descripcion, comentarios y pareados.

Pero algo aun mejor te puede pasar al añadir el patron *.hs:

Diálogo: el wildcard *.hs ya está registrado por 'Haskell language file'
EUREKA. IntelliJ ya tiene un fileType "Haskell language file" registrado, normalmente cortesia del plugin =Haskell LSP= (Rockofox) si lo instalaste antes. Aunque ese plugin no nos sirva para HLS+9.14, su fileType es perfecto.

Si te pasa eso, cancela la creacion manual del fileType y usa el que ya existe. Es la via mas barata.

Despues, en LSP4IJ DAP "Haskell hdb" → Mappings tab → File type → añadir el fileType "Haskell" con language id haskell:

LSP4IJ Mappings tab con File type Haskell + language id haskell
El Mappings tab del DAP "Haskell hdb": File type "Haskell" mapeado al language id haskell. La X morada al lado del nombre confirma que el fileType esta activo.

Trampa IntelliJ ocho (la final, la que casi tira la toalla)

Configurado todo. Pulso Debug. EL PROGRAMA SIGUE SIN PARAR.

Activo trace verbose otra vez. Miro el launch request:

{
  "request": "launch",
  "projectRoot": "/home/passh/src/pensando-en-haskell",
  "entryFile": "/home/passh/src/pensando-en-haskell/app/Debugging101.hs",
  "entryPoint": "main",
  "internalInterpreter": true,
  "noDebug": true
}

noDebug: true. En DAP eso significa "ejecuta sin debugger activo, sin breakpoints, sin pausas". El cliente NI siquiera envia setBreakpoints cuando noDebug:true, porque el server los ignoraria.

Que pasa? Estoy pulsando el boton Run en lugar del boton Debug.

En IntelliJ hay DOS botones contiguos en la barra:

Atajo: Shift+F9 para Debug, no Shift+F10 que es Run.

Le doy a Debug. PARA EN EL BREAKPOINT. Panel de variables abre el modulo. Veo primerosCincoParesAlCuadrado = {[Int]} _. Ese guion bajo es un thunk SIN EVALUAR. Conseguido.

El debugger en accion: pulsamos Debug, =hdb= compila el modulo dentro del flake, para en el breakpoint, expandimos el thunk en el panel de variables, step over, y vemos como el guion bajo se transforma en la lista evaluada. Lazy evaluation que se VE.

La config para tu repo Haskell en IntelliJ

Una vez configurado el DAP "Haskell hdb" en LSP4IJ, IntelliJ te ofrece "Store as project file" en la Run Configuration. Acepta. Eso guarda la config en .idea/runConfigurations/ dentro del proyecto. Cualquiera que clone tu repo y tenga LSP4IJ instalado va a tener el debugger funcional sin tocar UI.

Resumen de los puntos clave si quieres replicarlo:

  1. Plugin LSP4IJ (Red Hat, Marketplace 23257). Free.
  2. fileType Haskell registrado: o via Haskell LSP plugin (gratis, ignora HLS), o creandolo a mano en Settings → File Types.
  3. LSP4IJ Run/Debug Configuration tipo "Debug Adapter Protocol":
    • Server command: bash -c "cd /ruta/proyecto && exec /ruta/.local/bin/hdb-dap server --port ${port}"
    • Connect by waiting: log pattern "Running DAP server on"
    • Mappings → File type: tu fileType Haskell, language id haskell
    • Configuration → DAP parameters JSON con projectRoot, entryFile, entryPoint, internalInterpreter:true en RUTAS ABSOLUTAS (${file} no se expande).
  4. Wrapper hdb-dap que entra en nix develop y pasa ruta absoluta de hdb.
  5. PULSAR EL BOTON DEBUG, NO EL RUN.

Cierre breve

El post del lunes era una promesa. Este es la entrega doble. Tres dias desde "se puede" hasta "lo tengo en MIS DOS editores". El precio fueron quince trampas (siete en IntelliJ, ocho en Doom y NixOS) y la perdida temporal de HLS hasta que el ecosistema se ponga al dia con GHC 9.14.

Si tu tambien quieres probarlo: el flake esta en mi repo de github con todo lo necesario. cabal install haskell-debugger y a volar. Si te atascas, mirate las trampas de arriba: muy probablemente es una de ellas.

Pascual: que venga dios y lo vea. El hito esta. A estudiar.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario