{-# LANGUAGE GADTs #-} {-# LANGUAGE OverloadedLabels #-} module DiagnosticTests (tests) where import Control.Applicative.Combinators import qualified Control.Lens as Lens import Control.Monad import Control.Monad.IO.Class (liftIO) import Data.List.Extra import Data.Row import qualified Data.Text as T import Development.IDE.GHC.Compat (GhcVersion (..), ghcVersion) import Development.IDE.GHC.Util import Development.IDE.Test (diagnostic, expectCurrentDiagnostics, expectDiagnostics, expectDiagnosticsWithTags, expectNoMoreDiagnostics, flushMessages, waitForAction) import Development.IDE.Types.Location import qualified Language.LSP.Protocol.Lens as L import Language.LSP.Protocol.Message import Language.LSP.Protocol.Types hiding (SemanticTokenAbsolute (..), SemanticTokenRelative (..), SemanticTokensEdit (..), mkRange) import Language.LSP.Test import System.Directory import System.FilePath import System.IO.Extra hiding (withTempDir) import Control.Lens ((^.)) import Control.Monad.Extra (whenJust) import Development.IDE.Plugin.Test (WaitForIdeRuleResult (..)) import System.Time.Extra import Test.Tasty import Test.Tasty.HUnit import TestUtils tests :: TestTree tests = testGroup "diagnostics" [ testSessionWait "fix syntax error" $ do let content = T.unlines [ "module Testing wher" ] doc <- createDoc "Testing.hs" "haskell" content expectDiagnostics [("Testing.hs", [(DiagnosticSeverity_Error, (0, 15), "parse error")])] let change = TextDocumentContentChangeEvent $ InL $ #range .== Range (Position 0 15) (Position 0 19) .+ #rangeLength .== Nothing .+ #text .== "where" changeDoc doc [change] expectDiagnostics [("Testing.hs", [])] , testSessionWait "introduce syntax error" $ do let content = T.unlines [ "module Testing where" ] doc <- createDoc "Testing.hs" "haskell" content void $ skipManyTill anyMessage (message SMethod_WindowWorkDoneProgressCreate) waitForProgressBegin let change = TextDocumentContentChangeEvent$ InL $ #range .== Range (Position 0 15) (Position 0 18) .+ #rangeLength .== Nothing .+ #text .== "wher" changeDoc doc [change] expectDiagnostics [("Testing.hs", [(DiagnosticSeverity_Error, (0, 15), "parse error")])] , testSessionWait "update syntax error" $ do let content = T.unlines [ "module Testing(missing) where" ] doc <- createDoc "Testing.hs" "haskell" content expectDiagnostics [("Testing.hs", [(DiagnosticSeverity_Error, (0, 15), "Not in scope: 'missing'")])] let change = TextDocumentContentChangeEvent $ InL $ #range .== Range (Position 0 15) (Position 0 16) .+ #rangeLength .== Nothing .+ #text .== "l" changeDoc doc [change] expectDiagnostics [("Testing.hs", [(DiagnosticSeverity_Error, (0, 15), "Not in scope: 'lissing'")])] , testSessionWait "variable not in scope" $ do let content = T.unlines [ "module Testing where" , "foo :: Int -> Int -> Int" , "foo a _b = a + ab" , "bar :: Int -> Int -> Int" , "bar _a b = cd + b" ] _ <- createDoc "Testing.hs" "haskell" content expectDiagnostics [ ( "Testing.hs" , [ (DiagnosticSeverity_Error, (2, 15), "Variable not in scope: ab") , (DiagnosticSeverity_Error, (4, 11), "Variable not in scope: cd") ] ) ] , testSessionWait "type error" $ do let content = T.unlines [ "module Testing where" , "foo :: Int -> String -> Int" , "foo a b = a + b" ] _ <- createDoc "Testing.hs" "haskell" content expectDiagnostics [ ( "Testing.hs" , [(DiagnosticSeverity_Error, (2, 14), "Couldn't match type '[Char]' with 'Int'")] ) ] , testSessionWait "typed hole" $ do let content = T.unlines [ "module Testing where" , "foo :: Int -> String" , "foo a = _ a" ] _ <- createDoc "Testing.hs" "haskell" content expectDiagnostics [ ( "Testing.hs" , [(DiagnosticSeverity_Error, (2, 8), "Found hole: _ :: Int -> String")] ) ] , testGroup "deferral" $ let sourceA a = T.unlines [ "module A where" , "a :: Int" , "a = " <> a] sourceB = T.unlines [ "module B where" , "import A ()" , "b :: Float" , "b = True"] bMessage = "Couldn't match expected type 'Float' with actual type 'Bool'" expectedDs aMessage = [ ("A.hs", [(DiagnosticSeverity_Error, (2,4), aMessage)]) , ("B.hs", [(DiagnosticSeverity_Error, (3,4), bMessage)])] deferralTest title binding msg = testSessionWait title $ do _ <- createDoc "A.hs" "haskell" $ sourceA binding _ <- createDoc "B.hs" "haskell" sourceB expectDiagnostics $ expectedDs msg in [ deferralTest "type error" "True" "Couldn't match expected type" , deferralTest "typed hole" "_" "Found hole" , deferralTest "out of scope var" "unbound" "Variable not in scope" ] , testSessionWait "remove required module" $ do let contentA = T.unlines [ "module ModuleA where" ] docA <- createDoc "ModuleA.hs" "haskell" contentA let contentB = T.unlines [ "module ModuleB where" , "import ModuleA" ] _ <- createDoc "ModuleB.hs" "haskell" contentB let change = TextDocumentContentChangeEvent $ InL $ #range .== Range (Position 0 0) (Position 0 20) .+ #rangeLength .== Nothing .+ #text .== "" changeDoc docA [change] expectDiagnostics [("ModuleB.hs", [(DiagnosticSeverity_Error, (1, 0), "Could not find module")])] , testSessionWait "add missing module" $ do let contentB = T.unlines [ "module ModuleB where" , "import ModuleA ()" ] _ <- createDoc "ModuleB.hs" "haskell" contentB expectDiagnostics [("ModuleB.hs", [(DiagnosticSeverity_Error, (1, 7), "Could not find module")])] let contentA = T.unlines [ "module ModuleA where" ] _ <- createDoc "ModuleA.hs" "haskell" contentA expectDiagnostics [("ModuleB.hs", [])] , testCase "add missing module (non workspace)" $ -- By default lsp-test sends FileWatched notifications for all files, which we don't want -- as non workspace modules will not be watched by the LSP server. -- To work around this, we tell lsp-test that our client doesn't have the -- FileWatched capability, which is enough to disable the notifications withTempDir $ \tmpDir -> runInDir'' lspTestCapsNoFileWatches tmpDir "." "." [] $ do let contentB = T.unlines [ "module ModuleB where" , "import ModuleA ()" ] _ <- createDoc (tmpDir "ModuleB.hs") "haskell" contentB expectDiagnostics [(tmpDir "ModuleB.hs", [(DiagnosticSeverity_Error, (1, 7), "Could not find module")])] let contentA = T.unlines [ "module ModuleA where" ] _ <- createDoc (tmpDir "ModuleA.hs") "haskell" contentA expectDiagnostics [(tmpDir "ModuleB.hs", [])] , testSessionWait "cyclic module dependency" $ do let contentA = T.unlines [ "module ModuleA where" , "import ModuleB" ] let contentB = T.unlines [ "module ModuleB where" , "import ModuleA" ] _ <- createDoc "ModuleA.hs" "haskell" contentA _ <- createDoc "ModuleB.hs" "haskell" contentB expectDiagnostics [ ( "ModuleA.hs" , [(DiagnosticSeverity_Error, (1, 7), "Cyclic module dependency between ModuleA, ModuleB")] ) , ( "ModuleB.hs" , [(DiagnosticSeverity_Error, (1, 7), "Cyclic module dependency between ModuleA, ModuleB")] ) ] , testSession' "deeply nested cyclic module dependency" $ \path -> do let contentA = unlines [ "module ModuleA where" , "import ModuleB" ] let contentB = unlines [ "module ModuleB where" , "import ModuleA" ] let contentC = unlines [ "module ModuleC where" , "import ModuleB" ] let contentD = T.unlines [ "module ModuleD where" , "import ModuleC" ] cradle = "cradle: {direct: {arguments: [ModuleA, ModuleB, ModuleC, ModuleD]}}" liftIO $ writeFile (path "ModuleA.hs") contentA liftIO $ writeFile (path "ModuleB.hs") contentB liftIO $ writeFile (path "ModuleC.hs") contentC liftIO $ writeFile (path "hie.yaml") cradle _ <- createDoc "ModuleD.hs" "haskell" contentD expectDiagnostics [ ( "ModuleB.hs" , [(DiagnosticSeverity_Error, (1, 7), "Cyclic module dependency between ModuleA, ModuleB")] ) ] , testSessionWait "cyclic module dependency with hs-boot" $ do let contentA = T.unlines [ "module ModuleA where" , "import {-# SOURCE #-} ModuleB" ] let contentB = T.unlines [ "{-# OPTIONS -Wmissing-signatures#-}" , "module ModuleB where" , "import ModuleA" -- introduce an artificial diagnostic , "foo = ()" ] let contentBboot = T.unlines [ "module ModuleB where" ] _ <- createDoc "ModuleA.hs" "haskell" contentA _ <- createDoc "ModuleB.hs" "haskell" contentB _ <- createDoc "ModuleB.hs-boot" "haskell" contentBboot expectDiagnostics [("ModuleB.hs", [(DiagnosticSeverity_Warning, (3,0), "Top-level binding")])] , testSession' "bidirectional module dependency with hs-boot" $ \path -> do let cradle = unlines [ "cradle:" , " direct: {arguments: [ModuleA, ModuleB]}" ] let contentA = T.unlines [ "module ModuleA where" , "import {-# SOURCE #-} ModuleB" ] let contentB = T.unlines [ "{-# OPTIONS -Wmissing-signatures#-}" , "module ModuleB where" , "import {-# SOURCE #-} ModuleA" -- introduce an artificial diagnostic , "foo = ()" ] let contentBboot = T.unlines [ "module ModuleB where" ] let contentAboot = T.unlines [ "module ModuleA where" ] liftIO $ writeFile (path "hie.yaml") cradle _ <- createDoc "ModuleA.hs" "haskell" contentA _ <- createDoc "ModuleA.hs-boot" "haskell" contentAboot _ <- createDoc "ModuleB.hs" "haskell" contentB _ <- createDoc "ModuleB.hs-boot" "haskell" contentBboot expectDiagnostics [("ModuleB.hs", [(DiagnosticSeverity_Warning, (3,0), "Top-level binding")])] , testSessionWait "correct reference used with hs-boot" $ do let contentB = T.unlines [ "module ModuleB where" , "import {-# SOURCE #-} ModuleA()" ] let contentA = T.unlines [ "module ModuleA where" , "import ModuleB()" , "x = 5" ] let contentAboot = T.unlines [ "module ModuleA where" ] let contentC = T.unlines [ "{-# OPTIONS -Wmissing-signatures #-}" , "module ModuleC where" , "import ModuleA" -- this reference will fail if it gets incorrectly -- resolved to the hs-boot file , "y = x" ] _ <- createDoc "ModuleB.hs" "haskell" contentB _ <- createDoc "ModuleA.hs" "haskell" contentA _ <- createDoc "ModuleA.hs-boot" "haskell" contentAboot _ <- createDoc "ModuleC.hs" "haskell" contentC expectDiagnostics [("ModuleC.hs", [(DiagnosticSeverity_Warning, (3,0), "Top-level binding")])] , testSessionWait "redundant import" $ do let contentA = T.unlines ["module ModuleA where"] let contentB = T.unlines [ "{-# OPTIONS_GHC -Wunused-imports #-}" , "module ModuleB where" , "import ModuleA" ] _ <- createDoc "ModuleA.hs" "haskell" contentA _ <- createDoc "ModuleB.hs" "haskell" contentB expectDiagnosticsWithTags [ ( "ModuleB.hs" , [(DiagnosticSeverity_Warning, (2, 0), "The import of 'ModuleA' is redundant", Just DiagnosticTag_Unnecessary)] ) ] , testSessionWait "redundant import even without warning" $ do let contentA = T.unlines ["module ModuleA where"] let contentB = T.unlines [ "{-# OPTIONS_GHC -Wno-unused-imports -Wmissing-signatures #-}" , "module ModuleB where" , "import ModuleA" -- introduce an artificial warning for testing purposes , "foo = ()" ] _ <- createDoc "ModuleA.hs" "haskell" contentA _ <- createDoc "ModuleB.hs" "haskell" contentB expectDiagnostics [("ModuleB.hs", [(DiagnosticSeverity_Warning, (3,0), "Top-level binding")])] , testSessionWait "package imports" $ do let thisDataListContent = T.unlines [ "module Data.List where" , "x :: Integer" , "x = 123" ] let mainContent = T.unlines [ "{-# LANGUAGE PackageImports #-}" , "module Main where" , "import qualified \"this\" Data.List as ThisList" , "import qualified \"base\" Data.List as BaseList" , "useThis = ThisList.x" , "useBase = BaseList.map" , "wrong1 = ThisList.map" , "wrong2 = BaseList.x" , "main = pure ()" ] _ <- createDoc "Data/List.hs" "haskell" thisDataListContent _ <- createDoc "Main.hs" "haskell" mainContent expectDiagnostics [ ( "Main.hs" , [(DiagnosticSeverity_Error, (6, 9), if ghcVersion >= GHC96 then "Variable not in scope: ThisList.map" else if ghcVersion >= GHC94 then "Variable not in scope: map" -- See https://gitlab.haskell.org/ghc/ghc/-/issues/22130 else "Not in scope: \8216ThisList.map\8217") ,(DiagnosticSeverity_Error, (7, 9), if ghcVersion >= GHC96 then "Variable not in scope: BaseList.x" else if ghcVersion >= GHC94 then "Variable not in scope: x" -- See https://gitlab.haskell.org/ghc/ghc/-/issues/22130 else "Not in scope: \8216BaseList.x\8217") ] ) ] , testSessionWait "unqualified warnings" $ do let fooContent = T.unlines [ "{-# OPTIONS_GHC -Wredundant-constraints #-}" , "module Foo where" , "foo :: Ord a => a -> Int" , "foo _a = 1" ] _ <- createDoc "Foo.hs" "haskell" fooContent expectDiagnostics [ ( "Foo.hs" -- The test is to make sure that warnings contain unqualified names -- where appropriate. The warning should use an unqualified name 'Ord', not -- something like 'GHC.Classes.Ord'. The choice of redundant-constraints to -- test this is fairly arbitrary. , [(DiagnosticSeverity_Warning, (2, if ghcVersion >= GHC94 then 7 else 0), "Redundant constraint: Ord a") ] ) ] , testSessionWait "lower-case drive" $ do let aContent = T.unlines [ "module A.A where" , "import A.B ()" ] bContent = T.unlines [ "{-# OPTIONS_GHC -Wall #-}" , "module A.B where" , "import Data.List" ] uriB <- getDocUri "A/B.hs" Just pathB <- pure $ uriToFilePath uriB uriB <- pure $ let (drive, suffix) = splitDrive pathB in filePathToUri (joinDrive (lower drive) suffix) liftIO $ createDirectoryIfMissing True (takeDirectory pathB) liftIO $ writeFileUTF8 pathB $ T.unpack bContent uriA <- getDocUri "A/A.hs" Just pathA <- pure $ uriToFilePath uriA uriA <- pure $ let (drive, suffix) = splitDrive pathA in filePathToUri (joinDrive (lower drive) suffix) let itemA = TextDocumentItem uriA "haskell" 0 aContent let a = TextDocumentIdentifier uriA sendNotification SMethod_TextDocumentDidOpen (DidOpenTextDocumentParams itemA) TNotificationMessage{_params = PublishDiagnosticsParams fileUri _ diags} <- skipManyTill anyMessage diagnostic -- Check that if we put a lower-case drive in for A.A -- the diagnostics for A.B will also be lower-case. liftIO $ fileUri @?= uriB let msg :: T.Text = (head diags) ^. L.message liftIO $ unless ("redundant" `T.isInfixOf` msg) $ assertFailure ("Expected redundant import but got " <> T.unpack msg) closeDoc a , testSessionWait "haddock parse error" $ do let fooContent = T.unlines [ "module Foo where" , "foo :: Int" , "foo = 1 {-|-}" ] _ <- createDoc "Foo.hs" "haskell" fooContent if ghcVersion >= GHC90 then -- Haddock parse errors are ignored on ghc-9.0 pure () else expectDiagnostics [ ( "Foo.hs" , [(DiagnosticSeverity_Warning, (2, 8), "Haddock parse error on input")] ) ] , testSessionWait "strip file path" $ do let name = "Testing" content = T.unlines [ "module " <> name <> " where" , "value :: Maybe ()" , "value = [()]" ] _ <- createDoc (T.unpack name <> ".hs") "haskell" content notification <- skipManyTill anyMessage diagnostic let offenders = L.params . L.diagnostics . Lens.folded . L.message . Lens.filtered (T.isInfixOf ("/" <> name <> ".hs:")) failure msg = liftIO $ assertFailure $ "Expected file path to be stripped but got " <> T.unpack msg Lens.mapMOf_ offenders failure notification , testSession' "-Werror in cradle is ignored" $ \sessionDir -> do liftIO $ writeFile (sessionDir "hie.yaml") "cradle: {direct: {arguments: [\"-Wall\", \"-Werror\"]}}" let fooContent = T.unlines [ "module Foo where" , "foo = ()" ] _ <- createDoc "Foo.hs" "haskell" fooContent expectDiagnostics [ ( "Foo.hs" , [(DiagnosticSeverity_Warning, (1, 0), "Top-level binding with no type signature:") ] ) ] , testSessionWait "-Werror in pragma is ignored" $ do let fooContent = T.unlines [ "{-# OPTIONS_GHC -Wall -Werror #-}" , "module Foo() where" , "foo :: Int" , "foo = 1" ] _ <- createDoc "Foo.hs" "haskell" fooContent expectDiagnostics [ ( "Foo.hs" , [(DiagnosticSeverity_Warning, (3, 0), "Defined but not used:") ] ) ] , testCase "typecheck-all-parents-of-interest" $ runWithExtraFiles "recomp" $ \dir -> do let bPath = dir "B.hs" pPath = dir "P.hs" aPath = dir "A.hs" bSource <- liftIO $ readFileUtf8 bPath -- y :: Int pSource <- liftIO $ readFileUtf8 pPath -- bar = x :: Int aSource <- liftIO $ readFileUtf8 aPath -- x = y :: Int bdoc <- createDoc bPath "haskell" bSource _pdoc <- createDoc pPath "haskell" pSource expectDiagnostics [("P.hs", [(DiagnosticSeverity_Warning,(4,0), "Top-level binding")])] -- So that we know P has been loaded -- Change y from Int to B which introduces a type error in A (imported from P) changeDoc bdoc [TextDocumentContentChangeEvent . InR . (.==) #text $ T.unlines ["module B where", "y :: Bool", "y = undefined"]] expectDiagnostics [("A.hs", [(DiagnosticSeverity_Error, (5, 4), "Couldn't match expected type 'Int' with actual type 'Bool'")]) ] -- Open A and edit to fix the type error adoc <- createDoc aPath "haskell" aSource changeDoc adoc [TextDocumentContentChangeEvent . InR . (.==) #text $ T.unlines ["module A where", "import B", "x :: Bool", "x = y"]] expectDiagnostics [ ( "P.hs", [ (DiagnosticSeverity_Error, (4, 6), "Couldn't match expected type 'Int' with actual type 'Bool'"), (DiagnosticSeverity_Warning, (4, 0), "Top-level binding") ] ), ("A.hs", []) ] expectNoMoreDiagnostics 1 , testSessionWait "deduplicate missing module diagnostics" $ do let fooContent = T.unlines [ "module Foo() where" , "import MissingModule" ] doc <- createDoc "Foo.hs" "haskell" fooContent expectDiagnostics [("Foo.hs", [(DiagnosticSeverity_Error, (1,7), "Could not find module 'MissingModule'")])] changeDoc doc [TextDocumentContentChangeEvent . InR . (.==) #text $ "module Foo() where" ] expectDiagnostics [] changeDoc doc [TextDocumentContentChangeEvent . InR . (.==) #text $ T.unlines [ "module Foo() where" , "import MissingModule" ] ] expectDiagnostics [("Foo.hs", [(DiagnosticSeverity_Error, (1,7), "Could not find module 'MissingModule'")])] , testGroup "Cancellation" [ cancellationTestGroup "edit header" editHeader yesSession noParse noTc , cancellationTestGroup "edit import" editImport noSession yesParse noTc , cancellationTestGroup "edit body" editBody yesSession yesParse yesTc ] ] where editPair x y = let p = Position x y ; p' = Position x (y+2) in (TextDocumentContentChangeEvent $ InL $ #range .== Range p p .+ #rangeLength .== Nothing .+ #text .== "fd" ,TextDocumentContentChangeEvent $ InL $ #range .== Range p p' .+ #rangeLength .== Nothing .+ #text .== "") editHeader = editPair 0 0 editImport = editPair 2 10 editBody = editPair 3 10 noParse = False yesParse = True noSession = False yesSession = True noTc = False yesTc = True cancellationTestGroup :: TestName -> (TextDocumentContentChangeEvent, TextDocumentContentChangeEvent) -> Bool -> Bool -> Bool -> TestTree cancellationTestGroup name edits sessionDepsOutcome parseOutcome tcOutcome = testGroup name [ cancellationTemplate edits Nothing , cancellationTemplate edits $ Just ("GetFileContents", True) , cancellationTemplate edits $ Just ("GhcSession", True) -- the outcome for GetModSummary is always True because parseModuleHeader never fails (!) , cancellationTemplate edits $ Just ("GetModSummary", True) , cancellationTemplate edits $ Just ("GetModSummaryWithoutTimestamps", True) -- getLocatedImports never fails , cancellationTemplate edits $ Just ("GetLocatedImports", True) , cancellationTemplate edits $ Just ("GhcSessionDeps", sessionDepsOutcome) , cancellationTemplate edits $ Just ("GetParsedModule", parseOutcome) , cancellationTemplate edits $ Just ("TypeCheck", tcOutcome) , cancellationTemplate edits $ Just ("GetHieAst", tcOutcome) ] cancellationTemplate :: (TextDocumentContentChangeEvent, TextDocumentContentChangeEvent) -> Maybe (String, Bool) -> TestTree cancellationTemplate (edit, undoEdit) mbKey = testCase (maybe "-" fst mbKey) $ runTestNoKick $ do doc <- createDoc "Foo.hs" "haskell" $ T.unlines [ "{-# OPTIONS_GHC -Wall #-}" , "module Foo where" , "import Data.List()" , "f0 x = (x,x)" ] -- for the example above we expect one warning let missingSigDiags = [(DiagnosticSeverity_Warning, (3, 0), "Top-level binding") ] typeCheck doc >> expectCurrentDiagnostics doc missingSigDiags -- Now we edit the document and wait for the given key (if any) changeDoc doc [edit] whenJust mbKey $ \(key, expectedResult) -> do WaitForIdeRuleResult{ideResultSuccess} <- waitForAction key doc liftIO $ ideResultSuccess @?= expectedResult -- The 2nd edit cancels the active session and unbreaks the file -- wait for typecheck and check that the current diagnostics are accurate changeDoc doc [undoEdit] typeCheck doc >> expectCurrentDiagnostics doc missingSigDiags expectNoMoreDiagnostics 0.5 where -- similar to run except it disables kick runTestNoKick s = withTempDir $ \dir -> runInDir' dir "." "." ["--test-no-kick"] s typeCheck doc = do WaitForIdeRuleResult {..} <- waitForAction "TypeCheck" doc liftIO $ assertBool "The file should typecheck" ideResultSuccess -- wait for the debouncer to publish diagnostics if the rule runs liftIO $ sleep 0.2 -- flush messages to ensure current diagnostics state is updated flushMessages