{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE RecordWildCards #-}
module Utils where
import Control.DeepSeq (deepseq)
import qualified Control.Exception as E
import Control.Lens hiding (List, failing, (<.>), (.=))
import Control.Monad (unless, void)
import Control.Monad.IO.Class
import Data.Aeson
import Data.Foldable
import Data.Function (on)
import Data.IORef (writeIORef)
import Data.Maybe
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Ide.Plugin.Tactic as Tactic
import Language.LSP.Types
import Language.LSP.Types.Lens hiding (actions, applyEdit, capabilities, executeCommand, id, line, message, name, rename, title)
import qualified Language.LSP.Types.Lens as J
import System.Directory (doesFileExist)
import System.FilePath
import Test.Hls
import Test.Hspec
import Test.Hspec.Formatters (FailureReason(ExpectedButGot))
import Wingman.LanguageServer (mkShowMessageParams)
import Wingman.Types
plugin :: PluginDescriptor IdeState
plugin = Tactic.descriptor mempty "tactics"
------------------------------------------------------------------------------
-- | Get a range at the given line and column corresponding to having nothing
-- selected.
--
-- NB: These coordinates are in "file space", ie, 1-indexed.
pointRange :: Int -> Int -> Range
pointRange
(subtract 1 -> fromIntegral -> line)
(subtract 1 -> fromIntegral -> col) =
Range (Position line col) (Position line $ col + 1)
------------------------------------------------------------------------------
-- | Get the title of a code action.
codeActionTitle :: (Command |? CodeAction) -> Maybe Text
codeActionTitle InL{} = Nothing
codeActionTitle (InR(CodeAction title _ _ _ _ _ _ _)) = Just title
resetGlobalHoleRef :: IO ()
resetGlobalHoleRef = writeIORef globalHoleRef 0
runSessionForTactics :: Session a -> IO a
runSessionForTactics =
runSessionWithServer'
[plugin]
def
(def { messageTimeout = 20 } )
fullCaps
tacticPath
------------------------------------------------------------------------------
-- | Make a tactic unit test.
mkTest
:: Foldable t
=> String -- ^ The test name
-> FilePath -- ^ The file name stem (without extension) to load
-> Int -- ^ Cursor line
-> Int -- ^ Cursor columnn
-> t ( Bool -> Bool -- Use 'not' for actions that shouldnt be present
, TacticCommand -- An expected command ...
, Text -- ... for this variable
) -- ^ A collection of (un)expected code actions.
-> SpecWith (Arg Bool)
mkTest name fp line col ts = it name $ do
resetGlobalHoleRef
runSessionForTactics $ do
doc <- openDoc (fp <.> "hs") "haskell"
-- wait for diagnostics to start coming
void waitForDiagnostics
-- wait for the entire build to finish, so that Tactics code actions that
-- use stale data will get uptodate stuff
void $ waitForTypecheck doc
actions <- getCodeActions doc $ pointRange line col
let titles = mapMaybe codeActionTitle actions
for_ ts $ \(f, tc, var) -> do
let title = tacticTitle tc var
liftIO $
(title `elem` titles) `shouldSatisfy` f
data InvokeTactic = InvokeTactic
{ it_command :: TacticCommand
, it_argument :: Text
, it_line :: Int
, it_col :: Int
}
invokeTactic :: TextDocumentIdentifier -> InvokeTactic -> Session ()
invokeTactic doc InvokeTactic{..} = do
-- wait for the entire build to finish, so that Tactics code actions that
-- use stale data will get uptodate stuff
void waitForDiagnostics
void $ waitForTypecheck doc
actions <- getCodeActions doc $ pointRange it_line it_col
case find ((== Just (tacticTitle it_command it_argument)) . codeActionTitle) actions of
Just (InR CodeAction {_command = Just c}) -> do
executeCommand c
void $ skipManyTill anyMessage $ message SWorkspaceApplyEdit
_ -> error $ show actions
mkGoldenTest
:: (Text -> Text -> Assertion)
-> [InvokeTactic]
-> FilePath
-> SpecWith ()
mkGoldenTest eq invocations input =
it (input <> " (golden)") $ do
resetGlobalHoleRef
runSessionForTactics $ do
doc <- openDoc (input <.> "hs") "haskell"
traverse_ (invokeTactic doc) invocations
edited <- documentContents doc
let expected_name = input <.> "expected" <.> "hs"
-- Write golden tests if they don't already exist
liftIO $ (doesFileExist expected_name >>=) $ flip unless $ do
T.writeFile expected_name edited
expected <- liftIO $ T.readFile expected_name
liftIO $ edited `eq` expected
mkCodeLensTest
:: FilePath
-> SpecWith ()
mkCodeLensTest input =
it (input <> " (golden)") $ do
resetGlobalHoleRef
runSessionForTactics $ do
doc <- openDoc (input <.> "hs") "haskell"
_ <- waitForDiagnostics
lenses <- fmap (reverse . filter isWingmanLens) $ getCodeLenses doc
for_ lenses $ \(CodeLens _ (Just cmd) _) ->
executeCommand cmd
_resp <- skipManyTill anyMessage (message SWorkspaceApplyEdit)
edited <- documentContents doc
let expected_name = input <.> "expected" <.> "hs"
-- Write golden tests if they don't already exist
liftIO $ (doesFileExist expected_name >>=) $ flip unless $ do
T.writeFile expected_name edited
expected <- liftIO $ T.readFile expected_name
liftIO $ edited `shouldBe` expected
------------------------------------------------------------------------------
-- | A test that no code lenses can be run in the file
mkNoCodeLensTest
:: FilePath
-> SpecWith ()
mkNoCodeLensTest input =
it (input <> " (no code lenses)") $ do
resetGlobalHoleRef
runSessionForTactics $ do
doc <- openDoc (input <.> "hs") "haskell"
_ <- waitForBuildQueue
lenses <- fmap (reverse . filter isWingmanLens) $ getCodeLenses doc
liftIO $ lenses `shouldBe` []
isWingmanLens :: CodeLens -> Bool
isWingmanLens (CodeLens _ (Just (Command _ cmd _)) _)
= T.isInfixOf ":tactics:" cmd
isWingmanLens _ = False
mkShowMessageTest
:: TacticCommand
-> Text
-> Int
-> Int
-> FilePath
-> UserFacingMessage
-> SpecWith ()
mkShowMessageTest tc occ line col input ufm =
it (input <> " (golden)") $ do
resetGlobalHoleRef
runSessionForTactics $ do
doc <- openDoc (input <.> "hs") "haskell"
_ <- waitForDiagnostics
actions <- getCodeActions doc $ pointRange line col
Just (InR CodeAction {_command = Just c})
<- pure $ find ((== Just (tacticTitle tc occ)) . codeActionTitle) actions
executeCommand c
NotificationMessage _ _ err <- skipManyTill anyMessage (message SWindowShowMessage)
liftIO $ err `shouldBe` mkShowMessageParams ufm
goldenTest :: TacticCommand -> Text -> Int -> Int -> FilePath -> SpecWith ()
goldenTest tc occ line col = mkGoldenTest shouldBe [InvokeTactic tc occ line col]
goldenTestMany :: FilePath -> [InvokeTactic] -> SpecWith ()
goldenTestMany = flip $ mkGoldenTest shouldBe
goldenTestNoWhitespace :: TacticCommand -> Text -> Int -> Int -> FilePath -> SpecWith ()
goldenTestNoWhitespace tc occ line col = mkGoldenTest shouldBeIgnoringSpaces [InvokeTactic tc occ line col]
shouldBeIgnoringSpaces :: Text -> Text -> Assertion
shouldBeIgnoringSpaces = assertFun f ""
where
f = (==) `on` T.unwords . T.words
assertFun
:: Show a
=> (a -> a -> Bool)
-> String -- ^ The message prefix
-> a -- ^ The expected value
-> a -- ^ The actual value
-> Assertion
assertFun eq preface expected actual =
unless (eq actual expected) $ do
(prefaceMsg
`deepseq` expectedMsg
`deepseq` actualMsg
`deepseq`
E.throwIO
(HUnitFailure Nothing $ show $ ExpectedButGot prefaceMsg expectedMsg actualMsg))
where
prefaceMsg
| null preface = Nothing
| otherwise = Just preface
expectedMsg = show expected
actualMsg = show actual
------------------------------------------------------------------------------
-- | Don't run a test.
failing :: Applicative m => String -> b -> m ()
failing _ _ = pure ()
tacticPath :: FilePath
tacticPath = "test/golden"
executeCommandWithResp :: Command -> Session (ResponseMessage 'WorkspaceExecuteCommand)
executeCommandWithResp cmd = do
let args = decode $ encode $ fromJust $ cmd ^. arguments
execParams = ExecuteCommandParams Nothing (cmd ^. command) args
request SWorkspaceExecuteCommand execParams