From 22bb6f2af318e892f02715f97cddbe644b732ad4 Mon Sep 17 00:00:00 2001 From: Simon Hengel Date: Fri, 4 Oct 2024 08:16:27 +0700 Subject: [PATCH] Add support for GHC JSON diagnostics --- package.yaml | 1 + sensei.cabal | 11 ++- src/GHC/Diagnostic.hs | 92 +++++++++++++++++++ src/HTTP.hs | 26 ++++-- src/Imports.hs | 16 +++- src/Language/Haskell/GhciWrapper.hs | 48 +++++++--- src/ReadHandle.hs | 7 ++ src/Run.hs | 13 +-- src/Session.hs | 3 +- src/Trigger.hs | 20 ++-- test/ClientSpec.hs | 2 +- test/GHC/DiagnosticSpec.hs | 44 +++++++++ test/HTTPSpec.hs | 63 +++++++++---- test/Helper.hs | 28 ++++++ test/Language/Haskell/GhciWrapperSpec.hs | 33 +++++-- test/ReadHandleSpec.hs | 79 ++++++++++++---- test/SessionSpec.hs | 5 - test/TriggerSpec.hs | 8 +- test/assets/use-BlockArguments/Foo.hs | 5 + .../variable-not-in-scope-perhaps-use/Foo.hs | 2 + test/assets/variable-not-in-scope/Foo.hs | 2 + test/assets/variable-not-in-scope/Spec.hs | 2 + 22 files changed, 423 insertions(+), 87 deletions(-) create mode 100644 src/GHC/Diagnostic.hs create mode 100644 test/GHC/DiagnosticSpec.hs create mode 100644 test/assets/use-BlockArguments/Foo.hs create mode 100644 test/assets/variable-not-in-scope-perhaps-use/Foo.hs create mode 100644 test/assets/variable-not-in-scope/Foo.hs create mode 100644 test/assets/variable-not-in-scope/Spec.hs diff --git a/package.yaml b/package.yaml index 5f4548d..3a31ba5 100644 --- a/package.yaml +++ b/package.yaml @@ -22,6 +22,7 @@ other-extensions: dependencies: - base >= 4.11 && < 5 + - pretty - process - fsnotify == 0.4.* - time diff --git a/sensei.cabal b/sensei.cabal index 061e932..c3b1183 100644 --- a/sensei.cabal +++ b/sensei.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.36.0. +-- This file has been generated from package.yaml by hpack version 0.37.0. -- -- see: https://github.com/sol/hpack @@ -25,6 +25,7 @@ executable seito Client Config EventQueue + GHC.Diagnostic HTTP Imports Input @@ -65,6 +66,7 @@ executable seito , http-types , mtl , network + , pretty , process , stm , temporary @@ -83,6 +85,7 @@ executable sensei Client Config EventQueue + GHC.Diagnostic HTTP Imports Input @@ -123,6 +126,7 @@ executable sensei , http-types , mtl , network + , pretty , process , stm , temporary @@ -141,6 +145,7 @@ executable sensei-web Client Config EventQueue + GHC.Diagnostic HTTP Imports Input @@ -181,6 +186,7 @@ executable sensei-web , http-types , mtl , network + , pretty , process , stm , temporary @@ -200,6 +206,7 @@ test-suite spec Client Config EventQueue + GHC.Diagnostic HTTP Imports Input @@ -214,6 +221,7 @@ test-suite spec ClientSpec ConfigSpec EventQueueSpec + GHC.DiagnosticSpec Helper HTTPSpec Language.Haskell.GhciWrapperSpec @@ -262,6 +270,7 @@ test-suite spec , mockery , mtl , network + , pretty , process , stm , temporary diff --git a/src/GHC/Diagnostic.hs b/src/GHC/Diagnostic.hs new file mode 100644 index 0000000..9215fca --- /dev/null +++ b/src/GHC/Diagnostic.hs @@ -0,0 +1,92 @@ +{-# LANGUAGE DeriveAnyClass #-} +module GHC.Diagnostic ( + Diagnostic(..) +, Span(..) +, Location(..) +, Severity(..) +, parse +, format +) where + +import Prelude hiding ((<>), span) +import Imports hiding (empty) +import GHC.Generics (Generic) +import Data.Aeson (ToJSON(..), FromJSON(..), decode) +import Data.ByteString.Lazy (fromStrict) +import Text.PrettyPrint + +data Diagnostic = Diagnostic { + version :: String +, ghcVersion :: String +, span :: Maybe Span +, severity :: Severity +, code :: Maybe Int +, message :: [String] +, hints :: [String] +} deriving (Eq, Show, Generic, ToJSON, FromJSON) + +data Span = Span { + file :: FilePath +, start :: Location +, end :: Location +} deriving (Eq, Show, Generic, ToJSON, FromJSON) + +data Location = Location { + line :: Int +, column :: Int +} deriving (Eq, Show, Generic, ToJSON, FromJSON) + +data Severity = Warning | Error + deriving (Eq, Show, Generic, ToJSON, FromJSON) + +parse :: ByteString -> Maybe Diagnostic +parse = decode . fromStrict + +format :: Diagnostic -> ByteString +format diagnostic = encodeUtf8 . render $ unlines_ [ + hang header 4 messageWithHints + , "" + , "" + ] + where + header :: Doc + header = span <> colon <+> severity <> colon <+> code + + severity :: Doc + severity = case diagnostic.severity of + Warning -> "warning" + Error -> "error" + + code :: Doc + code = case diagnostic.code of + Nothing -> empty + Just c -> brackets $ "GHC-" <> int c + + span :: Doc + span = case diagnostic.span of + Nothing -> "" + Just l -> text l.file <> colon <> int l.start.line <> colon <> int l.start.column + + message :: Doc + message = bulleted $ map verbatim diagnostic.message + + hints :: [Doc] + hints = map verbatim diagnostic.hints + + messageWithHints :: Doc + messageWithHints = case hints of + [] -> message + [h] -> message $$ hang (text "Suggested fix:") 2 h + hs -> message $$ hang (text "Suggested fixes:") 2 (bulleted hs) + +bulleted :: [Doc] -> Doc +bulleted = \ case + [] -> empty + [m] -> m + ms -> vcat $ map (char '•' <+>) ms + +verbatim :: String -> Doc +verbatim = unlines_ . map text . lines + +unlines_ :: [Doc] -> Doc +unlines_ = foldr ($+$) empty diff --git a/src/HTTP.hs b/src/HTTP.hs index 333e083..e52cf5e 100644 --- a/src/HTTP.hs +++ b/src/HTTP.hs @@ -11,9 +11,10 @@ module HTTP ( #endif ) where -import Imports hiding (encodeUtf8) +import Imports hiding (strip, encodeUtf8) import System.Directory +import Data.Aeson (ToJSON(..), encode) import qualified Data.ByteString.Lazy as L import Data.Text.Lazy.Encoding (encodeUtf8) import Network.Wai @@ -22,6 +23,7 @@ import Network.Wai.Handler.Warp (runSettingsSocket, defaultSettings) import Network.Socket import qualified Trigger +import GHC.Diagnostic socketName :: FilePath -> String socketName dir = dir ".sensei.sock" @@ -35,8 +37,8 @@ newSocket = socket AF_UNIX Stream 0 withSocket :: (Socket -> IO a) -> IO a withSocket = bracket newSocket close -withServer :: FilePath -> IO (Trigger.Result, String) -> IO a -> IO a -withServer dir trigger = withApplication dir (app trigger) +withServer :: FilePath -> IO (Trigger.Result, String, [Diagnostic]) -> IO a -> IO a +withServer dir = withApplication dir . app withApplication :: FilePath -> Application -> IO a -> IO a withApplication dir application action = do @@ -59,8 +61,12 @@ withThread asyncAction action = do takeMVar mvar return r -app :: IO (Trigger.Result, String) -> Application -app trigger request respond = trigger >>= textPlain +app :: IO (Trigger.Result, String, [Diagnostic]) -> Application +app getLastResult request respond = case pathInfo request of + ["diagnostics"] -> do + (_, _, diagnostics) <- getLastResult + respond $ json diagnostics + _ -> getLastResult >>= textPlain where color :: Either ByteString Bool color = case join $ lookup "color" $ queryString request of @@ -69,8 +75,8 @@ app trigger request respond = trigger >>= textPlain Just "true" -> Right True Just value -> Left $ "invalid value for color: " <> urlEncode True value - textPlain :: (Trigger.Result, FilePath) -> IO ResponseReceived - textPlain (result, xs) = case color of + textPlain :: (Trigger.Result, FilePath, [Diagnostic]) -> IO ResponseReceived + textPlain (result, xs, _diagnostics) = case color of Left err -> respond $ responseLBS status400 [(hContentType, "text/plain")] (L.fromStrict err) Right c -> respond $ responseLBS status [(hContentType, "text/plain")] (encodeUtf8 . fromString $ strip xs) where @@ -84,6 +90,12 @@ app trigger request respond = trigger >>= textPlain Trigger.Failure -> status500 Trigger.Success -> status200 +json :: ToJSON a => a -> Response +json value = responseLBS + status200 + [("Content-Type", "application/json")] + (encode value) + -- | -- Remove terminal sequences. stripAnsi :: String -> String diff --git a/src/Imports.hs b/src/Imports.hs index afbeb70..2305da2 100644 --- a/src/Imports.hs +++ b/src/Imports.hs @@ -11,7 +11,7 @@ import Data.Functor as Imports ((<&>)) import Data.Bifunctor as Imports import Data.Char as Imports import Data.Either as Imports -import Data.List as Imports +import Data.List as Imports hiding (span) import Data.Maybe as Imports import Data.String as Imports import Data.ByteString.Char8 as Imports (ByteString, pack, unpack) @@ -26,6 +26,10 @@ import Control.Monad.IO.Class as Imports import System.IO (Handle) import GHC.IO.Handle.Internals (wantReadableHandle_) +import Data.Version as Imports (Version(..), showVersion, makeVersion) +import qualified Data.Version as Version +import Text.ParserCombinators.ReadP + import qualified Data.Text as T import qualified Data.Text.Encoding as T @@ -54,4 +58,12 @@ encodeUtf8 :: String -> ByteString encodeUtf8 = T.encodeUtf8 . T.pack decodeUtf8 :: ByteString -> String -decodeUtf8 = T.unpack . T.decodeUtf8 +decodeUtf8 = T.unpack . T.decodeUtf8Lenient + +strip :: String -> String +strip = reverse . dropWhile isSpace . reverse . dropWhile isSpace + +parseVersion :: String -> Maybe Version +parseVersion xs = case [v | (v, "") <- readP_to_S Version.parseVersion xs] of + [v] -> Just v + _ -> Nothing diff --git a/src/Language/Haskell/GhciWrapper.hs b/src/Language/Haskell/GhciWrapper.hs index 64e92f2..f0f0373 100644 --- a/src/Language/Haskell/GhciWrapper.hs +++ b/src/Language/Haskell/GhciWrapper.hs @@ -14,7 +14,8 @@ module Language.Haskell.GhciWrapper ( , reload #ifdef TEST -, extractReloadStatus +, extractReloadDiagnostics +, extractDiagnostics , extractNothing #endif ) where @@ -29,8 +30,10 @@ import System.Process hiding (createPipe) import System.Exit (exitFailure) import Util (isWritableByOthers) +import ReadHandle hiding (getResult) import qualified ReadHandle -import ReadHandle (ReadHandle, toReadHandle, Extract(..), partialMessageStartsWithOneOf) +import GHC.Diagnostic (Diagnostic) +import qualified GHC.Diagnostic as Diagnostic data Config = Config { configIgnoreDotGhci :: Bool @@ -81,17 +84,25 @@ new startupFile Config{..} envDefaults args_ = do env <- sanitizeEnv <$> getEnvironment let + ghc :: String + ghc = fromMaybe "ghc" $ lookup "SENSEI_GHC" env + + ghcVersion <- parseVersion . strip <$> readProcess ghc ["--numeric-version"] "" + + let + diagnosticsAsJson :: [String] -> [String] + diagnosticsAsJson + | ghcVersion < Just (makeVersion [9,10]) = id + | otherwise = ("-fdiagnostics-as-json" :) + mandatoryArgs :: [String] mandatoryArgs = ["-fshow-loaded-modules", "--interactive"] args :: [String] - args = "-ghci-script" : startupFile : args_ ++ catMaybes [ + args = "-ghci-script" : startupFile : diagnosticsAsJson args_ ++ catMaybes [ if configIgnoreDotGhci then Just "-ignore-dot-ghci" else Nothing ] ++ mandatoryArgs - ghc :: String - ghc = fromMaybe "ghc" $ lookup "SENSEI_GHC" env - (stdoutReadEnd, stdoutWriteEnd) <- createPipe (Just stdin_, Nothing, Nothing, processHandle ) <- createProcess (proc ghc args) { @@ -120,6 +131,7 @@ new startupFile Config{..} envDefaults args_ = do Just _ -> exitFailure Nothing -> return interpreter where + checkDotGhci :: IO () checkDotGhci = unless configIgnoreDotGhci $ do let dotGhci = fromMaybe "" configWorkingDirectory ".ghci" isWritableByOthers dotGhci >>= \ case @@ -131,18 +143,19 @@ new startupFile Config{..} envDefaults args_ = do , "" ] + setMode :: Handle -> IO () setMode h = do hSetBinaryMode h False hSetBuffering h LineBuffering hSetEncoding h utf8 - printStartupMessages :: Interpreter -> IO (String, [ReloadStatus]) - printStartupMessages interpreter = evalVerbose extractReloadStatus interpreter "" + printStartupMessages :: Interpreter -> IO (String, [Either ReloadStatus Diagnostic]) + printStartupMessages interpreter = evalVerbose extractReloadDiagnostics interpreter "" close :: Interpreter -> IO () close Interpreter{..} = do hClose hIn - ReadHandle.drain extractNothing readHandle echo + ReadHandle.drain extractReloadDiagnostics readHandle echo hClose hOut e <- waitForProcess process when (e /= ExitSuccess) $ do @@ -154,6 +167,9 @@ putExpression Interpreter{hIn = stdin} e = do ByteString.hPut stdin ReadHandle.marker hFlush stdin +extractReloadDiagnostics :: Extract (Either ReloadStatus Diagnostic) +extractReloadDiagnostics = extractReloadStatus <+> extractDiagnostics + data ReloadStatus = Ok | Failed deriving (Eq, Show) @@ -168,6 +184,12 @@ extractReloadStatus = Extract { ok = "Ok, modules loaded: " failed = "Failed, modules loaded: " +extractDiagnostics :: ReadHandle.Extract Diagnostic +extractDiagnostics = ReadHandle.Extract { + isPartialMessage = ByteString.isPrefixOf "{" +, parseMessage = fmap (id &&& Diagnostic.format) . Diagnostic.parse +} + extractNothing :: Extract () extractNothing = Extract { isPartialMessage = const False @@ -186,7 +208,7 @@ eval ghci = fmap fst . evalVerbose extractNothing ghci {echo = silent} evalVerbose :: Extract a -> Interpreter -> String -> IO (String, [a]) evalVerbose extract ghci expr = putExpression ghci expr >> getResult extract ghci -reload :: Interpreter -> IO (String, ReloadStatus) -reload ghci = evalVerbose extractReloadStatus ghci ":reload" <&> second \ case - [Ok] -> Ok - _ -> Failed +reload :: Interpreter -> IO (String, (ReloadStatus, [Diagnostic])) +reload ghci = evalVerbose extractReloadDiagnostics ghci ":reload" <&> second \ case + (partitionEithers -> ([Ok], diagnostics)) -> (Ok, diagnostics) + (partitionEithers ->(_, diagnostics)) -> (Failed, diagnostics) diff --git a/src/ReadHandle.hs b/src/ReadHandle.hs index d32b97c..6b2f230 100644 --- a/src/ReadHandle.hs +++ b/src/ReadHandle.hs @@ -4,6 +4,7 @@ module ReadHandle ( , toReadHandle , marker , Extract(..) +, (<+>) , partialMessageStartsWith , partialMessageStartsWithOneOf , getResult @@ -82,6 +83,12 @@ data Extract a = Extract { , parseMessage :: ByteString -> Maybe (a, ByteString) } +(<+>) :: Extract a -> Extract b -> Extract (Either a b) +(<+>) a b = Extract { + isPartialMessage = \ input -> a.isPartialMessage input || b.isPartialMessage input +, parseMessage = \ input -> first Left <$> a.parseMessage input <|> first Right <$> b.parseMessage input +} + partialMessageStartsWith :: ByteString -> ByteString -> Bool partialMessageStartsWith prefix chunk = ByteString.isPrefixOf chunk prefix || ByteString.isPrefixOf prefix chunk diff --git a/src/Run.hs b/src/Run.hs index c15070f..a137892 100644 --- a/src/Run.hs +++ b/src/Run.hs @@ -26,6 +26,7 @@ import qualified Input import Pager (pager) import Util import Config +import GHC.Diagnostic waitForever :: IO () waitForever = forever $ threadDelay 10000000 @@ -85,7 +86,7 @@ run args = do defaultRunArgs :: IO RunArgs defaultRunArgs = do queue <- newQueue - lastOutput <- newMVar (Trigger.Success, "") + lastOutput <- newMVar (Trigger.Success, "", []) return RunArgs { ignoreConfig = False , dir = "" @@ -100,7 +101,7 @@ data RunArgs = RunArgs { ignoreConfig :: Bool , dir :: FilePath , args :: [String] -, lastOutput :: MVar (Result, String) +, lastOutput :: MVar (Result, String, [Diagnostic]) , queue :: EventQueue , sessionConfig :: Session.Config , withSession :: forall r. Session.Config -> [String] -> (Session.Session -> IO r) -> IO r @@ -119,16 +120,16 @@ runWith RunArgs {..} = do addCleanupAction :: IO () -> IO () addCleanupAction cleanupAction = atomicModifyIORef' cleanup $ \ action -> (action >> cleanupAction, ()) - saveOutput :: IO (Trigger.Result, String) -> IO () + saveOutput :: IO (Trigger.Result, String, [Diagnostic]) -> IO () saveOutput action = do runCleanupAction result <- modifyMVar lastOutput $ \ _ -> (id &&& id) <$> action case result of - (HookFailed, _output) -> pass - (Failure, output) -> config.senseiHooksOnFailure >>= \ case + (HookFailed, _output, _diagnostics) -> pass + (Failure, output, _diagnostics) -> config.senseiHooksOnFailure >>= \ case HookSuccess -> pager output >>= addCleanupAction HookFailure message -> hPutStrLn stderr message - (Success, _output) -> config.senseiHooksOnSuccess >>= \ case + (Success, _output, _diagnostics) -> config.senseiHooksOnSuccess >>= \ case HookSuccess -> pass HookFailure message -> hPutStrLn stderr message diff --git a/src/Session.hs b/src/Session.hs index fb4d1ec..9ed970c 100644 --- a/src/Session.hs +++ b/src/Session.hs @@ -36,6 +36,7 @@ import qualified Language.Haskell.GhciWrapper as Interpreter import Util import Options +import GHC.Diagnostic data Session = Session { interpreter :: Interpreter @@ -63,7 +64,7 @@ withSession config args action = do where (ghciArgs, hspecArgs) = splitArgs args -reload :: MonadIO m => Session -> m (String, ReloadStatus) +reload :: MonadIO m => Session -> m (String, (ReloadStatus, [Diagnostic])) reload session = liftIO $ Interpreter.reload session.interpreter data Summary = Summary { diff --git a/src/Trigger.hs b/src/Trigger.hs index 132d602..b75a42a 100644 --- a/src/Trigger.hs +++ b/src/Trigger.hs @@ -27,6 +27,7 @@ import Util import Config (Hook, HookResult(..)) import Session (Session, ReloadStatus(..), isFailure, isSuccess, hspecPreviousSummary, resetSummary) import qualified Session +import GHC.Diagnostic data Hooks = Hooks { beforeReload :: Hook @@ -42,7 +43,7 @@ defaultHooks = Hooks { data Result = HookFailed | Failure | Success deriving (Eq, Show) -triggerAll :: Session -> Hooks -> IO (Result, String) +triggerAll :: Session -> Hooks -> IO (Result, String, [Diagnostic]) triggerAll session hooks = do resetSummary session trigger session hooks @@ -55,18 +56,18 @@ removeProgress xs = case break (== '\r') xs of dropLastLine :: String -> String dropLastLine = reverse . dropWhile (/= '\n') . reverse -type Trigger = ExceptT Result (WriterT String IO) +type Trigger = ExceptT Result (WriterT (String, [Diagnostic]) IO) -trigger :: Session -> Hooks -> IO (Result, String) +trigger :: Session -> Hooks -> IO (Result, String, [Diagnostic]) trigger session hooks = runWriterT (runExceptT go) >>= \ case - (Left result, output) -> return (result, output) - (Right (), output) -> return (Success, output) + (Left result, (output, diagnostics)) -> return (result, output, diagnostics) + (Right (), (output, diagnostics)) -> return (Success, output, diagnostics) where go :: Trigger () go = do runHook hooks.beforeReload - (output, r) <- Session.reload session - tell output + (output, (r, err)) <- Session.reload session + tell (output, err) case r of Failed -> do echo $ withColor Red "RELOADING FAILED" <> "\n" @@ -93,7 +94,8 @@ trigger session hooks = runWriterT (runExceptT go) >>= \ case runSpec :: IO String -> Trigger () runSpec hspec = do - liftIO hspec >>= tell . removeProgress + r <- removeProgress <$> liftIO hspec + tell (r, []) result <- hspecPreviousSummary session unless (isSuccess result) abort @@ -104,5 +106,5 @@ trigger session hooks = runWriterT (runExceptT go) >>= \ case echo :: String -> Trigger () echo message = do - tell message + tell (message, []) liftIO $ Session.echo session message diff --git a/test/ClientSpec.hs b/test/ClientSpec.hs index b413fce..d2eddc0 100644 --- a/test/ClientSpec.hs +++ b/test/ClientSpec.hs @@ -16,7 +16,7 @@ withFailure = withServer Trigger.Failure (withColor Red "failure") withServer :: Trigger.Result -> String -> (FilePath -> IO a) -> IO a withServer result text action = do withTempDirectory $ \ dir -> do - HTTP.withServer dir (return (result, text)) $ do + HTTP.withServer dir (return (result, text, [])) $ do action dir spec :: Spec diff --git a/test/GHC/DiagnosticSpec.hs b/test/GHC/DiagnosticSpec.hs new file mode 100644 index 0000000..105c8f8 --- /dev/null +++ b/test/GHC/DiagnosticSpec.hs @@ -0,0 +1,44 @@ +-- FIXME: check performance impact +{-# LANGUAGE BlockArguments #-} +module GHC.DiagnosticSpec (spec) where + +import Helper hiding (diagnostic) + +import System.Process + +import GHC.Diagnostic + +test :: HasCallStack => FilePath -> Spec +test name = it name $ do + err <- translate <$> ghc ["-fno-diagnostics-show-caret"] + Just diagnostic <- parse . encodeUtf8 <$> ghc ["-fdiagnostics-as-json"] + decodeUtf8 (format diagnostic) `shouldBe` err + where + ghc :: [String] -> IO String + ghc args = do + let + process :: CreateProcess + process = proc "ghc" (args ++ ["test/assets" name "Foo.hs"]) + (_, _, err) <- readCreateProcessWithExitCode process "" + return err + + translate :: String -> String + translate = map \ case + '‘' -> '`' + '’' -> '\'' + -- '•' -> '*' + c -> c + +ftest :: HasCallStack => FilePath -> Spec +ftest = focus . test + +_ignore :: HasCallStack => FilePath -> Spec +_ignore = ftest + +spec :: Spec +spec = do + describe "format" $ do + test "variable-not-in-scope" + test "variable-not-in-scope-perhaps-use" + test "use-BlockArguments" + test "non-existing" diff --git a/test/HTTPSpec.hs b/test/HTTPSpec.hs index 2c922c8..d29a955 100644 --- a/test/HTTPSpec.hs +++ b/test/HTTPSpec.hs @@ -1,7 +1,9 @@ module HTTPSpec (spec) where +import Prelude hiding (span) import Helper +import Network.Wai (Application) import Test.Hspec.Wai import qualified System.Console.ANSI as Ansi @@ -11,29 +13,54 @@ import qualified Trigger spec :: Spec spec = do describe "app" $ do - with (return $ app $ return (Trigger.Success, withColor Green "hello")) $ do - it "returns 200 on success" $ do - get "/" `shouldRespondWith` fromString (withColor Green "hello") + let + withApp :: (Trigger.Result, String, [Diagnostic]) -> SpecWith ((), Application) -> Spec + withApp = with . return . app . return - context "with ?color" $ do - it "keeps terminal sequences" $ do - get "/?color" `shouldRespondWith` fromString (withColor Green "hello") + describe "/" $ do + context "on success" $ do + withApp (Trigger.Success, withColor Green "success", []) $ do + it "returns 200" $ do + get "/" `shouldRespondWith` fromString (withColor Green "success") - context "with ?color=true" $ do - it "keeps terminal sequences" $ do - get "/?color=true" `shouldRespondWith` fromString (withColor Green "hello") + context "with ?color" $ do + it "keeps terminal sequences" $ do + get "/?color" `shouldRespondWith` fromString (withColor Green "success") - context "with ?color=false" $ do - it "removes terminal sequences" $ do - get "/?color=false" `shouldRespondWith` "hello" + context "with ?color=true" $ do + it "keeps terminal sequences" $ do + get "/?color=true" `shouldRespondWith` fromString (withColor Green "success") - context "with an in invalid value for ?color" $ do - it "returns status 400" $ do - get "/?color=some%20value" `shouldRespondWith` 400 { matchBody = "invalid value for color: some%20value" } + context "with ?color=false" $ do + it "removes terminal sequences" $ do + get "/?color=false" `shouldRespondWith` "success" - with (return $ app $ return (Trigger.Failure, "hello")) $ do - it "return 500 on failure" $ do - get "/" `shouldRespondWith` 500 + context "with an in invalid value for ?color" $ do + it "returns status 400" $ do + get "/?color=some%20value" `shouldRespondWith` 400 { matchBody = "invalid value for color: some%20value" } + + context "on failure" $ do + withApp (Trigger.Failure, withColor Red "failure", []) $ do + it "return 500" $ do + get "/" `shouldRespondWith` 500 + + describe "/diagnostics" $ do + let + start :: Location + start = Location 23 42 + + span :: Maybe Span + span = Just $ Span "Foo.hs" start start + + err :: Diagnostic + err = (diagnostic Error) { span, message = ["failure"] } + + expected :: ResponseMatcher + expected = fromString . decodeUtf8 $ to_json [err] + + withApp (Trigger.Failure, "", [err]) $ do + it "returns GHC diagnostics" $ do + get "/diagnostics" `shouldRespondWith` expected describe "stripAnsi" $ do it "removes ANSI color sequences" $ do diff --git a/test/Helper.hs b/test/Helper.hs index 72d1ca3..eb09177 100644 --- a/test/Helper.hs +++ b/test/Helper.hs @@ -13,8 +13,17 @@ module Helper ( , withColor , timeout + +, Diagnostic(..) +, Span(..) +, Location(..) +, Severity(..) +, diagnostic + +, to_json ) where +import Prelude hiding (span) import Imports import System.Directory as Imports @@ -27,10 +36,15 @@ import Test.Mockery.Directory as Imports (touch) import System.Environment import qualified System.Timeout +import Data.ByteString.Lazy (toStrict) +import Data.Aeson (ToJSON, encode) + import Run () import Util import Language.Haskell.GhciWrapper (Config(..)) +import GHC.Diagnostic + timeout :: IO a -> IO (Maybe a) timeout action = lookupEnv "CI" >>= \ case Nothing -> System.Timeout.timeout 5_000_000 action @@ -90,3 +104,17 @@ failingSpec = unlines [ , " it \"foo\" True" , " it \"bar\" False" ] + +diagnostic :: Severity -> Diagnostic +diagnostic severity = Diagnostic { + version = "1.0" +, ghcVersion = "ghc-9.10.1" +, span = Nothing +, severity +, code = Nothing +, message = [] +, hints = [] +} + +to_json :: ToJSON a => a -> ByteString +to_json = toStrict . encode diff --git a/test/Language/Haskell/GhciWrapperSpec.hs b/test/Language/Haskell/GhciWrapperSpec.hs index 54bad2d..69263c8 100644 --- a/test/Language/Haskell/GhciWrapperSpec.hs +++ b/test/Language/Haskell/GhciWrapperSpec.hs @@ -134,16 +134,37 @@ spec = do writeFile file "module Foo where" action file + failingModule :: String -> IO () + failingModule file = writeFile file $ unlines [ + "module Foo where" + , "foo = bar" + ] + it "indicates success" do withModule \ file -> do withInterpreter [file] \ ghci -> do - Interpreter.reload ghci `shouldReturn` ("", Ok) + Interpreter.reload ghci `shouldReturn` ("", (Ok, [])) it "indicates failure" do withModule \ file -> do withInterpreter [file] \ ghci -> do - writeFile file $ unlines [ - "module Foo where" - , "foo = bar" - ] - snd <$> Interpreter.reload ghci `shouldReturn` Failed + failingModule file + snd <$> Interpreter.reload ghci `shouldReturn` (Failed, [ +#if __GLASGOW_HASKELL__ >= 910 + (diagnostic Error) { + span = Just $ Span file (Location 2 7) (Location 2 10) + , code = Just 88464 + , message = ["Variable not in scope: bar"] + } +#endif + ]) + + context "with -fno-diagnostics-as-json" $ do + it "does not extract diagnostics" do +#if __GLASGOW_HASKELL__ < 910 + pending +#endif + withModule \ file -> do + withInterpreter ["-fno-diagnostics-as-json", file] \ ghci -> do + failingModule file + snd <$> Interpreter.reload ghci `shouldReturn` (Failed, []) diff --git a/test/ReadHandleSpec.hs b/test/ReadHandleSpec.hs index 2aa4169..57371a3 100644 --- a/test/ReadHandleSpec.hs +++ b/test/ReadHandleSpec.hs @@ -1,12 +1,13 @@ module ReadHandleSpec (spec) where +import Prelude hiding (span) import Helper import Test.QuickCheck import qualified Data.ByteString as ByteString import Session (Summary(..), extractSummary) -import Language.Haskell.GhciWrapper (extractReloadStatus) +import Language.Haskell.GhciWrapper (extractReloadDiagnostics, extractDiagnostics) import ReadHandle @@ -50,7 +51,7 @@ spec = do describe "drain" $ do it "drains all remaining input" $ do h <- fakeHandle ["foo", marker, "bar", marker, "baz", marker, ""] - withSpy (drain extractReloadStatus h) `shouldReturn` ["foo", "bar", "baz"] + withSpy (drain extractReloadDiagnostics h) `shouldReturn` ["foo", "bar", "baz"] describe "getResult" $ do context "with a single result" $ do @@ -59,14 +60,14 @@ spec = do it "returns result" $ do withSpy $ \ echo -> do h <- fakeHandle input - getResult extractReloadStatus h echo `shouldReturn` ("foobarbaz", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foobarbaz", []) `shouldReturn` ["foo", "bar", "baz"] context "with chunks of arbitrary size" $ do it "returns result" $ do withRandomChunkSizes input $ \ h -> do fmap mconcat . withSpy $ \ echo -> do - getResult extractReloadStatus h echo `shouldReturn` ("foobarbaz", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foobarbaz", []) `shouldReturn` "foobarbaz" context "with extractSummary" $ do @@ -110,52 +111,98 @@ spec = do getResult extract h echo `shouldReturn` ("foo\nbar\nSummary baz\n", []) `shouldReturn` "foo\nbar\nSummary baz\n" + context "with extractDiagnostics" $ do + let + extract :: Extract Diagnostic + extract = extractDiagnostics { + parseMessage = fmap (second $ const "") . extractDiagnostics.parseMessage + } + + it "extracts Diagnostic" $ do + let + start :: Location + start = Location 23 42 + + span :: Maybe Span + span = Just $ Span "Foo.hs" start start + + err :: Diagnostic + err = (diagnostic Error) { span } + + chunks :: [ByteString] + chunks = [ + "foo\n" + , "bar\n" + , to_json err <> "\n" + , "baz\n" + , marker + ] + withRandomChunkSizes chunks $ \ h -> do + fmap mconcat . withSpy $ \ echo -> do + getResult extract h echo `shouldReturn` ("foo\nbar\nbaz\n", [err]) + `shouldReturn` "foo\nbar\nbaz\n" + + context "with a partial match" $ do + it "retains the original input" $ do + let + chunks :: [ByteString] + chunks = [ + "foo\n" + , "bar\n" + , "{..." + , marker + ] + withRandomChunkSizes chunks $ \ h -> do + fmap mconcat . withSpy $ \ echo -> do + getResult extract h echo `shouldReturn` ("foo\nbar\n{...", []) + `shouldReturn` "foo\nbar\n{..." + context "with multiple results" $ do let input = ["foo", marker, "bar", marker, "baz", marker] it "returns one result at a time" $ do withSpy $ \ echo -> do h <- fakeHandle input - getResult extractReloadStatus h echo `shouldReturn` ("foo", []) - getResult extractReloadStatus h echo `shouldReturn` ("bar", []) - getResult extractReloadStatus h echo `shouldReturn` ("baz", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foo", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("bar", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("baz", []) `shouldReturn` ["foo", "bar", "baz"] context "with chunks of arbitrary size" $ do it "returns one result at a time" $ do withRandomChunkSizes input $ \ h -> do fmap mconcat . withSpy $ \ echo -> do - getResult extractReloadStatus h echo `shouldReturn` ("foo", []) - getResult extractReloadStatus h echo `shouldReturn` ("bar", []) - getResult extractReloadStatus h echo `shouldReturn` ("baz", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foo", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("bar", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("baz", []) `shouldReturn` "foobarbaz" context "when a chunk that contains a marker ends with a partial marker" $ do it "correctly gives the marker precedence over the partial marker" $ do withSpy $ \ echo -> do h <- fakeHandle ["foo" <> marker <> "bar" <> partialMarker, ""] - getResult extractReloadStatus h echo `shouldReturn` ("foo", []) - getResult extractReloadStatus h echo `shouldReturn` ("bar" <> partialMarker, []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foo", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("bar" <> partialMarker, []) `shouldReturn` ["foo", "bar", partialMarker] context "on EOF" $ do it "returns all remaining input" $ do withSpy $ \ echo -> do h <- fakeHandle ["foo", "bar", "baz", ""] - getResult extractReloadStatus h echo `shouldReturn` ("foobarbaz", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foobarbaz", []) `shouldReturn` ["foo", "bar", "baz"] context "with a partialMarker at the end" $ do it "includes the partial marker in the output" $ do withSpy $ \ echo -> do h <- fakeHandle ["foo", "bar", "baz", partialMarker, ""] - getResult extractReloadStatus h echo `shouldReturn` ("foobarbaz" <> partialMarker, []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foobarbaz" <> partialMarker, []) `shouldReturn` ["foo", "bar", "baz", partialMarker] context "after a marker" $ do it "returns all remaining input" $ do withSpy $ \ echo -> do h <- fakeHandle ["foo", "bar", "baz", marker, "qux", ""] - getResult extractReloadStatus h echo `shouldReturn` ("foobarbaz", []) - getResult extractReloadStatus h echo `shouldReturn` ("qux", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("foobarbaz", []) + getResult extractReloadDiagnostics h echo `shouldReturn` ("qux", []) `shouldReturn` ["foo", "bar", "baz", "qux"] diff --git a/test/SessionSpec.hs b/test/SessionSpec.hs index 58cefd1..ad33358 100644 --- a/test/SessionSpec.hs +++ b/test/SessionSpec.hs @@ -36,11 +36,6 @@ spec = do withSession ["-XOverloadedStrings", "-Wall", "-Werror"] $ \ Session{..} -> do eval interpreter "23 :: Int" `shouldReturn` "23\n" - describe "reload" $ do - it "reloads" $ do - withSession [] $ \ session -> do - Session.reload session `shouldReturn` ("", Ok) - describe "hasSpec" $ around withSomeSpec $ do context "when module contains spec" $ do it "returns True" $ \ name -> do diff --git a/test/TriggerSpec.hs b/test/TriggerSpec.hs index 9be4a19..207a886 100644 --- a/test/TriggerSpec.hs +++ b/test/TriggerSpec.hs @@ -39,10 +39,14 @@ trigger :: Session -> IO (Result, [String]) trigger session = triggerWithHooks session defaultHooks triggerWithHooks :: Session -> Hooks -> IO (Result, [String]) -triggerWithHooks session hooks = fmap normalize <$> Trigger.trigger session hooks +triggerWithHooks session hooks = do + (result, output, _) <- Trigger.trigger session hooks + return (result, normalize output) triggerAll :: Session -> IO (Result, [String]) -triggerAll session = fmap normalize <$> Trigger.triggerAll session defaultHooks +triggerAll session = do + (result, output, _) <- Trigger.triggerAll session defaultHooks + return (result, normalize output) requiresHspecMeta :: IO () -> IO () requiresHspecMeta action = try action >>= \ case diff --git a/test/assets/use-BlockArguments/Foo.hs b/test/assets/use-BlockArguments/Foo.hs new file mode 100644 index 0000000..de194c9 --- /dev/null +++ b/test/assets/use-BlockArguments/Foo.hs @@ -0,0 +1,5 @@ +{-# LANGUAGE NoBlockArguments #-} +module BlockArguments.Foo where + +foo :: IO () +foo = id do return () diff --git a/test/assets/variable-not-in-scope-perhaps-use/Foo.hs b/test/assets/variable-not-in-scope-perhaps-use/Foo.hs new file mode 100644 index 0000000..851426f --- /dev/null +++ b/test/assets/variable-not-in-scope-perhaps-use/Foo.hs @@ -0,0 +1,2 @@ +module Foo where +foo = filter_ diff --git a/test/assets/variable-not-in-scope/Foo.hs b/test/assets/variable-not-in-scope/Foo.hs new file mode 100644 index 0000000..5f2becf --- /dev/null +++ b/test/assets/variable-not-in-scope/Foo.hs @@ -0,0 +1,2 @@ +module Foo where +foo = bar diff --git a/test/assets/variable-not-in-scope/Spec.hs b/test/assets/variable-not-in-scope/Spec.hs new file mode 100644 index 0000000..5f2becf --- /dev/null +++ b/test/assets/variable-not-in-scope/Spec.hs @@ -0,0 +1,2 @@ +module Foo where +foo = bar