Prism for Massive-Scale Code Analysis
Ruby 3.3 adopted Prism as its default parser. With it, you can build robust code analyzers without needing to master RuboCop's complex DSL, and it scales across thousands of files.

Ruby 3.3 adopted Prism as its default parser, and it comes with a clean API for traversing Abstract Syntax Trees. That means you can build robust code analyzers without needing to master RuboCop's complex DSL or write your own parser from scratch.
The core primitive is Prism::Visitor. You subclass it and define visit_* methods for the node types you care about. Prism calls them as it walks the tree, and super tells it to continue the traversal down into child nodes.
class YourVisitor < Prism::Visitor
def visit_call_node(node)
# node.name, node.receiver, node.arguments
super # continue traversal
end
def visit_def_node(node)
# node.name, node.location.start_line
super
end
def visit_symbol_node(node)
# node.value
super
end
end
Prism.parse(source).value.accept(YourVisitor.new)
To see this in practice, here is a dead code finder for private methods. It tracks every method call across a file and every private method definition, then compares the two sets at the end.
require "prism"
class DeadCodeFinder < Prism::Visitor
attr_reader :private_methods, :calls
def initialize
@private_methods = {}
@calls = []
# tracks whether we've passed the 'private' keyword
@in_private = false
end
def visit_call_node(node)
# flip flag when we hit the 'private' keyword
@in_private = true if private?(node)
# collect every method call in the file
@calls << node.name
super
end
def visit_symbol_node(node)
# catch before_action :method, passed as symbol, not a call
@calls << node.value.to_sym
super
end
def visit_def_node(node)
# ignore public methods
return super unless @in_private
# register private method + line
@private_methods[node.name] = { line: node.location.start_line }
super
end
private
def private?(node)
# 'private' alone, not 'object.private'
node.name == :private && node.receiver.nil?
end
end
The visit_symbol_node hook matters here because Rails patterns like before_action :set_report pass method names as symbols, not calls. Without it, set_report would incorrectly appear as dead code.
Consider this feature spread across three files, where several private methods are defined but never actually called:
# app/controllers/reports_controller.rb
class ReportsController < ApplicationController
before_action :set_report
def index
@reports = ReportService.new(current_user).call
end
private
def set_report
@report = Report.find(params[:id])
end
def format_csv(data)
# never called
data.map(&:to_csv).join("\n")
end
end
# app/models/report.rb
class Report < ApplicationRecord
belongs_to :user
scope :completed, -> { where(status: :completed) }
scope :archived, -> { where(status: :archived) }
private
def calculate_totals
# never called
line_items.sum(:amount)
end
end
# app/services/report_service.rb
class ReportService
def initialize(user)
@user = user
end
def call
fetch_orders
end
private
def fetch_orders
@user.orders.completed
end
def build_summary(orders)
# never called
orders.group_by(&:status)
end
def send_alert(message)
# never called
AdminMailer.alert(message).deliver_later
end
end
Running the visitor across all files is a two-pass process: collect every call and every private definition in one sweep, then subtract the called ones.
files = Dir.glob("app/**/*.rb").sort
all_calls = []
per_file = {}
files.each do |file|
# parse file into AST
result = Prism.parse(File.read(file))
finder = DeadCodeFinder.new
# walk the tree, triggering visit_* methods
result.value.accept(finder)
# accumulate all calls across files
all_calls.concat(finder.calls)
per_file[file] = finder.private_methods
end
# remove duplicates before cross-file comparison
all_calls.uniq!
per_file.each do |file, methods|
dead = methods.reject { |name, _| all_calls.include?(name) }
next if dead.empty?
puts file
dead.each { |name, meta| puts " line #{meta[:line]}: #{name}" }
end
The output is a precise list of dead private methods with their file paths and line numbers, ready to delete or investigate.
app/controllers/reports_controller.rb
line 14: format_csv
app/models/report.rb
line 9: calculate_totals
app/services/report_service.rb
line 16: build_summary
line 21: send_alert
This scales to thousands of files without modification. Prism parses fast enough that running this kind of analysis on a large Rails codebase takes seconds, not minutes. And because you control exactly which node types you visit and what you collect, adapting this pattern to other questions like unused constants, undocumented public methods, or call chains is straightforward.