packages feed

cleveland-0.3.2: src/Test/Cleveland/Tasty/Internal/Report.hs

-- SPDX-FileCopyrightText: 2021 Oxhead Alpha
-- SPDX-License-Identifier: LicenseRef-MIT-OA

{-# OPTIONS_HADDOCK not-home #-}

-- | Pretty-printers for reports in Tasty logs.
module Test.Cleveland.Tasty.Internal.Report
  ( module Test.Cleveland.Tasty.Internal.Report
  ) where

import Data.Char (isPrint, isSpace)
import Data.Ix (Ix(inRange))
import Data.List qualified as List
import Data.Text (justifyRight)
import Data.Text.IO.Utf8 qualified as Utf8
import Fmt (Buildable(build), Doc, indentF, unlinesF, (+|), (|+))
import GHC.Stack (SrcLoc(..))
import System.Directory (makeRelativeToCurrentDirectory)
import System.IO.Error
  (IOError, catchIOError, ioError, isAlreadyInUseError, isDoesNotExistError, isPermissionError)
import Unsafe qualified

-- HLint wrongfully thinks we are not using these things from universum,
-- perhaps because of morley-prelude.
{-# ANN module ("HLint: ignore Use 'getCallStack' from Universum" :: Text) #-}
{-# ANN module ("HLint: ignore Use 'prettyCallStack' from Universum" :: Text) #-}


{-
Note [Cleveland & callstacks]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Cleveland's reports are very similar to Hedgehog's. They both:
  1. Display the source code line where the error occurred and some context around it.
  2. Display the error message right below the offending line.

However, there are some notable differences:
  1. Cleveland shows (by default) 5 lines before and after the error location,
     whereas Hedgehog is smarter and displays the full top-level declaration.
      * The number of lines to display can be configured with `--cleveland-context-lines`.
      * This decision was made because tasty tests are sometimes embedded in a (much)
        larger test tree, and displaying the whole top-level declaration would add
        a lot of clutter and make the report unreadable.

  2. Cleveland displays the full callstack at the bottom, with the filepath/line/column.
     This is helpful because in some editors/terminals, you can click on the filepath
     link and have the editor jump straight to that location in the source code.

  3. Hedgehog displays the source code for all callstack frames and annotates the top-most
     frame with the error message, whereas Cleveland displays only the bottom-most frame.

-}

-- | Pretty-prints an error by displaying:
--
-- * the source code where the error occurred
-- * the expression that threw the error
-- * an associated error message below said expression
-- * the error's callstack
formatError
  :: Natural -- ^ The number of source code lines to print before and after the location of the error
  -> CallStack -- ^ The error's callstack
  -> String -- ^ The error message to display below the expression that caused the error
  -> IO Doc
formatError contextLines cstack errMsg = do
  case getLocation cstack of
    Nothing -> pure $ build errMsg
    Just loc -> flip catchIOError handleIOError do
      path <- makeRelativeToCurrentDirectory (srcLocFile loc)
      fileLines <- numbered . lines <$> Utf8.readFile path

      let (beforeLines, errorLines, afterLines) = linesAround contextLines loc fileLines

      -- Calculate how many digits we need to display line numbers.
      -- If we're about to display lines 9 to 101, then we need 3 digits total.
      let lineNumberPadding =
            length . show @Text . maximum $ lineNumber <$>
              (beforeLines `appendNE` errorLines `prependNE` afterLines)
          appendNE xs ys = maybe ys (<> ys) $ nonEmpty xs
          prependNE (x :| xs) ys = x :| (xs <> ys)

      pure $ unlinesF $
        [ headerF lineNumberPadding path ]
        <> (srcLineF lineNumberPadding <$> beforeLines)
        <> (srcLineF lineNumberPadding <$> toList errorLines)
        <> errorMsgF lineNumberPadding loc errorLines errMsg
        <> (srcLineF lineNumberPadding <$> afterLines)
        <> ["", build (prettyCallStack cstack)]
  where
    handleIOError :: IOError -> IO Doc
    handleIOError err =
      if isAlreadyInUseError err || isDoesNotExistError err || isPermissionError err
        then pure $ unlinesF $
          [ errMsg
          , ""
          , prettyCallStack cstack
          ]
        else ioError err

getLocation :: CallStack -> Maybe SrcLoc
getLocation = fmap snd . safeLast . getCallStack
  where
    safeLast = \case
      [] -> Nothing
      l -> Just $ Unsafe.last l

data Line = Line
  { lineText :: Text
  , lineNumber :: Int
  }

-- | Attach line numbers to each line of source code.
numbered :: [Text] -> [Line]
numbered texts = uncurry Line <$> (texts `zip` [1..])

-- | Given a list of source code lines, returns:
--
-- * the @n@ lines that preceed the location of the error
-- * the line(s) where the error occurred
-- * the @n@ lines that follow the location of the error
linesAround :: Natural -> SrcLoc -> [Line] -> ([Line], NonEmpty Line, [Line])
linesAround (Unsafe.fromIntegral @_ @Int -> n) loc xs =
  ( beforeLines
  , Unsafe.fromJust $ nonEmpty errorLines -- <- errorLines is guaranteed to always have at least 1 line
  , afterLines
  )
  where
    beforeLines = filter (\l -> (srcLocStartLine loc - n , srcLocStartLine loc - 1) `inRange` (lineNumber l)) xs
    errorLines  = filter (\l -> (srcLocStartLine loc     , srcLocEndLine loc      ) `inRange` (lineNumber l)) xs
    afterLines  = filter (\l -> (srcLocEndLine loc + 1   , srcLocEndLine loc + n  ) `inRange` (lineNumber l)) xs

----------------------------------------------------------------------------
-- Formatters
----------------------------------------------------------------------------

verticalBorder :: Doc
verticalBorder = " ┃ "

headerF :: Int -> FilePath -> Doc
headerF lineNumberPadding path =
  indentF lineNumberPadding $
    " ┏━━ " +| path |+ " ━━━"

srcLineF :: Int -> Line -> Doc
srcLineF lineNumberPadding line =
  justifyRight lineNumberPadding ' ' (show $ lineNumber line)
     |+ verticalBorder +| build (lineText line)

errorMsgF :: Int -> SrcLoc -> NonEmpty Line -> String -> [Doc]
errorMsgF lineNumberPadding loc errorLines errMsg =
  indentF lineNumberPadding . addPadding <$>
    (carets : errLines)
  where
    (startColumn, endColumn) = caretColumns loc errorLines

    addPadding :: Doc -> Doc
    addPadding line =
      verticalBorder +| indentF (startColumn - 1) line

    carets :: Doc
    carets = build $ replicate (endColumn - startColumn) '^'

    errLines :: [Doc]
    errLines = List.lines errMsg <&> \line ->
      "| " +| build line

-- | Calculates the start and end columns for drawing carets (^).
--
-- If the expression that caused the error is in a single line,
-- then we can simply use the column numbers from 'SrcLoc'.
--
-- However, if the expression spans 2 or more lines, then it's a bit more complicated.
-- For example, say we have this expression:
--
-- > someFunc
-- >  arg
--
-- Then 'SrcLoc'\'s start column will be 1 and end column will be 5.
-- However, we actually want the carets to be displayed from columns 1 to 9, like so:
--
-- > someFunc
-- >  arg
-- > ^^^^^^^^
--
-- See more examples in the @TestSuite.Cleveland.Tasty.Report.Examples@ module.
caretColumns :: SrcLoc -> NonEmpty Line -> (Int, Int)
caretColumns loc errorLines =
  ( minimum (srcLocStartCol loc :| fmap lineStartColumn (tail errorLines))
  , maximum (srcLocEndCol loc :| fmap lineEndColumn (init errorLines))
  )
  where
    lineEndColumn :: Line -> Int
    lineEndColumn (Line text _) =
      1 + length (filter isPrint (toString text))

    lineStartColumn :: Line -> Int
    lineStartColumn (Line text _) =
      1 + length (takeWhile isSpace (toString text))