packages feed

skeletest-0.3.6: src/Skeletest/Internal/Spec/Output.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

module Skeletest.Internal.Spec.Output (
  reportGroup,
  reportTestInProgress,
  reportTestResultWithoutMessage,
  reportTestResultWithInlineMessage,
  reportTestResultWithBoxMessage,
  renderPrettyFailure,
  BoxSpec,
  BoxSpecContent (..),
  IndentLevel,
  indent,
) where

import Data.Maybe (listToMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
import Skeletest.Internal.Paths (readTestFile)
import System.Console.Terminal.Size qualified as Term
import System.IO qualified as IO
import UnliftIO.Exception (SomeException, try)

reportGroup :: IndentLevel -> Text -> IO ()
reportGroup lvl name = do
  Text.putStrLn $ indent lvl name

reportTestInProgress :: IndentLevel -> Text -> IO ()
reportTestInProgress lvl testName = do
  Text.putStr $ indent lvl (testName <> ": ")
  IO.hFlush IO.stdout

reportTestResultWithoutMessage :: Text -> IO ()
reportTestResultWithoutMessage testResultLabel = do
  Text.putStrLn testResultLabel

reportTestResultWithInlineMessage :: IndentLevel -> Text -> Text -> IO ()
reportTestResultWithInlineMessage lvl testResultLabel testResultMessage = do
  Text.putStrLn testResultLabel
  Text.putStrLn $ indent (lvl + 1) testResultMessage

reportTestResultWithBoxMessage :: Maybe (Term.Window Int) -> IndentLevel -> Text -> Text -> BoxSpec -> IO ()
reportTestResultWithBoxMessage termSize lvl testName testResultLabel box = do
  if null box
    then do
      Text.putStrLn testResultLabel
    else do
      Text.putStr "\r"
      Text.putStrLn $ drawBoxHeader lvl testName <> ": " <> testResultLabel
      Text.putStrLn $ drawBoxBody termSize box

type IndentLevel = Int

indentSize :: Int
indentSize = 4

indentWith :: Text -> IndentLevel -> Text -> Text
indentWith fill lvl = Text.intercalate "\n" . map (Text.replicate (lvl * indentSize) fill <>) . Text.splitOn "\n"

indent :: IndentLevel -> Text -> Text
indent = indentWith " "

-- | Render a test failure like:
--
-- @
-- At test/Skeletest/Internal/TestTargetsSpec.hs:19:
-- |
-- |           parseTestTargets input `shouldBe` Right (Just expected)
-- |                                   ^^^^^^^^
--
-- Right 1 ≠ Left 1
-- @
renderPrettyFailure ::
  -- | Message
  Text ->
  -- | Failure context
  [Text] ->
  -- | Call stack (file, line, startCol, endCol)
  [(FilePath, Int, Int, Int)] ->
  IO Text
renderPrettyFailure msg ctx callstack = do
  prettyStackTrace <- mapM renderCallLine . reverse $ callstack
  pure . Text.intercalate "\n\n" . concat $
    [ prettyStackTrace
    , if null ctx
        then []
        else [Text.intercalate "\n" $ reverse ctx]
    , [msg]
    ]
 where
  renderCallLine (path, lineNum, startCol, endCol) = do
    mLine <-
      try (readTestFile path) >>= \case
        Right srcFile
          | Just line <- getLineNum lineNum srcFile -> pure $ Right line
          | otherwise -> pure $ Left $ "<line does not exist: " <> path <> ":" <> show lineNum <> ">"
        Left (_ :: SomeException) -> pure $ Left $ "<could not open file: " <> path <> ">"
    let (srcLine, pointerLine) =
          case mLine of
            Right line ->
              ( line
              , Text.replicate (startCol - 1) " " <> Text.replicate (endCol - startCol) "^"
              )
            Left e -> (Text.pack e, "")

    pure . Text.intercalate "\n" $
      [ Text.pack path <> ":" <> (Text.pack . show) lineNum <> ":"
      , "│"
      , "│ " <> srcLine
      , "│ " <> pointerLine
      ]

  getLineNum n = listToMaybe . take 1 . drop (n - 1) . Text.lines

type BoxSpec = [BoxSpecContent]

data BoxSpecContent
  = BoxText Text
  | BoxHeader Text
  deriving (Show, Eq)

drawBoxHeader :: IndentLevel -> Text -> Text
drawBoxHeader lvl testName = "╭" <> Text.drop 2 (indentWith "─" lvl "") <> " " <> testName

drawBoxBody :: Maybe (Term.Window Int) -> BoxSpec -> Text
drawBoxBody termSize boxContents = Text.intercalate "\n" $ concatMap draw boxContents <> [footer]
 where
  termWidth = maybe 80 Term.width termSize
  width =
    maximum . (termWidth :) . flip concatMap boxContents $ \case
      BoxHeader s -> [Text.length s + indentSize + 2]
      BoxText s -> [Text.length line + 2 | line <- Text.lines s]

  footer = "╰" <> Text.replicate (width - 1) "─"

  draw = \case
    BoxHeader s ->
      [ drawLine ""
      , "╞═══ " <> s
      ]
    BoxText s -> map drawLine $ Text.lines s
  drawLine s = "│ " <> s