Skip to content

Improve handling of nonsense rename attempts #4111

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

Merged
merged 3 commits into from
Mar 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions haskell-language-server.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ library hls-rename-plugin
, mtl
, mod
, syb
, row-types
, text
, transformers
, unordered-containers
Expand All @@ -526,6 +527,9 @@ test-suite hls-rename-plugin-tests
, hls-plugin-api
, haskell-language-server:hls-rename-plugin
, hls-test-utils == 2.7.0.0
, lens
, lsp-types
, text

-----------------------------
-- retrie plugin
Expand Down
9 changes: 8 additions & 1 deletion hls-plugin-api/src/Ide/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,9 @@ instance PluginMethod Request Method_CodeLensResolve where
instance PluginMethod Request Method_TextDocumentRename where
handlesRequest = pluginEnabledWithFeature plcRenameOn

instance PluginMethod Request Method_TextDocumentPrepareRename where
handlesRequest = pluginEnabledWithFeature plcRenameOn

instance PluginMethod Request Method_TextDocumentHover where
handlesRequest = pluginEnabledWithFeature plcHoverOn

Expand Down Expand Up @@ -599,7 +602,7 @@ class PluginMethod Request m => PluginRequestMethod (m :: Method ClientToServer
---
instance PluginRequestMethod Method_TextDocumentCodeAction where
combineResponses _method _config (ClientCapabilities _ textDocCaps _ _ _ _) (CodeActionParams _ _ _ _ context) resps =
InL $ fmap compat $ filter wasRequested $ concat $ mapMaybe nullToMaybe $ toList resps
InL $ fmap compat $ concatMap (filter wasRequested) $ mapMaybe nullToMaybe $ toList resps
where
compat :: (Command |? CodeAction) -> (Command |? CodeAction)
compat x@(InL _) = x
Expand Down Expand Up @@ -657,6 +660,10 @@ instance PluginRequestMethod Method_CodeLensResolve where

instance PluginRequestMethod Method_TextDocumentRename where

instance PluginRequestMethod Method_TextDocumentPrepareRename where
-- TODO more intelligent combining?
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.

I think it is fine we only pick up the newest

Copy link
Collaborator Author

@jhrcek jhrcek Mar 2, 2024

Choose a reason for hiding this comment

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

I think this is used for combining of responses in case multiple plugins happen to handle same type of request.
I'd say it's ok to leave it like this for now, because this PR introduces this new request type and for now rename plugin is the only one that supports it.


instance PluginRequestMethod Method_TextDocumentHover where
combineResponses _ _ _ _ (mapMaybe nullToMaybe . toList -> hs :: [Hover]) =
if null hs
Expand Down
92 changes: 58 additions & 34 deletions plugins/hls-rename-plugin/src/Ide/Plugin/Rename.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import Data.List.NonEmpty (NonEmpty ((:|)),
import qualified Data.Map as M
import Data.Maybe
import Data.Mod.Word
import Data.Row
import qualified Data.Set as S
import qualified Data.Text as T
import Development.IDE (Recorder, WithPriority,
Expand Down Expand Up @@ -57,43 +58,66 @@ import Language.LSP.Server
instance Hashable (Mod a) where hash n = hash (unMod n)

descriptor :: Recorder (WithPriority E.Log) -> PluginId -> PluginDescriptor IdeState
descriptor recorder pluginId = mkExactprintPluginDescriptor recorder $ (defaultPluginDescriptor pluginId "Provides renaming of Haskell identifiers")
{ pluginHandlers = mkPluginHandler SMethod_TextDocumentRename renameProvider
, pluginConfigDescriptor = defaultConfigDescriptor
{ configCustomConfig = mkCustomConfig properties }
}
descriptor recorder pluginId = mkExactprintPluginDescriptor recorder $
(defaultPluginDescriptor pluginId "Provides renaming of Haskell identifiers")
{ pluginHandlers = mconcat
[ mkPluginHandler SMethod_TextDocumentRename renameProvider
, mkPluginHandler SMethod_TextDocumentPrepareRename prepareRenameProvider
]
, pluginConfigDescriptor = defaultConfigDescriptor
{ configCustomConfig = mkCustomConfig properties }
}

prepareRenameProvider :: PluginMethodHandler IdeState Method_TextDocumentPrepareRename
prepareRenameProvider state _pluginId (PrepareRenameParams (TextDocumentIdentifier uri) pos _progressToken) = do
nfp <- getNormalizedFilePathE uri
namesUnderCursor <- getNamesAtPos state nfp pos
-- When this handler says that rename is invalid, VSCode shows "The element can't be renamed"
-- and doesn't even allow you to create full rename request.
-- This handler deliberately approximates "things that definitely can't be renamed"
-- to mean "there is no Name at given position".
--
-- In particular it allows some cases through (e.g. cross-module renames),
-- so that the full rename handler can give more informative error about them.
let renameValid = not $ null namesUnderCursor
pure $ InL $ PrepareRenameResult $ InR $ InR $ #defaultBehavior .== renameValid
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm unsure if this is the correct behaviour. It's very hard to tell what the intention of the various options for results of prepareRename are. Worth a comment anyway. We could consider returning the range corresponding to the name, although it's unclear if we have that...

Copy link
Collaborator Author

@jhrcek jhrcek Mar 4, 2024

Choose a reason for hiding this comment

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

I would say that lsp spec is quite clear in this regard:

If { defaultBehavior: boolean } is returned (since 3.16) the rename position is valid and the client should use its default behavior to compute the rename range.

I looked at what the vscode-languageclient is doing with that response (see here) and when you return the boolean:
if true it triggers the default rename behavior (finds the range of the symbol under cursor itself, opens input box where you can specify the new name, it uses the current name of the symbol as placeholder).
if false it gives you a small error popup saying "The element can't be renamed".

Since I find the default behavior unproblematic (by pressing F2 on bunch of things you can see that vscode's default behavior correctly finds the range of symbol under cursor), I went with this Bool return type.

I'll let you read this / react and I can open a PR with some additional clarifying comment later today.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I guess I'm just wondering if we can rely on the "default behaviour" or whether it would be better for us to be returning the actual range of the name? Maybe it's fine?

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 played with it for a bit and it seems it would be hard to get this right if we're to provide the Range under cursor.

The thing I had trouble to make work was qualified names.
e.g. if you rename IO.putStrLn the HieAST contains the range of the whole thing (including IO.) so if you trigger rename on it, you get the whole thing prefilled into the rename input which is confusing.

I'd be more comfortable keeping the current implementation which seems to work robustly on all cases we have in tests.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Huh, and vscode does the right thing and just changes putStrLn? IDK, seems potentially fragile but maybe it's fine.

Anyway, this all seems like super useful information, so worth writing down!


renameProvider :: PluginMethodHandler IdeState Method_TextDocumentRename
renameProvider state pluginId (RenameParams _prog (TextDocumentIdentifier uri) pos newNameText) = do
nfp <- getNormalizedFilePathE uri
Copy link
Collaborator Author

@jhrcek jhrcek Mar 2, 2024

Choose a reason for hiding this comment

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

Most of this is indentation changes. I recommend reviewing with "Hide whitespace"
Peek 2024-03-02 08-36

directOldNames <- getNamesAtPos state nfp pos
directRefs <- concat <$> mapM (refsAtName state nfp) directOldNames

{- References in HieDB are not necessarily transitive. With `NamedFieldPuns`, we can have
indirect references through punned names. To find the transitive closure, we do a pass of
the direct references to find the references for any punned names.
See the `IndirectPuns` test for an example. -}
indirectOldNames <- concat . filter ((>1) . length) <$>
mapM (uncurry (getNamesAtPos state) <=< locToFilePos) directRefs
let oldNames = filter matchesDirect indirectOldNames ++ directOldNames
matchesDirect n = occNameFS (nameOccName n) `elem` directFS
where
directFS = map (occNameFS. nameOccName) directOldNames
refs <- HS.fromList . concat <$> mapM (refsAtName state nfp) oldNames

-- Validate rename
crossModuleEnabled <- liftIO $ runAction "rename: config" state $ usePropertyAction #crossModule pluginId properties
unless crossModuleEnabled $ failWhenImportOrExport state nfp refs oldNames
when (any isBuiltInSyntax oldNames) $ throwError $ PluginInternalError "Invalid rename of built-in syntax"

-- Perform rename
let newName = mkTcOcc $ T.unpack newNameText
filesRefs = collectWith locToUri refs
getFileEdit (uri, locations) = do
verTxtDocId <- lift $ getVersionedTextDoc (TextDocumentIdentifier uri)
getSrcEdit state verTxtDocId (replaceRefs newName locations)
fileEdits <- mapM getFileEdit filesRefs
pure $ InL $ fold fileEdits
nfp <- getNormalizedFilePathE uri
directOldNames <- getNamesAtPos state nfp pos
directRefs <- concat <$> mapM (refsAtName state nfp) directOldNames

{- References in HieDB are not necessarily transitive. With `NamedFieldPuns`, we can have
indirect references through punned names. To find the transitive closure, we do a pass of
the direct references to find the references for any punned names.
See the `IndirectPuns` test for an example. -}
indirectOldNames <- concat . filter ((>1) . length) <$>
mapM (uncurry (getNamesAtPos state) <=< locToFilePos) directRefs
let oldNames = filter matchesDirect indirectOldNames ++ directOldNames
where
matchesDirect n = occNameFS (nameOccName n) `elem` directFS
directFS = map (occNameFS . nameOccName) directOldNames

case oldNames of
-- There were no Names at given position (e.g. rename triggered within a comment or on a keyword)
[] -> throwError $ PluginInvalidParams "No symbol to rename at given position"
_ -> do
refs <- HS.fromList . concat <$> mapM (refsAtName state nfp) oldNames

-- Validate rename
crossModuleEnabled <- liftIO $ runAction "rename: config" state $ usePropertyAction #crossModule pluginId properties
unless crossModuleEnabled $ failWhenImportOrExport state nfp refs oldNames
when (any isBuiltInSyntax oldNames) $ throwError $ PluginInternalError "Invalid rename of built-in syntax"

-- Perform rename
let newName = mkTcOcc $ T.unpack newNameText
filesRefs = collectWith locToUri refs
getFileEdit (uri, locations) = do
verTxtDocId <- lift $ getVersionedTextDoc (TextDocumentIdentifier uri)
getSrcEdit state verTxtDocId (replaceRefs newName locations)
fileEdits <- mapM getFileEdit filesRefs
pure $ InL $ fold fileEdits

-- | Limit renaming across modules.
failWhenImportOrExport ::
Expand Down
24 changes: 21 additions & 3 deletions plugins/hls-rename-plugin/test/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

module Main (main) where

import Control.Lens ((^.))
import Data.Aeson
import qualified Data.Map as M
import qualified Data.Map as M
import Data.Text (Text)
import Ide.Plugin.Config
import qualified Ide.Plugin.Rename as Rename
import qualified Ide.Plugin.Rename as Rename
import qualified Language.LSP.Protocol.Lens as L
import System.FilePath
import Test.Hls

Expand Down Expand Up @@ -64,11 +67,26 @@ tests = testGroup "Rename"
rename doc (Position 2 17) "BinaryTree"
, goldenWithRename "Type variable" "TypeVariable" $ \doc ->
rename doc (Position 0 13) "b"
, goldenWithRename "Rename within comment" "Comment" $ \doc -> do
let expectedError = ResponseError
(InR ErrorCodes_InvalidParams)
"rename: Invalid Params: No symbol to rename at given position"
Nothing
renameExpectError expectedError doc (Position 0 10) "ImpossibleRename"
]

goldenWithRename :: TestName-> FilePath -> (TextDocumentIdentifier -> Session ()) -> TestTree
goldenWithRename title path act =
goldenWithHaskellDoc (def { plugins = M.fromList [("rename", def { plcConfig = "crossModule" .= True })] }) renamePlugin title testDataDir path "expected" "hs" act
goldenWithHaskellDoc (def { plugins = M.fromList [("rename", def { plcConfig = "crossModule" .= True })] })
renamePlugin title testDataDir path "expected" "hs" act

renameExpectError :: ResponseError -> TextDocumentIdentifier -> Position -> Text -> Session ()
renameExpectError expectedError doc pos newName = do
let params = RenameParams Nothing doc pos newName
rsp <- request SMethod_TextDocumentRename params
case rsp ^. L.result of
Right _ -> liftIO $ assertFailure $ "Was expecting " <> show expectedError <> ", got success"
Left actualError -> liftIO $ assertEqual "ResponseError" expectedError actualError

testDataDir :: FilePath
testDataDir = "plugins" </> "hls-rename-plugin" </> "test" </> "testdata"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{- IShouldNotBeRenaemable -}
1 change: 1 addition & 0 deletions plugins/hls-rename-plugin/test/testdata/Comment.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{- IShouldNotBeRenaemable -}