spoom 1.1.15 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/lib/spoom/cli/bump.rb +46 -25
- data/lib/spoom/cli/config.rb +4 -3
- data/lib/spoom/cli/coverage.rb +33 -21
- data/lib/spoom/cli/helper.rb +11 -35
- data/lib/spoom/cli/lsp.rb +4 -2
- data/lib/spoom/cli/run.rb +17 -10
- data/lib/spoom/cli.rb +4 -7
- data/lib/spoom/context/bundle.rb +58 -0
- data/lib/spoom/context/exec.rb +50 -0
- data/lib/spoom/context/file_system.rb +93 -0
- data/lib/spoom/context/git.rb +121 -0
- data/lib/spoom/context/sorbet.rb +136 -0
- data/lib/spoom/context.rb +17 -193
- data/lib/spoom/coverage/snapshot.rb +1 -1
- data/lib/spoom/coverage.rb +24 -37
- data/lib/spoom/sorbet/errors.rb +10 -7
- data/lib/spoom/sorbet/lsp/structures.rb +31 -28
- data/lib/spoom/sorbet/sigils.rb +11 -8
- data/lib/spoom/sorbet.rb +17 -101
- data/lib/spoom/timeline.rb +5 -8
- data/lib/spoom/version.rb +1 -1
- data/lib/spoom.rb +0 -52
- metadata +8 -4
- data/lib/spoom/git.rb +0 -130
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 323ebe199d0efed57d5e92af12eb377c3c5af35e468c83db851b27f86b44117b
|
4
|
+
data.tar.gz: c0fb24e2af1af3c0e460bb7c06ba9f3cff9cda471ec829ba00742b40dd67ab84
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: faa5f86d378dd4cf56aa6e680cdee5717c59069b464b823bebfc63e7c8a7a40aeb7a75d86ec4dd0c69e025c4ad397eff740fdfa2ac134f49868736197836790a
|
7
|
+
data.tar.gz: 67f7d4bc72282ff4bea575e85c2eed80f7195f94d3b518e3a84af57085bed90c757aedab5cfe33fb6d14dc96b62f21c6e8aa8cb56b79d4e9b148318a7c713e98
|
data/Gemfile
CHANGED
data/lib/spoom/cli/bump.rb
CHANGED
@@ -13,26 +13,41 @@ module Spoom
|
|
13
13
|
default_task :bump
|
14
14
|
|
15
15
|
desc "bump DIRECTORY", "Change Sorbet sigils from one strictness to another when no errors"
|
16
|
-
option :from,
|
16
|
+
option :from,
|
17
|
+
type: :string,
|
18
|
+
default: Spoom::Sorbet::Sigils::STRICTNESS_FALSE,
|
17
19
|
desc: "Change only files from this strictness"
|
18
|
-
option :to,
|
20
|
+
option :to,
|
21
|
+
type: :string,
|
22
|
+
default: Spoom::Sorbet::Sigils::STRICTNESS_TRUE,
|
19
23
|
desc: "Change files to this strictness"
|
20
|
-
option :force,
|
24
|
+
option :force,
|
25
|
+
type: :boolean,
|
26
|
+
default: false,
|
27
|
+
aliases: :f,
|
21
28
|
desc: "Change strictness without type checking"
|
22
29
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
23
|
-
option :dry,
|
30
|
+
option :dry,
|
31
|
+
type: :boolean,
|
32
|
+
default: false,
|
33
|
+
aliases: :d,
|
24
34
|
desc: "Only display what would happen, do not actually change sigils"
|
25
|
-
option :only,
|
35
|
+
option :only,
|
36
|
+
type: :string,
|
37
|
+
default: nil,
|
38
|
+
aliases: :o,
|
26
39
|
desc: "Only change specified list (one file by line)"
|
27
|
-
option :suggest_bump_command,
|
40
|
+
option :suggest_bump_command,
|
41
|
+
type: :string,
|
28
42
|
desc: "Command to suggest if files can be bumped"
|
29
|
-
option :count_errors,
|
43
|
+
option :count_errors,
|
44
|
+
type: :boolean,
|
45
|
+
default: false,
|
30
46
|
desc: "Count the number of errors if all files were bumped"
|
31
47
|
option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
|
32
48
|
sig { params(directory: String).void }
|
33
49
|
def bump(directory = ".")
|
34
|
-
|
35
|
-
|
50
|
+
context = context_requiring_sorbet!
|
36
51
|
from = options[:from]
|
37
52
|
to = options[:to]
|
38
53
|
force = options[:force]
|
@@ -61,7 +76,7 @@ module Spoom
|
|
61
76
|
directory = File.expand_path(directory)
|
62
77
|
files_to_bump = Sorbet::Sigils.files_with_sigil_strictness(directory, from)
|
63
78
|
|
64
|
-
files_from_config =
|
79
|
+
files_from_config = context.srb_files.map { |file| File.expand_path(file) }
|
65
80
|
files_to_bump.select! { |file| files_from_config.include?(file) }
|
66
81
|
|
67
82
|
if only
|
@@ -85,20 +100,32 @@ module Spoom
|
|
85
100
|
end
|
86
101
|
|
87
102
|
error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE
|
88
|
-
result =
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
check_sorbet_segfault(result.exit_code) do
|
103
|
+
result = begin
|
104
|
+
T.unsafe(context).srb_tc(
|
105
|
+
*options[:sorbet_options].split(" "),
|
106
|
+
"--error-url-base=#{error_url_base}",
|
107
|
+
capture_err: true,
|
108
|
+
sorbet_bin: options[:sorbet],
|
109
|
+
)
|
110
|
+
rescue Spoom::Sorbet::Error::Segfault => error
|
97
111
|
say_error(<<~ERR, status: nil)
|
112
|
+
!!! Sorbet exited with code #{Spoom::Sorbet::SEGFAULT_CODE} - SEGFAULT !!!
|
113
|
+
|
114
|
+
This is most likely related to a bug in Sorbet.
|
98
115
|
It means one of the file bumped to `typed: #{to}` made Sorbet crash.
|
99
116
|
Run `spoom bump -f` locally followed by `bundle exec srb tc` to investigate the problem.
|
100
117
|
ERR
|
101
118
|
undo_changes(files_to_bump, from)
|
119
|
+
exit(error.result.exit_code)
|
120
|
+
rescue Spoom::Sorbet::Error::Killed => error
|
121
|
+
say_error(<<~ERR, status: nil)
|
122
|
+
!!! Sorbet exited with code #{Spoom::Sorbet::KILLED_CODE} - KILLED !!!
|
123
|
+
|
124
|
+
It means Sorbet was killed while executing. Changes to files have not been applied.
|
125
|
+
Re-run `spoom bump` to try again.
|
126
|
+
ERR
|
127
|
+
undo_changes(files_to_bump, from)
|
128
|
+
exit(error.result.exit_code)
|
102
129
|
end
|
103
130
|
|
104
131
|
if result.status
|
@@ -166,12 +193,6 @@ module Spoom
|
|
166
193
|
def undo_changes(files, from_strictness)
|
167
194
|
Sorbet::Sigils.change_sigil_in_files(files, from_strictness)
|
168
195
|
end
|
169
|
-
|
170
|
-
def config_files(path: ".")
|
171
|
-
config = sorbet_config
|
172
|
-
files = Sorbet.srb_files(config, path: path)
|
173
|
-
files.map { |file| File.expand_path(file) }
|
174
|
-
end
|
175
196
|
end
|
176
197
|
end
|
177
198
|
end
|
data/lib/spoom/cli/config.rb
CHANGED
@@ -13,10 +13,11 @@ module Spoom
|
|
13
13
|
|
14
14
|
desc "show", "Show Sorbet config"
|
15
15
|
def show
|
16
|
-
|
17
|
-
config = sorbet_config
|
16
|
+
context = context_requiring_sorbet!
|
17
|
+
config = context.sorbet_config
|
18
|
+
config_path = Pathname.new("#{exec_path}/#{Spoom::Sorbet::CONFIG_PATH}").cleanpath
|
18
19
|
|
19
|
-
say("Found Sorbet config at `#{
|
20
|
+
say("Found Sorbet config at `#{config_path}`.")
|
20
21
|
|
21
22
|
say("\nPaths typechecked:")
|
22
23
|
if config.paths.empty?
|
data/lib/spoom/cli/coverage.rb
CHANGED
@@ -18,11 +18,10 @@ module Spoom
|
|
18
18
|
option :rbi, type: :boolean, default: true, desc: "Include RBI files in metrics"
|
19
19
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
20
20
|
def snapshot
|
21
|
-
|
22
|
-
path = exec_path
|
21
|
+
context = context_requiring_sorbet!
|
23
22
|
sorbet = options[:sorbet]
|
24
23
|
|
25
|
-
snapshot = Spoom::Coverage.snapshot(
|
24
|
+
snapshot = Spoom::Coverage.snapshot(context, rbi: options[:rbi], sorbet_bin: sorbet)
|
26
25
|
snapshot.print
|
27
26
|
|
28
27
|
save_dir = options[:save]
|
@@ -41,19 +40,19 @@ module Spoom
|
|
41
40
|
option :bundle_install, type: :boolean, desc: "Execute `bundle install` before collecting metrics"
|
42
41
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
43
42
|
def timeline
|
44
|
-
|
43
|
+
context = context_requiring_sorbet!
|
45
44
|
path = exec_path
|
46
45
|
sorbet = options[:sorbet]
|
47
46
|
|
48
|
-
ref_before =
|
49
|
-
ref_before =
|
47
|
+
ref_before = context.git_current_branch
|
48
|
+
ref_before = context.git_last_commit&.sha unless ref_before
|
50
49
|
unless ref_before
|
51
50
|
say_error("Not in a git repository")
|
52
51
|
say_error("\nSpoom needs to checkout into your previous commits to build the timeline.", status: nil)
|
53
52
|
exit(1)
|
54
53
|
end
|
55
54
|
|
56
|
-
unless
|
55
|
+
unless context.git_workdir_clean?
|
57
56
|
say_error("Uncommited changes")
|
58
57
|
say_error(<<~ERR, status: nil)
|
59
58
|
|
@@ -71,12 +70,12 @@ module Spoom
|
|
71
70
|
to = parse_time(options[:to], "--to")
|
72
71
|
|
73
72
|
unless from
|
74
|
-
intro_commit =
|
73
|
+
intro_commit = context.sorbet_intro_commit
|
75
74
|
intro_commit = T.must(intro_commit) # we know it's in there since in_sorbet_project!
|
76
75
|
from = intro_commit.time
|
77
76
|
end
|
78
77
|
|
79
|
-
timeline = Spoom::Timeline.new(from, to
|
78
|
+
timeline = Spoom::Timeline.new(context, from, to)
|
80
79
|
ticks = timeline.ticks
|
81
80
|
|
82
81
|
if ticks.empty?
|
@@ -87,17 +86,17 @@ module Spoom
|
|
87
86
|
ticks.each_with_index do |commit, i|
|
88
87
|
say("Analyzing commit `#{commit.sha}` - #{commit.time.strftime("%F")} (#{i + 1} / #{ticks.size})")
|
89
88
|
|
90
|
-
|
89
|
+
context.git_checkout!(ref: commit.sha)
|
91
90
|
|
92
91
|
snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
|
93
92
|
if options[:bundle_install]
|
94
93
|
Bundler.with_unbundled_env do
|
95
94
|
next unless bundle_install(path, commit.sha)
|
96
95
|
|
97
|
-
snapshot = Spoom::Coverage.snapshot(
|
96
|
+
snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
|
98
97
|
end
|
99
98
|
else
|
100
|
-
snapshot = Spoom::Coverage.snapshot(
|
99
|
+
snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
|
101
100
|
end
|
102
101
|
next unless snapshot
|
103
102
|
|
@@ -110,25 +109,38 @@ module Spoom
|
|
110
109
|
File.write(file, snapshot.to_json)
|
111
110
|
say(" Snapshot data saved under `#{file}`\n\n")
|
112
111
|
end
|
113
|
-
|
112
|
+
context.git_checkout!(ref: ref_before)
|
114
113
|
end
|
115
114
|
|
116
115
|
desc "report", "Produce a typing coverage report"
|
117
116
|
option :data, type: :string, default: DATA_DIR, desc: "Snapshots JSON data"
|
118
|
-
option :file,
|
117
|
+
option :file,
|
118
|
+
type: :string,
|
119
|
+
default: "spoom_report.html",
|
120
|
+
aliases: :f,
|
119
121
|
desc: "Save report to file"
|
120
|
-
option :color_ignore,
|
122
|
+
option :color_ignore,
|
123
|
+
type: :string,
|
124
|
+
default: Spoom::Coverage::D3::COLOR_IGNORE,
|
121
125
|
desc: "Color used for typed: ignore"
|
122
|
-
option :color_false,
|
126
|
+
option :color_false,
|
127
|
+
type: :string,
|
128
|
+
default: Spoom::Coverage::D3::COLOR_FALSE,
|
123
129
|
desc: "Color used for typed: false"
|
124
|
-
option :color_true,
|
130
|
+
option :color_true,
|
131
|
+
type: :string,
|
132
|
+
default: Spoom::Coverage::D3::COLOR_TRUE,
|
125
133
|
desc: "Color used for typed: true"
|
126
|
-
option :color_strict,
|
134
|
+
option :color_strict,
|
135
|
+
type: :string,
|
136
|
+
default: Spoom::Coverage::D3::COLOR_STRICT,
|
127
137
|
desc: "Color used for typed: strict"
|
128
|
-
option :color_strong,
|
138
|
+
option :color_strong,
|
139
|
+
type: :string,
|
140
|
+
default: Spoom::Coverage::D3::COLOR_STRONG,
|
129
141
|
desc: "Color used for typed: strong"
|
130
142
|
def report
|
131
|
-
|
143
|
+
context = context_requiring_sorbet!
|
132
144
|
|
133
145
|
data_dir = options[:data]
|
134
146
|
files = Dir.glob("#{data_dir}/*.json")
|
@@ -150,7 +162,7 @@ module Spoom
|
|
150
162
|
strong: options[:color_strong],
|
151
163
|
)
|
152
164
|
|
153
|
-
report = Spoom::Coverage.report(snapshots, palette: palette
|
165
|
+
report = Spoom::Coverage.report(context, snapshots, palette: palette)
|
154
166
|
file = options[:file]
|
155
167
|
File.write(file, report.html)
|
156
168
|
say("Report generated under `#{file}`")
|
data/lib/spoom/cli/helper.rb
CHANGED
@@ -46,25 +46,25 @@ module Spoom
|
|
46
46
|
$stderr.flush
|
47
47
|
end
|
48
48
|
|
49
|
-
#
|
50
|
-
sig { returns(
|
51
|
-
def
|
52
|
-
|
49
|
+
# Returns the context at `--path` (by default the current working directory)
|
50
|
+
sig { returns(Context) }
|
51
|
+
def context
|
52
|
+
@context ||= T.let(Context.new(exec_path), T.nilable(Context))
|
53
53
|
end
|
54
54
|
|
55
|
-
#
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
unless in_sorbet_project?
|
55
|
+
# Raise if `spoom` is not ran inside a context with a `sorbet/config` file
|
56
|
+
sig { returns(Context) }
|
57
|
+
def context_requiring_sorbet!
|
58
|
+
context = self.context
|
59
|
+
unless context.has_sorbet_config?
|
61
60
|
say_error(
|
62
|
-
"not in a Sorbet project (`#{
|
61
|
+
"not in a Sorbet project (`#{Spoom::Sorbet::CONFIG_PATH}` not found)\n\n" \
|
63
62
|
"When running spoom from another path than the project's root, " \
|
64
63
|
"use `--path PATH` to specify the path to the root.",
|
65
64
|
)
|
66
65
|
Kernel.exit(1)
|
67
66
|
end
|
67
|
+
context
|
68
68
|
end
|
69
69
|
|
70
70
|
# Return the path specified through `--path`
|
@@ -73,30 +73,6 @@ module Spoom
|
|
73
73
|
options[:path]
|
74
74
|
end
|
75
75
|
|
76
|
-
sig { returns(String) }
|
77
|
-
def sorbet_config_file
|
78
|
-
Pathname.new("#{exec_path}/#{Spoom::Sorbet::CONFIG_PATH}").cleanpath.to_s
|
79
|
-
end
|
80
|
-
|
81
|
-
sig { returns(Sorbet::Config) }
|
82
|
-
def sorbet_config
|
83
|
-
Sorbet::Config.parse_file(sorbet_config_file)
|
84
|
-
end
|
85
|
-
|
86
|
-
sig { params(exit_code: Integer, block: T.nilable(T.proc.void)).void }
|
87
|
-
def check_sorbet_segfault(exit_code, &block)
|
88
|
-
return unless exit_code == Spoom::Sorbet::SEGFAULT_CODE
|
89
|
-
|
90
|
-
say_error(<<~ERR, status: nil)
|
91
|
-
#{red("!!! Sorbet exited with code #{exit_code} - SEGFAULT !!!")}
|
92
|
-
|
93
|
-
This is most likely related to a bug in Sorbet.
|
94
|
-
ERR
|
95
|
-
|
96
|
-
block&.call
|
97
|
-
exit(exit_code)
|
98
|
-
end
|
99
|
-
|
100
76
|
# Colors
|
101
77
|
|
102
78
|
# Color used to highlight expressions in backticks
|
data/lib/spoom/cli/lsp.rb
CHANGED
@@ -14,7 +14,8 @@ module Spoom
|
|
14
14
|
|
15
15
|
desc "interactive", "Interactive LSP mode"
|
16
16
|
def show
|
17
|
-
|
17
|
+
context_requiring_sorbet!
|
18
|
+
|
18
19
|
lsp = lsp_client
|
19
20
|
# TODO: run interactive mode
|
20
21
|
puts lsp
|
@@ -111,7 +112,8 @@ module Spoom
|
|
111
112
|
|
112
113
|
no_commands do
|
113
114
|
def lsp_client
|
114
|
-
|
115
|
+
context_requiring_sorbet!
|
116
|
+
|
115
117
|
path = exec_path
|
116
118
|
client = Spoom::LSP::Client.new(
|
117
119
|
Spoom::Sorbet::BIN_PATH,
|
data/lib/spoom/cli/run.rb
CHANGED
@@ -24,9 +24,7 @@ module Spoom
|
|
24
24
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
25
25
|
option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
|
26
26
|
def tc(*paths_to_select)
|
27
|
-
|
28
|
-
|
29
|
-
path = exec_path
|
27
|
+
context = context_requiring_sorbet!
|
30
28
|
limit = options[:limit]
|
31
29
|
sort = options[:sort]
|
32
30
|
code = options[:code]
|
@@ -36,29 +34,24 @@ module Spoom
|
|
36
34
|
sorbet = options[:sorbet]
|
37
35
|
|
38
36
|
unless limit || code || sort
|
39
|
-
result = T.unsafe(
|
37
|
+
result = T.unsafe(context).srb_tc(
|
40
38
|
*options[:sorbet_options].split(" "),
|
41
|
-
path: path,
|
42
39
|
capture_err: false,
|
43
40
|
sorbet_bin: sorbet,
|
44
41
|
)
|
45
42
|
|
46
|
-
check_sorbet_segfault(result.code)
|
47
43
|
say_error(result.err, status: nil, nl: false)
|
48
44
|
exit(result.status)
|
49
45
|
end
|
50
46
|
|
51
47
|
error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE
|
52
|
-
result = T.unsafe(
|
48
|
+
result = T.unsafe(context).srb_tc(
|
53
49
|
*options[:sorbet_options].split(" "),
|
54
50
|
"--error-url-base=#{error_url_base}",
|
55
|
-
path: path,
|
56
51
|
capture_err: true,
|
57
52
|
sorbet_bin: sorbet,
|
58
53
|
)
|
59
54
|
|
60
|
-
check_sorbet_segfault(result.exit_code)
|
61
|
-
|
62
55
|
if result.status
|
63
56
|
say_error(result.err, status: nil, nl: false)
|
64
57
|
exit(0)
|
@@ -109,6 +102,20 @@ module Spoom
|
|
109
102
|
end
|
110
103
|
|
111
104
|
exit(1)
|
105
|
+
rescue Spoom::Sorbet::Error::Segfault => error
|
106
|
+
say_error(<<~ERR, status: nil)
|
107
|
+
#{red("!!! Sorbet exited with code #{error.result.exit_code} - SEGFAULT !!!")}
|
108
|
+
|
109
|
+
This is most likely related to a bug in Sorbet.
|
110
|
+
ERR
|
111
|
+
|
112
|
+
exit(error.result.exit_code)
|
113
|
+
rescue Spoom::Sorbet::Error::Killed => error
|
114
|
+
say_error(<<~ERR, status: nil)
|
115
|
+
#{red("!!! Sorbet exited with code #{error.result.exit_code} - KILLED !!!")}
|
116
|
+
ERR
|
117
|
+
|
118
|
+
exit(error.result.exit_code)
|
112
119
|
end
|
113
120
|
|
114
121
|
no_commands do
|
data/lib/spoom/cli.rb
CHANGED
@@ -41,23 +41,20 @@ module Spoom
|
|
41
41
|
option :tree, type: :boolean, default: true, desc: "Display list as an indented tree"
|
42
42
|
option :rbi, type: :boolean, default: true, desc: "Show RBI files"
|
43
43
|
def files
|
44
|
-
|
45
|
-
|
46
|
-
path = exec_path
|
47
|
-
config = sorbet_config
|
48
|
-
files = Spoom::Sorbet.srb_files(config, path: path)
|
44
|
+
context = context_requiring_sorbet!
|
45
|
+
files = context.srb_files
|
49
46
|
|
50
47
|
unless options[:rbi]
|
51
48
|
files = files.reject { |file| file.end_with?(".rbi") }
|
52
49
|
end
|
53
50
|
|
54
51
|
if files.empty?
|
55
|
-
say_error("No file matching `#{
|
52
|
+
say_error("No file matching `#{Sorbet::CONFIG_PATH}`")
|
56
53
|
exit(1)
|
57
54
|
end
|
58
55
|
|
59
56
|
if options[:tree]
|
60
|
-
tree = FileTree.new(files, strip_prefix:
|
57
|
+
tree = FileTree.new(files, strip_prefix: exec_path)
|
61
58
|
tree.print(colors: options[:color], indent_level: 0)
|
62
59
|
else
|
63
60
|
puts files
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
class Context
|
6
|
+
# Bundle features for a context
|
7
|
+
module Bundle
|
8
|
+
extend T::Sig
|
9
|
+
extend T::Helpers
|
10
|
+
|
11
|
+
requires_ancestor { Context }
|
12
|
+
|
13
|
+
# Read the `contents` of the Gemfile in this context directory
|
14
|
+
sig { returns(T.nilable(String)) }
|
15
|
+
def read_gemfile
|
16
|
+
read("Gemfile")
|
17
|
+
end
|
18
|
+
|
19
|
+
# Set the `contents` of the Gemfile in this context directory
|
20
|
+
sig { params(contents: String, append: T::Boolean).void }
|
21
|
+
def write_gemfile!(contents, append: false)
|
22
|
+
write!("Gemfile", contents, append: append)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Run a command with `bundle` in this context directory
|
26
|
+
sig { params(command: String, version: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
|
27
|
+
def bundle(command, version: nil, capture_err: true)
|
28
|
+
command = "_#{version}_ #{command}" if version
|
29
|
+
exec("bundle #{command}", capture_err: capture_err)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Run `bundle install` in this context directory
|
33
|
+
sig { params(version: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
|
34
|
+
def bundle_install!(version: nil, capture_err: true)
|
35
|
+
bundle("install", version: version, capture_err: capture_err)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Run a command `bundle exec` in this context directory
|
39
|
+
sig { params(command: String, version: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
|
40
|
+
def bundle_exec(command, version: nil, capture_err: true)
|
41
|
+
bundle("exec #{command}", version: version, capture_err: capture_err)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get `gem` version from the `Gemfile.lock` content
|
45
|
+
#
|
46
|
+
# Returns `nil` if `gem` cannot be found in the Gemfile.
|
47
|
+
sig { params(gem: String).returns(T.nilable(String)) }
|
48
|
+
def gem_version_from_gemfile_lock(gem)
|
49
|
+
return nil unless file?("Gemfile.lock")
|
50
|
+
|
51
|
+
content = read("Gemfile.lock").match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
|
52
|
+
return nil unless content
|
53
|
+
|
54
|
+
content[1]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
class ExecResult < T::Struct
|
6
|
+
extend T::Sig
|
7
|
+
|
8
|
+
const :out, String
|
9
|
+
const :err, T.nilable(String)
|
10
|
+
const :status, T::Boolean
|
11
|
+
const :exit_code, Integer
|
12
|
+
|
13
|
+
sig { returns(String) }
|
14
|
+
def to_s
|
15
|
+
<<~STR
|
16
|
+
########## STDOUT ##########
|
17
|
+
#{out.empty? ? "<empty>" : out}
|
18
|
+
########## STDERR ##########
|
19
|
+
#{err&.empty? ? "<empty>" : err}
|
20
|
+
########## STATUS: #{status} ##########
|
21
|
+
STR
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Context
|
26
|
+
# Execution features for a context
|
27
|
+
module Exec
|
28
|
+
extend T::Sig
|
29
|
+
extend T::Helpers
|
30
|
+
|
31
|
+
requires_ancestor { Context }
|
32
|
+
|
33
|
+
# Run a command in this context directory
|
34
|
+
sig { params(command: String, capture_err: T::Boolean).returns(ExecResult) }
|
35
|
+
def exec(command, capture_err: true)
|
36
|
+
Bundler.with_unbundled_env do
|
37
|
+
opts = T.let({ chdir: absolute_path }, T::Hash[Symbol, T.untyped])
|
38
|
+
|
39
|
+
if capture_err
|
40
|
+
out, err, status = Open3.capture3(command, opts)
|
41
|
+
ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
|
42
|
+
else
|
43
|
+
out, status = Open3.capture2(command, opts)
|
44
|
+
ExecResult.new(out: out, err: nil, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
class Context
|
6
|
+
# File System features for a context
|
7
|
+
module FileSystem
|
8
|
+
extend T::Sig
|
9
|
+
extend T::Helpers
|
10
|
+
|
11
|
+
requires_ancestor { Context }
|
12
|
+
|
13
|
+
# Returns the absolute path to `relative_path` in the context's directory
|
14
|
+
sig { params(relative_path: String).returns(String) }
|
15
|
+
def absolute_path_to(relative_path)
|
16
|
+
File.join(absolute_path, relative_path)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Does the context directory at `absolute_path` exist and is a directory?
|
20
|
+
sig { returns(T::Boolean) }
|
21
|
+
def exist?
|
22
|
+
File.directory?(absolute_path)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Create the context directory at `absolute_path`
|
26
|
+
sig { void }
|
27
|
+
def mkdir!
|
28
|
+
FileUtils.rm_rf(absolute_path)
|
29
|
+
FileUtils.mkdir_p(absolute_path)
|
30
|
+
end
|
31
|
+
|
32
|
+
# List all files in this context matching `pattern`
|
33
|
+
sig { params(pattern: String).returns(T::Array[String]) }
|
34
|
+
def glob(pattern = "**/*")
|
35
|
+
Dir.glob(absolute_path_to(pattern)).map do |path|
|
36
|
+
Pathname.new(path).relative_path_from(absolute_path).to_s
|
37
|
+
end.sort
|
38
|
+
end
|
39
|
+
|
40
|
+
# List all files at the top level of this context directory
|
41
|
+
sig { returns(T::Array[String]) }
|
42
|
+
def list
|
43
|
+
glob("*")
|
44
|
+
end
|
45
|
+
|
46
|
+
# Does `relative_path` point to an existing file in this context directory?
|
47
|
+
sig { params(relative_path: String).returns(T::Boolean) }
|
48
|
+
def file?(relative_path)
|
49
|
+
File.file?(absolute_path_to(relative_path))
|
50
|
+
end
|
51
|
+
|
52
|
+
# Return the contents of the file at `relative_path` in this context directory
|
53
|
+
#
|
54
|
+
# Will raise if the file doesn't exist.
|
55
|
+
sig { params(relative_path: String).returns(String) }
|
56
|
+
def read(relative_path)
|
57
|
+
File.read(absolute_path_to(relative_path))
|
58
|
+
end
|
59
|
+
|
60
|
+
# Write `contents` in the file at `relative_path` in this context directory
|
61
|
+
#
|
62
|
+
# Append to the file if `append` is true.
|
63
|
+
sig { params(relative_path: String, contents: String, append: T::Boolean).void }
|
64
|
+
def write!(relative_path, contents = "", append: false)
|
65
|
+
absolute_path = absolute_path_to(relative_path)
|
66
|
+
FileUtils.mkdir_p(File.dirname(absolute_path))
|
67
|
+
File.write(absolute_path, contents, mode: append ? "a" : "w")
|
68
|
+
end
|
69
|
+
|
70
|
+
# Remove the path at `relative_path` (recursive + force) in this context directory
|
71
|
+
sig { params(relative_path: String).void }
|
72
|
+
def remove!(relative_path)
|
73
|
+
FileUtils.rm_rf(absolute_path_to(relative_path))
|
74
|
+
end
|
75
|
+
|
76
|
+
# Move the file or directory from `from_relative_path` to `to_relative_path`
|
77
|
+
sig { params(from_relative_path: String, to_relative_path: String).void }
|
78
|
+
def move!(from_relative_path, to_relative_path)
|
79
|
+
destination_path = absolute_path_to(to_relative_path)
|
80
|
+
FileUtils.mkdir_p(File.dirname(destination_path))
|
81
|
+
FileUtils.mv(absolute_path_to(from_relative_path), destination_path)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Delete this context and its content
|
85
|
+
#
|
86
|
+
# Warning: it will `rm -rf` the context directory on the file system.
|
87
|
+
sig { void }
|
88
|
+
def destroy!
|
89
|
+
FileUtils.rm_rf(absolute_path)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|