packages feed

redact-0.4.0.0: src/Redact/Markdown.hs

------------------------------------------------------------------------------
-- |
-- Module      : Redact.Markdown
-- Description : Markdown redaction
-- Copyright   : Copyright (c) 2020-2022 Travis Cardwell
-- License     : MIT
--
-- Markdown inline code (text enclosed in backticks) is redacted.  This
-- redacted text may not span multiple lines.  Example:
--
-- @The word `hidden` is redacted.@
--
-- Markdown fenced code (text in between lines of three or more backticks is
-- redacted.  The number of backticks is not matched.  The backticks in the
-- beginning line may optionally be followed by addition text, which is
-- ignored.  Example:
--
-- @
-- ```
-- All lines of text between the backticks
-- are hidden.
-- ```
-- @
------------------------------------------------------------------------------

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}

module Redact.Markdown
  ( -- * Types
    Line(..)
  , Part(..)
  , Error(..)
    -- * Pure API
  , redact
  , redact'
    -- * Terminal API
  , textToTerminal
  , textToTerminal'
  , handleToTerminal
  , handleToTerminal'
  , fileToTerminal
  , fileToTerminal'
    -- * Internal
  , redactLine
  , redactLine'
  ) where

-- https://hackage.haskell.org/package/ansi-terminal
import qualified System.Console.ANSI as Term

-- https://hackage.haskell.org/package/base
import Control.Monad (guard)
import Prelude hiding (lines)
import System.IO (Handle)

-- https://hackage.haskell.org/package/text
import qualified Data.Text as T
import Data.Text (Text)

-- (redact)
import qualified Redact.Internal as Internal
import qualified Redact.Monad.Handle as RMH
import Redact.Monad.Handle (MonadHandle)
import qualified Redact.Monad.Terminal as RMT
import Redact.Monad.Terminal (MonadTerminal)
import Redact.Types
  ( Error(IOError, RedactError), Line(NormalLine, RedactLine)
  , Part(Redact, Stet)
  )

------------------------------------------------------------------------------
-- $PureAPI

-- | Redact text strictly
--
-- This function fails if inline or fenced code is not closed.
--
-- @since 0.4.0.0
redact :: Text -> Either String [Line]
redact = Internal.redact strictStep strictEnd initialStrictState

------------------------------------------------------------------------------

-- | Redact text leniently
--
-- This function does not fail if inline or fenced code is not closed.
--
-- @since 0.4.0.0
redact' :: Text -> [Line]
redact' = Internal.redact' lenientStep initialLenientState

------------------------------------------------------------------------------
-- $TerminalAPI

-- | Redact text strictly, putting it to the terminal
--
-- @since 0.4.0.0
textToTerminal
  :: MonadTerminal m
  => [Term.SGR]  -- ^ 'Term.SGR's for redacted text
  -> Text
  -> m (Either Error ())
textToTerminal sgrs
    = either (pure . Left . RedactError) (fmap Right . RMT.putLines sgrs)
    . redact

------------------------------------------------------------------------------

-- | Redact text leniently, putting it to the terminal
--
-- @since 0.4.0.0
textToTerminal'
  :: MonadTerminal m
  => [Term.SGR]  -- ^ 'Term.SGR's for redacted text
  -> Text
  -> m ()
textToTerminal' sgrs = RMT.putLines sgrs . redact'

------------------------------------------------------------------------------

-- | Redact text from a 'Handle' strictly, putting it to the terminal
--
-- @since 0.4.0.0
handleToTerminal
  :: (MonadHandle m, MonadTerminal m)
  => [Term.SGR]  -- ^ 'Term.SGR's for redacted text
  -> Handle
  -> m (Either Error ())
handleToTerminal =
    RMH.handleToTerminal strictStep strictEnd initialStrictState

------------------------------------------------------------------------------

-- | Redact text from a 'Handle' leniently, putting it to the terminal
--
-- @since 0.4.0.0
handleToTerminal'
  :: (MonadHandle m, MonadTerminal m)
  => [Term.SGR]  -- ^ 'Term.SGR's for redacted text
  -> Handle
  -> m ()
handleToTerminal' = RMH.handleToTerminal' lenientStep initialLenientState

------------------------------------------------------------------------------

-- | Redact text from a file strictly, putting it to the terminal
--
-- @since 0.4.0.0
fileToTerminal
  :: (MonadHandle m, MonadTerminal m)
  => [Term.SGR]  -- ^ 'Term.SGR's for redacted text
  -> FilePath
  -> m (Either Error ())
fileToTerminal = RMH.fileToTerminal strictStep strictEnd initialStrictState

------------------------------------------------------------------------------

-- | Redact text from a file leniently, putting it to the terminal
--
-- @since 0.4.0.0
fileToTerminal'
  :: (MonadHandle m, MonadTerminal m)
  => [Term.SGR]  -- ^ 'Term.SGR's for redacted text
  -> FilePath
  -> m (Either Error ())
fileToTerminal' = RMH.fileToTerminal' lenientStep initialLenientState

------------------------------------------------------------------------------
-- $Internal

-- | Redact a 'NormalLine' strictly
redactLine :: Text -> Maybe Line
redactLine line = do
    let ts = T.splitOn "`" line
    guard $ length ts `mod` 2 == 1
    pure $ redactLineParts ts

------------------------------------------------------------------------------

-- | Redact a 'NormalLine' leniently
redactLine' :: Text -> Line
redactLine' = redactLineParts . T.splitOn "`"

------------------------------------------------------------------------------

-- | Construct a 'NormalLine' from parts of a line split on @`@
redactLineParts :: [Text] -> Line
redactLineParts = NormalLine . go
  where
    go :: [Text] -> [Part]
    go (tStet:ts)
      | T.null tStet = goR [] ts
      | otherwise = goR [tStet] ts
    go [] = []

    goR :: [Text] -> [Text] -> [Part]
    goR acc (tRedact:ts)
      | T.null tRedact = goS ("`" : acc) ts
      | otherwise
          = Stet (T.concat . reverse $ "`" : acc)
          : Redact tRedact
          : goS [] ts
    goR acc []
      | null acc = []
      | otherwise = [Stet . T.concat $ reverse acc]

    goS :: [Text] -> [Text] -> [Part]
    goS acc (tStet:ts)
      | T.null tStet = goR ("`" : acc) ts
      | otherwise = goR (tStet : "`" : acc) ts
    goS acc []
      | null acc = []
      | otherwise = [Stet . T.concat $ reverse acc]

------------------------------------------------------------------------------

-- | Does a line begin a fenced code block?
--
-- A line that begins a fenced code block beings with three or more backticks.
isFenceBegin :: Text -> Bool
isFenceBegin = (>= 3) . T.length . T.takeWhile (== '`')

------------------------------------------------------------------------------

-- | Does a line end a fence code block?
--
-- A line that ends a fenced code block consists of three or more backticks.
isFenceEnd :: Text -> Bool
isFenceEnd = (&&) <$> (T.null . T.dropWhile (== '`')) <*> ((>= 3) . T.length)

------------------------------------------------------------------------------

-- | State for strict parsing
type StrictState = (Int, Bool)  -- (lineNum, isFenced)

------------------------------------------------------------------------------

-- | Initial 'StrictState'
initialStrictState :: StrictState
initialStrictState = (1, False)

------------------------------------------------------------------------------

-- | Parse the next line strictly
strictStep
  :: StrictState
  -> Text
  -> Either String (Line, StrictState)
strictStep (!lineNum, isFenced) t
    | isFenced = if isFenceEnd t
        then Right (NormalLine [Stet t], (lineNum + 1, False))
        else Right (RedactLine t, (lineNum + 1, True))
    | otherwise = if isFenceBegin t
        then Right (NormalLine [Stet t], (lineNum + 1, True))
        else case redactLine t of
          Just line -> Right (line, (lineNum + 1, False))
          Nothing -> Left inlineError
  where
    inlineError :: String
    inlineError = "inline code not terminated (line " ++ show lineNum ++ ")"

------------------------------------------------------------------------------

-- | Process the end of the input strictly
strictEnd :: StrictState -> Maybe String
strictEnd (!lineNum, isFenced)
    | isFenced = Just fencedError
    | otherwise = Nothing
  where
    fencedError :: String
    fencedError = "fenced code not terminated (line " ++ show lineNum ++ ")"

------------------------------------------------------------------------------

-- | State for lenient parsing
type LenientState = Bool  -- isFenced

------------------------------------------------------------------------------

-- Initial 'LenientState'
initialLenientState :: LenientState
initialLenientState = False

------------------------------------------------------------------------------

-- | Parse the next line leniently
lenientStep
  :: LenientState
  -> Text
  -> (Line, LenientState)
lenientStep isFenced t
    | isFenced = if isFenceEnd t
        then (NormalLine [Stet t], False)
        else (RedactLine t, True)
    | otherwise = if isFenceBegin t
        then (NormalLine [Stet t], True)
        else (redactLine' t, False)