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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/toys-core.rb +20 -5
- data/lib/toys/cli.rb +39 -32
- data/lib/toys/core_version.rb +1 -1
- data/lib/toys/{tool → definition}/acceptor.rb +21 -15
- data/lib/toys/{utils/line_output.rb → definition/alias.rb} +47 -59
- data/lib/toys/{tool/arg_definition.rb → definition/arg.rb} +17 -7
- data/lib/toys/{tool/flag_definition.rb → definition/flag.rb} +19 -9
- data/lib/toys/definition/tool.rb +574 -0
- data/lib/toys/dsl/arg.rb +118 -0
- data/lib/toys/dsl/flag.rb +132 -0
- data/lib/toys/dsl/tool.rb +521 -0
- data/lib/toys/errors.rb +2 -2
- data/lib/toys/helpers.rb +3 -3
- data/lib/toys/helpers/exec.rb +31 -25
- data/lib/toys/helpers/fileutils.rb +8 -2
- data/lib/toys/helpers/highline.rb +8 -1
- data/lib/toys/{alias.rb → helpers/terminal.rb} +44 -53
- data/lib/toys/input_file.rb +61 -0
- data/lib/toys/loader.rb +87 -77
- data/lib/toys/middleware.rb +3 -3
- data/lib/toys/middleware/add_verbosity_flags.rb +22 -20
- data/lib/toys/middleware/base.rb +53 -5
- data/lib/toys/middleware/handle_usage_errors.rb +9 -12
- data/lib/toys/middleware/set_default_descriptions.rb +6 -7
- data/lib/toys/middleware/show_help.rb +71 -67
- data/lib/toys/middleware/show_root_version.rb +9 -9
- data/lib/toys/runner.rb +157 -0
- data/lib/toys/template.rb +4 -3
- data/lib/toys/templates.rb +2 -2
- data/lib/toys/templates/clean.rb +2 -2
- data/lib/toys/templates/gem_build.rb +5 -5
- data/lib/toys/templates/minitest.rb +2 -2
- data/lib/toys/templates/rubocop.rb +2 -2
- data/lib/toys/templates/yardoc.rb +2 -2
- data/lib/toys/tool.rb +168 -625
- data/lib/toys/utils/exec.rb +19 -18
- data/lib/toys/utils/gems.rb +140 -0
- data/lib/toys/utils/help_text.rb +25 -20
- data/lib/toys/utils/terminal.rb +412 -0
- data/lib/toys/utils/wrappable_string.rb +3 -1
- metadata +15 -24
- data/lib/toys/config_dsl.rb +0 -699
- data/lib/toys/context.rb +0 -290
- data/lib/toys/helpers/spinner.rb +0 -142
data/lib/toys/utils/exec.rb
CHANGED
@@ -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::
|
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::
|
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,
|
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
|
-
|
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,
|
388
|
-
@
|
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 = @
|
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(@
|
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
|
data/lib/toys/utils/help_text.rb
CHANGED
@@ -27,7 +27,7 @@
|
|
27
27
|
# POSSIBILITY OF SUCH DAMAGE.
|
28
28
|
;
|
29
29
|
|
30
|
-
require "
|
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
|
55
|
+
# Create a usage helper given an executable tool.
|
56
56
|
#
|
57
|
-
# @param [Toys::
|
57
|
+
# @param [Toys::Tool] tool The current tool.
|
58
58
|
# @return [Toys::Utils::HelpText]
|
59
59
|
#
|
60
|
-
def self.
|
61
|
-
new(
|
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.
|
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.
|
172
|
+
synopses << namespace_synopsis if !@subtools.empty? && !@tool.runnable?
|
172
173
|
synopses << tool_synopsis
|
173
|
-
synopses << namespace_synopsis if !@subtools.empty? && @tool.
|
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
|
-
@
|
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.
|
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.
|
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.
|
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.
|
367
|
+
return unless @tool.source_path && @show_source_path
|
368
368
|
@lines << ""
|
369
369
|
@lines << bold("SOURCE")
|
370
|
-
@lines << indent_str("Defined in #{@tool.
|
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 =
|
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
|
-
@
|
481
|
+
@lines.apply_styles(str, :bold)
|
477
482
|
end
|
478
483
|
|
479
484
|
def underline(str)
|
480
|
-
@
|
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
|