classgraph logo

Git

A GHC typechecker plugin and browser visualizer for Haskell typeclass hierarchies. Drop the plugin onto a project, render the captured data as a self-contained interactive HTML page, and explore the inheritance DAG, class instances, type families, and superclass requirements with xdot-style highlighting.

   target package ───────────►  per-module JSON dumps
   (built with the plugin)        (.classgraph/*.json)
                                          │
                                          ▼
                              classgraph-view ──────►  classgraph.html
                                                     (one self-contained file)

What you get

When you point the plugin at a target package and run the viewer, you get:

  • An interactive classes view: every class in the program as a node, edges drawn for direct superclasses, with an extra dashed edge whenever the superclass is mediated by a type family (class Pretty (Norm a) => Foo a-style). Top-level classes (those no other class extends) get a gold border and a ★ top mark, and are rendered as the topmost classes.
  • An instance view per class, drilled into by double-clicking. Shows every instance of that class, the constraints in each instance’s context, the superclass requirements (and the matching superclass instances when present), and any associated type instance F … declarations. When the constraint goes through a type family, you also see the chain focused-instance → family → concrete fam-instance → satisfying class-instance.
  • A family view per type or data family. Shows every type instance / data instance of that family. Open, closed, associated, and data families are all distinguished — with (data) appended to the label for data families.
  • Backwards navigation via the side panel: every class lists its superclasses and its subclasses (i.e. the classes that extend it in this program). Click any name to navigate.
  • Search (top right or /) for classes and families, with badges for external and family entries.
  • Focus the classes view on a small set of classes from the side panel. The graph collapses to just the focused classes plus their immediate superclass neighbourhood (xdot-style narrowing). Click a ghost neighbour to expand the focus by one hop.
  • Mute noisy ambient classes (Show, Eq, Ord, NoThunks, Typeable…) so they vanish everywhere.
  • Per-class instance visibility filter and per-family type-instance filter — checkbox lists in the side panel for hiding clutter.
  • A foldable Help / legend in the bottom-right of every view.
  • A Fit button (and F shortcut) to re-frame after hiding instances.

Quick start: the demo

The repo ships with a tiny test target under examples/demo that exercises every extraction code path (multi-param classes, associated families, an open family used in a superclass context, a closed family, a data family, an orphan instance, equality contexts, etc.).

cabal build classgraph        # build the plugin library
cabal build demo              # builds with -fplugin=Classgraph.Plugin
cabal run classgraph-view -- \
  --input examples/demo/.classgraph \
  --output classgraph-demo.html
xdg-open classgraph-demo.html  # or `open` on macOS

That should produce a single HTML file ~780 KB containing 16 classes, 32 instances, 6 families, and 11 family instances, ready to be explored.

Using the plugin in your own project

There are two ways to attach the plugin to a target package. The right choice depends on whether your target has tight dependency bounds.

Option A — build-depends (simplest, okay for small projects)

In your target’s .cabal file:

library
  build-depends: classgraph
  ghc-options:   -fplugin=Classgraph.Plugin
                 "-fplugin-opt=Classgraph.Plugin:dir=.classgraph"

GHC loads the plugin from classgraph in the package set and the cabal solver figures everything out. Easiest to set up but the plugin’s transitive bounds (aeson ^>= 2.2, ghc ^>= 9.14, …) become part of your build plan — which can cause a project with tight version bounds to lose a usable build plan. If that happens, switch to Option B.

GHC 9.4 introduced -fplugin-library, which loads a prebuilt plugin shared object directly. The plugin lives in its own dependency closure; the consumer does not add classgraph to build-depends and the cabal solver never sees it.

The flag’s syntax is:

-fplugin-library=<file-path>;<unit-id>;<module>;<args>

with <args> parsed as a Haskell list-of-strings literal (["dir=…"]).

Concretely, in cabal.project.local of your target:

package my-app
  ghc-options:
    -fplugin-library=/abs/path/to/libHSclassgraph-0.1.0.0-inplace-ghc9.14.1.so;classgraph-0.1.0.0-inplace;Classgraph.Plugin;"[\"dir=.classgraph\"]"

Note: the args portion is wrapped in "..." and the inner quotes are escaped with \". Cabal’s ghc-options: parser strips bare " as token-grouping, so this exact spelling is the one that survives intact to GHC.

The shipped helper does this for you. It auto-discovers the .so from either a local cabal-build (./dist-newstyle/…) or your cabal-install store (~/.cabal/store/ghc-<ver>/…), generates the correctly-escaped flag, and emits a ready-to-paste cabal stanza:

# From the classgraph checkout (or wherever the script lives):
./classgraph-plugin-flag.sh --cabal --package my-app \
  >> /path/to/your/project/cabal.project.local

It works in three scenarios:

SetupWhat the helper finds
You cloned this repo and ran cabal build classgraph./dist-newstyle/build/.../libHSclassgraph-…-inplace-ghc<ver>.so
cabal install --lib classgraph (library only)~/.cabal/store/ghc-<ver>/classgraph-…/lib/libHSclassgraph-…-ghc<ver>.so, looked up via the store’s package.db
cabal install classgraph-view (executable + transitively the lib)Same store path as above — installing the executable also leaves the library installed in the store

Override the auto-discovery with the CLASSGRAPH_SO env var if you’ve got the .so in a custom location.

Re-run the helper whenever you rebuild classgraph: cabal hashes the unit-id, so a fresh build of the plugin produces a new .so filename and a stale flag will not load.

Finding the plugin path manually

If you don’t want to use the helper, you can discover the path and unit-id yourself with ghc-pkg. Pick the right db for your install method:

GHC=$(ghc --numeric-version)

# (a) cabal install --lib / cabal install classgraph-view → cabal store
STORE=$(ls -d ~/.cabal/store/ghc-${GHC}* | head -1)
DB=$STORE/package.db

# (b) cabal install --lib --package-env <env> classgraph → user env file
#     (no separate db, ghc-pkg --user works)

LIBDIR=$(ghc-pkg --package-db=$DB --simple-output field classgraph library-dirs | tr ' ' '\n' | head -1)
UNIT=$(ghc-pkg   --package-db=$DB --simple-output field classgraph id           | tr ' ' '\n' | head -1)

SO="$LIBDIR/libHS$UNIT-ghc$GHC.so"
test -f "$SO" || echo "expected $SO to exist"

echo "-fplugin-library=$SO;$UNIT;Classgraph.Plugin;\"[\\\"dir=.classgraph\\\"]\""

For an inplace (local-checkout cabal-build) the path is dist-newstyle/build/<arch>-<os>/ghc-<ver>/classgraph-<ver>/build/libHS<unit-id>-ghc<ver>.so — same shape, no ghc-pkg round trip needed.

Caveats

  • The .so is GHC-version-specific. A plugin built with ghc-9.14.1 only loads in ghc-9.14.1. Mixing minor versions can silently fail or crash.
  • Cabal hashes the unit-id. A non-inplace install ends up with classgraph-0.1.0.0-1234abcd…. The helper script always reads the current id from dist-newstyle/. If you want a stable id for scripting, add ghc-options: -this-unit-id classgraph to the plugin library’s stanza in classgraph.cabal and rebuild.
  • The plugin must be built against the same RTS as the compiler. Don’t link the plugin library statically with the RTS — leave it dynamic so the compiler and plugin share globals. (The default cabal invocation does the right thing.)

Optional: Haddocks in the side panel

If your target is compiled with -haddock, classgraph harvests every class / instance / type-family / family-instance Haddock comment and shows it in the right-side details panel.

ghc-options: -haddock
             -fplugin=Classgraph.Plugin
             "-fplugin-opt=Classgraph.Plugin:dir=.classgraph"

(Or, in the -fplugin-library setup: add -haddock to the same ghc-options: block — it’s a target-side flag, not a plugin option.)

Without -haddock the rest of the data still flows through; you just won’t get a “Documentation” entry in the panel.

Combining dumps from multiple packages

Big projects often span several cabal packages in the same monorepo or neighbouring checkouts. Build each with the plugin attached, then merge their .classgraph/ directories into a single rendered HTML by repeating --input:

cabal run classgraph-view -- \
  --input ../pkg-a/.classgraph \
  --input ../pkg-b/sub/foo/.classgraph \
  --input ../pkg-c/lib/.classgraph \
  --output combined.html

The merger:

  • Concatenates all classes / instances / families / fam-instances.
  • Deduplicates classes and families by QualName. First occurrence wins, so a class defined in one dump and referenced as external from another collapses to a single local node.
  • Normalises package ids before deduping: pkg-1.0-inplace, pkg-1.0-<sha>, and pkg-1.0-l-api-<sha> all collapse to pkg. This is what makes cross-dump references unify into one node — the same package can otherwise appear under three different ids depending on which dump observed it (locally-built, installed-via-hash, internal-library).

Viewer reference

ActionEffect
Click a nodeHighlight + populate the right-side details panel
Double-click a classDrill into its instance view
Double-click a familyDrill into its family view
Click a class name in the side panelNavigate to that class (highlight + center)
Search (/ or top-right input)Locate a class/family in the classes view
Focus (side panel button)Narrow the classes view to focused classes + one-hop neighbours
Mute (side panel button)Hide a class everywhere
Click a ghost (focus-mode neighbour)Add it to the focus and expand the subgraph
Back arrow (topbar) / browser backReturn to the classes view
Fit button or FRe-frame the current view
Help (bottom-right)Foldable legend of every node and edge style

The instance and family views also have a per-target visibility filter in the side panel — one checkbox per item, with Show all / Hide all buttons and a substring search.

Jumping to source from the panel

The side panel has an Editor link block at the top with two settings (persisted to localStorage):

SettingWhat it does
EditorPicks a URL scheme — VS Code, VS Code Insiders, Cursor, IntelliJ family, TextMate (txmt://), Emacs (emacs:// or org-protocol://), or plain file://. Set it to off to keep Defined at as plain text.
Source root overrideAbsolute prefix prepended to relative paths when no per-package root is known. Usually leave blank — classgraph-view infers roots from --input (see below).

Once an editor is chosen, every Defined at line in the panel becomes a clickable link that opens the file at the right line in your editor. Schemes that take a column (vscode, cursor, txmt, emacs) get one; idea, emacs-org, and file ignore it.

Source roots are inferred automatically. The plugin records source paths as GHC saw them (usually relative to each package’s source dir), so the viewer needs an absolute prefix to make vscode:// / cursor:// /etc URLs resolvable. classgraph-view does this for you:

  • For each --input DIR, the parent of DIR is used as the source root for every package whose dumps live there. So --input ~/code/my-app/.classgraph/ means ~/code/my-app/ is the root for the my-app package.

  • Repeat --input for multi-package merges; each package gets the root inferred from its own input dir.

  • Override per-package with --source-root PKG=PATH (repeatable):

    cabal run classgraph-view -- \
      --input ~/code/foo/.classgraph \
      --input ~/code/bar/sub/.classgraph \
      --source-root bar=~/code/bar \
      --output combined.html
    

The “Source root override” field in the panel is a global fallback applied only when no inferred root exists for the file’s package — useful when you’ve been handed an HTML file built elsewhere.

Setting up the Emacs schemes

The two Emacs variants build:

SchemeURL shape
emacsemacs://open?file=…&line=…&column=…
emacs-orgorg-protocol://open-source?url=file://…&line=…

Neither URL scheme is hooked up by default — you need to register a handler. Pick whichever suits your habits.

org-protocol:// is already understood by a running Emacs once you load org-protocol, so the only host-side wiring is a desktop entry that hands the URL to emacsclient.

1. Tell Emacs to run a server, load org-protocol, and register a local handler for open-source (in your init.el / ~/.config/emacs/init.el):

(require 'server)
(unless (server-running-p) (server-start))
(require 'org-protocol)

(defun my/org-protocol-open-source (fname)
  "Open a file:// URL with line, bypassing the project-alist remap."
  (let* ((data (org-protocol-parse-parameters fname nil '(:url :line)))
         (uri  (plist-get data :url))
         (line (plist-get data :line))
         (path (cond
                ((string-prefix-p "file://" uri) (url-unhex-string (substring uri 7)))
                (t (url-unhex-string uri)))))
    (find-file path)
    (when line
      (goto-char (point-min))
      (forward-line (1- (string-to-number line))))
    nil))

(add-to-list 'org-protocol-protocol-alist
             '("open-source-local"
               :protocol "open-source"
               :function my/org-protocol-open-source
               :kill-client nil))

Why the custom handler? The built‑in org-protocol-open-source is designed to remap web URLs to a local working copy via org-protocol-project-alist and silently does nothing for plain file:// URLs. Shadowing it with a user entry under the same protocol name (open-source) skips the remap and just opens the file at the given line. If you’re on straight/use-package, make sure these forms run eagerly (e.g. :demand t, not :defer/:commands) so the handler is registered at startup.

Restart Emacs (or M-x eval-buffer). Verify with M-x server-running-p returning t and (assoc "open-source-local" org-protocol-protocol-alist) returning your entry.

2. Register a desktop entry so the OS knows what to do with org-protocol:// URLs. On Linux, drop the following at ~/.local/share/applications/org-protocol.desktop:

[Desktop Entry]
Name=Emacs (org-protocol)
Exec=emacsclient -- %u
Icon=emacs
Type=Application
Terminal=false
Categories=Development;
MimeType=x-scheme-handler/org-protocol;

Refresh the MIME database and make it the default for the scheme:

update-desktop-database ~/.local/share/applications/
xdg-mime default org-protocol.desktop x-scheme-handler/org-protocol

The first link click in the browser will prompt to confirm; after that it opens silently.

On macOS, use a tool like OpenWith or a small Automator app that shells out to /usr/local/bin/emacsclient -n -- "$1" and register it as the org-protocol URL handler in System Settings → Privacy & SecurityDefault web browser ….

3. Pick Emacs (org-protocol) from the editor dropdown in the side panel. Click any Defined at line and the file opens at the right line in the running Emacs session.

emacs — DIY scheme

Use this if you don’t want org-protocol in your config or you’d rather thread the column through too. There is no built-in handler for emacs:// — you supply your own.

1. Drop a small wrapper script at, say, ~/.local/bin/emacs-uri-open:

#!/usr/bin/env bash
# Parse emacs://open?file=PATH&line=LINE&column=COL and pass to emacsclient.
url="$1"
file=$(printf '%s' "$url" | sed -nE 's#.*[?&]file=([^&]+).*#\1#p' | python3 -c 'import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read()))')
line=$(printf '%s' "$url" | sed -nE 's#.*[?&]line=([0-9]+).*#\1#p')
col=$( printf '%s' "$url" | sed -nE 's#.*[?&]column=([0-9]+).*#\1#p')
exec emacsclient -n "+${line:-1}:${col:-1}" -- "$file"

chmod +x ~/.local/bin/emacs-uri-open.

2. Register a desktop entry at ~/.local/share/applications/emacs-uri.desktop:

[Desktop Entry]
Name=Emacs (emacs:// URL)
Exec=/home/YOUR_USER/.local/bin/emacs-uri-open %u
Icon=emacs
Type=Application
Terminal=false
Categories=Development;
MimeType=x-scheme-handler/emacs;

Then:

update-desktop-database ~/.local/share/applications/
xdg-mime default emacs-uri.desktop x-scheme-handler/emacs

3. Pick Emacs (emacs://) from the dropdown. Same UX as above, plus the column is passed through.

Schema, data flow, design notes

For a deeper walkthrough of where every piece of information comes from — including how the type-family resolution chains in the instance view are computed, and what’s deterministic vs. best-effort — see docs/INTERNALS.md.

The plugin runs in typeCheckResultAction (post-typecheck, so all classes / instances / family equations are fully resolved). For each compiled module it writes one JSON file under the configured directory (-fplugin-opt=Classgraph.Plugin:dir=… or the dir=… arg in the -fplugin-library form, default .classgraph).

The on-disk format is defined in Classgraph.Schema. The viewer is a single self-contained HTML file: Cytoscape.js + dagre + the program JSON + the styling and JS, all inlined. No HTTP server, no CDN; opens with file://.

Notable extraction details:

  • Classes come from tcg_tcs filtered with tyConClass_maybe. Superclasses come from classSCTheta, decomposed via classifyPredType so constraint tuples expand into individual edges and the boxed equality classes (~)/(~~) get rendered infix as lhs ~ rhs chips on instance nodes (rather than as edges to a synthetic ~ class).
  • Type families (open / closed / associated) come from tcg_tcs filtered with isFamilyTyCon, plus the assoc families discovered via each class’s classATs. Closed-family branches are recovered from the CoAxiom Branched. Data family instances are detected via fi_flavor’s DataFamilyInst; the synthetic R:… data-constructor TyCon on the RHS is hidden from rendering since it isn’t usefully inspectable.
  • Type-arg rendering uses splitVisibleFunTy_maybe for arrows and filters invisible kind binders via tyConBinders + isInvisibleTyConBinder. Without this, function and kind-poly applications leak FUN ManyTy (BoxedRep Lifted) … into every label.
  • Pretty-printing uses an SDocContext with sdocPrintExplicitRuntimeReps = False, sdocLinearTypes = False, sdocPrintExplicitKinds = False and friends, so fallback pretty-printed strings (for OtherArg / LitArg) read as user-facing Haskell.
  • iiSrc uses the dfun’s Name span (the instance … declaration) rather than the class’s name span (which is UnhelpfulSpan for classes loaded from another package’s interface file).
  • fiSrc for fam-instances uses coAxBranchSpan of the underlying CoAxBranch, so each type family instance points at its own data instance / type instance declaration site.

Known limitations

  • GHC 9.14 only. The plugin uses APIs that are stable from 9.10 onward but the schema / viewer assume 9.14’s flavour of Class, FamInst, CoAxiom, etc. Patches for older GHCs welcome.
  • Cross-package merge requires you to extract every package you care about. A class defined in a package you didn’t build with the plugin becomes an external stub. The shape of the graph stays correct but drilling into such a class shows nothing.
  • Family resolution chains can stop early when the relevant fam-instance isn’t in your dumps. If a class’s superclass Eq (F a) reduces to Eq (G b) whose type instance G … = … declarations live in another package, the chain visualisation will stop at G’s family node. Extract that package too and the chain will keep going.
  • Pretty-printing is best-effort. Anything that survives the structural converter (Type shapes we don’t recognise) falls back to GHC’s Outputable, which is much friendlier than it used to be after the prettyCtx overrides but isn’t perfect. If you find a label that reads like internal compiler noise, file an issue with a repro.hs.
  • No support for data-family RHS bodies. We record that a data instance exists and what types it’s specialised to, but not the constructors / field names. Adding that is mostly a Schema + Render exercise if you need it.

Building from source

Requires:

  • GHC 9.14.1 (ghcup install ghc 9.14.1 && ghcup set ghc 9.14.1)
  • cabal-install 3.16+ (ghcup install cabal latest)
git clone <repo-url>
cd classgraph
cabal update
cabal build all                # plugin library + classgraph-view executable
cabal run classgraph-view -- --help

To run the demo end-to-end:

cabal build demo
cabal run classgraph-view -- --input examples/demo/.classgraph -o classgraph-demo.html

To regenerate dumps for a real target (e.g. my-app) using -fplugin-library:

./classgraph-plugin-flag.sh --cabal --package my-app \
  >> ../my-app/cabal.project.local
cd ../my-app
rm -rf .classgraph
cabal build lib:my-app
cd -
cabal run classgraph-view -- --input ../my-app/.classgraph -o my-app.html

License

BSD-3-Clause. See LICENSE.