packages feed

ghci-quickfix-0.1.1.1: src/GhciQuickfix.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE GADTs #-}
module GhciQuickfix
  ( plugin
  , pluginOffByDefault
  ) where

import           Control.Concurrent (threadDelay, MVar, newEmptyMVar, tryPutMVar)
import qualified Control.Concurrent.Async as Async
import           Control.Concurrent.STM.TVar
import           Control.Exception.Safe
import           Control.Monad
import           Control.Monad.STM
import qualified Data.Char as Char
import           Data.Either (partitionEithers)
import           Data.Foldable
import           Data.Functor
import           Data.IORef
import qualified Data.List as List
import           Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import           Data.Maybe
import           Data.Monoid (First(..))
import qualified Data.Ord as Ord
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO
import qualified System.Directory as Dir
import qualified System.Environment as Env
import           System.IO (stderr, hPutStrLn)
import           System.IO.Unsafe (unsafePerformIO)

import qualified GhciQuickfix.GhcFacade as Ghc

type ErrMap = TVar (Map FilePath Errors)

data Errors = Errors
  { ordering :: !Int -- track the ordering of module compilation
  , errors :: ![T.Text]
  }

{-# NOINLINE globalErrMap #-}
globalErrMap :: ErrMap
globalErrMap = unsafePerformIO (newTVarIO mempty)

{-# NOINLINE globalErrsUpdated #-}
globalErrsUpdated :: TVar Bool
globalErrsUpdated = unsafePerformIO (newTVarIO False)

{-# NOINLINE globalCounter #-}
globalCounter :: IORef Int
globalCounter = unsafePerformIO (newIORef 0)

{-# NOINLINE writerThreadLock #-}
writerThreadLock :: MVar ()
writerThreadLock = unsafePerformIO newEmptyMVar

plugin :: Ghc.Plugin
plugin = Ghc.defaultPlugin
  { Ghc.driverPlugin = modifyHscEnv False
  , Ghc.pluginRecompile = mempty
  }

-- | For use with repl-alliance, where this plugin should be off by default
pluginOffByDefault :: Ghc.Plugin
pluginOffByDefault = plugin
  { Ghc.driverPlugin = modifyHscEnv True }

-- | Background process that writes the quickfix file when errors change. Adds a
-- delay to mitigate excessive IO.
writeQuickfixLoop :: Maybe FilePath -> IO ()
writeQuickfixLoop mErrFilePath = forever $
  handleAny
    (\err -> do
      hPutStrLn stderr $ "ghci-quickfix: " <> displayException err
      threadDelay 1_000_000
    )
    $ do
      msgs <- atomically $ do
        isUpdated <- swapTVar globalErrsUpdated False
        check isUpdated
        Map.toList <$> readTVar globalErrMap
      prunedMsgs <- pruneDeletedFiles msgs
      TIO.writeFile (fromMaybe "errors.err" mErrFilePath)
        . T.unlines . foldMap' errors
        -- sort so that the last compiled module is the first in the list
        $ List.sortOn (Ord.Down . ordering) prunedMsgs
      threadDelay 200_000 -- 200ms

parseFilePathModifier :: [Ghc.CommandLineOption] -> IO (Either String [T.Text -> T.Text])
parseFilePathModifier opts = do
  envMod <- getEnvModifier
  pure $ case partitionEithers (mapMaybe getModifier opts ++ maybeToList envMod) of
    ([], modifiers) -> Right modifiers
    (errs, _) -> Left (unlines errs)
  where
  parseReplacement :: String -> String -> Either String (T.Text -> T.Text)
  parseReplacement source pat =
    case T.split (== ':') (T.pack pat) of
      [needle, replace] -> Right $ T.replace needle replace
      _ -> Left $ "Malformed " ++ source ++ ": expected format 'needle:replace', got '" ++ pat ++ "'"
  getEnvModifier = do
    mPat <- Env.lookupEnv "GHCI_QUICKFIX_PATH_REPLACE"
    pure $ parseReplacement "GHCI_QUICKFIX_PATH_REPLACE environment variable" <$> mPat
  getModifier opt = do
    pat <- List.stripPrefix "--quickfix-path-replace=" opt
    Just $ parseReplacement "--quickfix-path-replace argument" pat

parseQuickfixFilePath :: [Ghc.CommandLineOption] -> IO (Maybe FilePath)
parseQuickfixFilePath opts = do
  envPath <- Env.lookupEnv "GHCI_QUICKFIX_FILE"
  pure $ getFirst $ foldMap' (First . List.stripPrefix "--quickfix-file=") opts <> First envPath

parseIncludeParserErrors :: [Ghc.CommandLineOption] -> IO Bool
parseIncludeParserErrors opts = do
  envEnabled <- (== Just "true") . fmap (map Char.toLower)
    <$> Env.lookupEnv "GHCI_QUICKFIX_INCLUDE_PARSER_ERRORS"
  pure $ elem "--quickfix-include-parser-errors" opts || envEnabled

explicitlyEnabled :: [Ghc.CommandLineOption] -> IO Bool
explicitlyEnabled opts = do
  envEnabled <- (== Just "true") . fmap (map Char.toLower)
    <$> Env.lookupEnv "GHCI_QUICKFIX_ENABLED"
  pure $ elem "--quickfix" opts || envEnabled

modifyHscEnv :: Bool -> [Ghc.CommandLineOption] -> Ghc.HscEnv -> IO Ghc.HscEnv
modifyHscEnv isOffByDefault opts hscEnv = do
  enabled <- explicitlyEnabled opts
  if not isOffByDefault || enabled then do
    parseFilePathModifier opts >>= \case
      Left err -> fail err
      Right filePathMods -> do
        quickfixFilePath <- parseQuickfixFilePath opts
        started <- tryPutMVar writerThreadLock ()
        when started $
          void . Async.async $ writeQuickfixLoop quickfixFilePath
        includeParserErrors <- parseIncludeParserErrors opts
        pure hscEnv { Ghc.hsc_hooks = modifyHooks includeParserErrors filePathMods (Ghc.hsc_hooks hscEnv) }
  else
    pure hscEnv
  where
    modifyHooks includeParserErrors filePathMods hooks =
      let runPhaseOrExistingHook :: Ghc.TPhase res -> IO res
          runPhaseOrExistingHook = maybe Ghc.runPhase (\(Ghc.PhaseHook h) -> h)
            $ Ghc.runPhaseHook hooks
          phaseHook :: Ghc.PhaseHook
          phaseHook = Ghc.PhaseHook $ \phase -> do
            let tcWarnings :: Ghc.Messages Ghc.GhcMessage
                tcWarnings = case phase of
                  Ghc.T_HscPostTc _ _ _ msgs _ -> msgs
                  _ -> mempty
            dsWarnVar <- newIORef mempty
            try (runPhaseOrExistingHook $ addDsLogHook (logHookHack dsWarnVar hscEnv) phase) >>= \case
              Left err@(Ghc.SourceError msgs) -> do
                handleMessages includeParserErrors filePathMods msgs
                throw err
              Right res -> do
                dsWarns <- readIORef dsWarnVar
                case phase of
                  Ghc.T_HscPostTc _ modSummary _ _ _ ->
                    if Ghc.isEmptyMessages dsWarns
                    then atomically $ do
                      -- Module compiled without errors or warnings so delete map entry if exists
                      let modFile = Ghc.ms_hspp_file modSummary
                      entryDeleted <- stateTVar globalErrMap $ \m ->
                        let (mOld, m') = Map.updateLookupWithKey (\_ _ -> Nothing) modFile m
                        in (isJust mOld, m')
                      when entryDeleted $
                        writeTVar globalErrsUpdated True
                    else handleMessages includeParserErrors filePathMods $
                      if length tcWarnings == length dsWarns
                      then tcWarnings -- has preferred formatting
                      else dsWarns
                  _ -> pure ()
                pure res
       in hooks
            { Ghc.runPhaseHook = Just phaseHook }

addDsLogHook :: (Ghc.LogAction -> Ghc.LogAction) -> Ghc.TPhase res -> Ghc.TPhase res
addDsLogHook logHook = \case
  Ghc.T_HscPostTc hscEnv a b c d ->
    Ghc.T_HscPostTc (addHook hscEnv) a b c d
  x -> x
  where
    addHook hscEnv = hscEnv { Ghc.hsc_logger = Ghc.pushLogHook logHook $ Ghc.hsc_logger hscEnv }

-- | Get a textual representation of the diagnostic in GCC format
formatDiagnostic :: [T.Text -> T.Text] -> Ghc.MsgEnvelope Ghc.GhcMessage -> Maybe T.Text
formatDiagnostic filePathMods m = do
  severity <- case Ghc.errMsgSeverity m of
    Ghc.SevIgnore -> Nothing
    -- ^ Ignore this message, for example in case of suppression of warnings
    -- users don't want to see.
    Ghc.SevWarning -> Just "warning"
    Ghc.SevError -> Just "error"
  startLoc <- Ghc.realSrcSpanStart <$> Ghc.srcSpanToRealSrcSpan (Ghc.errMsgSpan m)
  let diag = Ghc.errMsgDiagnostic m
      opts = (Ghc.defaultDiagnosticOpts @Ghc.GhcMessage)
        { Ghc.tcMessageOpts = (Ghc.defaultDiagnosticOpts @Ghc.TcRnMessage)
          { Ghc.tcOptsShowContext = False -- Omit all the additional stuff
          }
        }
      ctx = Ghc.defaultSDocContext
        { Ghc.sdocStyle = Ghc.mkErrStyle (Ghc.errMsgContext m)
        , Ghc.sdocCanUseUnicode = True
        }

      file = TE.decodeUtf8 . Ghc.bytesFS $ Ghc.srcLocFile startLoc
      line = Ghc.srcLocLine startLoc
      col = Ghc.srcLocCol startLoc
      truncateMsg txt =
        let truncated = T.take 201 txt
        in if T.length truncated > 200 then T.dropEnd 1 truncated <> "…" else txt
      msg = truncateMsg . T.intercalate " • "
        $ T.unwords . T.words . T.pack
        . Ghc.renderWithContext ctx
        <$> filter (not . Ghc.isEmpty ctx) (Ghc.unDecorated (Ghc.diagnosticMessage opts diag))

  -- filename:line:column: error: message
  Just $ foldl' (flip ($)) file filePathMods
    <> ":" <> T.pack (show line) <> ":" <> T.pack (show col) <> ": "
    <> severity <> ": " <> msg

-- | Update state given all diagnostics for a module
handleMessages
  :: Bool
  -> [T.Text -> T.Text]
  -> Ghc.Messages Ghc.GhcMessage
  -> IO ()
handleMessages includeParserErrors filePathMods messages = do
  let envelopes = Ghc.getMessages messages
      isParseError = \case
        Ghc.GhcPsMessage{} -> True
        _ -> False
      -- Filter out parse errors unless explicitly included
      errs = mapMaybe (formatDiagnostic filePathMods)
             -- sort to match the order of appearance in GHCi
           . List.sortOn
               ( Ord.Down . fmap (\s -> (Ghc.srcSpanStartLine s, Ghc.srcSpanStartCol s))
               . Ghc.srcSpanToRealSrcSpan . Ghc.errMsgSpan
               )
           . filter (\env -> includeParserErrors || not (isParseError (Ghc.errMsgDiagnostic env)))
           $ Ghc.bagToList envelopes
      First mFile =
        foldMap'
          (First . fmap Ghc.unpackFS . Ghc.srcSpanFileName_maybe . Ghc.errMsgSpan)
          envelopes
  for_ mFile $ \file ->
    unless (null errs) $ do
      n <- atomicModifyIORef' globalCounter (\x -> let !nx = x + 1 in (nx, x))
      atomically $ do
        modifyTVar' globalErrMap (Map.insert file (Errors n errs))
        writeTVar globalErrsUpdated True

-- | Remove errors for files that no longer exist
pruneDeletedFiles :: [(FilePath, Errors)] -> IO [Errors]
pruneDeletedFiles errs = do
  let files = fst <$> errs
  deletedFiles <-
    (`foldMap'` files) $ \file ->
      Dir.doesFileExist file <&> \case
        True -> mempty
        False -> Set.singleton file
  atomically $ modifyTVar' globalErrMap (`Map.withoutKeys` deletedFiles)
  pure . fmap snd $ filter ((`Set.notMember` deletedFiles) . fst) errs

-- | Currently no good way to get warnings from desugarer, so a log action hook
-- is used to get the raw SDoc. Note: unfortunately this will also capture
-- warnings from the typechecker.
logHookHack :: IORef (Ghc.Messages Ghc.GhcMessage) -> Ghc.HscEnv -> Ghc.LogAction -> Ghc.LogAction
logHookHack dsWarnVar hscEnv logAction flags clss srcSpan sdoc = do
  case clss of
    Ghc.MCDiagnostic Ghc.SevWarning _ _ -> do
        let diag =
              Ghc.DiagnosticMessage
                { Ghc.diagMessage = Ghc.mkSimpleDecorated sdoc
                , Ghc.diagReason = Ghc.WarningWithoutFlag
                , Ghc.diagHints = []
                }
            diagOpts = Ghc.initDiagOpts $ Ghc.hsc_dflags hscEnv
            ghcMessage = Ghc.GhcDsMessage . Ghc.DsUnknownMessage $ Ghc.mkUnknownDiagnostic diag
            warn = Ghc.mkMsgEnvelope diagOpts srcSpan Ghc.neverQualify ghcMessage
        modifyIORef' dsWarnVar (Ghc.addMessage warn)
    _ -> pure ()
  logAction flags clss srcSpan sdoc