spoom 1.0.0 → 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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
- ignore = T.let(false, T::Boolean)
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
- ignore = true
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
- skip = true
82
+ state = :skip
74
83
  when /^-.*=?/
75
84
  next
76
85
  else
77
- if ignore
86
+ case state
87
+ when :ignore
78
88
  config.ignore << line
79
- ignore = false
80
- elsif skip
81
- skip = false
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