Back to blog
Technical post

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.

Prism for Massive-Scale Code Analysis

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.