toys-core 0.19.1 → 0.20.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: 815f546de5a0da65099cf1fdce70527fb42aafd3793bf8a99e35f63d77c4e66b
4
- data.tar.gz: 79bd650fb1bdc5d04a2b1e1d235864e39313b4320518b9ac6296a8e1892aa8b1
3
+ metadata.gz: f391c9aad35948c882c03809a80079b592c59208547080ae68bf2e0052896f9c
4
+ data.tar.gz: 4aacba23fe5a5a1560b4f38c6a109e5af7b6c98b9777b0c1ce3906bd6e42810c
5
5
  SHA512:
6
- metadata.gz: e696b9c3921aec0c56796bdd7b93a09d056f6d46347a5936bbf6a0ce9bc7b2811f90d4b9553c54bdf69b62d74289e4608da98473e9b62e34f155accbf665f7a1
7
- data.tar.gz: 675e01b73df268ceda5cfd5bd62f4a21b164c2fce0b37e88f3397bc12f76ba8baa4b2969df5d82901ffbb78440dd1156374cc6642792d81a4ec9c149a800148a
6
+ metadata.gz: c8a4e6ec594002577f1e81b937c1fdd23aed6ffe9adb2535073eba4d2eabe1c0e9e1e0062ea135062e7169734f65f20d0c9a3138dceca4b514ad86a67c215016
7
+ data.tar.gz: c00f4cf11e0af3807cdc0559275ab79f3af4f1fe1eff83cedcda125eedabc04f7ec439d95fa65b7f3763ca86b10e30fd6a5cf8d6412f02cd2e8dae9170d9e7fd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Release History
2
2
 
3
+ ### v0.20.0 / 2026-03-09
4
+
5
+ Toys-core 0.20 is a major release with several new features and a number of fixes, including a few minor breaking changes.
6
+
7
+ Major changes:
8
+
9
+ * NEW: Native tab completion for zsh.
10
+ * NEW: The `:bundler` mixin supports "manual" bundle setup, allowing bundler decisions to be deferred to execution time
11
+
12
+ Updates to the Exec mixin and library:
13
+
14
+ * NEW: The new `Toys::Utils::Exec::Result#effective_result` method provides a reasonable integer result code even when a process terminates via signal or fails to start at all.
15
+ * BREAKING API CHANGE: `:cli` is no longer a legal config option.
16
+ * BREAKING API CHANGE: An options hash is no longer passed to the proc when executing a proc using a fork.
17
+ * BREAKING API CHANGE: Passing an IO object as an input or output stream no longer closes it afterward.
18
+ * BREAKING API CHANGE: `Toys::Utils::Exec::Controller#result` no longer preemptively (and prematurely) closes the controller input stream
19
+ * FIXED: The `:unsetenv_others` option now works properly when executing a proc using a fork.
20
+ * FIXED: Environment variable values specified as nil are now correctly unset when executing a proc using a fork.
21
+ * FIXED: Fixed a rare concurrency issue if multiple threads concurrently get the result from a controller.
22
+
23
+ Minor fixes for TruffleRuby compatibility:
24
+
25
+ * FIXED: The `:bundler` mixin will not attempt to add the `pathname` gem to generated Gemfiles when running on TruffleRuby. This caused issues because TruffleRuby includes a special version of the gem and cannot install the one from Rubygems.
26
+ * FIXED: `ContextualError` no longer overrides `Exception#cause`, which could confuse TruffleRuby.
27
+
3
28
  ### v0.19.1 / 2026-01-06
4
29
 
5
30
  * DOCS: Some formatting fixes in the user guide
data/README.md CHANGED
@@ -265,8 +265,8 @@ Navigate to the simple-gem example:
265
265
 
266
266
  $ cd toys-core/examples/simple-gem
267
267
 
268
- This example wraps the simple "greet" executable that we
269
- [covered earlier](#Add_some_functionality) in a gem. You can see the
268
+ This example wraps the simple "greet" executable that we covered earlier, in a
269
+ gem. You can see the
270
270
  [executable file](https://github.com/dazuma/toys/tree/main/toys-core/examples/simple-gem/bin/toys-core-simple-example)
271
271
  in the bin directory.
272
272
 
@@ -348,7 +348,7 @@ recommended because it has a few known bugs that affect Toys.
348
348
 
349
349
  ## License
350
350
 
351
- Copyright 2019-2025 Daniel Azuma and the Toys contributors
351
+ Copyright 2019-2026 Daniel Azuma and the Toys contributors
352
352
 
353
353
  Permission is hereby granted, free of charge, to any person obtaining a copy
354
354
  of this software and associated documentation files (the "Software"), to deal
data/lib/toys/cli.rb CHANGED
@@ -552,7 +552,7 @@ module Toys
552
552
  #
553
553
  def default_error_handler
554
554
  proc do |error|
555
- cause = error.cause
555
+ cause = error.respond_to?(:underlying_error) ? error.underlying_error : error.cause
556
556
  raise cause.is_a?(::SignalException) ? cause : error
557
557
  end
558
558
  end
data/lib/toys/compat.rb CHANGED
@@ -13,35 +13,72 @@ module Toys
13
13
  parts = ::RUBY_VERSION.split(".")
14
14
  ruby_version = (parts[0].to_i * 10000) + (parts[1].to_i * 100) + parts[2].to_i
15
15
 
16
+ ##
16
17
  # @private
18
+ # An integer representation of the Ruby version, guaranteed to have the
19
+ # correct ordering. Currently, this is `major*10000 + minor*100 + patch`.
20
+ #
21
+ # @return [Integer]
22
+ #
17
23
  RUBY_VERSION_CODE = ruby_version
18
24
 
25
+ ##
19
26
  # @private
27
+ # Whether the current Ruby implementation is JRuby
28
+ #
29
+ # @return [boolean]
30
+ #
20
31
  def self.jruby?
21
32
  ::RUBY_ENGINE == "jruby"
22
33
  end
23
34
 
35
+ ##
24
36
  # @private
37
+ # Whether the current Ruby implementation is TruffleRuby
38
+ #
39
+ # @return [boolean]
40
+ #
25
41
  def self.truffleruby?
26
42
  ::RUBY_ENGINE == "truffleruby"
27
43
  end
28
44
 
45
+ ##
29
46
  # @private
47
+ # Whether we are running on Windows
48
+ #
49
+ # @return [boolean]
50
+ #
30
51
  def self.windows?
31
52
  ::RbConfig::CONFIG["host_os"] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/
32
53
  end
33
54
 
55
+ ##
34
56
  # @private
57
+ # Whether we are running on Mac OS
58
+ #
59
+ # @return [boolean]
60
+ #
35
61
  def self.macos?
36
62
  ::RbConfig::CONFIG["host_os"] =~ /darwin/
37
63
  end
38
64
 
65
+ ##
39
66
  # @private
67
+ # Whether fork is supported on the current Ruby and OS
68
+ #
69
+ # @return [boolean]
70
+ #
40
71
  def self.allow_fork?
41
72
  !jruby? && !truffleruby? && !windows?
42
73
  end
43
74
 
75
+ ##
44
76
  # @private
77
+ # Whether it is possible to get suggestions from DidYouMean. If this
78
+ # returns false, {Compat.suggestions} will always return the empty array.
79
+ #
80
+ # @return [boolean]
81
+ #
45
82
  def self.supports_suggestions?
46
83
  unless defined?(@supports_suggestions)
47
84
  begin
@@ -59,7 +96,16 @@ module Toys
59
96
  @supports_suggestions
60
97
  end
61
98
 
99
+ ##
62
100
  # @private
101
+ # A list of suggestions from DidYouMean.
102
+ #
103
+ # @param word [String] A value that seems wrong
104
+ # @param list [Array<String>] A list of valid values
105
+ #
106
+ # @return [Array<String>] A possibly empty array of suggestions from the
107
+ # valid list that could match the given word.
108
+ #
63
109
  def self.suggestions(word, list)
64
110
  if supports_suggestions?
65
111
  ::DidYouMean::SpellChecker.new(dictionary: list).correct(word)
@@ -67,5 +113,22 @@ module Toys
67
113
  []
68
114
  end
69
115
  end
116
+
117
+ ##
118
+ # @private
119
+ # A list of gems that should generally not be included in a bundle, usually
120
+ # because the Ruby implementation handles the library specially and cannot
121
+ # install the real gem. Currently, this includes the `pathname` gem for
122
+ # TruffleRuby, since TruffleRuby includes a special version of it.
123
+ #
124
+ # @return [Array<String>]
125
+ #
126
+ def self.gems_to_omit_from_bundles
127
+ if truffleruby?
128
+ ["pathname"]
129
+ else
130
+ []
131
+ end
132
+ end
70
133
  end
71
134
  end
data/lib/toys/core.rb CHANGED
@@ -9,7 +9,7 @@ module Toys
9
9
  # Current version of Toys core.
10
10
  # @return [String]
11
11
  #
12
- VERSION = "0.19.1"
12
+ VERSION = "0.20.0"
13
13
  end
14
14
 
15
15
  ##
data/lib/toys/errors.rb CHANGED
@@ -51,24 +51,25 @@ module Toys
51
51
  #
52
52
  # @private This interface is internal and subject to change without warning.
53
53
  #
54
- def initialize(cause, banner,
54
+ def initialize(underlying_error, banner,
55
55
  config_path: nil, config_line: nil,
56
56
  tool_name: nil, tool_args: nil)
57
- super("#{banner} : #{cause.message} (#{cause.class})")
58
- @cause = cause
57
+ super("#{banner} : #{underlying_error.message} (#{underlying_error.class})")
58
+ @underlying_error = underlying_error
59
59
  @banner = banner
60
60
  @config_path = config_path
61
61
  @config_line = config_line
62
62
  @tool_name = tool_name
63
63
  @tool_args = tool_args
64
- set_backtrace(cause.backtrace)
64
+ set_backtrace(underlying_error.backtrace)
65
65
  end
66
66
 
67
67
  ##
68
- # The underlying exception
68
+ # The underlying exception.
69
+ # Generally the same as `Exception#cause`.
69
70
  # @return [::StandardError]
70
71
  #
71
- attr_reader :cause
72
+ attr_reader :underlying_error
72
73
 
73
74
  ##
74
75
  # An overall banner message
@@ -141,10 +142,10 @@ module Toys
141
142
  end
142
143
  raise e
143
144
  rescue ::ScriptError, ::StandardError, ::SignalException => e
144
- e = ContextualError.new(e, banner)
145
- add_fields_if_missing(e, opts)
146
- add_config_path_if_missing(e, path)
147
- raise e
145
+ ce = ContextualError.new(e, banner)
146
+ add_fields_if_missing(ce, opts)
147
+ add_config_path_if_missing(ce, path)
148
+ raise ce
148
149
  end
149
150
 
150
151
  ##
@@ -172,7 +173,7 @@ module Toys
172
173
 
173
174
  def add_config_path_if_missing(error, path)
174
175
  if error.config_path.nil? && error.config_line.nil?
175
- l = (error.cause.backtrace_locations || []).find do |b|
176
+ l = (error.underlying_error.backtrace_locations || []).find do |b|
176
177
  b.absolute_path == path || b.path == path
177
178
  end
178
179
  if l
@@ -22,9 +22,21 @@ module Toys
22
22
  #
23
23
  # The following parameters can be passed when including this mixin:
24
24
  #
25
- # * `:static` (Boolean) If `true`, installs the bundle immediately, when
26
- # defining the tool. If `false` (the default), installs the bundle just
27
- # before the tool runs.
25
+ # * `:static` (Boolean) Has the same effect as passing `:static` to the
26
+ # `:setup` parameter.
27
+ #
28
+ # * `:setup` (:auto,:manual,:static) A symbol indicating when the bundle
29
+ # should be installed. Possible values are:
30
+ #
31
+ # * `:auto` - (Default) Installs the bundle just before the tool runs.
32
+ # * `:static` - Installs the bundle immediately when defining the
33
+ # tool.
34
+ # * `:manual` - Does not install the bundle, but defines the methods
35
+ # `bundler_setup` and `bundler_setup?` in the tool. The tool can
36
+ # call `bundler_setup` to install the bundle, optionally passing
37
+ # any of the remaining keyword arguments below to override the
38
+ # corresponding mixin parameters. The `bundler_setup?` method can
39
+ # be queried to determine whether the bundle has been set up yet.
28
40
  #
29
41
  # * `:groups` (Array\<String\>) The groups to include in setup.
30
42
  #
@@ -76,7 +88,9 @@ module Toys
76
88
  # (optional)
77
89
  #
78
90
  # * `:terminal` (Toys::Utils::Terminal) Terminal to use (optional)
91
+ #
79
92
  # * `:input` (IO) Input IO (optional, defaults to STDIN)
93
+ #
80
94
  # * `:output` (IO) Output IO (optional, defaults to STDOUT)
81
95
  #
82
96
  module Bundler
@@ -94,6 +108,14 @@ module Toys
94
108
  #
95
109
  DEFAULT_TOYS_GEMFILE_NAMES = [".gems.rb", "Gemfile"].freeze
96
110
 
111
+ ##
112
+ # @private
113
+ # Context key for the mixin parameters when using manual setup. The value
114
+ # will be a hash of parameters if the bundle has not been set up yet, or
115
+ # nil if the bundle has already been set up.
116
+ #
117
+ SETUP_PARAMS_KEY = Object.new.freeze
118
+
97
119
  ##
98
120
  # @private
99
121
  #
@@ -121,17 +143,43 @@ module Toys
121
143
  gems.bundle(groups: groups, gemfile_path: gemfile_path, retries: retries)
122
144
  end
123
145
 
124
- on_initialize do |static: false, **kwargs|
125
- unless static
146
+ on_initialize do |static: false, setup: nil, **kwargs|
147
+ setup ||= (static ? :static : :auto)
148
+ case setup
149
+ when :auto
126
150
  context_directory = self[::Toys::Context::Key::CONTEXT_DIRECTORY]
127
151
  source_info = self[::Toys::Context::Key::TOOL_SOURCE]
128
152
  ::Toys::StandardMixins::Bundler.setup_bundle(context_directory, source_info, **kwargs)
153
+ when :manual
154
+ self[::Toys::StandardMixins::Bundler::SETUP_PARAMS_KEY] = kwargs
129
155
  end
130
156
  end
131
157
 
132
- on_include do |static: false, **kwargs|
133
- if static
158
+ on_include do |static: false, setup: nil, **kwargs|
159
+ setup ||= (static ? :static : :auto)
160
+ case setup
161
+ when :static
134
162
  ::Toys::StandardMixins::Bundler.setup_bundle(context_directory, source_info, **kwargs)
163
+ when :manual
164
+ # @private
165
+ def bundler_setup(**kwargs)
166
+ original_kwargs = self[::Toys::StandardMixins::Bundler::SETUP_PARAMS_KEY]
167
+ raise ::Toys::Utils::Gems::AlreadyBundledError unless original_kwargs
168
+ context_directory = self[::Toys::Context::Key::CONTEXT_DIRECTORY]
169
+ source_info = self[::Toys::Context::Key::TOOL_SOURCE]
170
+ final_kwargs = original_kwargs.merge(kwargs)
171
+ ::Toys::StandardMixins::Bundler.setup_bundle(context_directory, source_info, **final_kwargs)
172
+ self[::Toys::StandardMixins::Bundler::SETUP_PARAMS_KEY] = nil
173
+ end
174
+
175
+ # @private
176
+ def bundler_setup?
177
+ self[::Toys::StandardMixins::Bundler::SETUP_PARAMS_KEY].nil?
178
+ end
179
+ when :auto
180
+ # Do nothing at this point
181
+ else
182
+ raise ::ArgumentError, "Unrecognized setup type: #{setup.inspect}"
135
183
  end
136
184
  end
137
185
 
@@ -165,7 +213,7 @@ module Toys
165
213
  when :toys
166
214
  search_toys
167
215
  else
168
- raise ::ArgumentError, "Unrecognized search_dir: #{dir.inspect}"
216
+ raise ::ArgumentError, "Unrecognized search_dir: #{search_dir.inspect}"
169
217
  end
170
218
  end
171
219
 
@@ -109,7 +109,8 @@ module Toys
109
109
  # an existing File stream. Unlike `Process#spawn`, this works for IO
110
110
  # objects that do not have a corresponding file descriptor (such as
111
111
  # StringIO objects). In such a case, a thread will be spawned to pipe
112
- # the IO data through to the child process.
112
+ # the IO data through to the child process. Note that the IO object
113
+ # will _not_ be closed on completion.
113
114
  #
114
115
  # * **Redirect to a pipe:** You can redirect to a pipe created using
115
116
  # `IO.pipe` (i.e. a two-element array of read and write IO objects) by
@@ -434,7 +435,7 @@ module Toys
434
435
  # the foreground.
435
436
  #
436
437
  def exec_tool(cmd, **opts, &block)
437
- func = Exec._make_tool_caller(cmd)
438
+ func = Exec._make_tool_caller(cmd, self[Context::Key::CLI])
438
439
  opts = Exec._setup_exec_opts(opts, self)
439
440
  opts = {log_cmd: "exec tool: #{cmd.inspect}"}.merge(opts)
440
441
  self[KEY].exec_proc(func, **opts, &block)
@@ -621,7 +622,7 @@ module Toys
621
622
  # @return [String] What was written to standard out.
622
623
  #
623
624
  def capture_tool(cmd, **opts, &block)
624
- func = Exec._make_tool_caller(cmd)
625
+ func = Exec._make_tool_caller(cmd, self[Context::Key::CLI])
625
626
  opts = Exec._setup_exec_opts(opts, self)
626
627
  self[KEY].capture_proc(func, **opts, &block)
627
628
  end
@@ -750,9 +751,9 @@ module Toys
750
751
  ##
751
752
  # @private
752
753
  #
753
- def self._make_tool_caller(cmd)
754
+ def self._make_tool_caller(cmd, cli)
754
755
  cmd = ::Shellwords.split(cmd) if cmd.is_a?(::String)
755
- proc { |config| ::Kernel.exit(config[:cli].run(*cmd)) }
756
+ proc { ::Kernel.exit(cli.run(*cmd)) }
756
757
  end
757
758
 
758
759
  ##
@@ -10,24 +10,27 @@ module Toys
10
10
  #
11
11
  module CompletionEngine
12
12
  ##
13
- # A completion engine for bash.
13
+ # Base class for shell completion engines that use a
14
+ # `COMP_LINE` / `COMP_POINT` protocol.
15
+ #
16
+ # Subclasses must implement the private methods `#shell_name` and
17
+ # `#output_completions`.
14
18
  #
15
- class Bash
19
+ class Base
16
20
  ##
17
- # Create a bash completion engine.
21
+ # Create a completion engine.
18
22
  #
19
23
  # @param cli [Toys::CLI] The CLI.
20
24
  #
21
25
  def initialize(cli)
22
- require "shellwords"
23
26
  @cli = cli
24
27
  end
25
28
 
26
29
  ##
27
30
  # Perform completion in the current shell environment, which must
28
31
  # include settings for the `COMP_LINE` and `COMP_POINT` environment
29
- # variables. Prints out completion candidates, one per line, and
30
- # returns a status code indicating the result.
32
+ # variables. Prints out completion candidates and returns a status code
33
+ # indicating the result.
31
34
  #
32
35
  # * **0** for success.
33
36
  # * **1** if completion failed.
@@ -42,9 +45,9 @@ module Toys
42
45
  point = ::ENV["COMP_POINT"].to_i
43
46
  point = line.length if point.negative?
44
47
  line = line[0, point]
45
- completions = run_internal(line)
46
- if completions
47
- completions.each { |completion| puts completion }
48
+ result = run_internal(line)
49
+ if result
50
+ output_completions(*result)
48
51
  0
49
52
  else
50
53
  1
@@ -53,6 +56,8 @@ module Toys
53
56
 
54
57
  ##
55
58
  # Internal completion method designed for testing.
59
+ # Returns `[quote_type, Array<Toys::Completion::Candidate>]`, or `nil`
60
+ # if the line cannot be parsed (e.g. the executable name is missing).
56
61
  #
57
62
  # @private
58
63
  #
@@ -68,39 +73,39 @@ module Toys
68
73
  end
69
74
  context = Completion::Context.new(
70
75
  cli: @cli, previous_words: words, fragment_prefix: prefix, fragment: last,
71
- params: {shell: :bash, quote_type: quote_type}
76
+ params: { shell: shell_name, quote_type: quote_type }
72
77
  )
73
- candidates = @cli.completion.call(context)
74
- candidates.uniq.sort.map do |candidate|
75
- CompletionEngine.format_candidate(candidate, quote_type)
76
- end
78
+ [quote_type, @cli.completion.call(context).uniq.sort]
79
+ end
80
+
81
+ private
82
+
83
+ def shell_name
84
+ raise ::NotImplementedError
85
+ end
86
+
87
+ def output_completions(_quote_type, _candidates)
88
+ raise ::NotImplementedError
77
89
  end
78
90
  end
79
91
 
80
- class << self
92
+ ##
93
+ # A completion engine for bash.
94
+ #
95
+ class Bash < Base
81
96
  ##
82
- # @private
97
+ # Create a bash completion engine.
83
98
  #
84
- def split(line)
85
- words = []
86
- field = ::String.new
87
- quote_type = nil
88
- line.scan(split_regex) do |word, sqw, dqw, esc, garbage, sep|
89
- raise ArgumentError, "Didn't expect garbage: #{line.inspect}" if garbage
90
- field << field_str(word, sqw, dqw, esc)
91
- quote_type = update_quote_type(quote_type, sqw, dqw)
92
- if sep
93
- words << [quote_type, field]
94
- quote_type = nil
95
- field = sep.empty? ? nil : ::String.new
96
- end
97
- end
98
- words << [quote_type, field] if field
99
- words
99
+ # @param cli [Toys::CLI] The CLI.
100
+ #
101
+ def initialize(cli)
102
+ require "shellwords"
103
+ super
100
104
  end
101
105
 
102
106
  ##
103
107
  # @private
108
+ # Accessible only for testing
104
109
  #
105
110
  def format_candidate(candidate, quote_type)
106
111
  str = candidate.to_s
@@ -120,6 +125,57 @@ module Toys
120
125
 
121
126
  private
122
127
 
128
+ def shell_name
129
+ :bash
130
+ end
131
+
132
+ def output_completions(quote_type, candidates)
133
+ candidates.each { |c| puts format_candidate(c, quote_type) }
134
+ end
135
+ end
136
+
137
+ ##
138
+ # A completion engine for zsh.
139
+ #
140
+ class Zsh < Base
141
+ private
142
+
143
+ def shell_name
144
+ :zsh
145
+ end
146
+
147
+ def output_completions(_quote_type, candidates)
148
+ finals, partials = candidates.partition(&:final?)
149
+ finals.each { |c| puts c.string unless c.string.empty? }
150
+ puts ""
151
+ partials.each { |c| puts c.string unless c.string.empty? }
152
+ end
153
+ end
154
+
155
+ class << self
156
+ ##
157
+ # @private
158
+ #
159
+ def split(line)
160
+ words = []
161
+ field = ::String.new
162
+ quote_type = nil
163
+ line.scan(split_regex) do |word, sqw, dqw, esc, garbage, sep|
164
+ raise ArgumentError, "Didn't expect garbage: #{line.inspect}" if garbage
165
+ field << field_str(word, sqw, dqw, esc)
166
+ quote_type = update_quote_type(quote_type, sqw, dqw)
167
+ if sep
168
+ words << [quote_type, field]
169
+ quote_type = nil
170
+ field = sep.empty? ? nil : ::String.new
171
+ end
172
+ end
173
+ words << [quote_type, field] if field
174
+ words
175
+ end
176
+
177
+ private
178
+
123
179
  def split_regex
124
180
  word_re = "([^\\s\\\\\\'\\\"]+)"
125
181
  sq_re = "'([^\\']*)(?:'|\\z)"
@@ -109,7 +109,8 @@ module Toys
109
109
  # an existing File stream. Unlike `Process#spawn`, this works for IO
110
110
  # objects that do not have a corresponding file descriptor (such as
111
111
  # StringIO objects). In such a case, a thread will be spawned to pipe
112
- # the IO data through to the child process.
112
+ # the IO data through to the child process. Note that the IO object
113
+ # will _not_ be closed on completion.
113
114
  #
114
115
  # * **Redirect to a pipe:** You can redirect to a pipe created using
115
116
  # `IO.pipe` (i.e. a two-element array of read and write IO objects) by
@@ -159,7 +160,7 @@ module Toys
159
160
  #
160
161
  # A subprocess result is represented by a {Toys::Utils::Exec::Result}
161
162
  # object, which includes the exit code, the content of any captured output
162
- # streams, and any exeption raised when attempting to run the process.
163
+ # streams, and any exception raised when attempting to run the process.
163
164
  # When you run a process in the foreground, the method will return a result
164
165
  # object. When you run a process in the background, you can obtain the
165
166
  # result from the controller once the process completes.
@@ -192,7 +193,7 @@ module Toys
192
193
  # ### Configuration options
193
194
  #
194
195
  # A variety of options can be used to control subprocesses. These can be
195
- # provided to any method that starts a subprocess. Youc an also set
196
+ # provided to any method that starts a subprocess. You can also set
196
197
  # defaults by calling {Toys::Utils::Exec#configure_defaults}.
197
198
  #
198
199
  # Options that affect the behavior of subprocesses:
@@ -432,8 +433,8 @@ module Toys
432
433
  end
433
434
 
434
435
  ##
435
- # Execute the given string in a shell. Returns the exit code.
436
- # Cannot be run in the background.
436
+ # Execute the given string in a shell. Returns an effective exit code
437
+ # that is always an integer. Cannot be run in the background.
437
438
  #
438
439
  # If a block is provided, a {Toys::Utils::Exec::Controller} will be
439
440
  # yielded to it.
@@ -444,11 +445,12 @@ module Toys
444
445
  # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
445
446
  # for the subprocess streams.
446
447
  #
447
- # @return [Integer] The exit code
448
+ # @return [Integer] An effective exit code. See
449
+ # {Toys::Utils::Exec::Result#effective_code}.
448
450
  #
449
451
  def sh(cmd, **opts, &block)
450
452
  opts = opts.merge(background: false)
451
- exec(cmd, **opts, &block).exit_code || -1
453
+ exec(cmd, **opts, &block).effective_code
452
454
  end
453
455
 
454
456
  ##
@@ -523,7 +525,7 @@ module Toys
523
525
  stream = stream_for(which)
524
526
  @join_threads << ::Thread.new do
525
527
  data = stream.read
526
- @mutex.synchronize do
528
+ @captures_mutex.synchronize do
527
529
  @captures[which] = data
528
530
  end
529
531
  ensure
@@ -560,6 +562,10 @@ module Toys
560
562
  # provide the mode and permissions for the call to `File#open`. You can
561
563
  # also specify the value `:null` to indicate the null file.
562
564
  #
565
+ # If the stream is redirected to an IO-like object, it is _not_ closed
566
+ # when the process is completed. (If it is redirected to a file
567
+ # specified by path, the file is closed on completion.)
568
+ #
563
569
  # After calling this, do not interact directly with the stream.
564
570
  #
565
571
  # @param which [:in,:out,:err] Which stream to redirect
@@ -570,9 +576,11 @@ module Toys
570
576
  #
571
577
  def redirect(which, io, *io_args)
572
578
  io = ::File::NULL if io == :null
579
+ close_afterward = false
573
580
  if io.is_a?(::String)
574
581
  io_args = which == :in ? ["r"] : ["w"] if io_args.empty?
575
582
  io = ::File.open(io, *io_args)
583
+ close_afterward = true
576
584
  end
577
585
  stream = stream_for(which, allow_in: true)
578
586
  @join_threads << ::Thread.new do
@@ -583,7 +591,7 @@ module Toys
583
591
  end
584
592
  ensure
585
593
  stream.close
586
- io.close
594
+ io.close if close_afterward
587
595
  end
588
596
  self
589
597
  end
@@ -669,47 +677,48 @@ module Toys
669
677
  ##
670
678
  # Wait for the subcommand to complete, and return a result object.
671
679
  #
672
- # Closes the control streams if present. The stdin stream is always
673
- # closed, even if the call times out. The stdout and stderr streams are
674
- # closed only after the command terminates.
675
- #
676
680
  # @param timeout [Numeric,nil] The timeout in seconds, or `nil` to
677
681
  # wait indefinitely.
678
682
  # @return [Toys::Utils::Exec::Result] The result object
679
683
  # @return [nil] if a timeout occurred.
680
684
  #
681
685
  def result(timeout: nil)
682
- close_streams(:in)
683
686
  return nil if @wait_thread && !@wait_thread.join(timeout)
684
- @result ||= begin
685
- close_streams(:out)
686
- @join_threads.each(&:join)
687
- Result.new(name, @captures[:out], @captures[:err], @wait_thread&.value, @exception)
688
- .tap { |result| @result_callback&.call(result) }
687
+ should_run_callback = false
688
+ @result_mutex.synchronize do
689
+ @result ||= begin
690
+ should_run_callback = true
691
+ close_streams(:both)
692
+ @join_threads.each(&:join)
693
+ Result.new(name, @captures[:out], @captures[:err], @wait_thread&.value, @exception)
694
+ end
689
695
  end
696
+ @result_callback&.call(@result) if should_run_callback
697
+ @result
690
698
  end
691
699
 
692
700
  ##
693
701
  # @private
694
702
  #
695
- def initialize(name, controller_streams, captures, pid, join_threads,
696
- result_callback, mutex)
703
+ def initialize(name:, controller_streams:, captures:, pid_or_exception:,
704
+ join_threads:, result_callback:, captures_mutex:)
697
705
  @name = name
698
706
  @in = controller_streams[:in]
699
707
  @out = controller_streams[:out]
700
708
  @err = controller_streams[:err]
701
709
  @captures = captures
702
710
  @pid = @exception = @wait_thread = nil
703
- case pid
711
+ case pid_or_exception
704
712
  when ::Integer
705
- @pid = pid
706
- @wait_thread = ::Process.detach(pid)
713
+ @pid = pid_or_exception
714
+ @wait_thread = ::Process.detach(@pid)
707
715
  when ::Exception
708
- @exception = pid
716
+ @exception = pid_or_exception
709
717
  end
710
718
  @join_threads = join_threads
711
719
  @result_callback = result_callback
712
- @mutex = mutex
720
+ @captures_mutex = captures_mutex
721
+ @result_mutex = ::Mutex.new
713
722
  @result = nil
714
723
  end
715
724
 
@@ -797,7 +806,7 @@ module Toys
797
806
  # Exactly one of {#exception} and {#status} will be non-nil.
798
807
  #
799
808
  # @return [Process::Status] The status, if the process was successfully
800
- # spanwed and terminated.
809
+ # spawned and terminated.
801
810
  # @return [nil] if the process could not be started.
802
811
  #
803
812
  attr_reader :status
@@ -888,6 +897,42 @@ module Toys
888
897
  !code.nil? && !code.zero?
889
898
  end
890
899
 
900
+ ##
901
+ # Returns an "effective" exit code, which is always an integer if the
902
+ # process has terminated for any reason. In general, this code will be:
903
+ #
904
+ # * The same as {#exit_code} if the process terminated normally with an
905
+ # exit code,
906
+ # * The convention of `128+signalnum` if the process terminated due to
907
+ # a signal,
908
+ # * The convention of 126 if the process could not start due to lack of
909
+ # execution permissions,
910
+ # * The convention of 127 if the process could not start because the
911
+ # command was not recognized or could not be found, or
912
+ # * An undefined value between 1 and 255 for other failures.
913
+ #
914
+ # Note that the normal exit code and signal number cases are stable,
915
+ # but any other cases are subject to change on future releases.
916
+ #
917
+ # @return [Integer]
918
+ #
919
+ def effective_code
920
+ code = exit_code
921
+ return code unless code.nil?
922
+ code = signal_code
923
+ return code + 128 unless code.nil?
924
+ case exception
925
+ when ::Errno::ENOENT
926
+ 127
927
+ else
928
+ # This is the intended result for ENOEXEC/EACCES.
929
+ # For now, any other error (e.g. EBADARCH on MacOS) will also map
930
+ # to this result. We can change this in the future since the
931
+ # documentation explicitly allows it.
932
+ 126
933
+ end
934
+ end
935
+
891
936
  ##
892
937
  # @private
893
938
  #
@@ -916,7 +961,6 @@ module Toys
916
961
  CONFIG_KEYS = [
917
962
  :argv0,
918
963
  :background,
919
- :cli,
920
964
  :env,
921
965
  :err,
922
966
  :in,
@@ -1012,6 +1056,10 @@ module Toys
1012
1056
  #
1013
1057
  def initialize(exec_opts, spawn_cmd, block)
1014
1058
  @fork_func = spawn_cmd.respond_to?(:call) ? spawn_cmd : nil
1059
+ if @fork_func && !::Process.respond_to?(:fork)
1060
+ raise ::NotImplementedError,
1061
+ "Executing a proc is not available because fork is not supported on the current Ruby platform"
1062
+ end
1015
1063
  @spawn_cmd = spawn_cmd.respond_to?(:call) ? nil : spawn_cmd
1016
1064
  @config_opts = exec_opts.config_opts
1017
1065
  @spawn_opts = exec_opts.spawn_opts
@@ -1022,7 +1070,7 @@ module Toys
1022
1070
  @parent_streams = []
1023
1071
  @block = block
1024
1072
  @default_stream = @config_opts[:background] ? :null : :inherit
1025
- @mutex = ::Mutex.new
1073
+ @captures_mutex = ::Mutex.new
1026
1074
  end
1027
1075
 
1028
1076
  ##
@@ -1037,6 +1085,7 @@ module Toys
1037
1085
  return controller if @config_opts[:background]
1038
1086
  begin
1039
1087
  @block&.call(controller)
1088
+ controller.close_streams(:in)
1040
1089
  controller.result
1041
1090
  ensure
1042
1091
  controller.close_streams(:both)
@@ -1068,15 +1117,20 @@ module Toys
1068
1117
  end
1069
1118
 
1070
1119
  def start_with_controller
1071
- pid =
1120
+ pid_or_exception =
1072
1121
  begin
1073
1122
  @fork_func ? start_fork : start_process
1074
1123
  rescue ::StandardError => e
1075
1124
  e
1076
1125
  end
1077
1126
  @child_streams.each(&:close)
1078
- Controller.new(@config_opts[:name], @controller_streams, @captures, pid,
1079
- @join_threads, @config_opts[:result_callback], @mutex)
1127
+ Controller.new(name: @config_opts[:name],
1128
+ controller_streams: @controller_streams,
1129
+ captures: @captures,
1130
+ pid_or_exception: pid_or_exception,
1131
+ join_threads: @join_threads,
1132
+ result_callback: @config_opts[:result_callback],
1133
+ captures_mutex: @captures_mutex)
1080
1134
  end
1081
1135
 
1082
1136
  def start_process
@@ -1106,21 +1160,28 @@ module Toys
1106
1160
  def run_fork_func
1107
1161
  catch(:result) do
1108
1162
  if @spawn_opts[:chdir]
1109
- ::Dir.chdir(@spawn_opts[:chdir]) { @fork_func.call(@config_opts) }
1163
+ ::Dir.chdir(@spawn_opts[:chdir]) { @fork_func.call }
1110
1164
  else
1111
- @fork_func.call(@config_opts)
1165
+ @fork_func.call
1112
1166
  end
1113
1167
  0
1114
1168
  end
1115
1169
  end
1116
1170
 
1117
1171
  def setup_env_within_fork
1118
- if @config_opts[:unsetenv_others]
1172
+ env = @config_opts[:env] || {}
1173
+ if @spawn_opts[:unsetenv_others]
1119
1174
  ::ENV.each_key do |k|
1120
- ::ENV.delete(k) unless @config_opts.key?(k)
1175
+ ::ENV.delete(k) unless env.key?(k)
1176
+ end
1177
+ end
1178
+ env.each do |k, v|
1179
+ if v.nil?
1180
+ ::ENV.delete(k.to_s)
1181
+ else
1182
+ ::ENV[k.to_s] = v.to_s
1121
1183
  end
1122
1184
  end
1123
- (@config_opts[:env] || {}).each { |k, v| ::ENV[k.to_s] = v.to_s }
1124
1185
  end
1125
1186
 
1126
1187
  def setup_streams_within_fork
@@ -1436,7 +1497,7 @@ module Toys
1436
1497
  when :close
1437
1498
  io.close rescue nil # rubocop:disable Style/RescueModifier
1438
1499
  when :capture
1439
- @mutex.synchronize do
1500
+ @captures_mutex.synchronize do
1440
1501
  @captures[key] = io.string
1441
1502
  end
1442
1503
  end
@@ -1507,7 +1568,6 @@ module Toys
1507
1568
  ::IO.copy_stream(io, stream)
1508
1569
  ensure
1509
1570
  stream.close
1510
- io.close
1511
1571
  end
1512
1572
  end
1513
1573
 
@@ -1517,7 +1577,6 @@ module Toys
1517
1577
  ::IO.copy_stream(stream, io)
1518
1578
  ensure
1519
1579
  stream.close
1520
- io.close
1521
1580
  end
1522
1581
  end
1523
1582
 
@@ -1525,7 +1584,7 @@ module Toys
1525
1584
  stream = make_out_pipe(key)
1526
1585
  @join_threads << ::Thread.new do
1527
1586
  data = stream.read
1528
- @mutex.synchronize do
1587
+ @captures_mutex.synchronize do
1529
1588
  @captures[key] = data
1530
1589
  end
1531
1590
  ensure
@@ -413,6 +413,8 @@ module Toys
413
413
  end
414
414
 
415
415
  loaded_gems = ::Gem.loaded_specs.values.sort_by(&:name)
416
+ omit_list = ::Toys::Compat.gems_to_omit_from_bundles
417
+ loaded_gems.delete_if { |spec| omit_list.include?(spec.name) } unless omit_list.empty?
416
418
  content << "toys_loaded_gems = #{loaded_gems.map(&:name).inspect}"
417
419
  content << "dependencies.delete_if { |dep| toys_loaded_gems.include?(dep.name) }"
418
420
  loaded_gems.each do |spec|
@@ -66,7 +66,11 @@ module Toys
66
66
  result = @exec_service.exec(@command, in: :controller) do |controller|
67
67
  yield controller.in if controller.pid
68
68
  rescue ::Errno::EPIPE => e
69
- raise e unless @rescue_broken_pipes
69
+ if @rescue_broken_pipes
70
+ return 1
71
+ else
72
+ raise e
73
+ end
70
74
  end
71
75
  return result.exit_code unless result.failed?
72
76
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toys-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Azuma
@@ -92,10 +92,10 @@ homepage: https://github.com/dazuma/toys
92
92
  licenses:
93
93
  - MIT
94
94
  metadata:
95
- changelog_uri: https://dazuma.github.io/toys/gems/toys-core/v0.19.1/file.CHANGELOG.html
96
- source_code_uri: https://github.com/dazuma/toys/tree/main/toys-core
95
+ changelog_uri: https://dazuma.github.io/toys/gems/toys-core/v0.20.0/file.CHANGELOG.html
96
+ source_code_uri: https://github.com/dazuma/toys/tree/toys-core/v0.20.0/toys-core
97
97
  bug_tracker_uri: https://github.com/dazuma/toys/issues
98
- documentation_uri: https://dazuma.github.io/toys/gems/toys-core/v0.19.1
98
+ documentation_uri: https://dazuma.github.io/toys/gems/toys-core/v0.20.0
99
99
  rdoc_options: []
100
100
  require_paths:
101
101
  - lib