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

Resolve refactoring #3688

Merged
merged 14 commits into from
Jul 17, 2023

Conversation

joyfulmantis
Copy link
Collaborator

@joyfulmantis joyfulmantis commented Jul 5, 2023

I had higher hopes on what I could do here but ended up just implementing a ResolveFunction which makes what used to look like this.

resolveProvider :: PluginMethodHandler IdeState 'Method_CodeActionResolve
resolveProvider ideState pluginId codeAction@(CodeAction _ _ _ _ _ _ _ (Just resolveData)) =

Now look something like this

resolveProvider :: ResolveFunction IdeState CustomResolveType 'Method_CodeActionResolve
resolveProvider ideState pluginId codeAction uri (CustomResolveType int) =

These resolve functions are complied with the mkResoveHandler, which is now the only way to write resolve handlers.

Things I still want to investigate, and possibly handle:

  • Does LSP send resolve requests even if the data field is empty? If so we need to create a default resolve handler for each that all resolve requests with empty fields are routed to, whose job is just to reflect its params.

@joyfulmantis
Copy link
Collaborator Author

Here's an example of how our function types and arguments change with the new resolve logic.

Before a resolve provider looked something like this:

resolveProvider :: PluginMethodHandler IdeState 'Method_CodeActionResolve
resolveProvider ideState pluginId codeAction@(CodeAction _ _ _ _ _ _ _ (Just resolveData)) =

Now it will look something like this

resolveProvider :: ResolveFunction IdeState CustomResolveType 'Method_CodeActionResolve
resolveProvider ideState pluginId codeAction uri (CustomResolveType int) =

The most obvious differences are now we always have a URI passed to us (necessary for almost everything, and we can start using it right away, without having to decode any data), and our data is already decoded into our custom type for us. Additionally, all responses that can be resolved will be (invisible to the plugin) wrapped in information that allows us to resolve specifically for that plugin only (it's also what allows us to provide the uri). We also no longer use pluginEnabled and combineResponses functions, or the type classes that provide them for any of the resolve methods.

Copy link
Collaborator

@michaelpj michaelpj left a comment

Choose a reason for hiding this comment

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

At a high level I like the interface for plugins. I'm not sure why we need a separate entry in the plugin descriptor though? Why can't we just have a function that turns a ResolveFunction into a PluginHandlers like before? I don't think that the stuff in combineResponses and so on was so bad - I think with the identifying information put into the data we had an okay solution there.

@joyfulmantis
Copy link
Collaborator Author

So the ResolveFunction, and how they are handled is pretty much equivalent to what's already done for Commands. And since Commands already had their own logic for being handled, I modeled the resolve logic off of them. Commands have an extra commandId that allows plugins to declare multiple commands per plugin. However the resolve stuff has extra complexity in that it has to allow plugins to register resolve handlers for different methods, hence the necessary usage of DMap. Both the ResolveFunction for resolve based functions and the CommandFunction for commands can be implemented on top of the existing Plugin Handler infrastructure.

Why were commands originally handled differently, and what are the advantages and disadvantages of the different approaches?
I'll list below what I think are some of the non-disadvantages, disadvantages, advantages, and non-advantages.

Non-disadvantage: Extra field in the PluginDescriptor. While this at first may seem like a disadvantage, no one actually creates a plugin descriptor from scratch, everyone just edits a default one with the stuff that they want, so the only time anyone would have to deal with it is if they actually want to provide resolve handlers themselves.

Disadvantage: Technical debt. With commands and resolve handlers handled separately from the plugin handlers architecture, they need certain functions and data types, both in the hls-plugin-api, and in the core hls section of ghcide. I should add though that in the grand scheme of things, it's not large. For the resolve stuff something like 70 lines in hls-plugin-api's Types.hs and another around 50 lines in ghcide's Hls.hs. We also get to delete a bunch of lines from Types.hs (everything resolve related in the two main typeclasses there), so even If we build on top of the plugin handler architecture we could probably save a few lines, but not that many.

Advantage: Less hacky, and better safety that nothing goes wrong. The reason the combineResponses is called combineResponses.. is because it's meant to... combine responses. While with combine responses it's possible to hackily just take the first item in the list, it's not an ideal solution. (What if more than one plugin returns a response, for example, because it fails by not changing anything rather than just returning an error (something that was previously done with ghcide completions I might add). Then we have the combineErrors, which is currently completely inadequate for the job of returning a response if everything errors out (It currently returns an internal error with the contents being a (show xs)!) It's not even clear how we would be able to single out only the error we want to use as a response. There are possibilities -- we could have resolve handlers return a specific error if the request is not for them, and filter those out, but that is super hacky, and you just need one plugin forgetting to return the right error to cause mayham for everyone.
There is stuff we can enforce if we require everyone to use functions to build the handlers, but the key thing is if the resolve methods are built on top of the plugin handler infrastructure, there is nothing we can do from people running roughshod of our attempts to enforce guarantees on how things are done with helper methods by just using a normal mkPluginHandler with the resolve method.

Non? advantage: Theoretically there is a performance improvement, albeit bound to be small, with the current approach we are only decoding the data field once, and only passing that on to one plugin, If we used the pluginHandler architecture we would have to decode the data field n times for n plugins.

@michaelpj
Copy link
Collaborator

Checking a few things in my understanding:

  • If we don't provide an instance of PluginMethod, then people can't put codeAction/resolve handlers into the normal plugin handlers, right?
  • But people can put a codeAction handler into the PluginMethods, right? I guess that's fine.

However the resolve stuff has extra complexity in that it has to allow plugins to register resolve handlers for different methods, hence the necessary usage of DMap.

Right, so I guess we could have different ResolveFunctions for all of the things that could get resolved, but that would be pretty gross.

Perhaps a bad idea: what if we made PluginHandler a sum type? With the alternatives being either the handlers we have today or a resolve handler? Would that let us reuse more of the machinery? We'd have to deal with the situation where we have multiple handlers, some of which are PluginHandlers and some of which are ResolveHandlers, but surely the current code must deal with that also?

TBH, I'm not sure whether there's any reason not to do the same for command handlers... I don't have this fully loaded into my head. But at least it seems like what you're doing with the resolve handlers is closer to what's being done for the other handlers?

The reason the combineResponses is called combineResponses.. is because it's meant to... combine responses. While with combine responses it's possible to hackily just take the first item in the list, it's not an ideal solution.

Isn't your code basically doing the same thing? https://github.com/haskell/haskell-language-server/pull/3688/files#diff-7a4ae7207d77fe6892ad9315e62ebdc3ea929941d0e2d9a78e18074422657b27R240

i.e. only taking the first thing that comes up? Yes, you're filtering by the plugin id, but you might still have multiple resolve functions! And the existing code also filters with pluginEnabled... could we not merge the filtering if we went the sum-type route?

@joyfulmantis
Copy link
Collaborator Author

Okay, I completely dumped all extra logic to handle resolve stuff and wrote a mkResolveHelper that turns a ResolveFunction into a normal PluginHandlers. With the pluginEnabled logic we only route requests to the plugin listed in the data field, so as long as no plugin has more than one resolve method listed per resolve method then we should only get one potential response which should work with the current combineResponses and combineErrors.

@joyfulmantis joyfulmantis changed the title WIP Experimental resolve refactoring Resolve refactoring Jul 11, 2023
@joyfulmantis joyfulmantis marked this pull request as ready for review July 12, 2023 10:24
@joyfulmantis
Copy link
Collaborator Author

So his clients will ask us to resolve even if there is no data set, but currently, we respond with the "no plugin enabled" error message, which is of type InvalidRequest, and that doesn't seem to show an error for least vscode, so we should be fine there.

then
case fromJSON value of
Success decodedValue ->
let newParams = params & L.data_ ?~ value
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Here we are unwrapping the PluginResolveData, to make it transparent to the resolveHandler.) However, there is one really weird bug here, where seemingly f is called with the old "params", not the updated "newParams". Have no clue why that is though.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmmm... would be good to track that down. A good case for having a logger in here - then you could log what's going on to see :)

Copy link
Collaborator

@michaelpj michaelpj left a comment

Choose a reason for hiding this comment

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

LGTM!

hls-plugin-api/src/Ide/Plugin/Resolve.hs Show resolved Hide resolved
hls-plugin-api/src/Ide/Plugin/Resolve.hs Show resolved Hide resolved
@michaelpj
Copy link
Collaborator

Oh right, I do think this module could do with a Note explaining the big picture of how we make the handlers all work nicely together.

Copy link
Collaborator

@michaelpj michaelpj left a comment

Choose a reason for hiding this comment

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

Okay sorry, I do actually have more comments 😅

@@ -877,6 +886,57 @@ type CommandFunction ideState a

-- ---------------------------------------------------------------------

type ResolveFunction ideState a (m :: Method ClientToServer Request) =
Copy link
Collaborator

Choose a reason for hiding this comment

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

These should probably go in Resolve.hs?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I left all the core Resolve stuff in Types, and moved the optional handler makers to Resolve. I am happy to move everything to resolve thought if you think that's better.

-> a
-> LspM Config (Either ResponseError (MessageResult m))

-- | Make a handler for plugins with no extra data
Copy link
Collaborator

Choose a reason for hiding this comment

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

unclear what "no extra data" means here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure either 🫤

hls-plugin-api/src/Ide/Types.hs Outdated Show resolved Hide resolved
in f ideState plId newParams uri decodedValue
Error err ->
pure $ Left $ ResponseError (InR ErrorCodes_ParseError) (parseError value err) Nothing
else pure $ Left $ ResponseError (InR ErrorCodes_InvalidRequest) invalidRequest Nothing
Copy link
Collaborator

Choose a reason for hiding this comment

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

So this is one of the cases where we would definitely want to say we are choosing not to respond rather than erroring !

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Actually pluginEnabled should prevent us from getting to this step, so if this is happening it probably means something is pretty broken

Error err ->
pure $ Left $ ResponseError (InR ErrorCodes_ParseError) (parseError value err) Nothing
else pure $ Left $ ResponseError (InR ErrorCodes_InvalidRequest) invalidRequest Nothing
_ -> pure $ Left $ ResponseError (InR ErrorCodes_InvalidRequest) invalidRequest Nothing
Copy link
Collaborator

Choose a reason for hiding this comment

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

use the aeson error!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also I don't think this should be using the "invalidRequest" message?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, maybe InternalError would work better here

-> PluginHandlers ideState
mkResolveHandler m f = mkPluginHandler m f'
where f' ideState plId params = do
case fromJSON <$> (params ^. L.data_) of
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we want to give a different error if the data is missing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

pluginEnabled should only route the request to us if the data field is full and decodes to PluginResolveData.

then
case fromJSON value of
Success decodedValue ->
let newParams = params & L.data_ ?~ value
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmmm... would be good to track that down. A good case for having a logger in here - then you could log what's going on to see :)

hls-plugin-api/src/Ide/Types.hs Outdated Show resolved Hide resolved
@@ -18,10 +19,13 @@ getResolvedCompletions :: TextDocumentIdentifier -> Position -> Session [Complet
getResolvedCompletions doc pos = do
Copy link
Collaborator

Choose a reason for hiding this comment

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

don't you have a test helper for this? I guess it's in lsp and we need to do a release

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes it's part of the lsp-test pr I did.

f' SMethod_TextDocumentCodeLens pid ide params@CodeLensParams{_textDocument=TextDocumentIdentifier {_uri}} =
pure . fmap (wrapCodeLenses pid _uri) <$> f ide pid params
f' SMethod_TextDocumentCompletion pid ide params@CompletionParams{_textDocument=TextDocumentIdentifier {_uri}} =
pure . fmap (wrapCompletions pid _uri) <$> f ide pid params
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmmm... couldn't we push this stuff into the helper functions we have for making such handlers? Then, yes, you need to use those if you want to get resolve working, but if you don't care you can just write a bare handler and we won't insert extra stuff if you haven't asked for it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We're case matching on the method provided, and only actually wrapping the data field if it has anything in it, and the data field is only ever used for resolve. Otherwise we would need another specific function mkPluginHandlerThatSupportsResolve, wich imo would be kinda ugly

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The helpers in Resolve.hs are opinionated ways to provide fallback to resloveless methods if not supported, but it makes sense to support plugin users who want to write their own fallback logic. For example code lenses don't need fallbacks, and thus currently use mkPluginHandler directly even though they support resolve.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Right, so I am suggesting something just like that, I was even thinking more specific: mkCodeLensHandlerWithResolve or something. I don't think it's so bad for people to have to call a specific function to opt in to the magic?

@joyfulmantis joyfulmantis enabled auto-merge (squash) July 14, 2023 17:23
@joyfulmantis joyfulmantis added the merge me Label to trigger pull request merge label Jul 14, 2023
@joyfulmantis joyfulmantis removed the merge me Label to trigger pull request merge label Jul 14, 2023
@joyfulmantis joyfulmantis enabled auto-merge (squash) July 15, 2023 06:40
@joyfulmantis joyfulmantis added the merge me Label to trigger pull request merge label Jul 15, 2023
@joyfulmantis joyfulmantis merged commit c501f38 into haskell:master Jul 17, 2023
71 of 73 checks passed
@fendor fendor mentioned this pull request Aug 8, 2023
19 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
merge me Label to trigger pull request merge
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants