From 42f46390ee7c9cc6f11c55f229c0c573910adc7c Mon Sep 17 00:00:00 2001 From: Javier Sagredo Date: Mon, 4 May 2026 02:34:07 +0200 Subject: [PATCH] Add README.md --- README.md | 362 ++++++++++++++++++++++++++++++++++++++ classgraph-plugin-flag.sh | 47 ++++- 2 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae67d66 --- /dev/null +++ b/README.md @@ -0,0 +1,362 @@ +# classgraph + +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 ──────► oc.html + (one self-contained file) +``` + +## Table of contents + +- [What you get](#what-you-get) +- [Quick start: the demo](#quick-start-the-demo) +- [Using the plugin in your own project](#using-the-plugin-in-your-own-project) + - [Option A — `build-depends`](#option-a--build-depends-simplest-okay-for-small-projects) + - [Option B — `-fplugin-library`](#option-b---fplugin-library-recommended-for-real-projects) +- [Combining dumps from multiple packages](#combining-dumps-from-multiple-packages) +- [Viewer reference](#viewer-reference) +- [Schema, data flow, design notes](#schema-data-flow-design-notes) +- [Known limitations](#known-limitations) +- [Building from source](#building-from-source) + +## 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 in the topmost row. +- 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 …` rows. + 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. +- **Pin** classes from the side panel to focus the classes view to just + the pinned classes plus their immediate superclass neighbourhood + (xdot-style narrowing). Click a ghost neighbour to expand 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 rows. + +## 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.). + +```bash +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 pop a single HTML file ~700 KB containing 14 classes, 29 +instances, 4 families, and 5 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: + +```cabal +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 bounds (e.g. +ouroboros-consensus, cardano-ledger) 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`](https://downloads.haskell.org/ghc/9.14.1/docs/users_guide/extending_ghc.html#ghc-flag--fplugin-library-=⟨file-path⟩;⟨unit-id⟩;⟨module⟩;⟨args⟩), +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=;;; +``` + +with `` parsed as a Haskell list-of-strings literal (`["dir=…"]`). + +Concretely, in `cabal.project.local` of your target: + +``` +package ouroboros-consensus + 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-/…`), generates the +correctly-escaped flag, and emits a ready-to-paste cabal stanza: + +```bash +# From the classgraph checkout (or wherever the script lives): +./classgraph-plugin-flag.sh --cabal --package ouroboros-consensus \ + >> /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.so` | +| `cabal install --lib classgraph` (library only) | `~/.cabal/store/ghc-/classgraph-…/lib/libHSclassgraph-…-ghc.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: + +```bash +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 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/-/ghc-/classgraph-/build/libHS-ghc.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.) + +## Combining dumps from multiple packages + +Big projects (consensus + cardano-ledger + …) live in separate cabal +packages. Build each with the plugin attached, then merge their +`.classgraph/` directories into a single rendered HTML by repeating +`--input`: + +```bash +cabal run classgraph-view -- \ + --input ../cardano-ledger/eras/{shelley,alonzo,babbage,conway}/impl/.classgraph \ + --input ../cardano-ledger/libs/{cardano-ledger-binary,cardano-ledger-core,cardano-ledger-api}/.classgraph \ + --input ../ouroboros-consensus/.classgraph \ + --output cardano.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-`, and `pkg-1.0-l-api-` 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 + +| 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 | +| **Pin** (side panel button) | Focus classes view to pinned classes + one-hop neighbours | +| **Mute** (side panel button) | Hide a class everywhere | +| **Click** a ghost (focus-mode neighbour) | Pin it and expand the focus 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 — checkbox per row, with `Show all` / `Hide all` +buttons and a substring search. + +## Schema, data flow, design notes + +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 row 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. E.g. for ouroboros-consensus's + `HasLedgerTables (LedgerState (ShelleyBlock proto era))`, the + superclass `Eq (TxOut blk)` reduces to `Eq (TxOut era)` — itself a + family application whose era-specific `type instance TxOut … = …` rows + live in `cardano-ledger-shelley` etc. Extract those packages 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`) + +```bash +git clone +cd classgraph +cabal update +cabal build all # plugin library + classgraph-view executable +cabal run classgraph-view -- --help +``` + +To run the demo end-to-end: + +```bash +cabal build demo +cabal run classgraph-view -- --input examples/demo/.classgraph -o demo.html +``` + +To regenerate dumps for a real target (e.g. ouroboros-consensus) using +`-fplugin-library`: + +```bash +./classgraph-plugin-flag.sh --cabal --package ouroboros-consensus \ + >> ../ouroboros-consensus/cabal.project.local +cd ../ouroboros-consensus +rm -rf .classgraph +cabal build lib:ouroboros-consensus +cd - +cabal run classgraph-view -- --input ../ouroboros-consensus/.classgraph -o oc.html +``` + +## License + +BSD-3-Clause. See `LICENSE`. diff --git a/classgraph-plugin-flag.sh b/classgraph-plugin-flag.sh index bf63968..f7a5e0a 100755 --- a/classgraph-plugin-flag.sh +++ b/classgraph-plugin-flag.sh @@ -48,15 +48,50 @@ while [ $# -gt 0 ]; do esac done -# Make sure the .so exists; build silently if not. -if [ -z "$(find dist-newstyle -type f -name 'libHSclassgraph-*.so' 2>/dev/null | head -1)" ]; then - cabal build --silent lib:classgraph +# Locate the plugin .so. Two sources, tried in order: +# +# 1. ./dist-newstyle/ — a local cabal build of this checkout. Run from +# the classgraph repo root; if no .so is present yet the script +# will trigger `cabal build lib:classgraph` first. +# +# 2. The cabal store — i.e. `cabal install --lib classgraph` or +# `cabal install classgraph-view`. We discover the right store db +# by GHC version and ask `ghc-pkg` for the library directory. +# +# Override the search by setting CLASSGRAPH_SO directly. +SO=${CLASSGRAPH_SO:-} + +if [ -z "$SO" ] && [ -d "dist-newstyle" ]; then + if [ -z "$(find dist-newstyle -type f -name 'libHSclassgraph-*.so' 2>/dev/null | head -1)" ]; then + cabal build --silent lib:classgraph 2>/dev/null || true + fi + SO=$(find dist-newstyle -type f -name 'libHSclassgraph-*.so' 2>/dev/null | head -1) fi -SO=$(find dist-newstyle -type f -name 'libHSclassgraph-*.so' | head -1) if [ -z "$SO" ]; then - echo "Could not find a libHSclassgraph-*.so under dist-newstyle/." >&2 - echo "Run \`cabal build classgraph\` first." >&2 + # Fall through to the cabal store (cabal install path). + GHC_VER=$(ghc --numeric-version 2>/dev/null || true) + STORE_DIR=$(ls -d "$HOME/.cabal/store/ghc-${GHC_VER}"* 2>/dev/null | head -1) + if [ -n "$STORE_DIR" ] && [ -d "$STORE_DIR/package.db" ]; then + LIBDIR=$(ghc-pkg --package-db="$STORE_DIR/package.db" \ + --simple-output field classgraph library-dirs 2>/dev/null \ + | tr ' ' '\n' | head -1) + UNIT=$(ghc-pkg --package-db="$STORE_DIR/package.db" \ + --simple-output field classgraph id 2>/dev/null \ + | tr ' ' '\n' | head -1) + if [ -n "$LIBDIR" ] && [ -n "$UNIT" ]; then + candidate="$LIBDIR/libHS${UNIT}-ghc${GHC_VER}.so" + [ -f "$candidate" ] && SO="$candidate" + fi + fi +fi + +if [ -z "$SO" ] || [ ! -f "$SO" ]; then + echo "Could not find a classgraph plugin .so." >&2 + echo "Tried: ./dist-newstyle/ and ${HOME}/.cabal/store/ghc-${GHC_VER:-?}/." >&2 + echo "Build it locally with \`cabal build classgraph\`," >&2 + echo "install it with \`cabal install --lib classgraph\`," >&2 + echo "or set CLASSGRAPH_SO to the path explicitly." >&2 exit 1 fi SO=$(readlink -f "$SO") -- 2.54.0