packages feed

patat-0.15.0.0: lib/Patat/PrettyPrint/Internal.hs

--------------------------------------------------------------------------------
-- | This is a small pretty-printing library.
{-# LANGUAGE DeriveFoldable             #-}
{-# LANGUAGE DeriveFunctor              #-}
{-# LANGUAGE DeriveTraversable          #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE RecordWildCards            #-}
module Patat.PrettyPrint.Internal
    ( Control (..)
    , Chunk (..)
    , Chunks
    , chunkToString
    , chunkLines

    , DocE (..)
    , chunkToDocE

    , Indentation (..)

    , Doc (..)
    , docToChunks

    , toString
    , dimensions
    , null

    , hPutDoc
    , putDoc
    , mkDoc
    , string
    ) where


--------------------------------------------------------------------------------
import           Control.Monad.Reader       (asks, local)
import           Control.Monad.RWS          (RWS, runRWS)
import           Control.Monad.State        (get, modify, state)
import           Control.Monad.Writer       (tell)
import           Data.Char.WCWidth.Extended (wcstrwidth)
import qualified Data.List                  as L
import           Data.String                (IsString (..))
import           Prelude                    hiding (null)
import qualified System.Console.ANSI        as Ansi
import qualified System.IO                  as IO


--------------------------------------------------------------------------------
-- | Control actions for the terminal.
data Control
    = ClearScreenControl
    | GoToLineControl Int
    deriving (Eq, Show)


--------------------------------------------------------------------------------
-- | Hyperlinks can be printed in multiple parts when formatting is used.  The
-- ID is used to tell which ones belong to the same link.
type HyperlinkID = Int


--------------------------------------------------------------------------------
-- | A simple chunk of text.  All ANSI codes are "reset" after printing.
data Chunk
    = StringChunk [Ansi.SGR] String
    | HyperlinkChunk HyperlinkID [Ansi.SGR] String String -- Codes, title, URL
    | NewlineChunk
    | ControlChunk Control
    deriving (Eq, Show)


--------------------------------------------------------------------------------
type Chunks = [Chunk]


--------------------------------------------------------------------------------
hPutChunk :: IO.Handle -> Chunk -> IO ()
hPutChunk h NewlineChunk = IO.hPutStrLn h ""
hPutChunk h (StringChunk codes str) = do
    Ansi.hSetSGR h (reverse codes)
    IO.hPutStr h str
    Ansi.hSetSGR h [Ansi.Reset]
hPutChunk h (HyperlinkChunk hid codes str url) = do
    Ansi.hSetSGR h (reverse codes)
    Ansi.hHyperlinkWithId h (show hid) url str
    Ansi.hSetSGR h [Ansi.Reset]
hPutChunk h (ControlChunk ctrl) = case ctrl of
    ClearScreenControl -> Ansi.hClearScreen h
    GoToLineControl l  -> Ansi.hSetCursorPosition h l 0


--------------------------------------------------------------------------------
chunkToString :: Chunk -> String
chunkToString NewlineChunk               = "\n"
chunkToString (StringChunk _ str)        = str
chunkToString (HyperlinkChunk _ _ str _) = str
chunkToString (ControlChunk _)           = ""


--------------------------------------------------------------------------------
-- | If two neighboring chunks have the same set of ANSI codes, we can group
-- them together.
optimizeChunks :: Chunks -> Chunks
optimizeChunks chunks = case chunks of
    (StringChunk c1 s1 : StringChunk c2 s2 : t)
        | c1 == c2 -> optimizeChunks (StringChunk c1 (s1 <> s2) : t)
        | otherwise ->
            StringChunk c1 s1 : optimizeChunks (StringChunk c2 s2 : t)
    (HyperlinkChunk i1 c1 s1 u1 : HyperlinkChunk i2 c2 s2 u2 : t)
        | i1 == i2 && c1 == c2 && u1 == u2 -> optimizeChunks $
            HyperlinkChunk i1 c1 (s1 <> s2) u1 : t
        | otherwise ->
            HyperlinkChunk i1 c1 s1 u1 :
            optimizeChunks (HyperlinkChunk i2 c2 s2 u2 : t)
    x : t -> x : optimizeChunks t
    [] -> []


--------------------------------------------------------------------------------
chunkLines :: Chunks -> [Chunks]
chunkLines chunks = case break (== NewlineChunk) chunks of
    (xs, _newline : ys) -> xs : chunkLines ys
    (xs, [])            -> [xs]


--------------------------------------------------------------------------------
data DocE d
    = String String
    | Softspace
    | Hardspace
    | Softline
    | Hardline
    | WrapAt
        { wrapAtCol :: Maybe Int
        , wrapDoc   :: d
        }
    | Ansi
        { ansiCode :: [Ansi.SGR] -> [Ansi.SGR]  -- ^ Modifies current codes.
        , ansiDoc  :: d
        }
    | Indent
        { indentFirstLine  :: Indentation [Chunk]
        , indentOtherLines :: Indentation [Chunk]
        , indentDoc        :: d
        }
    | Hyperlink String d
    | Control Control
    deriving (Functor)


--------------------------------------------------------------------------------
chunkToDocE :: Chunk -> DocE Doc
chunkToDocE chunk = case chunk of
    NewlineChunk              -> Hardline
    ControlChunk ctrl         -> Control ctrl
    StringChunk c1 s          -> Ansi (\c0 -> c1 ++ c0) $ Doc [String s]
    HyperlinkChunk _ c1 s url -> Ansi (\c0 -> c1 ++ c0) $ Doc
        [Hyperlink url $ Doc [String s]]


--------------------------------------------------------------------------------
newtype Doc = Doc {unDoc :: [DocE Doc]}
    deriving (Monoid, Semigroup)


--------------------------------------------------------------------------------
instance Show Doc where
    show = toString


--------------------------------------------------------------------------------
instance IsString Doc where
    fromString = string


--------------------------------------------------------------------------------
data DocEnv = DocEnv
    { -- | Most recent ones first
      deCodes     :: [Ansi.SGR]
    , -- | First-line indent not included
      deIndent    :: [Indentation [Chunk]]
    , -- | Wrap at columns
      deWrap      :: Maybe Int
    , -- | Hyperlink context
      deHyperlink :: Maybe (HyperlinkID, String)
    }


--------------------------------------------------------------------------------
data DocState = DocState
    { dsLineBuffer  :: LineBuffer
    , dsHyperlinkID :: Int
    }


--------------------------------------------------------------------------------
type DocM = RWS DocEnv Chunks DocState


--------------------------------------------------------------------------------
-- | Note that the lists here are reversed so we have fast append.
-- We also store the current length to avoid having to recompute it.
data LineBuffer = LineBuffer Int [Indentation [Chunk]] [Chunk]


--------------------------------------------------------------------------------
emptyLineBuffer :: LineBuffer
emptyLineBuffer = LineBuffer 0 [] []


--------------------------------------------------------------------------------
data Indentation a = Indentation Int a
    deriving (Foldable, Functor, Traversable)


--------------------------------------------------------------------------------
indentationToChunks :: Indentation [Chunk] -> [Chunk]
indentationToChunks (Indentation 0 c) = c
indentationToChunks (Indentation n c) = StringChunk [] (replicate n ' ') : c


--------------------------------------------------------------------------------
indentationWidth :: Indentation [Chunk] -> Int
indentationWidth (Indentation s c) =
    s + sum (map (wcstrwidth . chunkToString) c)


--------------------------------------------------------------------------------
bufferToChunks :: LineBuffer -> Chunks
bufferToChunks (LineBuffer _ ind chunks) = case chunks of
    [] -> concatMap indentationToChunks $ reverse $
        dropWhile emptyIndentation ind
    _ -> concatMap indentationToChunks (reverse ind) ++ reverse chunks
  where
    emptyIndentation (Indentation _ []) = True
    emptyIndentation _                  = False


--------------------------------------------------------------------------------
docToChunks :: Doc -> Chunks
docToChunks doc0 =
    let env0        = DocEnv [] [] Nothing Nothing
        state0      = DocState emptyLineBuffer 0
        ((), finalState, cs) = runRWS (go $ unDoc doc0) env0 state0 in
    optimizeChunks (cs <> bufferToChunks (dsLineBuffer finalState))
  where
    go :: [DocE Doc] -> DocM ()

    go [] = return ()

    go (String str : docs) = do
        chunk <- makeChunk str
        appendChunk chunk
        go docs

    go (Hyperlink url doc : docs) = do
        hid <- state $ \s ->
            let hid = dsHyperlinkID s in (hid, s {dsHyperlinkID = hid + 1})
        local (\env -> env {deHyperlink = Just (hid, url)}) (go $ unDoc doc)
        go docs

    go (Softspace : docs) = do
        hard <- softConversion Softspace docs
        go (hard : docs)

    go (Hardspace : docs) = do
        chunk <- makeChunk " "
        appendChunk chunk
        go docs

    go (Softline : docs) = do
        hard <- softConversion Softline docs
        go (hard : docs)

    go (Hardline : docs) = do
        buffer <- dsLineBuffer <$> get
        tell $ bufferToChunks buffer <> [NewlineChunk]
        ind <- asks deIndent
        let indw = sum $ map indentationWidth ind
        modify $ \s -> case docs of
            []    -> s {dsLineBuffer = emptyLineBuffer}
            _ : _ -> s {dsLineBuffer = LineBuffer indw ind []}
        go docs

    go (WrapAt {..} : docs) = do
        il <- asks $ sum . map indentationWidth . deIndent
        local (\env -> env {deWrap = fmap (+ il) wrapAtCol}) $ go (unDoc wrapDoc)
        go docs

    go (Ansi {..} : docs) = do
        local (\env -> env {deCodes = ansiCode (deCodes env)}) $
            go (unDoc ansiDoc)
        go docs

    go (Indent {..} : docs) = do
        local (\e -> e {deIndent = indentOtherLines : deIndent e}) $ do
            modify $ \s ->
                let LineBuffer w i c = dsLineBuffer s
                    w' = w + indentationWidth indentFirstLine in
                s {dsLineBuffer = LineBuffer w' (indentFirstLine : i) c}
            go (unDoc indentDoc)
        go docs

    go (Control ctrl : docs) = do
        tell [ControlChunk ctrl]
        go docs

    makeChunk :: String -> DocM Chunk
    makeChunk str = do
        codes <- asks deCodes
        mbHyperlink <- asks deHyperlink
        return $ case mbHyperlink of
            Just (hid, url) -> HyperlinkChunk hid codes str url
            Nothing         -> StringChunk codes str

    appendChunk :: Chunk -> DocM ()
    appendChunk c = modify $ \s ->
        let LineBuffer w i cs = dsLineBuffer s
            w' = w + wcstrwidth (chunkToString c) in
        s {dsLineBuffer = LineBuffer w' i (c : cs)}

    -- Convert 'Softspace' or 'Softline' to 'Hardspace' or 'Hardline'
    softConversion :: DocE Doc -> [DocE Doc] -> DocM (DocE Doc)
    softConversion soft docs = do
        mbWrapCol <- asks deWrap
        case mbWrapCol of
            Nothing     -> return hard
            Just maxCol -> do
                LineBuffer currentCol _ _ <- dsLineBuffer <$> get
                case nextWordLength docs of
                    Nothing                            -> return hard
                    Just l
                        | currentCol + 1 + l <= maxCol -> return Hardspace
                        | otherwise                    -> return Hardline
      where
        hard = case soft of
            Softspace -> Hardspace
            Softline  -> Hardline
            _         -> soft

    nextWordLength :: [DocE Doc] -> Maybe Int
    nextWordLength []                  = Nothing
    nextWordLength (String x : xs)
        | L.null x                     = nextWordLength xs
        | otherwise                    = Just (wcstrwidth x)
    nextWordLength (Hyperlink t _ : _) = Just (wcstrwidth t)
    nextWordLength (Softspace : xs)    = nextWordLength xs
    nextWordLength (Hardspace : xs)    = nextWordLength xs
    nextWordLength (Softline : xs)     = nextWordLength xs
    nextWordLength (Hardline : _)      = Nothing
    nextWordLength (WrapAt {..} : xs)  = nextWordLength (unDoc wrapDoc   ++ xs)
    nextWordLength (Ansi   {..} : xs)  = nextWordLength (unDoc ansiDoc   ++ xs)
    nextWordLength (Indent {..} : xs)  = nextWordLength (unDoc indentDoc ++ xs)
    nextWordLength (Control _ : _)     = Nothing


--------------------------------------------------------------------------------
toString :: Doc -> String
toString = concat . map chunkToString . docToChunks


--------------------------------------------------------------------------------
-- | Returns the rows and columns necessary to render this document
dimensions :: Doc -> (Int, Int)
dimensions doc =
    let ls = lines (toString doc) in
    (length ls, foldr max 0 (map wcstrwidth ls))


--------------------------------------------------------------------------------
null :: Doc -> Bool
null doc = case unDoc doc of [] -> True; _ -> False


--------------------------------------------------------------------------------
hPutDoc :: IO.Handle -> Doc -> IO ()
hPutDoc h = mapM_ (hPutChunk h) . docToChunks


--------------------------------------------------------------------------------
putDoc :: Doc -> IO ()
putDoc = hPutDoc IO.stdout


--------------------------------------------------------------------------------
mkDoc :: DocE Doc -> Doc
mkDoc e = Doc [e]


--------------------------------------------------------------------------------
string :: String -> Doc
string ""  = Doc []
string str = mkDoc $ String str  -- TODO (jaspervdj): Newline conversion?