Last active
May 19, 2020 08:17
-
-
Save thegedge/c05da047120c6b208dccce6025232610 to your computer and use it in GitHub Desktop.
Find unused Ruby methods (expect false positives) via static analysis
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# frozen_string_literal: true | |
require "csv" | |
require "set" | |
begin | |
require "erubi" | |
rescue LoadError | |
end | |
class ComputeCallsAndDefinitions | |
include Enumerable | |
attr_reader :called, :defined | |
def initialize(paths) | |
@paths = paths | |
@called = Set.new | |
@defined = {} | |
compute_calls_and_definitions | |
end | |
private | |
def compute_calls_and_definitions | |
@paths.each do |path| | |
next if path =~ %r{\b(test|spec|vendor|sorbet)\b} | |
ast = parse(path) | |
next unless ast | |
# TODO also keep track of the callers. If it's only in test, probably dead | |
walk(ast) do |node, depth| | |
case node.type.downcase | |
# | |
# Method calls | |
# | |
# obj.foo(...) | |
when :call, :send, :csend | |
called << node.children[1].to_s | |
# obj&.foo(...) | |
when :qcall | |
called << node.children[1].to_s | |
# foo(...) | |
when :fcall | |
called << node.children[0].to_s | |
# foo | |
when :vcall | |
called << node.children[0].to_s | |
# A string/symbol literal could potentially be a method name | |
when :lit, :str, :sym | |
lit = node.children[0].to_s | |
begin | |
called << lit.to_s if lit.is_a?(Symbol) || lit.is_a?(String) | |
rescue EncodingError | |
# Ignore | |
end | |
# | |
# Method definitions | |
# | |
# def foo; ...; end | |
when :def, :defn | |
# Liquid drops have a lot of deefined functions used xternally | |
# TODO this is technically specified in YAML files, so we could get it from there | |
# TODO copied below, factor out handling here | |
next if path.end_with?("_drop.rb") | |
name = node.children[0].to_s | |
defined[name] ||= Set.new | |
defined[name] << path.to_s | |
# def foo.bar; ...; end | |
when :defs | |
next if path.end_with?("_drop.rb") | |
name = node.children[1].to_s | |
defined[name] ||= Set.new | |
defined[name] << path.to_s | |
# TODO :alias | |
# TODO :undef | |
end | |
end | |
end | |
end | |
def walk(node, depth = 0) | |
return unless node | |
return unless node.is_a?(RubyVM::AbstractSyntaxTree::Node) | |
yield node, depth | |
node.children.each do |node| | |
walk(node, depth + 1) do |node, depth| | |
yield node, depth | |
end | |
end | |
end | |
def parse(path) | |
if path =~ /\.erb$/ | |
return nil unless defined?(ActionView::Template::Handlers::ERB::Erubi) | |
# We can't use ERB, because it doesn't produce proper code for | |
# | |
# <%= foo do |x| %> | |
# ... | |
# <% end %> | |
# | |
source, _encoding = ActionView::Template::Handlers::ERB::Erubi.new(File.read(path)).src | |
RubyVM::AbstractSyntaxTree.parse(source) | |
else | |
RubyVM::AbstractSyntaxTree.parse_file(path) | |
end | |
rescue Exception | |
STDERR.puts("Failed to parse #{path}") | |
nil | |
end | |
end | |
def exclude?(name, calls, paths) | |
# Assume that the same function name defined in two or more paths is called by something external | |
return true if paths.size > 1 | |
# GraphQL response types have a lot of derive_/load_ things that are called in an external gem | |
return true if name.start_with?("derive_") || name.start_with?("load_") | |
# Liquid filters will be called by themes/templates/etc | |
return true if paths.any? { |path| path.end_with?("_filter.rb") } | |
# Event-y and callback-y methods | |
return true if name.end_with?("_callback") || name.start_with?("visit_") || name.start_with?("on_") | |
# Serialization calls these | |
return true if name.start_with?("include_") | |
# Currently ignoring all assign functions (TODO: properly detect when these are called) | |
return true if name.end_with?("=") | |
# Ignore things in controllers (TODO: parse routes file, or simply ignore public methods in controllers) | |
return true if paths.any? { |p| p.end_with?("_controller.rb") } | |
# REMOVEME only intended for discourse example | |
return true if name.start_with?("can_") && calls.include?("ensure_#{name}") | |
false | |
end | |
def paths(base) | |
Dir["#{base}/*.{rb,erb,rake}"] | |
end | |
def main(args) | |
args = ["."] if args.empty? | |
files = args.flat_map do |path| | |
base = File.join(path, "**") | |
paths(base) | |
end | |
data = ComputeCallsAndDefinitions.new(files) | |
# If input isn't piped in, but coming from a user, the stream will be associated with a TTY | |
defined = if STDIN.tty? | |
data.defined | |
else | |
STDIN.read.lines.to_h { |line| [line.chomp, ["stdin"]] } | |
end | |
calls = data.called | |
csv = CSV.new(STDOUT) | |
csv << ["Method Name", "Defined In", "Referenced outside tests?"] | |
defined.each do |name, paths| | |
name = name | |
next if exclude?(name, calls, paths) | |
is_referenced = calls.include?(name) | |
paths.each do |path| | |
csv << [name, path, is_referenced] | |
end | |
end | |
end | |
main(ARGV) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment