Refactor MIR graph rendering to use a unified traversal via GraphBuilder#132
Refactor MIR graph rendering to use a unified traversal via GraphBuilder#1320xh4ty wants to merge 10 commits intoruntimeverification:masterfrom
Conversation
|
LGTM! Would you remove the previous implementation before merging? Or just in another PR? @dkcumming what do you think? |
|
Thanks! I’ll remove the legacy D2 implementation in this PR itself. |
| /// Format-agnostic MIR graph traversal. | ||
| /// Owns traversal order and graph semantics, delegates rendering to `GraphBuilder`. | ||
| pub fn render_graph<B: GraphBuilder>(smir: &SmirJson, mut builder: B) -> B::Output { | ||
| let ctx = GraphContext::from_smir(smir); |
There was a problem hiding this comment.
pub fn to_d2_file_new(&self) -> String {
let ctx = GraphContext::from_smir(self);
render_graph(self, D2Builder::new(&ctx))
}It seems not efficient. It builds GraphContext twice.
There was a problem hiding this comment.
Good catch. I’ll move GraphContext construction out of render_graph so it is built only once and passed through.
| ) { | ||
| let fn_id = short_name(name); | ||
| let label = name_lines(name); | ||
| let is_local = true; |
There was a problem hiding this comment.
Should be body.is_some() ?
There was a problem hiding this comment.
Yes, that should be body.is_some(). I’ll fix that.
| pub trait GraphBuilder { | ||
| type Output; | ||
|
|
||
| fn begin_graph(&mut self, name: &str); |
There was a problem hiding this comment.
I'd like to have some description here.
There was a problem hiding this comment.
Got it. I’ll expand the doc comment.
dkcumming
left a comment
There was a problem hiding this comment.
@0xh4ty this is great work! I love what you have done so far! I think it's time to go all in and get the old stuff out and hook up the full implementation of d2 and dot (okay if that is another PR if necessary). I think isolating the shared logic out in the traverse module is a great improvement. Do you think you are fine to do the full conversion? Also I think Jianhong had some great feedback too
There was a problem hiding this comment.
Good work @0xh4ty . @Stevengre gave some excellent feedback, and I added a few notes on the trait boundary design.
The traversal extraction is the right idea, and the hard part (generic traversal order, clean separation of items/blocks/edges) is already solid. The main thing to tighten up is where the format-agnostic boundary actually sits: a couple of the trait methods (block, block_edge) still carry D2-specific assumptions, which means the next renderer would inherit those assumptions rather than being free to do its own thing.
The comments above walk through the specifics. Once those methods pass structured data instead of raw MIR types and format-specific conventions, this will be a clean foundation for DOT, Mermaid, and whatever else comes next.
I also want to see more comments, as I want this project to be usable via cargo doc --open, one more sign of a mature Cargo library.
Finally, my vote is to rip out the old, and use this as the way forward. Though, you will have to make sure the operational parts remain consistent so we can generate graphs :)
| ) { | ||
| let fn_id = short_name(name); | ||
| let label = name_lines(name); | ||
| let is_local = true; |
There was a problem hiding this comment.
this true assignment is unconditional; is it needed?
There was a problem hiding this comment.
This will be removed.
| self.buf.push_str(&format!(" bb{}: \"{}\"\n", idx, label)); | ||
| } | ||
|
|
||
| fn block_edge(&mut self, _fn_id: &str, from: usize, to: usize, _label: Option<&str>) { |
There was a problem hiding this comment.
Same issue here: block_edge bakes D2's arrow syntax into the implementation, and the Option<&str> label parameter (which it looks like D2Builder ignores?) is a tell that the signature was shaped around what D2 needs today, with a speculative parameter tacked on for a future renderer.
This is the second method where format-specific concerns leak through the trait boundary, so it's worth stepping back and asking: what's the trait actually buying us?
A well-drawn trait boundary gives you two things: the code above it can change without touching the code below (new traversal logic doesn't require touching every renderer), and the code below can change without touching the code above (new output format, same traversal). But that only works if the boundary is at the right level of abstraction. When trait methods receive raw MIR types or carry format-specific assumptions, every new renderer has to understand the same internals, and the trait becomes ceremony rather than insulation.
The fix is the same as for block: have the traversal own the "what" (which blocks connect, what the edge represents) and pass structured, pre-rendered data. Let each renderer own the "how" (syntax, escaping, layout). That way the trait boundary actually earns its keep.
There was a problem hiding this comment.
Thanks, that makes sense. The label parameter was speculative and is not used by the current renderers, so I will remove it to avoid leaking format assumptions into the trait. I will keep traversal responsible for edge semantics and pass structured, pre-rendered data to builders, while the renderers handle syntax and layout.
|
Hey @0xh4ty , I had the opportunity to think this through some more and, well, here you go :) First: the The question I want to think through is: what happens when we go to add DOT, Mermaid, Markdown, or other formats on top of this? Right now, each builder holds a The thing is, the current So here's a concrete idea: instead of the driver pushing raw stable_mir types to the builder via a sequence of granular calls ( /// A single basic block with pre-rendered content and structural edges.
struct RenderedBlock<'a> {
idx: usize,
stmts: Vec<String>, // pre-rendered via GraphContext
terminator: String, // pre-rendered
raw_terminator: &'a Terminator, // escape hatch for structural inspection
cfg_edges: Vec<(usize, Option<String>)>, // (target_block, optional label)
}
/// A fully analyzed function, ready for format-specific rendering.
struct RenderedFunction<'a> {
id: String, // stable ID (short_name + body hash)
display_name: String, // name_lines() output for labels
is_local: bool,
locals: Vec<(usize, String)>, // (index, type_with_layout string)
blocks: Vec<RenderedBlock<'a>>,
call_edges: Vec<CallEdge>, // resolved callee IDs and pre-rendered args
}
struct CallEdge {
block_idx: usize,
callee_id: String,
callee_name: String,
rendered_args: String,
}The traversal driver does all the trait GraphBuilder {
type Output;
fn begin_graph(&mut self, name: &str);
fn alloc_legend(&mut self, lines: &[String]);
fn type_legend(&mut self, lines: &[String]);
fn external_function(&mut self, id: &str, name: &str);
fn render_function(&mut self, func: &RenderedFunction);
fn static_item(&mut self, id: &str, name: &str);
fn asm_item(&mut self, id: &str, content: &str);
fn finish(self) -> Self::Output;
}What does this actually buy us? A few things: No lifetime on builders. Rendering logic written once. The driver calls Still flexible where it matters. The Fewer trait methods. The granular push-based sequence ( New hooks emerge as data, not methods. Instead of adding One more thing worth noting: function IDs. The current branch uses /// Generate a stable, unique ID from a symbol name and body.
/// Handles the case where multiple monomorphizations of the same
/// generic produce the same short_name by incorporating a body hash.
fn make_fn_id(name: &str, body: &Body) -> String { ... }In terms of concrete next steps, I'd suggest:
Of course, feel free to push back, if you have a different roadmap in mind. |
|
Thanks for the detailed explanation. That separation between graph structure and MIR string rendering makes sense. I will refactor the driver to produce |
Summary
This PR introduces a format-agnostic MIR graph traversal based on a new
GraphBuilderabstraction. Traversal semantics are centralized in a single implementation, while individual renderers are responsible only for presentation.As an initial consumer, the D2 renderer has been ported to this new traversal behind a parallel code path. The legacy D2 implementation is retained for now to allow safe comparison and validation.
What changed
GraphBuildertrait describing semantic graph eventsrender_graph) that owns MIR graph structure and traversal orderGraphBuilderGraphContextcargo fmtandcargo clippyWhy this change
Previously, each renderer duplicated traversal logic, which made the code harder to reason about and maintain. Centralizing traversal ensures:
Validation and testing
The new traversal can be exercised by enabling the experimental D2 path:
To compare outputs against the legacy renderer:
No differences were observed across the existing integration test suite.
Status and follow-ups
Notes for reviewers
This PR is a structural refactor only. Traversal semantics and output behavior are unchanged, and the parallel wiring is intended to make review and validation straightforward.