Skip to content

Commit 29938d0

Browse files
committed
Enable reusable Prism parse result
Follow-up to Shopify/ruby-lsp#1849. This feature utilizes ruby/prism#3478 to implement Shopify/ruby-lsp#1849. By using ruby/prism#3478, this feature enables performance improvements by reusing Prism's parsed results instead of parsing the source code. Below is a sample code snippet, which is expected to improve performance by 1.3x in this case. ```ruby #!/usr/local/bin/ruby require 'benchmark/ips' require 'prism' require 'rubocop-ast' @source = File.read(__FILE__) @parse_lex_result = Prism.parse_lex(@source) def build_processed_source(parse_lex_result) RuboCop::AST::ProcessedSource.new( @source, 3.4, __FILE__, parser_engine: 'parser_prism', prism_result: parse_lex_result ) end Benchmark.ips do |x| x.report('source') { build_processed_source(nil) } x.report('Prism::ParseLexResult') { build_processed_source(@parse_lex_result) } x.compare! end ``` ```console $ bundle exec ruby reusable_ast.rb ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [x86_64-darwin23] Warming up -------------------------------------- source 116.000 i/100ms Prism::ParseLexResult 151.000 i/100ms Calculating ------------------------------------- source 1.144k (± 8.4%) i/s - 5.684k in 5.018270s Prism::ParseLexResult 1.460k (± 5.2%) i/s - 7.399k in 5.082102s Comparison: Prism::ParseLexResult: 1460.3 i/s source: 1144.4 i/s - 1.28x slower ``` Achieving 1.3x speedup with such this simple modification is a significant improvement for Ruby LSP and its users. ## Compatibility By using `parser_class.instance_method(:initialize).parameters.assoc(:key)`, the implementation checks whether `Prism#initialize` supports the `:parser` keyword. If it does not, the system falls back to the conventional parsing method. This ensures backward compatibility with previous versions of Prism. ## Development Notes Since this feature is specifically designed for Prism, the keyword name `prism_result` is meant for Prism. While it may be possible to achieve similar functionality with the Parser gem, there are currently no concrete use cases. Therefore, for clarity, we have chosen `prism_result` as the keyword. Additionally, Prism has two types of results: `Prism::ParseResult` and `Prism::ParseLexResult`, but the `prism_result` keyword argument is meant to receive `Prism::ParseLexResult`. Since `prism_parse_lex_result` would be too long, the name `prism_result` was chosen to align with the superclass `Prism::Result`.
1 parent 47e0eea commit 29938d0

File tree

3 files changed

+99
-7
lines changed

3 files changed

+99
-7
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#359](https://github.com/rubocop/rubocop-ast/pull/359): Enable reusable Prism parse result. ([@koic][])

lib/rubocop/ast/processed_source.rb

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@
44

55
module RuboCop
66
module AST
7+
# A `Prism` interface's class that provides a fixed `Prism::ParseLexResult` instead of parsing.
8+
#
9+
# This class implements the `parse_lex` method to return a preparsed `Prism::ParseLexResult`
10+
# rather than parsing the source code. It is useful in use cases like Ruby LSP,
11+
# where parsed results are reused across multiple language servers, improving performance.
12+
class PrismPreparsed
13+
def initialize(prism_result)
14+
unless prism_result.is_a?(Prism::ParseLexResult)
15+
raise ArgumentError, <<~MESSAGE
16+
Expected a `Prism::ParseLexResult` object, but received `#{prism_result.class}`.
17+
MESSAGE
18+
end
19+
20+
@prism_result = prism_result
21+
end
22+
23+
def parse_lex(_source, **_prism_options)
24+
@prism_result
25+
end
26+
end
27+
728
# ProcessedSource contains objects which are generated by Parser
829
# and other information such as disabled lines for cops.
930
# It also provides a convenient way to access source lines.
@@ -25,7 +46,9 @@ def self.from_file(path, ruby_version, parser_engine: :parser_whitequark)
2546
new(file, ruby_version, path, parser_engine: parser_engine)
2647
end
2748

28-
def initialize(source, ruby_version, path = nil, parser_engine: :parser_whitequark)
49+
def initialize(
50+
source, ruby_version, path = nil, parser_engine: :parser_whitequark, prism_result: nil
51+
)
2952
parser_engine = parser_engine.to_sym
3053
unless PARSER_ENGINES.include?(parser_engine)
3154
raise ArgumentError, 'The keyword argument `parser_engine` accepts `parser_whitequark` ' \
@@ -44,7 +67,7 @@ def initialize(source, ruby_version, path = nil, parser_engine: :parser_whitequa
4467
@parser_engine = parser_engine
4568
@parser_error = nil
4669

47-
parse(source, ruby_version, parser_engine)
70+
parse(source, ruby_version, parser_engine, prism_result)
4871
end
4972

5073
def ast_with_comments
@@ -202,7 +225,7 @@ def comment_index
202225
end
203226
end
204227

205-
def parse(source, ruby_version, parser_engine)
228+
def parse(source, ruby_version, parser_engine, prism_result)
206229
buffer_name = @path || STRING_SOURCE_NAME
207230
@buffer = Parser::Source::Buffer.new(buffer_name, 1)
208231

@@ -216,7 +239,9 @@ def parse(source, ruby_version, parser_engine)
216239
return
217240
end
218241

219-
@ast, @comments, @tokens = tokenize(create_parser(ruby_version, parser_engine))
242+
parser = create_parser(ruby_version, parser_engine, prism_result)
243+
244+
@ast, @comments, @tokens = tokenize(parser)
220245
end
221246

222247
def tokenize(parser)
@@ -326,10 +351,28 @@ def require_prism_translation_parser(version)
326351
exit!
327352
end
328353

329-
def create_parser(ruby_version, parser_engine)
354+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
355+
def create_parser(ruby_version, parser_engine, prism_result)
330356
builder = RuboCop::AST::Builder.new
331357

332-
parser_class(ruby_version, parser_engine).new(builder).tap do |parser|
358+
parser_class = parser_class(ruby_version, parser_engine)
359+
360+
# NOTE: Check if the `Prism#initialize` method has the `:parser` keyword argument.
361+
# The `:parser` keyword argument cannot be used to switch parsers because older versions of
362+
# Prism do not support it.
363+
parser_switch_available = parser_class.instance_method(:initialize).parameters.assoc(:key)
364+
365+
parser_instance = if prism_result && parser_switch_available
366+
# NOTE: Since it is intended for use with Ruby LSP, it targets only Prism.
367+
# If there is no reuse of a pre-parsed result, such as in Ruby LSP,
368+
# regular parsing with Prism occurs, and `else` branch will be executed.
369+
prism_reparsed = PrismPreparsed.new(prism_result)
370+
parser_class.new(builder, parser: prism_reparsed)
371+
else
372+
parser_class.new(builder)
373+
end
374+
375+
parser_instance.tap do |parser|
333376
# On JRuby there's a risk that we hang in tokenize() if we
334377
# don't set the all errors as fatal flag. The problem is caused by a bug
335378
# in Racc that is discussed in issue #93 of the whitequark/parser
@@ -341,6 +384,7 @@ def create_parser(ruby_version, parser_engine)
341384
end
342385
end
343386
end
387+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
344388

345389
def first_token_index(range_or_node)
346390
begin_pos = source_range(range_or_node).begin_pos

spec/rubocop/ast/processed_source_spec.rb

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# frozen_string_literal: true
22

3+
require 'prism'
4+
35
RSpec.describe RuboCop::AST::ProcessedSource do
46
subject(:processed_source) do
5-
described_class.new(source, ruby_version, path, parser_engine: parser_engine)
7+
described_class.new(
8+
source, ruby_version, path, parser_engine: parser_engine, prism_result: prism_result
9+
)
610
end
711

812
let(:source) { <<~RUBY }
@@ -12,6 +16,9 @@ def some_method
1216
end
1317
some_method
1418
RUBY
19+
let(:prism_result) { nil }
20+
let(:prism_parse_lex_result) { Prism.parse_lex(source) }
21+
1522
let(:ast) { processed_source.ast }
1623
let(:path) { 'ast/and_node_spec.rb' }
1724

@@ -59,6 +66,16 @@ def some_method
5966

6067
it_behaves_like 'invalid parser_engine'
6168
end
69+
70+
context 'when using `parser_engine: :parser_prism` and `prism_result` with a `ParseLexResult`' do
71+
let(:ruby_version) { 3.4 }
72+
let(:parser_prism) { :parser_prism }
73+
let(:prism_result) { prism_parse_lex_result }
74+
75+
it 'returns an instance of ProcessedSource' do
76+
is_expected.to be_a(described_class)
77+
end
78+
end
6279
end
6380

6481
describe '.from_file' do
@@ -94,18 +111,48 @@ def some_method
94111
it 'is the path passed to .new' do
95112
expect(processed_source.path).to eq(path)
96113
end
114+
115+
context 'when using `parser_engine: :parser_prism` and `prism_result` with a `ParseLexResult`' do
116+
let(:ruby_version) { 3.4 }
117+
let(:parser_engine) { :parser_prism }
118+
let(:prism_result) { prism_parse_lex_result }
119+
120+
it 'is the path passed to .new' do
121+
expect(processed_source.path).to eq(path)
122+
end
123+
end
97124
end
98125

99126
describe '#buffer' do
100127
it 'is a source buffer' do
101128
expect(processed_source.buffer).to be_a(Parser::Source::Buffer)
102129
end
130+
131+
context 'when using `parser_engine: :parser_prism` and `prism_result` with a `ParseLexResult`' do
132+
let(:ruby_version) { 3.4 }
133+
let(:parser_engine) { :parser_prism }
134+
let(:prism_result) { prism_parse_lex_result }
135+
136+
it 'is a source buffer' do
137+
expect(processed_source.buffer).to be_a(Parser::Source::Buffer)
138+
end
139+
end
103140
end
104141

105142
describe '#ast' do
106143
it 'is the root node of AST' do
107144
expect(processed_source.ast).to be_a(RuboCop::AST::Node)
108145
end
146+
147+
context 'when using `parser_engine: :parser_prism` and `prism_result` with a `ParseLexResult`' do
148+
let(:ruby_version) { 3.4 }
149+
let(:parser_engine) { :parser_prism }
150+
let(:prism_result) { prism_parse_lex_result }
151+
152+
it 'is the root node of AST' do
153+
expect(processed_source.ast).to be_a(RuboCop::AST::Node)
154+
end
155+
end
109156
end
110157

111158
describe '#comments' do

0 commit comments

Comments
 (0)