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

hls-class-plugin enhancement #2920

Merged
merged 15 commits into from
Jun 22, 2022
12 changes: 12 additions & 0 deletions plugins/hls-class-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Class Plugin

The class plugin provides handy operations about class, includes:

1. Code action to add minimal class definition methods.
2. Type lens about missing type signatures for instance methods.

## Demo

![Code Actions](codeactions.gif)

![Code Lens](codelens.gif)
Binary file added plugins/hls-class-plugin/codeactions.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plugins/hls-class-plugin/codelens.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion plugins/hls-class-plugin/hls-class-plugin.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,22 @@ extra-source-files:

library
exposed-modules: Ide.Plugin.Class
other-modules: Ide.Plugin.Class.CodeAction
, Ide.Plugin.Class.CodeLens
, Ide.Plugin.Class.ExactPrint
, Ide.Plugin.Class.Types
, Ide.Plugin.Class.Utils
hs-source-dirs: src
build-depends:
, aeson
, base >=4.12 && <5
, containers
, deepseq
, extra
, ghc
, ghcide ^>=1.7
, ghc-boot-th
, hls-graph
, hls-plugin-api ^>=1.4
, lens
, lsp
Expand All @@ -44,8 +53,9 @@ library
default-extensions:
DataKinds
TypeOperators
OverloadedStrings

ghc-options: -Wno-unticked-promoted-constructors
ghc-options: -Wall -Wno-unticked-promoted-constructors -Wno-name-shadowing

test-suite tests
type: exitcode-stdio-1.0
Expand All @@ -54,10 +64,12 @@ test-suite tests
main-is: Main.hs
ghc-options: -threaded -rtsopts -with-rtsopts=-N
build-depends:
, aeson
, base
, filepath
, ghcide
, hls-class-plugin
, hls-plugin-api
, hls-test-utils ^>=1.3
, lens
, lsp-types
307 changes: 19 additions & 288 deletions plugins/hls-class-plugin/src/Ide/Plugin/Class.hs
Original file line number Diff line number Diff line change
@@ -1,295 +1,26 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
module Ide.Plugin.Class
( descriptor,
Log (..)
) where
module Ide.Plugin.Class (descriptor, Log(..)) where

import Control.Applicative
import Control.Lens hiding (List, use)
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Class
import Control.Monad.Trans.Maybe
import Data.Aeson
import Data.Char
import Data.Either (rights)
import Data.List
import qualified Data.Map.Strict as Map
import Data.Maybe
import qualified Data.Set as Set
import qualified Data.Text as T
import Development.IDE hiding (pluginHandlers)
import Development.IDE.Core.PositionMapping (fromCurrentRange,
toCurrentRange)
import Development.IDE.GHC.Compat as Compat hiding (locA,
(<+>))
import Development.IDE.GHC.Compat.Util
import Development.IDE.Spans.AtPoint
import qualified GHC.Generics as Generics
import Ide.PluginUtils
import Development.IDE (IdeState, Recorder, WithPriority)
import Ide.Plugin.Class.CodeAction
import Ide.Plugin.Class.CodeLens
import Ide.Plugin.Class.Types
import Ide.Types
import Language.Haskell.GHC.ExactPrint
import Language.Haskell.GHC.ExactPrint.Parsers (parseDecl)
import Language.Haskell.GHC.ExactPrint.Types hiding (GhcPs, Parens)
import Language.Haskell.GHC.ExactPrint.Utils (rs)
import Language.LSP.Server
import Language.LSP.Types
import qualified Language.LSP.Types.Lens as J

#if MIN_VERSION_ghc(9,2,0)
import GHC.Hs (AnnsModule (AnnsModule))
import GHC.Parser.Annotation
#endif

data Log
= LogImplementedMethods Class [T.Text]

instance Pretty Log where
pretty = \case
LogImplementedMethods cls methods ->
pretty ("Detected implmented methods for class" :: String)
<+> pretty (show (getOccString cls) <> ":") -- 'show' is used here to add quotes around the class name
<+> pretty methods

descriptor :: Recorder (WithPriority Log) -> PluginId -> PluginDescriptor IdeState
descriptor recorder plId = (defaultPluginDescriptor plId)
{ pluginCommands = commands
, pluginHandlers = mkPluginHandler STextDocumentCodeAction (codeAction recorder)
}

commands :: [PluginCommand IdeState]
commands
= [ PluginCommand "addMinimalMethodPlaceholders" "add placeholders for minimal methods" addMethodPlaceholders
{ pluginCommands = commands plId
, pluginRules = rules recorder
, pluginHandlers = mkPluginHandler STextDocumentCodeAction (codeAction recorder)
<> mkPluginHandler STextDocumentCodeLens codeLens
, pluginConfigDescriptor =
defaultConfigDescriptor { configCustomConfig = mkCustomConfig properties }
}

commands :: PluginId -> [PluginCommand IdeState]
commands plId
= [ PluginCommand codeActionCommandId
"add placeholders for minimal methods" (addMethodPlaceholders plId)
, PluginCommand typeLensCommandId
"add type signatures for instance methods" codeLensCommandHandler
]

-- | Parameter for the addMethods PluginCommand.
data AddMinimalMethodsParams = AddMinimalMethodsParams
{ uri :: Uri
, range :: Range
, methodGroup :: List T.Text
}
deriving (Show, Eq, Generics.Generic, ToJSON, FromJSON)

addMethodPlaceholders :: CommandFunction IdeState AddMinimalMethodsParams
addMethodPlaceholders state AddMinimalMethodsParams{..} = do
caps <- getClientCapabilities
medit <- liftIO $ runMaybeT $ do
docPath <- MaybeT . pure . uriToNormalizedFilePath $ toNormalizedUri uri
pm <- MaybeT . runAction "classplugin" state $ use GetParsedModule docPath
(hsc_dflags . hscEnv -> df) <- MaybeT . runAction "classplugin" state $ use GhcSessionDeps docPath
(old, new) <- makeEditText pm df
pure (workspaceEdit caps old new)

forM_ medit $ \edit ->
sendRequest SWorkspaceApplyEdit (ApplyWorkspaceEditParams Nothing edit) (\_ -> pure ())
pure (Right Null)
where
indent = 2

workspaceEdit caps old new
= diffText caps (uri, old) new IncludeDeletions

toMethodName n
| Just (h, _) <- T.uncons n
, not (isAlpha h || h == '_')
= "(" <> n <> ")"
| otherwise
= n

#if MIN_VERSION_ghc(9,2,0)
makeEditText pm df = do
List mDecls <- MaybeT . pure $ traverse (makeMethodDecl df) methodGroup
let ps = makeDeltaAst $ pm_parsed_source pm
old = T.pack $ exactPrint ps
(ps', _, _) = runTransform (addMethodDecls ps mDecls)
new = T.pack $ exactPrint ps'
pure (old, new)

makeMethodDecl df mName =
either (const Nothing) Just . parseDecl df (T.unpack mName) . T.unpack
$ toMethodName mName <> " = _"

addMethodDecls ps mDecls = do
allDecls <- hsDecls ps
let (before, ((L l inst): after)) = break (containRange range . getLoc) allDecls
replaceDecls ps (before ++ (L l (addWhere inst)): (map newLine mDecls ++ after))
where
-- Add `where` keyword for `instance X where` if `where` is missing.
--
-- The `where` in ghc-9.2 is now stored in the instance declaration
-- directly. More precisely, giving an `HsDecl GhcPs`, we have:
-- InstD --> ClsInstD --> ClsInstDecl --> XCClsInstDecl --> (EpAnn [AddEpAnn], AnnSortKey),
-- here `AnnEpAnn` keeps the track of Anns.
--
-- See the link for the original definition:
-- https://hackage.haskell.org/package/ghc-9.2.1/docs/Language-Haskell-Syntax-Extension.html#t:XCClsInstDecl
addWhere (InstD xInstD (ClsInstD ext decl@ClsInstDecl{..})) =
let ((EpAnn entry anns comments), key) = cid_ext
in InstD xInstD (ClsInstD ext decl {
cid_ext = (EpAnn
entry
(AddEpAnn AnnWhere (EpaDelta (SameLine 1) []) : anns)
comments
, key)
})
addWhere decl = decl

newLine (L l e) =
let dp = deltaPos 1 indent
in L (noAnnSrcSpanDP (locA l) dp <> l) e

#else
makeEditText pm df = do
List (unzip -> (mAnns, mDecls)) <- MaybeT . pure $ traverse (makeMethodDecl df) methodGroup
let ps = pm_parsed_source pm
anns = relativiseApiAnns ps (pm_annotations pm)
old = T.pack $ exactPrint ps anns
(ps', (anns', _), _) = runTransform (mergeAnns (mergeAnnList mAnns) anns) (addMethodDecls ps mDecls)
new = T.pack $ exactPrint ps' anns'
pure (old, new)

makeMethodDecl df mName =
case parseDecl df (T.unpack mName) . T.unpack $ toMethodName mName <> " = _" of
Right (ann, d) -> Just (setPrecedingLines d 1 indent ann, d)
Left _ -> Nothing

addMethodDecls ps mDecls = do
d <- findInstDecl ps
newSpan <- uniqueSrcSpanT
let
annKey = mkAnnKey d
newAnnKey = AnnKey (rs newSpan) (CN "HsValBinds")
addWhere mkds@(Map.lookup annKey -> Just ann)
= Map.insert newAnnKey ann2 mkds2
where
ann1 = ann
{ annsDP = annsDP ann ++ [(G AnnWhere, DP (0, 1))]
, annCapturedSpan = Just newAnnKey
, annSortKey = Just (fmap (rs . getLoc) mDecls)
}
mkds2 = Map.insert annKey ann1 mkds
ann2 = annNone
{ annEntryDelta = DP (1, indent)
}
addWhere _ = panic "Ide.Plugin.Class.addMethodPlaceholder"
modifyAnnsT addWhere
modifyAnnsT (captureOrderAnnKey newAnnKey mDecls)
foldM (insertAfter d) ps (reverse mDecls)

findInstDecl :: ParsedSource -> Transform (LHsDecl GhcPs)
findInstDecl ps = head . filter (containRange range . getLoc) <$> hsDecls ps
#endif

-- |
-- This implementation is ad-hoc in a sense that the diagnostic detection mechanism is
-- sensitive to the format of diagnostic messages from GHC.
codeAction :: Recorder (WithPriority Log) -> PluginMethodHandler IdeState TextDocumentCodeAction
codeAction recorder state plId (CodeActionParams _ _ docId _ context) = liftIO $ fmap (fromMaybe errorResult) . runMaybeT $ do
docPath <- MaybeT . pure . uriToNormalizedFilePath $ toNormalizedUri uri
actions <- join <$> mapM (mkActions docPath) methodDiags
pure . Right . List $ actions
where
errorResult = Right (List [])
uri = docId ^. J.uri
List diags = context ^. J.diagnostics

ghcDiags = filter (\d -> d ^. J.source == Just "typecheck") diags
methodDiags = filter (\d -> isClassMethodWarning (d ^. J.message)) ghcDiags

mkActions docPath diag = do
(HAR {hieAst = ast}, pmap) <-
MaybeT . runAction "classplugin" state $ useWithStale GetHieAst docPath
instancePosition <- MaybeT . pure $
fromCurrentRange pmap range ^? _Just . J.start
& fmap (J.character -~ 1)

ident <- findClassIdentifier ast instancePosition
cls <- findClassFromIdentifier docPath ident
implemented <- findImplementedMethods ast instancePosition
logWith recorder Info (LogImplementedMethods cls implemented)
lift . traverse (mkAction . (\\ implemented)) . minDefToMethodGroups . classMinimalDef $ cls
where
range = diag ^. J.range

mkAction methodGroup
= pure $ mkCodeAction title $ mkLspCommand plId "addMinimalMethodPlaceholders" title (Just cmdParams)
where
title = mkTitle methodGroup
cmdParams = mkCmdParams methodGroup

mkTitle methodGroup
= "Add placeholders for "
<> mconcat (intersperse ", " (fmap (\m -> "'" <> m <> "'") methodGroup))

mkCmdParams methodGroup = [toJSON (AddMinimalMethodsParams uri range (List methodGroup))]

mkCodeAction title cmd
= InR
$ CodeAction title (Just CodeActionQuickFix) (Just (List [])) Nothing Nothing Nothing (Just cmd) Nothing

findClassIdentifier :: HieASTs a -> Position -> MaybeT IO (Either ModuleName Name)
findClassIdentifier ast instancePosition =
pure
$ head . head
$ pointCommand ast instancePosition
( (Map.keys . Map.filter isClassNodeIdentifier . Compat.getNodeIds)
<=< nodeChildren
)

findClassFromIdentifier docPath (Right name) = do
(hscEnv -> hscenv, _) <- MaybeT . runAction "classplugin" state $ useWithStale GhcSessionDeps docPath
(tmrTypechecked -> thisMod, _) <- MaybeT . runAction "classplugin" state $ useWithStale TypeCheck docPath
MaybeT . fmap snd . initTcWithGbl hscenv thisMod ghostSpan $ do
tcthing <- tcLookup name
case tcthing of
AGlobal (AConLike (RealDataCon con))
| Just cls <- tyConClass_maybe (dataConOrigTyCon con) -> pure cls
_ -> panic "Ide.Plugin.Class.findClassFromIdentifier"
findClassFromIdentifier _ (Left _) = panic "Ide.Plugin.Class.findClassIdentifier"

findImplementedMethods :: HieASTs a -> Position -> MaybeT IO [T.Text]
findImplementedMethods asts instancePosition = do
pure
$ concat
$ pointCommand asts instancePosition
$ map (T.pack . getOccString) . rights . findInstanceValBindIdentifiers

-- | Recurses through the given AST to find identifiers which are
-- 'InstanceValBind's.
findInstanceValBindIdentifiers :: HieAST a -> [Identifier]
findInstanceValBindIdentifiers ast =
let valBindIds = Map.keys
. Map.filter (any isInstanceValBind . identInfo)
$ getNodeIds ast
in valBindIds <> concatMap findInstanceValBindIdentifiers (nodeChildren ast)

ghostSpan :: RealSrcSpan
ghostSpan = realSrcLocSpan $ mkRealSrcLoc (fsLit "<haskell-language-sever>") 1 1

containRange :: Range -> SrcSpan -> Bool
containRange range x = isInsideSrcSpan (range ^. J.start) x || isInsideSrcSpan (range ^. J.end) x

isClassNodeIdentifier :: IdentifierDetails a -> Bool
isClassNodeIdentifier ident = (isNothing . identType) ident && Use `Set.member` identInfo ident

isClassMethodWarning :: T.Text -> Bool
isClassMethodWarning = T.isPrefixOf "• No explicit implementation for"

isInstanceValBind :: ContextInfo -> Bool
isInstanceValBind (ValBind InstanceBind _ _) = True
isInstanceValBind _ = False

minDefToMethodGroups :: BooleanFormula Name -> [[T.Text]]
minDefToMethodGroups = go
where
go (Var mn) = [[T.pack . occNameString . occName $ mn]]
go (Or ms) = concatMap (go . unLoc) ms
go (And ms) = foldr (liftA2 (<>)) [[]] (fmap (go . unLoc) ms)
go (Parens m) = go (unLoc m)
Loading