spoom 1.0.3 → 1.0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,118 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'find'
5
+ require 'open3'
6
+
7
+ module Spoom
8
+ module Cli
9
+ class Bump < Thor
10
+ extend T::Sig
11
+ include Helper
12
+
13
+ default_task :bump
14
+
15
+ desc "bump DIRECTORY", "Change Sorbet sigils from one strictness to another when no errors"
16
+ option :from, type: :string, default: Spoom::Sorbet::Sigils::STRICTNESS_FALSE,
17
+ desc: "Change only files from this strictness"
18
+ option :to, type: :string, default: Spoom::Sorbet::Sigils::STRICTNESS_TRUE,
19
+ desc: "Change files to this strictness"
20
+ option :force, type: :boolean, default: false, aliases: :f,
21
+ desc: "Change strictness without type checking"
22
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
23
+ option :dry, type: :boolean, default: false, aliases: :d,
24
+ desc: "Only display what would happen, do not actually change sigils"
25
+ option :only, type: :string, default: nil, aliases: :o,
26
+ desc: "Only change specified list (one file by line)"
27
+ sig { params(directory: String).void }
28
+ def bump(directory = ".")
29
+ in_sorbet_project!
30
+
31
+ from = options[:from]
32
+ to = options[:to]
33
+ force = options[:force]
34
+ dry = options[:dry]
35
+ only = options[:only]
36
+ exec_path = File.expand_path(self.exec_path)
37
+
38
+ unless Sorbet::Sigils.valid_strictness?(from)
39
+ say_error("Invalid strictness #{from} for option --from")
40
+ exit(1)
41
+ end
42
+
43
+ unless Sorbet::Sigils.valid_strictness?(to)
44
+ say_error("Invalid strictness #{to} for option --to")
45
+ exit(1)
46
+ end
47
+
48
+ directory = File.expand_path(directory)
49
+ files_to_bump = Sorbet::Sigils.files_with_sigil_strictness(directory, from)
50
+
51
+ if only
52
+ list = File.read(only).lines.map { |file| File.expand_path(file.strip) }
53
+ files_to_bump.select! { |file| list.include?(File.expand_path(file)) }
54
+ end
55
+
56
+ if files_to_bump.empty?
57
+ $stderr.puts("No file to bump from #{from} to #{to}")
58
+ exit(0)
59
+ end
60
+
61
+ Sorbet::Sigils.change_sigil_in_files(files_to_bump, to)
62
+
63
+ if force
64
+ print_changes(files_to_bump, from: from, to: to, dry: dry, path: exec_path)
65
+ undo_changes(files_to_bump, from) if dry
66
+ exit(files_to_bump.empty?)
67
+ end
68
+
69
+ output, no_errors = Sorbet.srb_tc(path: exec_path, capture_err: true, sorbet_bin: options[:sorbet])
70
+
71
+ if no_errors
72
+ print_changes(files_to_bump, from: from, to: to, dry: dry, path: exec_path)
73
+ undo_changes(files_to_bump, from) if dry
74
+ exit(files_to_bump.empty?)
75
+ end
76
+
77
+ errors = Sorbet::Errors::Parser.parse_string(output)
78
+
79
+ files_with_errors = errors.map do |err|
80
+ path = File.expand_path(err.file)
81
+ next unless path.start_with?(directory)
82
+ next unless File.file?(path)
83
+ path
84
+ end.compact.uniq
85
+
86
+ undo_changes(files_with_errors, from)
87
+
88
+ files_changed = files_to_bump - files_with_errors
89
+ print_changes(files_changed, from: from, to: to, dry: dry, path: exec_path)
90
+ undo_changes(files_to_bump, from) if dry
91
+ exit(files_changed.empty?)
92
+ end
93
+
94
+ no_commands do
95
+ def print_changes(files, from: "false", to: "true", dry: false, path: File.expand_path("."))
96
+ if files.empty?
97
+ $stderr.puts("No file to bump from #{from} to #{to}")
98
+ return
99
+ end
100
+ $stderr.write(dry ? "Can bump" : "Bumped")
101
+ $stderr.write(" #{files.size} file#{'s' if files.size > 1}")
102
+ $stderr.puts(" from #{from} to #{to}:")
103
+ files.each do |file|
104
+ file_path = Pathname.new(file).relative_path_from(path)
105
+ $stderr.puts(" + #{file_path}")
106
+ end
107
+ if dry
108
+ $stderr.puts("\nRun `spoom bump --from #{from} --to #{to}` to bump them")
109
+ end
110
+ end
111
+
112
+ def undo_changes(files, from_strictness)
113
+ Sorbet::Sigils.change_sigil_in_files(files, from_strictness)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -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,202 @@
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: "Exclude RBI files from metrics"
19
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
20
+ def snapshot
21
+ in_sorbet_project!
22
+ path = exec_path
23
+ sorbet = options[:sorbet]
24
+
25
+ snapshot = Spoom::Coverage.snapshot(path: path, rbi: options[:rbi], sorbet_bin: sorbet)
26
+ snapshot.print
27
+
28
+ save_dir = options[:save]
29
+ return unless save_dir
30
+ FileUtils.mkdir_p(save_dir)
31
+ file = "#{save_dir}/#{snapshot.commit_sha || snapshot.timestamp}.json"
32
+ File.write(file, snapshot.to_json)
33
+ puts "\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
+ in_sorbet_project!
44
+ path = exec_path
45
+ sorbet = options[:sorbet]
46
+
47
+ sha_before = Spoom::Git.last_commit(path: path)
48
+ unless sha_before
49
+ say_error("Not in a git repository")
50
+ $stderr.puts "\nSpoom needs to checkout into your previous commits to build the timeline."
51
+ exit(1)
52
+ end
53
+
54
+ unless Spoom::Git.workdir_clean?(path: path)
55
+ say_error("Uncommited changes")
56
+ $stderr.puts "\nSpoom needs to checkout into your previous commits to build the timeline."
57
+ $stderr.puts "\nPlease git commit or git stash your changes then try again."
58
+ exit(1)
59
+ end
60
+
61
+ save_dir = options[:save]
62
+ FileUtils.mkdir_p(save_dir) if save_dir
63
+
64
+ from = parse_time(options[:from], "--from")
65
+ to = parse_time(options[:to], "--to")
66
+
67
+ unless from
68
+ intro_sha = Spoom::Git.sorbet_intro_commit(path: path)
69
+ intro_sha = T.must(intro_sha) # we know it's in there since in_sorbet_project!
70
+ from = Spoom::Git.commit_time(intro_sha, path: path)
71
+ end
72
+
73
+ timeline = Spoom::Timeline.new(from, to, path: path)
74
+ ticks = timeline.ticks
75
+
76
+ if ticks.empty?
77
+ say_error("No commits to replay, try different --from and --to options")
78
+ exit(1)
79
+ end
80
+
81
+ ticks.each_with_index do |sha, i|
82
+ date = Spoom::Git.commit_time(sha, path: path)
83
+ puts "Analyzing commit #{sha} - #{date&.strftime('%F')} (#{i + 1} / #{ticks.size})"
84
+
85
+ Spoom::Git.checkout(sha, path: path)
86
+
87
+ snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
88
+ if options[:bundle_install]
89
+ Bundler.with_clean_env do
90
+ next unless bundle_install(path, sha)
91
+ snapshot = Spoom::Coverage.snapshot(path: path, sorbet_bin: sorbet)
92
+ end
93
+ else
94
+ snapshot = Spoom::Coverage.snapshot(path: path, sorbet_bin: sorbet)
95
+ end
96
+ next unless snapshot
97
+
98
+ snapshot.print(indent_level: 2)
99
+ puts "\n"
100
+
101
+ next unless save_dir
102
+ file = "#{save_dir}/#{sha}.json"
103
+ File.write(file, snapshot.to_json)
104
+ puts " Snapshot data saved under #{file}\n\n"
105
+ end
106
+ Spoom::Git.checkout(sha_before, path: path)
107
+ end
108
+
109
+ desc "report", "Produce a typing coverage report"
110
+ option :data, type: :string, default: DATA_DIR, desc: "Snapshots JSON data"
111
+ option :file, type: :string, default: "spoom_report.html", aliases: :f,
112
+ desc: "Save report to file"
113
+ option :color_ignore, type: :string, default: Spoom::Coverage::D3::COLOR_IGNORE,
114
+ desc: "Color used for typed: ignore"
115
+ option :color_false, type: :string, default: Spoom::Coverage::D3::COLOR_FALSE,
116
+ desc: "Color used for typed: false"
117
+ option :color_true, type: :string, default: Spoom::Coverage::D3::COLOR_TRUE,
118
+ desc: "Color used for typed: true"
119
+ option :color_strict, type: :string, default: Spoom::Coverage::D3::COLOR_STRICT,
120
+ desc: "Color used for typed: strict"
121
+ option :color_strong, type: :string, default: Spoom::Coverage::D3::COLOR_STRONG,
122
+ desc: "Color used for typed: strong"
123
+ def report
124
+ in_sorbet_project!
125
+
126
+ data_dir = options[:data]
127
+ files = Dir.glob("#{data_dir}/*.json")
128
+ if files.empty?
129
+ message_no_data(data_dir)
130
+ exit(1)
131
+ end
132
+
133
+ snapshots = files.sort.map do |file|
134
+ json = File.read(file)
135
+ Spoom::Coverage::Snapshot.from_json(json)
136
+ end.filter(&:commit_timestamp).sort_by!(&:commit_timestamp)
137
+
138
+ palette = Spoom::Coverage::D3::ColorPalette.new(
139
+ ignore: options[:color_ignore],
140
+ false: options[:color_false],
141
+ true: options[:color_true],
142
+ strict: options[:color_strict],
143
+ strong: options[:color_strong]
144
+ )
145
+
146
+ report = Spoom::Coverage.report(snapshots, palette: palette, path: exec_path)
147
+ file = options[:file]
148
+ File.write(file, report.html)
149
+ puts "Report generated under #{file}"
150
+ puts "\nUse #{colorize('spoom coverage open', :blue)} to open it."
151
+ end
152
+
153
+ desc "open", "Open the typing coverage report"
154
+ def open(file = "spoom_report.html")
155
+ unless File.exist?(file)
156
+ say_error("No report file to open #{colorize(file, :blue)}")
157
+ $stderr.puts <<~OUT
158
+
159
+ If you already generated a report under another name use #{colorize('spoom coverage open PATH', :blue)}.
160
+
161
+ To generate a report run #{colorize('spoom coverage report', :blue)}.
162
+ OUT
163
+ exit(1)
164
+ end
165
+
166
+ exec("open #{file}")
167
+ end
168
+
169
+ no_commands do
170
+ def parse_time(string, option)
171
+ return nil unless string
172
+ Time.parse(string)
173
+ rescue ArgumentError
174
+ say_error("Invalid date `#{string}` for option #{option} (expected format YYYY-MM-DD)")
175
+ exit(1)
176
+ end
177
+
178
+ def bundle_install(path, sha)
179
+ opts = {}
180
+ opts[:chdir] = path
181
+ out, status = Open3.capture2e("bundle install", opts)
182
+ unless status.success?
183
+ say_error("Can't run `bundle install` for commit #{sha}. Skipping snapshot")
184
+ $stderr.puts(out)
185
+ return false
186
+ end
187
+ true
188
+ end
189
+
190
+ def message_no_data(file)
191
+ say_error("No snapshot files found in #{colorize(file, :blue)}")
192
+ $stderr.puts <<~OUT
193
+
194
+ If you already generated snapshot files under another directory use #{colorize('spoom coverage report PATH', :blue)}.
195
+
196
+ To generate snapshot files run #{colorize('spoom coverage timeline --save-dir spoom_data', :blue)}.
197
+ OUT
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,75 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "stringio"
7
+
8
+ module Spoom
9
+ module Cli
10
+ module Helper
11
+ extend T::Sig
12
+ include Thor::Shell
13
+
14
+ # Print `message` on `$stderr`
15
+ #
16
+ # The message is prefixed by a status (default: `Error`).
17
+ sig { params(message: String, status: String).void }
18
+ def say_error(message, status = "Error")
19
+ status = set_color(status, :red)
20
+
21
+ buffer = StringIO.new
22
+ buffer << "#{status}: #{message}"
23
+ buffer << "\n" unless message.end_with?("\n")
24
+
25
+ $stderr.print(buffer.string)
26
+ $stderr.flush
27
+ end
28
+
29
+ # Is `spoom` ran inside a project with a `sorbet/config` file?
30
+ sig { returns(T::Boolean) }
31
+ def in_sorbet_project?
32
+ File.file?(sorbet_config)
33
+ end
34
+
35
+ # Enforce that `spoom` is ran inside a project with a `sorbet/config` file
36
+ #
37
+ # Display an error message and exit otherwise.
38
+ sig { void }
39
+ def in_sorbet_project!
40
+ unless in_sorbet_project?
41
+ say_error(
42
+ "not in a Sorbet project (#{colorize(sorbet_config, :yellow)} not found)\n\n" \
43
+ "When running spoom from another path than the project's root, " \
44
+ "use #{colorize('--path PATH', :blue)} to specify the path to the root."
45
+ )
46
+ Kernel.exit(1)
47
+ end
48
+ end
49
+
50
+ # Return the path specified through `--path`
51
+ sig { returns(String) }
52
+ def exec_path
53
+ T.unsafe(self).options[:path] # TODO: requires_ancestor
54
+ end
55
+
56
+ sig { returns(String) }
57
+ def sorbet_config
58
+ Pathname.new("#{exec_path}/#{Spoom::Sorbet::CONFIG_PATH}").cleanpath.to_s
59
+ end
60
+
61
+ # Is the `--color` option true?
62
+ sig { returns(T::Boolean) }
63
+ def color?
64
+ T.unsafe(self).options[:color] # TODO: requires_ancestor
65
+ end
66
+
67
+ # Colorize a string if `color?`
68
+ sig { params(string: String, color: Symbol).returns(String) }
69
+ def colorize(string, color)
70
+ return string unless color?
71
+ string.colorize(color)
72
+ end
73
+ end
74
+ end
75
+ 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::Sorbet::BIN_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