From f916d4e9880cd9aaeec0c5b8acc04fd8572ea35c Mon Sep 17 00:00:00 2001 From: "MATSUMOTO, Katsuyoshi" Date: Sat, 19 Sep 2026 18:44:54 +0900 Subject: [PATCH 1/3] Refactor Ruby frontend boundary --- AGENTS.md | 4 +- README.md | 6 +- lib/arpaka/cli.rb | 55 +--------------- lib/arpaka/ruby.rb | 18 ++++++ lib/arpaka/{ => ruby}/BSDL | 0 lib/arpaka/{ => ruby}/COPYING | 0 lib/arpaka/{ => ruby}/actions.rb | 2 +- lib/arpaka/ruby/cli.rb | 63 +++++++++++++++++++ lib/arpaka/{ => ruby}/generator.rb | 18 +++--- .../{ruby_grammar.rb => ruby/grammar.rb} | 6 +- .../{profile/ruby.rb => ruby/profile.rb} | 10 +-- .../{profile => ruby/profiles}/ruby_3_3.rb | 0 .../{profile => ruby/profiles}/ruby_3_4.rb | 0 lib/arpaka/{ => ruby}/runtime/ast.rb.erb | 0 lib/arpaka/{ => ruby}/runtime/builder.rb.erb | 0 lib/arpaka/{ => ruby}/runtime/frontend.rb.erb | 0 lib/arpaka/{ => ruby}/runtime/lexer.rb.erb | 0 lib/arpaka/{ => ruby}/source.json | 0 lib/arpaka/{ => ruby}/upstream_rules.json | 0 sig/arpaka.rbs | 4 +- test/fixtures/plugins/sample_plugin.rb | 10 +++ test/lrama/frontend_generator_test.rb | 19 +++++- test/lrama/ruby_3_3_profile_test.rb | 2 +- test/lrama/sample_plugin_test.rb | 21 +++++++ tool/check_package.rb | 11 ++-- 25 files changed, 164 insertions(+), 85 deletions(-) create mode 100644 lib/arpaka/ruby.rb rename lib/arpaka/{ => ruby}/BSDL (100%) rename lib/arpaka/{ => ruby}/COPYING (100%) rename lib/arpaka/{ => ruby}/actions.rb (99%) create mode 100644 lib/arpaka/ruby/cli.rb rename lib/arpaka/{ => ruby}/generator.rb (74%) rename lib/arpaka/{ruby_grammar.rb => ruby/grammar.rb} (98%) rename lib/arpaka/{profile/ruby.rb => ruby/profile.rb} (85%) rename lib/arpaka/{profile => ruby/profiles}/ruby_3_3.rb (100%) rename lib/arpaka/{profile => ruby/profiles}/ruby_3_4.rb (100%) rename lib/arpaka/{ => ruby}/runtime/ast.rb.erb (100%) rename lib/arpaka/{ => ruby}/runtime/builder.rb.erb (100%) rename lib/arpaka/{ => ruby}/runtime/frontend.rb.erb (100%) rename lib/arpaka/{ => ruby}/runtime/lexer.rb.erb (100%) rename lib/arpaka/{ => ruby}/source.json (100%) rename lib/arpaka/{ => ruby}/upstream_rules.json (100%) create mode 100644 test/fixtures/plugins/sample_plugin.rb create mode 100644 test/lrama/sample_plugin_test.rb diff --git a/AGENTS.md b/AGENTS.md index 55e29bf..82a0349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,8 @@ versions is not currently promised. - `lib/lrama/ruby/parser.rb.erb`: standalone parser template. - `lib/lrama/ruby/backend.rb` and `action_code.rb`: internal files loaded in the generator's box. - `examples/calculator.y`: executable example grammar. -- `lib/arpaka/`: frontend generator, CLI, source profile and Action mappings. -- `lib/arpaka/runtime/`: AST, builder, lexer and frontend source templates. +- `lib/arpaka/`: public frontend entry points and compatibility CLI. +- `lib/arpaka/ruby/`: Ruby frontend generator, CLI, source profiles, Action mappings and runtime templates. - `exe/arpaka`: installed generation command. - `tool/ruby/`: development entry point for frontend generation and checks. - `vendor/ruby/`: pinned upstream grammar, preprocessing tools and license files. diff --git a/README.md b/README.md index 47f7967..c9f4d30 100644 --- a/README.md +++ b/README.md @@ -273,10 +273,10 @@ requires an explicit Ruby source tree. `ruby:check` verifies the source profile, Action inventory, grammar transformation and generated frontend without editing artifacts. `ruby:generate` writes the standalone frontend to `OUTPUT`. -`lib/arpaka/actions.rb` holds the Ruby Action mappings; -`lib/arpaka/upstream_rules.json` records the expected expanded upstream rules. +`lib/arpaka/ruby/actions.rb` holds the Ruby Action mappings; +`lib/arpaka/ruby/upstream_rules.json` records the expected expanded upstream rules. Update these and the source checksums together when adding support for a changed -Ruby grammar. Runtime templates live under `lib/arpaka/runtime/`. +Ruby grammar. Runtime templates live under `lib/arpaka/ruby/runtime/`. Tests exercise the generated frontend and the generic backend. Package checks install the gem, generate from external Ruby sources, and execute the resulting diff --git a/lib/arpaka/cli.rb b/lib/arpaka/cli.rb index a67138c..bba239e 100644 --- a/lib/arpaka/cli.rb +++ b/lib/arpaka/cli.rb @@ -1,63 +1,12 @@ # frozen_string_literal: true -require "optparse" -require "tempfile" require_relative "../arpaka" +require_relative "ruby/cli" module Arpaka class CLI def self.run(argv, out: $stdout, err: $stderr) - options = { class_name: "RubyParser" } - force = false - help = false - parser = OptionParser.new do |opts| - 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 } - opts.on("-h", "--help", "Show usage") { help = true } - end - args = argv.dup - command = args.shift - if ["--help", "-h"].include?(command) - out.puts(parser) - return 0 - end - raise OptionParser::InvalidArgument, "expected generate" unless command == "generate" - parser.parse!(args) - if help - out.puts(parser) - return 0 - end - raise OptionParser::InvalidArgument, args.join(" ") unless args.empty? - 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)) - if !force && (File.exist?(output) || File.symlink?(output)) - raise Error, "Output already exists: #{output}; use --force to replace it" - end - source = Arpaka.generate(**options) - Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file| - file.write(source) - file.flush - file.chmod(0o644) - if force - File.rename(file.path, output) - else - # Publish only the complete file, without overwriting a concurrent writer. - File.link(file.path, output) - end - end - out.puts("Generated #{output}") - 0 - rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error - err.puts("arpaka: #{error.message}") - 1 + Ruby::CLI.run(argv, out: out, err: err) end end end diff --git a/lib/arpaka/ruby.rb b/lib/arpaka/ruby.rb new file mode 100644 index 0000000..1bec4bf --- /dev/null +++ b/lib/arpaka/ruby.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Arpaka + module Ruby + 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: 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) + end + end +end + +require_relative "ruby/generator" diff --git a/lib/arpaka/BSDL b/lib/arpaka/ruby/BSDL similarity index 100% rename from lib/arpaka/BSDL rename to lib/arpaka/ruby/BSDL diff --git a/lib/arpaka/COPYING b/lib/arpaka/ruby/COPYING similarity index 100% rename from lib/arpaka/COPYING rename to lib/arpaka/ruby/COPYING diff --git a/lib/arpaka/actions.rb b/lib/arpaka/ruby/actions.rb similarity index 99% rename from lib/arpaka/actions.rb rename to lib/arpaka/ruby/actions.rb index 22eba93..54728c0 100644 --- a/lib/arpaka/actions.rb +++ b/lib/arpaka/ruby/actions.rb @@ -2,7 +2,7 @@ # Rule IDs refer to upstream_rules.json, validated against the pinned source. # Each entry is [classification, Ruby expression used as the reduction value]. -module Arpaka::RubyGrammarActions +module Arpaka::Ruby::Actions ACTIONS = { 1 => ["empty", "nil"], 2 => ["empty", "nil"], 3 => ["empty", "nil"], 4 => ["pass_through", "$1"], 5 => ["ported", "@builder.program($2)"], diff --git a/lib/arpaka/ruby/cli.rb b/lib/arpaka/ruby/cli.rb new file mode 100644 index 0000000..307ddc5 --- /dev/null +++ b/lib/arpaka/ruby/cli.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "optparse" +require "tempfile" +require_relative "../../arpaka" + +module Arpaka::Ruby + class CLI + def self.run(argv, out: $stdout, err: $stderr) + options = { class_name: "RubyParser" } + force = false + help = false + parser = OptionParser.new do |opts| + 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 } + opts.on("-h", "--help", "Show usage") { help = true } + end + args = argv.dup + command = args.shift + if ["--help", "-h"].include?(command) + out.puts(parser) + return 0 + end + raise OptionParser::InvalidArgument, "expected generate" unless command == "generate" + parser.parse!(args) + if help + out.puts(parser) + return 0 + end + raise OptionParser::InvalidArgument, args.join(" ") unless args.empty? + 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)) + if !force && (File.exist?(output) || File.symlink?(output)) + raise ::Arpaka::Error, "Output already exists: #{output}; use --force to replace it" + end + source = Arpaka::Ruby.generate(**options) + Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file| + file.write(source) + file.flush + file.chmod(0o644) + if force + File.rename(file.path, output) + else + # Publish only the complete file, without overwriting a concurrent writer. + File.link(file.path, output) + end + end + out.puts("Generated #{output}") + 0 + rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error + err.puts("arpaka: #{error.message}") + 1 + end + end +end diff --git a/lib/arpaka/generator.rb b/lib/arpaka/ruby/generator.rb similarity index 74% rename from lib/arpaka/generator.rb rename to lib/arpaka/ruby/generator.rb index e9c6a50..4c28f46 100644 --- a/lib/arpaka/generator.rb +++ b/lib/arpaka/ruby/generator.rb @@ -2,21 +2,21 @@ require "erb" require "json" -require_relative "ruby_grammar" +require_relative "grammar" -module Arpaka +module Arpaka::Ruby class Generator 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" + raise ::Arpaka::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" + raise ::Arpaka::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." + raise ::Arpaka::Error, "Ruby Box is required for generation. Start Ruby with RUBY_BOX=1." end - artifacts = RubyGrammar.artifacts(ruby_source: ruby_source, parse_y: parse_y) + artifacts = Grammar.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: grammar_filename, class_name: "Parser", allow_error_rules: true) @@ -27,7 +27,7 @@ def generate(ruby_source: nil, parse_y: nil, class_name:) template = File.read(File.join(__dir__, "runtime", "#{name}.rb.erb")) 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__))] + notices = [File.read(File.expand_path("../../../LICENSE.txt", __dir__))] %w[COPYING BSDL].each do |name| path = ruby_source ? File.join(ruby_source, name) : File.join(__dir__, name) notices << File.read(path) @@ -43,9 +43,9 @@ def generate(ruby_source: nil, parse_y: nil, class_name:) box.eval(source) source rescue SyntaxError => error - raise Error, "Invalid generated frontend: #{error.message}" + raise ::Arpaka::Error, "Invalid generated frontend: #{error.message}" rescue SystemCallError => error - raise Error, "Cannot read Ruby frontend inputs: #{error.message}" + raise ::Arpaka::Error, "Cannot read Ruby frontend inputs: #{error.message}" end end end diff --git a/lib/arpaka/ruby_grammar.rb b/lib/arpaka/ruby/grammar.rb similarity index 98% rename from lib/arpaka/ruby_grammar.rb rename to lib/arpaka/ruby/grammar.rb index 2761597..c99bee7 100644 --- a/lib/arpaka/ruby_grammar.rb +++ b/lib/arpaka/ruby/grammar.rb @@ -4,9 +4,9 @@ require "json" require "digest" require_relative "actions" -require_relative "profile/ruby" +require_relative "profile" -module Arpaka::RubyGrammar +module Arpaka::Ruby::Grammar REVISION = "37d60dd3241d5fdb10ca43f36077f5d556ae1b8d" EXTENDED_RULES = { ["value_expr_command", ["command"]] => "tLBRACE_ARG" @@ -110,7 +110,7 @@ def self.artifacts(ruby_source: nil, parse_y: nil) end names = symbol_names(grammar) name = ->(symbol) { symbol.term ? symbol.id.s_value : names.fetch(symbol.id.s_value) } - profile = ::Arpaka::Profile::RUBY.select(grammar) + profile = ::Arpaka::Ruby::Profile.select(grammar) expected_conflicts = (grammar.expect || 0) + profile.fetch("additional_expected_conflicts", 0) lines = ["/* Generated from ruby/ruby #{REVISION}. See Arpaka Action mappings. */", "%no-stdlib", "%expect #{expected_conflicts}"] diff --git a/lib/arpaka/profile/ruby.rb b/lib/arpaka/ruby/profile.rb similarity index 85% rename from lib/arpaka/profile/ruby.rb rename to lib/arpaka/ruby/profile.rb index d05630e..2e1e08a 100644 --- a/lib/arpaka/profile/ruby.rb +++ b/lib/arpaka/ruby/profile.rb @@ -3,11 +3,11 @@ require "json" module Arpaka - module Profile - module RUBY + module Ruby + module Profile CURRENT = { "name" => "ruby-4.0", - "actions" => ::Arpaka::RubyGrammarActions::ACTIONS, + "actions" => ::Arpaka::Ruby::Actions::ACTIONS, "additional_expected_conflicts" => 0, "runtime" => {}.freeze }.freeze @@ -27,11 +27,11 @@ def self.select(grammar) end def self.legacy - @legacy ||= load("ruby_3_4.rb", "ruby-3.4") + @legacy ||= load("profiles/ruby_3_4.rb", "ruby-3.4") end def self.legacy_3_3 - @legacy_3_3 ||= load("ruby_3_3.rb", "ruby-3.3") + @legacy_3_3 ||= load("profiles/ruby_3_3.rb", "ruby-3.3") end def self.load(filename, name) diff --git a/lib/arpaka/profile/ruby_3_3.rb b/lib/arpaka/ruby/profiles/ruby_3_3.rb similarity index 100% rename from lib/arpaka/profile/ruby_3_3.rb rename to lib/arpaka/ruby/profiles/ruby_3_3.rb diff --git a/lib/arpaka/profile/ruby_3_4.rb b/lib/arpaka/ruby/profiles/ruby_3_4.rb similarity index 100% rename from lib/arpaka/profile/ruby_3_4.rb rename to lib/arpaka/ruby/profiles/ruby_3_4.rb diff --git a/lib/arpaka/runtime/ast.rb.erb b/lib/arpaka/ruby/runtime/ast.rb.erb similarity index 100% rename from lib/arpaka/runtime/ast.rb.erb rename to lib/arpaka/ruby/runtime/ast.rb.erb diff --git a/lib/arpaka/runtime/builder.rb.erb b/lib/arpaka/ruby/runtime/builder.rb.erb similarity index 100% rename from lib/arpaka/runtime/builder.rb.erb rename to lib/arpaka/ruby/runtime/builder.rb.erb diff --git a/lib/arpaka/runtime/frontend.rb.erb b/lib/arpaka/ruby/runtime/frontend.rb.erb similarity index 100% rename from lib/arpaka/runtime/frontend.rb.erb rename to lib/arpaka/ruby/runtime/frontend.rb.erb diff --git a/lib/arpaka/runtime/lexer.rb.erb b/lib/arpaka/ruby/runtime/lexer.rb.erb similarity index 100% rename from lib/arpaka/runtime/lexer.rb.erb rename to lib/arpaka/ruby/runtime/lexer.rb.erb diff --git a/lib/arpaka/source.json b/lib/arpaka/ruby/source.json similarity index 100% rename from lib/arpaka/source.json rename to lib/arpaka/ruby/source.json diff --git a/lib/arpaka/upstream_rules.json b/lib/arpaka/ruby/upstream_rules.json similarity index 100% rename from lib/arpaka/upstream_rules.json rename to lib/arpaka/ruby/upstream_rules.json diff --git a/sig/arpaka.rbs b/sig/arpaka.rbs index 55109a0..3cb5b08 100644 --- a/sig/arpaka.rbs +++ b/sig/arpaka.rbs @@ -7,8 +7,8 @@ module Arpaka def parse: (String source, ?filename: String) -> _Program end - def self.generate: (ruby_source: String, ?class_name: String) -> String - def self.compile: (ruby_source: String, ?class_name: String) -> _Frontend + def self.generate: (?ruby_source: String?, ?parse_y: String?, ?class_name: String) -> String + def self.compile: (?ruby_source: String?, ?parse_y: String?, ?class_name: String) -> _Frontend class Error < ::Lrama::Ruby::Error end diff --git a/test/fixtures/plugins/sample_plugin.rb b/test/fixtures/plugins/sample_plugin.rb new file mode 100644 index 0000000..3960546 --- /dev/null +++ b/test/fixtures/plugins/sample_plugin.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "lrama/ruby" + +module SamplePlugin + def self.generate(grammar_path, class_name: "SampleParser") + grammar = File.read(grammar_path) + Lrama::Ruby.generate(grammar, filename: grammar_path, class_name: class_name) + end +end diff --git a/test/lrama/frontend_generator_test.rb b/test/lrama/frontend_generator_test.rb index 69c8890..af9b65f 100644 --- a/test/lrama/frontend_generator_test.rb +++ b/test/lrama/frontend_generator_test.rb @@ -82,7 +82,7 @@ def generated_source id_def = File.join(directory, "id.def") File.write(id_def, '{ token_op: [["identifier", "", "tIDENTIFIER", 1], ["integer", "", "tINTEGER", 2]] }') assert_equal("%token 1 2\n%token RUBY_TOKEN(tUNKNOWN)\n", - Arpaka::RubyGrammar.preprocess(source, id_def)) + Arpaka::Ruby::Grammar.preprocess(source, id_def)) end end @@ -128,6 +128,23 @@ def generated_source assert_false(Lrama::Ruby.respond_to?(:parse)) end + test "public API delegates to the Ruby frontend" do + options = { parse_y: File.join(RUBY_SOURCE, "parse.y"), class_name: "DelegatedRuby" } + assert_equal(Arpaka::Ruby.generate(**options), Arpaka.generate(**options)) + + frontend = Arpaka::Ruby.compile(**options) + assert_equal(42, frontend.parse("42").statements.first.value) + end + + test "public CLI delegates to the Ruby frontend CLI" do + public_out, public_err = StringIO.new, StringIO.new + ruby_out, ruby_err = StringIO.new, StringIO.new + assert_equal(Arpaka::Ruby::CLI.run(["--help"], out: ruby_out, err: ruby_err), + Arpaka::CLI.run(["--help"], out: public_out, err: public_err)) + assert_equal(ruby_out.string, public_out.string) + assert_equal(ruby_err.string, public_err.string) + end + test "CLI generates from parse.y" do Dir.mktmpdir do |directory| path = File.join(directory, "frontend.rb") diff --git a/test/lrama/ruby_3_3_profile_test.rb b/test/lrama/ruby_3_3_profile_test.rb index f5f84bf..667cda8 100644 --- a/test/lrama/ruby_3_3_profile_test.rb +++ b/test/lrama/ruby_3_3_profile_test.rb @@ -3,7 +3,7 @@ require "test_helper" class Ruby33ProfileTest < Test::Unit::TestCase - PROFILE = Arpaka::Profile::RUBY.send(:load, "ruby_3_3.rb", "ruby-3.3") + PROFILE = Arpaka::Ruby::Profile.send(:load, "profiles/ruby_3_3.rb", "ruby-3.3") test "records the stable Ruby 3.3 source profile" do assert_equal("v3_3_10", PROFILE.fetch("source_tag")) diff --git a/test/lrama/sample_plugin_test.rb b/test/lrama/sample_plugin_test.rb new file mode 100644 index 0000000..f7b23fe --- /dev/null +++ b/test/lrama/sample_plugin_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../fixtures/plugins/sample_plugin" +require "tmpdir" + +class SamplePluginTest < Test::Unit::TestCase + test "generates one parser from a grammar file" do + Dir.mktmpdir do |directory| + grammar_path = File.join(directory, "sample.y") + File.write(grammar_path, "%token NUMBER\n%%\nstart: NUMBER;") + + source = SamplePlugin.generate(grammar_path, class_name: "SampleParser") + box = Ruby::Box.new + box.eval(source) + parser = box.const_get(:SampleParser, false) + + assert_equal(7, parser.new.parse([[:NUMBER, 7]])) + end + end +end diff --git a/tool/check_package.rb b/tool/check_package.rb index 103ad5c..f0f1cec 100644 --- a/tool/check_package.rb +++ b/tool/check_package.rb @@ -49,11 +49,12 @@ forbidden = files.select { |file| file.start_with?("vendor/", "tool/", "test/", "benchmark/", ".local/") || %w[plan.md DESIGN.md AGENTS.md].include?(file) } abort "Development files packaged: #{forbidden.inspect}" unless forbidden.empty? abort "Ruby grammar packaged" if files.any? { |file| file.end_with?("/parse.y", "/rules.json") } - %w[exe/arpaka lib/arpaka/COPYING lib/arpaka/BSDL lib/arpaka/actions.rb - lib/arpaka/upstream_rules.json lib/arpaka/source.json - lib/arpaka/profile/ruby.rb lib/arpaka/profile/ruby_3_3.rb - lib/arpaka/profile/ruby_3_4.rb - lib/arpaka/runtime/lexer.rb.erb].each do |file| + %w[exe/arpaka lib/arpaka/ruby/COPYING lib/arpaka/ruby/BSDL + lib/arpaka/ruby/actions.rb lib/arpaka/ruby/upstream_rules.json + lib/arpaka/ruby/source.json lib/arpaka/ruby/profile.rb + lib/arpaka/ruby/profiles/ruby_3_3.rb + lib/arpaka/ruby/profiles/ruby_3_4.rb + lib/arpaka/ruby/runtime/lexer.rb.erb].each do |file| unless files.include?(file) && File.file?(File.join(spec.full_gem_path, file)) abort "Missing generation support: #{file}" end From c9ba0f32391119aa3267391bcbbcd2420c280ae1 Mon Sep 17 00:00:00 2001 From: "MATSUMOTO, Katsuyoshi" Date: Sat, 19 Sep 2026 18:45:20 +0900 Subject: [PATCH 2/3] Route Arpaka API through Ruby frontend --- lib/arpaka.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/arpaka.rb b/lib/arpaka.rb index 31b5817..4976a46 100644 --- a/lib/arpaka.rb +++ b/lib/arpaka.rb @@ -5,16 +5,13 @@ module Arpaka class Error < Lrama::Ruby::Error; end + require_relative "arpaka/ruby" + 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) + Ruby.generate(ruby_source: ruby_source, parse_y: parse_y, class_name: class_name) end 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) + Ruby.compile(ruby_source: ruby_source, parse_y: parse_y, class_name: class_name) end end - -require_relative "arpaka/generator" From bec3d033ed9194e85a22722462cf9596b9aad96b Mon Sep 17 00:00:00 2001 From: "MATSUMOTO, Katsuyoshi" Date: Sun, 20 Sep 2026 12:37:56 +0900 Subject: [PATCH 3/3] Add boxed sample plugin command --- README.md | 11 ++++++ lib/arpaka/cli.rb | 3 ++ lib/arpaka/plugins/cli.rb | 61 +++++++++++++++++++++++++++++ lib/arpaka/plugins/sample.rb | 14 +++++++ lib/arpaka/plugins/sample_runner.rb | 21 ++++++++++ test/lrama/sample_plugin_test.rb | 25 ++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 lib/arpaka/plugins/cli.rb create mode 100644 lib/arpaka/plugins/sample.rb create mode 100644 lib/arpaka/plugins/sample_runner.rb diff --git a/README.md b/README.md index c9f4d30..8035418 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,17 @@ RUBY_BOX=1 bundle exec arpaka generate \ 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 experimental sample plugin demonstrates the plugin command shape. It is +selected by the first command argument and runs in its own Ruby Box; the host +process writes the generated source: + +```sh +RUBY_BOX=1 bundle exec arpaka sample generate \ + -y /path/to/parser.y \ + -c MyParser \ + -o lib/my_library/parser.rb +``` + The consumer only needs the generated file and Ruby 4.0 or later: ```ruby diff --git a/lib/arpaka/cli.rb b/lib/arpaka/cli.rb index bba239e..a4ee0a9 100644 --- a/lib/arpaka/cli.rb +++ b/lib/arpaka/cli.rb @@ -2,10 +2,13 @@ require_relative "../arpaka" require_relative "ruby/cli" +require_relative "plugins/cli" module Arpaka class CLI def self.run(argv, out: $stdout, err: $stderr) + return Plugins::CLI.run(argv.drop(1), out: out, err: err) if argv.first == "sample" + Ruby::CLI.run(argv, out: out, err: err) end end diff --git a/lib/arpaka/plugins/cli.rb b/lib/arpaka/plugins/cli.rb new file mode 100644 index 0000000..ce25951 --- /dev/null +++ b/lib/arpaka/plugins/cli.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "optparse" +require "tempfile" +require_relative "sample_runner" + +module Arpaka + module Plugins + class CLI + def self.run(argv, out: $stdout, err: $stderr) + options = { class_name: "SampleParser" } + force = false + parser = OptionParser.new do |opts| + opts.banner = "Usage: arpaka sample generate -y FILE -o FILE [--class-name NAME] [--force]" + opts.on("-y FILE", "--grammar FILE", "Grammar file") { |v| options[:grammar] = v } + opts.on("-c NAME", "--class-name NAME", "Generated class name (default: SampleParser)") do |v| + options[:class_name] = v + end + opts.on("-o FILE", "--output FILE", "Write the generated parser") { |v| options[:output] = v } + opts.on("--force", "Replace an existing output file") { force = true } + opts.on("-h", "--help", "Show usage") { out.puts(parser); return 0 } + end + + args = argv.dup + command = args.shift + if ["--help", "-h"].include?(command) + out.puts(parser) + return 0 + end + raise OptionParser::InvalidArgument, "expected generate" unless command == "generate" + parser.parse!(args) + raise OptionParser::InvalidArgument, args.join(" ") unless args.empty? + raise OptionParser::MissingArgument, "--grammar" unless options[:grammar] + raise OptionParser::MissingArgument, "--output" unless options[:output] + + output = File.expand_path(options.delete(:output)) + if !force && (File.exist?(output) || File.symlink?(output)) + raise ::Arpaka::Error, "Output already exists: #{output}; use --force to replace it" + end + + source = SampleRunner.generate(**options) + Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file| + file.write(source) + file.flush + file.chmod(0o644) + if force + File.rename(file.path, output) + else + # Publish only the complete file, without overwriting a concurrent writer. + File.link(file.path, output) + end + end + out.puts("Generated #{output}") + 0 + rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error + err.puts("arpaka: #{error.message}") + 1 + end + end + end +end diff --git a/lib/arpaka/plugins/sample.rb b/lib/arpaka/plugins/sample.rb new file mode 100644 index 0000000..d745485 --- /dev/null +++ b/lib/arpaka/plugins/sample.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "lrama/ruby" + +module Arpaka + module Plugins + module Sample + def self.generate(grammar_path, class_name: "SampleParser") + grammar = File.read(grammar_path) + Lrama::Ruby.generate(grammar, filename: grammar_path, class_name: class_name) + end + end + end +end diff --git a/lib/arpaka/plugins/sample_runner.rb b/lib/arpaka/plugins/sample_runner.rb new file mode 100644 index 0000000..8d758ac --- /dev/null +++ b/lib/arpaka/plugins/sample_runner.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Arpaka + module Plugins + class SampleRunner + PLUGIN = File.expand_path("sample.rb", __dir__) + + def self.generate(grammar:, class_name:) + unless ::Ruby::Box.enabled? + raise ::Arpaka::Error, "Ruby Box is required for plugins. Start Ruby with RUBY_BOX=1." + end + + box = ::Ruby::Box.new + # A new box does not inherit Bundler's activated load paths. + box.load_path.replace($LOAD_PATH) + box.require(PLUGIN) + box.eval("Arpaka::Plugins::Sample.generate(#{grammar.dump}, class_name: #{class_name.dump})") + end + end + end +end diff --git a/test/lrama/sample_plugin_test.rb b/test/lrama/sample_plugin_test.rb index f7b23fe..9decdca 100644 --- a/test/lrama/sample_plugin_test.rb +++ b/test/lrama/sample_plugin_test.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true require "test_helper" +require "arpaka/cli" require_relative "../fixtures/plugins/sample_plugin" require "tmpdir" +require "stringio" class SamplePluginTest < Test::Unit::TestCase test "generates one parser from a grammar file" do @@ -18,4 +20,27 @@ class SamplePluginTest < Test::Unit::TestCase assert_equal(7, parser.new.parse([[:NUMBER, 7]])) end end + + test "the sample command runs the plugin in a box and writes its output" do + Dir.mktmpdir do |directory| + grammar_path = File.join(directory, "sample.y") + output_path = File.join(directory, "parser.rb") + File.write(grammar_path, "%token NUMBER\n%%\nstart: NUMBER;") + + out = StringIO.new + err = StringIO.new + result = Arpaka::CLI.run( + ["sample", "generate", "-y", grammar_path, "-c", "FooParser", "-o", output_path], + out: out, err: err) + + assert_equal(0, result) + assert_empty(err.string) + assert_include(out.string, "Generated #{output_path}") + assert_nil(defined?(Arpaka::Plugins::Sample)) + + box = Ruby::Box.new + box.eval(File.read(output_path)) + assert_equal(7, box.eval("FooParser.new.parse([[:NUMBER, 7]])")) + end + end end