spoom 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +64 -55
  3. data/lib/spoom/backtrace_filter/minitest.rb +21 -0
  4. data/lib/spoom/cli/deadcode.rb +172 -0
  5. data/lib/spoom/cli/helper.rb +20 -0
  6. data/lib/spoom/cli/srb/bump.rb +200 -0
  7. data/lib/spoom/cli/srb/coverage.rb +224 -0
  8. data/lib/spoom/cli/srb/lsp.rb +159 -0
  9. data/lib/spoom/cli/srb/tc.rb +150 -0
  10. data/lib/spoom/cli/srb.rb +27 -0
  11. data/lib/spoom/cli.rb +72 -32
  12. data/lib/spoom/context/git.rb +2 -2
  13. data/lib/spoom/context/sorbet.rb +2 -2
  14. data/lib/spoom/deadcode/definition.rb +11 -0
  15. data/lib/spoom/deadcode/erb.rb +4 -4
  16. data/lib/spoom/deadcode/indexer.rb +266 -200
  17. data/lib/spoom/deadcode/location.rb +30 -2
  18. data/lib/spoom/deadcode/plugins/action_mailer.rb +21 -0
  19. data/lib/spoom/deadcode/plugins/action_mailer_preview.rb +19 -0
  20. data/lib/spoom/deadcode/plugins/actionpack.rb +59 -0
  21. data/lib/spoom/deadcode/plugins/active_job.rb +13 -0
  22. data/lib/spoom/deadcode/plugins/active_model.rb +46 -0
  23. data/lib/spoom/deadcode/plugins/active_record.rb +108 -0
  24. data/lib/spoom/deadcode/plugins/active_support.rb +32 -0
  25. data/lib/spoom/deadcode/plugins/base.rb +165 -12
  26. data/lib/spoom/deadcode/plugins/graphql.rb +47 -0
  27. data/lib/spoom/deadcode/plugins/minitest.rb +28 -0
  28. data/lib/spoom/deadcode/plugins/namespaces.rb +32 -0
  29. data/lib/spoom/deadcode/plugins/rails.rb +31 -0
  30. data/lib/spoom/deadcode/plugins/rake.rb +12 -0
  31. data/lib/spoom/deadcode/plugins/rspec.rb +19 -0
  32. data/lib/spoom/deadcode/plugins/rubocop.rb +41 -0
  33. data/lib/spoom/deadcode/plugins/ruby.rb +10 -18
  34. data/lib/spoom/deadcode/plugins/sorbet.rb +40 -0
  35. data/lib/spoom/deadcode/plugins/thor.rb +21 -0
  36. data/lib/spoom/deadcode/plugins.rb +91 -0
  37. data/lib/spoom/deadcode/remover.rb +651 -0
  38. data/lib/spoom/deadcode/send.rb +27 -6
  39. data/lib/spoom/deadcode/visitor.rb +755 -0
  40. data/lib/spoom/deadcode.rb +41 -10
  41. data/lib/spoom/file_tree.rb +0 -16
  42. data/lib/spoom/sorbet/errors.rb +1 -1
  43. data/lib/spoom/sorbet/lsp/structures.rb +2 -2
  44. data/lib/spoom/version.rb +1 -1
  45. metadata +36 -15
  46. data/lib/spoom/cli/bump.rb +0 -198
  47. data/lib/spoom/cli/coverage.rb +0 -222
  48. data/lib/spoom/cli/lsp.rb +0 -168
  49. data/lib/spoom/cli/run.rb +0 -148
@@ -1,222 +0,0 @@
1
- # typed: true
2
- # frozen_string_literal: true
3
-
4
- require_relative "../coverage"
5
- require_relative "../timeline"
6
-
7
- module Spoom
8
- module Cli
9
- class Coverage < Thor
10
- include Helper
11
-
12
- DATA_DIR = "spoom_data"
13
-
14
- default_task :snapshot
15
-
16
- desc "snapshot", "Run srb tc and display metrics"
17
- option :save, type: :string, lazy_default: DATA_DIR, desc: "Save snapshot data as json"
18
- option :rbi, type: :boolean, default: true, desc: "Include RBI files in metrics"
19
- option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
20
- def snapshot
21
- context = context_requiring_sorbet!
22
- sorbet = options[:sorbet]
23
-
24
- snapshot = Spoom::Coverage.snapshot(context, rbi: options[:rbi], sorbet_bin: sorbet)
25
- snapshot.print
26
-
27
- save_dir = options[:save]
28
- return unless save_dir
29
-
30
- FileUtils.mkdir_p(save_dir)
31
- file = "#{save_dir}/#{snapshot.commit_sha || snapshot.timestamp}.json"
32
- File.write(file, snapshot.to_json)
33
- say("\nSnapshot data saved under `#{file}`")
34
- end
35
-
36
- desc "timeline", "Replay a project and collect metrics"
37
- option :from, type: :string, desc: "From commit date"
38
- option :to, type: :string, default: Time.now.strftime("%F"), desc: "To commit date"
39
- option :save, type: :string, lazy_default: DATA_DIR, desc: "Save snapshot data as json"
40
- option :bundle_install, type: :boolean, desc: "Execute `bundle install` before collecting metrics"
41
- option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
42
- def timeline
43
- context = context_requiring_sorbet!
44
- path = exec_path
45
- sorbet = options[:sorbet]
46
-
47
- ref_before = context.git_current_branch
48
- ref_before = context.git_last_commit&.sha unless ref_before
49
- unless ref_before
50
- say_error("Not in a git repository")
51
- say_error("\nSpoom needs to checkout into your previous commits to build the timeline.", status: nil)
52
- exit(1)
53
- end
54
-
55
- unless context.git_workdir_clean?
56
- say_error("Uncommited changes")
57
- say_error(<<~ERR, status: nil)
58
-
59
- Spoom needs to checkout into your previous commits to build the timeline."
60
-
61
- Please `git commit` or `git stash` your changes then try again
62
- ERR
63
- exit(1)
64
- end
65
-
66
- save_dir = options[:save]
67
- FileUtils.mkdir_p(save_dir) if save_dir
68
-
69
- from = parse_time(options[:from], "--from")
70
- to = parse_time(options[:to], "--to")
71
-
72
- unless from
73
- intro_commit = context.sorbet_intro_commit
74
- intro_commit = T.must(intro_commit) # we know it's in there since in_sorbet_project!
75
- from = intro_commit.time
76
- end
77
-
78
- timeline = Spoom::Timeline.new(context, from, to)
79
- ticks = timeline.ticks
80
-
81
- if ticks.empty?
82
- say_error("No commits to replay, try different `--from` and `--to` options")
83
- exit(1)
84
- end
85
-
86
- ticks.each_with_index do |commit, i|
87
- say("Analyzing commit `#{commit.sha}` - #{commit.time.strftime("%F")} (#{i + 1} / #{ticks.size})")
88
-
89
- context.git_checkout!(ref: commit.sha)
90
-
91
- snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
92
- if options[:bundle_install]
93
- Bundler.with_unbundled_env do
94
- next unless bundle_install(path, commit.sha)
95
-
96
- snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
97
- end
98
- else
99
- snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
100
- end
101
- next unless snapshot
102
-
103
- snapshot.print(indent_level: 2)
104
- say("\n")
105
-
106
- next unless save_dir
107
-
108
- file = "#{save_dir}/#{commit.sha}.json"
109
- File.write(file, snapshot.to_json)
110
- say(" Snapshot data saved under `#{file}`\n\n")
111
- end
112
- context.git_checkout!(ref: ref_before)
113
- end
114
-
115
- desc "report", "Produce a typing coverage report"
116
- option :data, type: :string, default: DATA_DIR, desc: "Snapshots JSON data"
117
- option :file,
118
- type: :string,
119
- default: "spoom_report.html",
120
- aliases: :f,
121
- desc: "Save report to file"
122
- option :color_ignore,
123
- type: :string,
124
- default: Spoom::Coverage::D3::COLOR_IGNORE,
125
- desc: "Color used for typed: ignore"
126
- option :color_false,
127
- type: :string,
128
- default: Spoom::Coverage::D3::COLOR_FALSE,
129
- desc: "Color used for typed: false"
130
- option :color_true,
131
- type: :string,
132
- default: Spoom::Coverage::D3::COLOR_TRUE,
133
- desc: "Color used for typed: true"
134
- option :color_strict,
135
- type: :string,
136
- default: Spoom::Coverage::D3::COLOR_STRICT,
137
- desc: "Color used for typed: strict"
138
- option :color_strong,
139
- type: :string,
140
- default: Spoom::Coverage::D3::COLOR_STRONG,
141
- desc: "Color used for typed: strong"
142
- def report
143
- context = context_requiring_sorbet!
144
-
145
- data_dir = options[:data]
146
- files = Dir.glob("#{data_dir}/*.json")
147
- if files.empty?
148
- message_no_data(data_dir)
149
- exit(1)
150
- end
151
-
152
- snapshots = files.sort.map do |file|
153
- json = File.read(file)
154
- Spoom::Coverage::Snapshot.from_json(json)
155
- end.filter(&:commit_timestamp).sort_by!(&:commit_timestamp)
156
-
157
- palette = Spoom::Coverage::D3::ColorPalette.new(
158
- ignore: options[:color_ignore],
159
- false: options[:color_false],
160
- true: options[:color_true],
161
- strict: options[:color_strict],
162
- strong: options[:color_strong],
163
- )
164
-
165
- report = Spoom::Coverage.report(context, snapshots, palette: palette)
166
- file = options[:file]
167
- File.write(file, report.html)
168
- say("Report generated under `#{file}`")
169
- say("\nUse `spoom coverage open` to open it.")
170
- end
171
-
172
- desc "open", "Open the typing coverage report"
173
- def open(file = "spoom_report.html")
174
- unless File.exist?(file)
175
- say_error("No report file to open `#{file}`")
176
- say_error(<<~ERR, status: nil)
177
-
178
- If you already generated a report under another name use #{blue("spoom coverage open PATH")}.
179
-
180
- To generate a report run #{blue("spoom coverage report")}.
181
- ERR
182
- exit(1)
183
- end
184
-
185
- exec("open #{file}")
186
- end
187
-
188
- no_commands do
189
- def parse_time(string, option)
190
- return unless string
191
-
192
- Time.parse(string)
193
- rescue ArgumentError
194
- say_error("Invalid date `#{string}` for option `#{option}` (expected format `YYYY-MM-DD`)")
195
- exit(1)
196
- end
197
-
198
- def bundle_install(path, sha)
199
- opts = {}
200
- opts[:chdir] = path
201
- out, status = Open3.capture2e("bundle install", opts)
202
- unless status.success?
203
- say_error("Can't run `bundle install` for commit `#{sha}`. Skipping snapshot")
204
- say_error(out, status: nil)
205
- return false
206
- end
207
- true
208
- end
209
-
210
- def message_no_data(file)
211
- say_error("No snapshot files found in `#{file}`")
212
- say_error(<<~ERR, status: nil)
213
-
214
- If you already generated snapshot files under another directory use #{blue("spoom coverage report PATH")}.
215
-
216
- To generate snapshot files run #{blue("spoom coverage timeline --save")}.
217
- ERR
218
- end
219
- end
220
- end
221
- end
222
- end
data/lib/spoom/cli/lsp.rb DELETED
@@ -1,168 +0,0 @@
1
- # typed: true
2
- # frozen_string_literal: true
3
-
4
- require "shellwords"
5
-
6
- require_relative "../sorbet/lsp"
7
-
8
- module Spoom
9
- module Cli
10
- class LSP < Thor
11
- include Helper
12
-
13
- default_task :show
14
-
15
- desc "interactive", "Interactive LSP mode"
16
- def show
17
- context_requiring_sorbet!
18
-
19
- lsp = lsp_client
20
- # TODO: run interactive mode
21
- puts lsp
22
- end
23
-
24
- desc "list", "List all known symbols"
25
- # TODO: options, filter, limit, kind etc.. filter rbi
26
- def list
27
- run do |client|
28
- printer = symbol_printer
29
- Dir["**/*.rb"].each do |file|
30
- res = client.document_symbols(to_uri(file))
31
- next if res.empty?
32
-
33
- say("Symbols from `#{file}`:")
34
- printer.print_objects(res)
35
- end
36
- end
37
- end
38
-
39
- desc "hover", "Request hover informations"
40
- # TODO: options, filter, limit, kind etc.. filter rbi
41
- def hover(file, line, col)
42
- run do |client|
43
- res = client.hover(to_uri(file), line.to_i, col.to_i)
44
- say("Hovering `#{file}:#{line}:#{col}`:")
45
- if res
46
- symbol_printer.print_object(res)
47
- else
48
- say("<no data>")
49
- end
50
- end
51
- end
52
-
53
- desc "defs", "List definitions of a symbol"
54
- # TODO: options, filter, limit, kind etc.. filter rbi
55
- def defs(file, line, col)
56
- run do |client|
57
- res = client.definitions(to_uri(file), line.to_i, col.to_i)
58
- say("Definitions for `#{file}:#{line}:#{col}`:")
59
- symbol_printer.print_list(res)
60
- end
61
- end
62
-
63
- desc "find", "Find symbols matching a query"
64
- # TODO: options, filter, limit, kind etc.. filter rbi
65
- def find(query)
66
- run do |client|
67
- res = client.symbols(query).reject { |symbol| symbol.location.uri.start_with?("https") }
68
- say("Symbols matching `#{query}`:")
69
- symbol_printer.print_objects(res)
70
- end
71
- end
72
-
73
- desc "symbols", "List symbols from a file"
74
- # TODO: options, filter, limit, kind etc.. filter rbi
75
- def symbols(file)
76
- run do |client|
77
- res = client.document_symbols(to_uri(file))
78
- say("Symbols from `#{file}`:")
79
- symbol_printer.print_objects(res)
80
- end
81
- end
82
-
83
- desc "refs", "List references to a symbol"
84
- # TODO: options, filter, limit, kind etc.. filter rbi
85
- def refs(file, line, col)
86
- run do |client|
87
- res = client.references(to_uri(file), line.to_i, col.to_i)
88
- say("References to `#{file}:#{line}:#{col}`:")
89
- symbol_printer.print_list(res)
90
- end
91
- end
92
-
93
- desc "sigs", "List signatures for a symbol"
94
- # TODO: options, filter, limit, kind etc.. filter rbi
95
- def sigs(file, line, col)
96
- run do |client|
97
- res = client.signatures(to_uri(file), line.to_i, col.to_i)
98
- say("Signature for `#{file}:#{line}:#{col}`:")
99
- symbol_printer.print_list(res)
100
- end
101
- end
102
-
103
- desc "types", "Display type of a symbol"
104
- # TODO: options, filter, limit, kind etc.. filter rbi
105
- def types(file, line, col)
106
- run do |client|
107
- res = client.type_definitions(to_uri(file), line.to_i, col.to_i)
108
- say("Type for `#{file}:#{line}:#{col}`:")
109
- symbol_printer.print_list(res)
110
- end
111
- end
112
-
113
- no_commands do
114
- def lsp_client
115
- context_requiring_sorbet!
116
-
117
- path = exec_path
118
- client = Spoom::LSP::Client.new(
119
- Spoom::Sorbet::BIN_PATH,
120
- "--lsp",
121
- "--enable-all-experimental-lsp-features",
122
- "--disable-watchman",
123
- path: path,
124
- )
125
- client.open(File.expand_path(path))
126
- client
127
- end
128
-
129
- def symbol_printer
130
- Spoom::LSP::SymbolPrinter.new(
131
- indent_level: 2,
132
- colors: options[:color],
133
- prefix: "file://#{File.expand_path(exec_path)}",
134
- )
135
- end
136
-
137
- def run(&block)
138
- client = lsp_client
139
- block.call(client)
140
- rescue Spoom::LSP::Error::Diagnostics => err
141
- say_error("Sorbet returned typechecking errors for `#{symbol_printer.clean_uri(err.uri)}`")
142
- err.diagnostics.each do |d|
143
- say_error("#{d.message} (#{d.code})", status: " #{d.range}")
144
- end
145
- exit(1)
146
- rescue Spoom::LSP::Error::BadHeaders => err
147
- say_error("Sorbet didn't answer correctly (#{err.message})")
148
- exit(1)
149
- rescue Spoom::LSP::Error => err
150
- say_error(err.message)
151
- exit(1)
152
- ensure
153
- begin
154
- client&.close
155
- rescue
156
- # We can't do much if Sorbet refuse to close.
157
- # We kill the parent process and let the child be killed.
158
- exit(1)
159
- end
160
- end
161
-
162
- def to_uri(path)
163
- "file://" + File.join(File.expand_path(exec_path), path)
164
- end
165
- end
166
- end
167
- end
168
- end
data/lib/spoom/cli/run.rb DELETED
@@ -1,148 +0,0 @@
1
- # typed: true
2
- # frozen_string_literal: true
3
-
4
- module Spoom
5
- module Cli
6
- class Run < Thor
7
- include Helper
8
-
9
- default_task :tc
10
-
11
- SORT_CODE = "code"
12
- SORT_LOC = "loc"
13
- SORT_ENUM = [SORT_CODE, SORT_LOC]
14
-
15
- DEFAULT_FORMAT = "%C - %F:%L: %M"
16
-
17
- desc "tc", "Run `srb tc`"
18
- option :limit, type: :numeric, aliases: :l, desc: "Limit displayed errors"
19
- option :code, type: :numeric, aliases: :c, desc: "Filter displayed errors by code"
20
- option :sort, type: :string, aliases: :s, desc: "Sort errors", enum: SORT_ENUM, default: SORT_LOC
21
- option :format, type: :string, aliases: :f, desc: "Format line output"
22
- option :uniq, type: :boolean, aliases: :u, desc: "Remove duplicated lines"
23
- option :count, type: :boolean, default: true, desc: "Show errors count"
24
- option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
25
- option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
26
- def tc(*paths_to_select)
27
- context = context_requiring_sorbet!
28
- limit = options[:limit]
29
- sort = options[:sort]
30
- code = options[:code]
31
- uniq = options[:uniq]
32
- format = options[:format]
33
- count = options[:count]
34
- sorbet = options[:sorbet]
35
-
36
- unless limit || code || sort
37
- result = T.unsafe(context).srb_tc(
38
- *options[:sorbet_options].split(" "),
39
- capture_err: false,
40
- sorbet_bin: sorbet,
41
- )
42
-
43
- say_error(result.err, status: nil, nl: false)
44
- exit(result.status)
45
- end
46
-
47
- error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE
48
- result = T.unsafe(context).srb_tc(
49
- *options[:sorbet_options].split(" "),
50
- "--error-url-base=#{error_url_base}",
51
- capture_err: true,
52
- sorbet_bin: sorbet,
53
- )
54
-
55
- if result.status
56
- say_error(result.err, status: nil, nl: false)
57
- exit(0)
58
- end
59
-
60
- unless result.exit_code == 100
61
- # Sorbet will return exit code 100 if there are type checking errors.
62
- # If Sorbet returned something else, it means it didn't terminate normally.
63
- say_error(result.err, status: nil, nl: false)
64
- exit(1)
65
- end
66
-
67
- errors = Spoom::Sorbet::Errors::Parser.parse_string(result.err, error_url_base: error_url_base)
68
- errors_count = errors.size
69
-
70
- errors = errors.select { |e| e.code == code } if code
71
-
72
- unless paths_to_select.empty?
73
- errors.select! do |error|
74
- paths_to_select.any? { |path_to_select| error.file&.start_with?(path_to_select) }
75
- end
76
- end
77
-
78
- errors = case sort
79
- when SORT_CODE
80
- Spoom::Sorbet::Errors.sort_errors_by_code(errors)
81
- when SORT_LOC
82
- errors.sort
83
- else
84
- errors # preserve natural sort
85
- end
86
-
87
- errors = T.must(errors.slice(0, limit)) if limit
88
-
89
- lines = errors.map { |e| format_error(e, format || DEFAULT_FORMAT) }
90
- lines = lines.uniq if uniq
91
-
92
- lines.each do |line|
93
- say_error(line, status: nil)
94
- end
95
-
96
- if count
97
- if errors_count == errors.size
98
- say_error("Errors: #{errors_count}", status: nil)
99
- else
100
- say_error("Errors: #{errors.size} shown, #{errors_count} total", status: nil)
101
- end
102
- end
103
-
104
- exit(1)
105
- rescue Spoom::Sorbet::Error::Segfault => error
106
- say_error(<<~ERR, status: nil)
107
- #{red("!!! Sorbet exited with code #{error.result.exit_code} - SEGFAULT !!!")}
108
-
109
- This is most likely related to a bug in Sorbet.
110
- ERR
111
-
112
- exit(error.result.exit_code)
113
- rescue Spoom::Sorbet::Error::Killed => error
114
- say_error(<<~ERR, status: nil)
115
- #{red("!!! Sorbet exited with code #{error.result.exit_code} - KILLED !!!")}
116
- ERR
117
-
118
- exit(error.result.exit_code)
119
- end
120
-
121
- no_commands do
122
- def format_error(error, format)
123
- line = format
124
- line = line.gsub(/%C/, yellow(error.code.to_s))
125
- line = line.gsub(/%F/, error.file)
126
- line = line.gsub(/%L/, error.line.to_s)
127
- line = line.gsub(/%M/, colorize_message(error.message))
128
- line
129
- end
130
-
131
- def colorize_message(message)
132
- return message unless color?
133
-
134
- cyan = T.let(false, T::Boolean)
135
- word = StringIO.new
136
- message.chars.each do |c|
137
- if c == "`"
138
- cyan = !cyan
139
- next
140
- end
141
- word << (cyan ? cyan(c) : red(c))
142
- end
143
- word.string
144
- end
145
- end
146
- end
147
- end
148
- end