shellopts 2.0.0.pre.13 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
+