Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.1", "3.2", "3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install --yes graphviz
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake spec
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4.0.0
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
All notable changes to this project will be documented in this file.

## 0.17.0 - 2026-08-01
### Changed
* Require Ruby 3.1 or newer and add CI coverage through Ruby 4.0.
* Replace the legacy syntax parser pipeline with Prism's Parser-compatible AST translation.
* Modernize development and runtime dependencies, including Ruby 3 keyword-argument compatibility.

### Added
* Visualize Ruby 3.4 implicit `it` blocks, numbered blocks, argument forwarding,
endless methods, and `case`/`in` pattern-matching branches.
* Add structured graph support for `while`, `until`, post-condition loops,
`for`, `rescue`/`ensure`, and terminal control-flow statements.
* Keep valid syntax without a specialized graph rule as an atomic action node.

## 0.16.0 - 2019-07-10
### Changed
* Require at least Ruby version 2.3
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

![logo](logo.jpg)

Write a Ruby code and see method interactions on a flow chart. Works with procedural code, bare methods, and Classes.
This is experimental project and does not support all types of code.
If you'd like it to support more types of code please pull request.
Write Ruby code and see method interactions on a flow chart. It works with
procedural code, bare methods, and classes.

VisualizeRuby supports Ruby 3.1 through Ruby 4.0. Prism accepts current Ruby
grammar; the graph engine gives structured flow to sequences, logical
expressions, conditionals, `case`/`in`, blocks, `while`/`until`/`for` loops,
`rescue`/`ensure`, and terminal statements. Other valid Ruby syntax is kept as
an atomic action node, so a new expression will not prevent the surrounding
file from being visualized.

This is static analysis: dynamic dispatch, runtime `eval`, and metaprogramming
cannot be represented with complete runtime accuracy.

[Demo](https://visualize.dustinzeisler.com)

Expand Down Expand Up @@ -131,7 +140,7 @@ end

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. Ruby 3.1 or newer and Graphviz are required. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).

Expand Down
4 changes: 4 additions & 0 deletions lib/visualize_ruby/ast_helper.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "unparser"

module VisualizeRuby
class AstHelper
def initialize(ast)
Expand All @@ -7,6 +9,8 @@ def initialize(ast)
def description
return @ast unless @ast.respond_to?(:type)
Unparser.unparse(@ast)
rescue Unparser::UnsupportedNodeError
@ast.location.expression.source
end

def id(description: self.description)
Expand Down
103 changes: 45 additions & 58 deletions lib/visualize_ruby/builder.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
require "dissociated_introspection"
require "forwardable"

module VisualizeRuby
class Builder
# @param [String] ruby_code
Expand All @@ -10,31 +7,40 @@ def initialize(ruby_code:, in_line_local_method_calls: true)
end

def build
ruby_code = DissociatedIntrospection::RubyCode.build_from_source(@ruby_code.read)
ruby_class = DissociatedIntrospection::RubyClass.new(ruby_code)
source = @ruby_code.read
ast = Parser.new(source).ast

if ruby_class.class?
if ast.type == :class
class_name, _superclass, body = ast.children
definitions = method_definitions(body)
if @in_line_local_method_calls
do_in_lining(ruby_class)
inlined_ast = Parser.new(Unparser.unparse(inline_calls(ast, definitions, []))).ast
_name, _parent, inlined_body = inlined_ast.children
Result.new(
ruby_code: Unparser.unparse(inlined_ast),
ast: inlined_ast,
graphs: build_graphs(method_definitions(inlined_body)),
options: { label: AstHelper.new(class_name).description }
)
else
Result.new(
ruby_code: @ruby_code.input,
ast: ruby_code.ast,
graphs: build_from_class(ruby_class),
options: { label: ruby_class.class_name }
ruby_code: source,
ast: ast,
graphs: build_graphs(definitions),
options: { label: AstHelper.new(class_name).description }
)
end
elsif bare_methods?(ruby_code)
elsif bare_methods?(ast)
Result.new(
ruby_code: @ruby_code.input,
ast: ruby_code.ast,
graphs: wrap_bare_methods(ruby_code)
ruby_code: source,
ast: ast,
graphs: build_graphs(method_definitions(ast))
)
else
Result.new(
ruby_code: @ruby_code.input,
ast: ruby_code.ast,
graphs: [Graph.new(ast: ruby_code.ast)]
ruby_code: source,
ast: ast,
graphs: [Graph.new(ast: ast)]
)
end
end
Expand All @@ -59,23 +65,14 @@ def initialize(ruby_code:, graphs:, options: {}, ast:)

private

def do_in_lining(ruby_class)
ruby_code_class = DissociatedIntrospection::RubyCode.build_from_ast(ruby_class.send(:find_class))
in_lined_ruby = DissociatedIntrospection::MethodInLiner.new(ruby_code_class, defs: ruby_class.defs).in_line
reparsed_ruby = DissociatedIntrospection::RubyCode.build_from_source(in_lined_ruby.source)
in_lined_ruby_class = DissociatedIntrospection::RubyClass.new(reparsed_ruby)

Result.new(
ruby_code: reparsed_ruby.source,
ast: reparsed_ruby.ast,
graphs: build_from_class(in_lined_ruby_class),
options: { label: ruby_class.class_name }
)
def build_graphs(definitions)
definitions.map do |definition|
name, _arguments, body = definition.children
Graph.new(name: name, ast: body)
end.then { |graphs| connect_method_calls(graphs) }
end

def build_from_class(ruby_class)
graphs = build_graphs_by_method(ruby_class)

def connect_method_calls(graphs)
graphs.each do |graph|
graphs.each do |sub_graph|
sub_graph.nodes.each do |node|
Expand All @@ -102,36 +99,26 @@ def build_from_class(ruby_class)
graphs
end

def edge_search(a: nil, b: nil, edges:)
edges.select do |e|
e.node_a == a || e.node_b == b
end
def bare_methods?(ast)
ast.type == :def || ast.type == :begin && ast.children.all? { |child| child&.type == :def }
end

def build_graphs_by_method(ruby_class)
ruby_class.defs.map do |meth|
Graph.new(
ruby_code: meth.body.to_s,
name: meth.name,
ast: meth.body.ast
)
end
def method_definitions(ast)
body = ast.type == :begin ? ast.children : [ast]
body.select { |child| child&.type == :def }
end

def bare_methods?(ruby_code)
ruby_code.ast.type == :def ||
ruby_code.ast.type == :begin && ruby_code.ast.children.map(&:type).uniq == [:def]
end
def inline_calls(ast, definitions, call_stack)
return ast unless ast.respond_to?(:type)

def wrap_bare_methods(ruby_code)
wrapped_ruby_code = <<~Ruby
class BareMethodsClass
#{ruby_code.source}
end
Ruby
di_ruby_code = DissociatedIntrospection::RubyCode.build_from_source(wrapped_ruby_code)
ruby_class = DissociatedIntrospection::RubyClass.new(di_ruby_code)
build_from_class(ruby_class)
receiver, method_name, *arguments = ast.children if ast.type == :send
definition = definitions.find { |candidate| candidate.children.first == method_name } if ast.type == :send

if definition && arguments.empty? && (receiver.nil? || receiver.type == :self) && !call_stack.include?(method_name)
return inline_calls(definition.children.last, definitions, call_stack + [method_name])
end

ast.updated(nil, ast.children.map { |child| inline_calls(child, definitions, call_stack) })
end
end
end
2 changes: 1 addition & 1 deletion lib/visualize_ruby/edge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def initialize(name: nil, nodes:, dir: :forward, type: :default, display: :visua
@color = color
@type = type
@display = display
post_initialize(opts)
post_initialize(**opts)
end

def node_a
Expand Down
1 change: 0 additions & 1 deletion lib/visualize_ruby/execution_tracer.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
require "tracer"
require "tempfile"

module VisualizeRuby
Expand Down
6 changes: 3 additions & 3 deletions lib/visualize_ruby/graphviz.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def create_edges(sub_graphs)
g_graph,
nodes[node_id(edge.node_a)],
nodes[node_id(edge.node_b)],
**compact({ label: edge.name, dir: edge.dir, style: edge.style, **edge.options })
compact({ label: edge.name, dir: edge.dir, style: edge.style, **edge.options })
)
end
end
Expand All @@ -87,7 +87,7 @@ def create_nodes(graph, sub_graph)
graph.nodes.each do |node|
nodes[node_id(node)] = sub_graph.add_node(
node_id(node),
compact({
**compact({
shape: node.shape,
style: node.style,
label: node.name,
Expand All @@ -98,7 +98,7 @@ def create_nodes(graph, sub_graph)
end

def main_graph
@main_graph ||= ::Graphviz::Graph.new(:G, compact(label: label))
@main_graph ||= ::Graphviz::Graph.new(:G, **compact(label: label))
end

def compact(hash)
Expand Down
2 changes: 1 addition & 1 deletion lib/visualize_ruby/highlight_tracer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ def build_exe_edge(a, c)

def find_node(line:, graphs: builder.graphs)
graphs.each do |graph|
graph = graph
node = graph.nodes.detect { |n| n.line == line }
return node, graph if node
end
nil
end

def exe_edge(graph_a, node_a, node_b)
Expand Down
2 changes: 1 addition & 1 deletion lib/visualize_ruby/namable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Namable

def post_initialize(name_displayer: nil, **args)
@name_displayer = name_displayer || DEFAULT_DISPLAYER
super if defined? super
super(**args) if defined? super
end

def name
Expand Down
2 changes: 1 addition & 1 deletion lib/visualize_ruby/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def initialize(name: nil, type: :action, style: :rounded, ast: nil, line: nil, i
@style = style
@id = id || (ast ? AstHelper.new(ast).id : @label)
@line = line || AstHelper.new(ast).first_line
post_initialize(opts)
post_initialize(**opts)
end

def to_a
Expand Down
2 changes: 1 addition & 1 deletion lib/visualize_ruby/optionalable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module VisualizeRuby
module Optionalable
def post_initialize(**opts)
@graph_viz_options = opts
super if defined? super
super(**opts) if defined? super
end

def options(args={})
Expand Down
Loading
Loading