packages feed

skeletest-0.4.0: src/Skeletest/Internal/Utils/Term.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoFieldSelectors #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}

module Skeletest.Internal.Utils.Term (
  init,
  setANSISupport,

  -- * Global attributes
  Handle,
  width,
  stdout,
  stderr,
  supportsANSI,

  -- * Output helpers
  flush,
  output,
  outputN,
  outputErr,
  outputErrN,
  clearInPlace,
  outputInPlace,
) where

import Control.Exception (evaluate)
import Control.Monad (forM_, replicateM_)
import Data.Foldable (traverse_)
import Data.String.AnsiEscapeCodes.Strip.Text (stripAnsiEscapeCodes)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import GHC.IO.Handle qualified as IO
import System.Console.ANSI qualified as ANSI
import System.Console.Terminal.Size qualified as TermSize
import System.IO qualified as IO
import System.IO.Unsafe (unsafePerformIO)
import UnliftIO.MVar (MVar, modifyMVar_, newMVar, readMVar, withMVar)
import Prelude hiding (init)

data GlobalTermData = GlobalTermData
  { width :: Int
  , stdout :: Handle
  , stderr :: Handle
  }

newtype Handle = Handle {var :: MVar Handle'}

data Handle' = Handle'
  { handle :: IO.Handle
  , supportsANSI :: Bool
  , lastInPlaceOutput :: Maybe Text
  -- ^ See 'outputInPlace'
  }

globalTermData :: GlobalTermData
globalTermData = unsafePerformIO $ do
  width <- maybe 80 TermSize.width <$> TermSize.size
  stdout <- getHandle IO.stdout
  stderr <- getHandle IO.stderr
  pure GlobalTermData{..}
 where
  getHandle h = do
    handle <- IO.hDuplicate h
    supportsANSI <- ANSI.hSupportsANSI handle
    let lastInPlaceOutput = Nothing
    Handle <$> newMVar Handle'{..}
{-# NOINLINE globalTermData #-}

init :: IO ()
init = do
  -- Configure stdout/stderr globally, both for Term.output and for anything
  -- writing directly to stdout/stderr (e.g. user tests with --capture-output=off)
  forM_ [IO.stdout, IO.stderr] $ \h -> do
    IO.hSetEncoding h IO.utf8
    IO.hSetBuffering h IO.LineBuffering

  -- Make sure globalTermData is initialized
  _ <- evaluate globalTermData
  pure ()

-- Use the terminal width at the beginning of the test suite; don't
-- handle users changing terminal width in the middle right now
width :: Int
width = globalTermData.width

stdout :: Handle
stdout = globalTermData.stdout

stderr :: Handle
stderr = globalTermData.stderr

supportsANSI :: Handle -> IO Bool
supportsANSI (Handle hvar) = (.supportsANSI) <$> readMVar hvar

setANSISupport :: Bool -> IO ()
setANSISupport x =
  forM_ [globalTermData.stdout, globalTermData.stderr] $ \(Handle hvar) -> do
    modifyMVar_ hvar $ \h -> pure h{supportsANSI = x}

flush :: IO ()
flush = do
  forM_ [globalTermData.stdout, globalTermData.stderr] $ \(Handle hvar) -> do
    withMVar hvar $ \h -> IO.hFlush h.handle

output :: Text -> IO ()
output = outputWith globalTermData.stdout

outputN :: Text -> IO ()
outputN = outputNWith globalTermData.stdout

outputErr :: Text -> IO ()
outputErr = outputWith globalTermData.stderr

outputErrN :: Text -> IO ()
outputErrN = outputNWith globalTermData.stderr

outputWith :: Handle -> Text -> IO ()
outputWith (Handle hvar) s = modifyMVar_ hvar $ \h -> outputWith' h s

outputNWith :: Handle -> Text -> IO ()
outputNWith (Handle hvar) s = modifyMVar_ hvar $ \h -> outputNWith' h s

outputWith' :: Handle' -> Text -> IO Handle'
outputWith' h s = do
  Text.hPutStrLn h.handle s
  pure h{lastInPlaceOutput = Nothing}

outputNWith' :: Handle' -> Text -> IO Handle'
outputNWith' h s = do
  Text.hPutStr h.handle s
  IO.hFlush h.handle
  pure h{lastInPlaceOutput = Nothing}

-- | Same as 'outputN', except if called multiple times in a row, will update
-- the message in-place.
--
-- Should only be used when stdout supports ANSI, but this isn't checked.
--
-- Long-term, should probably be replaced with concurrent-output, but it's
-- currently hardcoded to IO.stdout.
outputInPlace :: Text -> IO ()
outputInPlace s = modifyMVar_ globalTermData.stdout.var $ \h0 -> do
  h1 <- clearInPlaceWith' h0
  h2 <- outputNWith' h1 s
  pure h2{lastInPlaceOutput = Just s}

-- | Clear last in-place output. See 'outputInPlace'.
clearInPlace :: IO ()
clearInPlace = modifyMVar_ globalTermData.stdout.var $ \h -> clearInPlaceWith' h

clearInPlaceWith' :: Handle' -> IO Handle'
clearInPlaceWith' h = do
  traverse_ clearLastInPlaceOutput h.lastInPlaceOutput
  pure h{lastInPlaceOutput = Nothing}
 where
  clearLastInPlaceOutput lastOutput = do
    let n = calculateNumLines lastOutput
    Text.hPutStr h.handle "\r\ESC[K" -- Clear the current line
    replicateM_ (n - 1) $ do
      -- Clear any lines above
      Text.hPutStr h.handle "\ESC[F\ESC[K"

  calculateNumLines =
    let calc = length . Text.chunksOf globalTermData.width . stripAnsiEscapeCodes
     in sum . map calc . Text.lines