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.
- checksums.yaml +4 -4
- data/.gitignore +2 -1
- data/.ruby-version +1 -1
- data/README.md +201 -267
- data/TODO +46 -134
- data/doc/format.rb +95 -0
- data/doc/grammar.txt +27 -0
- data/doc/syntax.rb +110 -0
- data/doc/syntax.txt +10 -0
- data/lib/ext/array.rb +58 -5
- data/lib/ext/forward_to.rb +15 -0
- data/lib/ext/lcs.rb +34 -0
- data/lib/shellopts/analyzer.rb +130 -0
- data/lib/shellopts/ansi.rb +8 -0
- data/lib/shellopts/args.rb +29 -21
- data/lib/shellopts/argument_type.rb +139 -0
- data/lib/shellopts/dump.rb +158 -0
- data/lib/shellopts/formatter.rb +325 -0
- data/lib/shellopts/grammar.rb +375 -0
- data/lib/shellopts/interpreter.rb +103 -0
- data/lib/shellopts/lexer.rb +175 -0
- data/lib/shellopts/parser.rb +269 -82
- data/lib/shellopts/program.rb +279 -0
- data/lib/shellopts/renderer.rb +227 -0
- data/lib/shellopts/stack.rb +7 -0
- data/lib/shellopts/token.rb +44 -0
- data/lib/shellopts/version.rb +2 -2
- data/lib/shellopts.rb +439 -220
- data/main +1180 -0
- data/shellopts.gemspec +9 -15
- metadata +85 -42
- data/lib/main.rb +0 -1
- data/lib/shellopts/ast/command.rb +0 -41
- data/lib/shellopts/ast/node.rb +0 -37
- data/lib/shellopts/ast/option.rb +0 -21
- data/lib/shellopts/ast/program.rb +0 -14
- data/lib/shellopts/compiler.rb +0 -128
- data/lib/shellopts/generator.rb +0 -15
- data/lib/shellopts/grammar/command.rb +0 -80
- data/lib/shellopts/grammar/node.rb +0 -33
- data/lib/shellopts/grammar/option.rb +0 -66
- data/lib/shellopts/grammar/program.rb +0 -65
- data/lib/shellopts/idr.rb +0 -236
- data/lib/shellopts/main.rb +0 -10
- data/lib/shellopts/option_struct.rb +0 -148
- data/lib/shellopts/shellopts.rb +0 -123
@@ -0,0 +1,375 @@
|
|
1
|
+
module ShellOpts
|
2
|
+
module Grammar
|
3
|
+
# Except for #parent, #children, and #token, all members are initialized by calling
|
4
|
+
# #parse on the object
|
5
|
+
#
|
6
|
+
class Node
|
7
|
+
attr_reader :parent
|
8
|
+
attr_reader :children
|
9
|
+
attr_reader :token
|
10
|
+
|
11
|
+
# Note that in derived classes 'super' should be called after member
|
12
|
+
# initialization because Node#initialize calls #attach on the parent that
|
13
|
+
# may need to access the members
|
14
|
+
def initialize(parent, token)
|
15
|
+
constrain parent, Node, nil
|
16
|
+
constrain parent, nil, lambda { |node| ALLOWED_PARENTS[self.class].any? { |klass| node.is_a?(klass) } }
|
17
|
+
constrain token, Token
|
18
|
+
|
19
|
+
@parent = parent
|
20
|
+
@children = []
|
21
|
+
@token = token
|
22
|
+
@parent.send(:attach, self) if @parent
|
23
|
+
end
|
24
|
+
|
25
|
+
def traverse(*klasses, &block)
|
26
|
+
do_traverse(Array(klasses).flatten, &block)
|
27
|
+
end
|
28
|
+
|
29
|
+
def parents() parent ? [parent] + parent.parents : [] end
|
30
|
+
def ancestors() parents.reverse end
|
31
|
+
|
32
|
+
def inspect
|
33
|
+
"#{self.class}"
|
34
|
+
end
|
35
|
+
|
36
|
+
protected
|
37
|
+
def attach(child)
|
38
|
+
@children << child
|
39
|
+
end
|
40
|
+
|
41
|
+
def do_traverse(klasses, &block)
|
42
|
+
yield(self) if klasses.empty? || klasses.any? { |klass| self.is_a?(klass) }
|
43
|
+
children.each { |node| node.traverse(klasses, &block) }
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class IdrNode < Node
|
48
|
+
# Command of this object. This is different from #parent when a
|
49
|
+
# subcommand is nested on a higher level than its supercommand.
|
50
|
+
# Initialized by the analyzer
|
51
|
+
attr_reader :command
|
52
|
+
|
53
|
+
# Unique identifier of node (String) within the context of a program. nil
|
54
|
+
# for the Program object. It is the list of path elements concatenated
|
55
|
+
# with '.' and with internal '!' removed (eg. "cmd.opt" or "cmd.cmd!").
|
56
|
+
# Initialized by the parser
|
57
|
+
attr_reader :uid
|
58
|
+
|
59
|
+
# Path from Program object and down to this node. Array of identifiers.
|
60
|
+
# Empty for the Program object. Initialized by the parser
|
61
|
+
attr_reader :path
|
62
|
+
|
63
|
+
# Canonical identifier (Symbol) of the object
|
64
|
+
#
|
65
|
+
# For options, this is the canonical name of the objekt without the
|
66
|
+
# initial '-' or '--'. For commands it is the command name including the
|
67
|
+
# suffixed exclamation mark. Both options and commands have internal dashes
|
68
|
+
# replaced with underscores. Initialized by the parser
|
69
|
+
#
|
70
|
+
# Note that the analyzer fails with an an error if both --with-separator
|
71
|
+
# and --with_separator are used because they would both map to
|
72
|
+
# :with_separator
|
73
|
+
attr_reader :ident
|
74
|
+
|
75
|
+
# Canonical name (String) of the object
|
76
|
+
#
|
77
|
+
# This is the name of the object as the user sees it. For options it is
|
78
|
+
# the name of the first long option or the name of the first short option
|
79
|
+
# if there is no long option name. For commands it is the name without
|
80
|
+
# the exclamation mark. Initialized by the parser
|
81
|
+
attr_reader :name
|
82
|
+
|
83
|
+
# The associated attribute (Symbol) in the parent command object. nil if
|
84
|
+
# #ident is a reserved word. Initialized by the parser
|
85
|
+
attr_reader :attr
|
86
|
+
|
87
|
+
protected
|
88
|
+
def lookup(path)
|
89
|
+
path.empty? or raise ArgumentError, "Argument should be empty"
|
90
|
+
self
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Note that options are children of Command object but are attached to
|
95
|
+
# OptionGroup objects that in turn are attached to the command. This is
|
96
|
+
# done to be able to handle multiple options with common brief or
|
97
|
+
# descriptions
|
98
|
+
#
|
99
|
+
class Option < IdrNode
|
100
|
+
# Redefine command of this object
|
101
|
+
def command() parent.parent end # double-parent because options live in option groups
|
102
|
+
|
103
|
+
# Option group of this object
|
104
|
+
def group() parent end
|
105
|
+
|
106
|
+
# Short option identfiers
|
107
|
+
attr_reader :short_idents
|
108
|
+
|
109
|
+
# Long option identifiers
|
110
|
+
attr_reader :long_idents
|
111
|
+
|
112
|
+
# Short option names (including initial '-')
|
113
|
+
attr_reader :short_names
|
114
|
+
|
115
|
+
# Long option names (including initial '--')
|
116
|
+
attr_reader :long_names
|
117
|
+
|
118
|
+
# Identifiers of option. Include both short and long identifiers
|
119
|
+
def idents() short_idents + long_idents end
|
120
|
+
|
121
|
+
# Names of option. Includes both short and long option names
|
122
|
+
def names() short_names + long_names end # TODO: Should be in declaration order
|
123
|
+
|
124
|
+
# Name of argument or nil if not present
|
125
|
+
attr_reader :argument_name
|
126
|
+
|
127
|
+
# Type of argument (ArgType)
|
128
|
+
attr_reader :argument_type
|
129
|
+
|
130
|
+
# Enum values if argument type is an enumerator
|
131
|
+
def argument_enum() @argument_type.values end
|
132
|
+
|
133
|
+
def repeatable?() @repeatable end
|
134
|
+
def argument?() @argument end
|
135
|
+
def optional?() @optional end
|
136
|
+
|
137
|
+
def integer?() @argument_type.is_a? IntegerArgument end
|
138
|
+
def float?() @argument_type.is_a? FloatArgument end
|
139
|
+
def file?() @argument_type.is_a? FileArgument end
|
140
|
+
def enum?() @argument_type.is_a? EnumArgument end
|
141
|
+
def string?() argument? && !integer? && !float? && !file? && !enum? end
|
142
|
+
|
143
|
+
def match?(literal) argument_type.match?(literal) end
|
144
|
+
|
145
|
+
# Return true if the option can be assigned the given value
|
146
|
+
# def value?(value) ... end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Note that all public attributes are assigned by #attach
|
150
|
+
#
|
151
|
+
class OptionGroup < Node
|
152
|
+
alias_method :command, :parent
|
153
|
+
|
154
|
+
# Array of options in declaration order
|
155
|
+
attr_reader :options
|
156
|
+
|
157
|
+
# Brief description of option(s)
|
158
|
+
attr_reader :brief
|
159
|
+
|
160
|
+
# Description of option(s)
|
161
|
+
attr_reader :description
|
162
|
+
|
163
|
+
def initialize(parent, token)
|
164
|
+
@options = []
|
165
|
+
@brief = nil
|
166
|
+
@default_brief = nil
|
167
|
+
@description = []
|
168
|
+
super(parent, token)
|
169
|
+
end
|
170
|
+
|
171
|
+
protected
|
172
|
+
def attach(child)
|
173
|
+
super
|
174
|
+
case child
|
175
|
+
when Option; @options << child
|
176
|
+
when Brief; @brief = child
|
177
|
+
when Paragraph; @description << child
|
178
|
+
when Code; @description << child
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Note that except for :options, all public attributes are assigned by
|
184
|
+
# #attach. :options and the member variables supporting the #[] and #[]=
|
185
|
+
# methods are initialized by the analyzer
|
186
|
+
#
|
187
|
+
class Command < IdrNode
|
188
|
+
# Supercommand or nil if this is the top-level Program object.
|
189
|
+
# Initialized by the analyzer
|
190
|
+
attr_reader :supercommand
|
191
|
+
|
192
|
+
# Brief description of command
|
193
|
+
attr_accessor :brief
|
194
|
+
|
195
|
+
# Description of command. Array of Paragraph or Code objects. Initialized
|
196
|
+
# by the parser
|
197
|
+
attr_reader :description
|
198
|
+
|
199
|
+
# Array of option groups in declaration order. Initialized by the parser
|
200
|
+
# TODO: Rename 'groups'
|
201
|
+
attr_reader :option_groups
|
202
|
+
|
203
|
+
# Array of options in declaration order. Initialized by the analyzer
|
204
|
+
attr_reader :options
|
205
|
+
|
206
|
+
# Array of sub-commands. Initialized by the parser but edited by the analyzer
|
207
|
+
attr_reader :commands
|
208
|
+
|
209
|
+
# Array of Arg objects. Initialized by the parser
|
210
|
+
attr_reader :specs
|
211
|
+
|
212
|
+
# Array of ArgDescr objects. Initialized by the parser
|
213
|
+
attr_reader :descrs
|
214
|
+
|
215
|
+
def initialize(parent, token)
|
216
|
+
@brief = nil
|
217
|
+
@default_brief = nil
|
218
|
+
@description = []
|
219
|
+
@option_groups = []
|
220
|
+
@options = []
|
221
|
+
@options_hash = {} # Initialized by the analyzer
|
222
|
+
@commands = []
|
223
|
+
@commands_hash = {} # Initialized by the analyzer
|
224
|
+
@specs = []
|
225
|
+
@descrs = []
|
226
|
+
super
|
227
|
+
end
|
228
|
+
|
229
|
+
# Maps from any (sub-)path, name or identifier of an option or command (including the
|
230
|
+
# suffixed '!') to the associated option. #[] and #key? can't be used
|
231
|
+
# until after the analyze phase
|
232
|
+
def [](key)
|
233
|
+
case key
|
234
|
+
when String; lookup(key.split("."))
|
235
|
+
when Symbol; lookup(key.to_s.sub(".", "!.").split(".").map(&:to_sym))
|
236
|
+
when Array; lookup(key)
|
237
|
+
else
|
238
|
+
nil
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
def key?(key) !self.[](key).nil? end
|
243
|
+
|
244
|
+
# Mostly for debug. Has questional semantics because it only lists local keys
|
245
|
+
def keys() @options_hash.keys + @commands_hash.keys end
|
246
|
+
|
247
|
+
# Shorthand to get the associated Grammar::Command object from a Program
|
248
|
+
# or a Grammar::Command object
|
249
|
+
def self.command(obj)
|
250
|
+
constrain obj, Command, ::ShellOpts::Program
|
251
|
+
obj.is_a?(Command) ? obj : obj.__grammar__
|
252
|
+
end
|
253
|
+
|
254
|
+
protected
|
255
|
+
def attach(child)
|
256
|
+
super
|
257
|
+
case child
|
258
|
+
when OptionGroup; @option_groups << child
|
259
|
+
when Command; @commands << child
|
260
|
+
when ArgSpec; @specs << child
|
261
|
+
when ArgDescr; @descrs << child
|
262
|
+
when Brief; @brief = child
|
263
|
+
when Paragraph; @description << child
|
264
|
+
when Code; @description << child
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def lookup(path)
|
269
|
+
key = path[0]
|
270
|
+
if path.size > 1
|
271
|
+
@commands_hash[key]&.lookup(path[1..-1])
|
272
|
+
elsif path.size == 1
|
273
|
+
@commands_hash[key] || @options_hash[key]
|
274
|
+
else
|
275
|
+
self
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
class Program < Command
|
281
|
+
# Lifted from .gemspec. TODO
|
282
|
+
attr_reader :info
|
283
|
+
end
|
284
|
+
|
285
|
+
class ArgSpec < Node
|
286
|
+
# List of Arg objects (initialized by the analyzer)
|
287
|
+
alias_method :command, :parent
|
288
|
+
|
289
|
+
attr_reader :arguments
|
290
|
+
|
291
|
+
def initialize(parent, token)
|
292
|
+
@arguments = []
|
293
|
+
super
|
294
|
+
end
|
295
|
+
|
296
|
+
protected
|
297
|
+
def attach(child)
|
298
|
+
arguments << child if child.is_a?(Arg)
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
class Arg < Node
|
303
|
+
alias_method :spec, :parent
|
304
|
+
end
|
305
|
+
|
306
|
+
# DocNode object has no children but lines.
|
307
|
+
#
|
308
|
+
class DocNode < Node
|
309
|
+
# Array of :text tokens. Assigned by the parser
|
310
|
+
attr_reader :tokens
|
311
|
+
|
312
|
+
# The text of the node
|
313
|
+
def text() @text ||= tokens.map(&:source).join(" ") end
|
314
|
+
|
315
|
+
def lines() [text] end
|
316
|
+
|
317
|
+
def to_s() text end # FIXME
|
318
|
+
|
319
|
+
def initialize(parent, token, text = nil)
|
320
|
+
@tokens = [token]
|
321
|
+
@text = text
|
322
|
+
super(parent, token)
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
class ArgDescr < DocNode
|
327
|
+
alias_method :command, :parent
|
328
|
+
end
|
329
|
+
|
330
|
+
class Usage < ArgDescr
|
331
|
+
end
|
332
|
+
|
333
|
+
module WrappedNode
|
334
|
+
using Ext::Array::Wrap
|
335
|
+
def words() @words ||= text.split(" ") end
|
336
|
+
def lines(width, initial = 0) @lines ||= words.wrap(width, initial) end
|
337
|
+
end
|
338
|
+
|
339
|
+
class Brief < DocNode
|
340
|
+
include WrappedNode
|
341
|
+
alias_method :subject, :parent # Either a command or an option
|
342
|
+
end
|
343
|
+
|
344
|
+
class Paragraph < DocNode
|
345
|
+
include WrappedNode
|
346
|
+
alias_method :subject, :parent # Either a command or an option
|
347
|
+
end
|
348
|
+
|
349
|
+
class Code < DocNode
|
350
|
+
def text() @text ||= lines.join("\n") end
|
351
|
+
def lines() @lines ||= tokens.map { |t| " " * [t.charno - token.charno, 0].max + t.source } end
|
352
|
+
end
|
353
|
+
|
354
|
+
class Section < Node
|
355
|
+
def name() token.source end
|
356
|
+
end
|
357
|
+
|
358
|
+
class Node
|
359
|
+
ALLOWED_PARENTS = {
|
360
|
+
Program => [NilClass],
|
361
|
+
Command => [Command],
|
362
|
+
OptionGroup => [Command],
|
363
|
+
Option => [OptionGroup],
|
364
|
+
ArgSpec => [Command],
|
365
|
+
Arg => [ArgSpec],
|
366
|
+
ArgDescr => [Command],
|
367
|
+
Brief => [Command, OptionGroup, ArgSpec, ArgDescr],
|
368
|
+
Paragraph => [Command, OptionGroup],
|
369
|
+
Code => [Command, OptionGroup],
|
370
|
+
Section => [Program]
|
371
|
+
}
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
@@ -0,0 +1,103 @@
|
|
1
|
+
|
2
|
+
module ShellOpts
|
3
|
+
class Interpreter
|
4
|
+
attr_reader :expr
|
5
|
+
attr_reader :args
|
6
|
+
|
7
|
+
def initialize(grammar, argv, float: true, exception: false)
|
8
|
+
constrain grammar, Grammar::Program
|
9
|
+
constrain argv, [String]
|
10
|
+
@grammar, @argv = grammar, argv.dup
|
11
|
+
@float, @exception = float, exception
|
12
|
+
end
|
13
|
+
|
14
|
+
def interpret
|
15
|
+
@expr = command = Program.new(@grammar)
|
16
|
+
@seen = {} # Set of seen options by UID (using UID is needed when float is true)
|
17
|
+
@args = []
|
18
|
+
|
19
|
+
while arg = @argv.shift
|
20
|
+
if arg == "--"
|
21
|
+
break
|
22
|
+
elsif arg.start_with?("-")
|
23
|
+
interpret_option(command, arg)
|
24
|
+
elsif @args.empty? && subcommand_grammar = command.__grammar__[:"#{arg}!"]
|
25
|
+
command = Command.add_command(command, Command.new(subcommand_grammar))
|
26
|
+
else
|
27
|
+
if @float
|
28
|
+
@args << arg # This also signals that no more commands are accepted
|
29
|
+
else
|
30
|
+
@argv.unshift arg
|
31
|
+
break
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
[@expr, Args.new(@args + @argv)]
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.interpret(grammar, argv, **opts)
|
39
|
+
self.new(grammar, argv, **opts).interpret
|
40
|
+
end
|
41
|
+
|
42
|
+
protected
|
43
|
+
# Lookup option in the command hierarchy and return pair of command and
|
44
|
+
# option associated command. Raise if not found
|
45
|
+
#
|
46
|
+
def find_option(command, name)
|
47
|
+
while command && (option = command.__grammar__[name]).nil?
|
48
|
+
command = command.__supercommand__
|
49
|
+
end
|
50
|
+
option or error "Unknown option '#{name}'"
|
51
|
+
[command, option]
|
52
|
+
end
|
53
|
+
|
54
|
+
def interpret_option(command, option)
|
55
|
+
# Split into name and argument
|
56
|
+
case option
|
57
|
+
when /^(--.+?)(?:=(.*))?$/
|
58
|
+
name, value, short = $1, $2, false
|
59
|
+
when /^(-.)(.+)?$/
|
60
|
+
name, value, short = $1, $2, true
|
61
|
+
end
|
62
|
+
|
63
|
+
option_command, option = find_option(command, name)
|
64
|
+
!@seen.key?(option.uid) || option.repeatable? or error "Duplicate option '#{name}'"
|
65
|
+
@seen[option.uid] = true
|
66
|
+
|
67
|
+
# Process argument
|
68
|
+
if option.argument?
|
69
|
+
if value.nil? && !option.optional?
|
70
|
+
if !@argv.empty?
|
71
|
+
value = @argv.shift
|
72
|
+
else
|
73
|
+
error "Missing argument for option '#{name}'"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
value &&= interpret_option_value(option, name, value)
|
77
|
+
elsif value && short
|
78
|
+
@argv.unshift("-#{value}")
|
79
|
+
value = nil
|
80
|
+
elsif !value.nil?
|
81
|
+
error "No argument allowed for option '#{opt_name}'"
|
82
|
+
end
|
83
|
+
|
84
|
+
Command.add_option(option_command, Option.new(option, name, value))
|
85
|
+
end
|
86
|
+
|
87
|
+
def interpret_option_value(option, name, value)
|
88
|
+
type = option.argument_type
|
89
|
+
if type.match?(name, value)
|
90
|
+
type.convert(value)
|
91
|
+
elsif value == ""
|
92
|
+
nil
|
93
|
+
else
|
94
|
+
error type.message
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def error(msg)
|
99
|
+
raise Error, msg
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
@@ -0,0 +1,175 @@
|
|
1
|
+
|
2
|
+
module ShellOpts
|
3
|
+
class Line
|
4
|
+
attr_reader :source
|
5
|
+
attr_reader :lineno
|
6
|
+
attr_reader :charno
|
7
|
+
attr_reader :text
|
8
|
+
|
9
|
+
def initialize(lineno, charno, source)
|
10
|
+
@lineno, @source = lineno, source
|
11
|
+
@charno = charno + ((@source =~ /(\S.*?)\s*$/) || 0)
|
12
|
+
@text = $1 || ""
|
13
|
+
end
|
14
|
+
|
15
|
+
def blank?() @text == "" end
|
16
|
+
|
17
|
+
forward_to :@text, :=~, :!~
|
18
|
+
|
19
|
+
# Split on whitespace while keeping track of character position. Returns
|
20
|
+
# array of char, word tuples
|
21
|
+
def words
|
22
|
+
return @words if @words
|
23
|
+
@words = []
|
24
|
+
charno = self.charno
|
25
|
+
text.scan(/(\s*)(\S*)/)[0..-2].each { |spaces, word|
|
26
|
+
charno += spaces.size
|
27
|
+
@words << [charno, word] if word != ""
|
28
|
+
charno += word.size
|
29
|
+
}
|
30
|
+
@words
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s() text end
|
34
|
+
def dump() puts "#{lineno}:#{charno} #{text.inspect}" end
|
35
|
+
end
|
36
|
+
|
37
|
+
class Lexer
|
38
|
+
COMMAND_RE = /[a-z][a-z._-]*!/
|
39
|
+
|
40
|
+
DECL_RE = /^(?:-|--|\+|\+\+|(?:@(?:\s|$))|(?:[^\\!]\S*!(?:\s|$)))/
|
41
|
+
|
42
|
+
# Match ArgSpec argument words. TODO
|
43
|
+
SPEC_RE = /^[^a-z]{2,}$/
|
44
|
+
|
45
|
+
# Match ArgDescr words (should be at least two characters long)
|
46
|
+
DESCR_RE = /^[^a-z]{2,}$/
|
47
|
+
|
48
|
+
SECTIONS = %w(DESCRIPTION OPTIONS COMMANDS)
|
49
|
+
|
50
|
+
using Ext::Array::ShiftWhile
|
51
|
+
|
52
|
+
attr_reader :name # Name of program
|
53
|
+
attr_reader :source
|
54
|
+
attr_reader :tokens
|
55
|
+
|
56
|
+
def oneline?() @oneline end
|
57
|
+
|
58
|
+
def initialize(name, source, oneline)
|
59
|
+
@name = name
|
60
|
+
@source = source
|
61
|
+
@oneline = oneline
|
62
|
+
@source += "\n" if @source[-1] != "\n" # Always terminate source with a newline
|
63
|
+
end
|
64
|
+
|
65
|
+
def lex(lineno = 1, charno = 1)
|
66
|
+
# Split source into lines and tag them with lineno and charno. Only the
|
67
|
+
# first line can have charno != 1
|
68
|
+
lines = source[0..-2].split("\n").map.with_index { |line,i|
|
69
|
+
l = Line.new(i + lineno, charno, line)
|
70
|
+
charno = 1
|
71
|
+
l
|
72
|
+
}
|
73
|
+
|
74
|
+
# Skip initial comments and blank lines and compute indent level
|
75
|
+
lines.shift_while { |line| line.text == "" || line.text.start_with?("#") && line.charno == 1 }
|
76
|
+
initial_indent = lines.first&.charno
|
77
|
+
|
78
|
+
# Create program token. The source is the program name
|
79
|
+
@tokens = [Token.new(:program, 0, 0, name)]
|
80
|
+
|
81
|
+
# Used to detect code blocks
|
82
|
+
last_nonblank = @tokens.first
|
83
|
+
|
84
|
+
# Process lines
|
85
|
+
while line = lines.shift
|
86
|
+
# Pass-trough blank lines
|
87
|
+
if line.to_s == ""
|
88
|
+
@tokens << Token.new(:blank, line.lineno, line.charno, "")
|
89
|
+
next
|
90
|
+
end
|
91
|
+
|
92
|
+
# Ignore meta comments
|
93
|
+
if line.charno < initial_indent
|
94
|
+
next if line =~ /^#/
|
95
|
+
error_token = Token.new(:text, line.lineno, 0, "")
|
96
|
+
lexer_error error_token, "Illegal indentation"
|
97
|
+
end
|
98
|
+
|
99
|
+
# Line without escape sequences
|
100
|
+
source = line.text[(line.text =~ /^\\/ ? 1 : 0)..-1]
|
101
|
+
|
102
|
+
# Code lines
|
103
|
+
if last_nonblank.kind == :text && line.charno > last_nonblank.charno && line !~ DECL_RE
|
104
|
+
@tokens << Token.new(:text, line.lineno, line.charno, source)
|
105
|
+
lines.shift_while { |line| line.blank? || line.charno > last_nonblank.charno }.each { |line|
|
106
|
+
kind = (line.blank? ? :blank : :text)
|
107
|
+
@tokens << Token.new(kind, line.lineno, line.charno, line.text)
|
108
|
+
}
|
109
|
+
|
110
|
+
# Sections
|
111
|
+
elsif SECTIONS.include?(line.text)
|
112
|
+
@tokens << Token.new(:section, line.lineno, line.charno, line.text)
|
113
|
+
|
114
|
+
# Options, commands, usage, arguments, and briefs
|
115
|
+
elsif line =~ DECL_RE
|
116
|
+
words = line.words
|
117
|
+
while (charno, word = words.shift)
|
118
|
+
case word
|
119
|
+
when "@"
|
120
|
+
if words.empty?
|
121
|
+
error_token = Token.new(:text, line.lineno, charno, "@")
|
122
|
+
lexer_error error_token, "Empty '@' declaration"
|
123
|
+
end
|
124
|
+
source = words.shift_while { true }.map(&:last).join(" ")
|
125
|
+
@tokens << Token.new(:brief, line.lineno, charno, source)
|
126
|
+
when "--" # FIXME Rename argdescr
|
127
|
+
@tokens << Token.new(:usage, line.lineno, charno, "--")
|
128
|
+
source = words.shift_while { |_,w| w =~ DESCR_RE }.map(&:last).join(" ")
|
129
|
+
@tokens << Token.new(:usage_string, line.lineno, charno, source)
|
130
|
+
when "++" # FIXME Rename argspec
|
131
|
+
@tokens << Token.new(:spec, line.lineno, charno, "++")
|
132
|
+
words.shift_while { |c,w|
|
133
|
+
w =~ SPEC_RE and @tokens << Token.new(:argument, line.lineno, c, w)
|
134
|
+
}
|
135
|
+
when /^-|\+/
|
136
|
+
@tokens << Token.new(:option, line.lineno, charno, word)
|
137
|
+
when /!$/
|
138
|
+
@tokens << Token.new(:command, line.lineno, charno, word)
|
139
|
+
else
|
140
|
+
source = [word, words.shift_while { |_,w| w !~ DECL_RE }.map(&:last)].flatten.join(" ")
|
141
|
+
@tokens << Token.new(:brief, line.lineno, charno, source)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# TODO: Move to parser and remove @oneline from Lexer
|
146
|
+
(token = @tokens.last).kind != :brief || !oneline? or
|
147
|
+
lexer_error token, "Briefs are only allowed in multi-line specifications"
|
148
|
+
|
149
|
+
# Paragraph lines
|
150
|
+
else
|
151
|
+
@tokens << Token.new(:text, line.lineno, line.charno, source)
|
152
|
+
end
|
153
|
+
# FIXME Not sure about this
|
154
|
+
# last_nonblank = @tokens.last
|
155
|
+
last_nonblank = @tokens.last if ![:blank, :usage_string, :argument].include? @tokens.last.kind
|
156
|
+
end
|
157
|
+
|
158
|
+
# Move arguments and briefs before first command if one-line source
|
159
|
+
# if oneline? && cmd_index = @tokens.index { |token| token.kind == :command }
|
160
|
+
# @tokens =
|
161
|
+
# @tokens[0...cmd_index] +
|
162
|
+
# @tokens[cmd_index..-1].partition { |token| ![:command, :option].include?(token.kind) }.flatten
|
163
|
+
# end
|
164
|
+
|
165
|
+
@tokens
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.lex(name, source, oneline, lineno = 1, charno = 1)
|
169
|
+
Lexer.new(name, source, oneline).lex(lineno, charno)
|
170
|
+
end
|
171
|
+
|
172
|
+
def lexer_error(token, message) raise LexerError.new(token), message end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|