spoom 1.1.15 → 1.2.0
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.
- 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
|