birman 0.2.1 → 0.3.0

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.
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # [file]
4
+ # purpose = "The combinators available inside every rule body"
5
+ # responsibility = "Building matchers: raw strings and patterns, literals, sequences, choices, lists, wrappers"
6
+ # dependencies = ["Birman::Matcher", "matchers.rb (Seq, Choice, Pick, SepBy, Infix, Lexeme)"]
7
+ # pattern = "Parser combinators (PEG)"
3
8
  module Birman
4
9
  # Marker for seq: everything after it is committed (see Matcher#>).
5
10
  COMMIT = Object.new.tap { |o| def o.inspect = "Birman::COMMIT" }.freeze
@@ -15,36 +20,78 @@ module Birman
15
20
  # a rule body, after >> or |) is a literal: it skips trailing spacing
16
21
  # when the grammar declares `ignore`, and word-like ones are keywords.
17
22
  # Use str and match for exact, raw matching.
23
+ # [module.Combinators]
24
+ # purpose = "Matcher-building methods mixed into every grammar (via Birman::Grammar)"
25
+ # input = "Strings (literals), Regexps (raw patterns) and matchers"
26
+ # output = "Immutable matcher objects; nothing is matched until Grammar#parse"
27
+ # notes = "Method names here are reserved: a rule can't share one"
18
28
  module Combinators
19
29
  # --- Raw matchers ---
20
30
 
21
- def str(s) = Str.new(s)
31
+ # [method.str]
32
+ # params = ["s: String"]
33
+ # returns = "Matcher that matches exactly s; never skips spacing, never a keyword"
34
+ # value = "s"
35
+ def str(s) = Str.new(s)
36
+
37
+ # [method.match]
38
+ # params = ["re: Regexp (anchored at the current position; \\A, ^ and lookbehind see the whole input)"]
39
+ # returns = "Matcher for the regexp; raw, nothing skipped"
40
+ # value = "the matched text"
22
41
  def match(re) = Pat.new(re)
23
- def any = Pat.new(/./m)
24
- def eof = any.absent.label("end of input")
25
- def never = Pat.new(/(?!)/)
42
+
43
+ # [method.any]
44
+ # returns = "Matcher for any one character, newlines included"
45
+ def any = Pat.new(/./m)
46
+
47
+ # [method.eof]
48
+ # returns = "Matcher that succeeds only at the end of the input, consuming nothing"
49
+ def eof = any.absent.label("end of input")
50
+
51
+ # [method.never]
52
+ # returns = "Matcher that always fails (a placeholder for an empty choice)"
53
+ def never = Pat.new(/(?!)/)
26
54
 
27
55
  # A whole word: "if" matches in "if x" but not in "iffy".
28
- def keyword(word) = (str(word) >> match(/\w/).absent).map(&:first).label(word.inspect)
56
+ # [method.keyword]
57
+ # params = ["word: String"]
58
+ # returns = "Matcher for word when not followed by a word character"
59
+ # value = "word"
60
+ def keyword(word) = Pick.new([str(word), match(/\w/).absent], 0).label(word.inspect)
29
61
 
30
62
  # --- Literals and the readable forms ---
31
63
 
32
64
  # Anything usable as a matcher: strings become literals, regexps
33
65
  # raw patterns, and matchers pass through unchanged.
66
+ # [method.lit]
67
+ # params = ["x: String | Regexp | Matcher"]
68
+ # returns = "A matcher: String -> literal, Regexp -> raw pattern, matcher unchanged"
69
+ # raises = ["ArgumentError (anything else, or only(...) outside seq)"]
34
70
  def lit(x) = Birman.coerce(x)
35
71
 
36
72
  # The matcher a literal stands for: "let" is a keyword, "=" an exact
37
- # string, either way followed by the grammar's ignore rule. Built
38
- # once per grammar instance.
73
+ # string, either way followed by the ignore rule of the grammar
74
+ # running it. Built once per grammar instance.
75
+ # [method.literal_matcher]
76
+ # params = ["text: String"]
77
+ # returns = "The matcher a literal stands for: keyword or exact string, then the ignore rule"
78
+ # side_effects = ["Caches the matcher on the grammar instance"]
79
+ # notes = "Called by Lit at match time; rarely needed directly"
39
80
  def literal_matcher(text)
40
81
  (@literal_matchers ||= {})[text] ||= begin
41
82
  base = text.match?(/\A\w+\z/) ? keyword(text) : str(text)
42
- Lexeme.new(base, ignore_rule, text)
83
+ Lexeme.new(base, text)
43
84
  end
44
85
  end
45
86
 
46
87
  # Sequence. Put `commit` where the construct becomes unambiguous:
47
88
  # seq("let", commit, name.as(:name), "=", expr.as(:value))
89
+ # [method.seq]
90
+ # params = ["*items: String | Regexp | Matcher | commit | only(...)"]
91
+ # returns = "Matcher for the items in order"
92
+ # value = "Array of item values; a Hash of the .as-named parts if any; the only(...) part's value if used"
93
+ # raises = ["ArgumentError (commit first; more than one only; only mixed with .as names)",
94
+ # "ArgumentError (a name used twice; a nested seq's names the value would drop)"]
48
95
  def seq(*items)
49
96
  commit_at = items.index(COMMIT)
50
97
  raise ArgumentError, "commit can't come first in a seq: nothing has matched yet" if commit_at&.zero?
@@ -54,38 +101,76 @@ module Birman
54
101
  Seq.new(parts.map { _1.is_a?(Only) ? _1.matcher : lit(_1) }, commit_at, only_at)
55
102
  end
56
103
 
104
+ # [method.commit]
105
+ # returns = "Marker for seq: if anything after it fails, the whole parse fails there"
106
+ # notes = "Lookaheads contain a commit failure; rep and maybe don't"
57
107
  def commit = COMMIT
58
108
 
59
109
  # Inside seq, makes this part the seq's whole value; the rest still
60
110
  # has to match but is dropped:
61
111
  # seq("[", commit, only(sep_by(value, ",")), "]") # => [...]
112
+ # [method.only]
113
+ # params = ["x: String | Regexp | Matcher"]
114
+ # returns = "Marker for seq making x's value the seq's whole value"
115
+ # notes = "x's .as name, if any, passes to the enclosing seq"
62
116
  def only(x) = Only.new(lit(x))
63
117
 
64
118
  # Ordered choice: the first alternative that matches wins.
119
+ # [method.either]
120
+ # params = ["*alts: String | Regexp | Matcher"]
121
+ # returns = "Ordered choice: the first alternative that matches wins"
122
+ # value = "The winning alternative's value (a name all the alternatives share passes outward)"
65
123
  def either(*alts) = alts.size == 1 ? lit(alts.first) : Choice.new(alts.map { lit(_1) })
66
124
 
67
125
  # One of several literals. Given a hash, each literal produces its
68
126
  # value: one_of("true" => true, "null" => nil). They're tried in
69
127
  # order, so put longer ones first ("<=" before "<").
128
+ # [method.one_of]
129
+ # params = ["*choices: String literals", "or one Hash of literal => value"]
130
+ # returns = "Ordered choice of the literals"
131
+ # value = "The matched literal, or its Hash value"
70
132
  def one_of(*choices)
71
133
  return either(*choices) unless choices in [Hash => table]
72
134
 
73
- either(*table.map { |text, value| lit(text).map { value } })
135
+ either(*table.map { |text, value| lit(text).means(value) })
74
136
  end
75
137
 
76
138
  # Left-associative binary operators, folded into nodes:
77
139
  # infix(product, "+", "-", node: BinOp) # BinOp = node(:op, :left, :right)
140
+ # [method.infix]
141
+ # params = ["operand: Matcher", "*ops: String literals", "node: node class with op, left, right fields"]
142
+ # returns = "Matcher for operand (op operand)*, folded left-associatively"
143
+ # value = "operand's value, or nested node instances with spans"
144
+ # raises = ["ArgumentError at parse time if node lacks op/left/right"]
78
145
  def infix(operand, *ops, node:) = Infix.new(Birman.coerce(operand), either(*ops).label("operator"), node)
79
146
 
80
147
  # One or more items separated by sep; value is an array of items.
81
- def sep_by1(item, sep) = SepBy.new(lit(item), lit(sep))
82
-
83
- # Zero or more items separated by sep.
84
- def sep_by(item, sep) = sep_by1(item, sep).maybe.map { _1 || [] }
148
+ # With trailing: true, one separator may follow the last item:
149
+ # seq("[", only(sep_by(value, ",", trailing: true)), "]") # [1, 2,]
150
+ # [method.sep_by1]
151
+ # params = ["item: String | Regexp | Matcher", "sep: String | Regexp | Matcher", "trailing: Boolean = false"]
152
+ # returns = "Matcher for one or more items separated by sep (and, if trailing, maybe one more sep)"
153
+ # value = "Array of item values (item's .as name passes outward)"
154
+ def sep_by1(item, sep, trailing: false) = SepBy.new(lit(item), lit(sep), 1, trailing)
155
+
156
+ # Zero or more items separated by sep. A trailing separator needs an
157
+ # item before it: "," alone isn't an empty list.
158
+ # [method.sep_by]
159
+ # params = ["item: String | Regexp | Matcher", "sep: String | Regexp | Matcher", "trailing: Boolean = false"]
160
+ # returns = "Matcher for zero or more items separated by sep (and, if trailing, maybe one more sep)"
161
+ # value = "Array of item values, [] when there are none"
162
+ def sep_by(item, sep, trailing: false) = SepBy.new(lit(item), lit(sep), 0, trailing)
85
163
 
86
164
  # inner wrapped in open/close; value is inner's.
165
+ # [method.between]
166
+ # params = ["open: String | Matcher", "close: String | Matcher", "inner: Matcher"]
167
+ # returns = "Matcher for open inner close"
168
+ # value = "inner's value (inner's .as name passes outward)"
87
169
  def between(open, close, inner) = Pick.new([lit(open), Birman.coerce(inner), lit(close)], 1)
88
170
 
171
+ # [method.parens]
172
+ # params = ["inner: Matcher"]
173
+ # returns = "between(\"(\", \")\", inner)"
89
174
  def parens(inner) = between("(", ")", inner)
90
175
 
91
176
  private
@@ -99,4 +184,11 @@ module Birman
99
184
 
100
185
  def ignore_rule = self.class.respond_to?(:ignore) ? self.class.ignore : nil
101
186
  end
187
+
188
+ # Birman.seq, Birman.str and the rest, for building matchers outside a
189
+ # grammar (see Birman.parse). literal_matcher stays private: it's what
190
+ # a grammar runs for a literal, and Birman isn't one.
191
+ extend Combinators
192
+
193
+ singleton_class.send(:private, :literal_matcher)
102
194
  end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Birman
4
+ class Compiler
5
+ # Copies the grammar methods that .action(:name) and .check(:name) call into the
6
+ # generated Parser, read from their source with Prism, together with
7
+ # the grammar methods they call and the constants they read (as
8
+ # literals, looked up lexically from where the method was written).
9
+ #
10
+ # Copied as written, so Integer(), String#to_i, #hex and #oct, which
11
+ # in a Spinel binary raise past 63 bits (or give 2**63 - 1, when the
12
+ # receiver's type isn't known), are refused (see Overflows):
13
+ # large_integer (Grammar's, and the generated parser's) is the way to
14
+ # read a number.
15
+ # [class.Compiler.Actions]
16
+ # purpose = "Copies .action and .check methods, their helpers and constants, into the generated parser"
17
+ # dependencies = ["prism (bundled from Ruby 3.3; a gem on 3.2), loaded on first use"]
18
+ class Actions
19
+ # Methods of the generated Parser and its runtime base class that a
20
+ # copied method must not replace.
21
+ RESERVED = /\A(?:rule_|ref_|lit_|init_|skip_|str_)/
22
+ RUNTIME_METHODS = %i[
23
+ initialize input parse reset skip_ignored fail_at merge commit_failure ignored_after token_end span_end
24
+ span failure failure_because invalid_input label_end check_end peek_end bytes_at? abandon left_recursion
25
+ large_integer
26
+ ].freeze
27
+
28
+ # Methods the generated parser's runtime provides as Birman::Grammar
29
+ # does, which copied methods may call and actions may name.
30
+ PROVIDED = %i[large_integer].freeze
31
+
32
+ # The constant paths copied methods may read: Float::INFINITY and
33
+ # Float::NAN, which have no literal.
34
+ FLOAT_CONSTANTS = %i[INFINITY NAN].freeze
35
+
36
+ def initialize(grammar)
37
+ @grammar = grammar
38
+ @methods = {}
39
+ @constants = CopiedConstants.new
40
+ @trees = {}
41
+ end
42
+
43
+ # Copies grammar method name (and what it needs), once.
44
+ # form: "action" or "check", for the error if there's no such method.
45
+ def copy(name, rule, form = "action")
46
+ return if @methods.key?(name) || provided?(name)
47
+
48
+ @methods[name] = nil # reserved first: methods can call each other
49
+ @methods[name] = method_source(find(name, rule, form), rule)
50
+ end
51
+
52
+ def any? = !@methods.empty?
53
+
54
+ def constants = @constants.sources
55
+
56
+ def constant_names = @constants.names
57
+
58
+ def methods = @methods.values
59
+
60
+ def names = @methods.keys
61
+
62
+ private
63
+
64
+ def find(name, rule, form)
65
+ unless @grammar.respond_to?(name, true)
66
+ raise Error, "rule :#{rule} uses .#{form}(:#{name}), but the grammar has no method #{name}"
67
+ end
68
+ if RESERVED.match?(name) || RUNTIME_METHODS.include?(name)
69
+ raise Error, "the grammar method #{name} would clash with the generated parser's own #{name}"
70
+ end
71
+
72
+ method = @grammar.method(name)
73
+ file, line = method.source_location
74
+ raise Error, "rule :#{rule}: can't find the source of #{name}" unless file && File.exist?(file)
75
+
76
+ MethodFinder.new(name, line).find(tree(file)) ||
77
+ raise(Error, "rule :#{rule}: #{name} isn't written with def, so it can't be copied")
78
+ end
79
+
80
+ def tree(file) = @trees[file] ||= MethodFinder.parse(file)
81
+
82
+ def method_source(found, rule)
83
+ node, scopes = found
84
+ walk(node) { visit(_1, scopes, rule) }
85
+ MethodFinder.source(node)
86
+ end
87
+
88
+ def visit(node, scopes, rule)
89
+ case node
90
+ when Prism::ConstantReadNode then @constants.read(node.name, scopes, rule)
91
+ when Prism::ConstantPathNode then constant_path(node, rule)
92
+ when Prism::SuperNode, Prism::ForwardingSuperNode
93
+ raise Error, "rule :#{rule}: copied methods can't call super"
94
+ when Prism::BlockArgumentNode then Overflows.check_block(node, rule)
95
+ when Prism::CallNode then call(node, rule)
96
+ end
97
+ end
98
+
99
+ # Float::INFINITY and Float::NAN, which the generated parser reads
100
+ # as written (its Float is checked like any core class's).
101
+ def constant_path(node, rule)
102
+ float = node.parent.is_a?(Prism::ConstantReadNode) && node.parent.name == :Float
103
+ return if float && FLOAT_CONSTANTS.include?(node.name)
104
+
105
+ raise Error, "rule :#{rule}: copied methods can't read #{node.slice}; use a constant of the grammar"
106
+ end
107
+
108
+ def call(node, rule)
109
+ if Overflows::DISPATCH.include?(node.name) then dispatch(node, rule)
110
+ elsif self_call?(node) then own_call(node.name, rule)
111
+ else Overflows.check_call(node, rule)
112
+ end
113
+ end
114
+
115
+ def self_call?(node) = node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
116
+
117
+ # A call on self (written or not): a grammar method is copied too;
118
+ # Birman's own API (rules, keywords...) isn't there to call, and
119
+ # Kernel's Integer overflows.
120
+ def own_call(name, rule)
121
+ return if provided?(name)
122
+ raise Error, "rule :#{rule}: copied methods can't call the grammar's #{name}" if birman_api?(name)
123
+ return copy(name, rule) if grammar_method?(name)
124
+
125
+ Overflows.check(name, rule)
126
+ end
127
+
128
+ # send(:name), method(:name)...: checked as a call of name would be.
129
+ def dispatch(node, rule)
130
+ name = Overflows.dispatched(node, rule)
131
+ self_call?(node) ? own_call(name, rule) : Overflows.check(name, rule)
132
+ end
133
+
134
+ # Birman::Grammar's own, not one the grammar defines over it.
135
+ def provided?(name)
136
+ PROVIDED.include?(name) && @grammar.method(name).owner == Birman::Grammar
137
+ end
138
+
139
+ # A method of the grammar's, or of a rule module's (Rules::Strings'
140
+ # decode_escape): not Ruby's, and not part of Birman's grammar API.
141
+ def grammar_method?(name)
142
+ return false unless @grammar.respond_to?(name, true)
143
+
144
+ ![Kernel, Object, BasicObject].include?(@grammar.method(name).owner)
145
+ end
146
+
147
+ # Rules (which return matchers) and the methods Birman::Grammar and
148
+ # Combinators give every grammar.
149
+ def birman_api?(name)
150
+ return false unless @grammar.respond_to?(name, true)
151
+ return true if @grammar.respond_to?(:"__rule_#{name}", true)
152
+
153
+ owner = @grammar.method(name).owner
154
+ [Birman::Grammar, Birman::Combinators].include?(owner)
155
+ end
156
+
157
+ def walk(node, &)
158
+ yield node
159
+ node.compact_child_nodes.each { walk(_1, &) }
160
+ end
161
+ end
162
+
163
+ # Methods that raise past 63 bits in a Spinel binary (or, on a receiver
164
+ # Spinel can't type, silently give 2**63 - 1), refused in copied
165
+ # methods, by call or by name (map(&:to_i), send(:hex), method(:Integer)).
166
+ # Whether a receiver is a String can't be told, so it's taken to be one;
167
+ # only to_i on an Integer literal is let through. A net for the common
168
+ # forms, not a proof: Float(t).round overflows too, maybe silently.
169
+ module Overflows
170
+ INTEGER = "Integer()/to_i overflow past 63 bits in a Spinel binary; use large_integer(...)"
171
+ MESSAGES = {
172
+ Integer: INTEGER, to_i: INTEGER,
173
+ hex: "hex overflows past 63 bits in a Spinel binary; use large_integer(text, 16)",
174
+ oct: "oct overflows past 63 bits in a Spinel binary; use large_integer(text, 8)"
175
+ }.freeze
176
+
177
+ def self.check(name, rule)
178
+ message = MESSAGES[name]
179
+ raise Error, "rule :#{rule}: #{message}" if message
180
+ end
181
+
182
+ def self.check_call(node, rule)
183
+ check(node.name, rule) unless node.name == :to_i && node.receiver.is_a?(Prism::IntegerNode)
184
+ end
185
+
186
+ # map(&:to_i) calls to_i on each element.
187
+ def self.check_block(node, rule)
188
+ check(node.expression.unescaped.to_sym, rule) if node.expression.is_a?(Prism::SymbolNode)
189
+ end
190
+
191
+ # Methods that call a method named by their first argument.
192
+ DISPATCH = %i[send public_send __send__ method public_method].freeze
193
+
194
+ # The method a DISPATCH call names, which must be written as a
195
+ # symbol to be checked.
196
+ def self.dispatched(node, rule)
197
+ target = node.arguments&.arguments&.first
198
+ return target.unescaped.to_sym if target.is_a?(Prism::SymbolNode)
199
+
200
+ raise Error, "rule :#{rule}: copied methods can't #{node.name} a computed name (#{node.slice}); " \
201
+ "call the method by name"
202
+ end
203
+ end
204
+
205
+ # The constants copied methods read, each written as a literal. Looked
206
+ # up lexically from where the method was written; core classes are
207
+ # left as they are, and anything else is refused.
208
+ class CopiedConstants
209
+ def initialize
210
+ @constants = {}
211
+ end
212
+
213
+ def sources = @constants.map { |name, (_owner, source)| "#{name} = #{source}" }
214
+
215
+ def names = @constants.keys.map(&:to_s)
216
+
217
+ # Classes Ruby (or the runtime) provides, which copied code may name.
218
+ CORE_CLASSES = %i[
219
+ Integer Float String Symbol Array Hash Regexp Comparable Math Kernel Encoding NilClass
220
+ StandardError ArgumentError KeyError RangeError TypeError FloatDomainError
221
+ ].freeze
222
+
223
+ def read(name, scopes, rule)
224
+ owner = scopes.find { _1.const_defined?(name) } or
225
+ raise Error, "rule :#{rule}: copied methods read #{name}, which can't be found"
226
+ value = owner.const_get(name)
227
+ return core_class(name, value, rule) if value.is_a?(Module)
228
+
229
+ check_constant(name, owner, value, rule)
230
+ @constants[name] = [owner, Literals.source(value)]
231
+ end
232
+
233
+ def core_class(name, value, rule)
234
+ return if CORE_CLASSES.include?(name) && Object.const_get(name).equal?(value)
235
+
236
+ raise Error, "rule :#{rule}: copied methods use #{value.inspect}, which the generated parser won't have"
237
+ end
238
+
239
+ def check_constant(name, owner, value, rule)
240
+ if RESERVED_CONSTANTS.include?(name.to_s)
241
+ raise Error, "rule :#{rule}: copied methods read #{owner}::#{name}, a name the generated parser needs"
242
+ end
243
+ unless Literals.literal?(value)
244
+ raise Error, "rule :#{rule}: #{name} must be a literal (strings, numbers, symbols, arrays, hashes)"
245
+ end
246
+
247
+ seen, = @constants[name]
248
+ return if seen.nil? || seen == owner
249
+
250
+ raise Error, "copied methods read both #{seen}::#{name} and #{owner}::#{name}; rename one"
251
+ end
252
+ end
253
+
254
+ # The def of name at line, with the modules it was written in
255
+ # (innermost first, then Object), for looking up its constants.
256
+ class MethodFinder
257
+ def initialize(name, line)
258
+ @name = name
259
+ @line = line
260
+ end
261
+
262
+ # file's syntax tree, read with Prism.
263
+ def self.parse(file)
264
+ load_prism
265
+ Prism.parse_file(file).value
266
+ end
267
+
268
+ def self.load_prism
269
+ require "prism"
270
+ rescue LoadError
271
+ raise Error, "copying .action and .check methods and node initializers needs the prism gem " \
272
+ "(bundled from Ruby 3.3): add it to your Gemfile"
273
+ end
274
+
275
+ # The def's source, its lines indented as if written at the left
276
+ # margin.
277
+ def self.source(node)
278
+ "#{reindent(node.slice, node.location.start_column)}\n"
279
+ end
280
+
281
+ # The method's later lines are indented for where it was written.
282
+ def self.reindent(source, column)
283
+ first, *rest = source.lines
284
+ [first, *rest.map { _1.sub(/\A {0,#{column}}/, "") }].join
285
+ end
286
+
287
+ def find(node, nesting = [])
288
+ case node
289
+ when Prism::DefNode
290
+ return [node, scopes(nesting)] if node.name == @name && node.location.start_line == @line
291
+ when Prism::ClassNode, Prism::ModuleNode
292
+ nesting = [*nesting, node.constant_path.slice]
293
+ end
294
+ node.compact_child_nodes.each do |child|
295
+ found = find(child, nesting)
296
+ return found if found
297
+ end
298
+ nil
299
+ end
300
+
301
+ private
302
+
303
+ def scopes(nesting)
304
+ paths = nesting.each_index.map { nesting[0.._1].join("::") }.reverse
305
+ [*paths.filter_map { Object.const_get(_1) if Object.const_defined?(_1) }, Object]
306
+ end
307
+ end
308
+ end
309
+ end