spoom 1.0.3 → 1.0.8

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,121 @@
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
+ 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
17
+
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")
34
+ end
35
+ T.unsafe(Spoom).exec(*arg, path: path, capture_err: capture_err)
36
+ end
37
+
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
50
+
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
60
+
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
+ "--version",
72
+ *arg,
73
+ path: path,
74
+ capture_err: capture_err,
75
+ sorbet_bin: sorbet_bin
76
+ )
77
+ return nil unless res
78
+ out.split(" ")[2]
79
+ end
80
+
81
+ sig do
82
+ params(
83
+ arg: String,
84
+ path: String,
85
+ capture_err: T::Boolean,
86
+ sorbet_bin: T.nilable(String)
87
+ ).returns(T.nilable(T::Hash[String, Integer]))
88
+ end
89
+ def srb_metrics(*arg, path: '.', capture_err: false, sorbet_bin: nil)
90
+ metrics_file = "metrics.tmp"
91
+ metrics_path = "#{path}/#{metrics_file}"
92
+ T.unsafe(self).srb_tc(
93
+ "--metrics-file",
94
+ metrics_file,
95
+ *arg,
96
+ path: path,
97
+ capture_err: capture_err,
98
+ sorbet_bin: sorbet_bin
99
+ )
100
+ if File.exist?(metrics_path)
101
+ metrics = Spoom::Sorbet::MetricsParser.parse_file(metrics_path)
102
+ File.delete(metrics_path)
103
+ return metrics
104
+ end
105
+ nil
106
+ end
107
+
108
+ # Get `gem` version from the `Gemfile.lock` content
109
+ #
110
+ # Returns `nil` if `gem` cannot be found in the Gemfile.
111
+ sig { params(gem: String, path: String).returns(T.nilable(String)) }
112
+ def version_from_gemfile_lock(gem: 'sorbet', path: '.')
113
+ gemfile_path = "#{path}/Gemfile.lock"
114
+ return nil unless File.exist?(gemfile_path)
115
+ content = File.read(gemfile_path).match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
116
+ return nil unless content
117
+ content[1]
118
+ end
119
+ end
120
+ end
121
+ end
@@ -27,12 +27,43 @@ 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])
37
+ end
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(" ")
36
67
  end
37
68
 
38
69
  class << self
@@ -46,13 +77,21 @@ module Spoom
46
77
  sig { params(sorbet_config: String).returns(Spoom::Sorbet::Config) }
47
78
  def parse_string(sorbet_config)
48
79
  config = Config.new
49
- ignore = T.let(false, T::Boolean)
50
- skip = T.let(false, T::Boolean)
80
+ state = T.let(nil, T.nilable(Symbol))
51
81
  sorbet_config.each_line do |line|
52
82
  line = line.strip
53
83
  case line
84
+ when /^--allowed-extension$/
85
+ state = :extension
86
+ next
87
+ when /^--allowed-extension=/
88
+ config.allowed_extensions << parse_option(line)
89
+ next
90
+ when /^--ignore=/
91
+ config.ignore << parse_option(line)
92
+ next
54
93
  when /^--ignore$/
55
- ignore = true
94
+ state = :ignore
56
95
  next
57
96
  when /^--ignore=/
58
97
  config.ignore << parse_option(line)
@@ -70,18 +109,21 @@ module Spoom
70
109
  when /^--.*=/
71
110
  next
72
111
  when /^--/
73
- skip = true
112
+ state = :skip
74
113
  when /^-.*=?/
75
114
  next
76
115
  else
77
- if ignore
116
+ case state
117
+ when :ignore
78
118
  config.ignore << line
79
- ignore = false
80
- elsif skip
81
- skip = false
119
+ when :extension
120
+ config.allowed_extensions << line
121
+ when :skip
122
+ # nothing
82
123
  else
83
124
  config.paths << line
84
125
  end
126
+ state = nil
85
127
  end
86
128
  end
87
129
  config
@@ -0,0 +1,147 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Sorbet
6
+ module Errors
7
+ extend T::Sig
8
+
9
+ # Parse errors from Sorbet output
10
+ class Parser
11
+ extend T::Sig
12
+
13
+ HEADER = [
14
+ "👋 Hey there! Heads up that this is not a release build of sorbet.",
15
+ "Release builds are faster and more well-supported by the Sorbet team.",
16
+ "Check out the README to learn how to build Sorbet in release mode.",
17
+ "To forcibly silence this error, either pass --silence-dev-message,",
18
+ "or set SORBET_SILENCE_DEV_MESSAGE=1 in your shell environment.",
19
+ ]
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
+
34
+ sig { params(output: String).returns(T::Array[Error]) }
35
+ def self.parse_string(output)
36
+ parser = Spoom::Sorbet::Errors::Parser.new
37
+ parser.parse(output)
38
+ end
39
+
40
+ sig { void }
41
+ def initialize
42
+ @errors = []
43
+ @current_error = nil
44
+ end
45
+
46
+ sig { params(output: String).returns(T::Array[Error]) }
47
+ def parse(output)
48
+ output.each_line do |line|
49
+ break if /^No errors! Great job\./.match?(line)
50
+ break if /^Errors: /.match?(line)
51
+ next if HEADER.include?(line.strip)
52
+
53
+ next if line == "\n"
54
+
55
+ if (error = match_error_line(line))
56
+ close_error if @current_error
57
+ open_error(error)
58
+ next
59
+ end
60
+
61
+ append_error(line) if @current_error
62
+ end
63
+ close_error if @current_error
64
+ @errors
65
+ end
66
+
67
+ private
68
+
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)
76
+ end
77
+
78
+ sig { params(error: Error).void }
79
+ def open_error(error)
80
+ raise "Error: Already parsing an error!" if @current_error
81
+ @current_error = error
82
+ end
83
+
84
+ sig { void }
85
+ def close_error
86
+ raise "Error: Not already parsing an error!" unless @current_error
87
+ @errors << @current_error
88
+ @current_error = nil
89
+ end
90
+
91
+ sig { params(line: String).void }
92
+ def append_error(line)
93
+ raise "Error: Not already parsing an error!" unless @current_error
94
+ @current_error.more << line
95
+ end
96
+ end
97
+
98
+ class Error
99
+ include Comparable
100
+ extend T::Sig
101
+
102
+ sig { returns(T.nilable(String)) }
103
+ attr_reader :file, :message
104
+
105
+ sig { returns(T.nilable(Integer)) }
106
+ attr_reader :line, :code
107
+
108
+ sig { returns(T::Array[String]) }
109
+ attr_reader :more
110
+
111
+ sig do
112
+ params(
113
+ file: T.nilable(String),
114
+ line: T.nilable(Integer),
115
+ message: T.nilable(String),
116
+ code: T.nilable(Integer),
117
+ more: T::Array[String]
118
+ ).void
119
+ end
120
+ def initialize(file, line, message, code, more = [])
121
+ @file = file
122
+ @line = line
123
+ @message = message
124
+ @code = code
125
+ @more = more
126
+ end
127
+
128
+ # By default errors are sorted by location
129
+ sig { params(other: T.untyped).returns(Integer) }
130
+ def <=>(other)
131
+ return 0 unless other.is_a?(Error)
132
+ [file, line, code, message] <=> [other.file, other.line, other.code, other.message]
133
+ end
134
+
135
+ sig { returns(String) }
136
+ def to_s
137
+ "#{file}:#{line}: #{message} (#{code})"
138
+ end
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
145
+ end
146
+ end
147
+ end