Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use translated Prism AST to run RuboCop #1849

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

vinistock
Copy link
Member

Motivation

Use the Prism AST translator to reuse our existing AST to run RuboCop.

Implementation

RuboCop doesn't have an API where we can simply ask "format this AST", so implementing this requires patching a handful of places so that we can pass the AST around.

The idea is that we instantiate the ProcessedSource ourselves and prevent the Prism translator from re-parsing the file if we already have an AST in hand.

Questions

  1. Is this the best way to pass the existing AST around? Could we be doing this in a more elegant way?
  2. What do we need to do to prevent this from failing in older RuboCop versions that do not support Prism as a backend?

Automated Tests

Will add tests.

Manual Tests

  1. Start the LSP on this branch
  2. Verify that diagnostics and formatting is working properly

@vinistock vinistock added enhancement New feature or request server This pull request should be included in the server gem's release notes labels Mar 27, 2024
@vinistock vinistock self-assigned this Mar 27, 2024
@vinistock
Copy link
Member Author

Tagging @koic and @kddnewton. I'd love to hear your thoughts on the approach and if there's something that can be upstreamed to Prism or RuboCop to make this integration more seamless.

Copy link
Contributor

@kddnewton kddnewton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. In general I think we should provide the ability to pass through a parsed AST to Prism::Translation::Parser in prism itself instead of patching it here. One way to do this would be change it in prism to do:

def parse(source_buffer)
  ...
  Prism.parse(...)
  ...
end

instead to:

def initialize(parser: Prism)
  @parser = parser
end

def parse(source_buffer)
  ....
  @parser.parse(...)
  ...
end

and then pass in a custom object in ruby-lsp for the parser that would return the parse result.

lib/ruby_lsp/document.rb Outdated Show resolved Hide resolved
sorbet/rbi/shims/rubocop.rbi Outdated Show resolved Hide resolved
lib/ruby_lsp/requests/support/rubocop_runner.rb Outdated Show resolved Hide resolved
Comment on lines 122 to 128
processed_source = T.unsafe(::RuboCop::AST::ProcessedSource).new(
@options[:stdin],
Prism::Translation::Parser::VERSION_3_3,
file,
parser_engine: parser_engine,
prism_result: @parse_result,
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could avoid some patching if there was a way to instantiate the ProcessedSource with an existing AST and token list. @koic, would you be open to accepting that?

It would allow us to avoid some patching.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I'm not familiar with Ruby LSP, I cannot say much at this point. However, I'd suggest opening a PR to RuboCop AST. There might be adjustments needed regarding the API, but I think they could be acceptable depending on the use case. cc @bbatsov @marcandre
https://github.com/rubocop/rubocop-ast

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the context. In particular, why is @parse_result being passed around as an AST and not a ProcessedSource? I.e., would it be possible upstream in the code to generate a ProcessedSource instead of just an AST, and pass that information instead of just the AST?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All features in the Ruby LSP depend only the Prim's parse result, so we keep that in our representation of documents.

For RuboCop, we just invoke run with the file contents, which will let it re-parse the file and do everything it needs to do.

The Ruby LSP supports multiple formatters. We hook into RuboCop if it is available, but it's not a dependency. So we can't depend on RuboCop internals for our basic document representations, like using a ProcessedSource. All RuboCop things are limited to the runner here.

Now that RuboCop supports the Prism backend, we're trying to find what the easiest way of reusing the existing AST is, so that we can stop parsing documents twice.

The ideal API would be something like what Syntax Tree supports, which allows you to format a given AST. That would not only simplify the code here a lot, it would enable RuboCop to support range formatting for LSPs, which is currently impossible.

That said, the ideal API may require significant refactors, which is why I suggested something a bit simpler that will still minimize the number of patches. If we can instantiate the ProcessedSource in a way that allows us to reuse the existing AST and prevents the second parse, that'll already simplify the code quite a bit.

Copy link

@marcandre marcandre Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But a ProcessedDocument also holds the tokens and the comments

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't understand what you mean. Are there two different classes that would require changing? Or are you saying we could be using something else?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use case this PR aims to address appears reasonable to me. When using a Ruby code with multiple tools (e.g., RuboCop, TypeProf, Steep...), parsing the same source code to an AST in each tool becomes a redundant process. In such cases, it makes sense to reuse the one AST across different tools.

In this PR, the only change to the interface for reusing the AST is the addition of the prism_result keyword argument. This means that backward compatibility is maintained, and there is no impact on the existing RuboCop (AST).

In summary, I think this proposal can be accepted by RuboCop and RuboCop AST. Considering this is not a specialized requirement but a backend tool for LSP, it would be more rational to provide a published interface rather than having users write monkey patches. Below, I share one concern and an idea related to it.

NOTE: Naming and the two ASTs (whitequark AST and Prism AST)

A point of caution is that the current AST RuboCop can analyze is only the whitequark/parser AST interface (Prism::Translation::Parser). It cannot process the vanilla Prism AST interface.

Therefore, I'm not sure if prism_result is the best name for the AST argument (since it's not the Prism AST). The keyword argument name could be ast, which is simpler, but naming is difficult and I'm not confident that it's better 😅

Here is a proposed design for the ast parameter, assuming future RuboCop can use Prism AST:

  • Current situation: When the ast: ast parameter is passed along with either parser_engine: :parser_whitequark or parser_engine: parser_prism, the AST is processed as a whitequark AST.
  • Future extension: When the ast: ast parameter and parser_engine: :prism are passed, the AST would be processed as a Prism AST (note that parser_engine: prism is not yet implemented).

This would potentially allow for a future interface where RuboCop can use a direct Prism AST without Prism::Translation::Parser, and handle any incompatibilities in ASTs.

@@ -31,12 +31,12 @@ def initialize(source:, version:, uri:, encoding: Encoding::UTF_8)
@version = T.let(version, Integer)
@uri = T.let(uri, URI::Generic)
@needs_parsing = T.let(true, T::Boolean)
@parse_result = T.let(parse, Prism::ParseResult)
@parse_result = T.let(parse, Prism::ParseLexResult)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should pull the ast out of this result here, so that tree can just be an attr_reader

Copy link
Contributor

This pull request is being marked as stale because there was no activity in the last 2 months

@github-actions github-actions bot added the Stale label Jul 21, 2024
@vinistock vinistock added pinned This issue or pull request is pinned and won't be marked as stale and removed Stale labels Jul 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request pinned This issue or pull request is pinned and won't be marked as stale server This pull request should be included in the server gem's release notes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants