feat(debug): enhance debug graph functionality with auto-discovery of local and global variables
This commit is contained in:
@@ -17,6 +17,21 @@ box:
|
||||
|
||||
$debug_graph(list)
|
||||
$debug_graph(list, "top", top) # extra name/pointer marker pairs
|
||||
|
||||
Besides EXPR and any explicit name/pointer pairs, every call also walks and
|
||||
labels, as additional roots:
|
||||
- every other local variable (and argument) visible at the current PC,
|
||||
from the innermost lexical block out to function scope -- kind "local"
|
||||
- every file-scope global/static visible from the current source file --
|
||||
kind "global"
|
||||
the explicit name/pointer pairs are labeled kind "watched". This is what
|
||||
lets e.g. $debug_graph(top) -- which VS Code's expressionTemplate produces
|
||||
when you just type "top" in the Watch box -- still show the list: `top` is
|
||||
a bare `int*` with no struct to recurse into on its own, but `list` (a
|
||||
sibling local at that point) gets auto-walked too, so `top`'s address lands
|
||||
on a node already present in the combined graph. Each root's "kind" rides
|
||||
along in the output JSON so the visualizer can offer show/hide toggles per
|
||||
category (see vis-plugins/node-table-visualizer.js).
|
||||
"""
|
||||
|
||||
import gdb
|
||||
@@ -135,41 +150,178 @@ def _describe_struct(val, node_id, nodes, edges, visited):
|
||||
|
||||
|
||||
def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited):
|
||||
"""Cycle-guards only on the struct/union branch (via `visited`), and
|
||||
only fills in a scalar leaf if nothing has described this address yet
|
||||
(via `nodes`). With auto-discovered roots (see build_graph_json), the
|
||||
same address can be reached both as a struct-typed pointer (e.g. a
|
||||
linked-list node) and, separately, as some other variable's plain
|
||||
scalar pointer into one of that struct's fields (e.g. a `T*` pointing
|
||||
at a `Node<T>`'s first member, which shares its address). Whichever
|
||||
order those walks happen in, the richer struct description must win --
|
||||
a scalar walk must never downgrade/overwrite an already-described
|
||||
struct node, and a struct walk must always be able to upgrade a
|
||||
previously-recorded scalar leaf at the same address."""
|
||||
node_id = _addr_str(ptr_int)
|
||||
if node_id in visited:
|
||||
return
|
||||
visited.add(node_id)
|
||||
|
||||
pointee_type = ptr_val.type.strip_typedefs().target().strip_typedefs()
|
||||
pointee = ptr_val.dereference()
|
||||
|
||||
if pointee_type.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
if node_id in visited:
|
||||
return
|
||||
visited.add(node_id)
|
||||
pointee = ptr_val.dereference()
|
||||
_describe_struct(pointee, node_id, nodes, edges, visited)
|
||||
else:
|
||||
# Pointer to a scalar (e.g. int*): still worth a leaf node.
|
||||
if node_id in nodes:
|
||||
return # already described (richly or otherwise) via another path
|
||||
pointee = ptr_val.dereference()
|
||||
nodes[node_id] = [
|
||||
{"name": "value", "value": _format_scalar(pointee), "isPointer": False}
|
||||
]
|
||||
|
||||
|
||||
def build_graph_json(root_val, roots):
|
||||
nodes = {}
|
||||
edges = []
|
||||
visited = set()
|
||||
|
||||
def _walk_primary_root(root_val, nodes, edges, visited):
|
||||
"""Walks EXPR itself (the first argument to $debug_graph). Returns the
|
||||
address string of EXPR's own node, if any, so callers can skip re-adding
|
||||
it as a redundant self-pointing root when auto-discovery finds the same
|
||||
variable again as a local."""
|
||||
t = root_val.type.strip_typedefs()
|
||||
if t.code == gdb.TYPE_CODE_PTR:
|
||||
ptr_int = int(root_val)
|
||||
if ptr_int:
|
||||
_visit_pointer(root_val, ptr_int, nodes, edges, visited)
|
||||
return _addr_str(ptr_int)
|
||||
return None
|
||||
elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
node_id = _addr_str(int(root_val.address))
|
||||
visited.add(node_id)
|
||||
_describe_struct(root_val, node_id, nodes, edges, visited)
|
||||
return node_id
|
||||
else:
|
||||
nodes["root"] = [
|
||||
{"name": "value", "value": _format_scalar(root_val), "isPointer": False}
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _add_named_root(name, val, kind, nodes, edges, visited, roots_out, skip_addrs):
|
||||
"""Walks `val` (a local/global/watched variable) same as a primary root,
|
||||
then records it as a (name, value, kind) root entry -- unless its
|
||||
address is in skip_addrs (already covered by the primary root)."""
|
||||
t = val.type.strip_typedefs()
|
||||
if t.code == gdb.TYPE_CODE_PTR:
|
||||
try:
|
||||
ptr_int = int(val)
|
||||
except gdb.error:
|
||||
return
|
||||
if not ptr_int:
|
||||
return
|
||||
addr = _addr_str(ptr_int)
|
||||
if addr in skip_addrs:
|
||||
return
|
||||
_visit_pointer(val, ptr_int, nodes, edges, visited)
|
||||
roots_out.append((name, addr, kind))
|
||||
elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
|
||||
try:
|
||||
addr = _addr_str(int(val.address))
|
||||
except gdb.error:
|
||||
return
|
||||
if addr in skip_addrs:
|
||||
return
|
||||
if addr not in visited:
|
||||
visited.add(addr)
|
||||
_describe_struct(val, addr, nodes, edges, visited)
|
||||
roots_out.append((name, addr, kind))
|
||||
else:
|
||||
roots_out.append((name, _format_scalar(val), kind))
|
||||
|
||||
|
||||
def _frame_local_symbols(frame):
|
||||
"""Yields every local variable/argument gdb.Symbol visible at the
|
||||
current PC, walking from the innermost lexical block out to (but not
|
||||
including) the enclosing file/global scope."""
|
||||
seen = set()
|
||||
block = frame.block()
|
||||
while block is not None and not block.is_global and not block.is_static:
|
||||
for sym in block:
|
||||
if not (sym.is_variable or sym.is_argument):
|
||||
continue
|
||||
if sym.name is None or sym.name in seen:
|
||||
continue
|
||||
seen.add(sym.name)
|
||||
yield sym
|
||||
block = block.superblock
|
||||
|
||||
|
||||
def _file_global_symbols(frame):
|
||||
"""Yields every file-scope global/static gdb.Symbol visible from the
|
||||
current frame's source file."""
|
||||
seen = set()
|
||||
try:
|
||||
symtab = frame.find_sal().symtab
|
||||
except gdb.error:
|
||||
return
|
||||
if symtab is None:
|
||||
return
|
||||
for block in (symtab.global_block(), symtab.static_block()):
|
||||
if block is None:
|
||||
continue
|
||||
for sym in block:
|
||||
if not sym.is_variable:
|
||||
continue
|
||||
if sym.name is None or sym.name in seen:
|
||||
continue
|
||||
seen.add(sym.name)
|
||||
yield sym
|
||||
|
||||
|
||||
def build_graph_json(root_val, watched_roots):
|
||||
nodes = {}
|
||||
edges = []
|
||||
visited = set()
|
||||
roots = []
|
||||
skip_addrs = set()
|
||||
used_names = set()
|
||||
|
||||
primary_addr = _walk_primary_root(root_val, nodes, edges, visited)
|
||||
if primary_addr is not None:
|
||||
skip_addrs.add(primary_addr)
|
||||
|
||||
for name, ptr_val in watched_roots:
|
||||
used_names.add(name)
|
||||
try:
|
||||
ptr_int = int(ptr_val)
|
||||
except gdb.error:
|
||||
ptr_int = 0
|
||||
addr = _addr_str(ptr_int)
|
||||
roots.append((name, addr, "watched"))
|
||||
if ptr_int and addr not in skip_addrs:
|
||||
_visit_pointer(ptr_val, ptr_int, nodes, edges, visited)
|
||||
|
||||
try:
|
||||
frame = gdb.selected_frame()
|
||||
except gdb.error:
|
||||
frame = None
|
||||
|
||||
if frame is not None:
|
||||
for sym in _frame_local_symbols(frame):
|
||||
if sym.name in used_names:
|
||||
continue
|
||||
used_names.add(sym.name)
|
||||
try:
|
||||
val = sym.value(frame)
|
||||
except gdb.error:
|
||||
continue
|
||||
_add_named_root(sym.name, val, "local", nodes, edges, visited, roots, skip_addrs)
|
||||
|
||||
for sym in _file_global_symbols(frame):
|
||||
if sym.name in used_names:
|
||||
continue
|
||||
used_names.add(sym.name)
|
||||
try:
|
||||
val = sym.value(frame)
|
||||
except gdb.error:
|
||||
continue
|
||||
_add_named_root(sym.name, val, "global", nodes, edges, visited, roots, skip_addrs)
|
||||
|
||||
nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()]
|
||||
edges_json = [
|
||||
@@ -177,7 +329,7 @@ def build_graph_json(root_val, roots):
|
||||
for (src, dst, label) in edges
|
||||
if dst in nodes
|
||||
]
|
||||
roots_json = [{"name": name, "value": value} for (name, value) in roots]
|
||||
roots_json = [{"name": name, "value": value, "kind": kind} for (name, value, kind) in roots]
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -260,7 +412,10 @@ gdb.pretty_printers.append(_pretty_print_lookup)
|
||||
class DebugGraphFunction(gdb.Function):
|
||||
"""$debug_graph(EXPR[, name, ptr]...) -- render EXPR's pointer-linked
|
||||
structure as Debug Visualizer JSON. Extra name/pointer argument pairs
|
||||
are drawn as external marker pointers into the graph."""
|
||||
are drawn as external "watched" marker pointers into the graph; every
|
||||
other local (and file-scope global) visible at the current PC is also
|
||||
auto-walked and added as a "local"/"global" root -- see module
|
||||
docstring for why."""
|
||||
|
||||
def __init__(self):
|
||||
super(DebugGraphFunction, self).__init__("debug_graph")
|
||||
@@ -271,7 +426,7 @@ class DebugGraphFunction(gdb.Function):
|
||||
|
||||
root = args[0]
|
||||
extra = args[1:]
|
||||
roots = []
|
||||
watched_roots = []
|
||||
i = 0
|
||||
while i + 1 < len(extra):
|
||||
name_val, ptr_val = extra[i], extra[i + 1]
|
||||
@@ -279,14 +434,10 @@ class DebugGraphFunction(gdb.Function):
|
||||
name = name_val.string()
|
||||
except (gdb.error, TypeError):
|
||||
name = str(name_val)
|
||||
try:
|
||||
ptr_int = int(ptr_val)
|
||||
except gdb.error:
|
||||
ptr_int = 0
|
||||
roots.append((name, _addr_str(ptr_int)))
|
||||
watched_roots.append((name, ptr_val))
|
||||
i += 2
|
||||
|
||||
return _as_debugger_string(build_graph_json(root, roots))
|
||||
return _as_debugger_string(build_graph_json(root, watched_roots))
|
||||
|
||||
|
||||
DebugGraphFunction()
|
||||
|
||||
@@ -17,6 +17,20 @@ Writes debug_cpp.json / debug_rust.json into the output directory (default
|
||||
~/lg, matching the style of the extension's own captured dumps): either the
|
||||
resolved data object on success, or a {"PARSE_ERROR": ..., "raw": ...}
|
||||
diagnostic on failure.
|
||||
|
||||
For the C++ build specifically, the breakpoint sits inside
|
||||
`if (const int* top = list.peek_front())`, so `top` is a live local pointing
|
||||
at the head node's `data` field (the node's first member, so its address
|
||||
equals the node's own address, but its *static type* is just `const int*` --
|
||||
gdb has no way to know it's secretly interior to a `Node<int>`). VS Code's
|
||||
"debugVisualizer.debugAdapterConfigurations" expressionTemplate wraps
|
||||
whatever you type in the Watch box as $debug_graph(${expr}), so typing
|
||||
plain `top` evaluates $debug_graph(top) -- not $debug_graph(list, ...): we
|
||||
replay that exact call and verify it still delves into the full list. This
|
||||
relies on gdb/debug_graph.py's auto-discovery of sibling locals (here,
|
||||
`list`, a struct type it *can* walk) merging into the same node graph as
|
||||
`top`'s address, rather than requiring `top` to be passed as an explicit
|
||||
watched root.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
@@ -27,14 +41,14 @@ from pathlib import Path
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run_gdb(gdb_bin, binary, break_file, break_line):
|
||||
def run_gdb(gdb_bin, binary, break_file, break_line, expr="$debug_graph(list)"):
|
||||
commands = [
|
||||
"-gdb-set print elements 0",
|
||||
"-gdb-set print characters 0",
|
||||
"source %s" % (REPO / "gdb" / "debug_graph.py"),
|
||||
"-break-insert %s:%d" % (break_file, break_line),
|
||||
"-exec-run",
|
||||
'-data-evaluate-expression "$debug_graph(list)"',
|
||||
'-data-evaluate-expression "%s"' % expr.replace('"', '\\"'),
|
||||
]
|
||||
proc = subprocess.run(
|
||||
[gdb_bin, "--interpreter=mi", binary],
|
||||
@@ -80,15 +94,55 @@ def parse_like_extension(mi_escaped_value):
|
||||
return {"ok": False, "error": str(e), "raw": result_text}
|
||||
|
||||
|
||||
def dump(label, gdb_bin, binary, break_file, break_line, out_dir):
|
||||
def verify_top_delves_into_list(data):
|
||||
"""Checks that evaluating bare `top` -- i.e. $debug_graph(top), exactly
|
||||
what VS Code's expressionTemplate produces for a Watch box containing
|
||||
just "top" -- still shows the whole 30/20/10 list rather than stopping
|
||||
at a lone scalar leaf. Returns True/False and prints why."""
|
||||
nodes = data.get("nodes", [])
|
||||
edges = data.get("edges", [])
|
||||
roots_by_name = {r.get("name"): r for r in data.get("roots", [])}
|
||||
|
||||
bare_leaves = [
|
||||
n for n in nodes
|
||||
if [f.get("name") for f in n.get("fields", [])] == ["value"]
|
||||
]
|
||||
if bare_leaves:
|
||||
print(" VERIFY FAIL: $debug_graph(top) produced bare scalar leaf node(s) %s -- "
|
||||
"top's address was never upgraded to the full Node it coincides with"
|
||||
% [n.get("id") for n in bare_leaves])
|
||||
return False
|
||||
|
||||
next_edges = [e for e in edges if e.get("label") == "next"]
|
||||
if len(next_edges) < 2:
|
||||
print(" VERIFY FAIL: expected 2 'next' edges chaining the 30/20/10 nodes, found %d "
|
||||
"(edges=%r)" % (len(next_edges), edges))
|
||||
return False
|
||||
|
||||
if "list" not in roots_by_name:
|
||||
print(" VERIFY FAIL: 'list' was not auto-discovered as a sibling local alongside "
|
||||
"top (roots=%r)" % data.get("roots"))
|
||||
return False
|
||||
|
||||
print(" VERIFY OK: $debug_graph(top) delves into the full list -- %d nodes, %d 'next' "
|
||||
"edges, 'list' auto-discovered as a %r root"
|
||||
% (len(nodes), len(next_edges), roots_by_name["list"].get("kind")))
|
||||
return True
|
||||
|
||||
|
||||
def dump(label, gdb_bin, binary, break_file, break_line, out_dir, expr="$debug_graph(list)", verify=None):
|
||||
ok = True
|
||||
try:
|
||||
raw_value = run_gdb(gdb_bin, binary, break_file, break_line)
|
||||
raw_value = run_gdb(gdb_bin, binary, break_file, break_line, expr)
|
||||
except Exception as e:
|
||||
result = {"PARSE_ERROR": "gdb invocation failed", "detail": str(e)}
|
||||
ok = False
|
||||
else:
|
||||
parsed = parse_like_extension(raw_value)
|
||||
if parsed["ok"]:
|
||||
result = parsed["data"]
|
||||
if verify is not None:
|
||||
ok = verify(result)
|
||||
else:
|
||||
result = {
|
||||
"PARSE_ERROR": parsed["error"],
|
||||
@@ -96,18 +150,28 @@ def dump(label, gdb_bin, binary, break_file, break_line, out_dir):
|
||||
"note": "This is what the Debug Visualizer extension would fail to parse, "
|
||||
"triggering its generic byte-walk fallback (constructGraphFromVariablesReference).",
|
||||
}
|
||||
ok = False
|
||||
|
||||
out_path = out_dir / ("debug_%s.json" % label)
|
||||
out_path.write_text(json.dumps(result, indent=4))
|
||||
print("Wrote %s (%s)" % (out_path, "OK" if "PARSE_ERROR" not in result else "PARSE_ERROR"))
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "lg"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dump("cpp", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir)
|
||||
dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 51, out_dir)
|
||||
ok_cpp = dump("cpp", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir)
|
||||
ok_cpp_top = dump(
|
||||
"cpp_top", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir,
|
||||
expr="$debug_graph(top)",
|
||||
verify=verify_top_delves_into_list,
|
||||
)
|
||||
ok_rust = dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 51, out_dir)
|
||||
|
||||
if not (ok_cpp and ok_cpp_top and ok_rust):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
// Data shape produced by gdb/debug_graph.py's $debug_graph(...):
|
||||
// nodes: [{ id: "0x...", fields: [{ name, value, isPointer? }, ...] }]
|
||||
// edges: [{ from: "0x...", to: "0x...", label? }]
|
||||
// roots: [{ name: "top", value: "0x..." }] -- external pointer variables
|
||||
// (e.g. a local `top`/`end` in the caller's stack frame); drawn as a
|
||||
// small pill that tracks above whichever node's id matches its value.
|
||||
// roots: [{ name: "top", value: "0x...", kind? }] -- external pointer
|
||||
// variables (e.g. a local `top`/`end` in the caller's stack frame);
|
||||
// drawn as a small pill that tracks above whichever node's id matches
|
||||
// its value. kind is "local" | "watched" | "global" (missing/unknown
|
||||
// treated as "watched"); the toolbar offers a show/hide toggle per
|
||||
// kind that's actually present in the data.
|
||||
|
||||
module.exports = function (register, lib) {
|
||||
var sj = lib.semanticJson;
|
||||
@@ -41,6 +44,7 @@ module.exports = function (register, lib) {
|
||||
var sRoot = sj.sOpenObject({
|
||||
name: sj.sString(),
|
||||
value: sj.sString(),
|
||||
kind: sj.sOptionalProp(sj.sString(), {}),
|
||||
});
|
||||
|
||||
var sData = sj.sOpenObject({
|
||||
@@ -119,7 +123,10 @@ module.exports = function (register, lib) {
|
||||
}
|
||||
|
||||
// ---- simple O(n^2) force-directed layout, vis-network-style ----
|
||||
function createSimulation(nodeList, edges) {
|
||||
// isVisible(id) lets collapsed-subtree nodes sit out of the simulation
|
||||
// entirely (frozen, not just hidden) so they don't invisibly shove
|
||||
// visible nodes around, and reappear where they last were when expanded.
|
||||
function createSimulation(nodeList, edges, isVisible) {
|
||||
var REPULSION = 55000;
|
||||
var SPRING_LENGTH = NODE_WIDTH + H_GAP;
|
||||
var SPRING_STIFFNESS = 0.02;
|
||||
@@ -143,6 +150,7 @@ module.exports = function (register, lib) {
|
||||
for (var j = i + 1; j < nodeList.length; j++) {
|
||||
var a = nodeList[i];
|
||||
var b = nodeList[j];
|
||||
if (!isVisible(a.id) || !isVisible(b.id)) continue;
|
||||
var dx = a.x - b.x;
|
||||
var dy = a.y - b.y;
|
||||
var distSq = dx * dx + dy * dy || 0.01;
|
||||
@@ -158,6 +166,7 @@ module.exports = function (register, lib) {
|
||||
}
|
||||
|
||||
edges.forEach(function (e) {
|
||||
if (!isVisible(e.from) || !isVisible(e.to)) return;
|
||||
var a = byId[e.from];
|
||||
var b = byId[e.to];
|
||||
if (!a || !b) return;
|
||||
@@ -176,7 +185,7 @@ module.exports = function (register, lib) {
|
||||
|
||||
var kinetic = 0;
|
||||
nodeList.forEach(function (n) {
|
||||
if (n.fixed) {
|
||||
if (n.fixed || !isVisible(n.id)) {
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
return;
|
||||
@@ -195,6 +204,21 @@ module.exports = function (register, lib) {
|
||||
return { nodes: nodeList, byId: byId, step: step };
|
||||
}
|
||||
|
||||
// ---- collapse/expand: purely client-side, over the already-fetched graph ----
|
||||
function subtreeSize(startChildren, childrenOf) {
|
||||
var seen = {};
|
||||
var stack = startChildren.slice();
|
||||
while (stack.length) {
|
||||
var id = stack.pop();
|
||||
if (seen[id]) continue;
|
||||
seen[id] = true;
|
||||
(childrenOf[id] || []).forEach(function (c) {
|
||||
if (!seen[c]) stack.push(c);
|
||||
});
|
||||
}
|
||||
return Object.keys(seen).length;
|
||||
}
|
||||
|
||||
function el(tag, style, attrs) {
|
||||
var e = document.createElement(tag);
|
||||
if (style) Object.assign(e.style, style);
|
||||
@@ -222,6 +246,34 @@ module.exports = function (register, lib) {
|
||||
return b;
|
||||
}
|
||||
|
||||
var ROOT_KINDS = ["local", "watched", "global"];
|
||||
var ROOT_KIND_LABELS = { local: "Locals", watched: "Watched", global: "Globals" };
|
||||
|
||||
function rootKind(r) {
|
||||
return ROOT_KINDS.indexOf(r.kind) !== -1 ? r.kind : "watched";
|
||||
}
|
||||
|
||||
function kindToggle(labelText, colors) {
|
||||
var wrap = el("label", {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
font: "12px var(--vscode-editor-font-family, monospace)",
|
||||
padding: "3px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid " + colors.border,
|
||||
background: colors.headerBg,
|
||||
color: colors.headerFg,
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
});
|
||||
var input = el("input", { cursor: "pointer" }, { type: "checkbox" });
|
||||
input.checked = true;
|
||||
wrap.appendChild(input);
|
||||
wrap.appendChild(document.createTextNode(labelText));
|
||||
return { wrap: wrap, input: input };
|
||||
}
|
||||
|
||||
function render(data, target, theme) {
|
||||
target.innerHTML = "";
|
||||
Object.assign(target.style, {
|
||||
@@ -278,7 +330,69 @@ module.exports = function (register, lib) {
|
||||
byId[n.id] = n;
|
||||
});
|
||||
|
||||
var sim = createSimulation(nodeList, data.edges);
|
||||
var childrenOf = {};
|
||||
var parentsOf = {};
|
||||
data.edges.forEach(function (e) {
|
||||
(childrenOf[e.from] = childrenOf[e.from] || []).push(e.to);
|
||||
(parentsOf[e.to] = parentsOf[e.to] || []).push(e.from);
|
||||
});
|
||||
|
||||
var collapsed = {}; // nodeId -> bool, user-toggled
|
||||
var visible = {}; // nodeId -> bool, recomputed by computeVisible()
|
||||
|
||||
function computeVisible() {
|
||||
var newVisible = {};
|
||||
var hasIncoming = {};
|
||||
data.edges.forEach(function (e) {
|
||||
hasIncoming[e.to] = true;
|
||||
});
|
||||
var roots = nodeIds.filter(function (id) {
|
||||
return !hasIncoming[id];
|
||||
});
|
||||
if (roots.length === 0 && nodeIds.length > 0) roots = [nodeIds[0]];
|
||||
|
||||
var queue = roots.slice();
|
||||
roots.forEach(function (id) {
|
||||
newVisible[id] = true;
|
||||
});
|
||||
while (queue.length) {
|
||||
var id = queue.shift();
|
||||
if (collapsed[id]) continue; // don't expand past a collapsed node
|
||||
(childrenOf[id] || []).forEach(function (childId) {
|
||||
if (!newVisible[childId]) {
|
||||
newVisible[childId] = true;
|
||||
queue.push(childId);
|
||||
}
|
||||
});
|
||||
}
|
||||
visible = newVisible;
|
||||
}
|
||||
computeVisible();
|
||||
|
||||
// A local/global/watched pointer's target can end up hidden inside a
|
||||
// collapsed subtree; rather than just hiding its arrow, walk up the
|
||||
// edge graph to whichever visible ancestor is currently standing in
|
||||
// for that hidden subtree, and point the arrow there instead -- so
|
||||
// expanding that ancestor is what makes the arrow "arrive" at its
|
||||
// real target.
|
||||
function nearestVisibleTarget(id) {
|
||||
var seen = {};
|
||||
var queue = [id];
|
||||
while (queue.length) {
|
||||
var cur = queue.shift();
|
||||
if (visible[cur]) return cur;
|
||||
if (seen[cur]) continue;
|
||||
seen[cur] = true;
|
||||
(parentsOf[cur] || []).forEach(function (p) {
|
||||
if (!seen[p]) queue.push(p);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var sim = createSimulation(nodeList, data.edges, function (id) {
|
||||
return !!visible[id];
|
||||
});
|
||||
var layoutMode = "auto"; // "auto" (physics) | "layered" (static chain)
|
||||
var running = true;
|
||||
|
||||
@@ -305,18 +419,28 @@ module.exports = function (register, lib) {
|
||||
var svg = document.createElementNS(svgNs, "svg");
|
||||
Object.assign(svg.style, { position: "absolute", left: "0", top: "0", overflow: "visible", pointerEvents: "none" });
|
||||
var defs = document.createElementNS(svgNs, "defs");
|
||||
var marker = document.createElementNS(svgNs, "marker");
|
||||
marker.setAttribute("id", "dv-arrow");
|
||||
marker.setAttribute("markerWidth", "8");
|
||||
marker.setAttribute("markerHeight", "8");
|
||||
marker.setAttribute("refX", "7");
|
||||
marker.setAttribute("refY", "4");
|
||||
marker.setAttribute("orient", "auto");
|
||||
var arrowPath = document.createElementNS(svgNs, "path");
|
||||
arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z");
|
||||
arrowPath.setAttribute("fill", colors.edge);
|
||||
marker.appendChild(arrowPath);
|
||||
defs.appendChild(marker);
|
||||
|
||||
function addArrowMarker(id, fill) {
|
||||
var marker = document.createElementNS(svgNs, "marker");
|
||||
marker.setAttribute("id", id);
|
||||
marker.setAttribute("markerWidth", "8");
|
||||
marker.setAttribute("markerHeight", "8");
|
||||
marker.setAttribute("refX", "7");
|
||||
marker.setAttribute("refY", "4");
|
||||
marker.setAttribute("orient", "auto");
|
||||
var arrowPath = document.createElementNS(svgNs, "path");
|
||||
arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z");
|
||||
arrowPath.setAttribute("fill", fill);
|
||||
marker.appendChild(arrowPath);
|
||||
defs.appendChild(marker);
|
||||
}
|
||||
// Struct-to-struct pointer fields (e.g. `next`) use the neutral edge
|
||||
// color; local/watched/global variable pointers use the same accent
|
||||
// color as pointer values in the field tables, so a glance at an
|
||||
// arrow's color says whether it's "inside the data structure" or
|
||||
// "a variable pointing into it".
|
||||
addArrowMarker("dv-arrow", colors.edge);
|
||||
addArrowMarker("dv-arrow-root", colors.pointerValue);
|
||||
svg.appendChild(defs);
|
||||
world.appendChild(svg);
|
||||
|
||||
@@ -341,6 +465,7 @@ module.exports = function (register, lib) {
|
||||
});
|
||||
|
||||
var boxes = {};
|
||||
var toggles = {};
|
||||
data.nodes.forEach(function (n) {
|
||||
var box = el("div", {
|
||||
position: "absolute",
|
||||
@@ -361,11 +486,45 @@ module.exports = function (register, lib) {
|
||||
color: colors.headerFg,
|
||||
fontWeight: "bold",
|
||||
borderBottom: "1px solid " + colors.border,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "6px",
|
||||
});
|
||||
var headerLabel = el("span", {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
minWidth: "0",
|
||||
});
|
||||
header.textContent = n.id;
|
||||
headerLabel.textContent = n.id;
|
||||
header.appendChild(headerLabel);
|
||||
|
||||
var kids = childrenOf[n.id];
|
||||
if (kids && kids.length) {
|
||||
var toggle = el("span", {
|
||||
flexShrink: "0",
|
||||
cursor: "pointer",
|
||||
border: "1px solid " + colors.border,
|
||||
borderRadius: "3px",
|
||||
padding: "0 5px",
|
||||
fontSize: "11px",
|
||||
lineHeight: "14px",
|
||||
userSelect: "none",
|
||||
});
|
||||
toggle.addEventListener("mousedown", function (e) {
|
||||
e.stopPropagation(); // don't start a box-drag
|
||||
});
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
collapsed[n.id] = !collapsed[n.id];
|
||||
computeVisible();
|
||||
redraw();
|
||||
if (layoutMode === "auto") stabilize(200);
|
||||
});
|
||||
header.appendChild(toggle);
|
||||
toggles[n.id] = toggle;
|
||||
}
|
||||
box.appendChild(header);
|
||||
|
||||
var body = el("div", { maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px", overflowY: "auto" });
|
||||
@@ -427,13 +586,12 @@ module.exports = function (register, lib) {
|
||||
if (matched) {
|
||||
line = document.createElementNS(svgNs, "path");
|
||||
line.setAttribute("fill", "none");
|
||||
line.setAttribute("stroke", colors.edge);
|
||||
line.setAttribute("stroke", colors.pointerValue);
|
||||
line.setAttribute("stroke-width", "1.5");
|
||||
line.setAttribute("stroke-dasharray", "3,3");
|
||||
line.setAttribute("marker-end", "url(#dv-arrow)");
|
||||
line.setAttribute("marker-end", "url(#dv-arrow-root)");
|
||||
svg.appendChild(line);
|
||||
}
|
||||
return { root: r, badge: badge, line: line, matched: matched, x: 0 };
|
||||
return { root: r, kind: rootKind(r), badge: badge, line: line, matched: matched, x: 0 };
|
||||
});
|
||||
var nextUnmatchedX = 0;
|
||||
var badgesPerTarget = {};
|
||||
@@ -452,14 +610,51 @@ module.exports = function (register, lib) {
|
||||
});
|
||||
var autoBtn = button("Auto-arrange", colors);
|
||||
var layeredBtn = button("Layered", colors);
|
||||
var expandAllBtn = button("Expand all", colors);
|
||||
var fitBtn = button("Fit", colors);
|
||||
var zoomOutBtn = button("−", colors);
|
||||
var zoomInBtn = button("+", colors);
|
||||
[autoBtn, layeredBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) {
|
||||
[autoBtn, layeredBtn, expandAllBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) {
|
||||
toolbar.appendChild(b);
|
||||
});
|
||||
target.appendChild(toolbar);
|
||||
|
||||
// ---- root-kind toggles (Locals / Watched / Globals) ----
|
||||
var rootKindEnabled = { local: true, watched: true, global: true };
|
||||
var kindsPresent = {};
|
||||
roots.forEach(function (r) {
|
||||
kindsPresent[rootKind(r)] = true;
|
||||
});
|
||||
var presentKinds = ROOT_KINDS.filter(function (k) {
|
||||
return kindsPresent[k];
|
||||
});
|
||||
if (presentKinds.length > 0) {
|
||||
var kindToolbar = el("div", {
|
||||
position: "absolute",
|
||||
left: "8px",
|
||||
top: "8px",
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
zIndex: "10",
|
||||
});
|
||||
presentKinds.forEach(function (kind) {
|
||||
var cb = kindToggle(ROOT_KIND_LABELS[kind], colors);
|
||||
cb.input.addEventListener("change", function () {
|
||||
rootKindEnabled[kind] = cb.input.checked;
|
||||
redraw();
|
||||
});
|
||||
kindToolbar.appendChild(cb.wrap);
|
||||
});
|
||||
target.appendChild(kindToolbar);
|
||||
}
|
||||
|
||||
expandAllBtn.addEventListener("click", function () {
|
||||
collapsed = {};
|
||||
computeVisible();
|
||||
redraw();
|
||||
if (layoutMode === "auto") stabilize(200);
|
||||
});
|
||||
|
||||
function highlightModeButtons() {
|
||||
autoBtn.style.background = layoutMode === "auto" ? colors.pointerValue : colors.headerBg;
|
||||
layeredBtn.style.background = layoutMode === "layered" ? colors.pointerValue : colors.headerBg;
|
||||
@@ -482,9 +677,12 @@ module.exports = function (register, lib) {
|
||||
}
|
||||
|
||||
function fitToView() {
|
||||
if (nodeList.length === 0) return;
|
||||
var visibleNodes = nodeList.filter(function (n) {
|
||||
return visible[n.id];
|
||||
});
|
||||
if (visibleNodes.length === 0) return;
|
||||
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
nodeList.forEach(function (n) {
|
||||
visibleNodes.forEach(function (n) {
|
||||
minX = Math.min(minX, n.x - NODE_WIDTH / 2);
|
||||
maxX = Math.max(maxX, n.x + NODE_WIDTH / 2);
|
||||
minY = Math.min(minY, n.y - n.height / 2 - (roots.length ? ROOT_BADGE_HEIGHT + ROOT_GAP : 0));
|
||||
@@ -600,14 +798,31 @@ module.exports = function (register, lib) {
|
||||
function redraw() {
|
||||
nodeList.forEach(function (n) {
|
||||
var box = boxes[n.id];
|
||||
box.style.left = n.x - NODE_WIDTH / 2 + "px";
|
||||
box.style.top = n.y - n.height / 2 + "px";
|
||||
var isVis = !!visible[n.id];
|
||||
box.style.display = isVis ? "" : "none";
|
||||
if (isVis) {
|
||||
box.style.left = n.x - NODE_WIDTH / 2 + "px";
|
||||
box.style.top = n.y - n.height / 2 + "px";
|
||||
}
|
||||
var toggle = toggles[n.id];
|
||||
if (toggle) {
|
||||
if (collapsed[n.id]) {
|
||||
toggle.textContent = "+" + subtreeSize(childrenOf[n.id] || [], childrenOf);
|
||||
toggle.title = "Expand";
|
||||
} else {
|
||||
toggle.textContent = "−";
|
||||
toggle.title = "Collapse";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
edgePaths.forEach(function (ep) {
|
||||
var a = byId[ep.edge.from];
|
||||
var b = byId[ep.edge.to];
|
||||
if (!a || !b) return;
|
||||
var show = !!(a && b && visible[ep.edge.from] && visible[ep.edge.to]);
|
||||
ep.line.style.display = show ? "" : "none";
|
||||
if (ep.text) ep.text.style.display = show ? "" : "none";
|
||||
if (!show) return;
|
||||
var x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
|
||||
var midX = (x1 + x2) / 2;
|
||||
var midY = (y1 + y2) / 2;
|
||||
@@ -624,10 +839,22 @@ module.exports = function (register, lib) {
|
||||
nextUnmatchedX = 0;
|
||||
badgesPerTarget = {};
|
||||
rootBadges.forEach(function (rb) {
|
||||
if (!rootKindEnabled[rb.kind]) {
|
||||
rb.badge.style.display = "none";
|
||||
if (rb.line) rb.line.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (rb.matched) {
|
||||
var target = byId[rb.root.value];
|
||||
var stackIndex = badgesPerTarget[rb.root.value] || 0;
|
||||
badgesPerTarget[rb.root.value] = stackIndex + 1;
|
||||
var targetId = visible[rb.root.value] ? rb.root.value : nearestVisibleTarget(rb.root.value);
|
||||
rb.badge.style.display = targetId ? "" : "none";
|
||||
if (rb.line) rb.line.style.display = targetId ? "" : "none";
|
||||
if (!targetId) return;
|
||||
var redirected = targetId !== rb.root.value;
|
||||
rb.badge.title = rb.root.name + " = " + rb.root.value +
|
||||
(redirected ? " (inside collapsed " + targetId + ")" : "");
|
||||
var target = byId[targetId];
|
||||
var stackIndex = badgesPerTarget[targetId] || 0;
|
||||
badgesPerTarget[targetId] = stackIndex + 1;
|
||||
rb.x = target.x + stackIndex * 90;
|
||||
var by = target.y - target.height / 2 - ROOT_GAP;
|
||||
rb.badge.style.left = rb.x - NODE_WIDTH / 4 + "px";
|
||||
@@ -639,6 +866,7 @@ module.exports = function (register, lib) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
rb.badge.style.display = "";
|
||||
rb.x = nextUnmatchedX;
|
||||
nextUnmatchedX += NODE_WIDTH / 2 + 20;
|
||||
rb.badge.style.left = rb.x + "px";
|
||||
@@ -683,6 +911,27 @@ module.exports = function (register, lib) {
|
||||
redraw();
|
||||
fitToView();
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// The panel/webview may not have its final layout size yet at the
|
||||
// instant render() runs (fresh panel, window just reloaded, tab just
|
||||
// became visible) -- fitToView() above would then measure a stale or
|
||||
// fallback 800x600 target size. Refit once more the first time the
|
||||
// target's real size shows up differently, then stop watching so we
|
||||
// don't fight the user's own manual pan/zoom on later resizes.
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
var lastW = target.clientWidth;
|
||||
var lastH = target.clientHeight;
|
||||
var ro = new ResizeObserver(function () {
|
||||
if (!document.body.contains(target)) {
|
||||
ro.disconnect(); // this render() call was superseded
|
||||
return;
|
||||
}
|
||||
if (target.clientWidth === lastW && target.clientHeight === lastH) return;
|
||||
ro.disconnect();
|
||||
fitToView();
|
||||
});
|
||||
ro.observe(target);
|
||||
}
|
||||
}
|
||||
|
||||
register({
|
||||
|
||||
Reference in New Issue
Block a user