spoom 1.0.1 → 1.0.6
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.
- checksums.yaml +4 -4
- data/Gemfile +4 -1
- data/README.md +253 -1
- data/Rakefile +2 -0
- data/exe/spoom +7 -0
- data/lib/spoom.rb +9 -1
- data/lib/spoom/cli.rb +68 -0
- data/lib/spoom/cli/bump.rb +59 -0
- data/lib/spoom/cli/config.rb +51 -0
- data/lib/spoom/cli/coverage.rb +191 -0
- data/lib/spoom/cli/helper.rb +70 -0
- data/lib/spoom/cli/lsp.rb +165 -0
- data/lib/spoom/cli/run.rb +79 -0
- data/lib/spoom/config.rb +11 -0
- data/lib/spoom/coverage.rb +73 -0
- data/lib/spoom/coverage/d3.rb +110 -0
- data/lib/spoom/coverage/d3/base.rb +50 -0
- data/lib/spoom/coverage/d3/circle_map.rb +195 -0
- data/lib/spoom/coverage/d3/pie.rb +175 -0
- data/lib/spoom/coverage/d3/timeline.rb +486 -0
- data/lib/spoom/coverage/report.rb +308 -0
- data/lib/spoom/coverage/snapshot.rb +132 -0
- data/lib/spoom/file_tree.rb +196 -0
- data/lib/spoom/git.rb +98 -0
- data/lib/spoom/printer.rb +81 -0
- data/lib/spoom/sorbet.rb +83 -0
- data/lib/spoom/sorbet/config.rb +21 -9
- data/lib/spoom/sorbet/errors.rb +139 -0
- data/lib/spoom/sorbet/lsp.rb +196 -0
- data/lib/spoom/sorbet/lsp/base.rb +58 -0
- data/lib/spoom/sorbet/lsp/errors.rb +45 -0
- data/lib/spoom/sorbet/lsp/structures.rb +312 -0
- data/lib/spoom/sorbet/metrics.rb +33 -0
- data/lib/spoom/sorbet/sigils.rb +98 -0
- data/lib/spoom/test_helpers/project.rb +103 -0
- data/lib/spoom/timeline.rb +53 -0
- data/lib/spoom/version.rb +2 -1
- data/templates/card.erb +8 -0
- data/templates/card_snapshot.erb +22 -0
- data/templates/page.erb +50 -0
- metadata +80 -20
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,81 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "colorize"
|
5
|
+
require "stringio"
|
6
|
+
|
7
|
+
module Spoom
|
8
|
+
class Printer
|
9
|
+
extend T::Sig
|
10
|
+
extend T::Helpers
|
11
|
+
|
12
|
+
abstract!
|
13
|
+
|
14
|
+
sig { returns(T.any(IO, StringIO)) }
|
15
|
+
attr_accessor :out
|
16
|
+
|
17
|
+
sig { params(out: T.any(IO, StringIO), colors: T::Boolean, indent_level: Integer).void }
|
18
|
+
def initialize(out: $stdout, colors: true, indent_level: 0)
|
19
|
+
@out = out
|
20
|
+
@colors = colors
|
21
|
+
@indent_level = indent_level
|
22
|
+
end
|
23
|
+
|
24
|
+
# Increase indent level
|
25
|
+
sig { void }
|
26
|
+
def indent
|
27
|
+
@indent_level += 2
|
28
|
+
end
|
29
|
+
|
30
|
+
# Decrease indent level
|
31
|
+
sig { void }
|
32
|
+
def dedent
|
33
|
+
@indent_level -= 2
|
34
|
+
end
|
35
|
+
|
36
|
+
# Print `string` into `out`
|
37
|
+
sig { params(string: T.nilable(String)).void }
|
38
|
+
def print(string)
|
39
|
+
return unless string
|
40
|
+
@out.print(string)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Print `string` colored with `color` into `out`
|
44
|
+
#
|
45
|
+
# Does not use colors unless `@colors`.
|
46
|
+
sig { params(string: T.nilable(String), color: Symbol, colors: Symbol).void }
|
47
|
+
def print_colored(string, color, *colors)
|
48
|
+
return unless string
|
49
|
+
string = colorize(string, color)
|
50
|
+
colors.each { |c| string = colorize(string, c) }
|
51
|
+
@out.print(string)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Print a new line into `out`
|
55
|
+
sig { void }
|
56
|
+
def printn
|
57
|
+
print("\n")
|
58
|
+
end
|
59
|
+
|
60
|
+
# Print `string` with indent and newline
|
61
|
+
sig { params(string: T.nilable(String)).void }
|
62
|
+
def printl(string)
|
63
|
+
return unless string
|
64
|
+
printt
|
65
|
+
print(string)
|
66
|
+
printn
|
67
|
+
end
|
68
|
+
|
69
|
+
# Print an indent space into `out`
|
70
|
+
sig { void }
|
71
|
+
def printt
|
72
|
+
print(" " * @indent_level)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Colorize `string` with color if `@colors`
|
76
|
+
sig { params(string: String, color: Symbol).returns(String) }
|
77
|
+
def colorize(string, color)
|
78
|
+
@colors ? string.colorize(color) : string
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
data/lib/spoom/sorbet.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "spoom/sorbet/config"
|
5
|
+
require "spoom/sorbet/errors"
|
6
|
+
require "spoom/sorbet/lsp"
|
7
|
+
require "spoom/sorbet/metrics"
|
8
|
+
require "spoom/sorbet/sigils"
|
9
|
+
|
10
|
+
require "open3"
|
11
|
+
|
12
|
+
module Spoom
|
13
|
+
module Sorbet
|
14
|
+
extend T::Sig
|
15
|
+
|
16
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns([String, T::Boolean]) }
|
17
|
+
def self.srb(*arg, path: '.', capture_err: false)
|
18
|
+
opts = {}
|
19
|
+
opts[:chdir] = path
|
20
|
+
out = T.let("", T.nilable(String))
|
21
|
+
res = T.let(false, T::Boolean)
|
22
|
+
if capture_err
|
23
|
+
Open3.popen2e(["bundle", "exec", "srb", *arg].join(" "), opts) do |_, o, t|
|
24
|
+
out = o.read
|
25
|
+
res = T.cast(t.value, Process::Status).success?
|
26
|
+
end
|
27
|
+
else
|
28
|
+
Open3.popen2(["bundle", "exec", "srb", *arg].join(" "), opts) do |_, o, t|
|
29
|
+
out = o.read
|
30
|
+
res = T.cast(t.value, Process::Status).success?
|
31
|
+
end
|
32
|
+
end
|
33
|
+
[out || "", res]
|
34
|
+
end
|
35
|
+
|
36
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns([String, T::Boolean]) }
|
37
|
+
def self.srb_tc(*arg, path: '.', capture_err: false)
|
38
|
+
srb(*T.unsafe(["tc", *arg]), path: path, capture_err: capture_err)
|
39
|
+
end
|
40
|
+
|
41
|
+
# List all files typechecked by Sorbet from its `config`
|
42
|
+
sig { params(config: Config, path: String).returns(T::Array[String]) }
|
43
|
+
def self.srb_files(config, path: '.')
|
44
|
+
regs = config.ignore.map { |string| Regexp.new(Regexp.escape(string)) }
|
45
|
+
exts = config.allowed_extensions.empty? ? ['.rb', '.rbi'] : config.allowed_extensions
|
46
|
+
Dir.glob((Pathname.new(path) / "**/*{#{exts.join(',')}}").to_s).reject do |f|
|
47
|
+
regs.any? { |re| re.match?(f) }
|
48
|
+
end.sort
|
49
|
+
end
|
50
|
+
|
51
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns(T.nilable(String)) }
|
52
|
+
def self.srb_version(*arg, path: '.', capture_err: false)
|
53
|
+
out, res = srb(*T.unsafe(["--version", *arg]), path: path, capture_err: capture_err)
|
54
|
+
return nil unless res
|
55
|
+
out.split(" ")[2]
|
56
|
+
end
|
57
|
+
|
58
|
+
sig { params(arg: String, path: String, capture_err: T::Boolean).returns(T.nilable(T::Hash[String, Integer])) }
|
59
|
+
def self.srb_metrics(*arg, path: '.', capture_err: false)
|
60
|
+
metrics_file = "metrics.tmp"
|
61
|
+
metrics_path = "#{path}/#{metrics_file}"
|
62
|
+
srb_tc(*T.unsafe(["--metrics-file=#{metrics_file}", *arg]), path: path, capture_err: capture_err)
|
63
|
+
if File.exist?(metrics_path)
|
64
|
+
metrics = Spoom::Sorbet::MetricsParser.parse_file(metrics_path)
|
65
|
+
File.delete(metrics_path)
|
66
|
+
return metrics
|
67
|
+
end
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
# Get `gem` version from the `Gemfile.lock` content
|
72
|
+
#
|
73
|
+
# Returns `nil` if `gem` cannot be found in the Gemfile.
|
74
|
+
sig { params(gem: String, path: String).returns(T.nilable(String)) }
|
75
|
+
def self.version_from_gemfile_lock(gem: 'sorbet', path: '.')
|
76
|
+
gemfile_path = "#{path}/Gemfile.lock"
|
77
|
+
return nil unless File.exist?(gemfile_path)
|
78
|
+
content = File.read(gemfile_path).match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
|
79
|
+
return nil unless content
|
80
|
+
content[1]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
data/lib/spoom/sorbet/config.rb
CHANGED
@@ -27,12 +27,13 @@ module Spoom
|
|
27
27
|
extend T::Sig
|
28
28
|
|
29
29
|
sig { returns(T::Array[String]) }
|
30
|
-
attr_reader :paths, :ignore
|
30
|
+
attr_reader :paths, :ignore, :allowed_extensions
|
31
31
|
|
32
32
|
sig { void }
|
33
33
|
def initialize
|
34
34
|
@paths = T.let([], T::Array[String])
|
35
35
|
@ignore = T.let([], T::Array[String])
|
36
|
+
@allowed_extensions = T.let([], T::Array[String])
|
36
37
|
end
|
37
38
|
|
38
39
|
class << self
|
@@ -46,13 +47,21 @@ module Spoom
|
|
46
47
|
sig { params(sorbet_config: String).returns(Spoom::Sorbet::Config) }
|
47
48
|
def parse_string(sorbet_config)
|
48
49
|
config = Config.new
|
49
|
-
|
50
|
-
skip = T.let(false, T::Boolean)
|
50
|
+
state = T.let(nil, T.nilable(Symbol))
|
51
51
|
sorbet_config.each_line do |line|
|
52
52
|
line = line.strip
|
53
53
|
case line
|
54
|
+
when /^--allowed-extension$/
|
55
|
+
state = :extension
|
56
|
+
next
|
57
|
+
when /^--allowed-extension=/
|
58
|
+
config.allowed_extensions << parse_option(line)
|
59
|
+
next
|
60
|
+
when /^--ignore=/
|
61
|
+
config.ignore << parse_option(line)
|
62
|
+
next
|
54
63
|
when /^--ignore$/
|
55
|
-
|
64
|
+
state = :ignore
|
56
65
|
next
|
57
66
|
when /^--ignore=/
|
58
67
|
config.ignore << parse_option(line)
|
@@ -70,18 +79,21 @@ module Spoom
|
|
70
79
|
when /^--.*=/
|
71
80
|
next
|
72
81
|
when /^--/
|
73
|
-
|
82
|
+
state = :skip
|
74
83
|
when /^-.*=?/
|
75
84
|
next
|
76
85
|
else
|
77
|
-
|
86
|
+
case state
|
87
|
+
when :ignore
|
78
88
|
config.ignore << line
|
79
|
-
|
80
|
-
|
81
|
-
|
89
|
+
when :extension
|
90
|
+
config.allowed_extensions << line
|
91
|
+
when :skip
|
92
|
+
# nothing
|
82
93
|
else
|
83
94
|
config.paths << line
|
84
95
|
end
|
96
|
+
state = nil
|
85
97
|
end
|
86
98
|
end
|
87
99
|
config
|
@@ -0,0 +1,139 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
module Sorbet
|
6
|
+
module Errors
|
7
|
+
# Parse errors from Sorbet output
|
8
|
+
class Parser
|
9
|
+
extend T::Sig
|
10
|
+
|
11
|
+
HEADER = [
|
12
|
+
"👋 Hey there! Heads up that this is not a release build of sorbet.",
|
13
|
+
"Release builds are faster and more well-supported by the Sorbet team.",
|
14
|
+
"Check out the README to learn how to build Sorbet in release mode.",
|
15
|
+
"To forcibly silence this error, either pass --silence-dev-message,",
|
16
|
+
"or set SORBET_SILENCE_DEV_MESSAGE=1 in your shell environment.",
|
17
|
+
]
|
18
|
+
|
19
|
+
ERROR_LINE_MATCH_REGEX = %r{
|
20
|
+
^ # match beginning of line
|
21
|
+
(\S[^:]*) # capture filename as something that starts with a non-space character
|
22
|
+
# followed by anything that is not a colon character
|
23
|
+
: # match the filename - line number seperator
|
24
|
+
(\d+) # capture the line number
|
25
|
+
:\s # match the line number - error message separator
|
26
|
+
(.*) # capture the error message
|
27
|
+
\shttps://srb.help/ # match the error code url prefix
|
28
|
+
(\d+) # capture the error code
|
29
|
+
$ # match end of line
|
30
|
+
}x.freeze
|
31
|
+
|
32
|
+
sig { params(output: String).returns(T::Array[Error]) }
|
33
|
+
def self.parse_string(output)
|
34
|
+
parser = Spoom::Sorbet::Errors::Parser.new
|
35
|
+
parser.parse(output)
|
36
|
+
end
|
37
|
+
|
38
|
+
sig { void }
|
39
|
+
def initialize
|
40
|
+
@errors = []
|
41
|
+
@current_error = nil
|
42
|
+
end
|
43
|
+
|
44
|
+
sig { params(output: String).returns(T::Array[Error]) }
|
45
|
+
def parse(output)
|
46
|
+
output.each_line do |line|
|
47
|
+
break if /^No errors! Great job\./.match?(line)
|
48
|
+
break if /^Errors: /.match?(line)
|
49
|
+
next if HEADER.include?(line.strip)
|
50
|
+
|
51
|
+
next if line == "\n"
|
52
|
+
|
53
|
+
if (error = match_error_line(line))
|
54
|
+
close_error if @current_error
|
55
|
+
open_error(error)
|
56
|
+
next
|
57
|
+
end
|
58
|
+
|
59
|
+
append_error(line) if @current_error
|
60
|
+
end
|
61
|
+
close_error if @current_error
|
62
|
+
@errors
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
sig { params(line: String).returns(T.nilable(Error)) }
|
68
|
+
def match_error_line(line)
|
69
|
+
match = line.match(ERROR_LINE_MATCH_REGEX)
|
70
|
+
return unless match
|
71
|
+
|
72
|
+
file, line, message, code = match.captures
|
73
|
+
Error.new(file, line&.to_i, message, code&.to_i)
|
74
|
+
end
|
75
|
+
|
76
|
+
sig { params(error: Error).void }
|
77
|
+
def open_error(error)
|
78
|
+
raise "Error: Already parsing an error!" if @current_error
|
79
|
+
@current_error = error
|
80
|
+
end
|
81
|
+
|
82
|
+
sig { void }
|
83
|
+
def close_error
|
84
|
+
raise "Error: Not already parsing an error!" unless @current_error
|
85
|
+
@errors << @current_error
|
86
|
+
@current_error = nil
|
87
|
+
end
|
88
|
+
|
89
|
+
sig { params(line: String).void }
|
90
|
+
def append_error(line)
|
91
|
+
raise "Error: Not already parsing an error!" unless @current_error
|
92
|
+
@current_error.more << line
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
class Error
|
97
|
+
include Comparable
|
98
|
+
extend T::Sig
|
99
|
+
|
100
|
+
sig { returns(T.nilable(String)) }
|
101
|
+
attr_reader :file, :message
|
102
|
+
|
103
|
+
sig { returns(T.nilable(Integer)) }
|
104
|
+
attr_reader :line, :code
|
105
|
+
|
106
|
+
sig { returns(T::Array[String]) }
|
107
|
+
attr_reader :more
|
108
|
+
|
109
|
+
sig do
|
110
|
+
params(
|
111
|
+
file: T.nilable(String),
|
112
|
+
line: T.nilable(Integer),
|
113
|
+
message: T.nilable(String),
|
114
|
+
code: T.nilable(Integer),
|
115
|
+
more: T::Array[String]
|
116
|
+
).void
|
117
|
+
end
|
118
|
+
def initialize(file, line, message, code, more = [])
|
119
|
+
@file = file
|
120
|
+
@line = line
|
121
|
+
@message = message
|
122
|
+
@code = code
|
123
|
+
@more = more
|
124
|
+
end
|
125
|
+
|
126
|
+
sig { params(other: T.untyped).returns(Integer) }
|
127
|
+
def <=>(other)
|
128
|
+
return 0 unless other.is_a?(Error)
|
129
|
+
[file, line, code, message] <=> [other.file, other.line, other.code, other.message]
|
130
|
+
end
|
131
|
+
|
132
|
+
sig { returns(String) }
|
133
|
+
def to_s
|
134
|
+
"#{file}:#{line}: #{message} (#{code})"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|