spoom 1.0.3 → 1.0.8

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,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