packages feed

hwm-0.5.0: src/HWM/Runtime/UI.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE NoImplicitPrelude #-}

module HWM.Runtime.UI
  ( MonadUI (..),
    UIT,
    runUIT,
    runUI,
    putLine,
    indent,
    section_,
    sectionWorkspace,
    sectionEnvironments,
    sectionConfig,
    sectionTableM,
    forTable_,
    printSummary,
    uiFormatTable,
    forTable,
    statusTableM,
    section,
    uiSpace,
    uiRow,
    uiSubPath,
    uiSubPathRow,
    uiLabel,
    minRowSize,
    uiIndicator,
  )
where

import Control.Concurrent (threadDelay)
import Control.Monad.Error.Class (MonadError)
import Control.Monad.Except (MonadError (..))
import Data.List (groupBy, maximum, (!!))
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Text.IO as T
import HWM.Core.Common (Name)
import HWM.Core.Formatting (Color (..), Format (..), Status (..), chalk, indentBlockNum, minRowSize, padDots, renderSummaryStatus, statusIcon, subPathSign)
import HWM.Core.Options (whenCI)
import HWM.Core.Result
  ( Issue (..),
    IssueDetails (..),
    ResultT (..),
    Severity (..),
  )
import Relude
import System.Console.ANSI (clearLine, setCursorColumn)

data UIContext m = UIContext
  { uiWriter :: Text -> m (),
    uiIndent :: Int
  }

newtype UIT m a = UIT {_unUIT :: ReaderT (UIContext m) m a}
  deriving newtype (Functor, Applicative, Monad, MonadIO)

instance MonadTrans UIT where
  lift = UIT . lift

instance (MonadError err m) => MonadError err (UIT m) where
  throwError = UIT . lift . throwError
  catchError (UIT action) handler = UIT $ ReaderT $ \ctx ->
    catchError (runReaderT action ctx) (\e -> runReaderT (_unUIT (handler e)) ctx)

runUIT :: (Monad m) => (Text -> m ()) -> UIT m a -> m a
runUIT writer (UIT action) =
  let ctx = UIContext {uiWriter = writer, uiIndent = 0}
   in runReaderT action ctx

runUI :: UIT IO a -> IO a
runUI = runUIT (putStr . toString)

class (Monad m) => MonadUI m where
  uiWrite :: Text -> m ()
  uiIndentLevel :: m Int
  uiWithIndent :: (Int -> Int) -> m a -> m a

instance (Monad m) => MonadUI (UIT m) where
  uiWrite txt = UIT $ do
    UIContext {uiWriter} <- ask
    lift (uiWriter txt)
  uiIndentLevel = UIT $ asks uiIndent
  uiWithIndent f (UIT action) = UIT $ local adjust action
    where
      adjust ctx = ctx {uiIndent = f (uiIndent ctx)}

instance (MonadUI m) => MonadUI (ResultT m) where
  uiWrite txt = lift (uiWrite txt)
  uiIndentLevel = lift uiIndentLevel
  uiWithIndent f (ResultT action) = ResultT $ uiWithIndent f action

uiSpace :: (MonadUI m) => m ()
uiSpace = uiWrite "\n"

uiSubPath :: (MonadUI m, Format a) => a -> m ()
uiSubPath value = putLine $ subPathSign <> format value

uiSubPathRow :: (MonadUI m) => Int -> Text -> Text -> m ()
uiSubPathRow size key value = putLine $ subPathSign <> padDots size key <> value

uiRow :: (MonadUI m) => Int -> Text -> Text -> m ()
uiRow size key value = putLine $ padDots size key <> value

uiLabel :: (MonadUI m) => Text -> m ()
uiLabel name = putLine $ "• " <> chalk Bold name

putLine :: (MonadUI m) => Text -> m ()
putLine txt = do
  level <- uiIndentLevel
  uiWrite (indentBlockNum level (txt <> "\n"))

indent :: (MonadUI m) => Int -> m a -> m a
indent amount = uiWithIndent (+ amount)

sectionBase :: (MonadUI m) => Text -> Text -> m a -> m a
sectionBase emoji title action = do
  uiSpace
  putLine (emoji <> " " <> chalk Bold title)
  indent 1 action

section_ :: (MonadUI m) => Text -> m a -> m ()
section_ t m = sectionBase "•" t m $> ()

section :: (MonadUI m) => Text -> m a -> m a
section = sectionBase "•"

sectionWorkspace :: (MonadUI m) => m a -> m ()
sectionWorkspace m = sectionBase "./" "workspace" m $> ()

sectionEnvironments :: (MonadUI m) => Maybe Text -> m a -> m ()
sectionEnvironments title = section_ ("environments" <> maybe "" (\name -> chalk Dim " (default: " <> chalk Magenta name <> chalk Dim ")") title)

forHLTable :: (MonadUI m) => [a] -> (a -> (Name, m (Name, b))) -> m [(Name, b)]
forHLTable as f = traverse formatRow rows
  where
    rows = map f as
    maxLabelLen = maximum (minRowSize : map (T.length . fst) rows) + 2
    formatRow (label, valueM) = do
      (value, mvalue) <- valueM
      putLine $ padDots maxLabelLen label <> value
      pure (label, mvalue)

forTable :: (MonadUI m) => Name -> [a] -> (a -> (Name, m (Name, b))) -> m [(Name, b)]
forTable title as f = sectionBase "•" title $ forHLTable as f

forTable_ :: (MonadUI m) => [a] -> (a -> (Text, m Text)) -> m ()
forTable_ rows f = tableM_ (map f rows)

tableM_ :: (MonadUI m) => [(Text, m Text)] -> m ()
tableM_ rows = forHLTable rows (second ((,()) <$>)) $> ()

statusTableM :: (MonadUI m) => [(Text, m Status)] -> m ()
statusTableM = tableM_ . map (second render)
  where
    render s = statusIcon <$> s

sectionTableM :: (MonadUI m) => Text -> [(Text, m Text)] -> m ()
sectionTableM title = section_ title . tableM_

sectionConfig :: (MonadUI m) => [(Text, m Status)] -> m ()
sectionConfig = section_ "config" . tableM_ . map (second render)
  where
    render s = statusIcon <$> s

uiFormatTable :: (MonadUI m) => [[Text]] -> m ()
uiFormatTable rows =
  let n = if null rows then 0 else maximum (map length rows)
      padRow r = take n (r ++ repeat "")
      paddedRows = map padRow rows
      colWidths = [maximum (0 : map (T.length . (!! i)) paddedRows) | i <- [0 .. n - 1]]
      formatRow row =
        let padded = padRow row
            cells = zipWith (`T.justifyLeft` ' ') colWidths padded
         in putLine $ T.intercalate "  " cells
   in traverse_ formatRow rows

isError :: Issue -> Bool
isError i = issueSeverity i == SeverityError

renderSummaryLines :: [Issue] -> [Text]
renderSummaryLines [] = ["", renderSummaryStatus Checked, ""]
renderSummaryLines issues =
  let headerLine =
        if any isError issues
          then renderSummaryStatus Invalid
          else renderSummaryStatus Warning
      grouped = groupBy ((==) `on` issueTopic) (sortOn issueTopic issues)
      renderGroup [] = []
      renderGroup pkgIssues@(Issue {issueTopic = header} : _) =
        let l1 = "  "
            step = l1 <> subPathSign <> chalk Dim "• "
            l2 = l1 <> "    " <> subPathSign
            headerText = l1 <> chalk Dim "• " <> chalk Bold header
            renderMultilineIssue prefix continuation severity message =
              case T.lines message of
                [] -> [prefix]
                (line1 : restLines) ->
                  (prefix <> chalk (levelColor severity) line1)
                    : map (\line -> continuation <> chalk (levelColor severity) line) restLines
            renderIssue Issue {issueDetails = Nothing, issueSeverity, issueMessage} =
              renderMultilineIssue step (l1 <> "    ") issueSeverity issueMessage
            renderIssue Issue {issueDetails = Just (GenericIssue {issueFile}), issueSeverity, issueMessage} =
              renderMultilineIssue step (l1 <> "    ") issueSeverity issueMessage
                <> [l2 <> chalk Dim ("file: " <> format issueFile)]
            renderIssue Issue {issueDetails = Just (CommandIssue {issueCommand, issueLogFile}), issueSeverity, issueMessage} =
              let cmd =
                    if T.length issueCommand > 60
                      then T.take 60 issueCommand <> chalk Dim "..."
                      else issueCommand
               in [ step <> chalk (levelColor issueSeverity) (issueMessage <> ": ") <> cmd,
                    l2 <> chalk Dim ("logs: " <> format issueLogFile)
                  ]
            renderIssue Issue {issueDetails = Just (DependencyIssue {issueDependencies, issueFile}), issueSeverity, issueMessage} =
              let groupedDeps = Map.toList $ Map.fromListWith (++) [(scope, [(depName, actual, expected)]) | (scope, depName, actual, expected) <- issueDependencies]
                  depLines = concatMap formatGroup groupedDeps
                  formatGroup (scope, deps) =
                    (l2 <> chalk Dim "• " <> chalk Dim scope) : map formatDepLine deps
                  formatDepLine (depName, actual, expected) =
                    l1 <> "        " <> subPathSign <> depName <> ": " <> chalk Dim (actual <> " → " <> expected)
               in step <> chalk (levelColor issueSeverity) issueMessage
                    : l2 <> chalk Dim ("file: " <> format issueFile)
                    : depLines
            renderIssue Issue {issueDetails = Just (SourceInclusionIssue {issueComponent, issueTargets}), issueSeverity, issueMessage} =
              let targets = take 8 issueTargets
                  rest = length issueTargets - length targets
               in (step <> chalk (levelColor issueSeverity) issueMessage)
                    : (l2 <> chalk Dim ("component: " <> issueComponent <> " (" <> show (length issueTargets) <> ")"))
                    : map (\file -> l1 <> "        " <> subPathSign <> chalk Dim file) targets
                      <> [l1 <> "        " <> subPathSign <> chalk Dim ("… (+" <> show rest <> " more)") | rest > 0]
            detailLines = concatMap renderIssue pkgIssues
         in headerText : detailLines
   in ["", headerLine, ""] <> concatMap renderGroup grouped <> [""]

levelColor :: Severity -> Color
levelColor SeverityError = Red
levelColor SeverityWarning = Yellow

printSummary :: (MonadUI m, MonadIO m) => [Issue] -> m ()
printSummary issues = do
  uiSpace
  traverse_ putLine (renderSummaryLines issues) >> whenCI (traverse_ extractLog issues)
  where
    extractLog Issue {issueDetails = Just (CommandIssue {issueLogFile})} = do
      content <- liftIO $ T.readFile (toString issueLogFile)
      putLine $ chalk Dim ("--- CI logs for " <> format issueLogFile <> " ---")
      putLine content
    extractLog _ = pure ()

refreshFrame :: Text -> IO ()
refreshFrame f = do
  clearLine
  setCursorColumn 0
  putStr $ toString f
  hFlush stdout

loopFrames :: (Text -> Text) -> [Text] -> IO ()
loopFrames g = loop
  where
    loop (f : fs) = do
      refreshFrame (g f)
      threadDelay 200000
      loop (fs ++ [f])
    loop [] = pure ()

uiIndicator :: (MonadIO m) => (Text -> Text) -> Bool -> Maybe Status -> m ()
uiIndicator f False Nothing = liftIO $ refreshFrame $ f "►"
uiIndicator f True Nothing = liftIO $ loopFrames f ["◜", "◠", "◝", "◞", "◡", "◟"]
uiIndicator f _ (Just s) = liftIO $ refreshFrame $ f (statusIcon s)