diff --git a/debuggers/gdb_debug_graph.py b/debuggers/gdb_debug_graph.py index 6aca852..f39b7a6 100644 --- a/debuggers/gdb_debug_graph.py +++ b/debuggers/gdb_debug_graph.py @@ -158,13 +158,19 @@ def _append_field(fields_out, name, value, is_pointer=False, type_hint=None): def _process_member(label, member_val, node_id, fields_out, edges, nodes, visited, type_hint=None): member_type = member_val.type.strip_typedefs() + # Every field gets a type hint, not just ones a wrapper (pretty-printer + # or Rust-enum-variant) has already labeled -- a plain `int`/`char *` + # member is just as worth showing as one that came out of an unwrapped + # std::optional. The caller's type_hint (a wrapper name) still wins when + # present, since that's more informative than the leaf's own raw type. + own_type_hint = type_hint or _type_name(member_type) if member_type.code == gdb.TYPE_CODE_PTR: try: ptr_int = int(member_val) except gdb.error: ptr_int = 0 - _append_field(fields_out, label, _addr_str(ptr_int), is_pointer=True, type_hint=type_hint) + _append_field(fields_out, label, _addr_str(ptr_int), is_pointer=True, type_hint=own_type_hint) if ptr_int: edges.append((node_id, _addr_str(ptr_int), label)) _visit_pointer(member_val, ptr_int, nodes, edges, visited) @@ -199,7 +205,7 @@ def _process_member(label, member_val, node_id, fields_out, edges, nodes, visite _append_field(fields_out, label, _format_scalar(member_val), type_hint=wrapper_name) return - _append_field(fields_out, label, _format_scalar(member_val), type_hint=type_hint) + _append_field(fields_out, label, _format_scalar(member_val), type_hint=own_type_hint) def _describe_struct(val, node_id, nodes, edges, visited): @@ -217,7 +223,7 @@ def _describe_struct(val, node_id, nodes, edges, visited): else: for leaf_label, leaf_val in leaves: _process_member(leaf_label, leaf_val, node_id, fields_out, edges, nodes, visited, wrapper_name) - nodes[node_id] = fields_out + nodes[node_id] = {"fields": fields_out, "type": wrapper_name} return t = val.type.strip_typedefs() @@ -230,7 +236,7 @@ def _describe_struct(val, node_id, nodes, edges, visited): continue _process_member(f.name, member_val, node_id, fields_out, edges, nodes, visited) - nodes[node_id] = fields_out + nodes[node_id] = {"fields": fields_out, "type": _type_name(t)} def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited): @@ -269,14 +275,16 @@ def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited): text = text[:500] + "…" except (gdb.error, UnicodeDecodeError): text = _format_scalar(ptr_val.dereference()) - nodes[node_id] = [{"name": "value", "value": text, "isPointer": False}] + leaf_fields = [] + _append_field(leaf_fields, "value", text, type_hint=_type_name(pointee_type)) + nodes[node_id] = {"fields": leaf_fields, "type": _type_name(ptr_val.type.strip_typedefs())} else: 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} - ] + leaf_fields = [] + _append_field(leaf_fields, "value", _format_scalar(pointee), type_hint=_type_name(pointee_type)) + nodes[node_id] = {"fields": leaf_fields, "type": _type_name(ptr_val.type.strip_typedefs())} def _walk_primary_root(root_val, nodes, edges, visited): @@ -297,9 +305,9 @@ def _walk_primary_root(root_val, nodes, edges, visited): _describe_struct(root_val, node_id, nodes, edges, visited) return node_id else: - nodes["root"] = [ - {"name": "value", "value": _format_scalar(root_val), "isPointer": False} - ] + leaf_fields = [] + _append_field(leaf_fields, "value", _format_scalar(root_val), type_hint=_type_name(t)) + nodes["root"] = {"fields": leaf_fields, "type": _type_name(t)} return None @@ -424,7 +432,12 @@ def build_graph_json(root_val, watched_roots): 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()] + nodes_json = [ + {"id": nid, "fields": node["fields"], "type": node["type"]} + if node.get("type") + else {"id": nid, "fields": node["fields"]} + for nid, node in nodes.items() + ] edges_json = [ {"from": src, "to": dst, "label": label} for (src, dst, label) in edges diff --git a/debuggers/py_debug_graph.py b/debuggers/py_debug_graph.py index 7e40a89..48ab6aa 100644 --- a/debuggers/py_debug_graph.py +++ b/debuggers/py_debug_graph.py @@ -28,10 +28,28 @@ This mirrors debuggers/gdb_debug_graph.py's auto-discovery: it's what lets show the whole list, because `lst` (a sibling local) gets auto-walked too. """ +import builtins import json import sys import types +# On gdb (C++/Rust), typing bare `root` into the Watch box happens to work +# as a throwaway "just show me the locals/globals" expression -- gdb +# resolves it to a real (if coincidental) always-null `void*` symbol from +# libc.so.6, so $debug_graph(root) contributes no primary node of its own +# but still triggers full auto-discovery of the frame's locals/globals. +# debugpy has no such accident to lean on -- evaluating a name that isn't +# actually bound anywhere raises NameError before our code ever runs -- so +# this deliberately recreates the same "always-resolvable, always-None" +# spelling as a builtin. This runs as an import-time side effect, but that's +# safe to rely on even the *first* time someone types `root`: evaluating a +# call expression like `__import__('py_debug_graph').debug_graph(root)` +# resolves the callable (which imports this module and runs this line) +# before it evaluates the `root` argument, so the patch is always in place +# by the time `root` itself gets looked up. +if not hasattr(builtins, "root"): + builtins.root = None + def _addr_str(obj_id): return hex(obj_id) @@ -67,9 +85,15 @@ def _append_field(fields_out, name, value, is_pointer=False, type_hint=None): fields_out.append(field) -def _describe_struct(val, node_id, nodes, edges, visited): +def _fields_from_items(items, node_id, nodes, edges, visited): + """Builds one field row per (name, value) pair, recursing into + describable values as edges exactly like a struct's own members -- + shared between _describe_struct's vars(obj).items() and the + globals()-as-root special case's plain dict.items() below, so a + "container of named values" only has one field-building rule + regardless of whether the container is an object or a dict.""" fields_out = [] - for name, member_val in vars(val).items(): + for name, member_val in items: if _is_describable(member_val): member_id = id(member_val) _append_field(fields_out, name, _addr_str(member_id), is_pointer=True, type_hint=_type_name(member_val)) @@ -78,8 +102,16 @@ def _describe_struct(val, node_id, nodes, edges, visited): visited.add(member_id) _describe_struct(member_val, _addr_str(member_id), nodes, edges, visited) else: - _append_field(fields_out, name, _format_scalar(member_val)) - nodes[node_id] = fields_out + # Every field gets a type hint, not just the describable ones -- + # a plain int/str/None member is just as worth labeling as a + # pointer-like one. + _append_field(fields_out, name, _format_scalar(member_val), type_hint=_type_name(member_val)) + return fields_out + + +def _describe_struct(val, node_id, nodes, edges, visited): + fields_out = _fields_from_items(vars(val).items(), node_id, nodes, edges, visited) + nodes[node_id] = {"fields": fields_out, "type": _type_name(val)} def _walk_primary_root(root_val, nodes, edges, visited): @@ -89,7 +121,13 @@ def _walk_primary_root(root_val, nodes, edges, visited): _describe_struct(root_val, node_id, nodes, edges, visited) return node_id if root_val is not None: - nodes["root"] = [{"name": "value", "value": _format_scalar(root_val), "isPointer": False}] + nodes["root"] = { + "fields": [{ + "name": "value", "value": _format_scalar(root_val), "isPointer": False, + "typeHint": _type_name(root_val), + }], + "type": _type_name(root_val), + } return None @@ -135,7 +173,26 @@ def build_graph_json(root_val, watched_roots, frame): skip_ids = set() used_names = set() - primary_id = _walk_primary_root(root_val, nodes, edges, visited) + if frame is not None and root_val is globals: + # Bare `globals` (no parens, e.g. typing "globals" into the Watch + # box) evaluates to the *builtin function itself*, not its result -- + # walked normally that's just an opaque "" + # scalar. Calling it here would also be wrong: globals() returns + # whatever frame *calls* it, which would be this module's own + # globals, not the paused frame's. Substituting the frame we + # already have (passed in from debug_graph()'s sys._getframe(1)) + # is what actually shows the debuggee's global variables. Filtered + # through the same _skip_global used for auto-discovered globals + # below, so this node shows the same clean set, not every dunder, + # module and class in the namespace. + primary_id = "globals()" + fields_out = _fields_from_items( + [(k, v) for k, v in frame.f_globals.items() if not _skip_global(k, v)], + primary_id, nodes, edges, visited, + ) + nodes[primary_id] = {"fields": fields_out, "type": "dict"} + else: + primary_id = _walk_primary_root(root_val, nodes, edges, visited) if primary_id is not None: skip_ids.add(primary_id) @@ -166,7 +223,12 @@ def build_graph_json(root_val, watched_roots, frame): used_names.add(name) _add_named_root(name, val, "global", nodes, edges, visited, roots, skip_ids) - nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()] + nodes_json = [ + {"id": nid, "fields": node["fields"], "type": node["type"]} + if node.get("type") + else {"id": nid, "fields": node["fields"]} + for nid, node in nodes.items() + ] edges_json = [ {"from": src, "to": dst, "label": label} for (src, dst, label) in edges diff --git a/vis-plugins/node-table-visualizer.js b/vis-plugins/node-table-visualizer.js index a8295b5..28a511c 100644 --- a/vis-plugins/node-table-visualizer.js +++ b/vis-plugins/node-table-visualizer.js @@ -34,6 +34,7 @@ module.exports = function (register, lib) { var sNode = sj.sOpenObject({ id: sj.sString(), fields: sj.sArrayOf(sField), + type: sj.sOptionalProp(sj.sString(), {}), }); var sEdge = sj.sOpenObject({ @@ -572,7 +573,18 @@ module.exports = function (register, lib) { textOverflow: "ellipsis", minWidth: "0", }); - headerLabel.textContent = n.id; + headerLabel.appendChild(document.createTextNode(n.id)); + if (n.type) { + var nodeTypeSpan = el("span", { + color: colors.fieldType, + fontSize: "10px", + fontWeight: "normal", + marginLeft: "6px", + }); + nodeTypeSpan.textContent = n.type; + headerLabel.appendChild(nodeTypeSpan); + header.title = n.id + " : " + n.type; + } header.appendChild(headerLabel); var kids = childrenOf[n.id]; @@ -752,7 +764,6 @@ module.exports = function (register, lib) { return { root: r, kind: rootKind(r), badge: box, line: null, matched: false, height: height, x: 0, y: 0, pinned: false }; }); - var nextUnmatchedX = 0; var badgesPerTarget = {}; viewport.appendChild(world); @@ -1035,7 +1046,26 @@ module.exports = function (register, lib) { } }); - nextUnmatchedX = 0; + // Unmatched-root cards (plain scalar locals/globals/watches) that + // aren't pinned by a drag auto-flow into a compact roughly-square + // cluster -- sqrt(count) columns -- instead of one ever-widening + // row, so a handful of locals reads as a group near the top + // instead of a line stretching off to the side. The cluster's + // bottom row sits just above the node cloud (same non-overlap + // tracking as before); earlier rows stack upward from there. + var unmatchedFlow = rootBadges.filter(function (rb) { + return !rb.matched && !rb.pinned && rootKindEnabled[rb.kind]; + }); + var flowCols = Math.max(1, Math.ceil(Math.sqrt(unmatchedFlow.length))); + var flowRows = Math.ceil(unmatchedFlow.length / flowCols); + var flowRowHeight = 0; + unmatchedFlow.forEach(function (rb) { + flowRowHeight = Math.max(flowRowHeight, rb.height); + }); + var flowRowGap = 10; + var flowShelfBottom = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP; + var flowIndex = 0; + badgesPerTarget = {}; rootBadges.forEach(function (rb) { if (!rootKindEnabled[rb.kind]) { @@ -1067,21 +1097,18 @@ module.exports = function (register, lib) { } else { rb.badge.style.display = ""; if (!rb.pinned) { - rb.x = nextUnmatchedX; - // Sit just above whatever's currently the top of the - // visible node cloud, rather than a fixed world y -- - // so this shelf of scalar-local cards tracks the - // graph instead of drifting into overlapping it once - // "auto" physics (or gravity re-centering) moves - // nodes around. - var shelfTop = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP; - rb.y = shelfTop - rb.height; - nextUnmatchedX += NODE_WIDTH + 20; + var row = Math.floor(flowIndex / flowCols); // 0 = top row + var col = flowIndex % flowCols; + var rowFromBottom = flowRows - 1 - row; // 0 = row nearest the graph + var rowBottom = flowShelfBottom - rowFromBottom * (flowRowHeight + flowRowGap); + rb.x = col * (NODE_WIDTH + 20); + rb.y = rowBottom - rb.height; + flowIndex++; } // A pinned (user-dragged) card keeps whatever rb.x/rb.y // the drag left it at, and drops out of the auto-flow - // row entirely -- the remaining unpinned cards close the - // gap instead of leaving a hole where it used to sit. + // cluster entirely -- the remaining unpinned cards + // re-flow to close the gap instead of leaving a hole. rb.badge.style.left = rb.x + "px"; rb.badge.style.top = rb.y + "px"; }