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