spoom 1.0.4 → 1.0.9

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