arpaka 0.1.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.
data/lib/arpaka.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lrama/ruby"
4
+
5
+ module Arpaka
6
+ class Error < Lrama::Ruby::Error; end
7
+
8
+ def self.generate(ruby_source:, class_name: "RubyParser")
9
+ Generator.new.generate(ruby_source: ruby_source, class_name: class_name)
10
+ end
11
+
12
+ def self.compile(ruby_source:, class_name: "RubyParser")
13
+ source = generate(ruby_source: ruby_source, class_name: class_name)
14
+ box = ::Ruby::Box.new
15
+ box.eval(source)
16
+ box.const_get(class_name, false)
17
+ end
18
+ end
19
+
20
+ require_relative "arpaka/generator"
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "action_scanner"
4
+
5
+ module Lrama
6
+ module Ruby
7
+ # Loaded only in the generator's box. Ruby strings, comments, regular
8
+ # expressions and instance variables must not become grammar references.
9
+ module ActionReferences
10
+ def ruby_short_reference?(offset)
11
+ references
12
+ @ruby_short_references.include?(offset)
13
+ end
14
+
15
+ private
16
+
17
+ def _references
18
+ result = ActionScanner.new(s_value, filename: location.grammar_file.path,
19
+ line: location.first_line, column: location.first_column).scan
20
+ @ruby_short_references = result.references.select(&:short_interpolation).map(&:first_column)
21
+ result.references.map do |reference|
22
+ attributes = if reference.number
23
+ { number: reference.number, index: reference.number }
24
+ else
25
+ { name: reference.name }
26
+ end
27
+ Grammar::Reference.new(type: :dollar, **attributes,
28
+ first_column: reference.first_column, last_column: reference.last_column)
29
+ end
30
+ rescue ActionScanner::Error => error
31
+ raise Backend::Error, error.message
32
+ end
33
+ end
34
+
35
+ module ActionLexer
36
+ def lex_c_code
37
+ return super unless @end_symbol == "}"
38
+
39
+ reset_first_position
40
+ result = ActionScanner.new(@scanner.rest, filename: @grammar_file.path,
41
+ line: line, column: column).scan(action: true)
42
+ code = @scanner.string.byteslice(@scanner.pos, result.end_offset)
43
+ @scanner.pos += code.bytesize
44
+ @line += code.count("\n")
45
+ @head = @scanner.pos - code.bytesize + code.b.rindex("\n") + 1 if code.include?("\n")
46
+ [:C_DECLARATION, Lexer::Token::UserCode.new(s_value: code, location: location)]
47
+ rescue ActionScanner::Error => error
48
+ raise Backend::Error, error.message
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ Lrama::Lexer::Token::UserCode.prepend(Lrama::Ruby::ActionReferences)
55
+ Lrama::Lexer.prepend(Lrama::Ruby::ActionLexer)
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Lrama
6
+ module Ruby
7
+ # A Ruby source lexer used for action boundary detection and references.
8
+ # All positions are bytes in the original source; literals are never decoded.
9
+ class ActionScanner
10
+ Reference = Data.define(:name, :number, :first_column, :last_column, :short_interpolation)
11
+ Result = Data.define(:end_offset, :references)
12
+ Heredoc = Data.define(:delimiter, :indent, :interpolate, :offset)
13
+ class Error < StandardError; end
14
+
15
+ IDENTIFIER = /[a-zA-Z_\x80-\xff][a-zA-Z_0-9\x80-\xff]*[!?]?/n
16
+ NUMBER = /(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?)[ri]*/
17
+ BEGIN_WORDS = %w[if unless while until when return yield break next rescue else elsif then do begin and or not in case for].freeze
18
+ END_WORDS = %w[nil true false self end __FILE__ __LINE__ __ENCODING__].freeze
19
+ PAIRS = { "(" => ")", "[" => "]", "{" => "}" }.freeze
20
+ LITERAL_PAIRS = PAIRS.merge("<" => ">").freeze
21
+ # Bound recursive interpolation rather than allowing SystemStackError.
22
+ MAX_INTERPOLATION_DEPTH = 128
23
+
24
+ def initialize(source, filename: "(grammar)", line: 1, column: 0)
25
+ @source = source.b
26
+ @scanner = StringScanner.new(@source)
27
+ @filename, @line, @column = filename, line, column
28
+ @references = []
29
+ @interpolation_depth = 0
30
+ @strict_references = false
31
+ end
32
+
33
+ def scan(action: false)
34
+ @strict_references = action
35
+ code(action ? "}" : nil)
36
+ Result.new(@scanner.pos, @references.sort_by(&:first_column).freeze)
37
+ end
38
+
39
+ private
40
+
41
+ def fail_at(message, offset = @scanner.pos)
42
+ prefix = @source.byteslice(0, offset)
43
+ lines = prefix.count("\n")
44
+ column = lines.zero? ? @column + offset : offset - prefix.rindex("\n") - 1
45
+ raise Error, "#{@filename}:#{@line + lines}:#{column}: #{message}"
46
+ end
47
+
48
+ def code(terminator)
49
+ brackets = []
50
+ heredocs = []
51
+ state = :begin
52
+ spaced = false
53
+ until @scanner.eos?
54
+ offset = @scanner.pos
55
+ char = @scanner.peek(1)
56
+ if @scanner.scan(/[ \t\r\f]+/)
57
+ spaced = true
58
+ next
59
+ elsif @scanner.scan(/\\\r?\n/)
60
+ spaced = true
61
+ next
62
+ elsif @scanner.scan(/\n/)
63
+ heredocs.each { |item| heredoc_body(item) }
64
+ heredocs.clear
65
+ state = :begin unless [:begin, :method].include?(state)
66
+ spaced = true
67
+ next
68
+ elsif char == "#"
69
+ @scanner.scan(/[^\n]*/)
70
+ next
71
+ elsif (offset.zero? || @source.getbyte(offset - 1) == 10) && @scanner.check(/=begin(?=\s|\z)/)
72
+ @scanner.scan(/[^\n]*(?:\n|\z)/)
73
+ found = false
74
+ until @scanner.eos?
75
+ line = @scanner.scan(/[^\n]*(?:\n|\z)/)
76
+ if /\A=end(?:\s|\z)/.match?(line)
77
+ found = true
78
+ break
79
+ end
80
+ end
81
+ fail_at("Unterminated block comment", offset) unless found
82
+ next
83
+ elsif terminator.nil? && (offset.zero? || @source.getbyte(offset - 1) == 10) &&
84
+ @scanner.check(/__END__(?:\s|\z)/)
85
+ @scanner.terminate
86
+ return
87
+ elsif char == terminator && brackets.empty?
88
+ fail_at("Close the action/interpolation after the heredoc body", offset) unless heredocs.empty?
89
+ return
90
+ elsif PAIRS.key?(char)
91
+ brackets << [PAIRS.fetch(char), offset]
92
+ @scanner.getch
93
+ state = :begin
94
+ elsif [")", "]", "}"].include?(char)
95
+ fail_at("Mismatched closing #{char}", offset) unless brackets.last&.first == char
96
+ brackets.pop
97
+ @scanner.getch
98
+ state = :end
99
+ elsif ["'", '"', "`"].include?(char)
100
+ @scanner.getch
101
+ literal(char, interpolate: char != "'", offset: offset)
102
+ state = :end
103
+ elsif char == "$"
104
+ reference
105
+ state = :end
106
+ elsif @scanner.scan(/@@?[a-zA-Z_\x80-\xff][a-zA-Z_0-9\x80-\xff]*/n)
107
+ state = :end
108
+ elsif @scanner.check(NUMBER)
109
+ @scanner.scan(NUMBER)
110
+ state = :end
111
+ elsif @scanner.check(IDENTIFIER)
112
+ word = @scanner.scan(IDENTIFIER)
113
+ fail_at("__END__ is not supported in Ruby actions", offset) if word == "__END__"
114
+ # Method names after dot/:: are not keywords.
115
+ state = if state == :method
116
+ :bare
117
+ elsif BEGIN_WORDS.include?(word)
118
+ :begin
119
+ elsif END_WORDS.include?(word)
120
+ :end
121
+ else
122
+ :bare
123
+ end
124
+ elsif char == "/" || char == "%" || @scanner.check(/<</)
125
+ if char == "%" && @scanner.check(/%[qQwWiIrsx]?[a-zA-Z0-9_]*[^a-zA-Z0-9_\\\s]/n) &&
126
+ (@scanner.peek(2).byteslice(1, 1).match?(/[qQwWiIrsx]/n) ||
127
+ !@scanner.peek(2).byteslice(1, 1).match?(/[a-zA-Z0-9_]/n))
128
+ percent_literal(offset)
129
+ state = :end
130
+ elsif (state == :begin || (state == :bare && spaced)) &&
131
+ @scanner.check(/<<[-~]?(?:['"`][^\r\n]+['"`]|[a-zA-Z_][a-zA-Z_0-9]*)(?=\s|[;,)\]}]|\z)/n)
132
+ heredocs << heredoc_start(offset)
133
+ state = :end
134
+ elsif state == :method
135
+ @scanner.scan(/(?:<<|\/|%)/)
136
+ state = :bare
137
+ elsif state == :begin
138
+ if char == "/"
139
+ @scanner.getch
140
+ literal("/", interpolate: true, regexp: true, offset: offset)
141
+ @scanner.scan(/[a-z]*/)
142
+ elsif char == "%"
143
+ percent_literal(offset)
144
+ elsif @scanner.check(/<<[-~]?(?:['"`][^\r\n]+['"`]|[a-zA-Z_][a-zA-Z_0-9]*)(?=\s|[;,)\]}]|\z)/n)
145
+ heredocs << heredoc_start(offset)
146
+ else
147
+ @scanner.scan(/<</)
148
+ end
149
+ state = :end
150
+ else
151
+ @scanner.scan(/(?:<<|\/|%)=?/)
152
+ state = :begin
153
+ end
154
+ elsif char == "?" && state == :begin
155
+ @scanner.getch
156
+ character(offset)
157
+ state = :end
158
+ elsif @scanner.scan(/&\.|::|\.(?!\.)/)
159
+ state = :method
160
+ elsif char == ":" && state == :begin && @scanner.check(/:\s/)
161
+ @scanner.getch
162
+ state = :begin
163
+ elsif char == ":" && state == :begin
164
+ @scanner.getch
165
+ if @scanner.check(/['"]/)
166
+ quote = @scanner.getch
167
+ literal(quote, interpolate: quote != "'", offset: offset)
168
+ elsif @scanner.scan(/#\{/)
169
+ @interpolation_depth += 1
170
+ code("}")
171
+ @scanner.getch
172
+ @interpolation_depth -= 1
173
+ elsif !@scanner.scan(/(?:@{1,2}|\$?)[a-zA-Z_\x80-\xff][a-zA-Z_0-9\x80-\xff]*[!?]?/n) &&
174
+ !@scanner.scan(/(?:\[\]=?|<=>|===|==|=~|!~|!=|<=|>=|<<|>>|\*\*|[+\-~]@?|[!*\/%&|^<>`])/)
175
+ fail_at("Unsupported symbol literal", offset)
176
+ end
177
+ state = :end
178
+ elsif char == "@" && @scanner.check(/@@?[a-zA-Z_\x80-\xff][a-zA-Z_0-9\x80-\xff]*/n)
179
+ @scanner.scan(/@@?[a-zA-Z_\x80-\xff][a-zA-Z_0-9\x80-\xff]*/n)
180
+ state = :end
181
+ elsif char == "@" && state == :begin
182
+ @scanner.getch
183
+ state = :end
184
+ elsif @scanner.scan(/(?:\.\.\.?|->|\*\*=|&&=?|\|\|=?|<=>|===|==|=>|!=|!~|=~|<=|>=|>>=?|\*\*|[+\-*|&^]=|[=+\-*!,;:<>?~|&^])/)
185
+ state = :begin
186
+ else
187
+ fail_at("Unsupported Ruby action token #{char.inspect}", offset)
188
+ end
189
+ spaced = false
190
+ end
191
+ fail_at("Unterminated heredoc", heredocs.first.offset) unless heredocs.empty?
192
+ fail_at("Unclosed #{brackets.last.first}", brackets.last.last) unless brackets.empty?
193
+ fail_at("Unterminated Ruby action/interpolation") if terminator
194
+ end
195
+
196
+ def reference(short: false)
197
+ offset = @scanner.pos
198
+ @global_variable_start = offset
199
+ value = @scanner.scan(/\$(?:\$|[0-9]+|[a-zA-Z_][a-zA-Z0-9_]*)/)
200
+ unless value
201
+ return scan_global_variable unless @strict_references
202
+ fail_at("Unsupported semantic reference", offset)
203
+ end
204
+ if value.match?(/\A\$0/) || @scanner.check(/[a-zA-Z_0-9\x80-\xff]/n)
205
+ return scan_global_variable unless @strict_references
206
+ fail_at("Unsupported semantic reference", offset)
207
+ end
208
+ number = value.match?(/\A\$[0-9]+\z/) ? value.delete_prefix("$").to_i : nil
209
+ @references << Reference.new(number ? nil : value.delete_prefix("$"), number,
210
+ offset, @scanner.pos, short)
211
+ end
212
+
213
+ def scan_global_variable
214
+ @scanner.pos = @global_variable_start || @scanner.pos
215
+ @scanner.scan(/\$(?:`|')/) ||
216
+ @scanner.scan(/\$(?:[<>]?|[!@&+~?=\/\\;,.:$-][a-zA-Z]?|[<>][^>\n]*>|[0-9]+|[a-zA-Z_][a-zA-Z0-9_]*)/)
217
+ fail_at("Unsupported global variable", @scanner.pos) if @scanner.pos == (@global_variable_start || @scanner.pos)
218
+ end
219
+
220
+ def interpolation
221
+ return false unless @scanner.peek(1) == "#"
222
+ if @scanner.scan(/#\{/)
223
+ @interpolation_depth += 1
224
+ fail_at("Interpolation nesting exceeds #{MAX_INTERPOLATION_DEPTH}") if @interpolation_depth > MAX_INTERPOLATION_DEPTH
225
+ code("}")
226
+ @scanner.getch
227
+ @interpolation_depth -= 1
228
+ elsif @scanner.scan(/#(?=\$)/)
229
+ reference(short: true)
230
+ elsif @scanner.scan(/#@@?[a-zA-Z_\x80-\xff][a-zA-Z_0-9\x80-\xff]*/n)
231
+ # Instance/class variable interpolation is preserved verbatim.
232
+ else
233
+ return false
234
+ end
235
+ true
236
+ end
237
+
238
+ def literal(close, interpolate:, offset:, open: nil, regexp: false)
239
+ depth = 0
240
+ until @scanner.eos?
241
+ char = @scanner.peek(1)
242
+ if char == "\\"
243
+ @scanner.getch
244
+ @scanner.getch || fail_at("Unterminated escape", offset)
245
+ elsif interpolate && interpolation
246
+ next
247
+ elsif regexp && char == "["
248
+ regexp_class(offset)
249
+ elsif char == close
250
+ @scanner.getch
251
+ return if depth.zero?
252
+ depth -= 1
253
+ elsif open && char == open
254
+ depth += 1
255
+ @scanner.getch
256
+ else
257
+ @scanner.getch
258
+ end
259
+ end
260
+ fail_at("Unterminated literal (expected #{close})", offset)
261
+ end
262
+
263
+ def regexp_class(offset)
264
+ @scanner.getch
265
+ @scanner.scan(/\^/)
266
+ @scanner.scan(/\]/) # A leading ] is literal.
267
+ until @scanner.eos?
268
+ if @scanner.scan(/\\[\s\S]/)
269
+ next
270
+ elsif interpolation
271
+ next
272
+ elsif @scanner.scan(/\[:[^\]\n]*:\]/)
273
+ next # POSIX character class inside a bracket expression.
274
+ elsif @scanner.scan(/\]/)
275
+ return
276
+ else
277
+ @scanner.getch
278
+ end
279
+ end
280
+ fail_at("Unterminated regexp character class", offset)
281
+ end
282
+
283
+ def percent_literal(offset)
284
+ @scanner.getch
285
+ kind = @scanner.scan(/[qQwWiIrsx]/) || "Q"
286
+ delimiter = @scanner.getch
287
+ unless delimiter && delimiter.bytesize == 1 &&
288
+ !delimiter.match?(/[a-zA-Z0-9\\\r\n]/n)
289
+ fail_at("Unsupported percent literal delimiter", offset)
290
+ end
291
+ literal(LITERAL_PAIRS.fetch(delimiter, delimiter), open: LITERAL_PAIRS.key?(delimiter) ? delimiter : nil,
292
+ interpolate: !%w[q w i s].include?(kind), regexp: kind == "r", offset: offset)
293
+ @scanner.scan(/[a-z]*/) if kind == "r"
294
+ end
295
+
296
+ def heredoc_start(offset)
297
+ @scanner.scan(/<</)
298
+ indent = !!@scanner.scan(/[-~]/)
299
+ quote = @scanner.scan(/['"`]/)
300
+ delimiter = if quote
301
+ start = @scanner.pos
302
+ @scanner.getch until @scanner.eos? || [quote, "\r", "\n"].include?(@scanner.peek(1))
303
+ text = @source.byteslice(start, @scanner.pos - start)
304
+ fail_at("Unterminated heredoc delimiter", offset) unless @scanner.getch == quote
305
+ text
306
+ else
307
+ @scanner.scan(/[a-zA-Z_][a-zA-Z_0-9]*/)
308
+ end
309
+ fail_at("Unsupported heredoc delimiter", offset) unless delimiter && !delimiter.empty?
310
+ Heredoc.new(delimiter, indent, quote != "'", offset)
311
+ end
312
+
313
+ def heredoc_body(item)
314
+ until @scanner.eos?
315
+ if @scanner.pos.zero? || @source.getbyte(@scanner.pos - 1) == 10
316
+ start = @scanner.pos
317
+ @scanner.scan(/[ \t]*/) if item.indent
318
+ if @scanner.peek(item.delimiter.bytesize) == item.delimiter
319
+ @scanner.pos += item.delimiter.bytesize
320
+ return if @scanner.eos? || @scanner.scan(/\r?\n/)
321
+ end
322
+ @scanner.pos = start
323
+ end
324
+ if item.interpolate && interpolation
325
+ next
326
+ elsif item.interpolate && @scanner.scan(/\\[\s\S]/)
327
+ next
328
+ else
329
+ @scanner.getch
330
+ end
331
+ end
332
+ fail_at("Unterminated heredoc #{item.delimiter}", item.offset)
333
+ end
334
+
335
+ def character(offset)
336
+ fail_at("Unterminated character literal", offset) if @scanner.eos? || @scanner.check(/\s/)
337
+ if @scanner.scan(/\\/)
338
+ if @scanner.scan(/u\{/)
339
+ fail_at("Unterminated character escape", offset) unless @scanner.scan(/[0-9a-fA-F \t]+\}/)
340
+ elsif @scanner.scan(/(?:[CM]-|c)/)
341
+ character(offset)
342
+ else
343
+ @scanner.getch || fail_at("Unterminated character escape", offset)
344
+ end
345
+ else
346
+ # Consume exactly one UTF-8 codepoint (or one ASCII byte).
347
+ @scanner.scan(/(?:[\xc2-\xdf][\x80-\xbf]|[\xe0-\xef][\x80-\xbf]{2}|[\xf0-\xf4][\x80-\xbf]{3}|[\x00-\x7f])/n) ||
348
+ fail_at("Unsupported character encoding", offset)
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lrama"
4
+ require "erb"
5
+
6
+ module Lrama
7
+ module Ruby
8
+ class Backend
9
+ class Error < StandardError; end
10
+
11
+ def generate(source, filename:, class_name:, mode:, allow_error_rules: false)
12
+ recognizer = mode == :recognizer
13
+ grammar = Lrama::Parser.new(source, filename).parse
14
+ unless grammar.no_stdlib
15
+ path = Lrama::Command::STDLIB_FILE_PATH
16
+ stdlib = Lrama::Parser.new(File.read(path), path).parse
17
+ grammar.prepend_parameterized_rules(stdlib.parameterized_rules)
18
+ end
19
+ grammar.prepare
20
+ grammar.validate!
21
+ validate_features!(grammar, allow_error_rules: allow_error_rules) unless recognizer
22
+
23
+ states = Lrama::States.new(grammar, Lrama::Tracer.new($stderr))
24
+ states.compute
25
+ states.compute_ielr if grammar.ielr_defined?
26
+ if states.rr_conflicts_count.positive? || states.sr_conflicts_count != (grammar.expect || 0)
27
+ raise Error, "Unresolved conflicts: #{states.sr_conflicts_count} shift/reduce, #{states.rr_conflicts_count} reduce/reduce"
28
+ end
29
+ context = Lrama::Context.new(states)
30
+ tokens = token_names(grammar)
31
+ actions = grammar.rules.filter_map do |rule|
32
+ next if recognizer || !rule.token_code
33
+ # Preserve whitespace inside multiline strings and heredocs.
34
+ " when #{rule.id + 1} # #{rule.as_comment.gsub(/[\r\n]/, ' ')}\n#{translate_action(rule)}\n"
35
+ end.join
36
+ template = File.read(File.expand_path("parser.rb.erb", __dir__))
37
+ result = ::ERB.new(template, trim_mode: "-").result_with_hash(
38
+ class_name: class_name, context: context, tokens: tokens, actions: actions,
39
+ recognizer: recognizer
40
+ )
41
+ result
42
+ end
43
+
44
+ private
45
+
46
+ def translate_action(rule)
47
+ code = rule.token_code.s_value.dup
48
+ position = rule.position_in_original_rule_rhs || rule.rhs.length
49
+ rule.token_code.references.reverse_each do |reference|
50
+ replacement = if reference.name == "$"
51
+ "yyval"
52
+ else
53
+ "values[#{reference.index - position - 1}]"
54
+ end
55
+ if rule.token_code.ruby_short_reference?(reference.first_column)
56
+ replacement = "{#{replacement}}"
57
+ end
58
+ # Lrama and action scanner locations are byte offsets, including UTF-8.
59
+ code = code.byteslice(0, reference.first_column) + replacement +
60
+ code.byteslice(reference.last_column..)
61
+ end
62
+ code
63
+ end
64
+
65
+ def token_names(grammar)
66
+ grammar.terms.each_with_object({}) do |term, names|
67
+ next if term.error_symbol? || term.undef_symbol?
68
+ names[term.id.s_value] = term.token_id
69
+ if term.id.is_a?(Lrama::Lexer::Token::Char)
70
+ names[term.token_id.chr(Encoding::UTF_8)] = term.token_id
71
+ end
72
+ if term.alias_name&.start_with?('"')
73
+ names[term.alias_name[1...-1]] = term.token_id
74
+ end
75
+ end
76
+ end
77
+
78
+ def validate_features!(grammar, allow_error_rules:)
79
+ unsupported = []
80
+ unsupported << "%locations" if grammar.locations
81
+ unsupported << "%union" if grammar.union
82
+ unsupported << "%code" unless grammar.percent_codes.empty?
83
+ unsupported << "prologue" unless grammar.aux.prologue.to_s.strip.empty?
84
+ unsupported << "epilogue" unless grammar.aux.epilogue.to_s.strip.empty?
85
+ {
86
+ "%parse-param" => grammar.parse_param, "%lex-param" => grammar.lex_param,
87
+ "%initial-action" => grammar.initial_action, "%after-shift" => grammar.after_shift,
88
+ "%before-reduce" => grammar.before_reduce, "%after-reduce" => grammar.after_reduce,
89
+ "%after-shift-error-token" => grammar.after_shift_error_token,
90
+ "%after-pop-stack" => grammar.after_pop_stack
91
+ }.each { |name, value| unsupported << name if value }
92
+ unsupported << "%printer" unless grammar.printers.empty?
93
+ unsupported << "%error-token" unless grammar.error_tokens.empty?
94
+ unsupported << "%destructor" if grammar.symbols.any?(&:destructor)
95
+ unsupported << "typed symbols" if grammar.symbols.any?(&:tag)
96
+ if !allow_error_rules && grammar.rules.any? { |rule| rule.rhs.include?(grammar.error_symbol) }
97
+ unsupported << "error recovery rules"
98
+ end
99
+ raise Error, "Not supported by the Ruby backend yet: #{unsupported.join(', ')}" unless unsupported.empty?
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lrama"
4
+
5
+ module Lrama
6
+ module Ruby
7
+ class Generator
8
+ def initialize
9
+ unless ::Ruby::Box.enabled?
10
+ raise Error, "Ruby Box is required. Start Ruby with RUBY_BOX=1."
11
+ end
12
+ end
13
+
14
+ def generate(source, filename: "(grammar)", class_name: "Parser", mode: :parser, allow_error_rules: false)
15
+ unless /\A[A-Z][a-zA-Z0-9_]*\z/.match?(class_name)
16
+ raise Error, "class_name must be a single Ruby constant name"
17
+ end
18
+
19
+ unless [:parser, :recognizer].include?(mode)
20
+ raise Error, "mode must be :parser or :recognizer"
21
+ end
22
+
23
+ box = ::Ruby::Box.new
24
+ # A new box does not inherit Bundler's activated load paths.
25
+ box.load_path.replace($LOAD_PATH)
26
+ box.require(File.expand_path("backend.rb", __dir__))
27
+ box.require(File.expand_path("action_code.rb", __dir__)) if mode == :parser
28
+ begin
29
+ box::Lrama::Ruby::Backend.new.generate(source,
30
+ filename: filename, class_name: class_name, mode: mode,
31
+ allow_error_rules: allow_error_rules)
32
+ rescue box::Lrama::Ruby::Backend::Error => e
33
+ raise Error, e.message
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end