spoom 1.0.0 → 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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