spoom 1.0.0 → 1.0.5

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.
@@ -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,70 @@
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("not in a Sorbet project (no sorbet/config)")
41
+ Kernel.exit(1)
42
+ end
43
+ end
44
+
45
+ # Return the path specified through `--path`
46
+ sig { returns(String) }
47
+ def exec_path
48
+ T.unsafe(self).options[:path] # TODO: requires_ancestor
49
+ end
50
+
51
+ sig { returns(String) }
52
+ def sorbet_config
53
+ Pathname.new("#{exec_path}/#{Spoom::Config::SORBET_CONFIG}").cleanpath.to_s
54
+ end
55
+
56
+ # Is the `--color` option true?
57
+ sig { returns(T::Boolean) }
58
+ def color?
59
+ T.unsafe(self).options[:color] # TODO: requires_ancestor
60
+ end
61
+
62
+ # Colorize a string if `color?`
63
+ sig { params(string: String, color: Symbol).returns(String) }
64
+ def colorize(string, color)
65
+ return string unless color?
66
+ string.colorize(color)
67
+ end
68
+ end
69
+ end
70
+ 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