Skip to content

Commit 35ec8e9

Browse files
committed
Translate existing AST into RuboCop AST
1 parent d5d4ea8 commit 35ec8e9

File tree

7 files changed

+148
-11
lines changed

7 files changed

+148
-11
lines changed

.rubocop.yml

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require:
1010
- ./lib/rubocop/cop/ruby_lsp/use_register_with_handler_method
1111

1212
AllCops:
13+
ParserEngine: parser_prism
1314
NewCops: disable
1415
SuggestExtensions: false
1516
Include:
@@ -42,6 +43,7 @@ Sorbet/TrueSigil:
4243
- "lib/ruby_indexer/test/**/*.rb"
4344
- "lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb"
4445
- "lib/ruby_lsp/load_sorbet.rb"
46+
- "lib/ruby_lsp/requests/support/ast_translation.rb"
4547
Exclude:
4648
- "**/*.rake"
4749
- "lib/**/*.rb"
@@ -57,3 +59,4 @@ Sorbet/StrictSigil:
5759
- "lib/ruby-lsp.rb"
5860
- "lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb"
5961
- "lib/ruby_lsp/load_sorbet.rb"
62+
- "lib/ruby_lsp/requests/support/ast_translation.rb"

lib/ruby_lsp/document.rb

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class Document
88

99
abstract!
1010

11-
sig { returns(Prism::ParseResult) }
11+
sig { returns(Prism::ParseLexResult) }
1212
attr_reader :parse_result
1313

1414
sig { returns(String) }
@@ -36,7 +36,7 @@ def initialize(source:, version:, uri:, encoding: Encoding::UTF_8)
3636

3737
sig { returns(Prism::ProgramNode) }
3838
def tree
39-
@parse_result.value
39+
@parse_result.value.first
4040
end
4141

4242
sig { returns(T::Array[Prism::Comment]) }
@@ -113,7 +113,7 @@ def create_scanner
113113
).returns([T.nilable(Prism::Node), T.nilable(Prism::Node), T::Array[String]])
114114
end
115115
def locate_node(position, node_types: [])
116-
locate(@parse_result.value, create_scanner.find_char_position(position), node_types: node_types)
116+
locate(tree, create_scanner.find_char_position(position), node_types: node_types)
117117
end
118118

119119
sig do
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
begin
5+
gem("rubocop", ">= 1.63.0")
6+
rescue LoadError
7+
$stderr.puts("AST translation turned off because RuboCop >= 1.63.0 is required")
8+
return
9+
end
10+
11+
require "prism/translation/parser/rubocop"
12+
13+
# Processed Source patch so that we can pass the existing AST to RuboCop without having to re-parse files a second time
14+
module ProcessedSourcePatch
15+
extend T::Sig
16+
17+
sig do
18+
params(
19+
source: String,
20+
ruby_version: Float,
21+
path: T.nilable(String),
22+
parser_engine: Symbol,
23+
prism_result: T.nilable(Prism::ParseLexResult),
24+
).void
25+
end
26+
def initialize(source, ruby_version, path = nil, parser_engine: :parser_whitequark, prism_result: nil)
27+
@prism_result = prism_result
28+
29+
# Invoking super will end up invoking our patched version of tokenize, which avoids re-parsing the file
30+
super(source, ruby_version, path, parser_engine: parser_engine)
31+
end
32+
33+
sig { params(parser: T.untyped).returns(T::Array[T.untyped]) }
34+
def tokenize(parser)
35+
begin
36+
# This is where we need to pass the existing result to prevent a re-parse
37+
ast, comments, tokens = parser.tokenize(@buffer, parse_result: @prism_result)
38+
39+
ast ||= nil
40+
rescue Parser::SyntaxError
41+
comments = []
42+
tokens = []
43+
end
44+
45+
ast&.complete!
46+
tokens.map! { |t| RuboCop::AST::Token.from_parser_token(t) }
47+
48+
[ast, comments, tokens]
49+
end
50+
51+
RuboCop::AST::ProcessedSource.prepend(self)
52+
end
53+
54+
# This patch allows Prism's translation parser to accept an existing AST in `tokenize`. This doesn't match the original
55+
# signature of RuboCop itself, but there's no other way to allow reusing the AST
56+
module TranslatorPatch
57+
extend T::Sig
58+
extend T::Helpers
59+
60+
requires_ancestor { Prism::Translation::Parser }
61+
62+
sig do
63+
params(
64+
source_buffer: ::Parser::Source::Buffer,
65+
recover: T::Boolean,
66+
parse_result: T.nilable(Prism::ParseLexResult),
67+
).returns(T::Array[T.untyped])
68+
end
69+
def tokenize(source_buffer, recover = false, parse_result: nil)
70+
@source_buffer = source_buffer
71+
source = source_buffer.source
72+
73+
offset_cache = build_offset_cache(source)
74+
result = if @prism_result
75+
@prism_result
76+
else
77+
begin
78+
unwrap(
79+
Prism.parse_lex(source, filepath: source_buffer.name, version: convert_for_prism(version)),
80+
offset_cache,
81+
)
82+
rescue ::Parser::SyntaxError
83+
raise unless recover
84+
end
85+
end
86+
87+
program, tokens = result.value
88+
ast = build_ast(program, offset_cache) if result.success?
89+
90+
[
91+
ast,
92+
build_comments(result.comments, offset_cache),
93+
build_tokens(tokens, offset_cache),
94+
]
95+
ensure
96+
@source_buffer = nil
97+
end
98+
99+
Prism::Translation::Parser.prepend(self)
100+
end

lib/ruby_lsp/requests/support/rubocop_formatter.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def run_formatting(uri, document)
2222
filename = T.must(uri.to_standardized_path || uri.opaque)
2323

2424
# Invoke RuboCop with just this file in `paths`
25-
@format_runner.run(filename, document.source)
25+
@format_runner.run(filename, document.source, document.parse_result)
2626
@format_runner.formatted_source
2727
end
2828

@@ -35,7 +35,7 @@ def run_formatting(uri, document)
3535
def run_diagnostic(uri, document)
3636
filename = T.must(uri.to_standardized_path || uri.opaque)
3737
# Invoke RuboCop with just this file in `paths`
38-
@diagnostic_runner.run(filename, document.source)
38+
@diagnostic_runner.run(filename, document.source, document.parse_result)
3939

4040
@diagnostic_runner.offenses.map do |offense|
4141
Support::RuboCopDiagnostic.new(document, offense, uri).to_lsp_diagnostic

lib/ruby_lsp/requests/support/rubocop_runner.rb

+29-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
RuboCop::LSP.enable
1818
end
1919

20+
require "ruby_lsp/requests/support/ast_translation"
21+
2022
module RubyLsp
2123
module Requests
2224
module Support
@@ -74,6 +76,7 @@ def initialize(*args)
7476
@offenses = T.let([], T::Array[RuboCop::Cop::Offense])
7577
@errors = T.let([], T::Array[String])
7678
@warnings = T.let([], T::Array[String])
79+
@parse_result = T.let(nil, T.nilable(Prism::ParseLexResult))
7780

7881
args += DEFAULT_ARGS
7982
rubocop_options = ::RuboCop::Options.new.parse(args).first
@@ -82,14 +85,15 @@ def initialize(*args)
8285
super(rubocop_options, config_store)
8386
end
8487

85-
sig { params(path: String, contents: String).void }
86-
def run(path, contents)
88+
sig { params(path: String, contents: String, parse_result: Prism::ParseLexResult).void }
89+
def run(path, contents, parse_result)
8790
# Clear Runner state between runs since we get a single instance of this class
8891
# on every use site.
8992
@errors = []
9093
@warnings = []
9194
@offenses = []
9295
@options[:stdin] = contents
96+
@parse_result = parse_result
9397

9498
super([path])
9599

@@ -109,6 +113,29 @@ def formatted_source
109113
@options[:stdin]
110114
end
111115

116+
sig { params(file: String).returns(RuboCop::ProcessedSource) }
117+
def get_processed_source(file)
118+
config = @config_store.for_file(file)
119+
parser_engine = config.parser_engine
120+
return super unless parser_engine == :parser_prism
121+
122+
processed_source = T.unsafe(::RuboCop::AST::ProcessedSource).new(
123+
@options[:stdin],
124+
3.3,
125+
file,
126+
parser_engine: parser_engine,
127+
prism_result: @parse_result,
128+
)
129+
processed_source.config = config
130+
processed_source.registry = mobilized_cop_classes(config)
131+
132+
# We have to reset the result to nil after returning the processed source the first time. This is needed for
133+
# formatting because RuboCop will keep re-parsing the same file until no more auto-corrects can be applied. If
134+
# we didn't reset it, we would end up operating in a stale AST
135+
@parse_result = nil
136+
processed_source
137+
end
138+
112139
class << self
113140
extend T::Sig
114141

sorbet/rbi/shims/rubocop.rbi

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# typed: true
2+
3+
class RuboCop::Runner
4+
def initialize(options, config_store)
5+
@config_store = T.let(T.unsafe(nil), RuboCop::ConfigStore)
6+
end
7+
end

test/ruby_document_test.rb

+4-4
Original file line numberDiff line numberDiff line change
@@ -514,14 +514,14 @@ def test_reparsing_without_new_edits_does_nothing
514514
version: 2,
515515
)
516516

517-
parse_result = Prism.parse(text)
517+
parse_result = Prism.parse_lex(text)
518518

519519
# When there's a new edit, we parse it the first `parse` invocation
520-
Prism.expects(:parse).with(document.source).once.returns(parse_result)
520+
Prism.expects(:parse_lex).with(document.source).once.returns(parse_result)
521521
document.parse
522522

523523
# If there are no new edits, we don't do anything
524-
Prism.expects(:parse).never
524+
Prism.expects(:parse_lex).never
525525
document.parse
526526

527527
document.push_edits(
@@ -530,7 +530,7 @@ def test_reparsing_without_new_edits_does_nothing
530530
)
531531

532532
# If there's another edit, we parse it once again
533-
Prism.expects(:parse).with(document.source).once.returns(parse_result)
533+
Prism.expects(:parse_lex).with(document.source).once.returns(parse_result)
534534
document.parse
535535
end
536536

0 commit comments

Comments
 (0)