-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add CLI commands for dead code detection and removal
Signed-off-by: Alexandre Terrasa <alexandre.terrasa@shopify.com>
- Loading branch information
Showing
4 changed files
with
358 additions
and
0 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
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 |
This file contains 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
This file contains 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
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 |