spoom 1.0.2 → 1.0.7
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.
- checksums.yaml +4 -4
- data/Gemfile +4 -0
- data/README.md +253 -1
- data/Rakefile +2 -0
- data/exe/spoom +7 -0
- data/lib/spoom.rb +9 -1
- data/lib/spoom/cli.rb +68 -0
- data/lib/spoom/cli/bump.rb +60 -0
- data/lib/spoom/cli/config.rb +51 -0
- data/lib/spoom/cli/coverage.rb +191 -0
- data/lib/spoom/cli/helper.rb +74 -0
- data/lib/spoom/cli/lsp.rb +165 -0
- data/lib/spoom/cli/run.rb +79 -0
- data/lib/spoom/config.rb +13 -0
- data/lib/spoom/coverage.rb +73 -0
- data/lib/spoom/coverage/d3.rb +110 -0
- data/lib/spoom/coverage/d3/base.rb +50 -0
- data/lib/spoom/coverage/d3/circle_map.rb +195 -0
- data/lib/spoom/coverage/d3/pie.rb +175 -0
- data/lib/spoom/coverage/d3/timeline.rb +486 -0
- data/lib/spoom/coverage/report.rb +308 -0
- data/lib/spoom/coverage/snapshot.rb +132 -0
- data/lib/spoom/file_tree.rb +196 -0
- data/lib/spoom/git.rb +98 -0
- data/lib/spoom/printer.rb +81 -0
- data/lib/spoom/sorbet.rb +83 -0
- data/lib/spoom/sorbet/config.rb +21 -9
- data/lib/spoom/sorbet/errors.rb +139 -0
- data/lib/spoom/sorbet/lsp.rb +196 -0
- data/lib/spoom/sorbet/lsp/base.rb +58 -0
- data/lib/spoom/sorbet/lsp/errors.rb +45 -0
- data/lib/spoom/sorbet/lsp/structures.rb +312 -0
- data/lib/spoom/sorbet/metrics.rb +33 -0
- data/lib/spoom/sorbet/sigils.rb +98 -0
- data/lib/spoom/test_helpers/project.rb +103 -0
- data/lib/spoom/timeline.rb +53 -0
- data/lib/spoom/version.rb +2 -1
- data/templates/card.erb +8 -0
- data/templates/card_snapshot.erb +22 -0
- data/templates/page.erb +50 -0
- metadata +80 -20
@@ -0,0 +1,51 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative "../file_tree"
|
5
|
+
require_relative "../sorbet/config"
|
6
|
+
|
7
|
+
module Spoom
|
8
|
+
module Cli
|
9
|
+
class Config < Thor
|
10
|
+
include Helper
|
11
|
+
|
12
|
+
default_task :show
|
13
|
+
|
14
|
+
desc "show", "show Sorbet config"
|
15
|
+
def show
|
16
|
+
in_sorbet_project!
|
17
|
+
config = Spoom::Sorbet::Config.parse_file(sorbet_config)
|
18
|
+
|
19
|
+
say("Found Sorbet config at `#{sorbet_config}`.")
|
20
|
+
|
21
|
+
say("\nPaths typechecked:")
|
22
|
+
if config.paths.empty?
|
23
|
+
say(" * (default: .)")
|
24
|
+
else
|
25
|
+
config.paths.each do |path|
|
26
|
+
say(" * #{path}")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
say("\nPaths ignored:")
|
31
|
+
if config.ignore.empty?
|
32
|
+
say(" * (default: none)")
|
33
|
+
else
|
34
|
+
config.ignore.each do |path|
|
35
|
+
say(" * #{path}")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
say("\nAllowed extensions:")
|
40
|
+
if config.allowed_extensions.empty?
|
41
|
+
say(" * .rb (default)")
|
42
|
+
say(" * .rbi (default)")
|
43
|
+
else
|
44
|
+
config.allowed_extensions.each do |ext|
|
45
|
+
say(" * #{ext}")
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,191 @@
|
|
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, desc: "Save snapshot data as json", lazy_default: DATA_DIR
|
18
|
+
def snapshot
|
19
|
+
in_sorbet_project!
|
20
|
+
|
21
|
+
path = exec_path
|
22
|
+
snapshot = Spoom::Coverage.snapshot(path: path)
|
23
|
+
snapshot.print
|
24
|
+
|
25
|
+
save_dir = options[:save]
|
26
|
+
return unless save_dir
|
27
|
+
FileUtils.mkdir_p(save_dir)
|
28
|
+
file = "#{save_dir}/#{snapshot.commit_sha || snapshot.timestamp}.json"
|
29
|
+
File.write(file, snapshot.to_json)
|
30
|
+
puts "\nSnapshot data saved under #{file}"
|
31
|
+
end
|
32
|
+
|
33
|
+
desc "timeline", "replay a project and collect metrics"
|
34
|
+
option :from, type: :string
|
35
|
+
option :to, type: :string, default: Time.now.strftime("%F")
|
36
|
+
option :save, type: :string, desc: "Save snapshot data as json", lazy_default: DATA_DIR
|
37
|
+
option :bundle_install, type: :boolean, desc: "Execute `bundle install` before collecting metrics"
|
38
|
+
def timeline
|
39
|
+
in_sorbet_project!
|
40
|
+
path = exec_path
|
41
|
+
|
42
|
+
sha_before = Spoom::Git.last_commit(path: path)
|
43
|
+
unless sha_before
|
44
|
+
say_error("Not in a git repository")
|
45
|
+
$stderr.puts "\nSpoom needs to checkout into your previous commits to build the timeline."
|
46
|
+
exit(1)
|
47
|
+
end
|
48
|
+
|
49
|
+
unless Spoom::Git.workdir_clean?(path: path)
|
50
|
+
say_error("Uncommited changes")
|
51
|
+
$stderr.puts "\nSpoom needs to checkout into your previous commits to build the timeline."
|
52
|
+
$stderr.puts "\nPlease git commit or git stash your changes then try again."
|
53
|
+
exit(1)
|
54
|
+
end
|
55
|
+
|
56
|
+
save_dir = options[:save]
|
57
|
+
FileUtils.mkdir_p(save_dir) if save_dir
|
58
|
+
|
59
|
+
from = parse_time(options[:from], "--from")
|
60
|
+
to = parse_time(options[:to], "--to")
|
61
|
+
|
62
|
+
unless from
|
63
|
+
intro_sha = Spoom::Git.sorbet_intro_commit(path: path)
|
64
|
+
intro_sha = T.must(intro_sha) # we know it's in there since in_sorbet_project!
|
65
|
+
from = Spoom::Git.commit_time(intro_sha, path: path)
|
66
|
+
end
|
67
|
+
|
68
|
+
timeline = Spoom::Timeline.new(from, to, path: path)
|
69
|
+
ticks = timeline.ticks
|
70
|
+
|
71
|
+
if ticks.empty?
|
72
|
+
say_error("No commits to replay, try different --from and --to options")
|
73
|
+
exit(1)
|
74
|
+
end
|
75
|
+
|
76
|
+
ticks.each_with_index do |sha, i|
|
77
|
+
date = Spoom::Git.commit_time(sha, path: path)
|
78
|
+
puts "Analyzing commit #{sha} - #{date&.strftime('%F')} (#{i + 1} / #{ticks.size})"
|
79
|
+
|
80
|
+
Spoom::Git.checkout(sha, path: path)
|
81
|
+
|
82
|
+
snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
|
83
|
+
if options[:bundle_install]
|
84
|
+
Bundler.with_clean_env do
|
85
|
+
next unless bundle_install(path, sha)
|
86
|
+
snapshot = Spoom::Coverage.snapshot(path: path)
|
87
|
+
end
|
88
|
+
else
|
89
|
+
snapshot = Spoom::Coverage.snapshot(path: path)
|
90
|
+
end
|
91
|
+
next unless snapshot
|
92
|
+
|
93
|
+
snapshot.print(indent_level: 2)
|
94
|
+
puts "\n"
|
95
|
+
|
96
|
+
next unless save_dir
|
97
|
+
file = "#{save_dir}/#{sha}.json"
|
98
|
+
File.write(file, snapshot.to_json)
|
99
|
+
puts " Snapshot data saved under #{file}\n\n"
|
100
|
+
end
|
101
|
+
Spoom::Git.checkout(sha_before, path: path)
|
102
|
+
end
|
103
|
+
|
104
|
+
desc "report", "produce a typing coverage report"
|
105
|
+
option :data, type: :string, desc: "Snapshots JSON data", default: DATA_DIR
|
106
|
+
option :file, type: :string, default: "spoom_report.html", aliases: :f
|
107
|
+
option :color_ignore, type: :string, default: Spoom::Coverage::D3::COLOR_IGNORE
|
108
|
+
option :color_false, type: :string, default: Spoom::Coverage::D3::COLOR_FALSE
|
109
|
+
option :color_true, type: :string, default: Spoom::Coverage::D3::COLOR_TRUE
|
110
|
+
option :color_strict, type: :string, default: Spoom::Coverage::D3::COLOR_STRICT
|
111
|
+
option :color_strong, type: :string, default: Spoom::Coverage::D3::COLOR_STRONG
|
112
|
+
def report
|
113
|
+
in_sorbet_project!
|
114
|
+
|
115
|
+
data_dir = options[:data]
|
116
|
+
files = Dir.glob("#{data_dir}/*.json")
|
117
|
+
if files.empty?
|
118
|
+
message_no_data(data_dir)
|
119
|
+
exit(1)
|
120
|
+
end
|
121
|
+
|
122
|
+
snapshots = files.sort.map do |file|
|
123
|
+
json = File.read(file)
|
124
|
+
Spoom::Coverage::Snapshot.from_json(json)
|
125
|
+
end.filter(&:commit_timestamp).sort_by!(&:commit_timestamp)
|
126
|
+
|
127
|
+
palette = Spoom::Coverage::D3::ColorPalette.new(
|
128
|
+
ignore: options[:color_ignore],
|
129
|
+
false: options[:color_false],
|
130
|
+
true: options[:color_true],
|
131
|
+
strict: options[:color_strict],
|
132
|
+
strong: options[:color_strong]
|
133
|
+
)
|
134
|
+
|
135
|
+
report = Spoom::Coverage.report(snapshots, palette: palette, path: exec_path)
|
136
|
+
file = options[:file]
|
137
|
+
File.write(file, report.html)
|
138
|
+
puts "Report generated under #{file}"
|
139
|
+
puts "\nUse #{colorize('spoom coverage open', :blue)} to open it."
|
140
|
+
end
|
141
|
+
|
142
|
+
desc "open", "open the typing coverage report"
|
143
|
+
def open(file = "spoom_report.html")
|
144
|
+
unless File.exist?(file)
|
145
|
+
say_error("No report file to open #{colorize(file, :blue)}")
|
146
|
+
$stderr.puts <<~OUT
|
147
|
+
|
148
|
+
If you already generated a report under another name use #{colorize('spoom coverage open PATH', :blue)}.
|
149
|
+
|
150
|
+
To generate a report run #{colorize('spoom coverage report', :blue)}.
|
151
|
+
OUT
|
152
|
+
exit(1)
|
153
|
+
end
|
154
|
+
|
155
|
+
exec("open #{file}")
|
156
|
+
end
|
157
|
+
|
158
|
+
no_commands do
|
159
|
+
def parse_time(string, option)
|
160
|
+
return nil unless string
|
161
|
+
Time.parse(string)
|
162
|
+
rescue ArgumentError
|
163
|
+
say_error("Invalid date `#{string}` for option #{option} (expected format YYYY-MM-DD)")
|
164
|
+
exit(1)
|
165
|
+
end
|
166
|
+
|
167
|
+
def bundle_install(path, sha)
|
168
|
+
opts = {}
|
169
|
+
opts[:chdir] = path
|
170
|
+
out, status = Open3.capture2e("bundle install", opts)
|
171
|
+
unless status.success?
|
172
|
+
say_error("Can't run `bundle install` for commit #{sha}. Skipping snapshot")
|
173
|
+
$stderr.puts(out)
|
174
|
+
return false
|
175
|
+
end
|
176
|
+
true
|
177
|
+
end
|
178
|
+
|
179
|
+
def message_no_data(file)
|
180
|
+
say_error("No snapshot files found in #{colorize(file, :blue)}")
|
181
|
+
$stderr.puts <<~OUT
|
182
|
+
|
183
|
+
If you already generated snapshot files under another directory use #{colorize('spoom coverage report PATH', :blue)}.
|
184
|
+
|
185
|
+
To generate snapshot files run #{colorize('spoom coverage timeline --save-dir spoom_data', :blue)}.
|
186
|
+
OUT
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "pathname"
|
5
|
+
require "stringio"
|
6
|
+
|
7
|
+
module Spoom
|
8
|
+
module Cli
|
9
|
+
module Helper
|
10
|
+
extend T::Sig
|
11
|
+
include Thor::Shell
|
12
|
+
|
13
|
+
# Print `message` on `$stderr`
|
14
|
+
#
|
15
|
+
# The message is prefixed by a status (default: `Error`).
|
16
|
+
sig { params(message: String, status: String).void }
|
17
|
+
def say_error(message, status = "Error")
|
18
|
+
status = set_color(status, :red)
|
19
|
+
|
20
|
+
buffer = StringIO.new
|
21
|
+
buffer << "#{status}: #{message}"
|
22
|
+
buffer << "\n" unless message.end_with?("\n")
|
23
|
+
|
24
|
+
$stderr.print(buffer.string)
|
25
|
+
$stderr.flush
|
26
|
+
end
|
27
|
+
|
28
|
+
# Is `spoom` ran inside a project with a `sorbet/config` file?
|
29
|
+
sig { returns(T::Boolean) }
|
30
|
+
def in_sorbet_project?
|
31
|
+
File.file?(sorbet_config)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Enforce that `spoom` is ran inside a project with a `sorbet/config` file
|
35
|
+
#
|
36
|
+
# Display an error message and exit otherwise.
|
37
|
+
sig { void }
|
38
|
+
def in_sorbet_project!
|
39
|
+
unless in_sorbet_project?
|
40
|
+
say_error(
|
41
|
+
"not in a Sorbet project (#{colorize(sorbet_config, :yellow)} not found)\n\n" \
|
42
|
+
"When running spoom from another path than the project's root, " \
|
43
|
+
"use #{colorize('--path PATH', :blue)} to specify the path to the root."
|
44
|
+
)
|
45
|
+
Kernel.exit(1)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Return the path specified through `--path`
|
50
|
+
sig { returns(String) }
|
51
|
+
def exec_path
|
52
|
+
T.unsafe(self).options[:path] # TODO: requires_ancestor
|
53
|
+
end
|
54
|
+
|
55
|
+
sig { returns(String) }
|
56
|
+
def sorbet_config
|
57
|
+
Pathname.new("#{exec_path}/#{Spoom::Config::SORBET_CONFIG}").cleanpath.to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
# Is the `--color` option true?
|
61
|
+
sig { returns(T::Boolean) }
|
62
|
+
def color?
|
63
|
+
T.unsafe(self).options[:color] # TODO: requires_ancestor
|
64
|
+
end
|
65
|
+
|
66
|
+
# Colorize a string if `color?`
|
67
|
+
sig { params(string: String, color: Symbol).returns(String) }
|
68
|
+
def colorize(string, color)
|
69
|
+
return string unless color?
|
70
|
+
string.colorize(color)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,165 @@
|
|
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
|
+
in_sorbet_project!
|
18
|
+
lsp = lsp_client
|
19
|
+
# TODO: run interactive mode
|
20
|
+
puts lsp
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "list", "list all known symbols"
|
24
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
25
|
+
def list
|
26
|
+
run do |client|
|
27
|
+
printer = symbol_printer
|
28
|
+
Dir["**/*.rb"].each do |file|
|
29
|
+
res = client.document_symbols(to_uri(file))
|
30
|
+
next if res.empty?
|
31
|
+
puts "Symbols from `#{file}`:"
|
32
|
+
printer.print_objects(res)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
desc "hover", "request hover informations"
|
38
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
39
|
+
def hover(file, line, col)
|
40
|
+
run do |client|
|
41
|
+
res = client.hover(to_uri(file), line.to_i, col.to_i)
|
42
|
+
say "Hovering `#{file}:#{line}:#{col}`:"
|
43
|
+
if res
|
44
|
+
symbol_printer.print_object(res)
|
45
|
+
else
|
46
|
+
puts "<no data>"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
desc "defs", "list definitions of a symbol"
|
52
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
53
|
+
def defs(file, line, col)
|
54
|
+
run do |client|
|
55
|
+
res = client.definitions(to_uri(file), line.to_i, col.to_i)
|
56
|
+
puts "Definitions for `#{file}:#{line}:#{col}`:"
|
57
|
+
symbol_printer.print_list(res)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
desc "find", "find symbols matching a query"
|
62
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
63
|
+
def find(query)
|
64
|
+
run do |client|
|
65
|
+
res = client.symbols(query).reject { |symbol| symbol.location.uri.start_with?("https") }
|
66
|
+
puts "Symbols matching `#{query}`:"
|
67
|
+
symbol_printer.print_objects(res)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
desc "symbols", "list symbols from a file"
|
72
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
73
|
+
def symbols(file)
|
74
|
+
run do |client|
|
75
|
+
res = client.document_symbols(to_uri(file))
|
76
|
+
puts "Symbols from `#{file}`:"
|
77
|
+
symbol_printer.print_objects(res)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
desc "refs", "list references to a symbol"
|
82
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
83
|
+
def refs(file, line, col)
|
84
|
+
run do |client|
|
85
|
+
res = client.references(to_uri(file), line.to_i, col.to_i)
|
86
|
+
puts "References to `#{file}:#{line}:#{col}`:"
|
87
|
+
symbol_printer.print_list(res)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
desc "sigs", "list signatures for a symbol"
|
92
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
93
|
+
def sigs(file, line, col)
|
94
|
+
run do |client|
|
95
|
+
res = client.signatures(to_uri(file), line.to_i, col.to_i)
|
96
|
+
puts "Signature for `#{file}:#{line}:#{col}`:"
|
97
|
+
symbol_printer.print_list(res)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
desc "types", "display type of a symbol"
|
102
|
+
# TODO: options, filter, limit, kind etc.. filter rbi
|
103
|
+
def types(file, line, col)
|
104
|
+
run do |client|
|
105
|
+
res = client.type_definitions(to_uri(file), line.to_i, col.to_i)
|
106
|
+
say "Type for `#{file}:#{line}:#{col}`:"
|
107
|
+
symbol_printer.print_list(res)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
no_commands do
|
112
|
+
def lsp_client
|
113
|
+
in_sorbet_project!
|
114
|
+
path = exec_path
|
115
|
+
client = Spoom::LSP::Client.new(
|
116
|
+
Spoom::Config::SORBET_PATH,
|
117
|
+
"--lsp",
|
118
|
+
"--enable-all-experimental-lsp-features",
|
119
|
+
"--disable-watchman",
|
120
|
+
path: path
|
121
|
+
)
|
122
|
+
client.open(File.expand_path(path))
|
123
|
+
client
|
124
|
+
end
|
125
|
+
|
126
|
+
def symbol_printer
|
127
|
+
Spoom::LSP::SymbolPrinter.new(
|
128
|
+
indent_level: 2,
|
129
|
+
colors: options[:color],
|
130
|
+
prefix: "file://#{File.expand_path(exec_path)}"
|
131
|
+
)
|
132
|
+
end
|
133
|
+
|
134
|
+
def run(&block)
|
135
|
+
client = lsp_client
|
136
|
+
block.call(client)
|
137
|
+
rescue Spoom::LSP::Error::Diagnostics => err
|
138
|
+
say_error("Sorbet returned typechecking errors for `#{symbol_printer.clean_uri(err.uri)}`")
|
139
|
+
err.diagnostics.each do |d|
|
140
|
+
say_error("#{d.message} (#{d.code})", " #{d.range}")
|
141
|
+
end
|
142
|
+
exit(1)
|
143
|
+
rescue Spoom::LSP::Error::BadHeaders => err
|
144
|
+
say_error("Sorbet didn't answer correctly (#{err.message})")
|
145
|
+
exit(1)
|
146
|
+
rescue Spoom::LSP::Error => err
|
147
|
+
say_error(err.message)
|
148
|
+
exit(1)
|
149
|
+
ensure
|
150
|
+
begin
|
151
|
+
client&.close
|
152
|
+
rescue
|
153
|
+
# We can't do much if Sorbet refuse to close.
|
154
|
+
# We kill the parent process and let the child be killed.
|
155
|
+
exit(1)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def to_uri(path)
|
160
|
+
"file://" + File.join(File.expand_path(exec_path), path)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|