dry-cli-help 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/Rakefile +38 -0
- data/SPECIFICATION.md +200 -0
- data/lib/dry/cli/help/colors.rb +71 -0
- data/lib/dry/cli/help/configuration.rb +241 -0
- data/lib/dry/cli/help/formatter.rb +107 -0
- data/lib/dry/cli/help/integration.rb +84 -0
- data/lib/dry/cli/help/screens/base.rb +87 -0
- data/lib/dry/cli/help/screens/command.rb +126 -0
- data/lib/dry/cli/help/screens/listing.rb +100 -0
- data/lib/dry/cli/help/terminal.rb +39 -0
- data/lib/dry/cli/help/text.rb +66 -0
- data/lib/dry/cli/help/version.rb +15 -0
- data/lib/dry/cli/help.rb +60 -0
- data/lib/dry-cli-help.rb +3 -0
- data/sig/dry/cli/help.rbs +88 -0
- metadata +99 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module Help
|
|
6
|
+
# Help settings, readable and writable two ways:
|
|
7
|
+
#
|
|
8
|
+
# help do
|
|
9
|
+
# title "Taxlibris" # inside a registry's help block
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Dry::CLI::Help.configure do |config|
|
|
13
|
+
# config.title = "Taxlibris" # on the yielded object
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# An instance stores only what was set on it. Everything else reads from
|
|
17
|
+
# {DEFAULTS}, which is what lets a registry's settings sit over the
|
|
18
|
+
# process-wide ones through {#merge} without either copying the other.
|
|
19
|
+
class Configuration
|
|
20
|
+
# Every section, in default order.
|
|
21
|
+
SECTIONS = %i[
|
|
22
|
+
banner usage description commands subcommands arguments options examples epilogue
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# Default heading text. `banner` and `epilogue` print no heading.
|
|
26
|
+
HEADINGS = {
|
|
27
|
+
usage: "Usage",
|
|
28
|
+
description: "Description",
|
|
29
|
+
commands: "Commands",
|
|
30
|
+
subcommands: "Subcommands",
|
|
31
|
+
arguments: "Arguments",
|
|
32
|
+
options: "Options",
|
|
33
|
+
examples: "Examples"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Default styles of every element a screen paints.
|
|
37
|
+
STYLES = {
|
|
38
|
+
title: %i[bold],
|
|
39
|
+
heading: %i[bold yellow],
|
|
40
|
+
command: %i[green],
|
|
41
|
+
argument: %i[cyan],
|
|
42
|
+
option: %i[cyan],
|
|
43
|
+
comment: %i[bright_black]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
HEADING_CASES = %i[upcase capitalize none].freeze
|
|
47
|
+
COMMAND_ORDERS = %i[registration alphabetical].freeze
|
|
48
|
+
|
|
49
|
+
# Narrowest column text wraps at, however small the terminal.
|
|
50
|
+
MIN_WIDTH = 20
|
|
51
|
+
|
|
52
|
+
BOOLEAN = ->(value) { [true, false].include?(value) }
|
|
53
|
+
TEXT = ->(value) { value.nil? || value.is_a?(String) }
|
|
54
|
+
|
|
55
|
+
# Single-value settings and what each accepts.
|
|
56
|
+
SCALARS = {
|
|
57
|
+
title: TEXT,
|
|
58
|
+
description: TEXT,
|
|
59
|
+
epilogue: TEXT,
|
|
60
|
+
color: ->(value) { Colors::SETTINGS.include?(value) },
|
|
61
|
+
wrap: BOOLEAN,
|
|
62
|
+
width: ->(value) { value == :terminal || (value.is_a?(Integer) && value.positive?) },
|
|
63
|
+
margin: ->(value) { value.is_a?(Integer) && !value.negative? },
|
|
64
|
+
exit_code_without_arguments: ->(value) { value.is_a?(Integer) && value.between?(0, 255) },
|
|
65
|
+
banner_on_subcommands: BOOLEAN,
|
|
66
|
+
heading_case: ->(value) { HEADING_CASES.include?(value) },
|
|
67
|
+
command_order: ->(value) { COMMAND_ORDERS.include?(value) }
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
DEFAULTS = {
|
|
71
|
+
title: nil,
|
|
72
|
+
description: nil,
|
|
73
|
+
epilogue: nil,
|
|
74
|
+
color: :auto,
|
|
75
|
+
wrap: true,
|
|
76
|
+
width: :terminal,
|
|
77
|
+
margin: 0,
|
|
78
|
+
exit_code_without_arguments: 1,
|
|
79
|
+
banner_on_subcommands: false,
|
|
80
|
+
heading_case: :upcase,
|
|
81
|
+
command_order: :registration
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
# Marks a DSL call made without a value, which reads instead of writes.
|
|
85
|
+
UNSET = Object.new.freeze
|
|
86
|
+
private_constant :UNSET
|
|
87
|
+
|
|
88
|
+
# @param values [Hash] settings made on this instance only
|
|
89
|
+
def initialize(values = {})
|
|
90
|
+
@values = values
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
SCALARS.each do |name, valid|
|
|
94
|
+
define_method(:"#{name}=") do |value|
|
|
95
|
+
raise ArgumentError, "#{name} cannot be #{value.inspect}" unless valid.call(value)
|
|
96
|
+
|
|
97
|
+
@values[name] = value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
define_method(name) do |value = UNSET|
|
|
101
|
+
return @values.fetch(name) { DEFAULTS.fetch(name) } if UNSET.equal?(value)
|
|
102
|
+
|
|
103
|
+
public_send(:"#{name}=", value)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Replace the text of one section's heading.
|
|
108
|
+
#
|
|
109
|
+
# @param section [Symbol] a key of {HEADINGS}
|
|
110
|
+
# @param text [String]
|
|
111
|
+
# @return [String]
|
|
112
|
+
def heading(section, text)
|
|
113
|
+
raise ArgumentError, "#{section.inspect} has no heading" unless HEADINGS.key?(section)
|
|
114
|
+
raise ArgumentError, "a heading must be a String" unless text.is_a?(String)
|
|
115
|
+
|
|
116
|
+
@values[:headings] = @values.fetch(:headings, {}).merge(section => text)
|
|
117
|
+
text
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Hash{Symbol => String}] every heading's text, before casing
|
|
121
|
+
def headings
|
|
122
|
+
HEADINGS.merge(@values.fetch(:headings, {}))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Replace the styles of one element. No styles prints it plain.
|
|
126
|
+
#
|
|
127
|
+
# @param element [Symbol] a key of {STYLES}
|
|
128
|
+
# @param names [Array<Symbol>] names from {Colors::STYLES}
|
|
129
|
+
# @return [Array<Symbol>]
|
|
130
|
+
def style(element, *names)
|
|
131
|
+
raise ArgumentError, "#{element.inspect} is not a styled element" unless STYLES.key?(element)
|
|
132
|
+
|
|
133
|
+
unknown = names - Colors::STYLES
|
|
134
|
+
raise ArgumentError, "unknown styles: #{unknown.inspect}" unless unknown.empty?
|
|
135
|
+
|
|
136
|
+
@values[:styles] = @values.fetch(:styles, {}).merge(element => names.freeze)
|
|
137
|
+
names
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Hash{Symbol => Array<Symbol>}] every element's styles
|
|
141
|
+
def styles
|
|
142
|
+
STYLES.merge(@values.fetch(:styles, {}))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# List commands under a heading of their own, in the order given.
|
|
146
|
+
# Groups print in the order they are declared, after ungrouped commands.
|
|
147
|
+
#
|
|
148
|
+
# @param name [String] the heading
|
|
149
|
+
# @param commands [Array<String>] command paths, such as "compile" or "db migrate"
|
|
150
|
+
# @return [Array<Array(String, Array<String>)>] every group
|
|
151
|
+
def group(name, *commands)
|
|
152
|
+
commands = commands.flatten
|
|
153
|
+
raise ArgumentError, "a group name must be a String" unless name.is_a?(String)
|
|
154
|
+
raise ArgumentError, "group #{name.inspect} lists no commands" if commands.empty?
|
|
155
|
+
|
|
156
|
+
@values[:groups] = groups + [[name, commands.map(&:to_s).freeze].freeze]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Array<Array(String, Array<String>)>]
|
|
160
|
+
def groups
|
|
161
|
+
@values.fetch(:groups, [])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# With names, set the order sections print in; a section left out is
|
|
165
|
+
# hidden. Without, read the order.
|
|
166
|
+
#
|
|
167
|
+
# @param names [Array<Symbol>] names from {SECTIONS}
|
|
168
|
+
# @return [Array<Symbol>]
|
|
169
|
+
def sections(*names)
|
|
170
|
+
return @values.fetch(:sections, SECTIONS) if names.empty?
|
|
171
|
+
|
|
172
|
+
self.sections = names
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @param names [Array<Symbol>]
|
|
176
|
+
def sections=(names)
|
|
177
|
+
@values[:sections] = validate_sections(names.flatten)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Hide sections without restating the order.
|
|
181
|
+
#
|
|
182
|
+
# @param names [Array<Symbol>] names from {SECTIONS}
|
|
183
|
+
# @return [Array<Symbol>] every hidden section
|
|
184
|
+
def hide(*names)
|
|
185
|
+
@values[:hidden] = hidden | validate_sections(names.flatten)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# @return [Array<Symbol>]
|
|
189
|
+
def hidden
|
|
190
|
+
@values.fetch(:hidden, [])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# @return [Array<Symbol>] the sections that print, in order
|
|
194
|
+
def visible_sections
|
|
195
|
+
sections - hidden
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The column text wraps at, or nil when wrapping is off.
|
|
199
|
+
#
|
|
200
|
+
# @param terminal_width [Integer]
|
|
201
|
+
# @return [Integer, nil]
|
|
202
|
+
def wrap_width(terminal_width)
|
|
203
|
+
return unless wrap
|
|
204
|
+
|
|
205
|
+
columns = width == :terminal ? terminal_width - margin : width
|
|
206
|
+
[columns, MIN_WIDTH].max
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# These settings with another instance's laid over them. Headings and
|
|
210
|
+
# styles merge key by key, hidden sections add up, and any other setting
|
|
211
|
+
# the other instance made replaces this one's.
|
|
212
|
+
#
|
|
213
|
+
# @param other [Configuration]
|
|
214
|
+
# @return [Configuration] a new instance
|
|
215
|
+
def merge(other)
|
|
216
|
+
combined = @values.merge(other.values) do |key, mine, theirs|
|
|
217
|
+
case key
|
|
218
|
+
when :headings, :styles then mine.merge(theirs)
|
|
219
|
+
when :hidden then mine | theirs
|
|
220
|
+
else theirs
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
self.class.new(combined)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
protected
|
|
227
|
+
|
|
228
|
+
attr_reader :values
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
|
|
232
|
+
def validate_sections(names)
|
|
233
|
+
unknown = names - SECTIONS
|
|
234
|
+
raise ArgumentError, "unknown sections: #{unknown.inspect}" unless unknown.empty?
|
|
235
|
+
|
|
236
|
+
names.freeze
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module Help
|
|
6
|
+
# The pieces every help screen is made of: headings, paragraphs and
|
|
7
|
+
# definition lists, wrapped to one width and painted with one palette.
|
|
8
|
+
#
|
|
9
|
+
# Text wraps before it is painted, so escape codes never count toward a
|
|
10
|
+
# line's length.
|
|
11
|
+
class Formatter
|
|
12
|
+
INDENT = " "
|
|
13
|
+
GAP = 2
|
|
14
|
+
|
|
15
|
+
# One line of a definition list: a term, and the text that describes it.
|
|
16
|
+
Row = ::Data.define(:term, :text, :term_style, :text_style) do
|
|
17
|
+
def initialize(term:, text: nil, term_style: :command, text_style: nil)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Configuration]
|
|
23
|
+
attr_reader :config
|
|
24
|
+
|
|
25
|
+
# @return [Integer, nil] the column text wraps at, nil when it does not
|
|
26
|
+
attr_reader :width
|
|
27
|
+
|
|
28
|
+
# @param config [Configuration]
|
|
29
|
+
# @param io [IO] where the screen prints, which decides `color :auto`
|
|
30
|
+
# @param terminal_width [Integer]
|
|
31
|
+
def initialize(config, io, terminal_width: Terminal.width)
|
|
32
|
+
@config = config
|
|
33
|
+
@width = config.wrap_width(terminal_width)
|
|
34
|
+
@pastel = ::Pastel.new(enabled: Colors.enabled_for?(config.color, io))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param text [String]
|
|
38
|
+
# @param element [Symbol, nil] a key of {Configuration::STYLES}; nil paints nothing
|
|
39
|
+
# @return [String]
|
|
40
|
+
def paint(text, element)
|
|
41
|
+
return text if element.nil?
|
|
42
|
+
|
|
43
|
+
@pastel.decorate(text, *config.styles.fetch(element))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param section [Symbol]
|
|
47
|
+
# @return [String]
|
|
48
|
+
def heading(section)
|
|
49
|
+
title(config.headings.fetch(section))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A heading with the configured case and style, for any text.
|
|
53
|
+
# @return [String]
|
|
54
|
+
def title(text)
|
|
55
|
+
cased = case config.heading_case
|
|
56
|
+
when :upcase then text.upcase
|
|
57
|
+
when :capitalize then text.sub(/\A\p{Ll}/, &:upcase)
|
|
58
|
+
else text
|
|
59
|
+
end
|
|
60
|
+
paint(cased, :heading)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param text [String, nil]
|
|
64
|
+
# @param indent [String]
|
|
65
|
+
# @return [Array<String>] wrapped lines; blank lines carry no indent
|
|
66
|
+
def paragraph(text, indent: "")
|
|
67
|
+
Text.lines(text, width && (width - indent.length)).map do |line|
|
|
68
|
+
line.empty? ? line : indent + line
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The description column for a set of rows: the longest term that has
|
|
73
|
+
# a description, but never more than half the wrap width. A term with
|
|
74
|
+
# nothing beside it has nothing to align.
|
|
75
|
+
#
|
|
76
|
+
# @param rows [Array<Row>]
|
|
77
|
+
# @return [Integer]
|
|
78
|
+
def column_for(rows)
|
|
79
|
+
longest = rows.reject { it.text.to_s.empty? }.map { it.term.length }.max || 0
|
|
80
|
+
width ? [longest, width / 2].min : longest
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param rows [Array<Row>]
|
|
84
|
+
# @param column [Integer] from {#column_for}
|
|
85
|
+
# @return [Array<String>]
|
|
86
|
+
def definitions(rows, column)
|
|
87
|
+
rows.flat_map { definition(it, column) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def definition(row, column)
|
|
93
|
+
term = INDENT + paint(row.term, row.term_style)
|
|
94
|
+
offset = INDENT.length + column + GAP
|
|
95
|
+
lines = Text.lines(row.text, width && [width - offset, Configuration::MIN_WIDTH].max)
|
|
96
|
+
return [term] if lines.empty?
|
|
97
|
+
|
|
98
|
+
body = lines.map { it.empty? ? it : paint(it, row.text_style) }
|
|
99
|
+
hanging = body.map { it.empty? ? it : (" " * offset) + it }
|
|
100
|
+
return [term, *hanging] if row.term.length > column
|
|
101
|
+
|
|
102
|
+
[term + (" " * (column + GAP - row.term.length)) + body.first, *hanging.drop(1)]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module Help
|
|
6
|
+
# The only code that touches dry-cli. Everything else renders.
|
|
7
|
+
module Integration
|
|
8
|
+
# Prepended to Dry::CLI. Overrides the two private methods dry-cli
|
|
9
|
+
# prints help from, and nothing else.
|
|
10
|
+
#
|
|
11
|
+
# Both are `@api private` in dry-cli, which is why the suite asserts they
|
|
12
|
+
# exist: a dry-cli release that renames them fails this gem's specs
|
|
13
|
+
# rather than a host's help screen.
|
|
14
|
+
module CLIMethods
|
|
15
|
+
HELP_FLAGS = %w[-h --help].freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# dry-cli calls this for `mycli deploy -h`.
|
|
20
|
+
def help(command, prog_name)
|
|
21
|
+
screen = Screens::Command.new(
|
|
22
|
+
command:, prog_name:, top_level: !kommand.nil?,
|
|
23
|
+
config: Help.config_for(registry), io: out
|
|
24
|
+
)
|
|
25
|
+
out.puts screen.render
|
|
26
|
+
exit(0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# dry-cli calls this whenever the arguments name no command: none at
|
|
30
|
+
# all, a group without a command of its own, `-h` or `--help` at a
|
|
31
|
+
# registry level, or a typo.
|
|
32
|
+
def spell_checker(result, arguments)
|
|
33
|
+
config = Help.config_for(registry)
|
|
34
|
+
unmatched = arguments.drop(result.names.length)
|
|
35
|
+
|
|
36
|
+
if unmatched.empty?
|
|
37
|
+
status = config.exit_code_without_arguments
|
|
38
|
+
list(result, config, status.zero? ? out : err, status)
|
|
39
|
+
elsif HELP_FLAGS.include?(unmatched.first)
|
|
40
|
+
list(result, config, out, 0)
|
|
41
|
+
else
|
|
42
|
+
suggestion = SpellChecker.call(result, arguments)
|
|
43
|
+
err.puts "#{suggestion}\n\n" if suggestion
|
|
44
|
+
list(result, config, err, 1)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def list(result, config, io, status)
|
|
49
|
+
io.puts Screens::Listing.new(result:, config:, io:).render
|
|
50
|
+
exit(status)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Included into Dry::CLI::Registry, so every registry can describe itself.
|
|
55
|
+
module RegistryMethods
|
|
56
|
+
# Configure this registry's help. A block taking an argument receives
|
|
57
|
+
# the configuration; any other block runs against it.
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# help do
|
|
61
|
+
# title "Taxlibris"
|
|
62
|
+
# width 100
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# @return [Configuration] this registry's settings
|
|
66
|
+
def help(&block)
|
|
67
|
+
@help_config ||= Configuration.new
|
|
68
|
+
if block&.arity == 1
|
|
69
|
+
yield @help_config
|
|
70
|
+
elsif block
|
|
71
|
+
@help_config.instance_eval(&block)
|
|
72
|
+
end
|
|
73
|
+
@help_config
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Configuration, nil] nil until {#help} is called
|
|
77
|
+
def help_config
|
|
78
|
+
@help_config
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module Help
|
|
6
|
+
# One class per kind of help screen.
|
|
7
|
+
module Screens
|
|
8
|
+
# Renders the configured sections a screen supports, in configured
|
|
9
|
+
# order, one blank line apart. A subclass lists the sections it
|
|
10
|
+
# supports in `SECTIONS` and renders each in a private `render_<name>`
|
|
11
|
+
# that returns lines, or nil when there is nothing to print.
|
|
12
|
+
class Base
|
|
13
|
+
Row = Formatter::Row
|
|
14
|
+
INDENT = Formatter::INDENT
|
|
15
|
+
HELP = Row.new(term: "-h, --help", text: "Show help", term_style: :option)
|
|
16
|
+
|
|
17
|
+
# @param config [Configuration]
|
|
18
|
+
# @param io [IO] where the screen prints
|
|
19
|
+
# @param terminal_width [Integer]
|
|
20
|
+
def initialize(config:, io:, terminal_width: Terminal.width)
|
|
21
|
+
@config = config
|
|
22
|
+
@format = Formatter.new(config, io, terminal_width:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String]
|
|
26
|
+
def render
|
|
27
|
+
config.visible_sections
|
|
28
|
+
.select { self.class::SECTIONS.include?(it) }
|
|
29
|
+
.filter_map { send(:"render_#{it}") }
|
|
30
|
+
.reject(&:empty?)
|
|
31
|
+
.map { it.join("\n") }
|
|
32
|
+
.join("\n\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :config, :format
|
|
38
|
+
|
|
39
|
+
# A subclass answers `banner?` and `epilogue?`: whether the title and
|
|
40
|
+
# description, and the epilogue, print on its screen.
|
|
41
|
+
def render_banner
|
|
42
|
+
return unless banner?
|
|
43
|
+
|
|
44
|
+
lines = []
|
|
45
|
+
lines << format.paint(config.title, :title) if config.title
|
|
46
|
+
description = format.paragraph(config.description)
|
|
47
|
+
lines << "" unless lines.empty? || description.empty?
|
|
48
|
+
lines.concat(description)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render_epilogue
|
|
52
|
+
format.paragraph(config.epilogue) if epilogue?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def section(name, body)
|
|
56
|
+
[format.heading(name), *body] unless body.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every definition list on a screen shares one description column,
|
|
60
|
+
# measured over the rows a subclass returns from `aligned_rows`.
|
|
61
|
+
def column
|
|
62
|
+
@column ||= format.column_for([HELP, *aligned_rows])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def definitions(rows)
|
|
66
|
+
format.definitions(rows, column)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The nodes of one registry level in the configured order, hidden ones left out.
|
|
70
|
+
def visible(children)
|
|
71
|
+
shown = children.to_a.reject { |_, node| node.hidden }
|
|
72
|
+
config.command_order == :alphabetical ? shown.sort_by(&:first) : shown
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A group registered without a command has no description of its own,
|
|
76
|
+
# so it describes itself by what it contains.
|
|
77
|
+
def describe_node(node)
|
|
78
|
+
return node.command.description if node.command
|
|
79
|
+
|
|
80
|
+
names = visible(node.children).map(&:first)
|
|
81
|
+
"Subcommands: #{names.join(', ')}" unless names.empty?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module Help
|
|
6
|
+
module Screens
|
|
7
|
+
# The help printed for one command: `mycli deploy -h`.
|
|
8
|
+
class Command < Base
|
|
9
|
+
SECTIONS = %i[banner usage description subcommands arguments options examples epilogue].freeze
|
|
10
|
+
|
|
11
|
+
# @param command [Class, Dry::CLI::Command]
|
|
12
|
+
# @param prog_name [String] the program and command path, "mycli db migrate"
|
|
13
|
+
# @param top_level [Boolean] true when the command is the whole CLI,
|
|
14
|
+
# as with `Dry::CLI.new(SomeCommand)`
|
|
15
|
+
def initialize(command:, prog_name:, top_level: false, **)
|
|
16
|
+
super(**)
|
|
17
|
+
@command = command
|
|
18
|
+
@prog_name = prog_name
|
|
19
|
+
@top_level = top_level
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :command, :prog_name
|
|
25
|
+
|
|
26
|
+
def banner?
|
|
27
|
+
@top_level || config.banner_on_subcommands
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def epilogue?
|
|
31
|
+
@top_level
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_usage
|
|
35
|
+
program = format.paint(prog_name, :command)
|
|
36
|
+
lines = ["#{INDENT}#{program}#{usage_arguments} [OPTIONS]"]
|
|
37
|
+
lines << "#{INDENT}#{program} COMMAND [OPTIONS]" if subcommand_rows.any?
|
|
38
|
+
section(:usage, lines)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def render_description
|
|
42
|
+
section(:description, format.paragraph(command.description, indent: INDENT))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_subcommands
|
|
46
|
+
section(:subcommands, definitions(subcommand_rows))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_arguments
|
|
50
|
+
section(:arguments, definitions(argument_rows))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_options
|
|
54
|
+
section(:options, definitions([*option_rows, HELP]))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Examples align among themselves: a full command line is longer than
|
|
58
|
+
# any option, and would push every other description off to the right.
|
|
59
|
+
def render_examples
|
|
60
|
+
rows = command.examples.map do |example|
|
|
61
|
+
line, comment = example.split(" # ", 2)
|
|
62
|
+
Row.new(term: "#{prog_name} #{line.strip}", text: comment&.strip, text_style: :comment)
|
|
63
|
+
end
|
|
64
|
+
section(:examples, format.definitions(rows, format.column_for(rows)))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def aligned_rows
|
|
68
|
+
subcommand_rows + argument_rows + option_rows
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def usage_arguments
|
|
72
|
+
required = command.required_arguments.map { argument_name(it) }
|
|
73
|
+
optional = command.optional_arguments.map { "[#{argument_name(it)}]" }
|
|
74
|
+
names = [*required, *optional]
|
|
75
|
+
" #{format.paint(names.join(' '), :argument)}" unless names.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def subcommand_rows
|
|
79
|
+
@subcommand_rows ||= visible(command.subcommands).map do |name, node|
|
|
80
|
+
Row.new(term: name, text: describe_node(node))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def argument_rows
|
|
85
|
+
@argument_rows ||= command.arguments.map do |argument|
|
|
86
|
+
Row.new(term: argument_name(argument), text: describe(argument), term_style: :argument)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def option_rows
|
|
91
|
+
@option_rows ||= command.options.map do |option|
|
|
92
|
+
Row.new(term: option_term(option), text: describe(option), term_style: :option)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def argument_name(argument)
|
|
97
|
+
name = argument.name.to_s.upcase
|
|
98
|
+
argument.array? ? "#{name}..." : name
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Short aliases, then the option, then long aliases: "-f, --[no-]force".
|
|
102
|
+
def option_term(option)
|
|
103
|
+
name = option.name.to_s.downcase.gsub(/[[:space:]]|_/, "-")
|
|
104
|
+
main = if option.boolean? then "--[no-]#{name}"
|
|
105
|
+
elsif option.flag? then "--#{name}"
|
|
106
|
+
elsif option.array? then "--#{name}=VALUE1,VALUE2,.."
|
|
107
|
+
else "--#{name}=VALUE"
|
|
108
|
+
end
|
|
109
|
+
aliases = option.aliases.map { it.sub(/\A-{1,2}/, "") }.uniq
|
|
110
|
+
short, long = aliases.partition { it.length == 1 }
|
|
111
|
+
[*short.map { "-#{it}" }, main, *long.map { "--#{it}" }].join(", ")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The description, then what a reader needs to use the value.
|
|
115
|
+
def describe(param)
|
|
116
|
+
notes = []
|
|
117
|
+
notes << "required" if param.required?
|
|
118
|
+
notes << "one of: #{param.values.join(', ')}" if param.values
|
|
119
|
+
notes << "default: #{param.default.inspect}" unless param.default.nil?
|
|
120
|
+
[param.options[:desc], ("(#{notes.join('; ')})" if notes.any?)].compact.join(" ")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|