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.
@@ -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