packages feed

hls-eval-plugin-0.1.0.0: src/Ide/Plugin/Eval/CodeLens.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE ExtendedDefaultRules #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ImplicitParams #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoMonomorphismRestriction #-}
{-# OPTIONS_GHC -fno-warn-type-defaults #-}

{- |
A plugin inspired by the REPLoid feature of <https://github.com/jyp/dante Dante>, <https://www.haskell.org/haddock/doc/html/ch03s08.html#idm140354810775744 Haddock>'s Examples and Properties and <https://hackage.haskell.org/package/doctest Doctest>.

For a full example see the "Ide.Plugin.Eval.Tutorial" module.
-}
module Ide.Plugin.Eval.CodeLens (
    codeLens,
    evalCommand,
) where

import Control.Applicative (Alternative ((<|>)))
import Control.Arrow (second)
import qualified Control.Exception as E
import Control.Monad (
    void,
    when,
 )
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Trans.Except (
    ExceptT (..),
    runExceptT,
 )
import Data.Aeson (
    FromJSON,
    ToJSON,
    toJSON,
 )
import Data.Char (isSpace)
import Data.Either (isRight)
import qualified Data.HashMap.Strict as Map
import Data.List (
    dropWhileEnd,
    find,
 )
import Data.Maybe (
    catMaybes,
    fromMaybe,
 )
import Data.String (IsString)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
import Data.Time (getCurrentTime)
import Data.Typeable (Typeable)
import Development.IDE (
    GetModSummary (..),
    GhcSession (..),
    HscEnvEq (envImportPaths, hscEnv),
    IdeState,
    List (List),
    NormalizedFilePath,
    Range (Range),
    Uri,
    evalGhcEnv,
    hscEnvWithImportPaths,
    runAction,
    stringBufferToByteString,
    textToStringBuffer,
    toNormalizedFilePath',
    toNormalizedUri,
    uriToFilePath',
    use_,
 )
import Development.IDE.Core.Preprocessor (
    preprocessor,
 )
import Development.IDE.GHC.Compat (HscEnv)
import DynamicLoading (initializePlugins)
import GHC (
    ExecOptions (
        execLineNumber,
        execSourceFile
    ),
    ExecResult (..),
    GeneralFlag (..),
    Ghc,
    GhcLink (LinkInMemory),
    GhcMode (CompManager),
    GhcMonad (getSession),
    HscTarget (HscInterpreted),
    LoadHowMuch (LoadAllTargets),
    ModSummary (ms_hspp_opts),
    Module (moduleName),
    SuccessFlag (Failed, Succeeded),
    TcRnExprMode (..),
    execOptions,
    execStmt,
    exprType,
    getInteractiveDynFlags,
    getSessionDynFlags,
    isImport,
    isStmt,
    load,
    runDecls,
    setContext,
    setInteractiveDynFlags,
    setLogAction,
    setSessionDynFlags,
    setTargets,
    typeKind,
 )
import GHC.Generics (Generic)
import qualified GHC.LanguageExtensions.Type as LangExt
import GhcPlugins (
    DynFlags (..),
    defaultLogActionHPutStrDoc,
    gopt_set,
    gopt_unset,
    interpWays,
    targetPlatform,
    updateWays,
    wayGeneralFlags,
    wayUnsetGeneralFlags,
    xopt_set,
 )
import HscTypes (
    InteractiveImport (IIModule),
    ModSummary (ms_mod),
    Target (Target),
    TargetId (TargetFile),
 )
import Ide.Plugin.Eval.Code (
    Statement,
    asStatements,
    evalExpr,
    evalExtensions,
    evalSetup,
    propSetup,
    resultRange,
    testCheck,
    testRanges,
 )
import Ide.Plugin.Eval.GHC (
    addExtension,
    addImport,
    addPackages,
    hasPackage,
    isExpr,
    showDynFlags,
 )
import Ide.Plugin.Eval.Parse.Option (langOptions)
import Ide.Plugin.Eval.Parse.Section (
    Section (
        sectionFormat,
        sectionTests
    ),
    allSections,
 )
import Ide.Plugin.Eval.Parse.Token (tokensFrom)
import Ide.Plugin.Eval.Types (
    Format (SingleLine),
    Loc,
    Located (Located),
    Test,
    hasTests,
    isProperty,
    splitSections,
    unLoc,
 )
import Ide.Plugin.Eval.Util (
    asS,
    gStrictTry,
    handleMaybe,
    handleMaybeM,
    isLiterate,
    logWith,
    response,
    response',
    timed,
 )
import Ide.PluginUtils (mkLspCommand)
import Ide.Types (
    CodeLensProvider,
    CommandFunction,
    CommandId,
    PluginCommand (PluginCommand),
 )
import Language.Haskell.LSP.Core (
    LspFuncs (
        getVirtualFileFunc,
        withIndefiniteProgress
    ),
    ProgressCancellable (
        Cancellable
    ),
 )
import Language.Haskell.LSP.Types (
    ApplyWorkspaceEditParams (
        ApplyWorkspaceEditParams
    ),
    CodeLens (CodeLens),
    CodeLensParams (
        CodeLensParams,
        _textDocument
    ),
    Command (_arguments, _title),
    ServerMethod (
        WorkspaceApplyEdit
    ),
    TextDocumentIdentifier (..),
    TextEdit (TextEdit),
    WorkspaceEdit (WorkspaceEdit),
 )
import Language.Haskell.LSP.VFS (virtualFileText)
import Outputable (
    nest,
    ppr,
    showSDoc,
    text,
    ($$),
    (<+>),
 )
import System.FilePath (takeFileName)
import System.IO (hClose)
import System.IO.Temp (withSystemTempFile)
import Text.Read (readMaybe)
import Util (OverridingBool (Never))

{- | Code Lens provider
 NOTE: Invoked every time the document is modified, not just when the document is saved.
-}
codeLens :: CodeLensProvider IdeState
codeLens lsp st plId CodeLensParams{_textDocument} =
    let dbg = logWith st
        perf = timed dbg
     in perf "codeLens" $
            response $ do
                let TextDocumentIdentifier uri = _textDocument
                fp <- handleMaybe "uri" $ uriToFilePath' uri
                dbg "fp" fp
                mdlText <- moduleText lsp uri

                {- Normalise CPP/LHS files/custom preprocessed files.
                   Used to extract tests correctly from CPP and LHS (Bird-style).
                -}
                session :: HscEnvEq <-
                    runGetSession st $ toNormalizedFilePath' fp

                Right (ppContent, _dflags) <-
                    perf "preprocessor" $
                        liftIO $
                            runExceptT $
                                preprocessor (hscEnv session) fp (Just $ textToStringBuffer mdlText)
                let text =
                        cleanSource (isLiterate fp) . decodeUtf8 $
                            stringBufferToByteString
                                ppContent
                -- dbg "PREPROCESSED CONTENT" text

                -- Extract tests from source code
                let Right (setups, nonSetups) =
                        (splitSections . filter hasTests <$>)
                            . allSections
                            . tokensFrom
                            . T.unpack
                            $ text
                let tests = testsBySection nonSetups

                cmd <- liftIO $ mkLspCommand plId evalCommandName "Evaluate=..." (Just [])
                let lenses =
                        [ CodeLens testRange (Just cmd') Nothing
                        | (section, test) <- tests
                        , let (testRange, resultRange) = testRanges test
                              args = EvalParams (setups ++ [section]) _textDocument
                              cmd' =
                                (cmd :: Command)
                                    { _arguments = Just (List [toJSON args])
                                    , _title =
                                        if trivial resultRange
                                            then "Evaluate..."
                                            else "Refresh..."
                                    }
                        ]

                perf "tests" $
                    dbg "Tests" $
                        unwords
                            [ show (length tests)
                            , "tests in"
                            , show (length nonSetups)
                            , "sections"
                            , show (length setups)
                            , "setups"
                            , show (length lenses)
                            , "lenses."
                            ]

                return $ List lenses
  where
    trivial (Range p p') = p == p'

evalCommandName :: CommandId
evalCommandName = "evalCommand"

evalCommand :: PluginCommand IdeState
evalCommand = PluginCommand evalCommandName "evaluate" runEvalCmd

-- |Specify the test section to execute
data EvalParams = EvalParams
    { sections :: [Section]
    , module_ :: !TextDocumentIdentifier
    }
    deriving (Eq, Show, Generic, FromJSON, ToJSON)

runEvalCmd :: CommandFunction IdeState EvalParams
runEvalCmd lsp st EvalParams{..} =
    let dbg = logWith st
        perf = timed dbg
        cmd = do
            let tests = testsBySection sections

            let TextDocumentIdentifier{_uri} = module_
            fp <- handleMaybe "uri" $ uriToFilePath' _uri
            let nfp = toNormalizedFilePath' fp
            mdlText <- moduleText lsp _uri

            session <- runGetSession st nfp

            (ms, _) <-
                liftIO $
                    runAction "runEvalCmd.getModSummary" st $
                        use_ GetModSummary nfp

            now <- liftIO getCurrentTime

            let modName = moduleName $ ms_mod ms
                thisModuleTarget =
                    Target
                        (TargetFile fp Nothing)
                        False
                        (Just (textToStringBuffer mdlText, now))

            -- Setup environment for evaluation
            hscEnv' <- withSystemTempFile (takeFileName fp) $ \logFilename logHandle -> ExceptT . (either Left id <$>) . gStrictTry . evalGhcEnv (hscEnvWithImportPaths session) $ do
                env <- getSession

                -- Install the module pragmas and options
                df <- liftIO $ setupDynFlagsForGHCiLike env $ ms_hspp_opts ms

                let impPaths = fromMaybe (importPaths df) (envImportPaths session)
                -- Restore the cradle import paths
                df <- return df{importPaths = impPaths}

                -- Set the modified flags in the session
                _lp <- setSessionDynFlags df

                -- property tests need QuickCheck
                when (needsQuickCheck tests) $ void $ addPackages ["QuickCheck"]
                dbg "QUICKCHECK NEEDS" $ needsQuickCheck tests
                dbg "QUICKCHECK HAS" $ hasQuickCheck df

                -- copy the package state to the interactive DynFlags
                idflags <- getInteractiveDynFlags
                df <- getSessionDynFlags
                setInteractiveDynFlags $
                    (foldl xopt_set idflags evalExtensions)
                        { pkgState =
                            pkgState
                                df
                        , pkgDatabase =
                            pkgDatabase
                                df
                        , packageFlags =
                            packageFlags
                                df
                        , useColor = Never
                        , canUseColor = False
                        }

                -- set up a custom log action
                setLogAction $ \_df _wr _sev _span _style _doc ->
                    defaultLogActionHPutStrDoc _df logHandle _doc _style

                -- Load the module with its current content (as the saved module might not be up to date)
                -- BUG: this fails for files that requires preprocessors (e.g. CPP) for ghc < 8.8
                -- see https://gitlab.haskell.org/ghc/ghc/-/issues/17066
                -- and https://hackage.haskell.org/package/ghc-8.10.1/docs/GHC.html#v:TargetFile
                eSetTarget <- gStrictTry $ setTargets [thisModuleTarget]
                dbg "setTarget" eSetTarget

                -- load the module in the interactive environment
                loadResult <- perf "loadModule" $ load LoadAllTargets
                dbg "LOAD RESULT" $ asS loadResult
                case loadResult of
                    Failed -> liftIO $ do
                        hClose logHandle
                        err <- readFile logFilename
                        dbg "load ERR" err
                        return $ Left err
                    Succeeded -> do
                        -- Evaluation takes place 'inside' the module
                        setContext [IIModule modName]
                        Right <$> getSession

            edits <-
                perf "edits" $
                    liftIO $
                        evalGhcEnv hscEnv' $
                            runTests
                                (st, fp)
                                tests

            let workspaceEditsMap = Map.fromList [(_uri, List edits)]
            let workspaceEdits = WorkspaceEdit (Just workspaceEditsMap) Nothing

            return (WorkspaceApplyEdit, ApplyWorkspaceEditParams workspaceEdits)
     in perf "evalCmd" $
            withIndefiniteProgress lsp "Evaluating" Cancellable $
                response' cmd

moduleText :: (IsString e, MonadIO m) => LspFuncs c -> Uri -> ExceptT e m Text
moduleText lsp uri =
    handleMaybeM "mdlText" $
        liftIO $
            (virtualFileText <$>)
                <$> getVirtualFileFunc
                    lsp
                    (toNormalizedUri uri)

testsBySection :: [Section] -> [(Section, Loc Test)]
testsBySection sections =
    [(section, test) | section <- sections, test <- sectionTests section]

type TEnv = (IdeState, String)

runTests :: TEnv -> [(Section, Loc Test)] -> Ghc [TextEdit]
runTests e@(_st, _) tests = do
    df <- getInteractiveDynFlags
    evalSetup
    when (hasQuickCheck df && needsQuickCheck tests) $ void $ evals e df propSetup

    mapM (processTest e df) tests
  where
    processTest :: TEnv -> DynFlags -> (Section, Loc Test) -> Ghc TextEdit
    processTest e@(st, fp) df (section, test) = do
        let dbg = logWith st
        let pad = pad_ $ (if isLiterate fp then ("> " `T.append`) else id) $ padPrefix (sectionFormat section)

        rs <- runTest e df test
        dbg "TEST RESULTS" rs

        let checkedResult = testCheck (section, unLoc test) rs

        let edit = TextEdit (resultRange test) (T.unlines . map pad $ checkedResult)
        dbg "TEST EDIT" edit
        return edit

    -- runTest :: String -> DynFlags -> Loc Test -> Ghc [Text]
    runTest _ df test
        | not (hasQuickCheck df) && (isProperty . unLoc $ test) =
            return $
                singleLine
                    "Add QuickCheck to your cabal dependencies to run this test."
    runTest e df test = evals e df (asStatements test)

{-
The result of evaluating a test line can be:
* a value
* nothing
* a (possibly multiline) error message

A value is returned for a correct expression.

Either a pure value:
>>> 'h' :"askell"
"haskell"

Or an 'IO a' (output on stdout/stderr is ignored):
>>> print "OK" >> return "ABC"
"ABC"

Nothing is returned for a correct directive:

>>>:set -XFlexibleInstances
>>> import Data.Maybe

Nothing is returned for a correct declaration (let..,x=, data, class)

>>> let x = 11
>>> y = 22
>>> data B = T | F
>>> class C a

Nothing is returned for an empty line:

>>>

A, possibly multi line, error is returned for a wrong declaration, directive or value or an exception thrown by the evaluated code:

>>>:set -XNonExistent
Unknown extension: "NonExistent"

>>> cls C
Variable not in scope: cls :: t0 -> f0
Data constructor not in scope: C

>>> "A
lexical error in string/character literal at end of input

>>> 3 `div` 0
divide by zero

>>> error "Something went wrong\nbad times" :: E.SomeException
Something went wrong
bad times

Or for a value that does not have a Show instance and can therefore not be displayed:
>>> data V = V
>>> V
No instance for (Show V)
-}
evals :: TEnv -> DynFlags -> [Statement] -> Ghc [Text]
evals (st, fp) df stmts = do
    er <- gStrictTry $ mapM eval stmts
    return $ case er of
        Left err -> errorLines err
        Right rs -> concat . catMaybes $ rs
  where
    dbg = logWith st
    eval :: Statement -> Ghc (Maybe [Text])
    eval (Located l stmt)
        | -- A :set -XLanguageOption directive
          isRight (langOptions stmt) =
            either
                (return . Just . errorLines)
                ( \es -> do
                    dbg "{:SET" es
                    ndf <- getInteractiveDynFlags
                    dbg "pre set" $ showDynFlags ndf
                    mapM_ addExtension es
                    ndf <- getInteractiveDynFlags
                    dbg "post set" $ showDynFlags ndf
                    return Nothing
                )
                $ ghcOptions stmt
        | -- A type/kind command
          Just (cmd, arg) <- parseGhciLikeCmd $ T.pack stmt =
            evalGhciLikeCmd cmd arg
        | -- An expression
          isExpr df stmt =
            do
                dbg "{EXPR" stmt
                eres <- gStrictTry $ evalExpr stmt
                dbg "RES ->" eres
                let res = case eres of
                        Left err -> errorLines err
                        Right rs -> [T.pack rs]
                dbg "EXPR} ->" res
                return . Just $ res
        | -- A statement
          isStmt df stmt =
            do
                dbg "{STMT " stmt
                res <- exec stmt l
                r <- case res of
                    ExecComplete (Left err) _ -> return . Just . errorLines . show $ err
                    ExecComplete (Right _) _ -> return Nothing
                    ExecBreak{} ->
                        return . Just . singleLine $ "breakpoints are not supported"
                dbg "STMT} -> " r
                return r
        | -- An import
          isImport df stmt =
            do
                dbg "{IMPORT " stmt
                _ <- addImport stmt
                return Nothing
        | -- A declaration
          otherwise =
            do
                dbg "{DECL " stmt
                void $ runDecls stmt
                return Nothing
    exec stmt l =
        let opts = execOptions{execSourceFile = fp, execLineNumber = l}
         in execStmt stmt opts

runGetSession :: MonadIO m => IdeState -> NormalizedFilePath -> m HscEnvEq
runGetSession st nfp =
    liftIO $
        runAction "getSession" st $
            use_
                GhcSession
                -- GhcSessionDeps
                nfp

needsQuickCheck :: [(Section, Loc Test)] -> Bool
needsQuickCheck = any (isProperty . unLoc . snd)

hasQuickCheck :: DynFlags -> Bool
hasQuickCheck df = hasPackage df "QuickCheck"

singleLine :: String -> [Text]
singleLine s = [T.pack s]

{- |
 Convert error messages to a list of text lines
 Remove unnecessary information.
-}
errorLines :: String -> [Text]
errorLines =
    map (\e -> fromMaybe e (T.stripSuffix "arising from a use of ‘asPrint’" e))
        . dropWhileEnd T.null
        . takeWhile (not . ("CallStack" `T.isPrefixOf`))
        . T.lines
        . T.pack

{-
Check that extensions actually exists.

>>> ghcOptions ":set -XLambdaCase"
Right [LambdaCase]
>>> ghcOptions ":set -XLambdaCase -XNotRight"
Left "Unknown extension: \"NotRight\""
-}
ghcOptions :: [Char] -> Either String [LangExt.Extension]
ghcOptions = either Left (mapM chk) . langOptions
  where
    chk o =
        maybe
            (Left $ unwords ["Unknown extension:", show o])
            Right
            (readMaybe o :: Maybe LangExt.Extension)

{- |
>>> map (pad_ (T.pack "--")) (map T.pack ["2+2",""])
["--2+2","--<BLANKLINE>"]
-}
pad_ :: Text -> Text -> Text
pad_ prefix = (prefix `T.append`) . convertBlank

convertBlank :: Text -> Text
convertBlank x
    | T.null x = "<BLANKLINE>"
    | otherwise = x

padPrefix :: IsString p => Format -> p
padPrefix SingleLine = "-- "
padPrefix _ = ""

{-
Normalise preprocessed source code (from a CPP/LHS or other processed file) so that tests are on the same lines as in the original source.

>>> cleanSource True $ T.pack "#line 1 \nA comment\n> module X where"
"comment\nmodule X where\n"

>>> cleanSource False $ T.pack "#1  \nmodule X where"
"module X where\n"
-}
cleanSource :: Bool -> Text -> Text
cleanSource isLit =
    T.unlines
        . reverse
        . (if isLit then map cleanBirdCode else id)
        . takeWhile (\t -> T.null t || (T.head t /= '#'))
        . reverse
        . T.lines

cleanBirdCode :: Text -> Text
cleanBirdCode = T.drop 2

{- | Resulting @Text@ MUST NOT prefix each line with @--@
   Such comment-related post-process will be taken place
   solely in 'evalGhciLikeCmd'.
-}
type GHCiLikeCmd = DynFlags -> Text -> Ghc (Maybe Text)

-- Should we use some sort of trie here?
ghciLikeCommands :: [(Text, GHCiLikeCmd)]
ghciLikeCommands =
    [("kind", doKindCmd False), ("kind!", doKindCmd True), ("type", doTypeCmd)]

evalGhciLikeCmd :: Text -> Text -> Ghc (Maybe [Text])
evalGhciLikeCmd cmd arg = do
    df <- getSessionDynFlags
    case lookup cmd ghciLikeCommands
        <|> snd
        <$> find (T.isPrefixOf cmd . fst) ghciLikeCommands of
        Just hndler ->
            fmap
                T.lines
                <$> hndler df arg
        _ -> E.throw $ GhciLikeCmdNotImplemented cmd arg

doKindCmd :: Bool -> DynFlags -> Text -> Ghc (Maybe Text)
doKindCmd False df arg = do
    let input = T.strip arg
    (_, kind) <- typeKind False $ T.unpack input
    let kindText = text (T.unpack input) <+> "::" <+> ppr kind
    pure $ Just $ T.pack (showSDoc df kindText)
doKindCmd True df arg = do
    let input = T.strip arg
    (ty, kind) <- typeKind True $ T.unpack input
    let kindDoc = text (T.unpack input) <+> "::" <+> ppr kind
        tyDoc = "=" <+> ppr ty
    pure $ Just $ T.pack (showSDoc df $ kindDoc $$ tyDoc)

doTypeCmd :: DynFlags -> Text -> Ghc (Maybe Text)
doTypeCmd dflags arg = do
    let (emod, expr) = parseExprMode arg
    ty <- exprType emod $ T.unpack expr
    let rawType = T.strip $ T.pack $ showSDoc dflags $ ppr ty
        broken = T.any (\c -> c == '\r' || c == '\n') rawType
    pure $
        Just $
            if broken
                then
                    T.pack $
                        showSDoc dflags $
                            text (T.unpack expr)
                                $$ nest 2 ("::" <+> ppr ty)
                else expr <> " :: " <> rawType <> "\n"

parseExprMode :: Text -> (TcRnExprMode, T.Text)
parseExprMode rawArg = case T.break isSpace rawArg of
    ("+v", rest) -> (TM_NoInst, T.strip rest)
    ("+d", rest) -> (TM_Default, T.strip rest)
    _ -> (TM_Inst, rawArg)

data GhciLikeCmdException = GhciLikeCmdNotImplemented
    { ghciCmdName :: Text
    , ghciCmdArg :: Text
    }
    deriving (Typeable)

instance Show GhciLikeCmdException where
    showsPrec _ GhciLikeCmdNotImplemented{..} =
        showString "unknown command '"
            . showString (T.unpack ghciCmdName)
            . showChar '\''

instance E.Exception GhciLikeCmdException

{-
>>> parseGhciLikeCmd (T.pack ":kind! N + M + 1")
Just ("kind!","N + M + 1")
>>> parseGhciLikeCmd (T.pack ":kind a")
Just ("kind","a")
-}
parseGhciLikeCmd :: Text -> Maybe (Text, Text)
parseGhciLikeCmd input = do
    (':', rest) <- T.uncons $ T.stripStart input
    pure $ second T.strip $ T.break isSpace rest

setupDynFlagsForGHCiLike :: HscEnv -> DynFlags -> IO DynFlags
setupDynFlagsForGHCiLike env dflags = do
    let dflags3 =
            dflags
                { hscTarget = HscInterpreted
                , ghcMode = CompManager
                , ghcLink = LinkInMemory
                }
        platform = targetPlatform dflags3
        dflags3a = updateWays $ dflags3{ways = interpWays}
        dflags3b =
            foldl gopt_set dflags3a $
                concatMap (wayGeneralFlags platform) interpWays
        dflags3c =
            foldl gopt_unset dflags3b $
                concatMap (wayUnsetGeneralFlags platform) interpWays
        dflags4 =
            dflags3c
                `gopt_set` Opt_ImplicitImportQualified
                `gopt_set` Opt_IgnoreOptimChanges
                `gopt_set` Opt_IgnoreHpcChanges
                `gopt_unset` Opt_DiagnosticsShowCaret
    initializePlugins env dflags4