Skip to content

Add initial control-flow graph model and Prism builder - #959

Open
Morriar wants to merge 1 commit into
mainfrom
cfg-model
Open

Add initial control-flow graph model and Prism builder#959
Morriar wants to merge 1 commit into
mainfrom
cfg-model

Conversation

@Morriar

@Morriar Morriar commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Why

Rubydex needs a control-flow graph before it can model instruction semantics, local-variable flow, reachability, and type inference.

This PR introduces the minimum CFG topology needed to start that work. The representation follows Sorbet’s non-SSA CFG structure but does not attempt full Sorbet compatibility yet.

What changed

Added a CFG model with:

  • Stable BlockIds
  • Dedicated entry and exit blocks
  • Ordered instructions within each BasicBlock
  • Jump, Branch, and Exit terminators
  • Source locations for instructions and control-flow transfers
  • Successor traversal and block lookup APIs

Added a Prism visitor that builds CFGs for:

  • Sequential statements
  • if and unless
  • while and until
  • return
  • Parenthesized expressions
  • Nested branching predicates

The builder preserves Ruby evaluation order and creates explicit branch, merge, loop-header, loop-exit, and unreachable blocks. These structural blocks may be empty; a future CFG simplification pass can remove unnecessary forwarding blocks.

Instructions currently contain only source locations. Instruction semantics, locals, block arguments, and type information will be introduced separately.

Testing

Builder tests render the complete CFG into a canonical textual representation. Each test verifies:

  • Block allocation
  • Instruction ordering
  • True and false successors
  • Merge edges
  • Loop backedges
  • Return-to-exit edges
  • Nested control flow

Follow-up work

This PR intentionally does not include:

  • Instruction lowering
  • Local variables or temporary values
  • break, next, redo, or retry
  • Exception, rescue, or ensure control flow
  • Method arguments or default arguments
  • CFG simplification
  • Sorbet fixture compatibility tests

@Morriar
Morriar requested a review from a team as a code owner July 28, 2026 21:25
Comment thread rust/rubydex/src/cfg.rs

/// Index of a basic block inside a [`ControlFlowGraph`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BlockId(u32);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get away with u16? How large does a method need to be to produce so many blocks that we exceed the 65k?

Maybe we can save a bit of memory.

Comment thread rust/rubydex/src/cfg.rs
/// The unique entry block. Method execution starts here.
pub const ENTRY: Self = Self(0);
/// The unique exit block. Every terminating path eventually reaches it.
pub const EXIT: Self = Self(1);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for my understanding: if a method has multiple return statements, do their blocks all point to the same exit?

Also, this might be worth documenting.

Comment thread rust/rubydex/src/cfg.rs
Comment on lines +45 to +62
/// An instruction whose semantics have not been lowered yet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Instruction {
/// The corresponding byte range in the Ruby source.
location: Offset,
}

impl Instruction {
#[must_use]
pub const fn new(location: Offset) -> Self {
Self { location }
}

#[must_use]
pub const fn location(&self) -> &Offset {
&self.location
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary for this PR, but I imagine instructions are going to grow a lot, so I'd move it to its own file under cfg/.

Comment thread rust/rubydex/src/cfg.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Instruction {
/// The corresponding byte range in the Ruby source.
location: Offset,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call this offset. Location is a different concept so I'm worried it might cause confusion.

Comment thread rust/rubydex/src/cfg.rs
/// The block that executes next.
target: BlockId,
/// The Ruby source range responsible for the transfer.
location: Offset,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things: first is the same as my previous comment, let's call this offset.

Second: are we duplicating the offsets for the same instruction? I'm trying to build my mental model of how the blocks connect exactly.

Consider this

def foo
  blah = true

  # The if statement here causes the branching. Is this if statement
  # stored exclusively as a block terminator or is it also an instruction
  # that belongs to the first block of `foo`?

  if blah
  else
  end
end

Comment thread rust/rubydex/src/cfg.rs
}
}

/// A maximal straight-line instruction sequence with one terminator.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the maximal straight-line part may not be clear to everyone, although I do like the concise way of describing it. Can we expand a bit on it?

}

struct Builder {
blocks: Vec<BasicBlock>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're building the blocks vector and then instantiating the Cfg with the complete list. No strong preference, but what do you think about having the builder hold a CFG instance that gets mutated instead?

Basically, something like this

self.cfg.add_block(...)

}

/// Appends an empty block with the next sequential ID.
fn fresh_block(&mut self) -> BlockId {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
fn fresh_block(&mut self) -> BlockId {
fn new_block(&mut self) -> BlockId {

}

/// Adds an instruction to the current block.
fn emit(&mut self, location: Offset) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
fn emit(&mut self, location: Offset) {
fn emit_instruction(&mut self, location: Offset) {

};
}
}
ControlFlowGraph::new(self.blocks)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If my understanding is correct, we build one CFG per file that we visit, which may contain multiple methods.

Considering we want to parallelize the analysis of the CFG, should we instead produce a new CFG for each statements node (a.k.a. method body)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants