-- |
-- Module      : Data.String.Interpolate
-- Description : Unicode-aware string interpolation that handles all textual types.
-- Copyright   : (c) William Yao, 2019-2020
-- License     : BSD-3
-- Maintainer  : williamyaoh@gmail.com
-- Stability   : experimental
-- Portability : POSIX
--
-- This module provides three quasiquoters, `i', `__i', and `iii', which:
--
-- * handle all of String\/Text\/ByteString, both strict and lazy
-- * can interpolate /into/ anything that implements `IsString'
-- * can interpolate anything that implements `Show'
-- * are Unicode aware
-- * are fast
-- * handle multiline strings
--
-- `i' leaves newlines and whitespace intact as they are in the source
-- code. `__i' strips leading indentation and surrounding blank lines, while
-- leaving linebreaks intact. `iii' collapses newlines/whitespace into single
-- spaces, putting all the output on a single line.
--
-- As an example,
--
-- > {-# LANGUAGE OverloadedStrings #-}
-- >
-- > import Data.Text
-- > import Data.String.Interpolate ( i )
-- >
-- > λ> age = 33 :: Int
-- > λ> name = "Tatiana" :: Text
-- > λ> [i|{"name": "#{name}", "age": #{age}}|] :: String
-- > >>> "{\"name\": \"Tatiana\", \"age\": 33}"
-- >
-- > λ> [i|
-- > Name: #{name}
-- > Age: #{age}
-- > |] :: String
-- > >>> "\nName: Tatiana\nAge: 33\n"
--
-- See the README at <https://gitlab.com/williamyaoh/string-interpolate/blob/master/README.md>
-- for more details and examples.

{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE LambdaCase      #-}
{-# LANGUAGE CPP             #-}

module Data.String.Interpolate
  ( i, __i, iii )
where

import Prelude hiding ( fail )

import Data.Char ( isSpace )
import Data.Proxy
import Data.Function ( on, (&) )
import Data.Semigroup ( Min(..) )
import Data.List
import Data.List.Split

import Control.Monad.Fail

import qualified Language.Haskell.Exts.Extension as Ext
import           Language.Haskell.Exts.Parser
  ( ParseMode(..), ParseResult(..), defaultParseMode, parseExpWithMode )
import           Language.Haskell.Meta           ( ToExp(..) )
import           Language.Haskell.TH
import           Language.Haskell.TH.Quote       ( QuasiQuoter(..) )

import Data.String.Interpolate.Conversion ( build, finalize, interpolate, ofString )
import Data.String.Interpolate.Parse      ( InterpSegment(..), dosToUnix, parseInterpSegments )

--------------------
-- QUASIQUOTERS
--------------------

-- |
-- The basic, no-frills interpolator. Will interpolate anything you wrap in @#{}@, and
-- otherwise leaves what you write alone.
i :: QuasiQuoter
i = QuasiQuoter
  { quoteExp  = toExp . parseInterpSegments . dosToUnix
  , quotePat  = const $ errQQType "i" "pattern"
  , quoteType = const $ errQQType "i" "type"
  , quoteDec  = const $ errQQType "i" "declaration"
  }
  where toExp :: Either String [InterpSegment] -> Q Exp
        toExp parseResult = case parseResult of
          Left msg   -> errQQ "i" msg
          Right segs -> interpToExp segs

-- |
-- An interpolator that handles indentation. Will interpolate anything you wrap in @#{}@,
-- remove leading indentation, and remove any blank lines before and after the content.
--
-- If the contained interpolation uses both tabs and spaces for indentation, @__i@
-- will assume the indentation type it finds in the first nonblank line, ignoring
-- indentation of the other type. Please don't use mixed indentation.
--
-- Note that only indentation you actually write in source code will be stripped;
-- @__i@ does not touch any lines or whitespace inserted by interpolations themselves.
--
-- There is no extra performance penalty for using @__i@.
__i :: QuasiQuoter
__i = QuasiQuoter
  { quoteExp  = toExp . parseInterpSegments . dosToUnix
  , quotePat  = const $ errQQType "__i" "pattern"
  , quoteType = const $ errQQType "__i" "type"
  , quoteDec  = const $ errQQType "__i" "declaration"
  }
  where toExp :: Either String [InterpSegment] -> Q Exp
        toExp parseResult = case parseResult of
          Left msg   -> errQQ "__i" msg
          Right segs -> unindent segs >>= interpToExp

        unindent :: [InterpSegment] -> Q [InterpSegment]
        unindent segs =
          let lines = interpLines segs
              mindent = mindentation lines
          in warnMixedIndent mindent lines >>
             (pure $! (interpUnlines . removeBlanksAround . reduceIndents mindent) lines)

-- |
-- An interpolator that strips excess whitespace. Will collapse any sequences of
-- multiple spaces or whitespace into a single space, putting the output onto a
-- single line with surrounding whitespace removed.
--
-- Note that only whitespace you actually write in source code will be collapsed;
-- @iii@ does not touch any lines or whitespace inserted by interpolations themselves.
--
-- There is no extra performance penalty for using @iii@.
iii :: QuasiQuoter
iii = QuasiQuoter
  { quoteExp  = toExp . parseInterpSegments . dosToUnix
  , quotePat  = const $ errQQType "iii" "pattern"
  , quoteType = const $ errQQType "iii" "type"
  , quoteDec  = const $ errQQType "iii" "declaration"
  }
  where toExp :: Either String [InterpSegment] -> Q Exp
        toExp parseResult = case parseResult of
          Left msg   -> errQQ "iii" msg
          Right segs -> collapse segs

        collapse :: [InterpSegment] -> Q Exp
        collapse segs = renderOutput segs
          & collapseStrings
          & fmap outputCollapseWS
          & removeWSAround
          & outputToExp

        outputCollapseWS :: OutputSegment -> OutputSegment
        outputCollapseWS (OfString str) = OfString $ collapseWhitespace str
        outputCollapseWS other          = other

--------------------
-- STRIPPING INDENTATION
--------------------

interpLines :: [InterpSegment] -> [[InterpSegment]]
interpLines = split $ dropDelims $ whenElt (== Newline)

interpUnlines :: [[InterpSegment]] -> [InterpSegment]
interpUnlines = intercalate [Newline]

data Mindent = UsesSpaces Int | UsesTabs Int

mindentation :: [[InterpSegment]] -> Mindent
mindentation lines =
  let nonblank = filter (not . blankLine) lines
      withIndent = find (\case { Spaces _ : _ -> True; Tabs _ : _ -> True; _ -> False }) nonblank
  in case withIndent of
      Nothing -> UsesSpaces 0
      Just (Spaces _ : _) ->
        maybe (UsesSpaces 0) UsesSpaces $
          findMinIndent (\case { Spaces n -> Just n; _ -> Nothing }) Nothing nonblank
      Just (Tabs _ : _) ->
        maybe (UsesSpaces 0) UsesTabs $
          findMinIndent (\case { Tabs n -> Just n; _ -> Nothing }) Nothing nonblank
      Just _ -> UsesSpaces 0
  where findMinIndent :: (InterpSegment -> Maybe Int) -> Maybe Int -> [[InterpSegment]] -> Maybe Int
        findMinIndent _ found [] = found
        findMinIndent f found ((seg:_):rest) =
          findMinIndent f (getMin <$> on mappend (fmap Min) (f seg) found) rest
        findMinIndent f found ([]:rest) = findMinIndent f found rest

warnMixedIndent :: Mindent -> [[InterpSegment]] -> Q ()
warnMixedIndent mindent = go 1 . removeBlanksAround
  where go :: Int -> [[InterpSegment]] -> Q ()
        go _lineno [] = pure ()
        go lineno (line:lines) = do
          let ind = indentation line
          case (mindent, any isSpaces ind, any isTabs ind) of
            (UsesSpaces _, _, True) ->
              reportWarning $
                "splice line " ++ show lineno ++ ": found TAB character in indentation"
            (UsesTabs _, True, _) ->
              reportWarning $
                "splice line " ++ show lineno ++ ": found SPACE character in indentation"
            _ -> pure ()
          go (lineno+1) lines

        indentation :: [InterpSegment] -> [InterpSegment]
        indentation =
          takeWhile (\case { Spaces _ -> True; Tabs _ -> True; _ -> False })

        isSpaces :: InterpSegment -> Bool
        isSpaces (Spaces n) = n > 0
        isSpaces _          = False

        isTabs :: InterpSegment -> Bool
        isTabs (Tabs n) = n > 0
        isTabs _        = False

reduceIndents :: Mindent -> [[InterpSegment]] -> [[InterpSegment]]
reduceIndents _ [] = []
reduceIndents i@(UsesSpaces indent) ((Spaces n:line):rest) =
  (Spaces (n-indent):line) : reduceIndents i rest
reduceIndents i@(UsesTabs indent) ((Tabs n:line):rest) =
  (Tabs (n-indent):line) : reduceIndents i rest
reduceIndents i (line:rest) = line : reduceIndents i rest

removeBlanksAround :: [[InterpSegment]] -> [[InterpSegment]]
removeBlanksAround =
    reverse
  . dropWhile blankLine
  . reverse
  . dropWhile blankLine

blankLine :: [InterpSegment] -> Bool
blankLine [] = True
blankLine (Expression _ : _) = False
blankLine (Newline : rest) = blankLine rest
blankLine (Spaces _ : rest) = blankLine rest
blankLine (Tabs _ : rest) = blankLine rest
blankLine (Verbatim str:rest) = blank str && blankLine rest
  where blank :: String -> Bool
        blank = all (\c -> elem c [' ', '\t'])

--------------------
-- COLLAPSING WHITESPACE
--------------------

byWhitespace :: String -> [String]
byWhitespace = split $ condense $ whenElt isSpace

collapseWhitespace :: String -> String
collapseWhitespace =
    foldMap (\s -> if all isSpace s && not (null s) then " " else s)
  . byWhitespace

-- Frankly this is a really ugly function; it feels too specific and like
-- we're making too many assumptions. But we need to strip that whitespace...
removeWSAround :: [OutputSegment] -> [OutputSegment]
removeWSAround =
    transformLeading (dropWhile isSpace)
  . reverse
  . transformLeading (reverse . dropWhile isSpace . reverse)
  . reverse
  where transformLeading :: (String -> String) -> [OutputSegment] -> [OutputSegment]
        transformLeading _ []                  = []
        transformLeading f (OfString str:rest) = OfString (f str) : rest
        transformLeading _ other               = other

--------------------
-- CONVERTING EXPRS
--------------------

interpToExp :: [InterpSegment] -> Q Exp
interpToExp = outputToExp . collapseStrings . renderOutput

outputToExp :: [OutputSegment] -> Q Exp
outputToExp segs = [|finalize Proxy $(go segs)|]
  where renderExp :: OutputSegment -> Q Exp
        renderExp (OfString str) = [|ofString Proxy str|]
        renderExp (Interpolate expr) = [|interpolate Proxy $(reifyExpression expr)|]

        go :: [OutputSegment] -> Q Exp
        go = foldr
          (\seg qexp -> [|build Proxy $(renderExp seg) $(qexp)|])
          [|ofString Proxy ""|]

data OutputSegment
  = OfString String
  | Interpolate String

collapseStrings :: [OutputSegment] -> [OutputSegment]
collapseStrings [] = []
collapseStrings (OfString s1 : OfString s2 : rest) =
  collapseStrings ((OfString $ s1 ++ s2) : rest)
collapseStrings (other : rest) = other : collapseStrings rest

renderOutput :: [InterpSegment] -> [OutputSegment]
renderOutput = fmap renderSegment
  where renderSegment :: InterpSegment -> OutputSegment
        renderSegment (Verbatim str)   = OfString str
        renderSegment Newline          = OfString "\n"
        renderSegment (Spaces n)       = OfString (replicate n ' ')
        renderSegment (Tabs n)         = OfString (replicate n '\t')
        renderSegment (Expression str) = Interpolate str

--------------------
-- UTILITIES
--------------------

errQQ :: MonadFail m => String -> String -> m a
errQQ qqName msg =
  fail ("Data.String.Interpolate." ++ qqName ++ ": " ++ msg)

errQQType :: MonadFail m => String -> String -> m a
errQQType qqName = errQQ qqName . ("This QuasiQuoter cannot be used as a " ++)

reifyExpression :: String -> Q Exp
reifyExpression s = do
  -- We want to explicitly use whatever extensions are enabled in current module
  exts      <- (fmap . fmap) (Ext.parseExtension . show) extsEnabled
  parseMode <- pure (defaultParseMode { extensions = exts })
  case parseExpWithMode parseMode s of
    ParseFailed _ err  -> fail $
      "Data.String.Interpolate.i: got error: '" ++ err ++ "' while parsing expression: " ++ s
    ParseOk e -> pure (toExp e)
