Skip to content

Commit

Permalink
Add CLI commands for dead code detection and removal
Browse files Browse the repository at this point in the history
Signed-off-by: Alexandre Terrasa <alexandre.terrasa@shopify.com>
  • Loading branch information
Morriar committed Mar 6, 2024
1 parent 9c9aed2 commit 638b45b
Show file tree
Hide file tree
Showing 4 changed files with 358 additions and 0 deletions.
4 changes: 4 additions & 0 deletions lib/spoom/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "cli/helper"

require_relative "cli/bump"
require_relative "cli/deadcode"
require_relative "cli/lsp"
require_relative "cli/coverage"
require_relative "cli/run"
Expand All @@ -27,6 +28,9 @@ class Main < Thor
desc "coverage", "Collect metrics related to Sorbet coverage"
subcommand "coverage", Spoom::Cli::Coverage

desc "deadcode", "Analyze code to find deadcode"
subcommand "deadcode", Spoom::Cli::Deadcode

desc "lsp", "Send LSP requests to Sorbet"
subcommand "lsp", Spoom::Cli::LSP

Expand Down
172 changes: 172 additions & 0 deletions lib/spoom/cli/deadcode.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# typed: true
# frozen_string_literal: true

require_relative "../deadcode"

module Spoom
module Cli
class Deadcode < Thor
extend T::Sig
include Helper

default_task :deadcode

desc "deadcode PATH...", "Analyze PATHS to find dead code"
option :allowed_extensions,
type: :array,
default: [".rb", ".erb", ".gemspec"],
aliases: :e,
desc: "Allowed extensions"
option :allowed_mime_types,
type: :array,
default: ["text/x-ruby", "text/x-ruby-script"],
aliases: :m,
desc: "Allowed mime types"
option :exclude,
type: :array,
default: ["vendor/", "sorbet/"],
aliases: :x,
desc: "Exclude paths"
option :show_files,
type: :boolean,
default: false,
desc: "Show the files that will be analyzed"
option :show_plugins,
type: :boolean,
default: false,
desc: "Show the loaded plugins"
option :show_defs,
type: :boolean,
default: false,
desc: "Show the indexed definitions"
option :show_refs,
type: :boolean,
default: false,
desc: "Show the indexed references"
option :sort,
type: :string,
default: "name",
enum: ["name", "location"],
desc: "Sort the output by name or location"
sig { params(paths: String).void }
def deadcode(*paths)
context = self.context

paths << exec_path if paths.empty?

$stderr.puts "Collecting files..."
collector = FileCollector.new(
allow_extensions: options[:allowed_extensions],
allow_mime_types: options[:allowed_mime_types],
exclude_patterns: options[:exclude].map { |p| Pathname.new(File.join(exec_path, p, "**")).cleanpath.to_s },
)
collector.visit_paths(paths)
files = collector.files.sort

if options[:show_files]
$stderr.puts "\nCollected #{blue(files.size.to_s)} files for analysis\n"
files.each do |file|
$stderr.puts " #{gray(file)}"
end
$stderr.puts
end

plugins = Spoom::Deadcode.plugins_from_gemfile_lock(context)
if options[:show_plugins]
$stderr.puts "\nLoaded #{blue(plugins.size.to_s)} plugins\n"
plugins.each do |plugin|
$stderr.puts " #{gray(plugin.class.to_s)}"
end
$stderr.puts
end

index = Spoom::Deadcode::Index.new

$stderr.puts "Indexing #{blue(files.size.to_s)} files..."
files.each do |file|
content = File.read(file)
content = ERB.new(content).src if file.end_with?(".erb")

tree = Spoom::Deadcode.parse_ruby(content, file: file)
Spoom::Deadcode.index_node(index, tree, content, file: file, plugins: plugins)
rescue Spoom::Deadcode::ParserError => e
say_error("Error parsing #{file}: #{e.message}")
next
rescue Spoom::Deadcode::IndexerError => e
say_error("Error indexing #{file}: #{e.message}")
next
end

if options[:show_defs]
$stderr.puts "\nDefinitions:"
index.definitions.each do |name, definitions|
$stderr.puts " #{blue(name)}"
definitions.each do |definition|
$stderr.puts " #{yellow(definition.kind.serialize)} #{gray(definition.location.to_s)}"
end
end
$stderr.puts
end

if options[:show_refs]
$stderr.puts "\nReferences:"
index.references.values.flatten.sort_by(&:name).each do |references|
name = references.name
kind = references.kind.serialize
loc = references.location.to_s
$stderr.puts " #{blue(name)} #{yellow(kind)} #{gray(loc)}"
end
$stderr.puts
end

definitions_count = index.definitions.size.to_s
references_count = index.references.size.to_s
$stderr.puts "Analyzing #{blue(definitions_count)} definitions against #{blue(references_count)} references..."

index.finalize!
dead = index.definitions.values.flatten.select(&:dead?)

if options[:sort] == "name"
dead.sort_by!(&:name)
else
dead.sort_by!(&:location)
end

if dead.empty?
$stderr.puts "\n#{green("No dead code found!")}"
else
$stderr.puts "\nCandidates:"
dead.each do |definition|
$stderr.puts " #{red(definition.full_name)} #{gray(definition.location.to_s)}"
end
$stderr.puts "\n"
$stderr.puts red(" Found #{dead.size} dead candidates")

exit(1)
end
end

desc "remove LOCATION", "Remove dead code at LOCATION"
def remove(location_string)
location = Spoom::Deadcode::Location.from_string(location_string)
context = self.context
remover = Spoom::Deadcode::Remover.new(context)

new_source = remover.remove_location(nil, location)
context.write!("PATCH", new_source)

diff = context.exec("diff -u #{location.file} PATCH")
$stderr.puts T.must(diff.out.lines[2..-1]).join
context.remove!("PATCH")

context.write!(location.file, new_source)
rescue Spoom::Deadcode::Remover::Error => e
say_error("Can't remove code at #{location_string}: #{e.message}")
exit(1)
rescue Spoom::Deadcode::Location::LocationError => e
say_error(e.message)
exit(1)
end
end
end
end
1 change: 1 addition & 0 deletions test/spoom/cli/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def test_display_help_long_option
spoom --version # Show version
spoom bump # Bump Sorbet sigils from `false` to `true` when no errors
spoom coverage # Collect metrics related to Sorbet coverage
spoom deadcode # Analyze code to find deadcode
spoom help [COMMAND] # Describe available commands or one specific command
spoom lsp # Send LSP requests to Sorbet
spoom tc # Run Sorbet and parses its output
Expand Down
181 changes: 181 additions & 0 deletions test/spoom/cli/deadcode_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# typed: true
# frozen_string_literal: true

require "test_with_project"

module Spoom
module Cli
class DeadcodeTest < TestWithProject
def test_deadcode_without_deadcode
@project.write!("lib/foo.rb", <<~RUBY)
def foo; end
def bar; end
def baz; end
foo; bar; baz
RUBY

result = @project.spoom("deadcode --no-color")
assert_equal(<<~ERR, result.err)
Collecting files...
Indexing 1 files...
Analyzing 3 definitions against 3 references...
No dead code found!
ERR
assert_empty(result.out)
assert(result.status)
end

def test_deadcode_with_deadcode
@project.write!("lib/foo.rb", <<~RUBY)
def foo; end
def bar; end
def baz; end
foo; bar
RUBY

result = @project.spoom("deadcode --no-color")
assert_equal(<<~ERR, result.err)
Collecting files...
Indexing 1 files...
Analyzing 3 definitions against 2 references...
Candidates:
baz lib/foo.rb:3:0-3:12
Found 1 dead candidates
ERR
assert_empty(result.out)
refute(result.status)
end

def test_deadcode_show_files
@project.write!("lib/foo.rb", <<~RUBY)
def foo; end
def bar; end
def baz; end
RUBY

@project.write!("lib/bar.rb", <<~RUBY)
foo; bar; baz
RUBY

result = @project.spoom("deadcode --show-files --no-color")
assert_equal(<<~ERR, result.err)
Collecting files...
Collected 2 files for analysis
lib/bar.rb
lib/foo.rb
Indexing 2 files...
Analyzing 3 definitions against 3 references...
No dead code found!
ERR
assert_empty(result.out)
assert(result.status)
end

def test_deadcode_show_defs
@project.write!("lib/foo.rb", <<~RUBY)
def foo; end
def bar; end
def baz; end
foo; bar; baz
RUBY

result = @project.spoom("deadcode --show-defs --no-color")
assert_equal(<<~ERR, result.err)
Collecting files...
Indexing 1 files...
Definitions:
foo
method lib/foo.rb:1:0-1:12
bar
method lib/foo.rb:2:0-2:12
baz
method lib/foo.rb:3:0-3:12
Analyzing 3 definitions against 3 references...
No dead code found!
ERR
assert_empty(result.out)
assert(result.status)
end

def test_deadcode_show_refs
@project.write!("lib/foo.rb", <<~RUBY)
def foo; end
def bar; end
def baz; end
foo; bar; baz
RUBY

result = @project.spoom("deadcode --show-refs --no-color")
assert_equal(<<~ERR, result.err)
Collecting files...
Indexing 1 files...
References:
bar method lib/foo.rb:5:5-5:8
baz method lib/foo.rb:5:10-5:13
foo method lib/foo.rb:5:0-5:3
Analyzing 3 definitions against 3 references...
No dead code found!
ERR
assert_empty(result.out)
assert(result.status)
end

def test_deadcode_show_plugins_default
result = @project.spoom("deadcode --show-plugins --no-color")
assert_equal(<<~ERR, result.err)
Collecting files...
Loaded 6 plugins
Spoom::Deadcode::Plugins::Namespaces
Spoom::Deadcode::Plugins::Ruby
Spoom::Deadcode::Plugins::Minitest
Spoom::Deadcode::Plugins::Rake
Spoom::Deadcode::Plugins::Sorbet
Spoom::Deadcode::Plugins::Thor
Indexing 0 files...
Analyzing 0 definitions against 0 references...
No dead code found!
ERR
assert_empty(result.out)
assert(result.status)
end

def test_remove
@project.write!("lib/foo.rb", <<~RUBY)
def foo; end
def bar; end
def baz; end
RUBY

result = @project.spoom("deadcode remove lib/foo.rb:2:0-2:12 --no-color")

assert_equal(<<~ERR, result.err)
@@ -1,3 +1,2 @@
def foo; end
-def bar; end
def baz; end
ERR

assert_empty(result.out)
assert(result.status)
end
end
end
end

0 comments on commit 638b45b

Please sign in to comment.