shellopts 2.0.0.pre.13 → 2.0.1

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/.gitignore +2 -1
  3. data/.ruby-version +1 -1
  4. data/README.md +201 -267
  5. data/TODO +46 -134
  6. data/doc/format.rb +95 -0
  7. data/doc/grammar.txt +27 -0
  8. data/doc/syntax.rb +110 -0
  9. data/doc/syntax.txt +10 -0
  10. data/lib/ext/array.rb +58 -5
  11. data/lib/ext/forward_to.rb +15 -0
  12. data/lib/ext/lcs.rb +34 -0
  13. data/lib/shellopts/analyzer.rb +130 -0
  14. data/lib/shellopts/ansi.rb +8 -0
  15. data/lib/shellopts/args.rb +29 -21
  16. data/lib/shellopts/argument_type.rb +139 -0
  17. data/lib/shellopts/dump.rb +158 -0
  18. data/lib/shellopts/formatter.rb +325 -0
  19. data/lib/shellopts/grammar.rb +375 -0
  20. data/lib/shellopts/interpreter.rb +103 -0
  21. data/lib/shellopts/lexer.rb +175 -0
  22. data/lib/shellopts/parser.rb +269 -82
  23. data/lib/shellopts/program.rb +279 -0
  24. data/lib/shellopts/renderer.rb +227 -0
  25. data/lib/shellopts/stack.rb +7 -0
  26. data/lib/shellopts/token.rb +44 -0
  27. data/lib/shellopts/version.rb +2 -2
  28. data/lib/shellopts.rb +439 -220
  29. data/main +1180 -0
  30. data/shellopts.gemspec +9 -15
  31. metadata +85 -42
  32. data/lib/main.rb +0 -1
  33. data/lib/shellopts/ast/command.rb +0 -41
  34. data/lib/shellopts/ast/node.rb +0 -37
  35. data/lib/shellopts/ast/option.rb +0 -21
  36. data/lib/shellopts/ast/program.rb +0 -14
  37. data/lib/shellopts/compiler.rb +0 -128
  38. data/lib/shellopts/generator.rb +0 -15
  39. data/lib/shellopts/grammar/command.rb +0 -80
  40. data/lib/shellopts/grammar/node.rb +0 -33
  41. data/lib/shellopts/grammar/option.rb +0 -66
  42. data/lib/shellopts/grammar/program.rb +0 -65
  43. data/lib/shellopts/idr.rb +0 -236
  44. data/lib/shellopts/main.rb +0 -10
  45. data/lib/shellopts/option_struct.rb +0 -148
  46. data/lib/shellopts/shellopts.rb +0 -123
@@ -3,36 +3,36 @@ module ShellOpts
3
3
  # Specialization of Array for arguments lists. Args extends Array with a
4
4
  # #extract and an #expect method to extract elements from the array. The
5
5
  # methods raise a ShellOpts::UserError exception in case of errors
6
+ #
6
7
  class Args < Array
7
- def initialize(shellopts, *args)
8
- @shellopts = shellopts
9
- super(*args)
10
- end
11
-
12
8
  # Remove and return elements from beginning of the array
13
9
  #
14
10
  # If +count_or_range+ is a number, that number of elements will be
15
11
  # returned. If the count is one, a simple value is returned instead of an
16
- # array. If the count is negative, the elements will be removed from the
12
+ # array. If the count is negative, the elements will be removed from the
17
13
  # end of the array. If +count_or_range+ is a range, the number of elements
18
14
  # returned will be in that range. The range can't contain negative numbers
19
15
  #
20
16
  # #extract raise a ShellOpts::UserError exception if there's is not enough
21
17
  # elements in the array to satisfy the request
18
+ #
22
19
  def extract(count_or_range, message = nil)
23
- if count_or_range.is_a?(Range)
24
- range = count_or_range
25
- range.min <= self.size or inoa(message)
26
- n_extract = [self.size, range.max].min
27
- n_extend = range.max > self.size ? range.max - self.size : 0
28
- r = self.shift(n_extract) + Array.new(n_extend)
29
- range.max <= 1 ? r.first : r
30
- else
31
- count = count_or_range
32
- self.size >= count.abs or inoa(message)
33
- start = count >= 0 ? 0 : size + count
34
- r = slice!(start, count.abs)
35
- r.size <= 0 ? nil : (r.size == 1 ? r.first : r)
20
+ case count_or_range
21
+ when Range
22
+ range = count_or_range
23
+ range.min <= self.size or inoa(message)
24
+ n_extract = [self.size, range.max].min
25
+ n_extend = range.max > self.size ? range.max - self.size : 0
26
+ r = self.shift(n_extract) + Array.new(n_extend)
27
+ range.max <= 1 ? r.first : r
28
+ when Integer
29
+ count = count_or_range
30
+ count.abs <= self.size or inoa(message)
31
+ start = count >= 0 ? 0 : size + count
32
+ r = slice!(start, count.abs)
33
+ r.size <= 0 ? nil : (r.size == 1 ? r.first : r)
34
+ else
35
+ raise ArgumentError
36
36
  end
37
37
  end
38
38
 
@@ -41,14 +41,22 @@ module ShellOpts
41
41
  #
42
42
  # #expect raise a ShellOpts::UserError exception if the array is not emptied
43
43
  # by the operation
44
+ #
44
45
  def expect(count_or_range, message = nil)
45
- count_or_range === self.size or inoa(message)
46
+ case count_or_range
47
+ when Range
48
+ count_or_range === self.size or inoa(message)
49
+ when Integer
50
+ count_or_range >= 0 or raise ArgumentError, "Count can't be negative"
51
+ count_or_range.abs == self.size or inoa(message)
52
+ end
46
53
  extract(count_or_range) # Can't fail
47
54
  end
48
55
 
49
56
  private
50
57
  def inoa(message = nil)
51
- raise ShellOpts::UserError, message || "Illegal number of arguments"
58
+ raise ArgumentError, message || "Illegal number of arguments"
52
59
  end
53
60
  end
54
61
  end
62
+
@@ -0,0 +1,139 @@
1
+ module ShellOpts
2
+ module Grammar
3
+ class ArgumentType
4
+ # Name of type
5
+ def name() self.class.to_s.sub(/.*::/, "").sub(/Argument/, "") end
6
+
7
+ # Return truish if value literal (String) match the type. Returns false
8
+ # and set #message if the value doesn't match. <name> is used to
9
+ # construct the error message and is the name/alias the user specified on
10
+ # the command line
11
+ def match?(name, literal) true end
12
+
13
+ # Error message if match? returned false. Note that this method is not
14
+ # safe for concurrent processing
15
+ attr_reader :message
16
+
17
+ # Return true if .value is an "instance" of self (not used atm. See
18
+ # Command#[] and Grammar::Option#value?)
19
+ def value?(value) true end
20
+
21
+ # Convert value to Ruby type
22
+ def convert(value) value end
23
+
24
+ # String representation. Equal to #name
25
+ def to_s() name end
26
+
27
+ protected
28
+ # it is important that #set_message return false
29
+ def set_message(msg)
30
+ @message = msg
31
+ false
32
+ end
33
+ end
34
+
35
+ class StringType < ArgumentType
36
+ end
37
+
38
+ class IntegerArgument < ArgumentType
39
+ def match?(name, literal)
40
+ literal =~ /^-?\d+$/ or
41
+ set_message "Illegal integer value in #{name}: #{literal}"
42
+ end
43
+
44
+ def value?(value) value.is_a?(Integer) end
45
+ def convert(value) value.to_i end
46
+ end
47
+
48
+ class FloatArgument < ArgumentType
49
+ def match?(name, literal)
50
+ # https://stackoverflow.com/a/21891705/2130986
51
+ literal =~ /^[+-]?(?:0|[1-9]\d*)(?:\.(?:\d*[1-9]|0))?$/ or
52
+ set_message "Illegal decimal value in #{name}: #{literal}"
53
+ end
54
+
55
+ def value?(value) value.is_a?(Numeric) end
56
+ def convert(value) value.to_f end
57
+ end
58
+
59
+ class FileArgument < ArgumentType
60
+ attr_reader :kind
61
+
62
+ def initialize(kind)
63
+ constrain kind, :file, :dir, :path, :efile, :edir, :epath, :nfile, :ndir, :npath
64
+ @kind = kind
65
+ end
66
+
67
+ def match?(name, literal)
68
+ case kind
69
+ when :file; match_path(name, literal, kind, :file?, :default)
70
+ when :dir; match_path(name, literal, kind, :directory?, :default)
71
+ when :path; match_path(name, literal, kind, :exist?, :default)
72
+
73
+ when :efile; match_path(name, literal, kind, :file?, :exist)
74
+ when :edir; match_path(name, literal, kind, :directory?, :exist)
75
+ when :epath; match_path(name, literal, kind, :exist?, :exist)
76
+
77
+ when :nfile; match_path(name, literal, kind, :file?, :new)
78
+ when :ndir; match_path(name, literal, kind, :directory?, :new)
79
+ when :npath; match_path(name, literal, kind, :exist?, :new)
80
+ else
81
+ raise InternalError, "Illegal kind: #{kind.inspect}"
82
+ end
83
+ end
84
+
85
+ # Note: No checks done, not sure if it is a feature or a bug
86
+ def value?(value) value.is_a?(String) end
87
+
88
+ protected
89
+ def match_path(name, literal, kind, method, mode)
90
+ subject =
91
+ case kind
92
+ when :file, :efile, :nfile; "regular file"
93
+ when :dir, :edir, :ndir; "directory"
94
+ when :path, :epath, :npath; "path"
95
+ else
96
+ raise ArgumentError
97
+ end
98
+
99
+ if File.send(method, literal) # exists?
100
+ if mode == :new
101
+ set_message "#{subject.capitalize} already exists in #{name}: #{literal}"
102
+ elsif kind == :path || kind == :epath
103
+ if File.file?(literal) || File.directory?(literal)
104
+ true
105
+ else
106
+ set_message "Expected regular file or directory as #{name} argument: #{literal}"
107
+ end
108
+ else
109
+ true
110
+ end
111
+ elsif File.exist?(literal) # exists but not the right type
112
+ if mode == :new
113
+ set_message "#{subject.capitalize} already exists"
114
+ else
115
+ set_message "Expected #{subject} as #{name} argument: #{literal}"
116
+ end
117
+ else # does not exist
118
+ if [:default, :new].include? mode
119
+ if File.exist?(File.dirname(literal))
120
+ true
121
+ else
122
+ set_message "Illegal path in #{name}: #{literal}"
123
+ end
124
+ else
125
+ set_message "Error in #{name} argument: Can't find #{literal}"
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ class EnumArgument < ArgumentType
132
+ attr_reader :values
133
+ def initialize(values) @values = values.dup end
134
+ def match?(name, literal) literal?(literal) or set_message "Illegal value in #{name}: '#{literal}'" end
135
+ def value?(value) @values.include?(value) end
136
+ end
137
+ end
138
+ end
139
+
@@ -0,0 +1,158 @@
1
+ module ShellOpts
2
+ module Grammar
3
+ class IdrNode
4
+ def dump_doc
5
+ puts "#{self.class} #{ident}"
6
+ indent {
7
+ children.each(&:dump_doc)
8
+ }
9
+ end
10
+ end
11
+ end
12
+
13
+ module Grammar
14
+ class Command
15
+ # Usable after parsing
16
+ def render_structure
17
+ io = StringIO.new
18
+ dump_structure(io)
19
+ io.string
20
+ end
21
+
22
+ def dump_structure(device = $stdout)
23
+ device.puts ident
24
+ device.indent { |dev|
25
+ option_groups.each { |group| dev.puts group.options.map(&:name).join(" ") }
26
+ commands.each { |command| command.dump_structure(dev) }
27
+ descrs.each { |descr| dev.puts descr.text }
28
+ }
29
+ end
30
+ end
31
+ end
32
+
33
+ module Grammar
34
+ class Node
35
+ def dump_ast
36
+ puts "#{classname} @ #{token.pos} #{token.source}"
37
+ indent { children.each(&:dump_ast) }
38
+ end
39
+
40
+ def dump_idr(short = false)
41
+ puts "#{classname}" if !short
42
+ end
43
+
44
+ def dump_attrs(*attrs)
45
+ indent {
46
+ Array(attrs).flatten.select { |attr| attr.is_a?(Symbol) }.each { |attr|
47
+ value = self.send(attr)
48
+ case value
49
+ when Brief
50
+ puts "#{attr}: #{value.text}"
51
+ when Node
52
+ puts "#{attr}:"
53
+ indent { value.dump_idr }
54
+ when Array
55
+ case value.first
56
+ when nil
57
+ puts "#{attr}: []"
58
+ when Node
59
+ puts "#{attr}:"
60
+ indent { value.each(&:dump_idr) }
61
+ else
62
+ puts "#{attr}: #{value.inspect}"
63
+ end
64
+ when ArgumentType
65
+ puts "#{attr}: #{value}"
66
+ else
67
+ # value = value.inspect if value.nil? || !value.respond_to?(:to_s)
68
+ puts "#{attr}: #{value.inspect}"
69
+ end
70
+ }
71
+ }
72
+ end
73
+
74
+ protected
75
+ def classname() self.class.to_s.sub(/.*::/, "") end
76
+ end
77
+
78
+ class Option
79
+ def dump_idr(short = false)
80
+ if short
81
+ s = [
82
+ name,
83
+ argument? ? argument_type.name : nil,
84
+ optional? ? "?" : nil,
85
+ repeatable? ? "*" : nil
86
+ ].compact.join(" ")
87
+ puts s
88
+ else
89
+ puts "#{name}: #{classname}"
90
+ dump_attrs(
91
+ :uid, :path, :attr, :ident, :name, :idents, :names,
92
+ :repeatable?,
93
+ :argument?, argument? && :argument_name, argument? && :argument_type,
94
+ :enum?, enum? && :argument_enum,
95
+ :optional?)
96
+ indent { puts "brief: #{group.brief}" }
97
+ end
98
+ end
99
+ end
100
+
101
+ class Command
102
+ def dump_idr(short = false)
103
+ if short
104
+ puts name
105
+ indent {
106
+ options.each { |option| option.dump_idr(short) }
107
+ commands.each { |command| command.dump_idr(short) }
108
+ descrs.each { |descr| descr.dump_idr(short) }
109
+ }
110
+ else
111
+ puts "#{name}: #{classname}"
112
+ dump_attrs :uid, :path, :ident, :name, :options, :commands, :specs, :descrs, :brief
113
+ end
114
+ end
115
+ end
116
+
117
+ class ArgDescr
118
+ def dump_idr(short = false)
119
+ super
120
+ indent { puts token.to_s }
121
+ end
122
+ end
123
+
124
+ class ArgSpec < Node
125
+ def dump_idr(short = false)
126
+ super
127
+ dump_attrs :arguments
128
+ end
129
+ end
130
+
131
+ class Arg < Node
132
+ def dump_idr(short = false)
133
+ puts "<type>"
134
+ end
135
+ end
136
+ end
137
+
138
+ class Command
139
+ def __dump__(argv = [])
140
+ ::Kernel.puts __name__
141
+ ::Kernel.indent {
142
+ __options__.each { |ident, value| ::Kernel.puts "#{ident}: #{value.inspect}" }
143
+ __subcommand__!&.__dump__
144
+ ::Kernel.puts argv.map(&:inspect).join(" ") if !argv.empty?
145
+ }
146
+ end
147
+
148
+ # Class-level accessor methods
149
+ def self.dump(expr, argv = []) expr.__dump__(argv) end
150
+ end
151
+
152
+ class Option
153
+ def dump
154
+ ::Kernel.puts [name, argument].compact.join(" ")
155
+ end
156
+ end
157
+ end
158
+
@@ -0,0 +1,325 @@
1
+ require 'terminfo'
2
+
3
+ # TODO: Move to ext/indented_io.rb
4
+ module IndentedIO
5
+ class IndentedIO
6
+ def margin() combined_indent.size end
7
+ end
8
+ end
9
+
10
+ module ShellOpts
11
+ module Grammar
12
+ class Node
13
+ def puts_help() end
14
+ def puts_usage() end
15
+ end
16
+
17
+ class Option
18
+ end
19
+
20
+ class OptionGroup
21
+ def puts_descr
22
+ puts Ansi.bold(render(:multi))
23
+ indent {
24
+ if description.any?
25
+ description.each { |descr|
26
+ descr.puts_descr
27
+ puts if descr != description.last
28
+ }
29
+ elsif brief
30
+ brief.puts_descr
31
+ end
32
+ }
33
+ end
34
+ end
35
+
36
+ # brief one-line commands should optionally use compact options
37
+ class Command
38
+ using Ext::Array::Wrap
39
+
40
+ def puts_usage(bol: false)
41
+ if descrs.size == 0
42
+ print (lead = Formatter.command_prefix || "")
43
+ indent(lead.size, ' ', bol: bol && lead == "") {
44
+ puts render(:multi, Formatter::USAGE_MAX_WIDTH)
45
+ }
46
+ else
47
+ lead = Formatter.command_prefix || ""
48
+ descrs.each { |descr|
49
+ print lead
50
+ puts render(:single, Formatter::USAGE_MAX_WIDTH, args: [descr.text])
51
+ }
52
+ end
53
+ end
54
+
55
+ def puts_brief
56
+ width = Formatter.rest
57
+ option_briefs = option_groups.map { |group| [group.render(:enum), group.brief&.words] }
58
+ command_briefs = commands.map { |command| [command.render(:single, width), command.brief&.words] }
59
+ widths = Formatter::compute_columns(width, option_briefs + command_briefs)
60
+
61
+ if brief
62
+ puts brief
63
+ puts
64
+ end
65
+
66
+ puts "Usage"
67
+ indent { puts_usage(bol: true) }
68
+
69
+ if options.any?
70
+ puts
71
+ puts "Options"
72
+ indent { Formatter::puts_columns(widths, option_briefs) }
73
+ end
74
+
75
+ if commands.any?
76
+ puts
77
+ puts "Commands"
78
+ indent { Formatter::puts_columns(widths, command_briefs) }
79
+ end
80
+ end
81
+
82
+ def puts_descr(prefix, brief: !self.brief.nil?, name: :path)
83
+ puts Ansi.bold([prefix, render(:single, Formatter.rest)].flatten.compact.join(" "))
84
+ indent {
85
+ if brief
86
+ puts self.brief.words.wrap(Formatter.rest)
87
+ else
88
+ newline = false
89
+ children.each { |child|
90
+ puts if newline
91
+ newline = true
92
+
93
+ if child.is_a?(Command)
94
+ child.puts_descr(prefix, name: :path)
95
+ else
96
+ child.puts_descr
97
+ end
98
+ }
99
+ end
100
+ }
101
+ end
102
+
103
+ def puts_help
104
+ puts Ansi.bold "NAME"
105
+ full_name = [Formatter::command_prefix, name].join
106
+ indent { puts brief ? "#{full_name} - #{brief}" : full_name }
107
+ puts
108
+
109
+ puts Ansi.bold "USAGE"
110
+ indent { puts_usage(bol: true) }
111
+
112
+ section = {
113
+ Paragraph => "DESCRIPTION",
114
+ OptionGroup => "OPTIONS",
115
+ Command => "COMMANDS"
116
+ }
117
+
118
+ newline = false # True if a newline should be printed before child
119
+ indent {
120
+ children.each { |child|
121
+ if child.is_a?(Section) # Explicit section
122
+ # p :A
123
+ puts
124
+ indent(-1).puts Ansi.bold child.name
125
+ section.delete_if { |_,v| v == child.name }
126
+ section.delete(Paragraph)
127
+ newline = false
128
+ next
129
+ elsif s = section[child.class] # Implicit section
130
+ # p :B
131
+ puts
132
+ indent(-1).puts Ansi.bold s
133
+ section.delete(child.class)
134
+ section.delete(Paragraph)
135
+ newline = false
136
+ else # Any other node add a newline
137
+ # p :C
138
+ puts if newline
139
+ newline = true
140
+ end
141
+
142
+ if child.is_a?(Command)
143
+ # prefix = child.parent != self ? nil : child.supercommand&.name
144
+ prefix = child.supercommand == self ? nil : child.supercommand&.name
145
+ child.puts_descr(prefix, brief: false, name: :path)
146
+ newline = true
147
+ else
148
+ child.puts_descr
149
+ newline = true
150
+ end
151
+ }
152
+
153
+ # Also emit commands not declared in nested scope
154
+ (commands - children.select { |child| child.is_a?(Command) }).each { |cmd|
155
+ puts if newline
156
+ newline = true
157
+ prefix = cmd.supercommand == self ? nil : cmd.supercommand&.name
158
+ cmd.puts_descr(prefix, brief: false, name: path)
159
+ }
160
+ }
161
+ end
162
+ end
163
+
164
+ class Program
165
+ using Ext::Array::Wrap
166
+ end
167
+
168
+ class DocNode
169
+ def puts_descr() puts lines end
170
+ end
171
+
172
+ module WrappedNode
173
+ def puts_descr(width = Formatter.rest) puts lines(width) end
174
+ end
175
+
176
+ class Code
177
+ def puts_descr() indent { super } end
178
+ end
179
+ end
180
+
181
+ class Formatter
182
+ using Ext::Array::Wrap
183
+
184
+ # Right margin
185
+ MARGIN_RIGHT = 3
186
+
187
+ # String for 'Usage' in error messages
188
+ USAGE_STRING = "Usage"
189
+
190
+ # Indent to use in usage output
191
+ USAGE_INDENT = USAGE_STRING.size
192
+
193
+ # Width of usage (after usage string)
194
+ USAGE_MAX_WIDTH = 70
195
+
196
+ # Indent to use in brief output
197
+ BRIEF_INDENT = 2
198
+
199
+ # Number of characters between columns in brief output
200
+ BRIEF_COL_SEP = 2
201
+
202
+ # Maximum width of first column in brief option and command lists
203
+ BRIEF_COL1_MIN_WIDTH = 6
204
+
205
+ # Maximum width of first column in brief option and command lists
206
+ BRIEF_COL1_MAX_WIDTH = 40
207
+
208
+ # Minimum width of second column in brief option and command lists
209
+ BRIEF_COL2_MAX_WIDTH = 50
210
+
211
+ # Indent to use in help output
212
+ HELP_INDENT = 4
213
+
214
+ # Command prefix when subject is a sub-command
215
+ def self.command_prefix() @command_prefix end
216
+
217
+ # Usage string in error messages
218
+ def self.usage(subject)
219
+ subject = Grammar::Command.command(subject)
220
+ @command_prefix = subject.ancestors.map { |node| node.name + " " }.join
221
+ setup_indent(1) {
222
+ print lead = "#{USAGE_STRING}: "
223
+ indent(lead.size, ' ', bol: false) { subject.puts_usage }
224
+ }
225
+ end
226
+
227
+ # # TODO
228
+ # def self.usage=(usage_lambda)
229
+ # end
230
+
231
+ # When the user gives a -h option
232
+ def self.brief(command)
233
+ command = Grammar::Command.command(command)
234
+ @command_prefix = command.ancestors.map { |node| node.name + " " }.join
235
+ setup_indent(BRIEF_INDENT) { command.puts_brief }
236
+ end
237
+
238
+ # # TODO
239
+ # def self.brief=(brief_lambda)
240
+ # end
241
+
242
+ # When the user gives a --help option
243
+ def self.help(subject)
244
+ subject = Grammar::Command.command(subject)
245
+ @command_prefix = subject.ancestors.map { |node| node.name + " " }.join
246
+ setup_indent(HELP_INDENT) { subject.puts_help }
247
+ end
248
+
249
+ # Short-hand to get the Grammar::Command object
250
+ def self.command_of(obj)
251
+ constrain obj, Grammar::Command, ::ShellOpts::Program
252
+ obj.is_a?(Grammar::Command) ? obj : obj.__grammar__
253
+ end
254
+
255
+ # # TODO
256
+ # def self.help_w_lambda(program)
257
+ # if @help_lambda
258
+ # #
259
+ # else
260
+ # program = Grammar::Command.command(program)
261
+ # setup_indent(HELP_INDENT) { program.puts_descr }
262
+ # end
263
+ # end
264
+ #
265
+ # def self.help=(help_lambda) @help_lambda end
266
+
267
+ def self.puts_columns(widths, fields)
268
+ l = []
269
+ first_width, second_width = *widths
270
+ second_col = first_width + 2
271
+
272
+ for (first, second) in fields
273
+ if first.size > first_width
274
+ puts first
275
+ indent(first_width + BRIEF_COL_SEP, ' ') { puts second.wrap(second_width) } if second
276
+ elsif second
277
+ printf "%-#{first_width + BRIEF_COL_SEP}s", first
278
+ indent(first_width, bol: false) { puts second.wrap(second_width) }
279
+ else
280
+ puts first
281
+ end
282
+ end
283
+ end
284
+
285
+ def self.compute_columns(width, fields)
286
+ first_max = [
287
+ (fields.map { |first, _| first.size } + [BRIEF_COL1_MIN_WIDTH]).max,
288
+ BRIEF_COL1_MAX_WIDTH
289
+ ].min
290
+ second_max = fields.map { |_, second| second ? second&.map(&:size).sum + second.size - 1: 0 }.max
291
+
292
+ if first_max + BRIEF_COL_SEP + second_max <= width
293
+ first_width = first_max
294
+ second_width = second_max
295
+ elsif first_max + BRIEF_COL_SEP + BRIEF_COL2_MAX_WIDTH <= width
296
+ first_width = first_max
297
+ second_width = width - first_width - BRIEF_COL_SEP
298
+ else
299
+ first_width = [width - BRIEF_COL_SEP - BRIEF_COL2_MAX_WIDTH, BRIEF_COL1_MAX_WIDTH].min
300
+ second_width = BRIEF_COL2_MAX_WIDTH
301
+ end
302
+
303
+ [first_width, second_width]
304
+ end
305
+
306
+ def self.width()
307
+ @width ||= TermInfo.screen_width - MARGIN_RIGHT
308
+ end
309
+
310
+ def self.rest() width - $stdout.margin end
311
+
312
+ private
313
+ # TODO Get rid of?
314
+ def self.setup_indent(indent, &block)
315
+ default_indent = IndentedIO.default_indent
316
+ begin
317
+ IndentedIO.default_indent = " " * indent
318
+ indent(0) { yield } # Ensure IndentedIO is on the top of the stack so we can use $stdout.levels
319
+ ensure
320
+ IndentedIO.default_indent = default_indent
321
+ end
322
+ end
323
+ end
324
+ end
325
+