Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ preserved unless `--force` is supplied. A failed generation leaves the previous
file intact. The output includes its source profile, generator version and
copyright/license notices.

When only `parse.y` is available, Arpaka can replace its `RUBY_TOKEN(NAME)`
declarations with token names and generate a frontend without the rest of the
Ruby source tree:

```sh
RUBY_BOX=1 bundle exec arpaka generate \
--parse-y /path/to/parse.y \
--class-name MyRubyParser \
--output lib/my_library/ruby_parser.rb
```

This mode embeds Arpaka's own license notices. The `--ruby-source` mode remains
the compatibility-preserving path when the Ruby source tree is available.

The consumer only needs the generated file and Ruby 4.0 or later:

```ruby
Expand Down
8 changes: 4 additions & 4 deletions lib/arpaka.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
module Arpaka
class Error < Lrama::Ruby::Error; end

def self.generate(ruby_source:, class_name: "RubyParser")
Generator.new.generate(ruby_source: ruby_source, class_name: class_name)
def self.generate(ruby_source: nil, parse_y: nil, class_name: "RubyParser")
Generator.new.generate(ruby_source: ruby_source, parse_y: parse_y, class_name: class_name)
end

def self.compile(ruby_source:, class_name: "RubyParser")
source = generate(ruby_source: ruby_source, class_name: class_name)
def self.compile(ruby_source: nil, parse_y: nil, class_name: "RubyParser")
source = generate(ruby_source: ruby_source, parse_y: parse_y, class_name: class_name)
box = ::Ruby::Box.new
box.eval(source)
box.const_get(class_name, false)
Expand Down
7 changes: 5 additions & 2 deletions lib/arpaka/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ def self.run(argv, out: $stdout, err: $stderr)
force = false
help = false
parser = OptionParser.new do |opts|
opts.banner = "Usage: arpaka generate --ruby-source DIR --output FILE [--class-name NAME] [--force]"
opts.banner = "Usage: arpaka generate (--ruby-source DIR | --parse-y FILE) --output FILE [--class-name NAME] [--force]"
opts.on("--ruby-source DIR", "Ruby source tree (provided by the user)") { |v| options[:ruby_source] = v }
opts.on("--parse-y FILE", "parse.y file (use Arpaka's token definitions)") { |v| options[:parse_y] = v }
opts.on("--output FILE", "Write a standalone Ruby frontend") { |v| options[:output] = v }
opts.on("--class-name NAME", "Generated class name (default: RubyParser)") { |v| options[:class_name] = v }
opts.on("--force", "Replace an existing output file") { force = true }
Expand All @@ -31,7 +32,9 @@ def self.run(argv, out: $stdout, err: $stderr)
return 0
end
raise OptionParser::InvalidArgument, args.join(" ") unless args.empty?
%i[ruby_source output].each do |key|
raise OptionParser::InvalidArgument, "specify exactly one of --ruby-source or --parse-y" if options[:ruby_source] == options[:parse_y]
raise OptionParser::MissingArgument, "--output" unless options[:output]
%i[output].each do |key|
raise OptionParser::MissingArgument, "--#{key.to_s.tr('_', '-')}" unless options[key]
end
output = File.expand_path(options.delete(:output))
Expand Down
22 changes: 15 additions & 7 deletions lib/arpaka/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,36 @@

module Arpaka
class Generator
def generate(ruby_source:, class_name:)
def generate(ruby_source: nil, parse_y: nil, class_name:)
if ruby_source.nil? == parse_y.nil?
raise Error, "specify exactly one of ruby_source or parse_y"
end
unless /\A[A-Z][a-zA-Z0-9_]*\z/.match?(class_name)
raise Error, "class_name must be a single Ruby constant name"
end
unless ::Ruby::Box.enabled?
raise Error, "Ruby Box is required for generation. Start Ruby with RUBY_BOX=1."
end
artifacts = RubyGrammar.artifacts(ruby_source: ruby_source)
artifacts = RubyGrammar.artifacts(ruby_source: ruby_source, parse_y: parse_y)
grammar_filename = parse_y || File.join(ruby_source, "parse.y")
parser = Lrama::Ruby.generate(artifacts.fetch("parse.y"),
filename: File.join(ruby_source, "parse.y"), class_name: "Parser", allow_error_rules: true)
filename: grammar_filename, class_name: "Parser", allow_error_rules: true)
parser = parser.delete_prefix("# frozen_string_literal: true\n")
rules = JSON.parse(artifacts.fetch("rules.json"))
runtime_options = artifacts.fetch("runtime", {})
runtime = %w[frontend ast builder lexer].map do |name|
template = File.read(File.join(__dir__, "runtime", "#{name}.rb.erb"))
name == "builder" ? ERB.new(template).result_with_hash(rules: rules) : template
ERB.new(template, trim_mode: "-").result_with_hash(rules: rules, runtime: runtime_options)
end.join("\n")
notices = [File.read(File.expand_path("../../LICENSE.txt", __dir__))]
%w[COPYING BSDL].each { |name| notices << File.read(File.join(ruby_source, name)) }
%w[COPYING BSDL].each do |name|
path = ruby_source ? File.join(ruby_source, name) : File.join(__dir__, name)
notices << File.read(path)
end
source = "# frozen_string_literal: true\n" \
"# Generated by Arpaka #{Lrama::Ruby::VERSION}. Do not edit.\n" \
"# Ruby source profile: #{RubyGrammar::REVISION}\n" \
"# parse.y SHA256: #{Digest::SHA256.file(File.join(ruby_source, 'parse.y')).hexdigest}\n" \
"# Ruby source profile: #{ruby_source ? RubyGrammar::REVISION : 'parse.y only'}\n" \
"# parse.y SHA256: #{Digest::SHA256.file(grammar_filename).hexdigest}\n" \
"#{notices.join("\n").lines.map { |line| "# #{line}" }.join}\n" \
"class #{class_name}\n#{parser}\n#{runtime}\nend\n"
# Compile in an isolated box to validate syntax without executing actions.
Expand Down
39 changes: 39 additions & 0 deletions lib/arpaka/profile/ruby.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require "json"

module Arpaka
module Profile
module RUBY
CURRENT = {
"name" => "ruby-4.0",
"actions" => ::Arpaka::RubyGrammarActions::ACTIONS,
"additional_expected_conflicts" => 0,
"runtime" => {}.freeze
}.freeze

def self.select(grammar)
if grammar.rules.any? { |rule| rule.lhs.id.s_value == "value_expr_command" }
CURRENT
elsif grammar.rules.any? { |rule| rule.lhs.id.s_value == "top_compstmt" }
legacy
else
raise ::Arpaka::Error, "Unsupported Ruby parse.y profile"
end
end

def self.legacy
@legacy ||= begin
box = ::Ruby::Box.new
profile = box.eval(File.read(File.join(__dir__, "ruby_3_4.rb")))
unless profile.is_a?(Hash) && profile["name"] == "ruby-3.4"
raise ::Arpaka::Error, "Invalid Ruby 3.4 profile"
end
profile.freeze
rescue SyntaxError, TypeError => error
raise ::Arpaka::Error, "Ruby legacy profile loading failed: #{error.message}"
end
end
end
end
end
Loading