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

Provide explicit import in inlay hints #4235

Merged
merged 40 commits into from
Jul 9, 2024

Conversation

jetjinser
Copy link
Contributor

@jetjinser jetjinser commented May 16, 2024

This PR introduces new feature to provide explicit imports via inlay hints.

textEdits are applied when the editor accepts inlay hints (double click in vscode). The effect of applying textEdits is to complete imports.This behavior is consistent with code lens.

(For example, in vscode) When the cursor is hover on the label of inlay hints, the tooltip will be displayed as a popup. tooltip is always "Make this import explicit".

Special logic, when the lsp client supports inlay hints, explicit import will be provided in the inlay hints, otherwise it will fallback to the code lens.

module ExplicitUsualCase where                                                                                                                                               

import ExplicitA

main :: IO ()
main = putStrLn $ "hello " ++ a1
module ExplicitUsualCase where                                                                                                                                               

import ExplicitA ( a1 ) -- in inlay hints

main :: IO ()
main = putStrLn $ "hello " ++ a1

@jetjinser jetjinser marked this pull request as ready for review June 14, 2024 14:12
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, so the things we definitely need before we can land this:

  • Clicking the hints needs to do something. I think that means either including the textEdits field or working out how to connect things to a command.
  • We need to be able to configure these so you can have one or the other. A simple thing to do would be to add some generic config for "inlay hints on or off", like we have for code lenses etc. Then people at least have some way of configuring it.

@@ -803,6 +809,12 @@ instance PluginRequestMethod Method_TextDocumentSemanticTokensFull where
instance PluginRequestMethod Method_TextDocumentSemanticTokensFullDelta where
combineResponses _ _ _ _ (x :| _) = x

instance PluginRequestMethod Method_TextDocumentInlayHint where
combineResponses _ _ _ _ (x :| _) = x
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doesn't seem right: the inlay hints request returns a list! So it's a really easy example where we can combine the responses nicely, just concatenate them.

combineResponses _ _ _ _ (x :| _) = x

instance PluginRequestMethod Method_InlayHintResolve where
combineResponses _ _ _ _ (x :| _) = x
Copy link
Collaborator

Choose a reason for hiding this comment

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

Include the reference to the note explaining why there should only be one resolve handler. We can also consider implementing resolve later.

-- This plugin provides code actions
-- This plugin provides inlay hints
<> mkPluginHandler SMethod_TextDocumentInlayHint (inlayHintProvider recorder)
-- <> mkResolveHandler SMethod_InlayHintResolve (inlayHintResolveProvider recorder)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think just delete this for now, we can worry about resolve later.

(ImportActionsResult {forLens, forResolve}, pm) <- runActionE "ImportActions" state $ useWithStaleE ImportActions nfp
let inlayHints = [ generateInlayHints newRange ie
| (range, int) <- forLens
, range < visibleRange
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you want isSubRangeOf, I'm not sure this does what you want.

InlayHint { _position = _end
, _label = mkLabel ie
, _kind = Nothing
, _textEdits = Nothing
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should definitely set this, it's kind of the point!

Copy link
Collaborator

Choose a reason for hiding this comment

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

God, the spec is weird. Inlay hints themselves only have text edits, but label parts can have commands??? I'm not even sure what you're meant to do there.

@@ -444,6 +479,10 @@ abbreviateImportTitle input =
else actualPrefix <> suffixText
in title

squashedAbbreviateImportTitle :: T.Text -> T.Text
Copy link
Collaborator

Choose a reason for hiding this comment

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

How is this different to what we do on line 170? Why are we doing different things?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think I worked it out: the previous thing has the whole import spec in it. I think it would be better to just create the two bits of the import spec separately when we generate the title, then we can use only the pieces we need.

We'd then need to tweak abbreviateImportTitle to just abbreviate the list of imported items, and then we can glue it onto the previous part of the import or not depending on whether we're creating an inlay hint or a code lens.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not only that, abbreviateImportTitle will retain a lot of indentation (shown as spaces after cutting by line), and inlay hints label will need remove those spaces because they are displayed at the end of each imports statement to avoid being too long.

Copy link
Collaborator

@michaelpj michaelpj Jun 17, 2024

Choose a reason for hiding this comment

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

The thing I was suggesting is that in the ImportActions rule we could compute both a) the prefix of the import (import Foo) and the suffix ((bar, baz)). Then for the code lenses we can concatenate them together and abbreviate, and for the lens we can just use the suffix and abbreviate?

cabal.project Outdated
@@ -16,6 +16,24 @@ benchmarks: True

write-ghc-environment-files: never

source-repository-package
Copy link
Collaborator

Choose a reason for hiding this comment

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

you can use the released version now, just bump the index-state

where
generateInlayHints :: Range -> ImportEdit -> InlayHint
generateInlayHints Range {_end} ie =
InlayHint { _position = _end
Copy link
Collaborator

Choose a reason for hiding this comment

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

This needs a comment. We are assuming that the appropriate position for the hint to begin is the end of the range for the lens, we should document that carefully.

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.

This is looking generally good! It's also quite non-obvious to me why the failing test is failing, it doesn't look like you've changed anything there. I'll think about it. Maybe @fendor has an idea?

}

isInlayHintsSupported :: MonadIO m => IdeState -> m Bool
isInlayHintsSupported state = do
Shake.ShakeExtras{lspEnv} <- liftIO $ runAction "" state Shake.getShakeExtras
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems a bit complicated, compare with https://github.com/haskell/haskell-language-server/blob/master/ghcide/src/Development/IDE/Plugin/Completions.hs#L208 (I just searched for "clientCaps"!)

, Just newRange <- [toCurrentRange pm range]]
pure $ InL lens
lensProvider _ state _ CodeLensParams {_textDocument = TextDocumentIdentifier {_uri}} = do
isIHSupported <- liftIO $ isInlayHintsSupported state
Copy link
Collaborator

Choose a reason for hiding this comment

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

We need a note explaining what we're doing here.

lensProvider _ state _ CodeLensParams {_textDocument = TextDocumentIdentifier {_uri}} = do
isIHSupported <- liftIO $ isInlayHintsSupported state
if isIHSupported
then do pure $ InR Null
Copy link
Collaborator

Choose a reason for hiding this comment

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

We might be able to return a more useful error here. I think we can maybe use PluginRequestRefused? Or maybe it's just the most sensible thing to succeed but return nothing.

{ _position = Position {_line = 3, _character = 16}
, _label = InL "( b1 )"
, _kind = Just InlayHintKind_Type
, _textEdits = Just [TextEdit (Range (Position 3 0) (Position 3 16)) "import ExplicitB ( b1 )"]
Copy link
Collaborator

Choose a reason for hiding this comment

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

These text edits are arguably overly aggressive. We could just insert the export list where it needs to go, rather than replacing the entire import. That would have the advantage of preserving as much as possible of what the user wrote.

I'm not sure it makes a big difference, but we should think about it and write down why we chose this one. I think in general the expectation is that an inlay hint inserts just the text in the hint...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, append is what I did first, but I did run into some problems, like this code:

import           Development.IDE                      hiding (pluginHandlers,
                                                       pluginRules)

If we just append it, it will obviously break the import statement.
So I chose the same behavior as the original code lens & code action, replacing the entire import statement, so that users only need to re-format it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Interesting! How does the inlay hint display in that case? I guess here the "perfect" thing to do would be to append the import list and also delete any "hiding" clauses. Complicated, though.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, needs a comment, and maybe a test?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I guess here the "perfect" thing to do would be to append the import list and also delete any "hiding" clauses. Complicated, though.

The current approach is to append inlay hints directly at the end.
I don't think it's possible to have inlay hints "cover" a part of your code.

Also, needs a comment, and maybe a test?

Okay, I'll add both.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think it's possible to have inlay hints "cover" a part of your code.

Right, that's definitely true for the hint. I meant that the text edits could only overwrite the hiding clause rather than the entire line.

, _kind = Just InlayHintKind_Type -- for type annotations
, _textEdits = fmap singleton $ toTEdit pm ie
, _tooltip = Nothing
, _paddingLeft = Just True
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you figure out what these padding fields do? It has been very unclear to me form the spec!

Copy link
Contributor Author

@jetjinser jetjinser Jun 25, 2024

Choose a reason for hiding this comment

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

In neovim and vscode, it means:

with padding
image

without padding
image

Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, so it basically means "show an extra space before the inlay hint. I guess that makes sense.

, codeActionOnlyGoldenTest "ExplicitOnlyThis" 3 0
, codeActionOnlyResolveGoldenTest "ExplicitOnlyThis" 3 0
, inlayHintsTest "ExplicitOnlyThis" 3 $ (@=?)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should test that we don't get code actions when we have inlay hints enabled and vice versa.

@fendor
Copy link
Collaborator

fendor commented Jun 25, 2024

Which test is failing unexpectedly? ExplicitBreakFile code action (golden)?

@jetjinser
Copy link
Contributor Author

jetjinser commented Jun 25, 2024

Which test is failing unexpectedly? ExplicitBreakFile code action (golden)?

Yes. The failed started appearing after this commit. This line is where I can find the error raised.

I can't get any more information out of this error.

    ExplicitBreakFile code action (golden):                 FAIL (0.21s)
      Received an expected error in a response for id IdInt 3:
      TResponseError {_code = InL LSPErrorCodes_RequestFailed, _message = "explicitImports: Rule Failed: ImportActions", _xdata = Nothing}
      Use -p '/ExplicitBreakFile code action (golden)/' to rerun this test only.

@fendor
Copy link
Collaborator

fendor commented Jun 26, 2024

I debugged this to the point that replacing isInlayHintsSupported with pure False makes the test succeed, but isInlayHintsSupported in the lensProvider makes it crash.
I don't understand this issue, yet. This change looks completely unrelated.

@fendor
Copy link
Collaborator

fendor commented Jun 27, 2024

@jetjinser Change the client capabilities of codeActionBreakFile to use codeActionNoInlayHintsCaps, that worked for me locally.

However, the error message itself is extremely puzzeling.

@jetjinser
Copy link
Contributor Author

@jetjinser Change the client capabilities of codeActionBreakFile to use codeActionNoInlayHintsCaps, that worked for me locally.

However, the error message itself is extremely puzzeling.

Yes, this works on my machine too.

Should I apply this modification? I'll add a comment pointing to the discussion here.

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.

I do think we still need a way to turn these off at the plugin level, like we have for code lenses etc. See https://github.com/haskell/haskell-language-server/blob/master/hls-plugin-api/src/Ide/Types.hs#L262

isInlayHintsSupported :: Applicative f => IdeState -> f Bool
isInlayHintsSupported ideState = do
let clientCaps = Shake.clientCapabilities $ shakeExtras ideState
pure $ case clientCaps of
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could do

isJust $ clientCaps ^? textDocument . _Just . inlayHint . _Just

-- otherwise it will be provided as a fallback
isIHSupported <- liftIO $ isInlayHintsSupported state
if isIHSupported
then do pure $ InL []
Copy link
Collaborator

Choose a reason for hiding this comment

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

Comment about why this is the right thing to return here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm trying to figure out what's the difference between the two, and from this comment (microsoft/language-server-protocol#1200 (comment)), it seems like [] is no different from null.

But we do need to uniformly choose a way to indicate “no information”. Do you think Right [] is better?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, that seems reasonable to me.

pure $ InL inlayHints
-- When the client does not support inlay hints, fallback to the code lens,
-- so this is Null
else do pure $ InR Null
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we return null here but an empty list of code lenses above? It helps to be consistent!

Also, we should add: if the client doesn't support inlay hints, this method should never get called!

, _kind = Just InlayHintKind_Type -- for type annotations
, _textEdits = fmap singleton $ toTEdit pm ie
, _tooltip = Nothing
, _paddingLeft = Just True
Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, so it basically means "show an extra space before the inlay hint. I guess that makes sense.

mkLabel :: ImportEdit -> T.Text
mkLabel (ImportEdit{ieResType, ieText}) =
let title ExplicitImport = abbreviateImportTitle . T.dropWhile (/= '(') $ ieText
title RefineImport = "Refine imports to " <> T.intercalate ", " (T.lines ieText)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems odd. Do we have any tests showing this? These perhaps shouldn't be shown as inlay hints? I'm unsure.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It's possible these need to always be code lenses, since they don't represent implicit content that can be added to the document by accepting it, which is pretty much what inlay hints are supposed to be. Maybe I'm wrong though, not sure.

Copy link
Contributor Author

@jetjinser jetjinser Jun 29, 2024

Choose a reason for hiding this comment

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

Well, I was going to support refine with inlay hints, it may looks like
import qualified RefineA <inlay>from RefineB</inlay> as RA

or
import RefineD <inlay>from RefineE</inlay><inlay> ( e2 )</inlay>

But this is a little tricky for me, I'm trying to locate where the module name position is. In addition, I don't know if I understand it wrong, there may be many original modules? This makes inlay hints longer and jagged.
So I want to ask your opinion.

EDIT:
Another, easier, but equally jag way to do this is to put original module name at the beginning of the import.

image

This may not be very good... (especially I can't think of what word to use, or not use it -- it definitely can't be from) I want to just ignore refine in inlay hints?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, I think it would be pretty sensible to leave refine as a code lens and not an inlay hint. Let's do that for now and maybe open an issue to discuss what the best UX would be for refine imports?

generateInlayHints (Range _ end) ie pm =
InlayHint { _position = end
, _label = InL $ mkLabel ie
, _kind = Just InlayHintKind_Type -- for type annotations
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is surely the wrong kind. I think this should just be omitted.

, _label = InL $ mkLabel ie
, _kind = Just InlayHintKind_Type -- for type annotations
, _textEdits = fmap singleton $ toTEdit pm ie
, _tooltip = Nothing
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should definitely consider using this! For example, the tooltip for the minimal imports inlay hint could say "This is an import list for exactly the names used by the current module", or something. That way it's a bit more discoverable what it means. It's a nice UX feature!

Copy link
Contributor Author

@jetjinser jetjinser Jun 29, 2024

Choose a reason for hiding this comment

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

Could this be as simple as "Make this import explicit"? This doesn't even require resolve, even though the LSP spec recommends that the tooltip be obtained via resolve, but it's simple enough.

edit: it may be different when refining, but this still simple enough.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, I think either of those would be fine. I think we can just make it a constant string; the hint itself shows the things that are going to be inserted. So no need for resolve. Resolve is only for things that are expensive to compute.

@jetjinser jetjinser requested a review from michaelpj July 2, 2024 07:29
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.

One last thing then I think this is good to go!

-- otherwise it will be provided as a fallback
if isInlayHintsSupported state
-- `[]` is no different from `null`, we chose to use all `[]` to indicate "no information"
then pure $ InL []
Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, so I think the only thing remaining here is that we were going to leave the refine import actions as code lenses, so we need to return them in both cases. And never return them for inlay hints.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should this be checked at lens resolve? If I want to know whether ImportEdit is explicit or refine, it seems that can't do it in lensProvider.

If I can't tell whether the lensProvider should respond at the time, then every import statement actually needs to be resolved even that is not refine. I'm worried this will rely on the expensive resolve.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmmm, that's annoying. I would have thought we could determine what type it is beforehand? Perhaps we need to tweak the rule to include that information?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think now should be okay.

@michaelpj
Copy link
Collaborator

Great stuff, let's merge it 🎉

@michaelpj michaelpj added the merge me Label to trigger pull request merge label Jul 6, 2024
@mergify mergify bot merged commit 763f34c into haskell:master Jul 9, 2024
38 checks passed
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.

3 participants