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 1: Support for resolve in overloaded-record-dot #3658

Merged
merged 24 commits into from
Jun 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bb742b3
resolve for overloaded-record-dot (checkpoint)
joyfulmantis Jun 13, 2023
be71eb9
resolve support works on VSCode (tests need to be redone)
joyfulmantis Jun 14, 2023
fb21134
Tests for both resolve and non resolve variants
joyfulmantis Jun 15, 2023
f347ebc
Added more tests
joyfulmantis Jun 19, 2023
c19480d
Fix merge mistakes; move function to hls-test-utils
joyfulmantis Jun 19, 2023
5e37f6f
Remove codeLens resolve
joyfulmantis Jun 19, 2023
4bcd45b
Don't use partial functions
joyfulmantis Jun 21, 2023
7d4f01e
Implement michaelpj's suggestions
joyfulmantis Jun 22, 2023
225152e
Make owned resolve data transparent to the plugins
joyfulmantis Jun 26, 2023
4b34265
Improve ord's resolve handler's error handling
joyfulmantis Jun 26, 2023
9985195
Oh well, if only we had MonadFail
joyfulmantis Jun 26, 2023
355e95c
Generic support for resolve in hls packages
joyfulmantis Jun 27, 2023
2e4d14c
Merge branch 'resolve-support' into ord-resolve
joyfulmantis Jun 27, 2023
fb49c31
Add a new code action resolve helper that falls backs to commands
joyfulmantis Jun 27, 2023
d1d299b
add resolve capability set to hls-test-utils
joyfulmantis Jun 28, 2023
e025840
Merge branch 'resolve-support' into ord-resolve
joyfulmantis Jun 28, 2023
0b57d5a
use caps defined at hls-test-utils
joyfulmantis Jun 28, 2023
735feca
Add code lens resolve support
joyfulmantis Jun 29, 2023
6b3b915
Merge branch 'master' into resolve-support
michaelpj Jun 29, 2023
1ba6098
Merge branch 'resolve-support' into ord-resolve
joyfulmantis Jun 29, 2023
7e9bf1d
Merge branch 'master' into ord-resolve
michaelpj Jun 29, 2023
0271ce2
Improve comments
joyfulmantis Jun 29, 2023
794034b
remove Benchmark as it wasn't that useful and triggered a lsp-test bug
joyfulmantis Jun 30, 2023
7790755
Merge branch 'master' into ord-resolve
joyfulmantis Jun 30, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ library
exposed-modules: Ide.Plugin.OverloadedRecordDot
build-depends:
, base >=4.16 && <5
, aeson
, ghcide
, hls-plugin-api
, lsp
Expand Down Expand Up @@ -58,8 +59,12 @@ test-suite tests
build-depends:
, base
, filepath
, ghcide
, text
, hls-overloaded-record-dot-plugin
, lens
, lsp-test
, lsp-types
, row-types
, hls-test-utils

Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@ module Ide.Plugin.OverloadedRecordDot

-- based off of Berk Okzuturk's hls-explicit-records-fields-plugin

import Control.Lens ((^.))
import Control.Lens (_Just, (^.), (^?))
import Control.Monad (replicateM)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Except (ExceptT)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT, throwE)
import Data.Aeson (FromJSON, Result (..),
ToJSON, fromJSON, toJSON)
import Data.Generics (GenericQ, everything,
everythingBut, mkQ)
import qualified Data.IntMap.Strict as IntMap
import qualified Data.Map as Map
import Data.Maybe (mapMaybe, maybeToList)
import Data.Maybe (fromJust, mapMaybe,
maybeToList)
import Data.Text (Text)
import Data.Unique (hashUnique, newUnique)
import Development.IDE (IdeState,
NormalizedFilePath,
NormalizedUri,
Pretty (..), Range,
Recorder (..), Rules,
WithPriority (..),
Expand Down Expand Up @@ -75,18 +83,22 @@ import Ide.Types (PluginDescriptor (..),
PluginId (..),
PluginMethodHandler,
defaultPluginDescriptor,
mkCodeActionHandlerWithResolve,
mkPluginHandler)
import Language.LSP.Protocol.Lens (HasChanges (changes))
import qualified Language.LSP.Protocol.Lens as L
import Language.LSP.Protocol.Message (Method (..),
SMethod (..))
import Language.LSP.Protocol.Types (CodeAction (..),
CodeActionKind (CodeActionKind_RefactorRewrite),
CodeActionParams (..),
Command, TextEdit (..),
WorkspaceEdit (WorkspaceEdit),
Uri (..),
WorkspaceEdit (WorkspaceEdit, _changeAnnotations, _changes, _documentChanges),
fromNormalizedUri,
normalizedFilePathToUri,
type (|?) (..))
import Language.LSP.Server (getClientCapabilities)
data Log
= LogShake Shake.Log
| LogCollectedRecordSelectors [RecordSelectorExpr]
Expand All @@ -105,7 +117,14 @@ instance Hashable CollectRecordSelectors
instance NFData CollectRecordSelectors

data CollectRecordSelectorsResult = CRSR
{ recordInfos :: RangeMap RecordSelectorExpr
{ -- |We store everything in here that we need to create the unresolved
-- codeAction: the range, an uniquely identifiable int, and the selector
--selector expression (HSExpr) that we use to generate the name
records :: RangeMap (Int, HsExpr (GhcPass 'Renamed))
joyfulmantis marked this conversation as resolved.
Show resolved Hide resolved
-- |This is for when we need to fully generate a textEdit. It contains the
-- whole expression we are interested in indexed to the unique id we got
-- from the previous field
, recordInfos :: IntMap.IntMap RecordSelectorExpr
joyfulmantis marked this conversation as resolved.
Show resolved Hide resolved
, enabledExtensions :: [Extension]
}
deriving (Generic)
Expand Down Expand Up @@ -135,56 +154,85 @@ instance Pretty RecordSelectorExpr where
instance NFData RecordSelectorExpr where
rnf = rwhnf

-- |The data that is serialized and placed in the data field of resolvable
-- code actions
data ORDResolveData = ORDRD {
Copy link
Collaborator

Choose a reason for hiding this comment

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

WDYT about the idea of just reusing the CodeActionParams?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

At least in this case, the CodeActionParams doesn't make sense. The problem with the codeActionParams is we are going to need to do processing anyways to know whether we can provide the codeAction, and right now it's a title too, so it makes sense to just process once instead of once to present and once to execute

Copy link
Collaborator

Choose a reason for hiding this comment

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

Right. I think it definitely makes sense that we'll want a "stateful version" of resolve-based handlers. Maybe we'll also want a stateless one... I think there are some plugins that don't define any of their own rules so don't even have anywhere to put state. But perhaps easier to do a few and then refactor afterwards.

-- |We need the uri to get shake results
uri :: Uri
-- |The unique id that allows us to find the specific codeAction we want
, uniqueID :: Int
} deriving (Generic, Show)
instance ToJSON ORDResolveData
instance FromJSON ORDResolveData

descriptor :: Recorder (WithPriority Log) -> PluginId
-> PluginDescriptor IdeState
descriptor recorder plId = (defaultPluginDescriptor plId)
{ pluginHandlers =
mkPluginHandler SMethod_TextDocumentCodeAction codeActionProvider
mkCodeActionHandlerWithResolve codeActionProvider resolveProvider
, pluginRules = collectRecSelsRule recorder
}

resolveProvider :: PluginMethodHandler IdeState 'Method_CodeActionResolve
resolveProvider ideState pId ca@(CodeAction _ _ _ _ _ _ _ (Just resData)) =
pluginResponse $ do
case fromJSON resData of
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just flagging that this seems like it's going to be a pretty much universal pattern for resolve handlers: the first thing they're going to do is decode their data from the data field. So we might want to make it part of the generic machinery.

Success (ORDRD uri int) -> do
nfp <- getNormalizedFilePath uri
CRSR _ crsDetails exts <- collectRecSelResult ideState nfp
pragma <- getFirstPragma pId ideState nfp
case IntMap.lookup int crsDetails of
Just rse -> pure $ ca {_edit = mkWorkspaceEdit uri rse exts pragma}
-- We need to throw a content modified error here, see
-- https://github.com/microsoft/language-server-protocol/issues/1738
-- but we need fendor's plugin error response pr to make it
-- convenient to use here, so we will wait to do that till that's merged
_ -> throwE "Content Modified Error"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looking at the spec, I'm not sure this is the correct meaning of ContentModified. I don't think there's actually anything wrong with the client sending us a document change notification in between asking for code actions and resolving them, even if we can't handle that. Perhaps just RequestFailed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It was mentioned in a GitHub issue that this was the way to do it. microsoft/language-server-protocol#1738

Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, let's link to that when we do this!

Copy link
Collaborator

Choose a reason for hiding this comment

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

Bonus points: make a PR upstream to clarify in the spec that this is what you should do!

_ -> throwE "Unable to deserialize the data"

codeActionProvider :: PluginMethodHandler IdeState 'Method_TextDocumentCodeAction
codeActionProvider ideState pId (CodeActionParams _ _ caDocId caRange _) =
pluginResponse $ do
nfp <- getNormalizedFilePath (caDocId ^. L.uri)
pragma <- getFirstPragma pId ideState nfp
CRSR crsMap exts <- collectRecSelResult ideState nfp
let pragmaEdit =
if OverloadedRecordDot `elem` exts
then Nothing
else Just $ insertNewPragma pragma OverloadedRecordDot
edits crs = convertRecordSelectors crs : maybeToList pragmaEdit
changes crs =
Just $ Map.singleton (fromNormalizedUri
(normalizedFilePathToUri nfp))
(edits crs)
mkCodeAction crs = InR CodeAction
CRSR crsMap crsDetails exts <- collectRecSelResult ideState nfp
let mkCodeAction (crsM, nse) = InR CodeAction
{ -- We pass the record selector to the title function, so that
-- we can have the name of the record selector in the title of
-- the codeAction. This allows the user can easily distinguish
-- between the different codeActions when using nested record
-- selectors, the disadvantage is we need to print out the
-- name of the record selector which will decrease performance
_title = mkCodeActionTitle exts crs
_title = mkCodeActionTitle exts crsM nse
, _kind = Just CodeActionKind_RefactorRewrite
, _diagnostics = Nothing
, _isPreferred = Nothing
, _disabled = Nothing
, _edit = Just $ WorkspaceEdit (changes crs) Nothing Nothing
, _edit = Nothing
, _command = Nothing
, _data_ = Nothing
, _data_ = Just $ toJSON $ ORDRD (caDocId ^. L.uri) crsM
}
actions = map mkCodeAction (RangeMap.filterByRange caRange crsMap)
pure $ InL actions
where
mkCodeActionTitle :: [Extension] -> RecordSelectorExpr-> Text
mkCodeActionTitle exts (RecordSelectorExpr _ se _) =
mkCodeActionTitle :: [Extension] -> Int -> HsExpr (GhcPass 'Renamed) -> Text
mkCodeActionTitle exts crsM se =
if OverloadedRecordDot `elem` exts
then title
else title <> " (needs extension: OverloadedRecordDot)"
where
title = "Convert `" <> name <> "` to record dot syntax"
name = printOutputable se
title = "Convert `" <> printOutputable se <> "` to record dot syntax"

mkWorkspaceEdit:: Uri -> RecordSelectorExpr -> [Extension] -> NextPragmaInfo-> Maybe WorkspaceEdit
mkWorkspaceEdit uri recSel exts pragma =
Just $ WorkspaceEdit
{ _changes =
Just (Map.singleton uri (convertRecordSelectors recSel : maybeToList pragmaEdit))
, _documentChanges = Nothing
, _changeAnnotations = Nothing}
where pragmaEdit =
if OverloadedRecordDot `elem` exts
then Nothing
else Just $ insertNewPragma pragma OverloadedRecordDot

collectRecSelsRule :: Recorder (WithPriority Log) -> Rules ()
collectRecSelsRule recorder = define (cmapWithPrio LogShake recorder) $
Expand All @@ -201,11 +249,17 @@ collectRecSelsRule recorder = define (cmapWithPrio LogShake recorder) $
-- the OverloadedRecordDot pragma
exts = getEnabledExtensions tmr
recSels = mapMaybe (rewriteRange pm) (getRecordSelectors tmr)
-- We are creating a list as long as our rec selectors of unique int s
-- created by calling hashUnique on a Unique. The reason why we are
-- extracting the ints is because they don't need any work to serialize.
uniques <- liftIO $ replicateM (length recSels) (hashUnique <$> newUnique)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is newUnique threadsafe? We run handlers in individual threads, so there could well be multiple instances of this running at once.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

great

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 have to worry about contention on the ioref if we end up using this everywhere, but I expect that won't manifest until we're using it a lot. We should remember to try and check when we're nearly done

logWith recorder Debug (LogCollectedRecordSelectors recSels)
let -- We need the rangeMap to be able to filter by range later
crsMap :: RangeMap RecordSelectorExpr
crsMap = RangeMap.fromList location recSels
pure ([], CRSR <$> Just crsMap <*> Just exts)
let crsUniquesAndDetails = zip uniques recSels
-- We need the rangeMap to be able to filter by range later
rangeAndUnique = toRangeAndUnique <$> crsUniquesAndDetails
crsMap :: RangeMap (Int, HsExpr (GhcPass 'Renamed))
crsMap = RangeMap.fromList' rangeAndUnique
pure ([], CRSR <$> Just crsMap <*> Just (IntMap.fromList crsUniquesAndDetails) <*> Just exts)
where getEnabledExtensions :: TcModuleResult -> [Extension]
getEnabledExtensions = getExtensions . tmrParsed
getRecordSelectors :: TcModuleResult -> [RecordSelectorExpr]
Expand All @@ -217,6 +271,7 @@ collectRecSelsRule recorder = define (cmapWithPrio LogShake recorder) $
case toCurrentRange pm (location recSel) of
Just newLoc -> Just $ recSel{location = newLoc}
Nothing -> Nothing
toRangeAndUnique (id, RecordSelectorExpr l (unLoc -> se) _) = (l, (id, se))

convertRecordSelectors :: RecordSelectorExpr -> TextEdit
convertRecordSelectors (RecordSelectorExpr l se re) =
Expand Down
70 changes: 55 additions & 15 deletions plugins/hls-overloaded-record-dot-plugin/test/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,64 @@

module Main ( main ) where

import Control.Lens ((^.))
import Data.Either (rights)
import Data.Functor (void)
import Data.Maybe (isNothing)
import Data.Row
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TL
import Development.IDE.Types.Logger (Doc, Logger (Logger),
Pretty (pretty),
Priority (Debug),
Recorder (Recorder, logger_),
WithPriority (WithPriority, priority),
cfilter, cmapWithPrio,
makeDefaultStderrRecorder)
import qualified Ide.Plugin.OverloadedRecordDot as OverloadedRecordDot
import System.FilePath ((</>))
import Language.LSP.Protocol.Lens as L
import System.FilePath ((<.>), (</>))
import Test.Hls

import Test.Hls.Util (codeActionNoResolveCaps,
codeActionResolveCaps)

main :: IO ()
main = defaultTestRunner test
main =
defaultTestRunner test

plugin :: PluginTestDescriptor OverloadedRecordDot.Log
plugin = mkPluginTestDescriptor OverloadedRecordDot.descriptor "overloaded-record-dot"

test :: TestTree
test = testGroup "overloaded-record-dot"
[ mkTest "Simple" "Simple" "name" 10 7 10 15,
mkTest "NoPragmaNeeded" "NoPragmaNeeded" "name" 11 7 11 15,
mkTest "NestedParens" "NestedParens" "name" 15 7 15 24,
mkTest "NestedDot" "NestedDot" "name" 17 7 17 22,
mkTest "NestedDollar" "NestedDollar" "name" 15 7 15 24,
mkTest "MultilineCase" "MultilineCase" "name" 10 7 12 15,
mkTest "Multiline" "Multiline" "name" 10 7 11 15,
mkTest "MultilineExpanded" "MultilineExpanded" "owner" 28 8 28 19
]

mkTest :: TestName -> FilePath -> T.Text -> UInt -> UInt -> UInt -> UInt -> TestTree
(mkTest "Simple" "Simple" "name" 10 7 10 15
<> mkTest "NoPragmaNeeded" "NoPragmaNeeded" "name" 11 7 11 15
<> mkTest "NestedParens" "NestedParens" "name" 15 7 15 24
<> mkTest "NestedDot" "NestedDot" "name" 17 7 17 22
<> mkTest "NestedDollar" "NestedDollar" "name" 15 7 15 24
<> mkTest "MultilineCase" "MultilineCase" "name" 10 7 12 15
<> mkTest "Multiline" "Multiline" "name" 10 7 11 15
<> mkTest "MultilineExpanded" "MultilineExpanded" "owner" 28 8 28 19)

mkTest :: TestName -> FilePath -> T.Text -> UInt -> UInt -> UInt -> UInt -> [TestTree]
mkTest title fp selectorName x1 y1 x2 y2 =
goldenWithHaskellDoc plugin title testDataDir fp "expected" "hs" $ \doc -> do
[mkNoResolveTest (title <> " without resolve") fp selectorName x1 y1 x2 y2,
mkResolveTest (title <> " with resolve") fp selectorName x1 y1 x2 y2]

mkNoResolveTest :: TestName -> FilePath -> T.Text -> UInt -> UInt -> UInt -> UInt -> TestTree
mkNoResolveTest title fp selectorName x1 y1 x2 y2 =
goldenWithHaskellAndCaps codeActionNoResolveCaps plugin title testDataDir fp "expected" "hs" $ \doc -> do
(act:_) <- getExplicitFieldsActions doc selectorName x1 y1 x2 y2
executeCodeAction act

mkResolveTest :: TestName -> FilePath -> T.Text -> UInt -> UInt -> UInt -> UInt -> TestTree
mkResolveTest title fp selectorName x1 y1 x2 y2 =
goldenWithHaskellAndCaps codeActionResolveCaps plugin title testDataDir fp "expected" "hs" $ \doc -> do
((Right act):_) <- getAndResolveExplicitFieldsActions doc selectorName x1 y1 x2 y2
executeCodeAction act


getExplicitFieldsActions
:: TextDocumentIdentifier
-> T.Text
Expand All @@ -46,6 +73,19 @@ getExplicitFieldsActions doc selectorName x1 y1 x2 y2 =
where
range = Range (Position x1 y1) (Position x2 y2)

getAndResolveExplicitFieldsActions
:: TextDocumentIdentifier
-> T.Text
-> UInt -> UInt -> UInt -> UInt
-> Session [Either ResponseError CodeAction]
getAndResolveExplicitFieldsActions doc selectorName x1 y1 x2 y2 = do
actions <- findExplicitFieldsAction selectorName <$> getCodeActions doc range
rsp <- mapM (request SMethod_CodeActionResolve) (filter (\x -> isNothing (x ^. L.edit)) actions)
pure $ (^. L.result) <$> rsp

where
range = Range (Position x1 y1) (Position x2 y2)

findExplicitFieldsAction :: T.Text -> [a |? CodeAction] -> [CodeAction]
findExplicitFieldsAction selectorName = filter (isExplicitFieldsCodeAction selectorName) . rights . map toEither

Expand Down