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
data/docs/usage.md ADDED
@@ -0,0 +1,497 @@
1
+ # Arpaka usage guide
2
+
3
+ Arpaka has two related roles:
4
+
5
+ - `Lrama::Ruby` generates standalone parsers from Yacc-style grammars.
6
+ - `Arpaka` generates a standalone Ruby source frontend whose `parse` method
7
+ returns an AST.
8
+
9
+ This guide focuses on the Ruby frontend. The frontend is experimental and
10
+ partial; successful parsing does not imply that every Ruby construct is
11
+ represented completely in the AST.
12
+
13
+ ## Requirements and execution model
14
+
15
+ Arpaka requires Ruby 4.0 or later. Generation and compilation use Ruby Box to
16
+ isolate Ruby-specific Lrama extensions, so start the generator with
17
+ `RUBY_BOX=1`:
18
+
19
+ ```sh
20
+ RUBY_BOX=1 bundle exec ruby your_script.rb
21
+ ```
22
+
23
+ When using the checkout before installing the executable, use
24
+ `RUBY_BOX=1 bundle exec ruby -Ilib exe/arpaka ...`. An installed gem exposes
25
+ the `arpaka` executable directly.
26
+
27
+ Generation requires either a Ruby source tree or a `parse.y` file. Arpaka
28
+ does not download Ruby sources. The source-tree mode expects the files used by
29
+ the selected source profile, including `parse.y`, `tool/id2token.rb`,
30
+ `defs/id.def`, `COPYING`, and `BSDL`. The current profile is pinned to Ruby
31
+ revision `37d60dd3241d5fdb10ca43f36077f5d556ae1b8d`; a different checkout may
32
+ produce checksum warnings or fail generation.
33
+
34
+ The generated frontend is a single Ruby file. At runtime it needs Ruby 4.0 or
35
+ later, but it does not need the Arpaka or Lrama gems, Ruby Box, the grammar
36
+ files, network access, or filesystem writes. Requiring the generated file
37
+ defines the requested parser class and its AST classes.
38
+
39
+ ## Ruby API
40
+
41
+ Require Arpaka in the process that performs generation or compilation:
42
+
43
+ ```ruby
44
+ require "arpaka"
45
+ ```
46
+
47
+ ### Generate standalone source
48
+
49
+ `Arpaka.generate` returns the complete frontend source as a string. It requires
50
+ exactly one of `ruby_source:` and `parse_y:`:
51
+
52
+ ```ruby
53
+ code = Arpaka.generate(
54
+ ruby_source: "/path/to/ruby",
55
+ class_name: "MyRubyParser"
56
+ )
57
+ File.write("local/tmp/my_ruby_parser.rb", code)
58
+ ```
59
+
60
+ For a `parse.y`-only input:
61
+
62
+ ```ruby
63
+ code = Arpaka.generate(
64
+ parse_y: "/path/to/parse.y",
65
+ class_name: "MyRubyParser"
66
+ )
67
+ File.write("local/tmp/my_ruby_parser.rb", code)
68
+ ```
69
+
70
+ `class_name` defaults to `RubyParser` and accepts either one Ruby constant name
71
+ or a namespace path such as `MyLibrary::RubyParser`. Arpaka parses the name as
72
+ one or more constant components separated by exactly `::`; each component must
73
+ start with an uppercase ASCII letter and may continue with ASCII letters,
74
+ digits, or underscores. Empty components, malformed separators, numeric or
75
+ lowercase components raise `Arpaka::Error` with the invalid component described.
76
+ Arpaka emits the required nested modules. Generation validates the generated
77
+ source in an isolated Ruby Box before returning it.
78
+ For example, `class_name: "A::B::Parser"` generates `module A`, nested
79
+ `module B`, and the `Parser` class. The returned source can be loaded normally,
80
+ and `Arpaka.compile` returns the leaf `A::B::Parser` class from its isolated Box.
81
+
82
+ ### Compile in memory
83
+
84
+ `Arpaka.compile` generates the same source and evaluates it in a new Ruby Box.
85
+ It returns the generated parser class:
86
+
87
+ ```ruby
88
+ frontend = Arpaka.compile(
89
+ ruby_source: "/path/to/ruby",
90
+ class_name: "A::B::Parser"
91
+ )
92
+
93
+ tree = frontend.parse("value = 1\nvalue + 2", filename: "example.rb")
94
+ ```
95
+
96
+ Each call to `parse` creates fresh parser and local-variable state. The
97
+ `filename:` keyword is retained for diagnostics and defaults to the parser's
98
+ normal filename value when omitted.
99
+
100
+ ### Parse with a generated frontend
101
+
102
+ The generated class exposes:
103
+
104
+ ```ruby
105
+ require_relative "local/tmp/my_ruby_parser"
106
+
107
+ tree = MyLibrary::RubyParser.parse("a = 1\na + 2", filename: "example.rb")
108
+ ```
109
+
110
+ `parse(source, filename:)` accepts Ruby source text and returns an AST rooted
111
+ at `MyRubyParser::AST::Program`; the generated runtime defaults `filename` to
112
+ `"(ruby)"` when it is omitted. The parser, lexer, and builder are internal
113
+ implementation details; callers do not provide tokens. Parsing state is not
114
+ shared between calls.
115
+
116
+ ### Structured AST output
117
+
118
+ Pass `ast_type: :structured` to `Arpaka.generate` or `Arpaka.compile`, or use
119
+ `--ast-type structured` with `arpaka generate`. The default is `:detailed`.
120
+ Both modes use the same `class_name` and generated frontend class.
121
+
122
+ ```ruby
123
+ frontend = Arpaka.compile(ruby_source: "/path/to/ruby",
124
+ class_name: "MyRubyParser", ast_type: :structured)
125
+ tree = frontend.parse("class Widget < Base; def self.build; end; end",
126
+ filename: "widget.rb")
127
+
128
+ tree.filename # => "widget.rb"
129
+ tree.children.first.name # => :Widget
130
+ tree.children.first.superclass # => :Base
131
+ tree.children.first.children.first.kind # => :singleton
132
+ ```
133
+
134
+ The generated `MyRubyParser::AST` module defines these immutable
135
+ `Data` nodes:
136
+
137
+ | Node | Fields |
138
+ | --- | --- |
139
+ | `Source` | `filename`, `children` |
140
+ | `Class` | `name`, `superclass`, `children` |
141
+ | `Module` | `name`, `children` |
142
+ | `Method` | `name`, `kind`, `receiver`, `children` |
143
+
144
+ The detailed parser nodes (`AST::Program`, `AST::ClassDef`, `AST::ModuleDef`,
145
+ and others) remain defined in the same module; `parse` returns the structured
146
+ nodes in this mode.
147
+
148
+ `kind` is `:instance` or `:singleton`. `receiver` is `nil` for an instance
149
+ method; for a singleton method it is `:self`, a simple receiver name, or `nil`
150
+ when the receiver is an expression. Simple class, module, and superclass names
151
+ are symbols; qualified constant paths are strings such as `"A::B"` or
152
+ `"::A::B"`. A dynamic superclass is `nil`. The superclass is separate from
153
+ `children`, which holds lexically nested definitions in source order. Each
154
+ class or module reopen is a separate node. The source root carries the supplied
155
+ filename, defaulting to `"(ruby)"`; definition nodes do not carry locations.
156
+
157
+ Structured output includes declarations inside control flow and method bodies,
158
+ but excludes calls such as `define_method`, mixins, reference resolution, and
159
+ expression details. Use the default detailed AST for deeper analysis. The
160
+ structured frontend includes the detailed AST runtime to build its projection,
161
+ so its generated Ruby file is larger than the default detailed frontend.
162
+
163
+ Generated frontends raise these generated error classes:
164
+
165
+ - `MyRubyParser::ParseError` for syntax errors; it exposes `token` and `state`.
166
+ - `MyRubyParser::UnsupportedSyntax` for an upstream grammar rule that is not
167
+ mapped; it exposes `rule` and `upstream line` information.
168
+ - `MyRubyParser::LexerError` for lexer failures; it includes filename, line,
169
+ and byte-column information.
170
+ - All of these inherit from `MyRubyParser::Error`.
171
+
172
+ The former `Arpaka.parse`, `Arpaka.parse_source`, and `Lrama::Ruby.parse`
173
+ frontends are not part of this API. Generate or compile a frontend first.
174
+
175
+ ## Command-line generation
176
+
177
+ The CLI command is `arpaka generate`. It requires exactly one input mode and an
178
+ output path:
179
+
180
+ ```sh
181
+ RUBY_BOX=1 bundle exec arpaka generate \
182
+ --ruby-source /path/to/ruby \
183
+ --class-name MyLibrary::RubyParser \
184
+ --output lib/my_library/ruby_parser.rb
185
+ ```
186
+
187
+ The `parse.y`-only mode uses Arpaka's token-name preprocessing:
188
+
189
+ ```sh
190
+ RUBY_BOX=1 bundle exec arpaka generate \
191
+ --parse-y /path/to/parse.y \
192
+ --class-name MyRubyParser \
193
+ --output lib/my_library/ruby_parser.rb
194
+ ```
195
+
196
+ The output directory must already exist. Existing output is preserved unless
197
+ `--force` is supplied. Output is written through a temporary file and is only
198
+ published after generation succeeds, so a failed generation leaves an existing
199
+ frontend intact. The generated file includes the source profile, generator
200
+ version, and license notices.
201
+
202
+ The experimental sample plugin is a separate command shape:
203
+
204
+ ```sh
205
+ RUBY_BOX=1 bundle exec arpaka sample generate \
206
+ -y /path/to/parser.y \
207
+ -c MyParser \
208
+ -o lib/my_library/parser.rb
209
+ ```
210
+
211
+ ## Frontend scope and limitations
212
+
213
+ The frontend currently covers literals, assignments, calls, collections, and
214
+ parts of control flow, blocks, methods, classes, modules, and related Ruby
215
+ syntax. It is intended to expose a useful AST, not to promise full Ruby
216
+ language equivalence. AST nodes currently do not have source locations.
217
+
218
+ Important limitations include:
219
+
220
+ - `%W` and `%I` have incomplete Action mappings; interpolated forms currently
221
+ raise a lexer error instead of producing a flattened AST.
222
+ - Some constructs parse while losing semantic details, including known losses
223
+ around block parameters and qualified constant paths.
224
+ - Unsupported upstream grammar rules raise `UnsupportedSyntax`.
225
+ - Ruby grammar revisions other than the pinned source profile are not
226
+ automatically supported.
227
+
228
+ These limitations are why the compatibility benchmark compares normalized ASTs
229
+ and reports unsupported or ambiguous cases separately from successful parses.
230
+
231
+ This frontend is distinct from the generic `Lrama::Ruby` backend. The generic
232
+ backend still accepts a Yacc-style grammar and returns a parser source string
233
+ from `Lrama::Ruby.generate`, or a parser class from `Lrama::Ruby.compile`.
234
+
235
+ ## Parsing speed benchmark
236
+
237
+ The parsing benchmark measures frontend generation separately from loading the
238
+ generated file and parsing representative inputs. Run it from the repository
239
+ root with:
240
+
241
+ ```sh
242
+ RUBY_BOX=1 bundle exec rake benchmark:parse
243
+ ```
244
+
245
+ `benchmark/parse.rb` uses three generated inputs:
246
+
247
+ - `small`: `value = 1\n`;
248
+ - `medium`: 100 assignments followed by `value100 + 1`;
249
+ - `large`: 1,000 assignments followed by `value1000 + 1`.
250
+
251
+ Set `RUBY_SOURCE` to benchmark generation from another Ruby source tree. If it
252
+ is unset, the benchmark uses the repository's `vendor/ruby` source. Set
253
+ `ITERATIONS` to change the number of measured parse calls; it defaults to 100
254
+ and must be positive:
255
+
256
+ ```sh
257
+ RUBY_BOX=1 RUBY_SOURCE=/path/to/ruby ITERATIONS=250 \
258
+ bundle exec rake benchmark:parse
259
+ ```
260
+
261
+ The benchmark has two processes and two measurement phases. The generator
262
+ process measures one `Arpaka.generate` call. It then writes the generated
263
+ frontend to a temporary file and starts a fresh runtime process with Ruby Box
264
+ disabled. That runtime process measures:
265
+
266
+ - loading the generated file, including reading and defining its classes;
267
+ - the first parse for each input;
268
+ - ten warmup parses, followed by the configured number of warm parses;
269
+ - `RubyVM::AbstractSyntaxTree.parse` for the same inputs, when RubyVM AST is
270
+ available; and
271
+ - `Prism.parse` for the same inputs.
272
+
273
+ Process startup is not included in the timed calls, and loading Prism itself is
274
+ outside the Prism measurements. The runtime process uses `--yjit` when YJIT is
275
+ already enabled in the invoking Ruby. Each result prints total elapsed time and
276
+ average milliseconds per call. Generation and file loading are reported
277
+ separately because generation is a one-time cost when a frontend is built, not
278
+ a cost paid by every later `parse` call.
279
+
280
+ RubyVM AST and Prism return different AST representations from Arpaka. Their
281
+ numbers are useful reference points for the same input and environment, but
282
+ they are not a guarantee of equivalent work, equivalent semantics, or a
283
+ benchmark ranking. Arpaka performs frontend-specific lexing, parsing, AST
284
+ construction, and normalization, so compare results only with the same Ruby
285
+ version, CPU, YJIT setting, input sizes, source profile, and `ITERATIONS`.
286
+
287
+ The README's reference run used Ruby 4.0.6 on x86_64 Linux with YJIT disabled
288
+ and Prism 1.9.0. It reported warm Arpaka parse times of 0.054 ms, 5.901 ms,
289
+ and 61.475 ms for 10-byte, 1,697-byte, and 18,800-byte inputs; the same table
290
+ reports RubyVM AST and Prism reference values. Generation took 2.190 s,
291
+ loading the generated file took 20.571 ms, and first Arpaka parses took
292
+ 0.130 ms, 6.707 ms, and 73.381 ms. These are historical measurements, not
293
+ service-level targets. Self-hosted elapsed time varies with CPU, Ruby version,
294
+ YJIT, source profile, and input shape; record the complete output from your
295
+ own run when comparing changes.
296
+
297
+ ## AST compatibility benchmark
298
+
299
+ `benchmark/compatibility.rb` compares the generated frontend with
300
+ `RubyVM::AbstractSyntaxTree` for every `.rb` file in explicitly supplied local
301
+ corpora. It parses source files; it does not execute them. Rails, Trick, and
302
+ other external repositories are read-only inputs and are not downloaded or
303
+ packaged by Arpaka.
304
+
305
+ ### Prepare a frontend
306
+
307
+ Generate the frontend once in a Ruby Box. Reuse it for the comparison so
308
+ generation time is not mixed into parser comparison time:
309
+
310
+ ```sh
311
+ RUBY_BOX=1 bundle exec ruby tool/ruby/grammar.rb \
312
+ --output local/tmp/compatibility-frontend.rb \
313
+ --class-name CompatibilityFrontend \
314
+ --force
315
+ ```
316
+
317
+ Alternatively, omit `--frontend` below and pass `--ruby-source`; the runner
318
+ will generate a frontend itself and therefore must run with `RUBY_BOX=1`.
319
+
320
+ ### Build and use a RubyVM reference cache
321
+
322
+ The comparison runner requires `--reference-cache`. The cache is JSONL keyed by
323
+ corpus name, relative path, and source SHA256. Each `type: "reference"` record
324
+ contains the normalized RubyVM tree and a status. A changed corpus file does
325
+ not silently reuse an old tree: its SHA256 no longer matches and the run
326
+ reports a reference-cache miss as an error.
327
+
328
+ `benchmark/compatibility.rb` reads this cache; it does not create RubyVM
329
+ reference records during a normal comparison. Generate the cache as a separate
330
+ oracle step using the same Ruby version as the comparison. A minimal local
331
+ example is:
332
+
333
+ ```ruby
334
+ # Save as local/tmp/build-reference-cache.rb and run with:
335
+ # bundle exec ruby -Ilib local/tmp/build-reference-cache.rb
336
+ require "digest"
337
+ require "json"
338
+ require_relative "../../benchmark/compatibility/normalizer"
339
+
340
+ corpora = {
341
+ "rails" => "/path/to/rails",
342
+ "trick25" => "/path/to/trick2025",
343
+ "trick22" => "/path/to/trick2022",
344
+ "trick18" => "/path/to/trick2018",
345
+ "trick15" => "/path/to/trick2015",
346
+ "trick13" => "/path/to/trick2013"
347
+ }
348
+ normalizer = Compatibility::Normalizer.new
349
+
350
+ File.open("local/tmp/arpaka-reference-all.jsonl", "w") do |out|
351
+ corpora.each do |name, root|
352
+ Dir.glob(File.join(root, "**", "*.rb")).sort.each do |path|
353
+ source = File.binread(path).force_encoding(Encoding::UTF_8)
354
+ relative = path.delete_prefix(File.expand_path(root) + File::SEPARATOR)
355
+ record = {
356
+ "type" => "reference", "corpus" => name, "path" => relative,
357
+ "sha256" => Digest::SHA256.hexdigest(source), "status" => "ok",
358
+ "elapsed_seconds" => 0.0
359
+ }
360
+ begin
361
+ tree = RubyVM::AbstractSyntaxTree.parse(source, keep_script_lines: false)
362
+ value = normalizer.rubyvm(tree.children.fetch(2))
363
+ record["tree"] = value.is_a?(Array) && value.first == :sequence ? value :
364
+ [:sequence, value.nil? ? [] : [value]]
365
+ rescue SyntaxError => error
366
+ record["status"] = "parse_failure"
367
+ record["reason"] = "#{error.class}: #{error.message}"
368
+ rescue Compatibility::Unsupported => error
369
+ record["status"] = "unverified"
370
+ record["side"] = error.side.to_s
371
+ record["reason"] = error.detail
372
+ rescue StandardError => error
373
+ record["status"] = "error"
374
+ record["reason"] = "#{error.class}: #{error.message}"
375
+ end
376
+ out.puts(JSON.generate(record, max_nesting: false))
377
+ end
378
+ end
379
+ end
380
+ ```
381
+
382
+ The oracle step must use the same corpus revisions and Ruby runtime as the
383
+ comparison. The cache may also be compressed as `.xz`; the runner invokes
384
+ `xz -dc` when the path ends in `.xz`.
385
+
386
+ ### Run the comparison
387
+
388
+ Pass one `--corpus NAME=PATH` per source tree. The names become part of the
389
+ cache key and report. A corpus can also be one Ruby file, but a directory is
390
+ recursively scanned for `.rb` files:
391
+
392
+ ```sh
393
+ bundle exec ruby -Ilib benchmark/compatibility.rb \
394
+ --frontend local/tmp/compatibility-frontend.rb \
395
+ --corpus rails=/path/to/rails \
396
+ --corpus trick25=/path/to/trick2025 \
397
+ --corpus trick22=/path/to/trick2022 \
398
+ --corpus trick18=/path/to/trick2018 \
399
+ --corpus trick15=/path/to/trick2015 \
400
+ --corpus trick13=/path/to/trick2013 \
401
+ --reference-cache local/tmp/arpaka-reference-all.jsonl \
402
+ --threads 32 \
403
+ --parallel thread \
404
+ --report local/tmp/arpaka-compatibility.jsonl
405
+ ```
406
+
407
+ If the frontend was generated with a namespace, pass its complete constant
408
+ path with `--frontend-class`:
409
+
410
+ ```sh
411
+ RUBY_BOX=1 bundle exec ruby -Ilib benchmark/compatibility.rb \
412
+ --frontend local/tmp/a_b_parser.rb \
413
+ --frontend-class A::B::Parser \
414
+ --corpus fixture=benchmark/fixtures \
415
+ --reference-cache local/tmp/fixture-reference.jsonl \
416
+ --threads 32 --parallel thread
417
+ ```
418
+
419
+ The runner and worker resolve each component of this path with `const_get`, so
420
+ the frontend can define `A::B::Parser` directly. The default remains
421
+ `CompatibilityFrontend`, preserving existing commands and generated frontends.
422
+
423
+ For self-hosted runs, `--threads 32 --parallel thread` is the recommended
424
+ starting point for the large Rails/Trick corpus. The default is one Thread,
425
+ which is useful for debugging but considerably slower on a large corpus.
426
+ `--parallel ractor` is available for experimental isolation comparisons, and
427
+ `--disable-gc` is an opt-in experiment that can substantially increase heap
428
+ usage. `--timeout` controls the per-parser/file timeout and defaults to 10
429
+ seconds.
430
+
431
+ This runner option is separate from the repository test suite. The normal
432
+ compatibility tests execute the Thread path. Tests that exercise Ruby
433
+ Box/Ractor compatibility are currently omitted by default because of the Ruby
434
+ 4.0 Ruby Box/Ractor constraints; run them explicitly with
435
+ `ARPAKA_RUN_RACTOR_TESTS=1`. That test setting does not enable the runner's
436
+ `--parallel ractor` mode, and selecting `--parallel ractor` does not change
437
+ which test-suite cases are omitted.
438
+
439
+ The Rake wrapper accepts equivalent selections through environment variables:
440
+
441
+ ```sh
442
+ CORPORA="rails=/path/to/rails trick13=/path/to/trick13" \
443
+ REPORT=local/tmp/arpaka-compatibility.jsonl \
444
+ RUBY_SOURCE=/path/to/ruby \
445
+ bundle exec rake benchmark:compatibility
446
+ ```
447
+
448
+ The Rake task forwards `CORPORA`, `REPORT`, and `RUBY_SOURCE`; for explicit
449
+ thread and parallel settings, invoke `benchmark/compatibility.rb` directly.
450
+
451
+ ### Read the report
452
+
453
+ The JSONL report contains one file record per comparison followed by a summary
454
+ record. The summary includes runtime, Git/source revisions, corpus manifests,
455
+ frontend and benchmark hashes, file count, per-corpus counts, and exit-related
456
+ totals. It also includes `elapsed_seconds` for the run and GC deltas for heap
457
+ pages, live slots, total allocations, minor GC, and major GC.
458
+
459
+ File statuses mean:
460
+
461
+ - `match`: the normalized RubyVM and Arpaka ASTs are equal.
462
+ - `accepted_mismatch`: a comparison difference explicitly accepted by the
463
+ comparator, currently including keyword-argument representation and certain
464
+ explicit terminal-`nil` body representations.
465
+ - `mismatch`: both sides produced comparable trees, but they differ in a way
466
+ not accepted by the comparator.
467
+ - `unverified`: a parser or normalizer used a construct outside the comparison
468
+ model, or required metadata is unavailable; this is not a match.
469
+ - `parse_failure`: RubyVM or Arpaka could not parse the file.
470
+ - `error`: an unexpected worker/input failure or a missing reference-cache
471
+ record occurred.
472
+ - `timeout`: a worker exceeded the configured timeout.
473
+
474
+ The command exits 0 only when every file is `match` or `accepted_mismatch`, 1
475
+ when any comparison has another status, and 2 for invalid configuration or a
476
+ runner error. A successful parse alone is not AST compatibility.
477
+
478
+ ### Self-hosted duration and baseline
479
+
480
+ Compatibility time depends on CPU, Ruby version, corpus revisions, file count,
481
+ frontend version, worker count, and GC behavior. Treat timings as local
482
+ capacity-planning hints, not guarantees. The repository's reference in
483
+ [`docs/contributes.md`](contributes.md) measured Ruby 4.0.7 against Rails and
484
+ Trick 2013, 2015, 2018, 2022, and 2025: 3,525 Ruby files with 32 workers.
485
+ The reported baselines were:
486
+
487
+ | Mode | Elapsed | Heap pages | Live slots | Minor GC | Major GC |
488
+ | --- | ---: | ---: | ---: | ---: | ---: |
489
+ | Thread | 6.85 s | 1,483 | 131,390 | 5 | 3 |
490
+ | Thread + `--disable-gc` | 6.62 s | 1,484 | 131,433 | 5 | 3 |
491
+ | Ractor | 15.66 s | 4,795 | 4,445,551 | 128 | 10 |
492
+ | Ractor + `--disable-gc` | 10.42 s | 101,705 | 131,481,029 | 2 | 1 |
493
+
494
+ Those values are the `docs/contributes.md` reference run, not a promise about
495
+ another host. Compare `elapsed_seconds` and GC fields from your own report,
496
+ along with its Ruby runtime, corpus commits, and file count.
497
+
data/lib/arpaka/cli.rb CHANGED
@@ -1,60 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
4
- require "tempfile"
5
3
  require_relative "../arpaka"
4
+ require_relative "ruby/cli"
5
+ require_relative "plugins/cli"
6
6
 
7
7
  module Arpaka
8
8
  class CLI
9
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 --output FILE [--class-name NAME] [--force]"
15
- opts.on("--ruby-source DIR", "Ruby source tree (provided by the user)") { |v| options[:ruby_source] = v }
16
- opts.on("--output FILE", "Write a standalone Ruby frontend") { |v| options[:output] = v }
17
- opts.on("--class-name NAME", "Generated class name (default: RubyParser)") { |v| options[:class_name] = v }
18
- opts.on("--force", "Replace an existing output file") { force = true }
19
- opts.on("-h", "--help", "Show usage") { help = true }
20
- end
21
- args = argv.dup
22
- command = args.shift
23
- if ["--help", "-h"].include?(command)
24
- out.puts(parser)
25
- return 0
26
- end
27
- raise OptionParser::InvalidArgument, "expected generate" unless command == "generate"
28
- parser.parse!(args)
29
- if help
30
- out.puts(parser)
31
- return 0
32
- end
33
- raise OptionParser::InvalidArgument, args.join(" ") unless args.empty?
34
- %i[ruby_source output].each do |key|
35
- raise OptionParser::MissingArgument, "--#{key.to_s.tr('_', '-')}" unless options[key]
36
- end
37
- output = File.expand_path(options.delete(:output))
38
- if !force && (File.exist?(output) || File.symlink?(output))
39
- raise Error, "Output already exists: #{output}; use --force to replace it"
40
- end
41
- source = Arpaka.generate(**options)
42
- Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file|
43
- file.write(source)
44
- file.flush
45
- file.chmod(0o644)
46
- if force
47
- File.rename(file.path, output)
48
- else
49
- # Publish only the complete file, without overwriting a concurrent writer.
50
- File.link(file.path, output)
51
- end
52
- end
53
- out.puts("Generated #{output}")
54
- 0
55
- rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error
56
- err.puts("arpaka: #{error.message}")
57
- 1
10
+ return Plugins::CLI.run(argv.drop(1), out: out, err: err) if argv.first == "sample"
11
+
12
+ Ruby::CLI.run(argv, out: out, err: err)
58
13
  end
59
14
  end
60
15
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "tempfile"
5
+ require_relative "sample_runner"
6
+
7
+ module Arpaka
8
+ module Plugins
9
+ class CLI
10
+ def self.run(argv, out: $stdout, err: $stderr)
11
+ options = { class_name: "SampleParser" }
12
+ force = false
13
+ parser = OptionParser.new do |opts|
14
+ opts.banner = "Usage: arpaka sample generate -y FILE -o FILE [--class-name NAME] [--force]"
15
+ opts.on("-y FILE", "--grammar FILE", "Grammar file") { |v| options[:grammar] = v }
16
+ opts.on("-c NAME", "--class-name NAME", "Generated class name (default: SampleParser)") do |v|
17
+ options[:class_name] = v
18
+ end
19
+ opts.on("-o FILE", "--output FILE", "Write the generated parser") { |v| options[:output] = v }
20
+ opts.on("--force", "Replace an existing output file") { force = true }
21
+ opts.on("-h", "--help", "Show usage") { out.puts(parser); return 0 }
22
+ end
23
+
24
+ args = argv.dup
25
+ command = args.shift
26
+ if ["--help", "-h"].include?(command)
27
+ out.puts(parser)
28
+ return 0
29
+ end
30
+ raise OptionParser::InvalidArgument, "expected generate" unless command == "generate"
31
+ parser.parse!(args)
32
+ raise OptionParser::InvalidArgument, args.join(" ") unless args.empty?
33
+ raise OptionParser::MissingArgument, "--grammar" unless options[:grammar]
34
+ raise OptionParser::MissingArgument, "--output" unless options[:output]
35
+
36
+ output = File.expand_path(options.delete(:output))
37
+ if !force && (File.exist?(output) || File.symlink?(output))
38
+ raise ::Arpaka::Error, "Output already exists: #{output}; use --force to replace it"
39
+ end
40
+
41
+ source = SampleRunner.generate(**options)
42
+ Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file|
43
+ file.write(source)
44
+ file.flush
45
+ file.chmod(0o644)
46
+ if force
47
+ File.rename(file.path, output)
48
+ else
49
+ # Publish only the complete file, without overwriting a concurrent writer.
50
+ File.link(file.path, output)
51
+ end
52
+ end
53
+ out.puts("Generated #{output}")
54
+ 0
55
+ rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error
56
+ err.puts("arpaka: #{error.message}")
57
+ 1
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lrama/ruby"
4
+
5
+ module Arpaka
6
+ module Plugins
7
+ module Sample
8
+ def self.generate(grammar_path, class_name: "SampleParser")
9
+ grammar = File.read(grammar_path)
10
+ Lrama::Ruby.generate(grammar, filename: grammar_path, class_name: class_name)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arpaka
4
+ module Plugins
5
+ class SampleRunner
6
+ PLUGIN = File.expand_path("sample.rb", __dir__)
7
+
8
+ def self.generate(grammar:, class_name:)
9
+ unless ::Ruby::Box.enabled?
10
+ raise ::Arpaka::Error, "Ruby Box is required for plugins. Start Ruby with RUBY_BOX=1."
11
+ end
12
+
13
+ box = ::Ruby::Box.new
14
+ # A new box does not inherit Bundler's activated load paths.
15
+ box.load_path.replace($LOAD_PATH)
16
+ box.require(PLUGIN)
17
+ box.eval("Arpaka::Plugins::Sample.generate(#{grammar.dump}, class_name: #{class_name.dump})")
18
+ end
19
+ end
20
+ end
21
+ end