shellopts 2.0.0.pre.14 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) 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 +37 -5
  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 +62 -0
  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 +25 -15
  16. data/lib/shellopts/argument_type.rb +139 -0
  17. data/lib/shellopts/dump.rb +158 -0
  18. data/lib/shellopts/formatter.rb +292 -92
  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 +293 -0
  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 +1 -1
  28. data/lib/shellopts.rb +359 -3
  29. data/main +1180 -0
  30. data/shellopts.gemspec +8 -14
  31. metadata +86 -41
  32. data/lib/ext/algorithm.rb +0 -14
  33. data/lib/ext/ruby_env.rb +0 -8
  34. data/lib/shellopts/ast/command.rb +0 -112
  35. data/lib/shellopts/ast/dump.rb +0 -28
  36. data/lib/shellopts/ast/option.rb +0 -15
  37. data/lib/shellopts/ast/parser.rb +0 -106
  38. data/lib/shellopts/constants.rb +0 -88
  39. data/lib/shellopts/exceptions.rb +0 -21
  40. data/lib/shellopts/grammar/analyzer.rb +0 -76
  41. data/lib/shellopts/grammar/command.rb +0 -87
  42. data/lib/shellopts/grammar/dump.rb +0 -56
  43. data/lib/shellopts/grammar/lexer.rb +0 -56
  44. data/lib/shellopts/grammar/option.rb +0 -55
  45. data/lib/shellopts/grammar/parser.rb +0 -78
@@ -0,0 +1,293 @@
1
+
2
+ module ShellOpts
3
+ module Grammar
4
+ class Node
5
+ def parse() end
6
+
7
+ def self.parse(parent, token)
8
+ this = self.new(parent, token)
9
+ this.parse
10
+ this
11
+ end
12
+
13
+ def parser_error(token, message) raise ParserError, "#{token.pos} #{message}" end
14
+ end
15
+
16
+ class IdrNode
17
+ # Assumes that @name and @path has been defined
18
+ def parse
19
+ @ident = @path.last || :!
20
+ @attr = ::ShellOpts::Command::RESERVED_OPTION_NAMES.include?(ident.to_s) ? nil : ident
21
+ @uid = parent && @path.join(".").sub(/!\./, ".") # uid is nil for the Program object
22
+ end
23
+ end
24
+
25
+ class Option
26
+ SHORT_NAME_RE = /[a-zA-Z0-9]/
27
+ LONG_NAME_RE = /[a-zA-Z0-9][a-zA-Z0-9_-]*/
28
+ NAME_RE = /(?:#{SHORT_NAME_RE}|#{LONG_NAME_RE})(?:,#{LONG_NAME_RE})*/
29
+
30
+ def parse
31
+ token.source =~ /^(-|--|\+|\+\+)(#{NAME_RE})(?:=(.+?)(\?)?)?$/ or
32
+ parser_error token, "Illegal option: #{token.source.inspect}"
33
+ initial = $1
34
+ name_list = $2
35
+ arg = $3
36
+ optional = $4
37
+
38
+ @repeatable = %w(+ ++).include?(initial)
39
+
40
+ @short_idents = []
41
+ @short_names = []
42
+ names = name_list.split(",")
43
+ if %w(+ -).include?(initial)
44
+ while names.first&.size == 1
45
+ name = names.shift
46
+ @short_names << "-#{name}"
47
+ @short_idents << name.to_sym
48
+ end
49
+ end
50
+
51
+ names.each { |name|
52
+ name.size > 1 or
53
+ parser_error token, "Long names should be at least two characters long: '#{name}'"
54
+ }
55
+
56
+ @long_names = names.map { |name| "--#{name}" }
57
+ @long_idents = names.map { |name| name.tr("-", "_").to_sym }
58
+
59
+ @name = @long_names.first || @short_names.first
60
+ @path = command.path + [@long_idents.first || @short_idents.first]
61
+
62
+ @argument = !arg.nil?
63
+
64
+ named = true
65
+ if @argument
66
+ if arg =~ /^([^:]+)(?::(.*))/
67
+ @argument_name = $1
68
+ named = true
69
+ arg = $2
70
+ elsif arg =~ /^:(.*)/
71
+ arg = $1
72
+ named = false
73
+ end
74
+
75
+ case arg
76
+ when "", nil
77
+ @argument_name ||= "VAL"
78
+ @argument_type = StringType.new
79
+ when "#"
80
+ @argument_name ||= "INT"
81
+ @argument_type = IntegerArgument.new
82
+ when "$"
83
+ @argument_name ||= "NUM"
84
+ @argument_type = FloatArgument.new
85
+ when "FILE", "DIR", "PATH", "EFILE", "EDIR", "EPATH", "NFILE", "NDIR", "NPATH"
86
+ @argument_name ||= arg.sub(/^(?:E|N)/, "")
87
+ @argument_type = FileArgument.new(arg.downcase.to_sym)
88
+ when /,/
89
+ @argument_name ||= arg
90
+ @argument_type = EnumArgument.new(arg.split(","))
91
+ else
92
+ named && @argument_name.nil? or parser_error token, "Illegal type expression: #{arg.inspect}"
93
+ @argument_name = arg
94
+ @argument_type = StringType.new
95
+ end
96
+ @optional = !optional.nil?
97
+ else
98
+ @argument_type = StringType.new
99
+ end
100
+ super
101
+ end
102
+
103
+ private
104
+ def basename2ident(s) s.tr("-", "_").to_sym end
105
+ end
106
+
107
+ class Command
108
+ def parse
109
+ if parent
110
+ path_names = token.source.sub("!", "").split(".")
111
+ @name = path_names.last
112
+ @path = path_names.map { |cmd| "#{cmd}!".to_sym }
113
+ else
114
+ @path = []
115
+ @name = token.source
116
+ end
117
+ super
118
+ end
119
+ end
120
+
121
+ class Program
122
+ def self.parse(token)
123
+ super(nil, token)
124
+ end
125
+ end
126
+
127
+ class ArgSpec
128
+ def parse # TODO
129
+ super
130
+ end
131
+ end
132
+ end
133
+
134
+ class Parser
135
+ using Stack
136
+ using Ext::Array::ShiftWhile
137
+
138
+ # AST root node
139
+ attr_reader :program
140
+
141
+ # Commands by UID
142
+ attr_reader :commands
143
+
144
+ def initialize(tokens)
145
+ @tokens = tokens.dup # Array of token. Consumed by #parse
146
+ @nodes = {}
147
+ end
148
+
149
+ def parse()
150
+ @program = Grammar::Program.parse(@tokens.shift)
151
+ oneline = @tokens.first.lineno == @tokens.last.lineno
152
+ nodes = [@program] # Stack of Nodes. Follows the indentation of the source
153
+ cmds = [@program] # Stack of cmds. Used to keep track of the current command
154
+
155
+ while token = @tokens.shift
156
+ # Unwind stack according to indentation
157
+ while token.charno <= nodes.top.token.charno
158
+ node = nodes.pop
159
+ cmds.pop if cmds.top == node
160
+ !nodes.empty? or parse_error(token, "Illegal indent")
161
+ end
162
+
163
+ case token.kind
164
+ when :section
165
+ Grammar::Section.parse(nodes.top, token)
166
+
167
+ when :option
168
+ # Collect options into option groups if on the same line and not in
169
+ # oneline mode
170
+ options = [token] + @tokens.shift_while { |follow|
171
+ !oneline && follow.kind == :option && follow.lineno == token.lineno
172
+ }
173
+ group = Grammar::OptionGroup.new(cmds.top, token)
174
+ options.each { |option| Grammar::Option.parse(group, option) }
175
+ nodes.push group
176
+
177
+ when :command
178
+ parent = nil # Required by #indent
179
+ token.source =~ /^(?:(.*)\.)?([^.]+)$/
180
+ parent_id = $1
181
+ ident = $2.to_sym
182
+ parent_uid = parent_id && parent_id.sub(".", "!.") + "!"
183
+
184
+ # Handle dotted command
185
+ if parent_uid
186
+ # Clear stack except for the top-level Program object and then
187
+ # push command objects in the path
188
+ #
189
+ # FIXME: Move to analyzer
190
+ # cmds = cmds[0..0]
191
+ # for ident in parent_uid.split(".").map(&:to_sym)
192
+ # cmds.push cmds.top.commands.find { |c| c.ident == ident } or
193
+ # parse_error token, "Unknown command: #{ident.sub(/!/, "")}"
194
+ # end
195
+ # parent = cmds.top
196
+ parent = cmds.top
197
+ if !cmds.top.is_a?(Grammar::Program) && token.lineno == cmds.top.token.lineno
198
+ parent = cmds.pop.parent
199
+ end
200
+
201
+ # Regular command
202
+ else
203
+ # Don't nest cmds if they are declared on the same line (as it
204
+ # often happens with one-line declarations). Program is special
205
+ # cased as its virtual token is on line 0
206
+ parent = cmds.top
207
+ if !cmds.top.is_a?(Grammar::Program) && token.lineno == cmds.top.token.lineno
208
+ parent = cmds.pop.parent
209
+ end
210
+ end
211
+
212
+ command = Grammar::Command.parse(parent, token)
213
+ nodes.push command
214
+ cmds.push command
215
+
216
+ when :spec
217
+ spec = Grammar::ArgSpec.parse(cmds.top, token)
218
+ @tokens.shift_while { |token| token.kind == :argument }.each { |token|
219
+ Grammar::Arg.parse(spec, token)
220
+ }
221
+
222
+ when :argument
223
+ ; raise # Should never happen
224
+
225
+ when :usage
226
+ ; # Do nothing
227
+
228
+ when :usage_string
229
+ Grammar::ArgDescr.parse(cmds.top, token)
230
+
231
+ when :text
232
+ # Text is only allowed on new lines
233
+ token.lineno > nodes.top.token.lineno
234
+
235
+ # Detect indented comment groups (code)
236
+ if nodes.top.is_a?(Grammar::Paragraph)
237
+ code = Grammar::Code.parse(nodes.top.parent, token) # Using parent of paragraph
238
+ @tokens.shift_while { |t|
239
+ if t.kind == :text && t.charno >= token.charno
240
+ code.tokens << t
241
+ elsif t.kind == :blank && @tokens.first&.kind != :blank # Emit last blank line
242
+ if @tokens.first&.charno >= token.charno # But only if it is not the last blank line
243
+ code.tokens << t
244
+ end
245
+ else
246
+ break
247
+ end
248
+ }
249
+
250
+ # Detect comment groups (paragraphs)
251
+ else
252
+ if nodes.top.is_a?(Grammar::Command) || nodes.top.is_a?(Grammar::OptionGroup)
253
+ Grammar::Brief.new(nodes.top, token, token.source.sub(/\..*/, "")) if !nodes.top.brief
254
+ parent = nodes.top
255
+ else
256
+ parent = nodes.top.parent
257
+ end
258
+
259
+ paragraph = Grammar::Paragraph.parse(parent, token)
260
+ while @tokens.first&.kind == :text && @tokens.first.charno == token.charno
261
+ paragraph.tokens << @tokens.shift
262
+ end
263
+ nodes.push paragraph # Leave paragraph on stack so we can detect code blocks
264
+ end
265
+
266
+ when :brief
267
+ parent = nodes.top.is_a?(Grammar::Paragraph) ? nodes.top.parent : nodes.top
268
+ parent.brief.nil? or parse_error token, "Duplicate brief"
269
+ Grammar::Brief.parse(parent, token)
270
+
271
+ when :blank
272
+ ; # do nothing
273
+
274
+ else
275
+ raise InternalError, "Unexpected token kind: #{token.kind.inspect}"
276
+ end
277
+
278
+ # Skip blank lines
279
+ @tokens.shift_while { |token| token.kind == :blank }
280
+ end
281
+
282
+ @program
283
+ end
284
+
285
+ def self.parse(tokens)
286
+ self.new(tokens).parse
287
+ end
288
+
289
+ protected
290
+ def parse_error(token, message) raise ParserError, token, message end
291
+ end
292
+ end
293
+
@@ -0,0 +1,279 @@
1
+
2
+ # TODO: Create a BasicShellOptsObject with is_a? and operators defined
3
+ #
4
+ module ShellOpts
5
+ # Command represents a program or a subcommand. It is derived from
6
+ # BasicObject to have only a minimum of inherited member methods.
7
+ # Additional methods defined in Command use the '__<identifier>__' naming
8
+ # convention that doesn't collide with option or subcommand names but
9
+ # they're rarely used in application code
10
+ #
11
+ # The names of the inherited methods can't be used as options or
12
+ # command namess. They are: instance_eval, instance_exec method_missing,
13
+ # singleton_method_added, singleton_method_removed, and
14
+ # singleton_method_undefined
15
+ #
16
+ # Command also defines #subcommand and #subcommand! but they can be
17
+ # overshadowed by an option or command declaration. Their values can
18
+ # still be accessed using the dashed name, though
19
+ #
20
+ # Options and subcommands can be accessed using #[]
21
+ #
22
+ # The following methods are created dynamically for each declared option
23
+ # with an attribute name
24
+ #
25
+ # def <identifier>(default = nil) self["<identifier>"] || default end
26
+ # def <identifier>=(value) self["<identifier>"] = value end
27
+ # def <identifier>?() self.key?("<identifier>") end
28
+ #
29
+ # Options without an an attribute can still be accessed using #[] or trough
30
+ # #__options__ or #__options_list__):
31
+ #
32
+ # Each subcommand has a single method:
33
+ #
34
+ # # Return the subcommand object or nil if not present
35
+ # def <identifier>!() subcommand == :<identifier> ? @__subcommand__ : nil end
36
+ #
37
+ # The general #subcommand method can be used to find out which subcommand is
38
+ # used
39
+ #
40
+ class Command < BasicObject
41
+ define_method(:is_a?, ::Kernel.method(:is_a?))
42
+
43
+ # These names can't be used as option or command names
44
+ RESERVED_OPTION_NAMES = %w(
45
+ is_a
46
+ instance_eval instance_exec method_missing singleton_method_added
47
+ singleton_method_removed singleton_method_undefined)
48
+
49
+ # These methods can be overridden by an option (the value is not used -
50
+ # this is just for informational purposes)
51
+ OVERRIDEABLE_METHODS = %w(
52
+ subcommand
53
+ )
54
+
55
+ # Redefine ::new to call #__initialize__
56
+ def self.new(grammar)
57
+ object = super()
58
+ object.__send__(:__initialize__, grammar)
59
+ object
60
+ end
61
+
62
+ # Return command object or option argument value if present, otherwise nil
63
+ #
64
+ # The key is the name or identifier of the object or any any option
65
+ # alias. Eg. :f, '-f', :file, or '--file' are all usable as option keys
66
+ # and :cmd! or 'cmd' as command keys
67
+ #
68
+ # For options, the returned value is the argument given by the user
69
+ # optionally converted to Integer or Float or nil if the option doesn't
70
+ # take arguments. If the option takes an argument and it is repeatable
71
+ # the value is an array of the arguments. Repeatable options without
72
+ # arguments have the number of occurences as the value
73
+ #
74
+ def [](key)
75
+ case object = __grammar__[key]
76
+ when ::ShellOpts::Grammar::Command
77
+ object.ident == __subcommand__!.__ident__ ? __subcommand__! : nil
78
+ when ::ShellOpts::Grammar::Option
79
+ __options__[object.ident]
80
+ else
81
+ nil
82
+ end
83
+ end
84
+
85
+ # Assign a value to an existing option. This can be used to implement
86
+ # default values. #[]= doesn't currently check the type of the given
87
+ # value so take care. Note that the corresponding option(s) in
88
+ # #__option_list__ is not updated
89
+ def []=(key, value)
90
+ case object = __grammar__[key]
91
+ when ::ShellOpts::Grammar::Command
92
+ ::Kernel.raise ArgumentError, "#{key.inspect} is not an option"
93
+ when ::ShellOpts::Grammar::Option
94
+ object.argument? || object.repeatable? or
95
+ ::Kernel.raise ArgumentError, "#{key.inspect} is not assignable"
96
+ __options__[object.ident] = value
97
+ else
98
+ ::Kernel.raise ArgumentError, "Unknown option or command: #{key.inspect}"
99
+ end
100
+ end
101
+
102
+ # Return true if the given command or option is present
103
+ def key?(key)
104
+ case object = __grammar__[key]
105
+ when ::ShellOpts::Grammar::Command
106
+ object.ident == __subcommand__!.ident ? __subcommand__! : nil
107
+ when ::ShellOpts::Grammar::Option
108
+ __options__.key?(object.ident)
109
+ else
110
+ nil
111
+ end
112
+ end
113
+
114
+ # Subcommand identifier or nil if not present. #subcommand is often used in
115
+ # case statement to branch out to code that handles the given subcommand:
116
+ #
117
+ # prog, args = ShellOpts.parse("do_this! do_that!", ARGV)
118
+ # case prog.subcommand
119
+ # when :do_this!; prog.do_this.operation # or prog[:subcommand!] or prog.subcommand!
120
+ # when :do_that!; prog.do_that.operation
121
+ # end
122
+ #
123
+ # Note: Can be overridden by option, in that case use #__subcommand__ or
124
+ # ShellOpts.subcommand(object) instead
125
+ def subcommand() __subcommand__ end
126
+
127
+ # The subcommand object or nil if not present. Per-subcommand methods
128
+ # (#<identifier>!) are often used instead of #subcommand! to get the
129
+ # subcommand
130
+ #
131
+ # Note: Can be overridden by a subcommand declaration (but not an
132
+ # option), in that case use #__subcommand__! or
133
+ # ShellOpts.subcommand!(object) instead
134
+ #
135
+ def subcommand!() __subcommand__! end
136
+
137
+ # The parent command or nil. Initialized by #add_command
138
+ attr_accessor :__supercommand__
139
+
140
+ # UID of command/program
141
+ def __uid__() @__grammar__.uid end
142
+
143
+ # Identfier including the exclamation mark (Symbol)
144
+ def __ident__() @__grammar__.ident end
145
+
146
+ # Name of command/program without the exclamation mark (String)
147
+ def __name__() @__grammar__.name end
148
+
149
+ # Grammar object
150
+ attr_reader :__grammar__
151
+
152
+ # Hash from identifier to value. Can be Integer, Float, or String
153
+ # depending on the option's type. Repeated options options without
154
+ # arguments have the number of occurences as the value, with arguments
155
+ # the value is an array of the given values
156
+ attr_reader :__options__
157
+
158
+ # List of Option objects for the subcommand in the same order as
159
+ # given by the user but note that options are reordered to come after
160
+ # their associated subcommand if float is true. Repeated options are not
161
+ # collapsed
162
+ attr_reader :__option_list__
163
+
164
+ # The subcommand identifier (a Symbol incl. the exclamation mark) or nil
165
+ # if not present. Use #subcommand!, or the dynamically generated
166
+ # '#<identifier>!' method to get the actual subcommand object
167
+ def __subcommand__() @__subcommand__&.__ident__ end
168
+
169
+ # The actual subcommand object or nil if not present
170
+ def __subcommand__!() @__subcommand__ end
171
+
172
+ private
173
+ def __initialize__(grammar)
174
+ @__grammar__ = grammar
175
+ @__options__ = {}
176
+ @__option_list__ = []
177
+ @__options__ = {}
178
+ @__subcommand__ = nil
179
+
180
+ __define_option_methods__
181
+ end
182
+
183
+ def __define_option_methods__
184
+ @__grammar__.options.each { |opt|
185
+ next if opt.attr.nil?
186
+ if opt.argument? || opt.repeatable?
187
+ if opt.optional?
188
+ self.instance_eval %(
189
+ def #{opt.attr}(default = nil)
190
+ if @__options__.key?(:#{opt.attr})
191
+ @__options__[:#{opt.attr}] || default
192
+ else
193
+ nil
194
+ end
195
+ end
196
+ )
197
+ elsif !opt.argument?
198
+ self.instance_eval %(
199
+ def #{opt.attr}(default = nil)
200
+ if @__options__.key?(:#{opt.attr})
201
+ value = @__options__[:#{opt.attr}]
202
+ value == 0 ? default : value
203
+ else
204
+ nil
205
+ end
206
+ end
207
+ )
208
+ else
209
+ self.instance_eval("def #{opt.attr}() @__options__[:#{opt.attr}] end")
210
+ end
211
+ self.instance_eval("def #{opt.attr}=(value) @__options__[:#{opt.attr}] = value end")
212
+ @__options__[opt.attr] = 0 if !opt.argument?
213
+ end
214
+ self.instance_eval("def #{opt.attr}?() @__options__.key?(:#{opt.attr}) end")
215
+ }
216
+
217
+ @__grammar__.commands.each { |cmd|
218
+ next if cmd.attr.nil?
219
+ self.instance_eval %(
220
+ def #{cmd.attr}()
221
+ :#{cmd.attr} == __subcommand__ ? __subcommand__! : nil
222
+ end
223
+ )
224
+ }
225
+ end
226
+
227
+ def __add_option__(option)
228
+ ident = option.grammar.ident
229
+ @__option_list__ << option
230
+ if option.repeatable?
231
+ if option.argument?
232
+ (@__options__[ident] ||= []) << option.argument
233
+ else
234
+ @__options__[ident] ||= 0
235
+ @__options__[ident] += 1
236
+ end
237
+ else
238
+ @__options__[ident] = option.argument
239
+ end
240
+ end
241
+
242
+ def __add_command__(subcommand)
243
+ subcommand.__supercommand__ = self
244
+ @__subcommand__ = subcommand
245
+ end
246
+
247
+ def self.add_option(subcommand, option) subcommand.__send__(:__add_option__, option) end
248
+ def self.add_command(subcommand, cmd) subcommand.__send__(:__add_command__, cmd) end
249
+ end
250
+
251
+ # The top-level command
252
+ class Program < Command
253
+ end
254
+
255
+ # Option models an option as given by the user on the subcommand line.
256
+ # Compiled options (and possibly aggregated) options are stored in the
257
+ # Command#__options__ array
258
+ class Option
259
+ # Associated Grammar::Option object
260
+ attr_reader :grammar
261
+
262
+ # The actual name used on the shell command-line (String)
263
+ attr_reader :name
264
+
265
+ # Argument value or nil if not present. The value is a String, Integer,
266
+ # or Float depending the on the type of the option
267
+ attr_accessor :argument
268
+
269
+ forward_to :grammar,
270
+ :uid, :ident,
271
+ :repeatable?, :argument?, :integer?, :float?,
272
+ :file?, :enum?, :string?, :optional?,
273
+ :argument_name, :argument_type, :argument_enum
274
+
275
+ def initialize(grammar, name, argument)
276
+ @grammar, @name, @argument = grammar, name, argument
277
+ end
278
+ end
279
+ end