From 9f81486fb3ce31d5f0b97e1db8b1e7cde96334d5 Mon Sep 17 00:00:00 2001 From: Javier Sagredo Date: Fri, 19 Jun 2026 18:03:56 +0200 Subject: [PATCH] Render instance-context equalities as arrows too MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Follow-up to the equality-superclass change: instance-context equality constraints (`instance (a ~ b) => ...`) were drawn as `where a ~ b` chips baked into the instance node's label. Now they render the same way as equality superclasses — an edge to a shared `(~)` node labelled `-{a}-{b}->` — so the two views are consistent. - ensureEqNode: the shared `(~)` (or `(~~)`) node, modelled as an external class node, deduped by the operator's qid so all equalities converge. - parenthesiseOp: JS mirror of Render.hs's externalLabel (`~` -> `(~)`). - addInstanceContext draws an `#eq#` edge per equality predicate and still surfaces any type families inside it (e.g. `Norm` in `Norm a ~ Int`). - The instance node label drops the `where` chips; the `eqs` strings stay on the node data for the side panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- data/viewer.js | 71 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/data/viewer.js b/data/viewer.js index 15a30c9..d26d496 100644 --- a/data/viewer.js +++ b/data/viewer.js @@ -594,10 +594,12 @@ if (seenNodes.has(id)) return id; seenNodes.add(id); const head = renderInstanceHead(inst, originClassQn); - // Equality constraints from the instance's iiContext are rendered - // as "chip" lines beneath the head, indented and prefixed with - // `where`, instead of being drawn as fake-class edges. This keeps - // them visible at-a-glance without polluting the layout. + // Equality constraints from the instance's iiContext used to be + // baked into the node label as `where a ~ b` chips. They're now + // drawn as edges to a shared `(~)` node (see addInstanceContext), + // mirroring how the classes view renders equality superclasses, so + // the node label is just the instance head. The `eqs` strings are + // still exposed on the node data for the side panel / tests. const eqs = (inst.iiContext || []) .filter(p => p.piIsEq) .map(p => @@ -605,12 +607,9 @@ p.piClass.qnName + ' ' + renderArg(p.piArgs[1], inst.iiTyVars) ); - const label = eqs.length === 0 - ? head - : head + '\n' + eqs.map(s => 'where ' + s).join('\n'); els.push({ group: 'nodes', data: { id, - label, + label: head, kind: 'instance', orphan: !!inst.iiOrphan, classId: qid(inst.iiClass), @@ -702,6 +701,27 @@ return id; } + // The shared node an equality constraint (`a ~ b`) points at. Modelled + // as an external class node labelled `(~)` — identical to how the + // classes view renders equality *superclasses* — so all equalities in + // this instance view converge on one `(~)` (or `(~~)`) node, each via + // its own `-{a}-{b}->` labelled edge. Deduped by the operator's qid. + function ensureEqNode(opQn) { + const id = qid(opQn); + if (seenNodes.has(id)) return id; + seenNodes.add(id); + els.push({ group: 'nodes', data: { + id, + label: parenthesiseOp(opQn.qnName), + kind: 'class', + external: true, + classId: id, + package: opQn.qnPackage, + module: opQn.qnModule, + }}); + return id; + } + // 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 family instances into @@ -890,13 +910,31 @@ // node builders and appends to the shared element list. // Context constraints — the "new classes the instance declares as needed - // for the implementation". Equality predicates (a ~ b) don't point at a - // class node (they appear in the side panel), and globally muted classes - // are skipped. + // for the implementation". Globally muted classes are skipped. Equality + // predicates (a ~ b) point at a shared `(~)` node via a `-{a}-{b}->` + // labelled edge, the same way the classes view renders equality + // superclasses. function addInstanceContext(inst, instId) { inst.iiContext.forEach((pred, pi) => { - if (pred.piIsEq) return; if (mutedSet.has(qid(pred.piClass))) return; + if (pred.piIsEq) { + const eid = ensureEqNode(pred.piClass); + els.push({ group: 'edges', data: { + id: instId + '#eq#' + pi, + source: instId, + target: eid, + kind: 'context', + label: pred.piArgs + .map(a => '{' + renderArg(a, inst.iiTyVars) + '}') + .join(' '), + }}); + // Surface any type-family applications hiding in the equality + // (e.g. the `Norm` in `Norm a ~ Int`), chained to the `(~)` node. + // No predicate class to resolve against, so predClassQn is null. + addFamilyLinksFromArgs(pred.piArgs, instId, 'eq-fam', + null, inst.iiTyVars, eid); + return; + } // Render the predicate (e.g. `ShelleyBasedEra era`) as its own node, // not as a class node with a "ctx: era" arrow — it's morally a // place-holder for whichever instance satisfies it at use time. @@ -1289,6 +1327,15 @@ return null; } + // Parenthesise a symbolic operator name so it reads the way it would in + // prefix position (`~` -> `(~)`). Mirrors Render.hs's externalLabel: a + // name is "operator-like" when it doesn't start with a letter, `_`, `(` + // or `[`. Alphabetic names (e.g. `Coercible`) pass through untouched. + function parenthesiseOp(name) { + if (!name) return name; + return /^[A-Za-z_([]/.test(name) ? name : '(' + name + ')'; + } + // Recognise GHC's promoted-list constructors. The dumps use the // unprimed forms (`:`, `[]`) for the type-level cons / nil — GHC's // pretty-printer drops the leading apostrophe when round-tripping -- 2.54.0