
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
classin 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★ topmark, 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 instanceof 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 forexternalandfamilyentries. - 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
Fshortcut) 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.
Option B — -fplugin-library (recommended for real projects)
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:
| Setup | What 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
.sois GHC-version-specific. A plugin built withghc-9.14.1only loads inghc-9.14.1. Mixing minor versions can silently fail or crash. - Cabal hashes the unit-id. A non-
inplaceinstall ends up withclassgraph-0.1.0.0-1234abcd…. The helper script always reads the current id fromdist-newstyle/. If you want a stable id for scripting, addghc-options: -this-unit-id classgraphto the plugin library’s stanza inclassgraph.cabaland 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 asexternalfrom another collapses to a single local node. - Normalises package ids before deduping:
pkg-1.0-inplace,pkg-1.0-<sha>, andpkg-1.0-l-api-<sha>all collapse topkg. 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
| Action | Effect |
|---|---|
| Click a node | Highlight + populate the right-side details panel |
| Double-click a class | Drill into its instance view |
| Double-click a family | Drill into its family view |
| Click a class name in the side panel | Navigate 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 back | Return to the classes view |
Fit button or F | Re-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):
| Setting | What it does |
|---|---|
| Editor | Picks 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 override | Absolute 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 ofDIRis 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 themy-apppackage.Repeat
--inputfor 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:
| Scheme | URL shape |
|---|---|
emacs | emacs://open?file=…&line=…&column=… |
emacs-org | org-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.
emacs-org — recommended (org-protocol)
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 & Security
→ Default 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_tcsfiltered withtyConClass_maybe. Superclasses come fromclassSCTheta, decomposed viaclassifyPredTypeso constraint tuples expand into individual edges and the boxed equality classes(~)/(~~)get rendered infix aslhs ~ rhschips on instance nodes (rather than as edges to a synthetic~class). - Type families (open / closed / associated) come from
tcg_tcsfiltered withisFamilyTyCon, plus the assoc families discovered via each class’sclassATs. Closed-family branches are recovered from theCoAxiom Branched. Data family instances are detected viafi_flavor’sDataFamilyInst; the syntheticR:…data-constructor TyCon on the RHS is hidden from rendering since it isn’t usefully inspectable. - Type-arg rendering uses
splitVisibleFunTy_maybefor arrows and filters invisible kind binders viatyConBinders+isInvisibleTyConBinder. Without this, function and kind-poly applications leakFUN ManyTy (BoxedRep Lifted) …into every label. - Pretty-printing uses an
SDocContextwithsdocPrintExplicitRuntimeReps = False,sdocLinearTypes = False,sdocPrintExplicitKinds = Falseand friends, so fallback pretty-printed strings (forOtherArg/LitArg) read as user-facing Haskell. iiSrcuses the dfun’sNamespan (theinstance …declaration) rather than the class’s name span (which isUnhelpfulSpanfor classes loaded from another package’s interface file).fiSrcfor fam-instances usescoAxBranchSpanof the underlyingCoAxBranch, so each type family instance points at its owndata instance/type instancedeclaration 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
externalstub. 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 toEq (G b)whosetype instance G … = …declarations live in another package, the chain visualisation will stop atG’s family node. Extract that package too and the chain will keep going. - Pretty-printing is best-effort. Anything that survives the
structural converter (
Typeshapes we don’t recognise) falls back to GHC’sOutputable, which is much friendlier than it used to be after theprettyCtxoverrides but isn’t perfect. If you find a label that reads like internal compiler noise, file an issue with arepro.hs. - No support for data-family RHS bodies. We record that a
data instanceexists 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.