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