spoom 1.0.4 → 1.0.9

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +0 -1
  3. data/README.md +296 -1
  4. data/Rakefile +1 -0
  5. data/lib/spoom.rb +21 -2
  6. data/lib/spoom/cli.rb +56 -10
  7. data/lib/spoom/cli/bump.rb +138 -0
  8. data/lib/spoom/cli/config.rb +51 -0
  9. data/lib/spoom/cli/coverage.rb +206 -0
  10. data/lib/spoom/cli/helper.rb +149 -0
  11. data/lib/spoom/cli/lsp.rb +165 -0
  12. data/lib/spoom/cli/run.rb +109 -0
  13. data/lib/spoom/coverage.rb +89 -0
  14. data/lib/spoom/coverage/d3.rb +110 -0
  15. data/lib/spoom/coverage/d3/base.rb +50 -0
  16. data/lib/spoom/coverage/d3/circle_map.rb +195 -0
  17. data/lib/spoom/coverage/d3/pie.rb +175 -0
  18. data/lib/spoom/coverage/d3/timeline.rb +486 -0
  19. data/lib/spoom/coverage/report.rb +308 -0
  20. data/lib/spoom/coverage/snapshot.rb +132 -0
  21. data/lib/spoom/file_tree.rb +196 -0
  22. data/lib/spoom/git.rb +98 -0
  23. data/lib/spoom/printer.rb +80 -0
  24. data/lib/spoom/sorbet.rb +99 -47
  25. data/lib/spoom/sorbet/config.rb +30 -0
  26. data/lib/spoom/sorbet/errors.rb +33 -15
  27. data/lib/spoom/sorbet/lsp.rb +2 -4
  28. data/lib/spoom/sorbet/lsp/structures.rb +108 -14
  29. data/lib/spoom/sorbet/metrics.rb +10 -79
  30. data/lib/spoom/sorbet/sigils.rb +98 -0
  31. data/lib/spoom/test_helpers/project.rb +112 -0
  32. data/lib/spoom/timeline.rb +53 -0
  33. data/lib/spoom/version.rb +2 -2
  34. data/templates/card.erb +8 -0
  35. data/templates/card_snapshot.erb +22 -0
  36. data/templates/page.erb +50 -0
  37. metadata +28 -11
  38. data/lib/spoom/cli/commands/base.rb +0 -36
  39. data/lib/spoom/cli/commands/config.rb +0 -67
  40. data/lib/spoom/cli/commands/lsp.rb +0 -156
  41. data/lib/spoom/cli/commands/run.rb +0 -92
  42. data/lib/spoom/cli/symbol_printer.rb +0 -71
  43. data/lib/spoom/config.rb +0 -11
@@ -0,0 +1,138 @@
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
+ option :suggest_bump_command, type: :string,
28
+ desc: "Command to suggest if files can be bumped"
29
+ sig { params(directory: String).void }
30
+ def bump(directory = ".")
31
+ in_sorbet_project!
32
+
33
+ from = options[:from]
34
+ to = options[:to]
35
+ force = options[:force]
36
+ dry = options[:dry]
37
+ only = options[:only]
38
+ cmd = options[:suggest_bump_command]
39
+ exec_path = File.expand_path(self.exec_path)
40
+
41
+ unless Sorbet::Sigils.valid_strictness?(from)
42
+ say_error("Invalid strictness `#{from}` for option `--from`")
43
+ exit(1)
44
+ end
45
+
46
+ unless Sorbet::Sigils.valid_strictness?(to)
47
+ say_error("Invalid strictness `#{to}` for option `--to`")
48
+ exit(1)
49
+ end
50
+
51
+ say("Checking files...")
52
+
53
+ directory = File.expand_path(directory)
54
+ files_to_bump = Sorbet::Sigils.files_with_sigil_strictness(directory, from)
55
+
56
+ files_from_config = config_files(path: exec_path)
57
+ files_to_bump.select! { |file| files_from_config.include?(file) }
58
+
59
+ if only
60
+ list = File.read(only).lines.map { |file| File.expand_path(file.strip) }
61
+ files_to_bump.select! { |file| list.include?(File.expand_path(file)) }
62
+ end
63
+
64
+ say("\n")
65
+
66
+ if files_to_bump.empty?
67
+ say("No file to bump from `#{from}` to `#{to}`")
68
+ exit(0)
69
+ end
70
+
71
+ Sorbet::Sigils.change_sigil_in_files(files_to_bump, to)
72
+
73
+ if force
74
+ print_changes(files_to_bump, command: cmd, from: from, to: to, dry: dry, path: exec_path)
75
+ undo_changes(files_to_bump, from) if dry
76
+ exit(files_to_bump.empty?)
77
+ end
78
+
79
+ output, no_errors = Sorbet.srb_tc(path: exec_path, capture_err: true, sorbet_bin: options[:sorbet])
80
+
81
+ if no_errors
82
+ print_changes(files_to_bump, command: cmd, from: from, to: to, dry: dry, path: exec_path)
83
+ undo_changes(files_to_bump, from) if dry
84
+ exit(files_to_bump.empty?)
85
+ end
86
+
87
+ errors = Sorbet::Errors::Parser.parse_string(output)
88
+
89
+ files_with_errors = errors.map do |err|
90
+ path = File.expand_path(err.file)
91
+ next unless path.start_with?(directory)
92
+ next unless File.file?(path)
93
+ path
94
+ end.compact.uniq
95
+
96
+ undo_changes(files_with_errors, from)
97
+
98
+ files_changed = files_to_bump - files_with_errors
99
+ print_changes(files_changed, command: cmd, from: from, to: to, dry: dry, path: exec_path)
100
+ undo_changes(files_to_bump, from) if dry
101
+ exit(files_changed.empty?)
102
+ end
103
+
104
+ no_commands do
105
+ def print_changes(files, command:, from: "false", to: "true", dry: false, path: File.expand_path("."))
106
+ if files.empty?
107
+ say("No file to bump from `#{from}` to `#{to}`")
108
+ return
109
+ end
110
+ message = StringIO.new
111
+ message << (dry ? "Can bump" : "Bumped")
112
+ message << " `#{files.size}` file#{'s' if files.size > 1}"
113
+ message << " from `#{from}` to `#{to}`:"
114
+ say(message.string)
115
+ files.each do |file|
116
+ file_path = Pathname.new(file).relative_path_from(path)
117
+ say(" + #{file_path}")
118
+ end
119
+ if dry && command
120
+ say("\nRun `#{command}` to bump them")
121
+ elsif dry
122
+ say("\nRun `spoom bump --from #{from} --to #{to}` to bump them")
123
+ end
124
+ end
125
+
126
+ def undo_changes(files, from_strictness)
127
+ Sorbet::Sigils.change_sigil_in_files(files, from_strictness)
128
+ end
129
+
130
+ def config_files(path: ".")
131
+ config = sorbet_config
132
+ files = Sorbet.srb_files(config, path: path)
133
+ files.map { |file| File.expand_path(file) }
134
+ end
135
+ end
136
+ end
137
+ end
138
+ 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 = sorbet_config
18
+
19
+ say("Found Sorbet config at `#{sorbet_config_file}`.")
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,206 @@
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
+ say("\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
+ say_error("\nSpoom needs to checkout into your previous commits to build the timeline.", status: nil)
51
+ exit(1)
52
+ end
53
+
54
+ unless Spoom::Git.workdir_clean?(path: path)
55
+ say_error("Uncommited changes")
56
+ say_error(<<~ERR, status: nil)
57
+
58
+ Spoom needs to checkout into your previous commits to build the timeline."
59
+
60
+ Please `git commit` or `git stash` your changes then try again
61
+ ERR
62
+ exit(1)
63
+ end
64
+
65
+ save_dir = options[:save]
66
+ FileUtils.mkdir_p(save_dir) if save_dir
67
+
68
+ from = parse_time(options[:from], "--from")
69
+ to = parse_time(options[:to], "--to")
70
+
71
+ unless from
72
+ intro_sha = Spoom::Git.sorbet_intro_commit(path: path)
73
+ intro_sha = T.must(intro_sha) # we know it's in there since in_sorbet_project!
74
+ from = Spoom::Git.commit_time(intro_sha, path: path)
75
+ end
76
+
77
+ timeline = Spoom::Timeline.new(from, to, path: path)
78
+ ticks = timeline.ticks
79
+
80
+ if ticks.empty?
81
+ say_error("No commits to replay, try different `--from` and `--to` options")
82
+ exit(1)
83
+ end
84
+
85
+ ticks.each_with_index do |sha, i|
86
+ date = Spoom::Git.commit_time(sha, path: path)
87
+ say("Analyzing commit `#{sha}` - #{date&.strftime('%F')} (#{i + 1} / #{ticks.size})")
88
+
89
+ Spoom::Git.checkout(sha, path: path)
90
+
91
+ snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
92
+ if options[:bundle_install]
93
+ Bundler.with_clean_env do
94
+ next unless bundle_install(path, sha)
95
+ snapshot = Spoom::Coverage.snapshot(path: path, sorbet_bin: sorbet)
96
+ end
97
+ else
98
+ snapshot = Spoom::Coverage.snapshot(path: path, sorbet_bin: sorbet)
99
+ end
100
+ next unless snapshot
101
+
102
+ snapshot.print(indent_level: 2)
103
+ say("\n")
104
+
105
+ next unless save_dir
106
+ file = "#{save_dir}/#{sha}.json"
107
+ File.write(file, snapshot.to_json)
108
+ say(" Snapshot data saved under `#{file}`\n\n")
109
+ end
110
+ Spoom::Git.checkout(sha_before, path: path)
111
+ end
112
+
113
+ desc "report", "Produce a typing coverage report"
114
+ option :data, type: :string, default: DATA_DIR, desc: "Snapshots JSON data"
115
+ option :file, type: :string, default: "spoom_report.html", aliases: :f,
116
+ desc: "Save report to file"
117
+ option :color_ignore, type: :string, default: Spoom::Coverage::D3::COLOR_IGNORE,
118
+ desc: "Color used for typed: ignore"
119
+ option :color_false, type: :string, default: Spoom::Coverage::D3::COLOR_FALSE,
120
+ desc: "Color used for typed: false"
121
+ option :color_true, type: :string, default: Spoom::Coverage::D3::COLOR_TRUE,
122
+ desc: "Color used for typed: true"
123
+ option :color_strict, type: :string, default: Spoom::Coverage::D3::COLOR_STRICT,
124
+ desc: "Color used for typed: strict"
125
+ option :color_strong, type: :string, default: Spoom::Coverage::D3::COLOR_STRONG,
126
+ desc: "Color used for typed: strong"
127
+ def report
128
+ in_sorbet_project!
129
+
130
+ data_dir = options[:data]
131
+ files = Dir.glob("#{data_dir}/*.json")
132
+ if files.empty?
133
+ message_no_data(data_dir)
134
+ exit(1)
135
+ end
136
+
137
+ snapshots = files.sort.map do |file|
138
+ json = File.read(file)
139
+ Spoom::Coverage::Snapshot.from_json(json)
140
+ end.filter(&:commit_timestamp).sort_by!(&:commit_timestamp)
141
+
142
+ palette = Spoom::Coverage::D3::ColorPalette.new(
143
+ ignore: options[:color_ignore],
144
+ false: options[:color_false],
145
+ true: options[:color_true],
146
+ strict: options[:color_strict],
147
+ strong: options[:color_strong]
148
+ )
149
+
150
+ report = Spoom::Coverage.report(snapshots, palette: palette, path: exec_path)
151
+ file = options[:file]
152
+ File.write(file, report.html)
153
+ say("Report generated under `#{file}`")
154
+ say("\nUse `spoom coverage open` to open it.")
155
+ end
156
+
157
+ desc "open", "Open the typing coverage report"
158
+ def open(file = "spoom_report.html")
159
+ unless File.exist?(file)
160
+ say_error("No report file to open `#{file}`")
161
+ say_error(<<~ERR, status: nil)
162
+
163
+ If you already generated a report under another name use #{blue('spoom coverage open PATH')}.
164
+
165
+ To generate a report run #{blue('spoom coverage report')}.
166
+ ERR
167
+ exit(1)
168
+ end
169
+
170
+ exec("open #{file}")
171
+ end
172
+
173
+ no_commands do
174
+ def parse_time(string, option)
175
+ return nil unless string
176
+ Time.parse(string)
177
+ rescue ArgumentError
178
+ say_error("Invalid date `#{string}` for option `#{option}` (expected format `YYYY-MM-DD`)")
179
+ exit(1)
180
+ end
181
+
182
+ def bundle_install(path, sha)
183
+ opts = {}
184
+ opts[:chdir] = path
185
+ out, status = Open3.capture2e("bundle install", opts)
186
+ unless status.success?
187
+ say_error("Can't run `bundle install` for commit `#{sha}`. Skipping snapshot")
188
+ say_error(out, status: nil)
189
+ return false
190
+ end
191
+ true
192
+ end
193
+
194
+ def message_no_data(file)
195
+ say_error("No snapshot files found in `#{file}`")
196
+ say_error(<<~ERR, status: nil)
197
+
198
+ If you already generated snapshot files under another directory use #{blue('spoom coverage report PATH')}.
199
+
200
+ To generate snapshot files run #{blue('spoom coverage timeline --save-dir spoom_data')}.
201
+ ERR
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,149 @@
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 `$stdout`
15
+ sig { params(message: String).void }
16
+ def say(message)
17
+ buffer = StringIO.new
18
+ buffer << highlight(message)
19
+ buffer << "\n" unless message.end_with?("\n")
20
+
21
+ $stdout.print(buffer.string)
22
+ $stdout.flush
23
+ end
24
+
25
+ # Print `message` on `$stderr`
26
+ #
27
+ # The message is prefixed by a status (default: `Error`).
28
+ sig do
29
+ params(
30
+ message: String,
31
+ status: T.nilable(String),
32
+ nl: T::Boolean
33
+ ).void
34
+ end
35
+ def say_error(message, status: "Error", nl: true)
36
+ buffer = StringIO.new
37
+ buffer << "#{red(status)}: " if status
38
+ buffer << highlight(message)
39
+ buffer << "\n" if nl && !message.end_with?("\n")
40
+
41
+ $stderr.print(buffer.string)
42
+ $stderr.flush
43
+ end
44
+
45
+ # Is `spoom` ran inside a project with a `sorbet/config` file?
46
+ sig { returns(T::Boolean) }
47
+ def in_sorbet_project?
48
+ File.file?(sorbet_config_file)
49
+ end
50
+
51
+ # Enforce that `spoom` is ran inside a project with a `sorbet/config` file
52
+ #
53
+ # Display an error message and exit otherwise.
54
+ sig { void }
55
+ def in_sorbet_project!
56
+ unless in_sorbet_project?
57
+ say_error(
58
+ "not in a Sorbet project (`#{sorbet_config_file}` not found)\n\n" \
59
+ "When running spoom from another path than the project's root, " \
60
+ "use `--path PATH` to specify the path to the root."
61
+ )
62
+ Kernel.exit(1)
63
+ end
64
+ end
65
+
66
+ # Return the path specified through `--path`
67
+ sig { returns(String) }
68
+ def exec_path
69
+ T.unsafe(self).options[:path] # TODO: requires_ancestor
70
+ end
71
+
72
+ sig { returns(String) }
73
+ def sorbet_config_file
74
+ Pathname.new("#{exec_path}/#{Spoom::Sorbet::CONFIG_PATH}").cleanpath.to_s
75
+ end
76
+
77
+ sig { returns(Sorbet::Config) }
78
+ def sorbet_config
79
+ Sorbet::Config.parse_file(sorbet_config_file)
80
+ end
81
+
82
+ # Colors
83
+
84
+ # Color used to highlight expressions in backticks
85
+ HIGHLIGHT_COLOR = :blue
86
+
87
+ # Is the `--color` option true?
88
+ sig { returns(T::Boolean) }
89
+ def color?
90
+ T.unsafe(self).options[:color] # TODO: requires_ancestor
91
+ end
92
+
93
+ sig { params(string: String).returns(String) }
94
+ def highlight(string)
95
+ return string unless color?
96
+
97
+ res = StringIO.new
98
+ word = StringIO.new
99
+ in_ticks = T.let(false, T::Boolean)
100
+ string.chars.each do |c|
101
+ if c == '`' && !in_ticks
102
+ in_ticks = true
103
+ elsif c == '`' && in_ticks
104
+ in_ticks = false
105
+ res << colorize(word.string, HIGHLIGHT_COLOR)
106
+ word = StringIO.new
107
+ elsif in_ticks
108
+ word << c
109
+ else
110
+ res << c
111
+ end
112
+ end
113
+ res.string
114
+ end
115
+
116
+ # Colorize a string if `color?`
117
+ sig { params(string: String, color: Symbol).returns(String) }
118
+ def colorize(string, color)
119
+ return string unless color?
120
+ string.colorize(color)
121
+ end
122
+
123
+ sig { params(string: String).returns(String) }
124
+ def blue(string)
125
+ colorize(string, :blue)
126
+ end
127
+
128
+ sig { params(string: String).returns(String) }
129
+ def gray(string)
130
+ colorize(string, :light_black)
131
+ end
132
+
133
+ sig { params(string: String).returns(String) }
134
+ def green(string)
135
+ colorize(string, :green)
136
+ end
137
+
138
+ sig { params(string: String).returns(String) }
139
+ def red(string)
140
+ colorize(string, :red)
141
+ end
142
+
143
+ sig { params(string: String).returns(String) }
144
+ def yellow(string)
145
+ colorize(string, :yellow)
146
+ end
147
+ end
148
+ end
149
+ end