From caf8afb01f2ebc86828904874738afcac2909ae0 Mon Sep 17 00:00:00 2001 From: Javier Sagredo Date: Wed, 6 May 2026 22:56:21 +0200 Subject: [PATCH] WIP --- README.md | 66 +++++++++++++++++++++------------------- cabal.project | 3 ++ data/viewer.css | 45 ++++++++++++++++++++++----- data/viewer.html | 14 ++++++--- data/viewer.js | 50 ++++++++++++++++++++++-------- src/Classgraph/Render.hs | 18 +++++------ 6 files changed, 130 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index ae67d66..4bc5e0f 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ When you point the plugin at a target package and run the viewer, you get: 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*. @@ -62,7 +63,7 @@ When you point the plugin at a target package and run the viewer, you get: - 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 @@ -102,9 +103,8 @@ library 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) @@ -125,7 +125,7 @@ with `` parsed as a Haskell list-of-strings literal (`["dir=…"]`). 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\"]" ``` @@ -142,7 +142,7 @@ 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 \ +./classgraph-plugin-flag.sh --cabal --package my-app \ >> /path/to/your/project/cabal.project.local ``` @@ -207,17 +207,17 @@ For an `inplace` (local-checkout cabal-build) the path is ## 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: @@ -250,11 +250,17 @@ 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 @@ -294,8 +300,8 @@ Notable extraction details: 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 @@ -306,13 +312,11 @@ Notable extraction details: 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 @@ -344,17 +348,17 @@ 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 +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 diff --git a/cabal.project b/cabal.project index 9e2bf62..0c4c938 100644 --- a/cabal.project +++ b/cabal.project @@ -1,3 +1,6 @@ +index-state: + 2026-05-06T18:15:59Z + packages: . examples/demo diff --git a/data/viewer.css b/data/viewer.css index 62212bb..4f8eb05 100644 --- a/data/viewer.css +++ b/data/viewer.css @@ -136,7 +136,7 @@ body { display: flex; } 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; } @@ -199,6 +199,18 @@ body { display: flex; } #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; @@ -263,21 +275,38 @@ body { display: flex; } #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; diff --git a/data/viewer.html b/data/viewer.html index 09aad9d..4f9284c 100644 --- a/data/viewer.html +++ b/data/viewer.html @@ -16,6 +16,10 @@ +
@@ -29,10 +33,10 @@

Nodes

ClsClass defined in this program
-
ClsTop-level class (no other class extends it; rendered at the top)
+
ClsLeaf class (no other class extends it; rendered at the top)
FamType family (open / closed / associated)
InstClass instance
-
f a=btype instance F … = … row
+
f a=bType family instance (type instance F … = …)
ExtExternal class (referenced but not defined here)
ClsGhost — one-hop neighbour in focus mode (click to pin)
InstOrphan instance (red dashed border)
@@ -48,9 +52,9 @@
Instance needs a superclass instance (matched locally)
Needs a superclass instance (no local match)
Instance references this type family
-
Family → its type-instance row
+
Family → one of its type family instances
Instance's associated type instance RHS
-
Type-instance row resolves to a class instance (chain)
+
Type family instance resolves to a class instance (chain)

Controls

Click → highlight + side panel
@@ -67,7 +71,7 @@

classgraph

Search to locate a class. Click any node to inspect it on the right (with Pin/Mute buttons). Double-click a class to drill into its instances, or a family (diamond) to see its type instances. Scroll to zoom; drag to pan.

- +
Muted classes (0) diff --git a/data/viewer.js b/data/viewer.js index ad5868b..666635e 100644 --- a/data/viewer.js +++ b/data/viewer.js @@ -86,6 +86,18 @@ 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) { @@ -591,7 +603,7 @@ // 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. // @@ -617,7 +629,7 @@ 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 @@ -1430,12 +1442,11 @@ '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, @@ -1498,8 +1509,12 @@ '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', @@ -1640,10 +1655,13 @@ '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 } }, ]; } @@ -1739,6 +1757,14 @@ 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. diff --git a/src/Classgraph/Render.hs b/src/Classgraph/Render.hs index 35c83a7..10931ed 100644 --- a/src/Classgraph/Render.hs +++ b/src/Classgraph/Render.hs @@ -89,35 +89,33 @@ buildGraph pd = CyGraph , 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) -- 2.54.0