]> Repositorios git - classgraph.git/commitdiff
viewer: search bar, pin/mute/filter panels, splitter, equality preds, clean type...
authorJavier Sagredo <[email protected]>
Sun, 3 May 2026 16:41:35 +0000 (18:41 +0200)
committerJavier Sagredo <[email protected]>
Mon, 4 May 2026 00:02:04 +0000 (02:02 +0200)
data/viewer.css
data/viewer.html
data/viewer.js
src/Classgraph/Extract.hs
src/Classgraph/Schema.hs

index 840e5441f2d4313608bbb0c662925d956df4ff98..1bfaf410858d653cd5f8bd4e87c983a5fc9bca5d 100644 (file)
@@ -45,6 +45,67 @@ body { display: flex; }
 #back-button:hover { background: #f3f4f6; }
 #back-button[hidden] { display: none; }
 
+#pin-chips {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+  flex: 1 1 auto;
+  min-width: 0;
+}
+#pin-chips[hidden] { display: none; }
+#pin-label {
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: #6b7280;
+  font-weight: 600;
+}
+#pin-list {
+  display: contents;
+}
+.pin-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 2px 4px 2px 10px;
+  background: #dbeafe;
+  color: #1e40af;
+  font-size: 12px;
+  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+  border-radius: 12px;
+  max-width: 220px;
+}
+.pin-chip .chip-name {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.pin-chip button {
+  appearance: none;
+  border: 0;
+  background: transparent;
+  padding: 0 4px;
+  margin: 0;
+  font-size: 14px;
+  line-height: 1;
+  cursor: pointer;
+  color: #1e3a8a;
+  border-radius: 50%;
+}
+.pin-chip button:hover { background: rgba(30, 58, 138, 0.15); }
+#pin-clear {
+  appearance: none;
+  border: 1px solid #cbd5e1;
+  background: #fff;
+  color: #475569;
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+#pin-clear:hover { background: #f1f5f9; }
+
 #search-wrap {
   position: relative;
   margin-left: auto;
@@ -94,6 +155,66 @@ body { display: flex; }
 #search-results li:hover, #search-results li.active {
   background: #eff6ff;
 }
+#search-results li {
+  /* override defaults for the new two-column layout */
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  gap: 8px;
+}
+#search-results .row-content {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+  flex: 1 1 auto;
+}
+#search-results .pin-add {
+  appearance: none;
+  border: 1px solid transparent;
+  background: transparent;
+  color: #64748b;
+  font-size: 14px;
+  cursor: pointer;
+  padding: 2px 6px;
+  border-radius: 4px;
+  flex-shrink: 0;
+}
+#search-results .pin-add:hover {
+  background: #dbeafe;
+  color: #1e40af;
+  border-color: #bfdbfe;
+}
+#search-results .pin-add.pinned {
+  color: #1e40af;
+  background: #dbeafe;
+}
+#search-results .mute-add {
+  appearance: none;
+  border: 1px solid transparent;
+  background: transparent;
+  color: #92400e;
+  font-size: 14px;
+  cursor: pointer;
+  padding: 2px 6px;
+  border-radius: 4px;
+  flex-shrink: 0;
+}
+#search-results .mute-add:hover {
+  background: #fed7aa;
+  color: #7c2d12;
+  border-color: #fdba74;
+}
+#search-results .mute-add.muted {
+  color: #7c2d12;
+  background: #fed7aa;
+}
+#search-results .row-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  flex-shrink: 0;
+}
 #search-results .name {
   font-weight: 600;
   color: #111827;
@@ -129,6 +250,27 @@ body { display: flex; }
   min-height: 0;
 }
 
+#splitter {
+  flex: 0 0 6px;
+  height: 100vh;
+  background: #e5e7eb;
+  cursor: ew-resize;
+  position: relative;
+  user-select: none;
+}
+#splitter:hover, #splitter.dragging { background: #93c5fd; }
+#splitter::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 2px;
+  height: 32px;
+  background: #94a3b8;
+  border-radius: 1px;
+  transform: translate(-50%, -50%);
+}
+
 #panel {
   width: 340px;
   flex: 0 0 auto;
@@ -153,3 +295,151 @@ body { display: flex; }
 #selected li { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; }
 
 footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid #eee; font-size: 11px; color: #999; }
+
+#mute-filter {
+  margin: 0 0 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  background: #fff7ed;
+}
+#mute-filter summary {
+  padding: 8px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #7c2d12;
+  cursor: pointer;
+  outline: none;
+  user-select: none;
+}
+.mute-hint {
+  margin: 0 12px 8px;
+  font-size: 11px;
+  color: #92400e;
+  line-height: 1.5;
+}
+.mute-hint code {
+  background: #fde68a;
+  padding: 0 4px;
+  border-radius: 3px;
+}
+#mute-list {
+  margin: 0 0 8px;
+  padding: 0 12px;
+  list-style: none;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+.mute-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  padding: 2px 4px 2px 8px;
+  background: #fed7aa;
+  color: #7c2d12;
+  font-size: 11px;
+  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+  border-radius: 10px;
+  max-width: 100%;
+}
+.mute-chip .chip-name {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.mute-chip button {
+  appearance: none;
+  border: 0;
+  background: transparent;
+  padding: 0 4px;
+  font-size: 13px;
+  cursor: pointer;
+  color: #7c2d12;
+  border-radius: 50%;
+}
+.mute-chip button:hover { background: rgba(124, 45, 18, 0.15); }
+
+#instance-filter {
+  margin: 0 0 16px;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  background: #f9fafb;
+}
+#instance-filter[hidden] { display: none; }
+#instance-filter summary {
+  padding: 8px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #1f2937;
+  cursor: pointer;
+  outline: none;
+  user-select: none;
+}
+#instance-filter summary::-webkit-details-marker { color: #6b7280; }
+#filter-summary { color: inherit; }
+.filter-controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 0 12px 8px;
+}
+.filter-controls button {
+  appearance: none;
+  border: 1px solid #d1d5db;
+  background: #fff;
+  color: #374151;
+  font-size: 11px;
+  padding: 3px 8px;
+  border-radius: 3px;
+  cursor: pointer;
+}
+.filter-controls button:hover { background: #f3f4f6; }
+#filter-search {
+  flex: 1 1 auto;
+  min-width: 100px;
+  padding: 3px 8px;
+  font-size: 11px;
+  border: 1px solid #d1d5db;
+  border-radius: 3px;
+  outline: none;
+  background: #fff;
+  color: #111827;
+}
+#filter-search:focus { border-color: #3b82f6; }
+#filter-list {
+  margin: 0;
+  padding: 4px 6px 8px;
+  list-style: none;
+  max-height: 40vh;
+  overflow-y: auto;
+}
+#filter-list li {
+  display: flex;
+  align-items: flex-start;
+  gap: 6px;
+  padding: 4px 6px;
+  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+  font-size: 12px;
+  cursor: pointer;
+  border-radius: 3px;
+}
+#filter-list li.hidden-row { display: none; }
+#filter-list li:hover { background: #eef2ff; }
+#filter-list label {
+  display: flex;
+  align-items: flex-start;
+  gap: 6px;
+  flex: 1 1 auto;
+  cursor: pointer;
+  min-width: 0;
+}
+#filter-list label .head {
+  /* Wrap long instance heads onto multiple lines so the user can read
+     them even when the panel is narrow. */
+  white-space: normal;
+  word-break: break-word;
+  line-height: 1.4;
+  color: #111827;
+}
+#filter-list label.unchecked .head { color: #9ca3af; text-decoration: line-through; }
+#filter-list input[type="checkbox"] { margin: 3px 0 0; flex-shrink: 0; }
index 98f851fff80d1f5c76f5415383c9743e19279439..2ef7b8f6481d43b19f84fe395d4d1c28d09336a6 100644 (file)
       <button id="back-button" title="Back to class hierarchy">&larr;</button>
       <h1 id="view-title">classgraph</h1>
       <span id="view-subtitle"></span>
+      <div id="pin-chips" hidden>
+        <span id="pin-label">Focus on:</span>
+        <span id="pin-list"></span>
+        <button id="pin-clear" title="Show full graph">Clear</button>
+      </div>
       <div id="search-wrap">
-        <input id="search" type="search" placeholder="Find class or family…" autocomplete="off" spellcheck="false" />
+        <input id="search" type="search" placeholder="Find class or family…  (press / to focus)" autocomplete="off" spellcheck="false" />
         <ul id="search-results" hidden></ul>
       </div>
     </div>
     <div id="cy"></div>
   </div>
+  <div id="splitter" title="Drag to resize panel"></div>
   <aside id="panel">
     <header>
       <h1>classgraph</h1>
       <p class="hint" id="hint-instance" hidden>Click any class or family to drill in; the back arrow returns to the full hierarchy.</p>
       <p class="hint" id="hint-family" hidden>Each row is a <code>type instance</code> declaration. Back arrow returns; click the parent class to see its instances.</p>
     </header>
+    <details id="mute-filter">
+      <summary><span id="mute-summary">Muted classes (0)</span></summary>
+      <p class="mute-hint">Use the 🙈 button in the search results to mute noisy superclasses (<code>Show</code>, <code>NoThunks</code>, <code>Typeable</code>, etc.). Muted classes are hidden everywhere — including from instance views' context and superclass edges.</p>
+      <ul id="mute-list"></ul>
+      <div class="filter-controls">
+        <button id="mute-clear" type="button">Clear all</button>
+      </div>
+    </details>
+    <details id="instance-filter" hidden>
+      <summary><span id="filter-summary">Instances</span></summary>
+      <div class="filter-controls">
+        <button id="filter-all" type="button">Show all</button>
+        <button id="filter-none" type="button">Hide all</button>
+        <input id="filter-search" type="search" placeholder="Filter by head…" autocomplete="off" />
+      </div>
+      <ul id="filter-list"></ul>
+    </details>
     <section id="selected">
       <p class="empty">No selection.</p>
     </section>
index 2af8a4de8f58b0a1ed5339af2c65f3b24f4cb20c..90d78951be430b830e5252617a8fc4a3f76819bd 100644 (file)
   const familyById = new Map();
   for (const f of graph.meta.pdTypeFamilies) familyById.set(qid(f.tfName), f);
 
-  // Instances grouped by class id (qid).
+  // Instances grouped by class id (qid). Each instance gets a stable global
+  // `_idx` (its position in pdInstances) so the visibility filter and the
+  // matched-superclass-instance lookup can refer to it deterministically.
   const instancesByClass = new Map();
-  for (const i of graph.meta.pdInstances) {
+  graph.meta.pdInstances.forEach((i, idx) => {
+    i._idx = idx;
     const k = qid(i.iiClass);
     if (!instancesByClass.has(k)) instancesByClass.set(k, []);
     instancesByClass.get(k).push(i);
-  }
+  });
 
   // Family instances grouped by family id (qid). Each fam-instance gets a
   // stable index so we can mint deterministic Cytoscape node ids. Closed
@@ -81,7 +84,8 @@
     setTopbar('classgraph', '');
     setHint('classes');
     setBackVisible(false);
-    setCounts(`${classById.size} classes  ·  ${familyById.size} families  ·  ${cy.edges().length} edges`);
+    setCounts(focusCountsLine());
+    document.getElementById('instance-filter').hidden = true;
     showSelection(null);
     if (!opts || !opts.fromHistory) {
       history.pushState({ view: 'classes' }, '', '#classes');
     setBackVisible(true);
     const insts = instancesByClass.get(classId) || [];
     const supers = (cls ? cls.ciSuperclasses : []).length;
-    setCounts(`${insts.length} instances  ·  ${supers} superclass requirements`);
+    const hidden = getHidden(classId).size;
+    const visiblePart = hidden > 0 ? `${insts.length - hidden}/${insts.length}` : `${insts.length}`;
+    setCounts(`${visiblePart} instances  ·  ${supers} superclass requirements`);
+    renderInstanceFilter(classId);
     showSelection(null);
     if (!opts || !opts.fromHistory) {
       history.pushState({ view: 'instance', classId }, '', '#instances/' + encodeURIComponent(classId));
     }
   }
 
+  // Re-run the instance view with the current visibility set, preserving
+  // the user's pan/zoom.
+  function rebuildInstanceView() {
+    if (state.view !== 'instance' || !state.classId) return;
+    const pan  = cy.pan();
+    const zoom = cy.zoom();
+    loadGraph(buildInstanceView(state.classId));
+    cy.layout({ name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 80 }).run();
+    cy.pan(pan);
+    cy.zoom(zoom);
+    const cls = classById.get(state.classId);
+    const insts = instancesByClass.get(state.classId) || [];
+    const hidden = getHidden(state.classId).size;
+    const visiblePart = hidden > 0 ? `${insts.length - hidden}/${insts.length}` : `${insts.length}`;
+    setCounts(`${visiblePart} instances  ·  ${(cls ? cls.ciSuperclasses : []).length} superclass requirements`);
+    renderInstanceFilter(state.classId);
+  }
+
+  // -------------------------------------------------------------------------
+  // Instance filter (right-hand side panel)
+
+  const filterEl       = document.getElementById('instance-filter');
+  const filterListEl   = document.getElementById('filter-list');
+  const filterSummary  = document.getElementById('filter-summary');
+  const filterSearch   = document.getElementById('filter-search');
+
+  function renderInstanceFilter(classId) {
+    const insts = instancesByClass.get(classId) || [];
+    if (insts.length === 0) {
+      filterEl.hidden = true;
+      return;
+    }
+    filterEl.hidden = false;
+    const hidden = getHidden(classId);
+    const visible = insts.length - hidden.size;
+    filterSummary.textContent = `Instances (${visible} / ${insts.length})`;
+    filterListEl.innerHTML = insts.map(inst => {
+      const head = renderInstanceHead(inst);
+      const isHidden = hidden.has(inst._idx);
+      return `<li data-idx="${inst._idx}">
+        <label class="${isHidden ? 'unchecked' : ''}">
+          <input type="checkbox" ${isHidden ? '' : 'checked'} />
+          <span class="head" title="${escapeAttr(head)}">${escape(head)}</span>
+        </label>
+      </li>`;
+    }).join('');
+    applyFilterSearch();
+  }
+
+  function applyFilterSearch() {
+    const q = (filterSearch.value || '').toLowerCase().trim();
+    [...filterListEl.children].forEach(li => {
+      if (!q) { li.classList.remove('hidden-row'); return; }
+      const text = li.textContent.toLowerCase();
+      li.classList.toggle('hidden-row', !text.includes(q));
+    });
+  }
+
+  filterListEl.addEventListener('change', evt => {
+    if (state.view !== 'instance' || !state.classId) return;
+    const cb = evt.target.closest('input[type="checkbox"]');
+    if (!cb) return;
+    const li = cb.closest('li[data-idx]');
+    if (!li) return;
+    const idx = parseInt(li.dataset.idx, 10);
+    const hidden = getHidden(state.classId);
+    if (cb.checked) hidden.delete(idx); else hidden.add(idx);
+    rebuildInstanceView();
+  });
+
+  document.getElementById('filter-all').addEventListener('click', () => {
+    if (state.view !== 'instance' || !state.classId) return;
+    getHidden(state.classId).clear();
+    rebuildInstanceView();
+  });
+
+  document.getElementById('filter-none').addEventListener('click', () => {
+    if (state.view !== 'instance' || !state.classId) return;
+    const insts = instancesByClass.get(state.classId) || [];
+    const hidden = getHidden(state.classId);
+    insts.forEach(i => hidden.add(i._idx));
+    rebuildInstanceView();
+  });
+
+  filterSearch.addEventListener('input', applyFilterSearch);
+
   function switchToFamily(familyId, opts) {
     const els = buildFamilyView(familyId);
     if (!els) return;
     setBackVisible(true);
     const fis = famInstsByFamily.get(familyId) || [];
     setCounts(`${fis.length} type instances`);
+    document.getElementById('instance-filter').hidden = true;
     showSelection(null);
     if (!opts || !opts.fromHistory) {
       history.pushState({ view: 'family', familyId }, '', '#families/' + encodeURIComponent(familyId));
   }
 
   // ---------------------------------------------------------------------------
-  // Classes view
+  // Globally muted classes — qid set hidden across all views. Useful for
+  // taking out noisy "ambient" superclasses (Show, Eq, Ord, Typeable,
+  // NoThunks, …) so the rest of the graph reads cleanly.
+
+  const mutedSet = new Set();
+
+  function muteClass(id) {
+    if (mutedSet.has(id)) return;
+    mutedSet.add(id);
+    renderMuteList();
+    refreshCurrentView();
+  }
+
+  function unmuteClass(id) {
+    if (!mutedSet.delete(id)) return;
+    renderMuteList();
+    refreshCurrentView();
+  }
+
+  function clearMuted() {
+    if (mutedSet.size === 0) return;
+    mutedSet.clear();
+    renderMuteList();
+    refreshCurrentView();
+  }
+
+  function refreshCurrentView() {
+    if (state.view === 'classes') relayoutClassesView();
+    else if (state.view === 'instance') rebuildInstanceView();
+    // Family view shows no class nodes; nothing to refresh.
+  }
+
+  function renderMuteList() {
+    const summary = document.getElementById('mute-summary');
+    const listEl  = document.getElementById('mute-list');
+    summary.textContent = `Muted classes (${mutedSet.size})`;
+    if (mutedSet.size === 0) {
+      listEl.innerHTML = '';
+      return;
+    }
+    const parts = [];
+    for (const id of mutedSet) {
+      const cls = classById.get(id);
+      const name = cls ? cls.ciName.qnName : id.split('.').pop();
+      parts.push(
+        `<li><span class="mute-chip" data-id="${escapeAttr(id)}" title="${escapeAttr(id)}">` +
+        `<span class="chip-name">${escape(name)}</span>` +
+        `<button class="mute-x" title="Unmute">×</button>` +
+        `</span></li>`
+      );
+    }
+    listEl.innerHTML = parts.join('');
+  }
+
+  document.getElementById('mute-clear').addEventListener('click', clearMuted);
+  document.getElementById('mute-list').addEventListener('click', evt => {
+    const x = evt.target.closest('.mute-x');
+    if (!x) return;
+    const chip = x.closest('.mute-chip');
+    if (!chip) return;
+    unmuteClass(chip.dataset.id);
+  });
+
+  // ---------------------------------------------------------------------------
+  // Classes view (with optional focus filter)
+  //
+  // When `focusSet` is empty, the full pre-built graph from Render.hs is
+  // shown. When non-empty, the view shrinks to only the pinned classes
+  // plus their immediate superclasses (and any associated type families).
+  // Ghost (one-hop) nodes can be promoted to pinned by clicking them, which
+  // expands the view by one more hop.
+
+  const focusSet = new Set();
 
   function buildClassesView() {
-    return graph.elements;  // pre-built by Render.hs
+    const focused = focusSet.size > 0;
+    const muted   = mutedSet.size > 0;
+    if (!focused && !muted) return graph.elements;
+
+    let visible = null;
+    if (focused) {
+      visible = new Set(focusSet);
+      for (const id of focusSet) {
+        const c = classById.get(id);
+        if (!c) continue;
+        for (const sc of c.ciSuperclasses) visible.add(qid(sc.seSuperclass));
+        for (const at of c.ciAssocTypes)  visible.add(qid(at));
+      }
+    }
+
+    const out = [];
+    for (const e of graph.elements) {
+      if (e.group === 'nodes') {
+        if (mutedSet.has(e.data.id)) continue;
+        if (visible && !visible.has(e.data.id)) continue;
+        const ghost = focused && !focusSet.has(e.data.id);
+        out.push({ group: 'nodes', data: Object.assign({}, e.data, { ghost }) });
+      } else {
+        if (mutedSet.has(e.data.source) || mutedSet.has(e.data.target)) continue;
+        if (visible && (!visible.has(e.data.source) || !visible.has(e.data.target))) continue;
+        out.push(e);
+      }
+    }
+    return out;
+  }
+
+  function relayoutClassesView() {
+    if (state.view !== 'classes') return;
+    loadGraph(buildClassesView());
+    cy.layout({ name: 'dagre', rankDir: 'BT', nodeSep: 50, rankSep: 90 }).run();
+    setCounts(focusCountsLine());
+  }
+
+  function focusCountsLine() {
+    if (focusSet.size === 0) {
+      return `${classById.size} classes  ·  ${familyById.size} families  ·  ${cy.edges().length} edges`;
+    }
+    const total = cy.nodes().length;
+    const pinned = focusSet.size;
+    return `Focus: ${pinned} pinned  ·  ${total} visible nodes  ·  ${cy.edges().length} edges`;
+  }
+
+  function pinClass(id) {
+    if (!classById.has(id)) return;
+    if (focusSet.has(id)) return;
+    focusSet.add(id);
+    renderChips();
+    if (state.view === 'classes') {
+      relayoutClassesView();
+    } else {
+      switchToClasses();  // pinning implies you want to look at the focused subgraph
+    }
+  }
+
+  function unpinClass(id) {
+    if (!focusSet.delete(id)) return;
+    renderChips();
+    if (state.view === 'classes') relayoutClassesView();
+  }
+
+  function clearFocus() {
+    if (focusSet.size === 0) return;
+    focusSet.clear();
+    renderChips();
+    if (state.view === 'classes') relayoutClassesView();
+  }
+
+  function renderChips() {
+    const chips = document.getElementById('pin-chips');
+    const list = document.getElementById('pin-list');
+    if (focusSet.size === 0) {
+      chips.hidden = true;
+      list.innerHTML = '';
+      return;
+    }
+    chips.hidden = false;
+    const parts = [];
+    for (const id of focusSet) {
+      const c = classById.get(id);
+      const name = c ? c.ciName.qnName : id;
+      parts.push(
+        `<span class="pin-chip" data-id="${escapeAttr(id)}" title="${escapeAttr(id)}">` +
+          `<span class="chip-name">${escape(name)}</span>` +
+          `<button class="pin-x" title="Remove">×</button>` +
+        `</span>`
+      );
+    }
+    list.innerHTML = parts.join('');
+  }
+
+  function escapeAttr(s) {
+    return String(s).replace(/[&<>"']/g, ch => ({
+      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
+    }[ch]));
   }
 
   // ---------------------------------------------------------------------------
   // Instance view
 
+  // Per-class set of *hidden* instance _idx values. Persists across
+  // navigation so the filter survives a back-and-forth trip. Empty (or
+  // missing) means "show all".
+  const hiddenInstancesByClass = new Map();
+
+  function getHidden(classId) {
+    let s = hiddenInstancesByClass.get(classId);
+    if (!s) { s = new Set(); hiddenInstancesByClass.set(classId, s); }
+    return s;
+  }
+
   function buildInstanceView(classId) {
     const cls = classById.get(classId);
     if (!cls) return null;
-    const insts = instancesByClass.get(classId) || [];
+    const all = instancesByClass.get(classId) || [];
+    const hidden = getHidden(classId);
+    const insts = all.filter(i => !hidden.has(i._idx));
 
     const els = [];
     const seenNodes = new Set();
       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-instance rows into
+    // the graph so the user can verify the family is actually defined
+    // for the relevant types.
+    function addFamilyLinksFromArgs(args, originId, edgeTag) {
+      const fams = collectFamilyRefs(args);
+      for (const fa of fams) {
+        const famNodeId = ensureFamilyNode(fa);
+        const linkId = originId + '#viafam#' + edgeTag + '#' + famNodeId;
+        if (seenNodes.has(linkId)) continue;
+        seenNodes.add(linkId);
+        els.push({ group: 'edges', data: {
+          id: linkId,
+          source: originId,
+          target: famNodeId,
+          kind: 'via-family',
+          label: 'via ' + fa.qnName,
+        }});
+        // Also surface the matching type-instance rows so we see where
+        // the family is actually defined for which heads.
+        for (const fi of (famInstsByFamily.get(famNodeId) || [])) {
+          const fiNodeId = ensureFamInstanceNode(fi);
+          const fdId = famNodeId + '=>' + fiNodeId;
+          if (seenNodes.has(fdId)) continue;
+          seenNodes.add(fdId);
+          els.push({ group: 'edges', data: {
+            id: fdId,
+            source: famNodeId,
+            target: fiNodeId,
+            kind: 'fam-defines',
+          }});
+        }
+      }
+    }
+
     // Focus class at the top.
     const focusedId = ensureClassNode(cls.ciName, { focused: true });
 
       }});
 
       // Context constraints — these are the "new classes the instance
-      // declares as needed for the implementation".
+      // declares as needed for the implementation". Skip predicates whose
+      // class is globally muted. Equality predicates (a ~ b) don't point
+      // at a class node, so they aren't drawn as edges; they appear in
+      // the side panel under the instance.
       inst.iiContext.forEach((pred, pi) => {
+        if (pred.piIsEq) return;
+        if (mutedSet.has(qid(pred.piClass))) return;
         const cid = ensureClassNode(pred.piClass);
         els.push({ group: 'edges', data: {
           id: instId + '#ctx#' + pi,
           kind: 'context',
           label: 'ctx: ' + renderArgsCompact(pred.piArgs, inst.iiTyVars),
         }});
+        // Surface any type-family applications hiding inside the predicate
+        // (e.g. `Eq (TxOut era)` — `Eq` itself is the class, but `TxOut`
+        // is a type family that must be defined for that era).
+        addFamilyLinksFromArgs(pred.piArgs, instId, 'ctx-fam');
       });
 
       // Associated type families: when the focused class declares assoc
       // produce the requirement `S substArgs`. We then look in our program
       // data for instances of S whose head shape matches.
       cls.ciSuperclasses.forEach((sc, si) => {
+        if (mutedSet.has(qid(sc.seSuperclass))) return;
         const reqArgs = sc.seArgs.map(a => substTypeArg(a, inst.iiArgs));
+        // Same family-linkage as for context preds: a superclass requirement
+        // like `Eq (TxOut era)` should also flag the `TxOut` family.
+        addFamilyLinksFromArgs(reqArgs, instId, 'sc-fam-' + si);
         const matched = findMatchingInstances(sc.seSuperclass, reqArgs);
         const scClsId = ensureClassNode(sc.seSuperclass);
         const reqLabel = 'needs ' + sc.seSuperclass.qnName + ' ' +
   function findMatchingInstances(classQn, reqArgs) {
     const target = qid(classQn);
     const matches = [];
-    for (let i = 0; i < graph.meta.pdInstances.length; i++) {
-      const inst = graph.meta.pdInstances[i];
+    for (const inst of graph.meta.pdInstances) {
       if (qid(inst.iiClass) !== target) continue;
-      if (matchArgs(inst.iiArgs, reqArgs)) {
-        // Stash a stable index for id generation.
-        if (inst._idx === undefined) inst._idx = i;
-        matches.push(inst);
-      }
+      if (matchArgs(inst.iiArgs, reqArgs)) matches.push(inst);
     }
     return matches;
   }
     }
     if (a.tag === 'TyConApp' || a.tag === 'FamilyApp') {
       const [q, args] = a.contents;
+      // Infix render for equality/coercibility-like binary operators that
+      // appear as TyCons in argument position. Without this they would
+      // print as e.g. "(~ a b)".
+      const op = infixOpName(q.qnName);
+      if (op && args && args.length === 2) {
+        return '(' + renderArg(args[0], boundTvs) + ' ' + op + ' ' +
+                     renderArg(args[1], boundTvs) + ')';
+      }
       if (!args || args.length === 0) return q.qnName;
       const inner = args.map(x => renderArg(x, boundTvs)).join(' ');
       return '(' + q.qnName + ' ' + inner + ')';
     return '?';
   }
 
+  // Walk a list of TypeArgs and collect every distinct family QualName
+  // mentioned. Used by the instance view to surface the type families
+  // referenced by a constraint (e.g. `Eq (TxOut era)` mentions `TxOut`).
+  function collectFamilyRefs(args) {
+    const seen = new Map();   // qid → QualName
+    function go(t) {
+      if (!t || !t.tag) return;
+      if (t.tag === 'FamilyApp') {
+        const [q, inner] = t.contents;
+        if (!seen.has(qid(q))) seen.set(qid(q), q);
+        for (const x of (inner || [])) go(x);
+      } else if (t.tag === 'TyConApp') {
+        const [, inner] = t.contents;
+        for (const x of (inner || [])) go(x);
+      }
+    }
+    for (const a of (args || [])) go(a);
+    return [...seen.values()];
+  }
+
+  // Returns the infix operator string for a TyCon name that should render
+  // infix (currently the various equality and coercion-evidence forms).
+  function infixOpName(name) {
+    if (name === '->' || name === '(->)') return '->';
+    if (name === '~'  || name === '(~)')  return '~';
+    if (name === '~~' || name === '(~~)') return '~~';
+    if (name === '~#' || name === '(~#)') return '~#';
+    if (name === '~R#' || name === '(~R#)') return '~R#';
+    return null;
+  }
+
   // ---------------------------------------------------------------------------
   // Side panel
 
     const head = escape(inst.iiClass.qnName) + ' ' +
       escape(renderArgsCompact(inst.iiArgs, inst.iiTyVars));
     const ctx = inst.iiContext.length === 0 ? '<dd><em>none</em></dd>' :
-      inst.iiContext.map(p =>
-        `<dd>${escape(p.piClass.qnName)} ${escape(renderArgsCompact(p.piArgs, inst.iiTyVars))}</dd>`).join('');
+      inst.iiContext.map(p => {
+        const txt = p.piIsEq
+          ? renderArg(p.piArgs[0], inst.iiTyVars) + ' ' + p.piClass.qnName +
+            ' ' + renderArg(p.piArgs[1], inst.iiTyVars)
+          : p.piClass.qnName + ' ' + renderArgsCompact(p.piArgs, inst.iiTyVars);
+        return `<dd>${escape(txt)}</dd>`;
+      }).join('');
     const tvs = inst.iiTyVars.length === 0 ? '<dd><em>none</em></dd>' :
       `<dd><ul>${inst.iiTyVars.map(v =>
         `<li>${escape(v.tvName)}<span style="color:#888"> :: ${escape(v.tvKind)}</span></li>`).join('')}</ul></dd>`;
           'border-style': 'dashed',
         },
       },
+      // Ghost class node (one-hop neighbour in a focus-filtered classes view)
+      {
+        selector: 'node[kind = "class"][?ghost]',
+        style: {
+          'background-color': '#dbeafe',
+          color: '#1e3a8a',
+          'border-color': '#1d4ed8',
+          'border-style': 'dashed',
+          opacity: 0.85,
+        },
+      },
       // Focused class in instance view
       {
         selector: 'node[?focused]',
           width: 1.4,
         },
       },
+      // Instance view: via-family (instance -> family node, indicating the
+      // constraint mentions a type family that must be defined elsewhere).
+      {
+        selector: 'edge[kind = "via-family"]',
+        style: {
+          'line-color': '#c084fc',
+          'target-arrow-color': '#c084fc',
+          'line-style': 'dashed',
+          width: 1.2,
+          'font-size': 9,
+        },
+      },
       // Family/instance view: fam-defines (family -> fam-instance node)
       {
         selector: 'edge[kind = "fam-defines"]',
   cy.on('tap', 'node', evt => {
     const n = evt.target;
     const data = n.data();
+
+    // In the classes view, when focus filtering is on:
+    //   * clicking a *ghost* node (visible because it's a one-hop superclass
+    //     of something pinned) promotes it to pinned, expanding the view.
+    //   * clicking a pinned node still drills in to its instance view.
+    if (state.view === 'classes' && focusSet.size > 0
+        && data.kind === 'class' && data.ghost && classById.has(n.id())) {
+      pinClass(n.id());
+      return;
+    }
+
     // Class node → drill into its instance view (skip the already-focused
     // class in the current instance view).
     if (data.kind === 'class' && !data.focused && classById.has(n.id())) {
     switchToClasses();
   });
 
+  document.getElementById('pin-clear').addEventListener('click', () => {
+    clearFocus();
+  });
+
+  document.getElementById('pin-list').addEventListener('click', evt => {
+    const x = evt.target.closest('.pin-x');
+    if (!x) return;
+    const chip = x.closest('.pin-chip');
+    if (!chip) return;
+    unpinClass(chip.dataset.id);
+  });
+
   window.addEventListener('popstate', evt => {
     const s = evt.state;
     if (s && s.view === 'instance') {
   }
   bootstrap();
 
+  // -------------------------------------------------------------------------
+  // Resizable side panel — drag the splitter to widen/narrow the panel.
+  // The chosen width is persisted to localStorage.
+
+  (function setupSplitter() {
+    const splitter = document.getElementById('splitter');
+    const panel    = document.getElementById('panel');
+    if (!splitter || !panel) return;
+    const STORAGE_KEY = 'classgraph.panelWidthPx';
+    const stored = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
+    if (stored && stored > 200 && stored < window.innerWidth - 200) {
+      panel.style.width = stored + 'px';
+    }
+    let dragging = false;
+    splitter.addEventListener('mousedown', evt => {
+      dragging = true;
+      splitter.classList.add('dragging');
+      document.body.style.cursor = 'ew-resize';
+      evt.preventDefault();
+    });
+    document.addEventListener('mousemove', evt => {
+      if (!dragging) return;
+      const w = Math.max(220, Math.min(window.innerWidth - 200,
+                                        window.innerWidth - evt.clientX));
+      panel.style.width = w + 'px';
+      cy.resize();
+    });
+    document.addEventListener('mouseup', () => {
+      if (!dragging) return;
+      dragging = false;
+      splitter.classList.remove('dragging');
+      document.body.style.cursor = '';
+      const w = parseInt(panel.style.width, 10);
+      if (w) localStorage.setItem(STORAGE_KEY, String(w));
+    });
+  })();
+
   // ---------------------------------------------------------------------------
   // Search bar
   //
       const badge = e.kind === 'family'
         ? '<span class="badge family">family</span>'
         : (e.external ? '<span class="badge external">external</span>' : '');
+      const canPin = (e.kind === 'class' && !e.external);
+      const canMute = (e.kind === 'class');
+      const pinned = canPin && focusSet.has(e.id);
+      const muted  = canMute && mutedSet.has(e.id);
+      const actions = [];
+      if (canMute) {
+        actions.push(`<button class="mute-add${muted ? ' muted' : ''}" title="${muted ? 'Unmute' : 'Mute (hide everywhere)'}">🙈</button>`);
+      }
+      if (canPin) {
+        actions.push(`<button class="pin-add${pinned ? ' pinned' : ''}" title="${pinned ? 'Unpin' : 'Add to focus'}">📌</button>`);
+      }
       return `<li data-index="${i}">
-        <span class="name">${escape(e.name)}${badge}</span>
-        <span class="qual">${escape(e.package)} · ${escape(e.module)}</span>
+        <span class="row-content">
+          <span class="name">${escape(e.name)}${badge}</span>
+          <span class="qual">${escape(e.package)} · ${escape(e.module)}</span>
+        </span>
+        <span class="row-actions">${actions.join('')}</span>
       </li>`;
     }).join('');
   }
     const li = evt.target.closest('li[data-index]');
     if (!li) return;
     evt.preventDefault();
-    selectMatch(parseInt(li.dataset.index, 10));
+    const idx = parseInt(li.dataset.index, 10);
+    const m = currentMatches[idx];
+    if (!m) return;
+    const pinBtn = evt.target.closest('.pin-add');
+    if (pinBtn) {
+      if (focusSet.has(m.id)) unpinClass(m.id); else pinClass(m.id);
+      renderResults(currentMatches);
+      updateActive();
+      searchInput.focus();
+      return;
+    }
+    const muteBtn = evt.target.closest('.mute-add');
+    if (muteBtn) {
+      if (mutedSet.has(m.id)) unmuteClass(m.id); else muteClass(m.id);
+      renderResults(currentMatches);
+      updateActive();
+      searchInput.focus();
+      return;
+    }
+    selectMatch(idx);
   });
 
   document.addEventListener('mousedown', evt => {
index 1971611b6a7b970fbf04bffff5786e733e83b848..5b9102436ecbb68876c421902efcf2240a0fce85 100644 (file)
@@ -32,6 +32,11 @@ import GHC.Core.Coercion.Axiom
   , fromBranches
   )
 import GHC.Core.FamInstEnv (FamInst (..))
+import GHC.Core.Predicate
+  ( EqRel (..)
+  , Pred (..)
+  , classifyPredType
+  )
 import GHC.Core (IsOrphan, isOrphan)
 import GHC.Core.InstEnv
   ( ClsInst
@@ -43,18 +48,23 @@ import GHC.Core.TyCon
   ( FamTyConFlav (..)
   , TyCon
   , famTyConFlav_maybe
-  , isClassTyCon
   , isFamilyTyCon
+  , isInvisibleTyConBinder
+  , tyConBinders
   , tyConName
   , tyConResKind
+  , tyConTuple_maybe
   , tyConTyVars
   , tyConClass_maybe
   )
+import GHC.Core.Class (classTyCon)
+import GHC.Types.Basic (TupleSort (..))
 import GHC.Core.Type
   ( Type
   , getTyVar_maybe
   , isLitTy
   , splitTyConApp_maybe
+  , splitVisibleFunTy_maybe
   )
 import GHC.Tc.Types (TcGblEnv (..))
 import GHC.Types.Name
@@ -73,11 +83,40 @@ import GHC.Types.SrcLoc
 import GHC.Types.Var (Var, varName, varType)
 import GHC.Unit.Module (moduleName, moduleNameString, moduleUnit)
 import GHC.Unit.Types (unitString)
-import GHC.Utils.Outputable (showPprUnsafe)
+import GHC.Utils.Outputable
+  ( Outputable
+  , SDocContext (..)
+  , defaultSDocContext
+  , ppr
+  , showSDocOneLine
+  )
 import qualified GHC.Data.FastString as FS
 
 import Classgraph.Schema
 
+-- | A user-style 'SDocContext' that suppresses the noise the default
+-- (debug-leaning) context emits — explicit kinds, RuntimeRep arguments,
+-- linearity multiplicities, foralls, etc. Without these overrides, types
+-- like @* -> *@ and @a -> b@ get printed as @TYPE BoxedRep Lifted@ and
+-- @FUN ManyTy a b@, which is unreadable in the viewer.
+prettyCtx :: SDocContext
+prettyCtx = defaultSDocContext
+  { sdocPrintExplicitKinds       = False
+  , sdocPrintExplicitRuntimeReps = False
+  , sdocPrintExplicitForalls     = False
+  , sdocPrintExplicitCoercions   = False
+  , sdocSuppressVarKinds         = True
+  , sdocSuppressUniques          = True
+  , sdocLinearTypes              = False
+  , sdocPrintTypeAbbreviations   = True
+  , sdocSuppressModulePrefixes   = True
+  , sdocStarIsType               = True
+  , sdocListTuplePuns            = True
+  }
+
+pprText :: Outputable a => a -> Text
+pprText = T.pack . showSDocOneLine prettyCtx . ppr
+
 -- | Identify which module + package this dump corresponds to.
 currentModuleNames :: TcGblEnv -> (Text, Text)
 currentModuleNames env =
@@ -126,7 +165,7 @@ extractClass :: Class -> Maybe ClassInfo
 extractClass cls = Just ClassInfo
   { ciName         = qualName (className cls)
   , ciTyVars       = map tyVarInfo (classTyVars cls)
-  , ciSuperclasses = mapMaybe (predToSuperEdge boundTvs) (classSCTheta cls)
+  , ciSuperclasses = concatMap (predToSuperEdges boundTvs) (classSCTheta cls)
   , ciAssocTypes   = [ qualName (tyConName atTc) | atTc <- classATs cls ]
   , ciMethods      = map (T.pack . occNameString . nameOccName . varName) (classMethods cls)
   , ciSrc          = srcSpanInfo (nameSrcSpan (className cls))
@@ -134,16 +173,26 @@ extractClass cls = Just ClassInfo
   where
     boundTvs = classTyVars cls
 
-predToSuperEdge :: [Var] -> Type -> Maybe SuperclassEdge
-predToSuperEdge boundTvs predTy =
-  case splitTyConApp_maybe predTy of
-    Just (tc, args)
-      | isClassTyCon tc ->
-          Just SuperclassEdge
-            { seSuperclass = qualName (tyConName tc)
+-- | Decompose one PredType into 0..n 'SuperclassEdge's. Class predicates
+-- become a single edge; constraint tuples are flattened recursively;
+-- equality\/quantified\/irreducible predicates produce no edge (they don't
+-- map onto a class node).
+predToSuperEdges :: [Var] -> Type -> [SuperclassEdge]
+predToSuperEdges boundTvs predTy = case classifyPredType predTy of
+  ClassPred cls args
+    | isCTupleClass cls -> concatMap (predToSuperEdges boundTvs) args
+    | otherwise         ->
+        [ SuperclassEdge
+            { seSuperclass = qualName (className cls)
             , seArgs       = map (typeArg boundTvs) args
             }
-    _ -> Nothing  -- equality, implicit-param, etc. — not a class constraint
+        ]
+  EqPred{}     -> []
+  IrredPred{}  -> []
+  ForAllPred{} -> []
+
+isCTupleClass :: Class -> Bool
+isCTupleClass cls = tyConTuple_maybe (classTyCon cls) == Just ConstraintTuple
 
 ------------------------------------------------------------------------------
 -- Instances
@@ -154,10 +203,10 @@ extractInstance inst =
    in InstanceInfo
         { iiClass   = qualName (className cls)
         , iiArgs    = map (typeArg tvs) args
-        , iiContext = mapMaybe (predToPredInfo tvs) theta
+        , iiContext = concatMap (predToPredInfos tvs) theta
         , iiTyVars  = map tyVarInfo tvs
         , iiOrphan  = orphanFlag (is_orphan inst)
-        , iiOverlap = Just (T.pack (showPprUnsafe (is_flag inst)))
+        , iiOverlap = Just (pprText (is_flag inst))
           -- The dfun's name span would be more precise; for synthesised
           -- dfuns it's often unhelpful, so fall back to the class name.
         , iiSrc     = srcSpanInfo (nameSrcSpan (className cls))
@@ -166,16 +215,34 @@ extractInstance inst =
 orphanFlag :: IsOrphan -> Bool
 orphanFlag = isOrphan
 
-predToPredInfo :: [Var] -> Type -> Maybe PredInfo
-predToPredInfo boundTvs predTy =
-  case splitTyConApp_maybe predTy of
-    Just (tc, args)
-      | isClassTyCon tc ->
-          Just PredInfo
-            { piClass = qualName (tyConName tc)
+-- | Decompose one PredType into 0..n 'PredInfo's:
+--   * ClassPred → one info (or many, if it's a constraint tuple).
+--   * EqPred    → one info with @piIsEq = True@ and the two operands.
+--   * IrredPred / ForAllPred → none.
+predToPredInfos :: [Var] -> Type -> [PredInfo]
+predToPredInfos boundTvs predTy = case classifyPredType predTy of
+  ClassPred cls args
+    | isCTupleClass cls -> concatMap (predToPredInfos boundTvs) args
+    | otherwise ->
+        [ PredInfo
+            { piClass = qualName (className cls)
             , piArgs  = map (typeArg boundTvs) args
+            , piIsEq  = False
             }
-    _ -> Nothing
+        ]
+  EqPred eqRel a b ->
+    [ PredInfo
+        { piClass = QualName "<builtin>" "GHC.Builtin" (eqOpName eqRel)
+        , piArgs  = [typeArg boundTvs a, typeArg boundTvs b]
+        , piIsEq  = True
+        }
+    ]
+  IrredPred{}  -> []
+  ForAllPred{} -> []
+
+eqOpName :: EqRel -> Text
+eqOpName NomEq  = "~"
+eqOpName ReprEq = "Coercible"  -- shows up as "a ~R b" sometimes; closest readable form
 
 ------------------------------------------------------------------------------
 -- Type families
@@ -187,7 +254,7 @@ extractTypeFamily assocByName tc
       { tfName       = qualName (tyConName tc)
       , tfTyVars     = map tyVarInfo (tyConTyVars tc)
       , tfFlavor     = flavor
-      , tfResultKind = T.pack (showPprUnsafe (tyConResKind tc))
+      , tfResultKind = pprText (tyConResKind tc)
       , tfSrc        = srcSpanInfo (nameSrcSpan (tyConName tc))
       , tfEquations  = closedEquations
       }
@@ -240,19 +307,55 @@ extractFamInst fi = FamInstInfo
 -- returned by 'instanceSig'). Their positional index in this list is
 -- what 'TyVarRef' carries — exactly what the viewer needs to render the
 -- multi-param positional mapping on edge labels.
+--
+-- We aggressively sugar two structural forms that would otherwise leak the
+-- compiler's explicit-runtime-rep representation into the viewer:
+--
+--   * Function arrows are recognised via 'splitVisibleFunTy_maybe' and
+--     rendered as a synthetic @(->)@ 'TyConApp' with two arguments only —
+--     no multiplicity, no kind args. (Otherwise we'd see @FUN ManyTy
+--     (BoxedRep Lifted) (BoxedRep Lifted) (TYPE …) a b@.)
+--
+--   * For all other 'TyConApp's the invisible (kind / runtime-rep)
+--     argument positions are filtered out using 'tyConBinders' +
+--     'isInvisibleTyConBinder', so we don't surface @TYPE (BoxedRep
+--     Lifted)@ injections at every kind-polymorphic call site.
 typeArg :: [Var] -> Type -> TypeArg
 typeArg boundTvs t =
   case getTyVar_maybe t of
     Just tv | Just i <- elemIndex tv boundTvs -> TyVarRef i
-    _ -> case splitTyConApp_maybe t of
-      Just (tc, args)
-        | isFamilyTyCon tc ->
-            FamilyApp (qualName (tyConName tc)) (map (typeArg boundTvs) args)
-        | otherwise ->
-            TyConApp  (qualName (tyConName tc)) (map (typeArg boundTvs) args)
-      Nothing -> case isLitTy t of
-        Just _  -> LitArg   (T.pack (showPprUnsafe t))
-        Nothing -> OtherArg (T.pack (showPprUnsafe t))
+    _ -> case splitVisibleFunTy_maybe t of
+      Just (a, b) ->
+        TyConApp arrowQualName [typeArg boundTvs a, typeArg boundTvs b]
+      Nothing -> case splitTyConApp_maybe t of
+        Just (tc, args) ->
+          let visArgs = visibleArgs tc args
+              kids    = map (typeArg boundTvs) visArgs
+           in if isFamilyTyCon tc
+                then FamilyApp (qualName (tyConName tc)) kids
+                else TyConApp  (qualName (tyConName tc)) kids
+        Nothing -> case isLitTy t of
+          Just _  -> LitArg   (pprText t)
+          Nothing -> OtherArg (pprText t)
+
+-- | Drop the elements of @args@ corresponding to invisible 'tyConBinders'.
+-- If @args@ has more entries than the TyCon has binders (over-application,
+-- which can happen for higher-kinded type variables), the trailing extras
+-- are kept as visible.
+visibleArgs :: TyCon -> [Type] -> [Type]
+visibleArgs tc args =
+  let binders = tyConBinders tc
+   in zipFilter binders args
+  where
+    zipFilter (b:bs) (a:as)
+      | isInvisibleTyConBinder b = zipFilter bs as
+      | otherwise                = a : zipFilter bs as
+    zipFilter _ leftover = leftover
+
+-- | Synthetic 'QualName' used to flag a function arrow in a 'TypeArg'. The
+-- viewer renders this as the @->@ infix operator.
+arrowQualName :: QualName
+arrowQualName = QualName "<builtin>" "GHC.Builtin" "->"
 
 ------------------------------------------------------------------------------
 -- Helpers
@@ -260,7 +363,7 @@ typeArg boundTvs t =
 tyVarInfo :: Var -> TyVarInfo
 tyVarInfo v = TyVarInfo
   { tvName = T.pack (occNameString (nameOccName (varName v)))
-  , tvKind = T.pack (showPprUnsafe (varType v))
+  , tvKind = pprText (varType v)
     -- For TyVars, varType v is the kind (Kind = Type).
   }
 
index 6785e0ecd6b1c84c2e5387e53a972dfda02e3099..f4339c76f3efbdd62e9c576e65e01e7da955fb66 100644 (file)
@@ -22,7 +22,7 @@ module Classgraph.Schema
   , SrcSpanInfo (..)
   ) where
 
-import Data.Aeson (FromJSON, ToJSON)
+import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.!=), (.:), (.:?), (.=))
 import Data.Text (Text)
 import GHC.Generics (Generic)
 
@@ -125,10 +125,31 @@ data InstanceInfo = InstanceInfo
 
 data PredInfo = PredInfo
   { piClass :: !QualName
+    -- ^ For a class predicate: the class. For an equality predicate, a
+    -- synthetic 'QualName' with name "~" or "~~" (see 'piIsEq').
   , piArgs  :: ![TypeArg]
+    -- ^ For a class predicate: the class arguments. For an equality
+    -- predicate: exactly two arguments — the LHS and the RHS.
+  , piIsEq  :: !Bool
+    -- ^ 'True' when this represents a type-equality constraint (@a ~ b@,
+    -- @a ~~ b@). The viewer renders such predicates infix.
   }
   deriving stock (Generic, Show, Eq)
-  deriving anyclass (ToJSON, FromJSON)
+
+-- Custom JSON instances so older dumps (produced before 'piIsEq' existed)
+-- still decode: missing 'piIsEq' is treated as 'False'.
+instance ToJSON PredInfo where
+  toJSON p = object
+    [ "piClass" .= piClass p
+    , "piArgs"  .= piArgs p
+    , "piIsEq"  .= piIsEq p
+    ]
+
+instance FromJSON PredInfo where
+  parseJSON = withObject "PredInfo" $ \o -> PredInfo
+    <$> o .:  "piClass"
+    <*> o .:  "piArgs"
+    <*> o .:? "piIsEq" .!= False
 
 data TypeFamilyFlavor
   = OpenFam