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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a61ae83fa56b7a78027c8954c5680d4784c5a3f8b2d717ed3ba49b36409ede2
4
- data.tar.gz: b339a398e57f92f71b797dd8d427e18749d1afd6246be42fe549baf96563706e
3
+ metadata.gz: 323ebe199d0efed57d5e92af12eb377c3c5af35e468c83db851b27f86b44117b
4
+ data.tar.gz: c0fb24e2af1af3c0e460bb7c06ba9f3cff9cda471ec829ba00742b40dd67ab84
5
5
  SHA512:
6
- metadata.gz: 981c7b625e31b732ce731c600fd1291afb9635818918d77424750ba152633f06e8f4515037c5044badcc951ded1dd9fd768ae135ff2305359005a2cb7ad47a38
7
- data.tar.gz: 123baadb32b3341271b323eab29a1ddb4561c74b9afbe6cfa71af61ff1547783a24a1d439406f482b3c40920b960e4b04fc19ca7c0423c18407af00bc35fa692
6
+ metadata.gz: faa5f86d378dd4cf56aa6e680cdee5717c59069b464b823bebfc63e7c8a7a40aeb7a75d86ec4dd0c69e025c4ad397eff740fdfa2ac134f49868736197836790a
7
+ data.tar.gz: 67f7d4bc72282ff4bea575e85c2eed80f7195f94d3b518e3a84af57085bed90c757aedab5cfe33fb6d14dc96b62f21c6e8aa8cb56b79d4e9b148318a7c713e98
data/Gemfile CHANGED
@@ -6,7 +6,7 @@ source "https://rubygems.org"
6
6
  gemspec
7
7
 
8
8
  group :development do
9
- gem "pry-byebug"
9
+ gem "debug"
10
10
  gem "ruby-lsp"
11
11
  gem "rubocop-shopify", require: false
12
12
  gem "rubocop-sorbet", require: false
@@ -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, type: :string, default: Spoom::Sorbet::Sigils::STRICTNESS_FALSE,
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, type: :string, default: Spoom::Sorbet::Sigils::STRICTNESS_TRUE,
20
+ option :to,
21
+ type: :string,
22
+ default: Spoom::Sorbet::Sigils::STRICTNESS_TRUE,
19
23
  desc: "Change files to this strictness"
20
- option :force, type: :boolean, default: false, aliases: :f,
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, type: :boolean, default: false, aliases: :d,
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, type: :string, default: nil, aliases: :o,
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, type: :string,
40
+ option :suggest_bump_command,
41
+ type: :string,
28
42
  desc: "Command to suggest if files can be bumped"
29
- option :count_errors, type: :boolean, default: false,
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
- in_sorbet_project!
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 = config_files(path: exec_path)
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 = Sorbet.srb_tc(
89
- *options[:sorbet_options].split(" "),
90
- "--error-url-base=#{error_url_base}",
91
- path: exec_path,
92
- capture_err: true,
93
- sorbet_bin: options[:sorbet],
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
@@ -13,10 +13,11 @@ module Spoom
13
13
 
14
14
  desc "show", "Show Sorbet config"
15
15
  def show
16
- in_sorbet_project!
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 `#{sorbet_config_file}`.")
20
+ say("Found Sorbet config at `#{config_path}`.")
20
21
 
21
22
  say("\nPaths typechecked:")
22
23
  if config.paths.empty?
@@ -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
- in_sorbet_project!
22
- path = exec_path
21
+ context = context_requiring_sorbet!
23
22
  sorbet = options[:sorbet]
24
23
 
25
- snapshot = Spoom::Coverage.snapshot(path: path, rbi: options[:rbi], sorbet_bin: sorbet)
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
- in_sorbet_project!
43
+ context = context_requiring_sorbet!
45
44
  path = exec_path
46
45
  sorbet = options[:sorbet]
47
46
 
48
- ref_before = Spoom::Git.current_branch
49
- ref_before = Spoom::Git.last_commit(path: path)&.sha unless 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 Spoom::Git.workdir_clean?(path: path)
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 = Spoom::Git.sorbet_intro_commit(path: path)
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, path: path)
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
- Spoom::Git.checkout(commit.sha, path: path)
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(path: path, sorbet_bin: sorbet)
96
+ snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
98
97
  end
99
98
  else
100
- snapshot = Spoom::Coverage.snapshot(path: path, sorbet_bin: sorbet)
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
- Spoom::Git.checkout(ref_before, path: path)
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, type: :string, default: "spoom_report.html", aliases: :f,
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, type: :string, default: Spoom::Coverage::D3::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, type: :string, default: Spoom::Coverage::D3::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, type: :string, default: Spoom::Coverage::D3::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, type: :string, default: Spoom::Coverage::D3::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, type: :string, default: Spoom::Coverage::D3::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
- in_sorbet_project!
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, path: exec_path)
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}`")
@@ -46,25 +46,25 @@ module Spoom
46
46
  $stderr.flush
47
47
  end
48
48
 
49
- # Is `spoom` ran inside a project with a `sorbet/config` file?
50
- sig { returns(T::Boolean) }
51
- def in_sorbet_project?
52
- File.file?(sorbet_config_file)
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
- # Enforce that `spoom` is ran inside a project with a `sorbet/config` file
56
- #
57
- # Display an error message and exit otherwise.
58
- sig { void }
59
- def in_sorbet_project!
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 (`#{sorbet_config_file}` not found)\n\n" \
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
- in_sorbet_project!
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
- in_sorbet_project!
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
- in_sorbet_project!
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(Spoom::Sorbet).srb_tc(
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(Spoom::Sorbet).srb_tc(
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
- in_sorbet_project!
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 `#{sorbet_config_file}`")
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: path)
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