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.
+ 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 …` rows.
+ 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*.
- 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.
+- A **Fit** button (and `F` shortcut) to re-frame after hiding instances.
## Quick start: the demo
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.
+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)
Concretely, in `cabal.project.local` of your target:
```
-package ouroboros-consensus
+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\"]"
```
```bash
# From the classgraph checkout (or wherever the script lives):
-./classgraph-plugin-flag.sh --cabal --package ouroboros-consensus \
+./classgraph-plugin-flag.sh --cabal --package my-app \
>> /path/to/your/project/cabal.project.local
```
## 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
+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`:
```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
+ --input ../pkg-a/.classgraph \
+ --input ../pkg-b/sub/foo/.classgraph \
+ --input ../pkg-c/lib/.classgraph \
+ --output combined.html
```
The merger:
| **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`
+in the side panel — one checkbox per item, with `Show all` / `Hide all`
buttons and a substring search.
## 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`](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
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.
+ `CoAxBranch`, so each type family instance points at its own
+ `data instance` / `type instance` declaration site.
## Known limitations
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.
+- **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
cabal run classgraph-view -- --input examples/demo/.classgraph -o demo.html
```
-To regenerate dumps for a real target (e.g. ouroboros-consensus) using
+To regenerate dumps for a real target (e.g. my-app) using
`-fplugin-library`:
```bash
-./classgraph-plugin-flag.sh --cabal --package ouroboros-consensus \
- >> ../ouroboros-consensus/cabal.project.local
-cd ../ouroboros-consensus
+./classgraph-plugin-flag.sh --cabal --package my-app \
+ >> ../my-app/cabal.project.local
+cd ../my-app
rm -rf .classgraph
-cabal build lib:ouroboros-consensus
+cabal build lib:my-app
cd -
-cabal run classgraph-view -- --input ../ouroboros-consensus/.classgraph -o oc.html
+cabal run classgraph-view -- --input ../my-app/.classgraph -o my-app.html
```
## License
+index-state:
+ 2026-05-06T18:15:59Z
+
packages: .
examples/demo
white-space: nowrap;
}
.swatch-class { background: #3b82f6; color: #fff; border-color: #1d4ed8; }
-.swatch-toplevel { border-color: #f59e0b; border-width: 2px; }
+.swatch-leaf { border-color: #f59e0b; border-width: 2px; }
.swatch-family { background: #fbbf24; color: #3f2d05; border-color: #b45309; }
.swatch-instance { background: #ecfdf5; color: #065f46; border-color: #10b981; }
.swatch-faminst { background: #f5f3ff; color: #5b21b6; border-color: #a78bfa; font-style: italic; }
#back-button:hover { background: #f3f4f6; }
#back-button[hidden] { display: none; }
+#orphan-toggle-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: #6b7280;
+ user-select: none;
+ cursor: pointer;
+ white-space: nowrap;
+}
+#orphan-toggle-wrap input { margin: 0; }
+
#pin-chips {
display: flex;
align-items: center;
#search-wrap {
position: relative;
margin-left: auto;
- width: 320px;
- max-width: 50%;
+ width: 460px;
+ max-width: 60%;
+}
+#search-wrap::before {
+ content: '🔍';
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 14px;
+ pointer-events: none;
+ opacity: 0.55;
}
#search {
width: 100%;
box-sizing: border-box;
- padding: 5px 10px;
- font-size: 13px;
- border: 1px solid #d1d5db;
- border-radius: 4px;
+ padding: 9px 12px 9px 34px;
+ font-size: 14px;
+ font-weight: 500;
+ border: 2px solid #cbd5e1;
+ border-radius: 6px;
outline: none;
background: #fff;
color: #111827;
+ transition: border-color 120ms, box-shadow 120ms;
+}
+#search::placeholder { color: #94a3b8; font-weight: 400; }
+#search:hover { border-color: #94a3b8; }
+#search:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
-#search:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
#search-results {
position: absolute;
<span id="pin-list"></span>
<button id="pin-clear" title="Show full graph">Clear</button>
</div>
+ <label id="orphan-toggle-wrap" title="Mark orphan instances with a red dashed border">
+ <input type="checkbox" id="orphan-toggle" />
+ <span>Mark orphans</span>
+ </label>
<div id="search-wrap">
<input id="search" type="search" placeholder="Find class or family… (press / to focus)" autocomplete="off" spellcheck="false" />
<ul id="search-results" hidden></ul>
<div class="help-content">
<h4>Nodes</h4>
<div class="legend-row"><span class="swatch"><span class="swatch-node swatch-class">Cls</span></span><span>Class defined in this program</span></div>
- <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-class swatch-toplevel">Cls</span></span><span><strong>Top-level</strong> class (no other class extends it; rendered at the top)</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-class swatch-leaf">Cls</span></span><span><strong>Leaf</strong> class (no other class extends it; rendered at the top)</span></div>
<div class="legend-row"><span class="swatch"><span class="swatch-node swatch-family">Fam</span></span><span>Type family (open / closed / associated)</span></div>
<div class="legend-row"><span class="swatch"><span class="swatch-node swatch-instance">Inst</span></span><span>Class instance</span></div>
- <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-faminst">f a=b</span></span><span><code>type instance F … = …</code> row</span></div>
+ <div class="legend-row"><span class="swatch"><span class="swatch-node swatch-faminst">f a=b</span></span><span>Type family instance (<code>type instance F … = …</code>)</span></div>
<div class="legend-row"><span class="swatch"><span class="swatch-node swatch-external">Ext</span></span><span>External class (referenced but not defined here)</span></div>
<div class="legend-row"><span class="swatch"><span class="swatch-node swatch-ghost">Cls</span></span><span>Ghost — one-hop neighbour in focus mode (click to pin)</span></div>
<div class="legend-row"><span class="swatch"><span class="swatch-node swatch-orphan">Inst</span></span><span>Orphan instance (red dashed border)</span></div>
<div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid amber"></span></span><span>Instance needs a superclass instance (matched locally)</span></div>
<div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dashed gray"></span></span><span>Needs a superclass instance (no local match)</span></div>
<div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dashed violet-light"></span></span><span>Instance references this type family</span></div>
- <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid violet"></span></span><span>Family → its type-instance row</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge solid violet"></span></span><span>Family → one of its type family instances</span></div>
<div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dotted violet"></span></span><span>Instance's associated <code>type instance</code> RHS</span></div>
- <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dotted teal"></span></span><span>Type-instance row resolves to a class instance (chain)</span></div>
+ <div class="legend-row"><span class="swatch swatch-edge-wrap"><span class="swatch-edge dotted teal"></span></span><span>Type family instance resolves to a class instance (chain)</span></div>
<h4>Controls</h4>
<div class="help-tip">Click → highlight + side panel</div>
<h1>classgraph</h1>
<p class="hint" id="hint-classes">Search to locate a class. Click any node to inspect it on the right (with <em>Pin</em>/<em>Mute</em> buttons). <strong>Double-click</strong> a class to drill into its instances, or a family (diamond) to see its type instances. Scroll to zoom; drag to pan.</p>
<p class="hint" id="hint-instance" hidden>Click to highlight; <strong>double-click</strong> a class or family to drill in. The back arrow returns to the hierarchy.</p>
- <p class="hint" id="hint-family" hidden>Each row is a <code>type instance</code> declaration. <strong>Double-click</strong> the parent class to see its instances.</p>
+ <p class="hint" id="hint-family" hidden>Each entry is a <code>type instance</code> declaration. <strong>Double-click</strong> the parent class to see its instances.</p>
</header>
<details id="mute-filter">
<summary><span id="mute-summary">Muted classes (0)</span></summary>
cy.add(els.map(e => ({ group: e.group, data: Object.assign({}, e.data) })));
});
cy.elements().removeClass('highlight').removeClass('dim');
+ applyOrphanMarker();
+ }
+
+ // Toggle orphan border styling. The cy stylesheet only paints
+ // [kind="instance"][?orphan].show-orphan with the red dashed border, so
+ // we add or remove that class on every orphan instance whenever the
+ // checkbox flips.
+ function applyOrphanMarker() {
+ const showOrphans = orphanToggle ? orphanToggle.checked : true;
+ const orphanNodes = cy.nodes('node[kind = "instance"][?orphan]');
+ if (showOrphans) orphanNodes.addClass('show-orphan');
+ else orphanNodes.removeClass('show-orphan');
}
function switchToClasses(opts) {
// For every distinct type family referenced anywhere inside `args`,
// ensure a family node exists, draw a "via family" edge from the
- // origin node, and lift the family's known type-instance rows into
+ // origin node, and lift the family's known type family instances into
// the graph so the user can verify the family is actually defined
// for the relevant types.
//
label: 'via ' + fa.qnName,
}});
}
- // Surface only the type-instance rows whose LHS *can* describe
+ // Surface only the type family instances whose LHS *can* describe
// the focused instance's family-app — anything else is unrelated
// noise (e.g. `TxIn ByronBlock` while looking at a Shelley
// instance). Run the relevance check *before* creating the
'transition-duration': '120ms',
},
},
- // Top-level class — no other class has it as a (direct) superclass.
- // Visually marked with a gold border so they pop against ordinary
- // classes; their label also carries an extra "★ top" line baked in
- // by Render.hs.
+ // Leaf class — no other class has it as a (direct) superclass, so
+ // it's an entry-point in the inheritance graph. Marked with a gold
+ // border so the leaves stand out at the top of the classes view.
{
- selector: 'node[kind = "class"][?isTopLevel]',
+ selector: 'node[kind = "class"][?isLeaf]',
style: {
'border-color': '#f59e0b',
'border-width': 2.5,
'font-family': 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
},
},
+ // Orphan instances get a red dashed border, but only when the
+ // global "Mark orphans" toggle is on. The toggle adds the
+ // `.show-orphan` class to every orphan node in the graph; we only
+ // re-style instances that wear it.
{
- selector: 'node[kind = "instance"][?orphan]',
+ selector: 'node[kind = "instance"][?orphan].show-orphan',
style: {
'border-color': '#dc2626',
'border-style': 'dashed',
'font-size': 9,
},
},
- // Highlight / dim
- { selector: '.dim', style: { opacity: 0.12 } },
- { selector: 'node.highlight', style: { 'border-width': 3, 'border-color': '#f97316' } },
- { selector: 'edge.highlight', style: { width: 2.5, 'line-color': '#f97316', 'target-arrow-color': '#f97316' } },
+ // Highlight / dim. Highlighted elements keep their original colour
+ // (so the user can still see what kind of edge they're looking at);
+ // we just bump the line/border thickness slightly for emphasis,
+ // and dim everything else.
+ { selector: '.dim', style: { opacity: 0.12 } },
+ { selector: 'node.highlight', style: { 'border-width': 3 } },
+ { selector: 'edge.highlight', style: { width: 2.4 } },
];
}
switchToClasses();
});
+ // Orphan-marker toggle: when checked, orphan instance nodes get the
+ // red dashed border (the `.show-orphan` class). When unchecked, they
+ // render identically to ordinary instances.
+ const orphanToggle = document.getElementById('orphan-toggle');
+ if (orphanToggle) {
+ orphanToggle.addEventListener('change', applyOrphanMarker);
+ }
+
// -------------------------------------------------------------------------
// Side-panel event delegation: toggle Pin/Mute buttons, follow class links.
, cyMeta = Aeson.toJSON pd
}
where
- -- "Top-level" = no other class has this class as a (direct) superclass.
- -- Computed once over the merged ProgramData; used to mark such classes
- -- visually so the user can find the entry-point classes at a glance.
+ -- "Leaf" = no other class has this class as a (direct) superclass.
+ -- These are the entry-point classes: nothing in the program builds
+ -- on top of them. They render at the top of the classes view (the
+ -- dagre layout already places leaves of the subclass-DAG at the top
+ -- with rankDir = TB), with a gold border via the viewer stylesheet.
referencedAsSuper :: Set.Set Text
referencedAsSuper = Set.fromList
[ renderQualName (seSuperclass se)
| c <- pdClasses pd
, se <- ciSuperclasses c
]
- isTopLevel c = not (Set.member (renderQualName (ciName c)) referencedAsSuper)
+ isLeaf c = not (Set.member (renderQualName (ciName c)) referencedAsSuper)
classNodes =
[ CyNode $ Aeson.object
[ "id" Aeson..= renderQualName (ciName c)
- , "label" Aeson..= classNodeLabel c
+ , "label" Aeson..= qnName (ciName c)
, "kind" Aeson..= ("class" :: Text)
, "module" Aeson..= qnModule (ciName c)
, "package" Aeson..= qnPackage (ciName c)
, "tyvars" Aeson..= ciTyVars c
, "methods" Aeson..= ciMethods c
- , "isTopLevel" Aeson..= isTopLevel c
+ , "isLeaf" Aeson..= isLeaf c
]
| c <- pdClasses pd
]
- classNodeLabel c
- | isTopLevel c = qnName (ciName c) <> "\n\9733 top" -- ★
- | otherwise = qnName (ciName c)
-
familyNodes =
[ CyNode $ Aeson.object
[ "id" Aeson..= renderQualName (tfName f)