arpaka 0.1.1 → 2.0.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -2
  3. data/README.md +118 -7
  4. data/Rakefile +15 -1
  5. data/docs/architecture.dot +30 -0
  6. data/docs/architecture.md +96 -0
  7. data/docs/architecture.webp +0 -0
  8. data/docs/contributes.md +115 -0
  9. data/docs/usage.md +497 -0
  10. data/lib/arpaka/cli.rb +5 -50
  11. data/lib/arpaka/plugins/cli.rb +61 -0
  12. data/lib/arpaka/plugins/sample.rb +14 -0
  13. data/lib/arpaka/plugins/sample_runner.rb +21 -0
  14. data/lib/arpaka/{actions.rb → ruby/actions.rb} +230 -151
  15. data/lib/arpaka/ruby/class_name_parser.rb +97 -0
  16. data/lib/arpaka/ruby/cli.rb +64 -0
  17. data/lib/arpaka/ruby/frontend_emitter.rb +59 -0
  18. data/lib/arpaka/ruby/generator.rb +41 -0
  19. data/lib/arpaka/{ruby_grammar.rb → ruby/grammar.rb} +53 -59
  20. data/lib/arpaka/ruby/grammar_emitter.rb +71 -0
  21. data/lib/arpaka/ruby/profile.rb +129 -0
  22. data/lib/arpaka/ruby/profiles/ruby_3_3.rb +800 -0
  23. data/lib/arpaka/ruby/profiles/ruby_3_4.rb +802 -0
  24. data/lib/arpaka/ruby/runtime/detailed/ast.rb.erb +92 -0
  25. data/lib/arpaka/ruby/runtime/detailed/builder.rb.erb +1177 -0
  26. data/lib/arpaka/{runtime → ruby/runtime}/frontend.rb.erb +17 -4
  27. data/lib/arpaka/{runtime → ruby/runtime}/lexer.rb.erb +583 -99
  28. data/lib/arpaka/ruby/runtime/structured.rb.erb +111 -0
  29. data/lib/arpaka/ruby.rb +23 -0
  30. data/lib/arpaka.rb +6 -9
  31. data/lib/lrama/ruby/backend.rb +4 -9
  32. data/lib/lrama/ruby/parser.rb.erb +35 -9
  33. data/lib/lrama/ruby/parser_emitter.rb +41 -0
  34. data/lib/lrama/ruby/table_codec.rb +50 -0
  35. data/lib/lrama/ruby/version.rb +1 -1
  36. data/lib/lrama/ruby.rb +1 -0
  37. data/sig/arpaka.rbs +8 -3
  38. metadata +46 -13
  39. data/lib/arpaka/generator.rb +0 -43
  40. data/lib/arpaka/runtime/ast.rb.erb +0 -35
  41. data/lib/arpaka/runtime/builder.rb.erb +0 -247
  42. /data/lib/arpaka/{BSDL → ruby/BSDL} +0 -0
  43. /data/lib/arpaka/{COPYING → ruby/COPYING} +0 -0
  44. /data/lib/arpaka/{source.json → ruby/source.json} +0 -0
  45. /data/lib/arpaka/{upstream_rules.json → ruby/upstream_rules.json} +0 -0
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arpaka::Ruby
4
+ class ClassName
5
+ attr_reader :components
6
+
7
+ def initialize(components)
8
+ @components = components.freeze
9
+ freeze
10
+ end
11
+
12
+ def namespaces
13
+ components[0...-1]
14
+ end
15
+
16
+ def constant
17
+ components.last
18
+ end
19
+
20
+ def resolve(root)
21
+ components.reduce(root) { |namespace, name| namespace.const_get(name, false) }
22
+ end
23
+
24
+ def to_s
25
+ components.join("::")
26
+ end
27
+ end
28
+
29
+ class ClassNameParser
30
+ def self.parse(value)
31
+ new(value).parse
32
+ end
33
+
34
+ def initialize(value)
35
+ @value = value
36
+ @index = 0
37
+ @components = []
38
+ end
39
+
40
+ def parse
41
+ unless @value.is_a?(String)
42
+ invalid("must be a String")
43
+ end
44
+
45
+ parse_component
46
+ until end_of_input?
47
+ parse_separator
48
+ parse_component
49
+ end
50
+ ClassName.new(@components)
51
+ end
52
+
53
+ private
54
+
55
+ def parse_component
56
+ component_number = @components.length + 1
57
+ if end_of_input? || current_byte == 58
58
+ invalid("component #{component_number} is empty")
59
+ end
60
+ unless uppercase_byte?(current_byte)
61
+ invalid("component #{component_number} must start with an uppercase letter")
62
+ end
63
+
64
+ start = @index
65
+ @index += 1
66
+ @index += 1 while identifier_byte?(current_byte)
67
+ @components << @value.byteslice(start, @index - start)
68
+ end
69
+
70
+ def parse_separator
71
+ unless current_byte == 58 && @value.getbyte(@index + 1) == 58
72
+ invalid("expected `::` after component #{@components.length}")
73
+ end
74
+ @index += 2
75
+ end
76
+
77
+ def end_of_input?
78
+ @index >= @value.bytesize
79
+ end
80
+
81
+ def current_byte
82
+ @value.getbyte(@index)
83
+ end
84
+
85
+ def uppercase_byte?(byte)
86
+ byte && byte.between?(65, 90)
87
+ end
88
+
89
+ def identifier_byte?(byte)
90
+ byte && (uppercase_byte?(byte) || byte.between?(97, 122) || byte.between?(48, 57) || byte == 95)
91
+ end
92
+
93
+ def invalid(message)
94
+ raise ::Arpaka::Error, "invalid class_name: #{message}"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "tempfile"
5
+ require_relative "../../arpaka"
6
+
7
+ module Arpaka::Ruby
8
+ class CLI
9
+ def self.run(argv, out: $stdout, err: $stderr)
10
+ options = { class_name: "RubyParser" }
11
+ force = false
12
+ help = false
13
+ parser = OptionParser.new do |opts|
14
+ opts.banner = "Usage: arpaka generate (--ruby-source DIR | --parse-y FILE) --output FILE [--class-name NAME] [--ast-type detailed|structured] [--force]"
15
+ opts.on("--ruby-source DIR", "Ruby source tree (provided by the user)") { |v| options[:ruby_source] = v }
16
+ opts.on("--parse-y FILE", "parse.y file (use Arpaka's token definitions)") { |v| options[:parse_y] = v }
17
+ opts.on("--output FILE", "Write a standalone Ruby frontend") { |v| options[:output] = v }
18
+ opts.on("--class-name NAME", "Generated class name (default: RubyParser)") { |v| options[:class_name] = v }
19
+ opts.on("--ast-type TYPE", %w[detailed structured], "AST output type (default: detailed)") { |v| options[:ast_type] = v }
20
+ opts.on("--force", "Replace an existing output file") { force = true }
21
+ opts.on("-h", "--help", "Show usage") { help = true }
22
+ end
23
+ args = argv.dup
24
+ command = args.shift
25
+ if ["--help", "-h"].include?(command)
26
+ out.puts(parser)
27
+ return 0
28
+ end
29
+ raise OptionParser::InvalidArgument, "expected generate" unless command == "generate"
30
+ parser.parse!(args)
31
+ if help
32
+ out.puts(parser)
33
+ return 0
34
+ end
35
+ raise OptionParser::InvalidArgument, args.join(" ") unless args.empty?
36
+ raise OptionParser::InvalidArgument, "specify exactly one of --ruby-source or --parse-y" if options[:ruby_source] == options[:parse_y]
37
+ raise OptionParser::MissingArgument, "--output" unless options[:output]
38
+ %i[output].each do |key|
39
+ raise OptionParser::MissingArgument, "--#{key.to_s.tr('_', '-')}" unless options[key]
40
+ end
41
+ output = File.expand_path(options.delete(:output))
42
+ if !force && (File.exist?(output) || File.symlink?(output))
43
+ raise ::Arpaka::Error, "Output already exists: #{output}; use --force to replace it"
44
+ end
45
+ source = Arpaka::Ruby.generate(**options)
46
+ Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file|
47
+ file.write(source)
48
+ file.flush
49
+ file.chmod(0o644)
50
+ if force
51
+ File.rename(file.path, output)
52
+ else
53
+ # Publish only the complete file, without overwriting a concurrent writer.
54
+ File.link(file.path, output)
55
+ end
56
+ end
57
+ out.puts("Generated #{output}")
58
+ 0
59
+ rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error
60
+ err.puts("arpaka: #{error.message}")
61
+ 1
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "erb"
5
+ require "json"
6
+
7
+ module Arpaka::Ruby
8
+ class FrontendEmitter
9
+ RuntimeBundle = Data.define(:templates)
10
+ DETAILED_RUNTIME_BUNDLE = RuntimeBundle.new(
11
+ %w[frontend detailed/ast detailed/builder lexer].freeze
12
+ ).freeze
13
+ STRUCTURED_RUNTIME_BUNDLE = RuntimeBundle.new(
14
+ %w[frontend detailed/ast detailed/builder lexer structured].freeze
15
+ ).freeze
16
+
17
+ def emit(parser:, artifacts:, class_name:, grammar_filename:, ruby_source:,
18
+ runtime_bundle: DETAILED_RUNTIME_BUNDLE, ast_type: :detailed)
19
+ rules = JSON.parse(artifacts.fetch("rules.json"))
20
+ runtime_options = artifacts.fetch("runtime", {})
21
+ runtime = runtime_bundle.templates.map do |template_name|
22
+ template = File.read(File.join(__dir__, "runtime", "#{template_name}.rb.erb"))
23
+ ERB.new(template, trim_mode: "-").result_with_hash(rules: rules, runtime: runtime_options,
24
+ ast_type: ast_type)
25
+ end.join("\n")
26
+ notices = [File.read(File.expand_path("../../../LICENSE.txt", __dir__))]
27
+ %w[COPYING BSDL].each do |name|
28
+ path = ruby_source ? File.join(ruby_source, name) : File.join(__dir__, name)
29
+ notices << File.read(path)
30
+ end
31
+ source = "# frozen_string_literal: true\n" \
32
+ "# Generated by Arpaka #{Lrama::Ruby::VERSION}. Do not edit.\n" \
33
+ "# Ruby source profile: #{ruby_source ? artifacts.fetch('source_profile') : 'parse.y only'}\n" \
34
+ "# parse.y SHA256: #{Digest::SHA256.file(grammar_filename).hexdigest}\n" \
35
+ "#{notices.join("\n").lines.map { |line| "# #{line}" }.join}\n"
36
+ source << wrap_class(class_name, parser, runtime)
37
+ validate(source)
38
+ source
39
+ rescue SyntaxError => error
40
+ raise ::Arpaka::Error, "Invalid generated frontend: #{error.message}"
41
+ end
42
+
43
+ private
44
+
45
+ def validate(source)
46
+ # Compile in an isolated box to validate syntax without executing actions.
47
+ ::Ruby::Box.new.eval(source)
48
+ end
49
+
50
+ def wrap_class(class_name, parser, runtime)
51
+ definition = "class #{class_name.constant}\n#{parser}\n#{runtime}\nend\n"
52
+ class_name.namespaces.reverse_each do |namespace|
53
+ body = definition.lines.map { |line| " #{line}" }.join
54
+ definition = "module #{namespace}\n#{body}end\n"
55
+ end
56
+ definition
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "grammar"
4
+ require_relative "class_name_parser"
5
+ require_relative "frontend_emitter"
6
+
7
+ module Arpaka::Ruby
8
+ class Generator
9
+ def generate(ruby_source: nil, parse_y: nil, class_name:, ast_type: :detailed,
10
+ runtime_bundle: nil)
11
+ if ruby_source.nil? == parse_y.nil?
12
+ raise ::Arpaka::Error, "specify exactly one of ruby_source or parse_y"
13
+ end
14
+ ast_type = case ast_type.to_s
15
+ when "detailed" then :detailed
16
+ when "structured" then :structured
17
+ else
18
+ raise ::Arpaka::Error, "ast_type must be detailed or structured"
19
+ end
20
+ runtime_bundle ||= ast_type == :structured ?
21
+ FrontendEmitter::STRUCTURED_RUNTIME_BUNDLE : FrontendEmitter::DETAILED_RUNTIME_BUNDLE
22
+ class_name = ClassNameParser.parse(class_name)
23
+ unless ::Ruby::Box.enabled?
24
+ raise ::Arpaka::Error, "Ruby Box is required for generation. Start Ruby with RUBY_BOX=1."
25
+ end
26
+ artifacts = Grammar.artifacts(ruby_source: ruby_source, parse_y: parse_y)
27
+ grammar_filename = parse_y || File.join(ruby_source, "parse.y")
28
+ parser = Lrama::Ruby.generate(artifacts.fetch("parse.y"),
29
+ filename: grammar_filename, class_name: "Parser", allow_error_rules: true)
30
+ parser = parser.delete_prefix("# frozen_string_literal: true\n")
31
+ FrontendEmitter.new.emit(parser: parser, artifacts: artifacts, class_name: class_name,
32
+ grammar_filename: grammar_filename, ruby_source: ruby_source, runtime_bundle: runtime_bundle,
33
+ ast_type: ast_type)
34
+ rescue SyntaxError => error
35
+ raise ::Arpaka::Error, "Invalid generated frontend: #{error.message}"
36
+ rescue SystemCallError => error
37
+ raise ::Arpaka::Error, "Cannot read Ruby frontend inputs: #{error.message}"
38
+ end
39
+
40
+ end
41
+ end
@@ -3,16 +3,10 @@
3
3
  require "lrama"
4
4
  require "json"
5
5
  require "digest"
6
- require "open3"
7
- require "rbconfig"
8
- require_relative "actions"
6
+ require_relative "grammar_emitter"
9
7
 
10
- module Arpaka::RubyGrammar
8
+ module Arpaka::Ruby::Grammar
11
9
  REVISION = "37d60dd3241d5fdb10ca43f36077f5d556ae1b8d"
12
- EXTENDED_RULES = {
13
- ["value_expr_command", ["command"]] => "tLBRACE_ARG"
14
- }.freeze
15
-
16
10
  def self.parse(source, filename)
17
11
  grammar = Lrama::Parser.new(source, filename).parse
18
12
  unless grammar.no_stdlib
@@ -35,10 +29,20 @@ module Arpaka::RubyGrammar
35
29
  warn "Arpaka: Ruby source differs from the recorded profile: #{path}; " \
36
30
  "expected SHA256 #{digest}, got #{actual}."
37
31
  end
38
- source, error, result = Open3.capture3(RbConfig.ruby,
39
- File.join(vendor, "tool/id2token.rb"), File.join(vendor, "parse.y"))
40
- raise ::Arpaka::Error, "Ruby preprocessing failed: #{error}" unless result.success?
32
+ source = preprocess(File.read(File.join(vendor, "parse.y")),
33
+ File.join(vendor, "defs/id.def"))
41
34
  grammar = parse(source, File.join(vendor, "parse.y"))
35
+ [grammar, inventory(grammar)]
36
+ end
37
+
38
+ def self.load_parse_y(parse_y)
39
+ path = File.expand_path(parse_y)
40
+ source = preprocess_names(File.read(path))
41
+ grammar = parse(source, path)
42
+ [grammar, inventory(grammar)]
43
+ end
44
+
45
+ def self.inventory(grammar)
42
46
  inventory = grammar.rules.reject(&:initial_rule?).map do |rule|
43
47
  { "id" => rule.id, "lhs" => rule.lhs.id.s_value,
44
48
  "rhs" => rule.rhs.map { |symbol| symbol.id.s_value },
@@ -47,9 +51,34 @@ module Arpaka::RubyGrammar
47
51
  "midrule_position" => rule.position_in_original_rule_rhs,
48
52
  "precedence" => rule.precedence_sym&.id&.s_value }
49
53
  end
50
- unknown = ::Arpaka::RubyGrammarActions::ACTIONS.keys - inventory.map { |rule| rule.fetch("id") }
51
- warn "Arpaka: Unknown action IDs: #{unknown.inspect}" unless unknown.empty?
52
- [grammar, inventory]
54
+ inventory
55
+ end
56
+
57
+ # Ruby's id.def is Ruby code. Evaluate it in a Box so its temporary locals,
58
+ # methods and constants cannot affect Arpaka or Lrama's process.
59
+ def self.preprocess(source, id_def)
60
+ box = ::Ruby::Box.new
61
+ ids = box.eval(File.read(id_def))
62
+ tokens = ids.fetch(:token_op).each_with_object({}) do |(_id, _op, token, id), result|
63
+ result[token] = id if token
64
+ end
65
+ return source unless tokens.any?
66
+
67
+ names = tokens.keys.map { |name| Regexp.escape(name) }.join("|")
68
+ token_pattern = /\bRUBY_TOKEN\((#{names})\)\s*(?=\s)/
69
+ source.lines.map do |line|
70
+ if line.start_with?("%token")
71
+ line.gsub(token_pattern) { tokens.fetch(Regexp.last_match(1)).to_s }
72
+ else
73
+ line
74
+ end
75
+ end.join
76
+ rescue KeyError, TypeError, SyntaxError => error
77
+ raise ::Arpaka::Error, "Ruby id.def preprocessing failed: #{error.message}"
78
+ end
79
+
80
+ def self.preprocess_names(source)
81
+ source.gsub(/\bRUBY_TOKEN\(([A-Za-z_][A-Za-z0-9_]*)\)/, '\\1')
53
82
  end
54
83
 
55
84
  def self.symbol_names(grammar)
@@ -69,52 +98,17 @@ module Arpaka::RubyGrammar
69
98
  end
70
99
  end
71
100
 
72
- def self.artifacts(ruby_source:)
73
- grammar, inventory = load_upstream(ruby_source)
74
- names = symbol_names(grammar)
75
- name = ->(symbol) { symbol.term ? symbol.id.s_value : names.fetch(symbol.id.s_value) }
76
- lines = ["/* Generated from ruby/ruby #{REVISION}. See Arpaka Action mappings. */",
77
- "%no-stdlib", "%expect #{grammar.expect || 0}"]
78
- lines << "%define lr.type ielr" if grammar.ielr_defined?
79
- grammar.terms.each do |symbol|
80
- next if symbol.error_symbol? || symbol.undef_symbol?
81
- declaration = "%token #{name.call(symbol)} #{symbol.token_id}"
82
- declaration += " #{symbol.alias_name}" if symbol.alias_name
83
- lines << declaration
84
- end
85
- grammar.precedences.group_by(&:precedence).sort.each do |_level, group|
86
- lines << "%#{group.first.type} #{group.map(&:s_value).join(' ')}"
101
+ def self.artifacts(ruby_source: nil, parse_y: nil)
102
+ grammar, inventory = if ruby_source
103
+ load_upstream(ruby_source)
104
+ else
105
+ load_parse_y(parse_y)
87
106
  end
88
- lines << "%left keyword_do_block"
89
- lines << "%start #{name.call(grammar.rules.first.rhs.first)}"
90
- lines << "%%"
91
- metadata = inventory.to_h { |rule| [rule.fetch("id"), rule] }
92
- rules = grammar.rules.reject(&:initial_rule?)
93
- rules.each do |rule|
94
- rhs = rule.rhs.empty? ? "%empty" : rule.rhs.map(&name).join(" ")
95
- extended_precedence = EXTENDED_RULES[[rule.lhs.id.s_value, rule.rhs.map { |symbol| symbol.id.s_value }]]
96
- precedence = if rule.precedence_sym
97
- " %prec #{name.call(rule.precedence_sym)}"
98
- elsif extended_precedence
99
- " %prec #{extended_precedence}"
100
- else
101
- ""
102
- end
103
- expression = ::Arpaka::RubyGrammarActions::ACTIONS[rule.id]&.last || "@builder.unsupported(#{rule.id})"
104
- lines << "/* upstream parse.y:#{metadata.fetch(rule.id).fetch('line')}: #{rule.as_comment} */"
105
- lines << "#{name.call(rule.lhs)}: #{rhs}#{precedence} { $$ = #{expression} };"
106
- end
107
- lines << "/* arpaka extension: block call as a parenthesized argument */"
108
- lines << "call_args: block_call { $$ = [$1].freeze };"
109
- lines << "call_args: args ',' block_call %prec tLOWEST { $$ = ($1 + [$3]).freeze };"
110
- source = lines.join("\n") + "\n"
107
+ names = symbol_names(grammar)
108
+ artifacts = ::Arpaka::Ruby::GrammarEmitter.new(grammar: grammar, inventory: inventory, names: names).emit
109
+ source = artifacts.fetch("parse.y")
111
110
  verify_structure(grammar, source, names)
112
- compact = rules.map do |rule|
113
- { id: rule.id, rule: rule.as_comment,
114
- line: metadata.fetch(rule.id).fetch("line"),
115
- status: ::Arpaka::RubyGrammarActions::ACTIONS[rule.id]&.first || "unsupported" }
116
- end
117
- { "parse.y" => source, "rules.json" => JSON.pretty_generate(compact) + "\n" }
111
+ artifacts
118
112
  end
119
113
 
120
114
  def self.verify_structure(original, source, names)
@@ -127,7 +121,7 @@ module Arpaka::RubyGrammar
127
121
  end.sort_by(&:inspect)
128
122
  end
129
123
  expected_rules = signature.call(original, names).map do |rule|
130
- if rule[0] == names.fetch("value_expr_command") && rule[1] == [names.fetch("command")]
124
+ if names.key?("value_expr_command") && rule[0] == names.fetch("value_expr_command") && rule[1] == [names.fetch("command")]
131
125
  rule[0, 2] + ["tLBRACE_ARG"]
132
126
  else
133
127
  rule
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "actions"
5
+ require_relative "profile"
6
+
7
+ module Arpaka::Ruby
8
+ class GrammarEmitter
9
+ EXTENDED_RULES = {
10
+ ["value_expr_command", ["command"]] => "tLBRACE_ARG"
11
+ }.freeze
12
+
13
+ def initialize(grammar:, inventory:, names:)
14
+ @grammar = grammar
15
+ @inventory = inventory
16
+ @names = names
17
+ end
18
+
19
+ def emit
20
+ name = ->(symbol) { symbol.term ? symbol.id.s_value : @names.fetch(symbol.id.s_value) }
21
+ profile = ::Arpaka::Ruby::Profile.select(@grammar)
22
+ expected_conflicts = (@grammar.expect || 0) + profile.fetch("additional_expected_conflicts", 0)
23
+ lines = ["/* Generated from ruby/ruby #{Arpaka::Ruby::Grammar::REVISION}. See Arpaka Action mappings. */",
24
+ "%no-stdlib", "%expect #{expected_conflicts}"]
25
+ lines << "%define lr.type ielr" if @grammar.ielr_defined?
26
+ @grammar.terms.each do |symbol|
27
+ next if symbol.error_symbol? || symbol.undef_symbol?
28
+ declaration = "%token #{name.call(symbol)} #{symbol.token_id}"
29
+ declaration += " #{symbol.alias_name}" if symbol.alias_name
30
+ lines << declaration
31
+ end
32
+ @grammar.precedences.group_by(&:precedence).sort.each do |_level, group|
33
+ lines << "%#{group.first.type} #{group.map(&:s_value).join(' ')}"
34
+ end
35
+ lines << "%left keyword_do_block"
36
+ lines << "%start #{name.call(@grammar.rules.first.rhs.first)}"
37
+ lines << "%%"
38
+ metadata = @inventory.to_h { |rule| [rule.fetch("id"), rule] }
39
+ rules = @grammar.rules.reject(&:initial_rule?)
40
+ rules.each do |rule|
41
+ rhs = rule.rhs.empty? ? "%empty" : rule.rhs.map(&name).join(" ")
42
+ extended_precedence = EXTENDED_RULES[[rule.lhs.id.s_value, rule.rhs.map { |symbol| symbol.id.s_value }]]
43
+ precedence = if rule.precedence_sym
44
+ " %prec #{name.call(rule.precedence_sym)}"
45
+ elsif extended_precedence
46
+ " %prec #{extended_precedence}"
47
+ else
48
+ ""
49
+ end
50
+ key = [rule.lhs.id.s_value, rule.rhs.map { |symbol| symbol.id.s_value }]
51
+ action = profile.fetch("compatibility_actions", {})[key] || profile.fetch("actions")[rule.id]
52
+ expression = action&.last || "@builder.unsupported(#{rule.id})"
53
+ lines << "/* upstream parse.y:#{metadata.fetch(rule.id).fetch('line')}: #{rule.as_comment} */"
54
+ lines << "#{name.call(rule.lhs)}: #{rhs}#{precedence} { $$ = #{expression} };"
55
+ end
56
+ lines << "/* arpaka extension: block call as a parenthesized argument */"
57
+ lines << "call_args: block_call { $$ = [$1].freeze };"
58
+ lines << "call_args: args ',' block_call %prec tLOWEST { $$ = ($1 + [$3]).freeze };"
59
+ source = lines.join("\n") + "\n"
60
+ compact = rules.map do |rule|
61
+ { id: rule.id, rule: rule.as_comment,
62
+ line: metadata.fetch(rule.id).fetch("line"),
63
+ status: (profile.fetch("compatibility_actions", {})[[rule.lhs.id.s_value,
64
+ rule.rhs.map { |symbol| symbol.id.s_value }]] || profile.fetch("actions")[rule.id])&.first || "unsupported" }
65
+ end
66
+ { "parse.y" => source, "rules.json" => JSON.pretty_generate(compact) + "\n",
67
+ "runtime" => profile.fetch("runtime", {}),
68
+ "source_profile" => profile.fetch("source_tag", Arpaka::Ruby::Grammar::REVISION) }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Arpaka
6
+ module Ruby
7
+ module Profile
8
+ CURRENT = {
9
+ "name" => "ruby-4.0",
10
+ "actions" => ::Arpaka::Ruby::Actions::ACTIONS,
11
+ # Rule numbers are stable only within one parse.y revision. Keep
12
+ # this Ruby 4.0-specific reduction keyed by its grammar signature so
13
+ # the shared action table cannot accidentally apply the old action to
14
+ # a shifted rule number.
15
+ "compatibility_actions" => {
16
+ ["top_stmt", ["keyword_BEGIN", "begin_block"]] =>
17
+ ["ported", "@builder.begin_block($2)"],
18
+ ["stmt_or_begin", ["keyword_BEGIN", "$@5", "begin_block"]] =>
19
+ ["ported", "@builder.begin_block($3)"],
20
+ ["def_endless_method_endless_command", ["defn_head", "f_opt_paren_args", "'='", "endless_command"]] =>
21
+ ["ported", "@builder.def_node($1, $2, @builder.body_nodes($4))"],
22
+ ["def_endless_method_endless_command", ["defs_head", "f_opt_paren_args", "'='", "endless_command"]] =>
23
+ ["ported", "@builder.singleton_method_node($1[0], $1[2], $2, @builder.body_nodes($4))"],
24
+ ["def_endless_method_endless_arg", ["defn_head", "f_opt_paren_args", "'='", "endless_arg"]] =>
25
+ ["ported", "@builder.def_node($1, $2, @builder.body_nodes($4))"],
26
+ ["def_endless_method_endless_arg", ["defs_head", "f_opt_paren_args", "'='", "endless_arg"]] =>
27
+ ["ported", "@builder.singleton_method_node($1[0], $1[2], $2, @builder.body_nodes($4))"],
28
+ ["begin_block", ["block_open", "compstmt_top_stmts", "'}'"]] =>
29
+ ["pass_through", "$2"],
30
+ ["stmt", ["k_END", "block_open", "compstmt_stmts", "'}'"]] =>
31
+ ["ported", "@builder.end_block($3)"],
32
+ ["mlhs_basic", ["mlhs_head", "tSTAR", "mlhs_node", "','", "mlhs_items_mlhs_item"]] =>
33
+ ["ported", "@builder.multiple_assign_post($1, $3, $5)"],
34
+ ["mlhs_basic", ["mlhs_head", "tSTAR", "','", "mlhs_items_mlhs_item"]] =>
35
+ ["ported", "@builder.multiple_assign_post($1, :NODE_SPECIAL_NO_NAME_REST, $4)"],
36
+ ["mlhs_basic", ["tSTAR", "mlhs_node", "','", "mlhs_items_mlhs_item"]] =>
37
+ ["ported", "@builder.multiple_assign_post([], $2, $4)"],
38
+ ["mlhs_basic", ["tSTAR", "','", "mlhs_items_mlhs_item"]] =>
39
+ ["ported", "@builder.multiple_assign_post([], :NODE_SPECIAL_NO_NAME_REST, $3)"],
40
+ ["f_opt_primary_value", ["f_arg_asgn", "f_eq", "primary_value"]] =>
41
+ ["ported", "@builder.parameter(:optional, $1, $3)"],
42
+ ["f_opt_arg_primary_value", ["f_opt_arg_primary_value", "','", "f_opt_primary_value"]] =>
43
+ ["ported", "@builder.parameter_list($1, $3)"],
44
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
45
+ ["f_arg", "','", "f_opt_arg_primary_value", "','", "f_rest_arg", "opt_args_tail_block_args_tail_none"]] =>
46
+ ["ported", "@builder.parameter_list(@builder.append_block_rest(@builder.parameter_list($1, $3), $5), $6)"],
47
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
48
+ ["f_arg", "','", "f_opt_arg_primary_value", "','", "f_rest_arg", "','", "f_arg", "opt_args_tail_block_args_tail_none"]] =>
49
+ ["ported", "@builder.parameter_list(@builder.append_block_rest(@builder.parameter_list($1, $3), $5, $7), $8)"],
50
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
51
+ ["f_arg", "','", "f_opt_arg_primary_value", "opt_args_tail_block_args_tail_none"]] =>
52
+ ["ported", "@builder.parameter_list($1, $3, $4)"],
53
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
54
+ ["f_arg", "','", "f_opt_arg_primary_value", "','", "f_arg", "opt_args_tail_block_args_tail_none"]] =>
55
+ ["ported", "@builder.parameter_list($1, $3, $5, $6)"],
56
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
57
+ ["f_arg", "','", "f_rest_arg", "opt_args_tail_block_args_tail_none"]] =>
58
+ ["ported", "@builder.parameter_list(@builder.append_block_rest($1, $3), $4)"],
59
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
60
+ ["f_arg", "','", "f_rest_arg", "','", "f_arg", "opt_args_tail_block_args_tail_none"]] =>
61
+ ["ported", "@builder.parameter_list(@builder.append_block_rest($1, $3, $5), $6)"],
62
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
63
+ ["f_opt_arg_primary_value", "','", "f_rest_arg", "opt_args_tail_block_args_tail_none"]] =>
64
+ ["ported", "@builder.parameter_list(@builder.append_block_rest([], $1), $3)"],
65
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
66
+ ["f_opt_arg_primary_value", "','", "f_rest_arg", "','", "f_arg", "opt_args_tail_block_args_tail_none"]] =>
67
+ ["ported", "@builder.parameter_list(@builder.append_block_rest([], $1, $5), $6)"],
68
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
69
+ ["f_opt_arg_primary_value", "opt_args_tail_block_args_tail_none"]] =>
70
+ ["ported", "@builder.parameter_list($1, $2)"],
71
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
72
+ ["f_opt_arg_primary_value", "','", "f_arg", "opt_args_tail_block_args_tail_none"]] =>
73
+ ["ported", "@builder.parameter_list($1, $3, $4)"],
74
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
75
+ ["f_rest_arg", "opt_args_tail_block_args_tail_none"]] =>
76
+ ["ported", "(@builder.append_block_rest([], $1) + Array($2)).freeze"],
77
+ ["args-list_primary_value_opt_args_tail_block_args_tail_none",
78
+ ["f_rest_arg", "','", "f_arg", "opt_args_tail_block_args_tail_none"]] =>
79
+ ["ported", "@builder.parameter_list(@builder.append_block_rest([], $1, $3), $4)"],
80
+ ["block_param", ["f_arg", "excessed_comma"]] =>
81
+ ["ported", "@builder.parameter_list($1)"],
82
+ ["block_param", ["f_arg", "opt_args_tail_block_args_tail_none"]] =>
83
+ ["ported", "@builder.parameter_list($1, $2)"],
84
+ ["block_param", ["tail-only-args_block_args_tail"]] =>
85
+ ["ported", "@builder.parameter_list($1)"],
86
+ }.freeze,
87
+ "additional_expected_conflicts" => 0,
88
+ "runtime" => {
89
+ "ast_nodes" => %w[self assignment singleton_method singleton_class pattern keyword_arguments].freeze,
90
+ "builder_methods" => %w[self assignment singleton_method singleton_class pattern keyword_arguments rescue].freeze,
91
+ "lexer" => { "source_text_tokens" => %w[tOP_ASGN tUMINUS tUPLUS].freeze }.freeze
92
+ }.freeze
93
+ }.freeze
94
+
95
+ def self.select(grammar)
96
+ if grammar.rules.any? { |rule| rule.lhs.id.s_value == "value_expr_command" }
97
+ CURRENT
98
+ elsif grammar.rules.any? { |rule| rule.lhs.id.s_value == "top_compstmt" &&
99
+ rule.rhs.any? { |symbol| symbol.id.s_value == "option_terms" } }
100
+ legacy
101
+ elsif grammar.rules.any? { |rule| rule.lhs.id.s_value == "top_compstmt" &&
102
+ rule.rhs.any? { |symbol| symbol.id.s_value == "opt_terms" } }
103
+ legacy_3_3
104
+ else
105
+ raise ::Arpaka::Error, "Unsupported Ruby parse.y profile"
106
+ end
107
+ end
108
+
109
+ def self.legacy
110
+ @legacy ||= load("profiles/ruby_3_4.rb", "ruby-3.4")
111
+ end
112
+
113
+ def self.legacy_3_3
114
+ @legacy_3_3 ||= load("profiles/ruby_3_3.rb", "ruby-3.3")
115
+ end
116
+
117
+ def self.load(filename, name)
118
+ box = ::Ruby::Box.new
119
+ profile = box.eval(File.read(File.join(__dir__, filename)))
120
+ unless profile.is_a?(Hash) && profile["name"] == name
121
+ raise ::Arpaka::Error, "Invalid #{name} profile"
122
+ end
123
+ profile.freeze
124
+ rescue SyntaxError, TypeError => error
125
+ raise ::Arpaka::Error, "Ruby profile loading failed: #{error.message}"
126
+ end
127
+ end
128
+ end
129
+ end