From af55315a59f3f773ef66be1ed7bcd9bdad54a762 Mon Sep 17 00:00:00 2001 From: Dustin Zeisler Date: Sun, 2 Aug 2026 08:53:26 -0700 Subject: [PATCH 1/3] Support modern Ruby syntax --- .github/workflows/ci.yml | 21 + .ruby-version | 1 + CHANGELOG.md | 13 + README.md | 17 +- lib/visualize_ruby/ast_helper.rb | 4 + lib/visualize_ruby/builder.rb | 103 ++--- lib/visualize_ruby/edge.rb | 2 +- lib/visualize_ruby/execution_tracer.rb | 1 - lib/visualize_ruby/graphviz.rb | 6 +- lib/visualize_ruby/highlight_tracer.rb | 2 +- lib/visualize_ruby/namable.rb | 2 +- lib/visualize_ruby/node.rb | 2 +- lib/visualize_ruby/optionalable.rb | 2 +- lib/visualize_ruby/parser.rb | 440 ++++++++++++++++++-- lib/visualize_ruby/parser/fragment.rb | 79 ++++ lib/visualize_ruby/runner.rb | 4 +- lib/visualize_ruby/touchable.rb | 2 +- lib/visualize_ruby/version.rb | 2 +- spec/examples/bankruptcy_rule.dot | 36 +- spec/visualize_ruby/modern_syntax_spec.rb | 49 +++ spec/visualize_ruby/runner_spec.rb | 2 + spec/visualize_ruby/syntax_coverage_spec.rb | 151 +++++++ visualize_ruby.gemspec | 18 +- 23 files changed, 828 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .ruby-version create mode 100644 lib/visualize_ruby/parser/fragment.rb create mode 100644 spec/visualize_ruby/modern_syntax_spec.rb create mode 100644 spec/visualize_ruby/syntax_coverage_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3aa3855 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..fcdb2e1 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 82563b9..e4f565f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6372298..08d9f9e 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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). diff --git a/lib/visualize_ruby/ast_helper.rb b/lib/visualize_ruby/ast_helper.rb index 04c8161..df1b310 100644 --- a/lib/visualize_ruby/ast_helper.rb +++ b/lib/visualize_ruby/ast_helper.rb @@ -1,3 +1,5 @@ +require "unparser" + module VisualizeRuby class AstHelper def initialize(ast) @@ -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) diff --git a/lib/visualize_ruby/builder.rb b/lib/visualize_ruby/builder.rb index c166ae3..c7a13e0 100644 --- a/lib/visualize_ruby/builder.rb +++ b/lib/visualize_ruby/builder.rb @@ -1,6 +1,3 @@ -require "dissociated_introspection" -require "forwardable" - module VisualizeRuby class Builder # @param [String] ruby_code @@ -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 @@ -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| @@ -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 diff --git a/lib/visualize_ruby/edge.rb b/lib/visualize_ruby/edge.rb index 26a0c4b..1043db7 100644 --- a/lib/visualize_ruby/edge.rb +++ b/lib/visualize_ruby/edge.rb @@ -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 diff --git a/lib/visualize_ruby/execution_tracer.rb b/lib/visualize_ruby/execution_tracer.rb index 97febd5..8d36816 100644 --- a/lib/visualize_ruby/execution_tracer.rb +++ b/lib/visualize_ruby/execution_tracer.rb @@ -1,4 +1,3 @@ -require "tracer" require "tempfile" module VisualizeRuby diff --git a/lib/visualize_ruby/graphviz.rb b/lib/visualize_ruby/graphviz.rb index 918e795..23a618d 100644 --- a/lib/visualize_ruby/graphviz.rb +++ b/lib/visualize_ruby/graphviz.rb @@ -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 @@ -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, @@ -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) diff --git a/lib/visualize_ruby/highlight_tracer.rb b/lib/visualize_ruby/highlight_tracer.rb index b033b20..3c4e64a 100644 --- a/lib/visualize_ruby/highlight_tracer.rb +++ b/lib/visualize_ruby/highlight_tracer.rb @@ -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) diff --git a/lib/visualize_ruby/namable.rb b/lib/visualize_ruby/namable.rb index 1359519..ffc52d9 100644 --- a/lib/visualize_ruby/namable.rb +++ b/lib/visualize_ruby/namable.rb @@ -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 diff --git a/lib/visualize_ruby/node.rb b/lib/visualize_ruby/node.rb index 31ad173..1d31fd9 100644 --- a/lib/visualize_ruby/node.rb +++ b/lib/visualize_ruby/node.rb @@ -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 diff --git a/lib/visualize_ruby/optionalable.rb b/lib/visualize_ruby/optionalable.rb index 8e9fd54..7d42fdf 100644 --- a/lib/visualize_ruby/optionalable.rb +++ b/lib/visualize_ruby/optionalable.rb @@ -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={}) diff --git a/lib/visualize_ruby/parser.rb b/lib/visualize_ruby/parser.rb index 36e8254..5868326 100644 --- a/lib/visualize_ruby/parser.rb +++ b/lib/visualize_ruby/parser.rb @@ -1,53 +1,435 @@ -require "parser/current" -require_relative "parser/conditions" +require "parser" +require "prism" +require_relative "parser/fragment" +# Retained for callers that instantiate VisualizeRuby::Parser::Block directly. require_relative "parser/base" -require_relative "parser/or" -require_relative "parser/and" -require_relative "parser/begin" -require_relative "parser/send" -require_relative "parser/str" -require_relative "parser/if" -require_relative "parser/type" -require_relative "parser/true" -require_relative "parser/false" -require_relative "parser/case" require_relative "parser/block" -require_relative "parser/return" module VisualizeRuby class Parser + HANDLERS = { + and: :parse_logical, + or: :parse_logical, + begin: :parse_sequence, + kwbegin: :parse_sequence, + block: :parse_block, + case: :parse_case, + case_match: :parse_case_match, + ensure: :parse_ensure, + for: :parse_for, + if: :parse_if, + itblock: :parse_itblock, + numblock: :parse_numblock, + rescue: :parse_rescue, + return: :parse_return, + break: :parse_break, + next: :parse_next, + redo: :parse_redo, + retry: :parse_retry, + until: :parse_loop, + until_post: :parse_post_loop, + while: :parse_loop, + while_post: :parse_post_loop, + }.freeze + attr_reader :ast - def initialize(ruby_code = nil, ast: ::Parser::CurrentRuby.parse(ruby_code)) + def initialize(ruby_code = nil, ast: parse_ruby(ruby_code)) @ast = ast end # @return [Array, Array] def parse - merge *parse_by_type + fragment = parse_ast(ast) + [fragment.nodes, fragment.edges] + end + + private - return nodes, edges + # Prism tracks current Ruby grammar and translates it to the parser-gem AST + # used by this project. New AST types are safely rendered as source nodes + # until they receive a dedicated control-flow handler. + def parse_ruby(ruby_code) + Prism::Translation::Parser.parse(ruby_code) end - - def nodes - @nodes ||= [] + + def parse_ast(node) + return Fragment.empty unless ast_node?(node) + + handler = HANDLERS.fetch(node.type, :parse_atomic) + send(handler, node) end - def edges - @edges ||= [] + def parse_sequence(node) + Fragment.sequence(node.children.filter_map { |child| parse_ast(child) if ast_node?(child) }) end - private + def parse_atomic(node) + if [:true, :false].include?(node.type) + Fragment.node(Node.new(name: node.type, type: :action)) + else + Fragment.node(Node.new(ast: node, type: :action)) + end + end + + def parse_logical(node) + left, right = node.children + left_fragment = condition_fragment(left) + right_fragment = condition_fragment(right) + nodes = left_fragment.nodes + right_fragment.nodes + edges = left_fragment.edges + right_fragment.edges + + Fragment.link( + edges, + nodes, + condition_endpoint(left_fragment), + condition_endpoint(right_fragment), + name: node.type.to_s.upcase + ) + # HighlightTracer follows this edge when two condition nodes appear on + # the same executed line path. Keep the legacy connection metadata for + # logical expressions, even though the graph is now built as fragments. + condition_endpoint(right_fragment).lineno_connection = edges.last + + Fragment.new( + nodes: nodes, + edges: edges, + entries: left_fragment.entries, + exits: [condition_endpoint(right_fragment)] + ) + end + + def parse_if(node) + condition, on_true, on_false = node.children + condition_fragment = condition_fragment(condition) + true_fragment = branch_fragment(on_true, condition_fragment, "true") + false_fragment = branch_fragment(on_false, condition_fragment, "false") + nodes = condition_fragment.nodes + true_fragment.nodes + false_fragment.nodes + edges = condition_fragment.edges.dup + + Fragment.link(edges, nodes, condition_fragment.exits, true_fragment.entries, name: "true") + Fragment.link(edges, nodes, condition_fragment.exits, false_fragment.entries, name: "false") + edges.concat(false_fragment.edges) + edges.concat(true_fragment.edges) + + Fragment.new( + nodes: nodes, + edges: edges, + entries: condition_fragment.entries, + exits: false_fragment.exits + true_fragment.exits + ) + end + + def parse_loop(node) + condition, body = node.children + condition_fragment = condition_fragment(condition) + body_fragment = parse_ast(body) + nodes = condition_fragment.nodes + body_fragment.nodes + edges = condition_fragment.edges.dup + body_label, exit_label = node.type == :until ? ["false", "true"] : ["true", "false"] + exit_node = end_node(condition_fragment, exit_label) + nodes << exit_node + + if body_fragment.entries.empty? + Fragment.link(edges, nodes, condition_fragment.exits, condition_fragment.entries, name: "↺") + else + Fragment.link(edges, nodes, condition_fragment.exits, body_fragment.entries, name: body_label) + end + Fragment.link(edges, nodes, condition_fragment.exits, exit_node, name: exit_label) + edges.concat(body_fragment.edges) + Fragment.link(edges, nodes, body_fragment.exits, condition_fragment.entries, name: "↺") + + Fragment.new( + nodes: nodes, + edges: edges, + entries: condition_fragment.entries, + exits: [exit_node] + ) + end + + def parse_post_loop(node) + body, condition = node.children + condition_fragment = condition_fragment(condition) + body_fragment = parse_ast(body) + nodes = body_fragment.nodes + condition_fragment.nodes + edges = body_fragment.edges.dup + body_label, exit_label = node.type == :until_post ? ["false", "true"] : ["true", "false"] + exit_node = end_node(condition_fragment, exit_label) + nodes << exit_node + + Fragment.link(edges, nodes, body_fragment.exits, condition_fragment.entries) + edges.concat(condition_fragment.edges) + Fragment.link(edges, nodes, condition_fragment.exits, body_fragment.entries, name: body_label) + Fragment.link(edges, nodes, condition_fragment.exits, exit_node, name: exit_label) + + Fragment.new( + nodes: nodes, + edges: edges, + entries: body_fragment.entries.empty? ? condition_fragment.entries : body_fragment.entries, + exits: [exit_node] + ) + end + + def parse_for(node) + variables, collection, body = node.children + loop_node = Node.new( + name: "for #{description(variables)} in #{description(collection)}", + type: :decision, + ast: node + ) + body_fragment = parse_ast(body) + exit_node = end_node(Fragment.node(loop_node), "false") + nodes = [loop_node] + body_fragment.nodes + [exit_node] + edges = [] + + Fragment.link(edges, nodes, loop_node, body_fragment.entries, name: "true") + Fragment.link(edges, nodes, loop_node, exit_node, name: "false") + edges.concat(body_fragment.edges) + Fragment.link(edges, nodes, body_fragment.exits, loop_node, name: "↺") + + Fragment.new(nodes: nodes, edges: edges, entries: [loop_node], exits: [exit_node]) + end + + def parse_block(node) + iterator, arguments, body = node.children + parse_iterating_block(iterator, arguments&.children&.first, body) + end + + def parse_itblock(node) + iterator, _parameter, body = node.children + parse_iterating_block(iterator, :it, body) + end + + def parse_numblock(node) + iterator, _count, body = node.children + parse_iterating_block(iterator, :_1, body) + end + + def parse_case(node) + subject, *branches = node.children + decision = Node.new(name: subject ? description(subject) : "case", type: :decision, ast: node) + nodes = [decision] + edges = [] + exits = [] + else_branch = branches.pop + + branches.each do |branch| + next unless ast_node?(branch) && branch.type == :when + + *patterns, body = branch.children + body_fragment = parse_ast(body) + body_fragment = branch_fragment(nil, Fragment.node(decision), description(branch)) if body_fragment.entries.empty? + nodes.concat(body_fragment.nodes) + Fragment.link(edges, nodes, decision, body_fragment.entries, name: patterns.map { |pattern| description(pattern) }.join(", ")) + edges.concat(body_fragment.edges) + exits.concat(body_fragment.exits) + end + + if ast_node?(else_branch) + else_fragment = parse_ast(else_branch) + nodes.concat(else_fragment.nodes) + Fragment.link(edges, nodes, decision, else_fragment.entries, name: "else") + edges.concat(else_fragment.edges) + exits.concat(else_fragment.exits) + else + exits << decision + end + + Fragment.new(nodes: nodes, edges: edges, entries: [decision], exits: exits) + end + + def parse_case_match(node) + subject, *branches = node.children + decision = Node.new(name: description(subject), type: :decision, ast: node) + nodes = [decision] + edges = [] + exits = [] + else_branch = branches.reject { |branch| ast_node?(branch) && branch.type == :in_pattern }.last + + branches.each do |branch| + next unless ast_node?(branch) && branch.type == :in_pattern + + pattern, guard, body = branch.children + body_fragment = parse_ast(body) + nodes.concat(body_fragment.nodes) + label = description(pattern) + if ast_node?(guard) + guard_description = description(guard) + guard = guard_description.start_with?("if") ? guard_description : "if #{guard_description}" + label = "#{label} #{guard}" + end + Fragment.link(edges, nodes, decision, body_fragment.entries, name: label) + edges.concat(body_fragment.edges) + exits.concat(body_fragment.exits) + end + + if ast_node?(else_branch) + else_fragment = parse_ast(else_branch) + nodes.concat(else_fragment.nodes) + Fragment.link(edges, nodes, decision, else_fragment.entries, name: "else") + edges.concat(else_fragment.edges) + exits.concat(else_fragment.exits) + else + exits << decision + end + + Fragment.new(nodes: nodes, edges: edges, entries: [decision], exits: exits) + end + + def parse_rescue(node) + body, *parts = node.children + else_body = parts.last unless ast_node?(parts.last) && parts.last.type == :resbody + rescue_bodies = parts.select { |part| ast_node?(part) && part.type == :resbody } + body_fragment = parse_ast(body) + else_fragment = parse_ast(else_body) + rescue_node = Node.new(name: "rescue", type: :decision, ast: node) + nodes = body_fragment.nodes + [rescue_node] + else_fragment.nodes + edges = body_fragment.edges.dup + exits = [] + + if else_fragment.entries.empty? + exits.concat(body_fragment.exits) + else + Fragment.link(edges, nodes, body_fragment.exits, else_fragment.entries, name: "success") + exits.concat(else_fragment.exits) + end + edges.concat(else_fragment.edges) + + rescue_bodies.each do |resbody| + exceptions, _variable, rescue_body = resbody.children + rescue_fragment = parse_ast(rescue_body) + nodes.concat(rescue_fragment.nodes) + Fragment.link(edges, nodes, rescue_node, rescue_fragment.entries, name: rescue_label(exceptions)) + edges.concat(rescue_fragment.edges) + exits.concat(rescue_fragment.exits) + end + + Fragment.new( + nodes: nodes, + edges: edges, + entries: body_fragment.entries.empty? ? [rescue_node] : body_fragment.entries, + exits: exits + ) + end + + def parse_ensure(node) + protected_body, ensure_body = node.children + protected_fragment = parse_ast(protected_body) + ensure_fragment = parse_ast(ensure_body) + nodes = protected_fragment.nodes + ensure_fragment.nodes + edges = protected_fragment.edges.dup + Fragment.link(edges, nodes, protected_fragment.exits, ensure_fragment.entries, name: "ensure") + edges.concat(ensure_fragment.edges) + + Fragment.new( + nodes: nodes, + edges: edges, + entries: protected_fragment.entries.empty? ? ensure_fragment.entries : protected_fragment.entries, + exits: ensure_fragment.entries.empty? ? protected_fragment.exits : ensure_fragment.exits + ) + end + + def parse_return(node) + parse_terminal(node, :return) + end + + def parse_break(node) + parse_terminal(node, :terminal) + end + + def parse_next(node) + parse_terminal(node, :terminal) + end + + def parse_redo(node) + parse_terminal(node, :terminal) + end + + def parse_retry(node) + parse_terminal(node, :terminal) + end + + def parse_terminal(node, type) + value = node.children.first + terminal = if ast_node?(value) + Node.new(ast: value, type: type) + else + Node.new(ast: node, type: type) + end + Fragment.node(terminal, terminal: true) + end + + def parse_iterating_block(iterator, argument, body) + iterator_node = Node.new(ast: iterator, type: :action, color: enumerable?(iterator) ? "blue" : "orange") + body_fragment = parse_ast(body) + nodes = [iterator_node] + body_fragment.nodes + edges = [] + label = argument.respond_to?(:type) ? argument.to_s : argument + + Fragment.link(edges, nodes, iterator_node, body_fragment.entries, name: label, color: iterator_node.options[:color]) + edges.concat(body_fragment.edges) + + if enumerable?(iterator) + Fragment.link(edges, nodes, body_fragment.exits, iterator_node, name: "↺", color: iterator_node.options[:color]) + exits = [iterator_node] + else + exits = body_fragment.entries.empty? ? [iterator_node] : body_fragment.exits + end + + Fragment.new(nodes: nodes, edges: edges, entries: [iterator_node], exits: exits) + end + + def condition_fragment(node) + fragment = parse_ast(node) + fragment.nodes.first.type = :decision if fragment.nodes.first + fragment + end + + def branch_fragment(node, condition, label) + fragment = parse_ast(node) + return fragment unless fragment.entries.empty? + + Fragment.node(end_node(condition, label)) + end + + def condition_endpoint(fragment) + fragment.nodes.last + end + + def end_node(fragment, label) + @end_node_sequence ||= 0 + @end_node_sequence += 1 + source = fragment.nodes.last || ast + Node.new( + name: "END", + type: :branch_leaf, + id: "end-#{label}-#{source.id}-#{@end_node_sequence}" + ) + end + + def enumerable?(iterator) + return false unless ast_node?(iterator) && iterator.type == :send + + method_name = iterator.children[1] + method_name == :each || Enumerable.instance_methods.include?(method_name) + end + + def rescue_label(exceptions) + return "StandardError" unless ast_node?(exceptions) + + return exceptions.children.map { |exception| description(exception) }.join(", ") if exceptions.type == :array + + description(exceptions) + end + + def description(node) + return node.to_s unless ast_node?(node) - def parse_by_type - Parser.const_get(ast.type.to_s.capitalize, false).new(ast).parse - rescue NameError - Str.new(ast).parse + AstHelper.new(node).description end - def merge(nodes, edges) - self.nodes.concat(nodes) - self.edges.concat(edges) + def ast_node?(node) + node.respond_to?(:type) && node.respond_to?(:children) end end end diff --git a/lib/visualize_ruby/parser/fragment.rb b/lib/visualize_ruby/parser/fragment.rb new file mode 100644 index 0000000..6f4db2b --- /dev/null +++ b/lib/visualize_ruby/parser/fragment.rb @@ -0,0 +1,79 @@ +module VisualizeRuby + class Parser + # A composable section of a control-flow graph. A fragment keeps the + # points where execution can enter and continue so larger expressions can + # be connected without inspecting implementation-specific node arrays. + class Fragment + attr_reader :nodes, :edges, :entries, :exits + + def initialize(nodes: [], edges: [], entries: [], exits: []) + @nodes = nodes + @edges = edges + @entries = entries + @exits = exits + end + + def self.empty + new + end + + def self.node(node, terminal: false) + new(nodes: [node], entries: [node], exits: terminal ? [] : [node]) + end + + def self.sequence(fragments) + fragments = fragments.compact.reject { |fragment| fragment.nodes.empty? } + return empty if fragments.empty? + + nodes = fragments.flat_map(&:nodes) + edges = [] + entries = fragments.first.entries + + fragments.each_with_index do |fragment, index| + following = fragments[index + 1] + if following && !fragment.exits.empty? + # Keep a fragment's outgoing links before its internal edges. This + # matches the longstanding graph ordering while still allowing an + # END leaf to be replaced by the following executable node. + original_length = fragment.edges.length + link(fragment.edges, nodes, fragment.exits, following.entries) + edges.concat(fragment.edges.slice!(original_length..) || []) + end + edges.concat(fragment.edges) + end + + new(nodes: nodes, edges: edges, entries: entries, exits: fragments.last.exits) + end + + def self.combine(*fragments, entries:, exits:) + new( + nodes: fragments.flat_map(&:nodes), + edges: fragments.flat_map(&:edges), + entries: entries, + exits: exits + ) + end + + # Branches with no body use a temporary END node. If a later statement + # follows, replace that endpoint with the next executable node while + # preserving its true/false edge label. + def self.link(edges, nodes, from, to, name: nil, **options) + sources = from.is_a?(::Array) ? from : [from] + destinations = to.is_a?(::Array) ? to : [to] + + sources.compact.product(destinations.compact).each do |source, destination| + if source.type == :branch_leaf + edge = edges.reverse.find { |candidate| candidate.node_b == source } + if edge + edge.nodes[1] = destination + nodes.delete(source) + next + end + end + + edges << Edge.new(name: name, nodes: [source, destination], **options) + end + end + end + end +end diff --git a/lib/visualize_ruby/runner.rb b/lib/visualize_ruby/runner.rb index 79fc04b..088613f 100644 --- a/lib/visualize_ruby/runner.rb +++ b/lib/visualize_ruby/runner.rb @@ -1,5 +1,3 @@ -require "active_support/core_ext/hash/compact" - module VisualizeRuby class Runner # @return [String, File, Pathname, Proc] The code that calls the graphed code. @@ -32,7 +30,7 @@ def run! graphs: filter_graphs, unique_nodes: unique_nodes, only_graphs: only_graphs, - ).to_graph({ path: output_path, format: output_format }.compact) + ).to_graph(**{ path: output_path, format: output_format }.compact) end self end diff --git a/lib/visualize_ruby/touchable.rb b/lib/visualize_ruby/touchable.rb index 0ec7790..e07f300 100644 --- a/lib/visualize_ruby/touchable.rb +++ b/lib/visualize_ruby/touchable.rb @@ -4,7 +4,7 @@ def post_initialize(**args) self.class.add_names(:touched_display, :step_display) @steps = [] @touched = 0 - super if defined? super + super(**args) if defined? super end def touch(color, step: nil) diff --git a/lib/visualize_ruby/version.rb b/lib/visualize_ruby/version.rb index 0c37495..edd7f27 100644 --- a/lib/visualize_ruby/version.rb +++ b/lib/visualize_ruby/version.rb @@ -1,3 +1,3 @@ module VisualizeRuby - VERSION = "0.16.0" + VERSION = "0.17.0" end diff --git a/spec/examples/bankruptcy_rule.dot b/spec/examples/bankruptcy_rule.dot index 6deb633..323fa6f 100644 --- a/spec/examples/bankruptcy_rule.dot +++ b/spec/examples/bankruptcy_rule.dot @@ -3,23 +3,23 @@ digraph G { subgraph "cluster_0" { label="eligible?"; style=dotted; - "bankruptcies.any? L7"[shape=diamond, style=rounded, label="bankruptcies.any? step: 5", color=forestgreen]; - "bankruptcy.closed_date.nil? L8"[shape=ellipse, style=rounded, label="bankruptcy.closed_date.nil? (called: 2) step: 7, 10", color=forestgreen]; - "bankruptcies.any? L9"[shape=diamond, style=rounded, label="bankruptcies.any?", color="blue"]; - "bankruptcy.closed_date > 2.years.ago L10"[shape=ellipse, style=rounded, label="bankruptcy.closed_date > 2.years.ago (called: 2) step: 9, 11", color=forestgreen]; - "bankruptcies.any? L11"[shape=diamond, style=rounded, label="bankruptcies.any? step: 13", color=forestgreen]; - "bankruptcy.closed_date > 3.years.ago L12"[shape=ellipse, style=rounded, label="bankruptcy.closed_date > 3.years.ago step: 15", color=forestgreen]; - "credit_report.fico > 700 L13"[shape=diamond, style=rounded, label="credit_report.fico > 700"]; - "bankruptcies.any? L7" -> "bankruptcy.closed_date.nil? L8"[label="(arg :bankruptcy) step: 6", dir=forward, color=forestgreen]; - "bankruptcy.closed_date.nil? L8" -> "bankruptcies.any? L7"[label="↺", dir=forward, color="blue"]; - "bankruptcies.any? L9" -> "bankruptcy.closed_date > 2.years.ago L10"[label="(arg :bankruptcy)", dir=forward, color="blue"]; - "bankruptcy.closed_date > 2.years.ago L10" -> "bankruptcies.any? L9"[label="↺", dir=forward, color="blue"]; - "bankruptcy.closed_date.nil? L8" -> "bankruptcy.closed_date > 2.years.ago L10"[label="OR step: 8", dir=forward, color=forestgreen]; - "bankruptcies.any? L11" -> "bankruptcy.closed_date > 3.years.ago L12"[label="(arg :bankruptcy) step: 14", dir=forward, color=forestgreen]; - "bankruptcy.closed_date > 3.years.ago L12" -> "bankruptcies.any? L11"[label="↺", dir=forward, color="blue"]; - "bankruptcy.closed_date > 3.years.ago L12" -> "credit_report.fico > 700 L13"[label="AND", dir=forward]; - "bankruptcy.closed_date > 2.years.ago L10" -> "credit_report.fico > 700 L13"[label="OR", dir=forward]; - "bankruptcy.closed_date > 2.years.ago L10" -> "bankruptcies.any? L11"[label="step: 12", dir=forward, style=dotted, color=forestgreen]; + "bankruptcies.any? L8"[shape=diamond, style=rounded, label="bankruptcies.any? step: 5", color=forestgreen]; + "bankruptcy.closed_date.nil? L9"[shape=ellipse, style=rounded, label="bankruptcy.closed_date.nil? (called: 2) step: 7, 10", color=forestgreen]; + "bankruptcies.any? L10"[shape=diamond, style=rounded, label="bankruptcies.any?", color="blue"]; + "bankruptcy.closed_date > 2.years.ago L11"[shape=ellipse, style=rounded, label="bankruptcy.closed_date > 2.years.ago (called: 2) step: 9, 11", color=forestgreen]; + "bankruptcies.any? L12"[shape=diamond, style=rounded, label="bankruptcies.any?", color="blue"]; + "bankruptcy.closed_date > 3.years.ago L13"[shape=ellipse, style=rounded, label="bankruptcy.closed_date > 3.years.ago step: 13", color=forestgreen]; + "credit_report.fico > 700 L14"[shape=diamond, style=rounded, label="credit_report.fico > 700"]; + "bankruptcies.any? L8" -> "bankruptcy.closed_date.nil? L9"[label="(arg :bankruptcy) step: 6", dir=forward, color=forestgreen]; + "bankruptcy.closed_date.nil? L9" -> "bankruptcies.any? L8"[label="↺", dir=forward, color="blue"]; + "bankruptcies.any? L10" -> "bankruptcy.closed_date > 2.years.ago L11"[label="(arg :bankruptcy)", dir=forward, color="blue"]; + "bankruptcy.closed_date > 2.years.ago L11" -> "bankruptcies.any? L10"[label="↺", dir=forward, color="blue"]; + "bankruptcy.closed_date.nil? L9" -> "bankruptcy.closed_date > 2.years.ago L11"[label="OR step: 8", dir=forward, color=forestgreen]; + "bankruptcies.any? L12" -> "bankruptcy.closed_date > 3.years.ago L13"[label="(arg :bankruptcy)", dir=forward, color="blue"]; + "bankruptcy.closed_date > 3.years.ago L13" -> "bankruptcies.any? L12"[label="↺", dir=forward, color="blue"]; + "bankruptcy.closed_date > 3.years.ago L13" -> "credit_report.fico > 700 L14"[label="AND", dir=forward]; + "bankruptcy.closed_date > 2.years.ago L11" -> "credit_report.fico > 700 L14"[label="OR", dir=forward]; + "bankruptcy.closed_date > 2.years.ago L11" -> "bankruptcy.closed_date > 3.years.ago L13"[label="step: 12", dir=forward, style=dotted, color=forestgreen]; } subgraph "cluster_1" { label="initialize"; @@ -27,6 +27,6 @@ digraph G { "@bankruptcies = bankruptcies L3"[shape=ellipse, style=rounded, label="@bankruptcies = bankruptcies step: 1", color=forestgreen]; "@credit_report = credit_report L4"[shape=ellipse, style=rounded, label="@credit_report = credit_report step: 3", color=forestgreen]; "@bankruptcies = bankruptcies L3" -> "@credit_report = credit_report L4"[label="step: 2", dir=forward, color=forestgreen]; - "@credit_report = credit_report L4" -> "bankruptcies.any? L7"[label="step: 4", dir=forward, style=dotted, color=forestgreen]; + "@credit_report = credit_report L4" -> "bankruptcies.any? L8"[label="step: 4", dir=forward, style=dotted, color=forestgreen]; } } diff --git a/spec/visualize_ruby/modern_syntax_spec.rb b/spec/visualize_ruby/modern_syntax_spec.rb new file mode 100644 index 0000000..ff604fd --- /dev/null +++ b/spec/visualize_ruby/modern_syntax_spec.rb @@ -0,0 +1,49 @@ +RSpec.describe "modern Ruby syntax" do + def graph_for(source) + VisualizeRuby::Graph.new(ruby_code: source) + end + + it "visualizes Ruby 3.4 implicit it blocks" do + graph = graph_for("items.map { it.upcase }") + + expect(graph.nodes.map(&:to_a)).to eq([ + [:action, "items.map"], + [:action, "it.upcase"], + ]) + expect(graph.edges.map(&:to_a)).to eq([ + ["items.map", "it", "->", "it.upcase"], + ["it.upcase", "↺", "->", "items.map"], + ]) + end + + it "visualizes pattern-matching branches" do + graph = graph_for(<<~RUBY) + case response + in { ok: true, data: } + process(data) + in { error: } + handle(error) + end + RUBY + + expect(graph.nodes.map(&:to_a)).to eq([ + [:decision, "response"], + [:action, "process(data)"], + [:action, "handle(error)"], + ]) + expect(graph.edges.map(&:to_a)).to eq([ + ["response", "{ok: true, data:}", "->", "process(data)"], + ["response", "{error:}", "->", "handle(error)"], + ]) + end + + it "parses endless methods and argument forwarding" do + result = VisualizeRuby::Builder.new(ruby_code: <<~RUBY, in_line_local_method_calls: false).build + class Reporter + def log(...) = logger.info(...) + end + RUBY + + expect(result.graphs.first.nodes.map(&:to_a)).to eq([[:action, "logger.info(...)"]]) + end +end diff --git a/spec/visualize_ruby/runner_spec.rb b/spec/visualize_ruby/runner_spec.rb index 469859d..46f830d 100644 --- a/spec/visualize_ruby/runner_spec.rb +++ b/spec/visualize_ruby/runner_spec.rb @@ -1,3 +1,5 @@ +require "ostruct" +require "active_support" require "active_support/core_ext/time" require "active_support/core_ext/integer/time" diff --git a/spec/visualize_ruby/syntax_coverage_spec.rb b/spec/visualize_ruby/syntax_coverage_spec.rb new file mode 100644 index 0000000..71ec9fe --- /dev/null +++ b/spec/visualize_ruby/syntax_coverage_spec.rb @@ -0,0 +1,151 @@ +RSpec.describe "Ruby syntax coverage" do + def parse(source) + VisualizeRuby::Parser.new(source).parse + end + + def assert_valid_graph(source) + nodes, edges = parse(source) + + expect(nodes).not_to be_empty + expect(edges).to all(satisfy { |edge| nodes.include?(edge.node_a) && nodes.include?(edge.node_b) }) + end + + it "models while and until loops with an exit and a back edge" do + nodes, edges = parse(<<~RUBY) + while ready? + work + end + until finished? + wait + end + RUBY + + expect(nodes.map(&:to_a)).to include( + [:decision, "ready?"], + [:action, "work"], + [:decision, "finished?"], + [:action, "wait"] + ) + expect(edges.map(&:to_a)).to include( + ["ready?", "true", "->", "work"], + ["work", "↺", "->", "ready?"], + ["finished?", "false", "->", "wait"], + ["wait", "↺", "->", "finished?"] + ) + end + + it "models for loops as decisions with a loop-back edge" do + nodes, edges = parse(<<~RUBY) + for item in items + process(item) + end + RUBY + + expect(nodes.map(&:to_a)).to include( + [:decision, "for item in items"], + [:action, "process(item)"] + ) + expect(edges.map(&:to_a)).to include( + ["for item in items", "true", "->", "process(item)"], + ["process(item)", "↺", "->", "for item in items"] + ) + end + + it "keeps terminal statements from flowing into subsequent statements" do + nodes, edges = parse(<<~RUBY) + return result + unreachable + RUBY + + expect(nodes.map(&:to_a)).to eq([[:return, "result"], [:action, "unreachable"]]) + expect(edges).to be_empty + end + + it "models rescue, else, and ensure paths without losing protected code" do + source = <<~RUBY + begin + perform + rescue NetworkError => error + recover(error) + else + finish + ensure + cleanup + end + RUBY + + nodes, edges = parse(source) + + expect(nodes.map(&:to_a)).to include( + [:action, "perform"], + [:decision, "rescue"], + [:action, "recover(error)"], + [:action, "finish"], + [:action, "cleanup"] + ) + expect(edges.map(&:to_a)).to include( + ["perform", "success", "->", "finish"], + ["rescue", "NetworkError", "->", "recover(error)"], + ["finish", "ensure", "->", "cleanup"], + ["recover(error)", "ensure", "->", "cleanup"] + ) + end + + it "models guarded case-in branches and an else branch" do + nodes, edges = parse(<<~RUBY) + case response + in { ok: true, data: } if valid?(data) + process(data) + in { error: } + handle(error) + else + fallback + end + RUBY + + expect(nodes.map(&:to_a)).to include( + [:decision, "response"], + [:action, "process(data)"], + [:action, "handle(error)"], + [:action, "fallback"] + ) + expect(edges.map(&:to_a)).to include( + ["response", "{ok: true, data:} if valid?(data)", "->", "process(data)"], + ["response", "{error:}", "->", "handle(error)"], + ["response", "else", "->", "fallback"] + ) + end + + it "accepts broad modern expression syntax through safe action fallbacks" do + sources = [ + "total ||= fetch_total", + "count += 1", + "left, right = values", + "payload => {name:, role:}", + "options = {timeout:, retries:}", + "user&.profile&.name", + "lambda { _1 * 2 }", + "def relay(...) = target(...)\n", + "class << service\n def call = super\nend", + "<<~TEXT\n hello \#{name}\nTEXT", + "%i[one two three]" + ] + + sources.each { |source| assert_valid_graph(source) } + end + + it "retains valid graph references across control-flow constructs" do + assert_valid_graph(<<~RUBY) + if active? + while retryable? + attempt + break if successful? + end + elsif paused? + wait + else + stop + end + RUBY + end +end diff --git a/visualize_ruby.gemspec b/visualize_ruby.gemspec index 0c20408..cbb1dc0 100644 --- a/visualize_ruby.gemspec +++ b/visualize_ruby.gemspec @@ -20,14 +20,16 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 2.3" + # Prism's Parser translation keeps the AST API used by this gem while + # accepting current Ruby syntax (including Ruby 4.0). + spec.required_ruby_version = ">= 3.1" - spec.add_development_dependency "bundler", "~> 1.16" - spec.add_development_dependency "rake", "~> 12.3", ">= 12.3.1" - spec.add_development_dependency "rspec", "~> 3.7" - spec.add_development_dependency "activesupport", "~> 5.2" + spec.add_development_dependency "rake", "~> 13.2" + spec.add_development_dependency "rspec", "~> 3.13" + spec.add_development_dependency "activesupport", "~> 7.2" - spec.add_runtime_dependency "graphviz", "~> 1.0" - spec.add_runtime_dependency "dissociated_introspection", "~> 0.12.0" - spec.add_runtime_dependency "parser", ">= 2.3" + spec.add_runtime_dependency "graphviz", "~> 1.2" + spec.add_runtime_dependency "parser", ">= 3.3", "< 4.0" + spec.add_runtime_dependency "prism", ">= 1.6", "< 2.0" + spec.add_runtime_dependency "unparser", ">= 0.8", "< 1.0" end From cb743f5791cfb39dcf5645274733755a4c08ee21 Mon Sep 17 00:00:00 2001 From: Dustin Zeisler Date: Sun, 2 Aug 2026 08:57:38 -0700 Subject: [PATCH 2/3] Preserve Parser current compatibility --- lib/visualize_ruby/parser.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/visualize_ruby/parser.rb b/lib/visualize_ruby/parser.rb index 5868326..3655055 100644 --- a/lib/visualize_ruby/parser.rb +++ b/lib/visualize_ruby/parser.rb @@ -1,4 +1,5 @@ require "parser" +require "parser/current" require "prism" require_relative "parser/fragment" # Retained for callers that instantiate VisualizeRuby::Parser::Block directly. From 5ecdd2b33afb1abb8784772f3d66d07c085020f5 Mon Sep 17 00:00:00 2001 From: Dustin Zeisler Date: Sun, 2 Aug 2026 08:59:48 -0700 Subject: [PATCH 3/3] Add Ruby 4 OpenStruct test dependency --- visualize_ruby.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/visualize_ruby.gemspec b/visualize_ruby.gemspec index cbb1dc0..ba4e1f0 100644 --- a/visualize_ruby.gemspec +++ b/visualize_ruby.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rake", "~> 13.2" spec.add_development_dependency "rspec", "~> 3.13" spec.add_development_dependency "activesupport", "~> 7.2" + spec.add_development_dependency "ostruct", ">= 0.5", "< 1.0" spec.add_runtime_dependency "graphviz", "~> 1.2" spec.add_runtime_dependency "parser", ">= 3.3", "< 4.0"