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
data/lib/spoom/git.rb ADDED
@@ -0,0 +1,98 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+
6
+ module Spoom
7
+ # Execute git commands
8
+ module Git
9
+ extend T::Sig
10
+
11
+ # Execute a `command`
12
+ sig { params(command: String, arg: String, path: String).returns([String, String, T::Boolean]) }
13
+ def self.exec(command, *arg, path: '.')
14
+ return "", "Error: `#{path}` is not a directory.", false unless File.directory?(path)
15
+ opts = {}
16
+ opts[:chdir] = path
17
+ _, o, e, s = Open3.popen3(*T.unsafe([command, *T.unsafe(arg), opts]))
18
+ out = o.read.to_s
19
+ o.close
20
+ err = e.read.to_s
21
+ e.close
22
+ [out, err, T.cast(s.value, Process::Status).success?]
23
+ end
24
+
25
+ # Git commands
26
+
27
+ sig { params(arg: String, path: String).returns([String, String, T::Boolean]) }
28
+ def self.checkout(*arg, path: ".")
29
+ exec("git checkout -q #{arg.join(' ')}", path: path)
30
+ end
31
+
32
+ sig { params(arg: String, path: String).returns([String, String, T::Boolean]) }
33
+ def self.diff(*arg, path: ".")
34
+ exec("git diff #{arg.join(' ')}", path: path)
35
+ end
36
+
37
+ sig { params(arg: String, path: String).returns([String, String, T::Boolean]) }
38
+ def self.log(*arg, path: ".")
39
+ exec("git log #{arg.join(' ')}", path: path)
40
+ end
41
+
42
+ sig { params(arg: String, path: String).returns([String, String, T::Boolean]) }
43
+ def self.rev_parse(*arg, path: ".")
44
+ exec("git rev-parse --short #{arg.join(' ')}", path: path)
45
+ end
46
+
47
+ sig { params(arg: String, path: String).returns([String, String, T::Boolean]) }
48
+ def self.show(*arg, path: ".")
49
+ exec("git show #{arg.join(' ')}", path: path)
50
+ end
51
+
52
+ # Utils
53
+
54
+ # Get the commit epoch timestamp for a `sha`
55
+ sig { params(sha: String, path: String).returns(T.nilable(Integer)) }
56
+ def self.commit_timestamp(sha, path: ".")
57
+ out, _, status = show("--no-notes --no-patch --pretty=%at #{sha}", path: path)
58
+ return nil unless status
59
+ out.strip.to_i
60
+ end
61
+
62
+ # Get the commit Time for a `sha`
63
+ sig { params(sha: String, path: String).returns(T.nilable(Time)) }
64
+ def self.commit_time(sha, path: ".")
65
+ timestamp = commit_timestamp(sha, path: path)
66
+ return nil unless timestamp
67
+ epoch_to_time(timestamp.to_s)
68
+ end
69
+
70
+ # Get the last commit sha
71
+ sig { params(path: String).returns(T.nilable(String)) }
72
+ def self.last_commit(path: ".")
73
+ out, _, status = rev_parse("HEAD", path: path)
74
+ return nil unless status
75
+ out.strip
76
+ end
77
+
78
+ # Translate a git epoch timestamp into a Time
79
+ sig { params(timestamp: String).returns(Time) }
80
+ def self.epoch_to_time(timestamp)
81
+ Time.strptime(timestamp, "%s")
82
+ end
83
+
84
+ # Is there uncommited changes in `path`?
85
+ sig { params(path: String).returns(T::Boolean) }
86
+ def self.workdir_clean?(path: ".")
87
+ diff("HEAD", path: path).first.empty?
88
+ end
89
+
90
+ # Get the hash of the commit introducing the `sorbet/config` file
91
+ sig { params(path: String).returns(T.nilable(String)) }
92
+ def self.sorbet_intro_commit(path: ".")
93
+ res, _, status = Spoom::Git.log("--diff-filter=A --format='%h' -1 -- sorbet/config", path: path)
94
+ return nil unless status
95
+ res.strip
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,80 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "stringio"
5
+
6
+ module Spoom
7
+ class Printer
8
+ extend T::Sig
9
+ extend T::Helpers
10
+
11
+ abstract!
12
+
13
+ sig { returns(T.any(IO, StringIO)) }
14
+ attr_accessor :out
15
+
16
+ sig { params(out: T.any(IO, StringIO), colors: T::Boolean, indent_level: Integer).void }
17
+ def initialize(out: $stdout, colors: true, indent_level: 0)
18
+ @out = out
19
+ @colors = colors
20
+ @indent_level = indent_level
21
+ end
22
+
23
+ # Increase indent level
24
+ sig { void }
25
+ def indent
26
+ @indent_level += 2
27
+ end
28
+
29
+ # Decrease indent level
30
+ sig { void }
31
+ def dedent
32
+ @indent_level -= 2
33
+ end
34
+
35
+ # Print `string` into `out`
36
+ sig { params(string: T.nilable(String)).void }
37
+ def print(string)
38
+ return unless string
39
+ @out.print(string)
40
+ end
41
+
42
+ # Print `string` colored with `color` into `out`
43
+ #
44
+ # Does not use colors unless `@colors`.
45
+ sig { params(string: T.nilable(String), color: Symbol, colors: Symbol).void }
46
+ def print_colored(string, color, *colors)
47
+ return unless string
48
+ string = colorize(string, color)
49
+ colors.each { |c| string = colorize(string, c) }
50
+ @out.print(string)
51
+ end
52
+
53
+ # Print a new line into `out`
54
+ sig { void }
55
+ def printn
56
+ print("\n")
57
+ end
58
+
59
+ # Print `string` with indent and newline
60
+ sig { params(string: T.nilable(String)).void }
61
+ def printl(string)
62
+ return unless string
63
+ printt
64
+ print(string)
65
+ printn
66
+ end
67
+
68
+ # Print an indent space into `out`
69
+ sig { void }
70
+ def printt
71
+ print(" " * @indent_level)
72
+ end
73
+
74
+ # Colorize `string` with color if `@colors`
75
+ sig { params(string: String, color: Symbol).returns(String) }
76
+ def colorize(string, color)
77
+ @colors ? string.colorize(color) : string
78
+ end
79
+ end
80
+ end
data/lib/spoom/sorbet.rb CHANGED
@@ -5,66 +5,118 @@ require "spoom/sorbet/config"
5
5
  require "spoom/sorbet/errors"
6
6
  require "spoom/sorbet/lsp"
7
7
  require "spoom/sorbet/metrics"
8
+ require "spoom/sorbet/sigils"
8
9
 
9
10
  require "open3"
10
11
 
11
12
  module Spoom
12
13
  module Sorbet
13
- extend T::Sig
14
+ CONFIG_PATH = "sorbet/config"
15
+ GEM_PATH = Gem::Specification.find_by_name("sorbet-static").full_gem_path
16
+ BIN_PATH = (Pathname.new(GEM_PATH) / "libexec" / "sorbet").to_s
14
17
 
15
- sig { params(arg: String, path: String, capture_err: T::Boolean).returns([String, T::Boolean]) }
16
- def self.srb(*arg, path: '.', capture_err: false)
17
- opts = {}
18
- opts[:chdir] = path
19
- out = T.let("", T.nilable(String))
20
- res = T.let(false, T::Boolean)
21
- if capture_err
22
- Open3.popen2e(["bundle", "exec", "srb", *arg].join(" "), opts) do |_, o, t|
23
- out = o.read
24
- res = T.cast(t.value, Process::Status).success?
25
- end
26
- else
27
- Open3.popen2(["bundle", "exec", "srb", *arg].join(" "), opts) do |_, o, t|
28
- out = o.read
29
- res = T.cast(t.value, Process::Status).success?
18
+ class << self
19
+ extend T::Sig
20
+
21
+ sig do
22
+ params(
23
+ arg: String,
24
+ path: String,
25
+ capture_err: T::Boolean,
26
+ sorbet_bin: T.nilable(String)
27
+ ).returns([String, T::Boolean])
28
+ end
29
+ def srb(*arg, path: '.', capture_err: false, sorbet_bin: nil)
30
+ if sorbet_bin
31
+ arg.prepend(sorbet_bin)
32
+ else
33
+ arg.prepend("bundle", "exec", "srb")
30
34
  end
35
+ T.unsafe(Spoom).exec(*arg, path: path, capture_err: capture_err)
31
36
  end
32
- [out || "", res]
33
- end
34
37
 
35
- sig { params(arg: String, path: String, capture_err: T::Boolean).returns([String, T::Boolean]) }
36
- def self.srb_tc(*arg, path: '.', capture_err: false)
37
- srb(*T.unsafe(["tc", *arg]), path: path, capture_err: capture_err)
38
- end
38
+ sig do
39
+ params(
40
+ arg: String,
41
+ path: String,
42
+ capture_err: T::Boolean,
43
+ sorbet_bin: T.nilable(String)
44
+ ).returns([String, T::Boolean])
45
+ end
46
+ def srb_tc(*arg, path: '.', capture_err: false, sorbet_bin: nil)
47
+ arg.prepend("tc") unless sorbet_bin
48
+ T.unsafe(self).srb(*arg, path: path, capture_err: capture_err, sorbet_bin: sorbet_bin)
49
+ end
39
50
 
40
- # List all files typechecked by Sorbet from its `config`
41
- sig { params(config: Config, path: String).returns(T::Array[String]) }
42
- def self.srb_files(config, path: '.')
43
- regs = config.ignore.map { |string| Regexp.new(Regexp.escape(string)) }
44
- exts = config.allowed_extensions.empty? ? ['.rb', '.rbi'] : config.allowed_extensions
45
- Dir.glob((Pathname.new(path) / "**/*{#{exts.join(',')}}").to_s).reject do |f|
46
- regs.any? { |re| re.match?(f) }
47
- end.sort
48
- end
51
+ # List all files typechecked by Sorbet from its `config`
52
+ sig { params(config: Config, path: String).returns(T::Array[String]) }
53
+ def srb_files(config, path: '.')
54
+ regs = config.ignore.map { |string| Regexp.new(Regexp.escape(string)) }
55
+ exts = config.allowed_extensions.empty? ? ['.rb', '.rbi'] : config.allowed_extensions
56
+ Dir.glob((Pathname.new(path) / "**/*{#{exts.join(',')}}").to_s).reject do |f|
57
+ regs.any? { |re| re.match?(f) }
58
+ end.sort
59
+ end
49
60
 
50
- sig { params(arg: String, path: String, capture_err: T::Boolean).returns(T.nilable(String)) }
51
- def self.srb_version(*arg, path: '.', capture_err: false)
52
- out, res = srb(*T.unsafe(["--version", *arg]), path: path, capture_err: capture_err)
53
- return nil unless res
54
- out.split(" ")[2]
55
- end
61
+ sig do
62
+ params(
63
+ arg: String,
64
+ path: String,
65
+ capture_err: T::Boolean,
66
+ sorbet_bin: T.nilable(String)
67
+ ).returns(T.nilable(String))
68
+ end
69
+ def srb_version(*arg, path: '.', capture_err: false, sorbet_bin: nil)
70
+ out, res = T.unsafe(self).srb_tc(
71
+ "--no-config",
72
+ "--version",
73
+ *arg,
74
+ path: path,
75
+ capture_err: capture_err,
76
+ sorbet_bin: sorbet_bin
77
+ )
78
+ return nil unless res
79
+ out.split(" ")[2]
80
+ end
81
+
82
+ sig do
83
+ params(
84
+ arg: String,
85
+ path: String,
86
+ capture_err: T::Boolean,
87
+ sorbet_bin: T.nilable(String)
88
+ ).returns(T.nilable(T::Hash[String, Integer]))
89
+ end
90
+ def srb_metrics(*arg, path: '.', capture_err: false, sorbet_bin: nil)
91
+ metrics_file = "metrics.tmp"
92
+ metrics_path = "#{path}/#{metrics_file}"
93
+ T.unsafe(self).srb_tc(
94
+ "--metrics-file",
95
+ metrics_file,
96
+ *arg,
97
+ path: path,
98
+ capture_err: capture_err,
99
+ sorbet_bin: sorbet_bin
100
+ )
101
+ if File.exist?(metrics_path)
102
+ metrics = Spoom::Sorbet::MetricsParser.parse_file(metrics_path)
103
+ File.delete(metrics_path)
104
+ return metrics
105
+ end
106
+ nil
107
+ end
56
108
 
57
- sig { params(arg: String, path: String, capture_err: T::Boolean).returns(T.nilable(Metrics)) }
58
- def self.srb_metrics(*arg, path: '.', capture_err: false)
59
- metrics_file = "metrics.tmp"
60
- metrics_path = "#{path}/#{metrics_file}"
61
- srb_tc(*T.unsafe(["--metrics-file=#{metrics_file}", *arg]), path: path, capture_err: capture_err)
62
- if File.exist?(metrics_path)
63
- metrics = Spoom::Sorbet::Metrics.parse_file(metrics_path)
64
- File.delete(metrics_path)
65
- return metrics
109
+ # Get `gem` version from the `Gemfile.lock` content
110
+ #
111
+ # Returns `nil` if `gem` cannot be found in the Gemfile.
112
+ sig { params(gem: String, path: String).returns(T.nilable(String)) }
113
+ def version_from_gemfile_lock(gem: 'sorbet', path: '.')
114
+ gemfile_path = "#{path}/Gemfile.lock"
115
+ return nil unless File.exist?(gemfile_path)
116
+ content = File.read(gemfile_path).match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
117
+ return nil unless content
118
+ content[1]
66
119
  end
67
- nil
68
120
  end
69
121
  end
70
122
  end
@@ -36,6 +36,36 @@ module Spoom
36
36
  @allowed_extensions = T.let([], T::Array[String])
37
37
  end
38
38
 
39
+ sig { returns(Config) }
40
+ def copy
41
+ new_config = Sorbet::Config.new
42
+ new_config.paths.concat(@paths)
43
+ new_config.ignore.concat(@ignore)
44
+ new_config.allowed_extensions.concat(@allowed_extensions)
45
+ new_config
46
+ end
47
+
48
+ # Returns self as a string of options that can be passed to Sorbet
49
+ #
50
+ # Example:
51
+ # ~~~rb
52
+ # config = Sorbet::Config.new
53
+ # config.paths << "/foo"
54
+ # config.paths << "/bar"
55
+ # config.ignore << "/baz"
56
+ # config.allowed_extensions << ".rb"
57
+ #
58
+ # puts config.options_string # "/foo /bar --ignore /baz --allowed-extension .rb"
59
+ # ~~~
60
+ sig { returns(String) }
61
+ def options_string
62
+ opts = []
63
+ opts.concat(paths)
64
+ opts.concat(ignore.map { |p| "--ignore #{p}" })
65
+ opts.concat(allowed_extensions.map { |ext| "--allowed-extension #{ext}" })
66
+ opts.join(" ")
67
+ end
68
+
39
69
  class << self
40
70
  extend T::Sig
41
71
 
@@ -4,6 +4,8 @@
4
4
  module Spoom
5
5
  module Sorbet
6
6
  module Errors
7
+ extend T::Sig
8
+
7
9
  # Parse errors from Sorbet output
8
10
  class Parser
9
11
  extend T::Sig
@@ -16,6 +18,19 @@ module Spoom
16
18
  "or set SORBET_SILENCE_DEV_MESSAGE=1 in your shell environment.",
17
19
  ]
18
20
 
21
+ ERROR_LINE_MATCH_REGEX = %r{
22
+ ^ # match beginning of line
23
+ (\S[^:]*) # capture filename as something that starts with a non-space character
24
+ # followed by anything that is not a colon character
25
+ : # match the filename - line number seperator
26
+ (\d+) # capture the line number
27
+ :\s # match the line number - error message separator
28
+ (.*) # capture the error message
29
+ \shttps://srb.help/ # match the error code url prefix
30
+ (\d+) # capture the error code
31
+ $ # match end of line
32
+ }x.freeze
33
+
19
34
  sig { params(output: String).returns(T::Array[Error]) }
20
35
  def self.parse_string(output)
21
36
  parser = Spoom::Sorbet::Errors::Parser.new
@@ -37,9 +52,9 @@ module Spoom
37
52
 
38
53
  next if line == "\n"
39
54
 
40
- if leading_spaces(line) == 0
55
+ if (error = match_error_line(line))
41
56
  close_error if @current_error
42
- open_error(line)
57
+ open_error(error)
43
58
  next
44
59
  end
45
60
 
@@ -51,15 +66,19 @@ module Spoom
51
66
 
52
67
  private
53
68
 
54
- sig { params(line: String).returns(T.nilable(Integer)) }
55
- def leading_spaces(line)
56
- line.index(/[^ ]/)
69
+ sig { params(line: String).returns(T.nilable(Error)) }
70
+ def match_error_line(line)
71
+ match = line.match(ERROR_LINE_MATCH_REGEX)
72
+ return unless match
73
+
74
+ file, line, message, code = match.captures
75
+ Error.new(file, line&.to_i, message, code&.to_i)
57
76
  end
58
77
 
59
- sig { params(line: String).void }
60
- def open_error(line)
78
+ sig { params(error: Error).void }
79
+ def open_error(error)
61
80
  raise "Error: Already parsing an error!" if @current_error
62
- @current_error = Error.from_error_line(line)
81
+ @current_error = error
63
82
  end
64
83
 
65
84
  sig { void }
@@ -106,13 +125,7 @@ module Spoom
106
125
  @more = more
107
126
  end
108
127
 
109
- sig { params(line: String).returns(Error) }
110
- def self.from_error_line(line)
111
- file, line, rest = line.split(/: ?/, 3)
112
- message, code = rest&.split(%r{ https://srb\.help/}, 2)
113
- Error.new(file, line&.to_i, message, code&.to_i)
114
- end
115
-
128
+ # By default errors are sorted by location
116
129
  sig { params(other: T.untyped).returns(Integer) }
117
130
  def <=>(other)
118
131
  return 0 unless other.is_a?(Error)
@@ -124,6 +137,11 @@ module Spoom
124
137
  "#{file}:#{line}: #{message} (#{code})"
125
138
  end
126
139
  end
140
+
141
+ sig { params(errors: T::Array[Error]).returns(T::Array[Error]) }
142
+ def self.sort_errors_by_code(errors)
143
+ errors.sort_by { |e| [e.code, e.file, e.line, e.message] }
144
+ end
127
145
  end
128
146
  end
129
147
  end