toys-core 0.3.6 → 0.3.7

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/lib/toys-core.rb +20 -5
  4. data/lib/toys/cli.rb +39 -32
  5. data/lib/toys/core_version.rb +1 -1
  6. data/lib/toys/{tool → definition}/acceptor.rb +21 -15
  7. data/lib/toys/{utils/line_output.rb → definition/alias.rb} +47 -59
  8. data/lib/toys/{tool/arg_definition.rb → definition/arg.rb} +17 -7
  9. data/lib/toys/{tool/flag_definition.rb → definition/flag.rb} +19 -9
  10. data/lib/toys/definition/tool.rb +574 -0
  11. data/lib/toys/dsl/arg.rb +118 -0
  12. data/lib/toys/dsl/flag.rb +132 -0
  13. data/lib/toys/dsl/tool.rb +521 -0
  14. data/lib/toys/errors.rb +2 -2
  15. data/lib/toys/helpers.rb +3 -3
  16. data/lib/toys/helpers/exec.rb +31 -25
  17. data/lib/toys/helpers/fileutils.rb +8 -2
  18. data/lib/toys/helpers/highline.rb +8 -1
  19. data/lib/toys/{alias.rb → helpers/terminal.rb} +44 -53
  20. data/lib/toys/input_file.rb +61 -0
  21. data/lib/toys/loader.rb +87 -77
  22. data/lib/toys/middleware.rb +3 -3
  23. data/lib/toys/middleware/add_verbosity_flags.rb +22 -20
  24. data/lib/toys/middleware/base.rb +53 -5
  25. data/lib/toys/middleware/handle_usage_errors.rb +9 -12
  26. data/lib/toys/middleware/set_default_descriptions.rb +6 -7
  27. data/lib/toys/middleware/show_help.rb +71 -67
  28. data/lib/toys/middleware/show_root_version.rb +9 -9
  29. data/lib/toys/runner.rb +157 -0
  30. data/lib/toys/template.rb +4 -3
  31. data/lib/toys/templates.rb +2 -2
  32. data/lib/toys/templates/clean.rb +2 -2
  33. data/lib/toys/templates/gem_build.rb +5 -5
  34. data/lib/toys/templates/minitest.rb +2 -2
  35. data/lib/toys/templates/rubocop.rb +2 -2
  36. data/lib/toys/templates/yardoc.rb +2 -2
  37. data/lib/toys/tool.rb +168 -625
  38. data/lib/toys/utils/exec.rb +19 -18
  39. data/lib/toys/utils/gems.rb +140 -0
  40. data/lib/toys/utils/help_text.rb +25 -20
  41. data/lib/toys/utils/terminal.rb +412 -0
  42. data/lib/toys/utils/wrappable_string.rb +3 -1
  43. metadata +15 -24
  44. data/lib/toys/config_dsl.rb +0 -699
  45. data/lib/toys/context.rb +0 -290
  46. data/lib/toys/helpers/spinner.rb +0 -142
@@ -72,13 +72,13 @@ module Toys
72
72
  # stream of the subprocess. If set to `:controller`, the controller
73
73
  # will control the output stream. If set to `:capture`, the output will
74
74
  # be captured in a string that is available in the
75
- # {Toys::Helpers::Exec::Result} object. If not set, the subprocess
75
+ # {Toys::Utils::Exec::Result} object. If not set, the subprocess
76
76
  # standard out is connected to STDOUT of the Toys process.
77
77
  # * **:err_to** (`:controller`,`:capture`) Connects the standard error
78
78
  # stream of the subprocess. If set to `:controller`, the controller
79
79
  # will control the output stream. If set to `:capture`, the output will
80
80
  # be captured in a string that is available in the
81
- # {Toys::Helpers::Exec::Result} object. If not set, the subprocess
81
+ # {Toys::Utils::Exec::Result} object. If not set, the subprocess
82
82
  # standard out is connected to STDERR of the Toys process.
83
83
  #
84
84
  # In addition, the following options recognized by `Process#spawn` are
@@ -134,8 +134,18 @@ module Toys
134
134
  # exit code and any captured output.
135
135
  #
136
136
  def exec(cmd, opts = {}, &block)
137
+ spawn_cmd =
138
+ if cmd.is_a?(::Array)
139
+ if cmd.size == 1 && cmd.first.is_a?(::String)
140
+ [[cmd.first, opts[:argv0] || cmd.first]]
141
+ else
142
+ cmd
143
+ end
144
+ else
145
+ [cmd]
146
+ end
137
147
  exec_opts = Opts.new(@default_opts).add(opts)
138
- executor = Executor.new(exec_opts, cmd)
148
+ executor = Executor.new(exec_opts, spawn_cmd)
139
149
  executor.execute(&block)
140
150
  end
141
151
 
@@ -155,13 +165,8 @@ module Toys
155
165
  # exit code and any captured output.
156
166
  #
157
167
  def ruby(args, opts = {}, &block)
158
- cmd =
159
- if args.is_a?(::Array)
160
- [[::RbConfig.ruby, "ruby"]] + args
161
- else
162
- "#{::RbConfig.ruby} #{args}"
163
- end
164
- exec(cmd, opts, &block)
168
+ cmd = args.is_a?(::Array) ? [::RbConfig.ruby] + args : "#{::RbConfig.ruby} #{args}"
169
+ exec(cmd, {argv0: "ruby"}.merge(opts), &block)
165
170
  end
166
171
 
167
172
  ##
@@ -170,8 +175,6 @@ module Toys
170
175
  # @param [String] cmd The shell command to execute.
171
176
  # @param [Hash] opts The command options. See the section on
172
177
  # configuration options in the {Toys::Utils::Exec} module docs.
173
- # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
174
- # for the subprocess streams.
175
178
  #
176
179
  # @return [Integer] The exit code
177
180
  #
@@ -188,8 +191,6 @@ module Toys
188
191
  # @param [String,Array<String>] cmd The command to execute.
189
192
  # @param [Hash] opts The command options. See the section on
190
193
  # configuration options in the {Toys::Utils::Exec} module docs.
191
- # @yieldparam controller [Toys::Utils::Exec::Controller] A controller
192
- # for the subprocess streams.
193
194
  #
194
195
  # @return [String] What was written to standard out.
195
196
  #
@@ -384,8 +385,8 @@ module Toys
384
385
  # @private
385
386
  #
386
387
  class Executor
387
- def initialize(exec_opts, cmd)
388
- @cmd = Array(cmd)
388
+ def initialize(exec_opts, spawn_cmd)
389
+ @spawn_cmd = spawn_cmd
389
390
  @config_opts = exec_opts.config_opts
390
391
  @spawn_opts = exec_opts.spawn_opts
391
392
  @captures = {}
@@ -409,7 +410,7 @@ module Toys
409
410
  def log_command
410
411
  logger = @config_opts[:logger]
411
412
  if logger && @config_opts[:log_level] != false
412
- cmd_str = @cmd.size == 1 ? @cmd.first : @cmd.inspect
413
+ cmd_str = @spawn_cmd.size == 1 ? @spawn_cmd.first : @spawn_cmd.inspect
413
414
  logger.add(@config_opts[:log_level] || ::Logger::INFO, cmd_str)
414
415
  end
415
416
  end
@@ -417,7 +418,7 @@ module Toys
417
418
  def start_process
418
419
  args = []
419
420
  args << @config_opts[:env] if @config_opts[:env]
420
- args.concat(@cmd)
421
+ args.concat(@spawn_cmd)
421
422
  pid = ::Process.spawn(*args, @spawn_opts)
422
423
  @child_streams.each(&:close)
423
424
  ::Process.detach(pid)
@@ -0,0 +1,140 @@
1
+ # Copyright 2018 Daniel Azuma
2
+ #
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ # * Neither the name of the copyright holder, nor the names of any other
14
+ # contributors to this software, may be used to endorse or promote products
15
+ # derived from this software without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
+ # POSSIBILITY OF SUCH DAMAGE.
28
+ ;
29
+
30
+ module Toys
31
+ module Utils
32
+ ##
33
+ # A helper module that activates and installs gems
34
+ #
35
+ class Gems
36
+ ##
37
+ # Failed to activate a gem.
38
+ #
39
+ class ActivationFailedError < ::StandardError
40
+ end
41
+
42
+ ##
43
+ # Failed to install a gem.
44
+ #
45
+ class InstallFailedError < ActivationFailedError
46
+ end
47
+
48
+ ##
49
+ # Need to add a gem to the bundle.
50
+ #
51
+ class GemfileUpdateNeededError < ActivationFailedError
52
+ def initialize(requirements_text, gemfile_path)
53
+ super("Required gem not available in the bundle: #{requirements_text}.\n" \
54
+ "Please update your Gemfile #{gemfile_path.inspect}.")
55
+ end
56
+ end
57
+
58
+ ##
59
+ # Activate the given gem.
60
+ #
61
+ # @param [String] name Name of the gem
62
+ # @param [String...] requirements Version requirements
63
+ #
64
+ def self.activate(name, *requirements)
65
+ new.activate(name, *requirements)
66
+ end
67
+
68
+ ##
69
+ # Create a new gem activator.
70
+ #
71
+ def initialize
72
+ @terminal = Terminal.new(output: $stderr)
73
+ @exec = Exec.new
74
+ end
75
+
76
+ ##
77
+ # Activate the given gem.
78
+ #
79
+ # @param [String] name Name of the gem
80
+ # @param [String...] requirements Version requirements
81
+ #
82
+ def activate(name, *requirements)
83
+ gem(name, *requirements)
84
+ rescue ::Gem::MissingSpecError
85
+ install_gem(name, requirements)
86
+ rescue ::Gem::LoadError => e
87
+ if ::ENV["BUNDLE_GEMFILE"]
88
+ raise GemfileUpdateNeededError.new(gem_requirements_text(name, requirements),
89
+ ::ENV["BUNDLE_GEMFILE"])
90
+ end
91
+ raise ActivationFailedError, e.message
92
+ end
93
+
94
+ private
95
+
96
+ def gem_requirements_text(name, requirements)
97
+ "#{name.inspect}, #{requirements.map(&:inspect).join(', ')}"
98
+ end
99
+
100
+ def install_gem(name, requirements)
101
+ requirements_text = gem_requirements_text(name, requirements)
102
+ response = @terminal.confirm("Gem needed: #{requirements_text}. Install?")
103
+ unless response
104
+ raise InstallFailedError, "Canceled installation of needed gem: #{requirements_text}"
105
+ end
106
+ version = find_best_version(name, requirements)
107
+ raise InstallFailedError, "No gem found matching #{requirements_text}." unless version
108
+ perform_install(name, version)
109
+ activate(name, *requirements)
110
+ end
111
+
112
+ def find_best_version(name, requirements)
113
+ @terminal.spinner(leading_text: "Getting info on gem #{name.inspect}... ",
114
+ final_text: "Done.\n") do
115
+ req = ::Gem::Requirement.new(*requirements)
116
+ result = @exec.exec(["gem", "query", "-q", "-r", "-a", "-e", name], out_to: :capture)
117
+ if result.captured_out =~ /\(([\w\.,\s]+)\)/
118
+ $1.split(", ")
119
+ .map { |v| ::Gem::Version.new(v) }
120
+ .find { |v| !v.prerelease? && req.satisfied_by?(v) }
121
+ else
122
+ raise InstallFailedError, "Unable to determine existing versions of gem #{name.inspect}"
123
+ end
124
+ end
125
+ end
126
+
127
+ def perform_install(name, version)
128
+ @terminal.spinner(leading_text: "Installing gem #{name} #{version}... ",
129
+ final_text: "Done.\n") do
130
+ result = @exec.exec(["gem", "install", name, "--version", version.to_s],
131
+ out_to: :capture, err_to: :capture)
132
+ if result.error?
133
+ @terminal.puts(result.captured_out + result.captured_err)
134
+ raise InstallFailedError, "Failed to install gem #{name} #{version}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -27,7 +27,7 @@
27
27
  # POSSIBILITY OF SUCH DAMAGE.
28
28
  ;
29
29
 
30
- require "highline"
30
+ require "toys/utils/terminal"
31
31
 
32
32
  module Toys
33
33
  module Utils
@@ -52,13 +52,14 @@ module Toys
52
52
  DEFAULT_INDENT = 4
53
53
 
54
54
  ##
55
- # Create a usage helper given an execution context.
55
+ # Create a usage helper given an executable tool.
56
56
  #
57
- # @param [Toys::Context] context The current execution context.
57
+ # @param [Toys::Tool] tool The current tool.
58
58
  # @return [Toys::Utils::HelpText]
59
59
  #
60
- def self.from_context(context)
61
- new(context[Context::TOOL], context[Context::LOADER], context[Context::BINARY_NAME])
60
+ def self.from_tool(tool)
61
+ new(tool[Tool::Keys::TOOL_DEFINITION], tool[Tool::Keys::LOADER],
62
+ tool[Tool::Keys::BINARY_NAME])
62
63
  end
63
64
 
64
65
  ##
@@ -133,7 +134,7 @@ module Toys
133
134
  def find_subtools(recursive, search)
134
135
  subtools = @loader.list_subtools(@tool.full_name, recursive: recursive)
135
136
  return subtools if search.nil? || search.empty?
136
- regex = Regexp.new(search, Regexp::IGNORECASE)
137
+ regex = ::Regexp.new(search, ::Regexp::IGNORECASE)
137
138
  subtools.find_all do |tool|
138
139
  regex =~ tool.display_name || regex =~ tool.desc.to_s
139
140
  end
@@ -161,16 +162,16 @@ module Toys
161
162
  def assemble
162
163
  add_synopsis_section
163
164
  add_flags_section
164
- add_positional_arguments_section if @tool.includes_script?
165
+ add_positional_arguments_section if @tool.runnable?
165
166
  add_subtool_list_section
166
167
  @result = @lines.join("\n") + "\n"
167
168
  end
168
169
 
169
170
  def add_synopsis_section
170
171
  synopses = []
171
- synopses << namespace_synopsis if !@subtools.empty? && !@tool.includes_script?
172
+ synopses << namespace_synopsis if !@subtools.empty? && !@tool.runnable?
172
173
  synopses << tool_synopsis
173
- synopses << namespace_synopsis if !@subtools.empty? && @tool.includes_script?
174
+ synopses << namespace_synopsis if !@subtools.empty? && @tool.runnable?
174
175
  first = true
175
176
  synopses.each do |synopsis|
176
177
  @lines << (first ? "Usage: #{synopsis}" : " #{synopsis}")
@@ -228,7 +229,7 @@ module Toys
228
229
  @subtools.each do |subtool|
229
230
  tool_name = subtool.full_name.slice(name_len..-1).join(" ")
230
231
  desc =
231
- if subtool.is_a?(Alias)
232
+ if subtool.is_a?(Definition::Alias)
232
233
  ["(Alias of #{subtool.display_target})"]
233
234
  else
234
235
  wrap_desc(subtool.desc)
@@ -283,8 +284,7 @@ module Toys
283
284
  @indent = indent
284
285
  @indent2 = indent2
285
286
  @wrap_width = wrap_width
286
- @styled = styled
287
- @lines = []
287
+ @lines = Utils::Terminal.new(output: ::StringIO.new, styled: styled)
288
288
  assemble
289
289
  end
290
290
 
@@ -300,7 +300,7 @@ module Toys
300
300
  add_positional_arguments_section
301
301
  add_subtool_list_section
302
302
  add_source_section
303
- @result = @lines.join("\n") + "\n"
303
+ @result = @lines.output.string
304
304
  end
305
305
 
306
306
  def add_name_section
@@ -326,11 +326,11 @@ module Toys
326
326
  def add_synopsis_section
327
327
  @lines << ""
328
328
  @lines << bold("SYNOPSIS")
329
- if !@subtools.empty? && !@tool.includes_script?
329
+ if !@subtools.empty? && !@tool.runnable?
330
330
  add_synopsis_clause(namespace_synopsis)
331
331
  end
332
332
  add_synopsis_clause(tool_synopsis)
333
- if !@subtools.empty? && @tool.includes_script?
333
+ if !@subtools.empty? && @tool.runnable?
334
334
  add_synopsis_clause(namespace_synopsis)
335
335
  end
336
336
  end
@@ -364,10 +364,10 @@ module Toys
364
364
  end
365
365
 
366
366
  def add_source_section
367
- return unless @tool.definition_path && @show_source_path
367
+ return unless @tool.source_path && @show_source_path
368
368
  @lines << ""
369
369
  @lines << bold("SOURCE")
370
- @lines << indent_str("Defined in #{@tool.definition_path}")
370
+ @lines << indent_str("Defined in #{@tool.source_path}")
371
371
  end
372
372
 
373
373
  def add_description_section
@@ -427,7 +427,12 @@ module Toys
427
427
  name_len = @tool.full_name.length
428
428
  @subtools.each do |subtool|
429
429
  tool_name = subtool.full_name.slice(name_len..-1).join(" ")
430
- desc = subtool.is_a?(Alias) ? "(Alias of #{subtool.display_target})" : subtool.desc
430
+ desc =
431
+ if subtool.is_a?(Definition::Alias)
432
+ "(Alias of #{subtool.display_target})"
433
+ else
434
+ subtool.desc
435
+ end
431
436
  add_prefix_with_desc(bold(tool_name), desc)
432
437
  end
433
438
  end
@@ -473,11 +478,11 @@ module Toys
473
478
  end
474
479
 
475
480
  def bold(str)
476
- @styled ? ::HighLine.color(str, :bold) : str
481
+ @lines.apply_styles(str, :bold)
477
482
  end
478
483
 
479
484
  def underline(str)
480
- @styled ? ::HighLine.color(str, :underline) : str
485
+ @lines.apply_styles(str, :underline)
481
486
  end
482
487
 
483
488
  def indent_str(str)
@@ -0,0 +1,412 @@
1
+ # Copyright 2018 Daniel Azuma
2
+ #
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ # * Neither the name of the copyright holder, nor the names of any other
14
+ # contributors to this software, may be used to endorse or promote products
15
+ # derived from this software without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
21
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
+ # POSSIBILITY OF SUCH DAMAGE.
28
+ ;
29
+
30
+ require "stringio"
31
+ require "monitor"
32
+
33
+ begin
34
+ require "io/console"
35
+ rescue ::LoadError # rubocop:disable Lint/HandleExceptions
36
+ # TODO: use stty to get terminal size
37
+ end
38
+
39
+ module Toys
40
+ module Utils
41
+ ##
42
+ # A simple terminal class.
43
+ #
44
+ # ## Styles
45
+ #
46
+ # This class supports ANSI styled output where supported.
47
+ #
48
+ # Styles may be specified in any of the following forms:
49
+ # * A symbol indicating the name of a well-known style, or the name of
50
+ # a defined style.
51
+ # * An rgb string in hex "rgb" or "rrggbb" form.
52
+ # * An ANSI code string in `\e[XXm` form.
53
+ # * An array of ANSI codes as integers.
54
+ #
55
+ class Terminal
56
+ ## ANSI style code to clear styles
57
+ CLEAR_CODE = "\e[0m".freeze
58
+
59
+ ## Standard ANSI style codes
60
+ BUILTIN_STYLE_NAMES = {
61
+ clear: [0],
62
+ reset: [0],
63
+ bold: [1],
64
+ faint: [2],
65
+ italic: [3],
66
+ underline: [4],
67
+ blink: [5],
68
+ reverse: [7],
69
+ black: [30],
70
+ red: [31],
71
+ green: [32],
72
+ yellow: [33],
73
+ blue: [34],
74
+ magenta: [35],
75
+ cyan: [36],
76
+ white: [37],
77
+ on_black: [30],
78
+ on_red: [31],
79
+ on_green: [32],
80
+ on_yellow: [33],
81
+ on_blue: [34],
82
+ on_magenta: [35],
83
+ on_cyan: [36],
84
+ on_white: [37],
85
+ bright_black: [90],
86
+ bright_red: [91],
87
+ bright_green: [92],
88
+ bright_yellow: [93],
89
+ bright_blue: [94],
90
+ bright_magenta: [95],
91
+ bright_cyan: [96],
92
+ bright_white: [97],
93
+ on_bright_black: [100],
94
+ on_bright_red: [101],
95
+ on_bright_green: [102],
96
+ on_bright_yellow: [103],
97
+ on_bright_blue: [104],
98
+ on_bright_magenta: [105],
99
+ on_bright_cyan: [106],
100
+ on_bright_white: [107]
101
+ }.freeze
102
+
103
+ ##
104
+ # Default length of a single spinner frame, in seconds.
105
+ # @return [Float]
106
+ #
107
+ DEFAULT_SPINNER_FRAME_LENGTH = 0.1
108
+
109
+ ##
110
+ # Default set of spinner frames.
111
+ # @return [Array<String>]
112
+ #
113
+ DEFAULT_SPINNER_FRAMES = ["-", "\\", "|", "/"].freeze
114
+
115
+ ##
116
+ # Returns a copy of the given string with all ANSI style codes removed.
117
+ #
118
+ # @param [String] str Input string
119
+ # @return [String] String with styles removed
120
+ #
121
+ def self.remove_style_escapes(str)
122
+ str.gsub(/\e\[\d+(;\d+)*m/, "")
123
+ end
124
+
125
+ ##
126
+ # Create a terminal.
127
+ #
128
+ # @param [IO,Logger,nil] output Output stream or logger.
129
+ # @param [IO,nil] input Input stream.
130
+ #
131
+ def initialize(input: $stdin,
132
+ output: $stdout,
133
+ styled: nil)
134
+ @input = input
135
+ @output = output
136
+ @styled =
137
+ if styled.nil?
138
+ output.respond_to?(:tty?) && output.tty?
139
+ else
140
+ styled ? true : false
141
+ end
142
+ @named_styles = BUILTIN_STYLE_NAMES.dup
143
+ end
144
+
145
+ ##
146
+ # Output stream or logger
147
+ # @return [IO,Logger,nil]
148
+ #
149
+ attr_reader :output
150
+
151
+ ##
152
+ # Input stream
153
+ # @return [IO,nil]
154
+ #
155
+ attr_reader :input
156
+
157
+ ##
158
+ # Whether output is styled
159
+ # @return [Boolean]
160
+ #
161
+ attr_accessor :styled
162
+
163
+ ##
164
+ # Write a partial line without appending a newline.
165
+ #
166
+ # @param [String] str The line to write
167
+ # @param [Symbol,String,Array<Integer>...] styles Styles to apply to the
168
+ # partial line.
169
+ #
170
+ def write(str = "", *styles)
171
+ output.write(apply_styles(str, *styles))
172
+ output.flush
173
+ self
174
+ end
175
+
176
+ ##
177
+ # Write a line, appending a newline if one is not already present.
178
+ #
179
+ # @param [String] str The line to write
180
+ # @param [Symbol,String,Array<Integer>...] styles Styles to apply to the
181
+ # entire line.
182
+ #
183
+ def puts(str = "", *styles)
184
+ str = "#{str}\n" unless str.end_with?("\n")
185
+ write(str, *styles)
186
+ end
187
+
188
+ ##
189
+ # Write a line, appending a newline if one is not already present.
190
+ #
191
+ # @param [String] str The line to write
192
+ #
193
+ def <<(str)
194
+ puts(str)
195
+ end
196
+
197
+ ##
198
+ # Write a newline and flush the current line.
199
+ #
200
+ def newline
201
+ puts
202
+ end
203
+
204
+ ##
205
+ # Confirm with the user.
206
+ #
207
+ # @param [String] prompt Prompt string. Defaults to `"Proceed?"`.
208
+ # @return [Boolean]
209
+ #
210
+ def confirm(prompt = "Proceed?")
211
+ write("#{prompt} (y/n) ")
212
+ resp = input.gets
213
+ if resp =~ /^y/i
214
+ true
215
+ elsif resp =~ /^n/i
216
+ false
217
+ else
218
+ confirm("Please answer \"y\" or \"n\"")
219
+ end
220
+ end
221
+
222
+ ##
223
+ # Display a spinner during a task. You should provide a block that
224
+ # performs the long-running task. While the block is executing, a
225
+ # spinner will be displayed.
226
+ #
227
+ # @param [String] leading_text Optional leading string to display to the
228
+ # left of the spinner. Default is the empty string.
229
+ # @param [Float] frame_length Length of a single frame, in seconds.
230
+ # Defaults to {DEFAULT_SPINNER_FRAME_LENGTH}.
231
+ # @param [Array<String>] frames An array of frames. Defaults to
232
+ # {DEFAULT_SPINNER_FRAMES}.
233
+ # @param [Symbol,Array<Symbol>] style A terminal style or array of styles
234
+ # to apply to all frames in the spinner. Defaults to empty,
235
+ # @param [String] final_text Optional final string to display when the
236
+ # spinner is complete. Default is the empty string. A common practice
237
+ # is to set this to newline.
238
+ #
239
+ def spinner(leading_text: "", final_text: "",
240
+ frame_length: nil, frames: nil, style: nil)
241
+ return nil unless block_given?
242
+ frame_length ||= DEFAULT_SPINNER_FRAME_LENGTH
243
+ frames ||= DEFAULT_SPINNER_FRAMES
244
+ output.write(leading_text) unless leading_text.empty?
245
+ spin = SpinDriver.new(self, frames, Array(style), frame_length)
246
+ begin
247
+ yield
248
+ ensure
249
+ spin.stop
250
+ output.write(final_text) unless final_text.empty?
251
+ end
252
+ end
253
+
254
+ ##
255
+ # Return the terminal size as an array of width, height.
256
+ #
257
+ # @return [Array(Integer,Integer)]
258
+ #
259
+ def size
260
+ if @output.respond_to?(:tty?) && @output.tty? && @output.respond_to?(:winsize)
261
+ @output.winsize.reverse
262
+ else
263
+ [80, 25]
264
+ end
265
+ end
266
+
267
+ ##
268
+ # Return the terminal width
269
+ #
270
+ # @return [Integer]
271
+ #
272
+ def width
273
+ size[0]
274
+ end
275
+
276
+ ##
277
+ # Return the terminal height
278
+ #
279
+ # @return [Integer]
280
+ #
281
+ def height
282
+ size[1]
283
+ end
284
+
285
+ ##
286
+ # Define a named style.
287
+ #
288
+ # Style names must be symbols.
289
+ # The definition of a style may include any valid style specification,
290
+ # including the symbol names of existing defined styles.
291
+ #
292
+ # @param [Symbol] name The name for the style
293
+ # @param [Symbol,String,Array<Integer>...] styles
294
+ #
295
+ def define_style(name, *styles)
296
+ @named_styles[name] = resolve_styles(*styles)
297
+ self
298
+ end
299
+
300
+ ##
301
+ # Apply the given styles to the given string, returning the styled
302
+ # string. Honors the styled setting; if styling is disabled, does not
303
+ # add any ANSI style codes and in fact removes any existing codes. If
304
+ # styles were added, ensures that the string ends with a clear code.
305
+ #
306
+ # @param [String] str String to style
307
+ # @param [Symbol,String,Array<Integer>...] styles Styles to apply
308
+ # @return [String] The styled string
309
+ #
310
+ def apply_styles(str, *styles)
311
+ if styled
312
+ prefix = escape_styles(*styles)
313
+ suffix = prefix.empty? || str.end_with?(CLEAR_CODE) ? "" : CLEAR_CODE
314
+ "#{prefix}#{str}#{suffix}"
315
+ else
316
+ Terminal.remove_style_escapes(str)
317
+ end
318
+ end
319
+
320
+ private
321
+
322
+ ##
323
+ # Resolve a style to an ANSI style escape sequence.
324
+ #
325
+ def escape_styles(*styles)
326
+ codes = resolve_styles(*styles)
327
+ codes.empty? ? "" : "\e[#{codes.join(';')}m"
328
+ end
329
+
330
+ ##
331
+ # Resolve a style to an array of ANSI style codes (integers).
332
+ #
333
+ def resolve_styles(*styles)
334
+ result = []
335
+ styles.each do |style|
336
+ codes =
337
+ case style
338
+ when ::Array
339
+ style
340
+ when ::String
341
+ interpret_style_string(style)
342
+ when ::Symbol
343
+ @named_styles[style]
344
+ end
345
+ raise ::ArgumentError, "Unknown style code: #{s.inspect}" unless codes
346
+ result.concat(codes)
347
+ end
348
+ result
349
+ end
350
+
351
+ ##
352
+ # Transform various style string formats into a list of style codes.
353
+ #
354
+ def interpret_style_string(style)
355
+ case style
356
+ when /^[0-9a-fA-F]{6}$/
357
+ rgb = style.to_i(16)
358
+ [38, 2, rgb >> 16, (rgb & 0xff00) >> 8, rgb & 0xff]
359
+ when /^[0-9a-fA-F]{3}$/
360
+ rgb = style.to_i(16)
361
+ [38, 2, (rgb >> 8) * 0x11, ((rgb & 0xf0) >> 4) * 0x11, (rgb & 0xf) * 0x11]
362
+ when /^\e\[([\d;]+)m$/
363
+ $1.split(";").map(&:to_i)
364
+ end
365
+ end
366
+
367
+ ## @private
368
+ class SpinDriver
369
+ include ::MonitorMixin
370
+
371
+ def initialize(terminal, frames, style, frame_length)
372
+ @terminal = terminal
373
+ @frames = frames.map do |f|
374
+ [@terminal.apply_styles(f, *style), Terminal.remove_style_escapes(f).size]
375
+ end
376
+ @frame_length = frame_length
377
+ @cur_frame = 0
378
+ @stopping = false
379
+ @cond = new_cond
380
+ super()
381
+ @thread = @terminal.output.tty? ? start_thread : nil
382
+ end
383
+
384
+ def stop
385
+ synchronize do
386
+ @stopping = true
387
+ @cond.broadcast
388
+ end
389
+ @thread.join if @thread
390
+ self
391
+ end
392
+
393
+ private
394
+
395
+ def start_thread
396
+ ::Thread.new do
397
+ synchronize do
398
+ until @stopping
399
+ @terminal.output.write(@frames[@cur_frame][0])
400
+ @cond.wait(@frame_length)
401
+ size = @frames[@cur_frame][1]
402
+ @terminal.output.write("\b" * size + " " * size + "\b" * size)
403
+ @cur_frame += 1
404
+ @cur_frame = 0 if @cur_frame >= @frames.size
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end