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.
- checksums.yaml +4 -4
- data/.gitignore +2 -1
- data/.ruby-version +1 -1
- data/README.md +201 -267
- data/TODO +37 -5
- 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 +62 -0
- 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 +25 -15
- data/lib/shellopts/argument_type.rb +139 -0
- data/lib/shellopts/dump.rb +158 -0
- data/lib/shellopts/formatter.rb +292 -92
- 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 +293 -0
- 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 +1 -1
- data/lib/shellopts.rb +359 -3
- data/main +1180 -0
- data/shellopts.gemspec +8 -14
- metadata +86 -41
- data/lib/ext/algorithm.rb +0 -14
- data/lib/ext/ruby_env.rb +0 -8
- data/lib/shellopts/ast/command.rb +0 -112
- data/lib/shellopts/ast/dump.rb +0 -28
- data/lib/shellopts/ast/option.rb +0 -15
- data/lib/shellopts/ast/parser.rb +0 -106
- data/lib/shellopts/constants.rb +0 -88
- data/lib/shellopts/exceptions.rb +0 -21
- data/lib/shellopts/grammar/analyzer.rb +0 -76
- data/lib/shellopts/grammar/command.rb +0 -87
- data/lib/shellopts/grammar/dump.rb +0 -56
- data/lib/shellopts/grammar/lexer.rb +0 -56
- data/lib/shellopts/grammar/option.rb +0 -55
- data/lib/shellopts/grammar/parser.rb +0 -78
@@ -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 += @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
|
+
|