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