Skip to content

Commit f2c4166

Browse files
committed
Support Prism as a Ruby parser
This PR introduces the `parser_engine` option to `ProcessedSource` to support Prism, as part of the RuboCop AST side effort towards addressing rubocop/rubocop#12600. ## Configuration By default, analysis is performed using the Parser gem, so the default value for the newly added `parser_engine` is `parser_whitequark`: ```ruby ProcessedSource.new(@options[:stdin], ruby_version, file, parser_engine: :parser_whitequark) ``` This code maintains compatibility, meaning the traditional behavior is preserved: ```ruby ProcessedSource.new(@options[:stdin], ruby_version, file) ``` To perform analysis using Prism, specify `parser_engine: :parser_prism`: ```ruby ProcessedSource.new(@options[:stdin], ruby_version, file, parser_engine: :parser_prism) ``` The parameter name `parser_prism` reflects the original parser_prism which was the basis for `Prism::Translation::Parser` (now integrated into Prism): https://github.com/kddnewton/parser-prism This is an experimental introduction, and some incompatibilities still remain. > [!NOTE] > As initially mentioned in rubocop/rubocop#12600 (comment), > the plan was to set `parser_engine: prism`. > > However, the parser engine used in this PR is `Prism::Translation::Parser`, not `Prism`: > ruby/prism#2419 > > `Prism::Translation::Parser` and `Prism` have different ASTs, so their migration will definitely cause incompatibility. > So, considering the possibility of further replacing `Prism::Translation::Parser` with `Prism` in the future, > it has been decided that it might be better not to use `ParserEngine: prism` for the time being. > `ParserEngine: prism` is reserved for `Prism`, not `Prism::Translation::Parser`. > > Therefore, the parameter value has been set to `parser_engine: parser_prism` specifically for > `Prism::Translation::Parser`. > > This means that the planned way to specify Prism in .rubocop.yml file will be `ParserEngine: parser_prism`, > not `ParserEngine: prism`. ## Compatibility The compatibility issues between Prism and the Parser gem have not been resolved. The failing tests will be skipped with `broken_on: :prism`: - ruby/prism#2454 has been resolved but not yet released. - ruby/prism#2467 is still unresolved. Issues that will be resolved in several upcoming releases of Prism are being skipped with `broken_on: :prism`. Anyway, RuboCop AST can be released independently of the resolution and release of Prism. > [!NOTE] > The hack in `Prism::Translation::Parser` for `ProcessedSource` needs to be fixed: > https://github.com/ruby/prism/blob/v0.24.0/lib/prism/translation/parser/rubocop.rb > > If the above interface is accepted, a fix will be proposed on the Prism side. ## Test Tests for RuboCop AST with Prism as the backend can be run as follows: ```console bundle exec rake prism_spec ``` The above is the shortcut alias for: ```console PARSER_ENGINE=parser_prism TARGET_RUBY_VERSION=3.3 rake spec ``` RuboCop AST works on Ruby versions 2.6+, but since Prism only targets analysis for Ruby 3.3+, `internal_investigation` Rake task will not be executed. This task is only run with the Parser gem, which can analyze Ruby versions 2.0+.
1 parent 7fb6fbe commit f2c4166

File tree

14 files changed

+196
-81
lines changed

14 files changed

+196
-81
lines changed

.github/workflows/rubocop.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ jobs:
7676
- name: spec
7777
if: "matrix.coverage != true && matrix.internal_investigation != true"
7878
run: bundle exec rake spec
79+
prism:
80+
runs-on: ubuntu-latest
81+
name: Prism
82+
steps:
83+
- uses: actions/checkout@v4
84+
- name: set up Ruby
85+
uses: ruby/setup-ruby@v1
86+
with:
87+
# Specify the minimum Ruby version 2.7 required for Prism to run.
88+
ruby-version: 2.7
89+
bundler-cache: true
90+
- name: spec
91+
env:
92+
# Specify the minimum Ruby version 3.3 required for Prism to analyze.
93+
PARSER_ENGINE: parser_prism
94+
TARGET_RUBY_VERSION: 3.3
95+
run: bundle exec rake prism_spec
7996
rubocop_specs:
8097
name: >-
8198
Main Gem Specs | RuboCop: ${{ matrix.rubocop }} | ${{ matrix.ruby }}

Rakefile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ RSpec::Core::RakeTask.new(spec: :generate) do |spec|
2222
spec.pattern = FileList['spec/**/*_spec.rb']
2323
end
2424

25+
desc 'Run RSpec code examples with Prism'
26+
task prism_spec: :generate do
27+
original_parser_engine = ENV.fetch('PARSER_ENGINE', nil)
28+
original_target_ruby_version = ENV.fetch('TARGET_RUBY_VERSION', nil)
29+
30+
RSpec::Core::RakeTask.new(prism_spec: :generate) do |spec|
31+
# Specify the minimum Ruby version 3.3 required for Prism to analyze.
32+
ENV['PARSER_ENGINE'] = 'parser_prism'
33+
ENV['TARGET_RUBY_VERSION'] = '3.3'
34+
35+
spec.pattern = FileList['spec/**/*_spec.rb']
36+
end
37+
38+
ENV['PARSER_ENGINE'] = original_parser_engine
39+
ENV['TARGET_RUBY_VERSION'] = original_target_ruby_version
40+
end
41+
2542
desc 'Run RSpec with code coverage'
2643
task :coverage do
2744
ENV['COVERAGE'] = 'true'
@@ -35,5 +52,6 @@ end
3552

3653
task default: %i[
3754
spec
55+
prism_spec
3856
internal_investigation
3957
]

changelog/new_support_prism.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#277](https://github.com/rubocop/rubocop-ast/pull/277): Support Prism as a Ruby parser (experimental). ([@koic][])

docs/modules/ROOT/pages/index.adoc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,14 @@ source = RuboCop::AST::ProcessedSource.new(code, 2.7)
8282
rule = MyRule.new
8383
source.ast.each_node { |n| rule.process(n) }
8484
----
85+
86+
In RuboCop AST, you can specify Prism as the parser engine backend by setting `parser_engine: :parser_prism`:
87+
88+
```ruby
89+
# Using the Parser gem with `parser_engine: parser_whitequark` is the default.
90+
ProcessedSource.new(@options[:stdin], ruby_version, file, parser_engine: :parser_prism)
91+
```
92+
93+
This is an experimental feature. If you encounter any incompatibilities between
94+
Prism and the Parser gem, please check the following URL:
95+
https://github.com/ruby/prism/issues?q=is%3Aissue+is%3Aopen+label%3Arubocop

lib/rubocop/ast/processed_source.rb

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,23 @@ class ProcessedSource
1515
INVALID_LEVELS = %i[error fatal].freeze
1616
private_constant :INVALID_LEVELS
1717

18+
PARSER_ENGINES = %i[parser_whitequark parser_prism].freeze
19+
private_constant :PARSER_ENGINES
20+
1821
attr_reader :path, :buffer, :ast, :comments, :tokens, :diagnostics,
19-
:parser_error, :raw_source, :ruby_version
22+
:parser_error, :raw_source, :ruby_version, :parser_engine
2023

21-
def self.from_file(path, ruby_version)
24+
def self.from_file(path, ruby_version, parser_engine: :parser_whitequark)
2225
file = File.read(path, mode: 'rb')
23-
new(file, ruby_version, path)
26+
new(file, ruby_version, path, parser_engine: parser_engine)
2427
end
2528

26-
def initialize(source, ruby_version, path = nil)
29+
def initialize(source, ruby_version, path = nil, parser_engine: :parser_whitequark)
30+
unless PARSER_ENGINES.include?(parser_engine)
31+
raise ArgumentError, 'The keyword argument `parser_engine` accepts ' \
32+
"`parser` or `parser_prism`, but `#{parser_engine}` was passed."
33+
end
34+
2735
# Defaults source encoding to UTF-8, regardless of the encoding it has
2836
# been read with, which could be non-utf8 depending on the default
2937
# external encoding.
@@ -33,9 +41,10 @@ def initialize(source, ruby_version, path = nil)
3341
@path = path
3442
@diagnostics = []
3543
@ruby_version = ruby_version
44+
@parser_engine = parser_engine
3645
@parser_error = nil
3746

38-
parse(source, ruby_version)
47+
parse(source, ruby_version, parser_engine)
3948
end
4049

4150
def ast_with_comments
@@ -193,7 +202,7 @@ def comment_index
193202
end
194203
end
195204

196-
def parse(source, ruby_version)
205+
def parse(source, ruby_version, parser_engine)
197206
buffer_name = @path || STRING_SOURCE_NAME
198207
@buffer = Parser::Source::Buffer.new(buffer_name, 1)
199208

@@ -207,7 +216,7 @@ def parse(source, ruby_version)
207216
return
208217
end
209218

210-
@ast, @comments, @tokens = tokenize(create_parser(ruby_version))
219+
@ast, @comments, @tokens = tokenize(create_parser(ruby_version, parser_engine))
211220
end
212221

213222
def tokenize(parser)
@@ -227,61 +236,77 @@ def tokenize(parser)
227236
end
228237

229238
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
230-
def parser_class(ruby_version)
231-
case ruby_version
232-
when 1.9
233-
require 'parser/ruby19'
234-
Parser::Ruby19
235-
when 2.0
236-
require 'parser/ruby20'
237-
Parser::Ruby20
238-
when 2.1
239-
require 'parser/ruby21'
240-
Parser::Ruby21
241-
when 2.2
242-
require 'parser/ruby22'
243-
Parser::Ruby22
244-
when 2.3
245-
require 'parser/ruby23'
246-
Parser::Ruby23
247-
when 2.4
248-
require 'parser/ruby24'
249-
Parser::Ruby24
250-
when 2.5
251-
require 'parser/ruby25'
252-
Parser::Ruby25
253-
when 2.6
254-
require 'parser/ruby26'
255-
Parser::Ruby26
256-
when 2.7
257-
require 'parser/ruby27'
258-
Parser::Ruby27
259-
when 2.8, 3.0
260-
require 'parser/ruby30'
261-
Parser::Ruby30
262-
when 3.1
263-
require 'parser/ruby31'
264-
Parser::Ruby31
265-
when 3.2
266-
require 'parser/ruby32'
267-
Parser::Ruby32
268-
when 3.3
269-
require 'parser/ruby33'
270-
Parser::Ruby33
271-
when 3.4
272-
require 'parser/ruby34'
273-
Parser::Ruby34
274-
else
275-
raise ArgumentError,
276-
"RuboCop found unknown Ruby version: #{ruby_version.inspect}"
239+
def parser_class(ruby_version, parser_engine)
240+
case parser_engine
241+
when :parser_whitequark
242+
case ruby_version
243+
when 1.9
244+
require 'parser/ruby19'
245+
Parser::Ruby19
246+
when 2.0
247+
require 'parser/ruby20'
248+
Parser::Ruby20
249+
when 2.1
250+
require 'parser/ruby21'
251+
Parser::Ruby21
252+
when 2.2
253+
require 'parser/ruby22'
254+
Parser::Ruby22
255+
when 2.3
256+
require 'parser/ruby23'
257+
Parser::Ruby23
258+
when 2.4
259+
require 'parser/ruby24'
260+
Parser::Ruby24
261+
when 2.5
262+
require 'parser/ruby25'
263+
Parser::Ruby25
264+
when 2.6
265+
require 'parser/ruby26'
266+
Parser::Ruby26
267+
when 2.7
268+
require 'parser/ruby27'
269+
Parser::Ruby27
270+
when 2.8, 3.0
271+
require 'parser/ruby30'
272+
Parser::Ruby30
273+
when 3.1
274+
require 'parser/ruby31'
275+
Parser::Ruby31
276+
when 3.2
277+
require 'parser/ruby32'
278+
Parser::Ruby32
279+
when 3.3
280+
require 'parser/ruby33'
281+
Parser::Ruby33
282+
when 3.4
283+
require 'parser/ruby34'
284+
Parser::Ruby34
285+
else
286+
raise ArgumentError, "RuboCop found unknown Ruby version: #{ruby_version.inspect}"
287+
end
288+
when :parser_prism
289+
require 'prism'
290+
291+
case ruby_version
292+
when 3.3
293+
require 'prism/translation/parser33'
294+
Prism::Translation::Parser33
295+
when 3.4
296+
require 'prism/translation/parser34'
297+
Prism::Translation::Parser34
298+
else
299+
raise ArgumentError, 'RuboCop supports target Ruby versions 3.3 and above with Prism. ' \
300+
"Specified target Ruby version: #{ruby_version.inspect}"
301+
end
277302
end
278303
end
279304
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
280305

281-
def create_parser(ruby_version)
306+
def create_parser(ruby_version, parser_engine)
282307
builder = RuboCop::AST::Builder.new
283308

284-
parser_class(ruby_version).new(builder).tap do |parser|
309+
parser_class(ruby_version, parser_engine).new(builder).tap do |parser|
285310
# On JRuby there's a risk that we hang in tokenize() if we
286311
# don't set the all errors as fatal flag. The problem is caused by a bug
287312
# in Racc that is discussed in issue #93 of the whitequark/parser

rubocop-ast.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Gem::Specification.new do |s|
3434
}
3535

3636
s.add_runtime_dependency('parser', '>= 3.3.0.4')
37+
s.add_runtime_dependency('prism', '>= 0.24.0')
3738

3839
##### Do NOT add `rubocop` (or anything depending on `rubocop`) here. See Gemfile
3940
end

spec/rubocop/ast/if_node_spec.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

3-
RSpec.describe RuboCop::AST::IfNode do
3+
# FIXME: `broken_on: :prism` can be removed when Prism > 0.24.0 will be released.
4+
RSpec.describe RuboCop::AST::IfNode, broken_on: :prism do
45
subject(:if_node) { parse_source(source).ast }
56

67
describe '.new' do

spec/rubocop/ast/processed_source_spec.rb

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# frozen_string_literal: true
22

33
RSpec.describe RuboCop::AST::ProcessedSource do
4-
subject(:processed_source) { described_class.new(source, ruby_version, path) }
4+
subject(:processed_source) do
5+
described_class.new(source, ruby_version, path, parser_engine: parser_engine)
6+
end
57

68
let(:source) { <<~RUBY }
79
# an awesome method
@@ -25,6 +27,17 @@ def some_method
2527
is_expected.to be_a(described_class)
2628
end
2729
end
30+
31+
context 'when using invalid `parser_engine` argument' do
32+
let(:parser_engine) { :unknown_parser_engine }
33+
34+
it 'raises a Errno::ENOENT when the file does not exist' do
35+
expect { processed_source }.to raise_error(ArgumentError) do |e|
36+
expect(e.message).to eq 'The keyword argument `parser_engine` accepts `parser` or ' \
37+
'`parser_prism`, but `unknown_parser_engine` was passed.'
38+
end
39+
end
40+
end
2841
end
2942

3043
describe '.from_file' do
@@ -36,7 +49,9 @@ def some_method
3649
Dir.chdir(org_pwd)
3750
end
3851

39-
let(:processed_source) { described_class.from_file(path, ruby_version) }
52+
let(:processed_source) do
53+
described_class.from_file(path, ruby_version, parser_engine: parser_engine)
54+
end
4055

4156
it 'returns an instance of ProcessedSource' do
4257
is_expected.to be_a(described_class)
@@ -186,7 +201,9 @@ def some_method
186201
end
187202
end
188203

189-
context 'when the source is valid but has some warning diagnostics' do
204+
# FIXME: `broken_on: :prism` can be removed when
205+
# https://github.com/ruby/prism/issues/2454 will be released.
206+
context 'when the source is valid but has some warning diagnostics', broken_on: :prism do
190207
let(:source) { 'do_something *array' }
191208

192209
it 'returns true' do
@@ -442,7 +459,8 @@ def some_method
442459
end
443460
# rubocop:enable RSpec/RedundantPredicateMatcher
444461

445-
describe '#preceding_line' do
462+
# FIXME: https://github.com/ruby/prism/issues/2467
463+
describe '#preceding_line', broken_on: :prism do
446464
let(:source) { <<~RUBY }
447465
[ line, 1 ]
448466
{ line: 2 }
@@ -458,7 +476,8 @@ def some_method
458476
end
459477
end
460478

461-
describe '#following_line' do
479+
# FIXME: https://github.com/ruby/prism/issues/2467
480+
describe '#following_line', broken_on: :prism do
462481
let(:source) { <<~RUBY }
463482
[ line, 1 ]
464483
{ line: 2 }

spec/rubocop/ast/range_node_spec.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
it { is_expected.to be_range_type }
2323
end
2424

25-
context 'with an infinite range' do
26-
let(:ruby_version) { 2.6 }
25+
context 'with an infinite range', :ruby26 do
2726
let(:source) do
2827
'1..'
2928
end
@@ -32,8 +31,7 @@
3231
it { is_expected.to be_range_type }
3332
end
3433

35-
context 'with a beignless range' do
36-
let(:ruby_version) { 2.7 }
34+
context 'with a beignless range', :ruby27 do
3735
let(:source) do
3836
'..42'
3937
end

spec/rubocop/ast/token_spec.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,9 @@ def foo
342342
end
343343

344344
describe '#left_brace?' do
345-
it 'returns true for left hash brace tokens' do
345+
# FIXME: `broken_on: :prism` can be removed when
346+
# https://github.com/ruby/prism/issues/2454 will be released.
347+
it 'returns true for left hash brace tokens', broken_on: :prism do
346348
expect(left_hash_brace_token).to be_left_brace
347349
end
348350

@@ -357,7 +359,9 @@ def foo
357359
expect(left_block_brace_token).to be_left_curly_brace
358360
end
359361

360-
it 'returns false for non left block brace tokens' do
362+
# FIXME: `broken_on: :prism` can be removed when
363+
# https://github.com/ruby/prism/issues/2454 will be released.
364+
it 'returns false for non left block brace tokens', broken_on: :prism do
361365
expect(left_hash_brace_token).not_to be_left_curly_brace
362366
expect(right_block_brace_token).not_to be_left_curly_brace
363367
end

0 commit comments

Comments
 (0)