arpaka 1.0.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -2
- data/README.md +104 -7
- data/Rakefile +15 -1
- data/docs/architecture.dot +30 -0
- data/docs/architecture.md +96 -0
- data/docs/architecture.webp +0 -0
- data/docs/contributes.md +115 -0
- data/docs/usage.md +497 -0
- data/lib/arpaka/cli.rb +5 -53
- data/lib/arpaka/plugins/cli.rb +61 -0
- data/lib/arpaka/plugins/sample.rb +14 -0
- data/lib/arpaka/plugins/sample_runner.rb +21 -0
- data/lib/arpaka/{actions.rb → ruby/actions.rb} +230 -151
- data/lib/arpaka/ruby/class_name_parser.rb +97 -0
- data/lib/arpaka/ruby/cli.rb +64 -0
- data/lib/arpaka/ruby/frontend_emitter.rb +59 -0
- data/lib/arpaka/ruby/generator.rb +41 -0
- data/lib/arpaka/{ruby_grammar.rb → ruby/grammar.rb} +5 -56
- data/lib/arpaka/ruby/grammar_emitter.rb +71 -0
- data/lib/arpaka/ruby/profile.rb +129 -0
- data/lib/arpaka/{profile → ruby/profiles}/ruby_3_3.rb +21 -21
- data/lib/arpaka/{profile → ruby/profiles}/ruby_3_4.rb +21 -21
- data/lib/arpaka/{runtime → ruby/runtime/detailed}/ast.rb.erb +41 -5
- data/lib/arpaka/ruby/runtime/detailed/builder.rb.erb +1177 -0
- data/lib/arpaka/{runtime → ruby/runtime}/frontend.rb.erb +17 -4
- data/lib/arpaka/{runtime → ruby/runtime}/lexer.rb.erb +576 -99
- data/lib/arpaka/ruby/runtime/structured.rb.erb +111 -0
- data/lib/arpaka/ruby.rb +23 -0
- data/lib/arpaka.rb +6 -9
- data/lib/lrama/ruby/backend.rb +4 -9
- data/lib/lrama/ruby/parser.rb.erb +35 -9
- data/lib/lrama/ruby/parser_emitter.rb +41 -0
- data/lib/lrama/ruby/table_codec.rb +50 -0
- data/lib/lrama/ruby/version.rb +1 -1
- data/lib/lrama/ruby.rb +1 -0
- data/sig/arpaka.rbs +8 -3
- metadata +32 -16
- data/lib/arpaka/generator.rb +0 -51
- data/lib/arpaka/profile/ruby.rb +0 -49
- data/lib/arpaka/runtime/builder.rb.erb +0 -323
- /data/lib/arpaka/{BSDL → ruby/BSDL} +0 -0
- /data/lib/arpaka/{COPYING → ruby/COPYING} +0 -0
- /data/lib/arpaka/{source.json → ruby/source.json} +0 -0
- /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,63 +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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
parser = OptionParser.new do |opts|
|
|
14
|
-
opts.banner = "Usage: arpaka generate (--ruby-source DIR | --parse-y FILE) --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("--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("--force", "Replace an existing output file") { force = true }
|
|
20
|
-
opts.on("-h", "--help", "Show usage") { help = true }
|
|
21
|
-
end
|
|
22
|
-
args = argv.dup
|
|
23
|
-
command = args.shift
|
|
24
|
-
if ["--help", "-h"].include?(command)
|
|
25
|
-
out.puts(parser)
|
|
26
|
-
return 0
|
|
27
|
-
end
|
|
28
|
-
raise OptionParser::InvalidArgument, "expected generate" unless command == "generate"
|
|
29
|
-
parser.parse!(args)
|
|
30
|
-
if help
|
|
31
|
-
out.puts(parser)
|
|
32
|
-
return 0
|
|
33
|
-
end
|
|
34
|
-
raise OptionParser::InvalidArgument, args.join(" ") unless args.empty?
|
|
35
|
-
raise OptionParser::InvalidArgument, "specify exactly one of --ruby-source or --parse-y" if options[:ruby_source] == options[:parse_y]
|
|
36
|
-
raise OptionParser::MissingArgument, "--output" unless options[:output]
|
|
37
|
-
%i[output].each do |key|
|
|
38
|
-
raise OptionParser::MissingArgument, "--#{key.to_s.tr('_', '-')}" unless options[key]
|
|
39
|
-
end
|
|
40
|
-
output = File.expand_path(options.delete(:output))
|
|
41
|
-
if !force && (File.exist?(output) || File.symlink?(output))
|
|
42
|
-
raise Error, "Output already exists: #{output}; use --force to replace it"
|
|
43
|
-
end
|
|
44
|
-
source = Arpaka.generate(**options)
|
|
45
|
-
Tempfile.create([".arpaka-", ".rb"], File.dirname(output)) do |file|
|
|
46
|
-
file.write(source)
|
|
47
|
-
file.flush
|
|
48
|
-
file.chmod(0o644)
|
|
49
|
-
if force
|
|
50
|
-
File.rename(file.path, output)
|
|
51
|
-
else
|
|
52
|
-
# Publish only the complete file, without overwriting a concurrent writer.
|
|
53
|
-
File.link(file.path, output)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
out.puts("Generated #{output}")
|
|
57
|
-
0
|
|
58
|
-
rescue OptionParser::ParseError, Lrama::Ruby::Error, SystemCallError => error
|
|
59
|
-
err.puts("arpaka: #{error.message}")
|
|
60
|
-
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)
|
|
61
13
|
end
|
|
62
14
|
end
|
|
63
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
|