morley-1.18.0: src/Morley/Util/Interpolate/Internal.hs
-- SPDX-FileCopyrightText: 2021 Oxhead Alpha
-- SPDX-License-Identifier: LicenseRef-MIT-OA
-- | Internal module exporting utilities for making string interpolation quasiquoters
module Morley.Util.Interpolate.Internal
( Transformation(..)
, Transformations
, transformationsPowerSet
, mkQuoter
, generateName
) where
import Prelude hiding (lift)
import Data.Char (isSpace)
import Data.List qualified as List
import Data.Set qualified as S
import Data.Text.Internal.Builder (Builder, fromLazyText, toLazyText)
import Data.Text.Lazy qualified as TL
import Fmt (build, fmt)
import Language.Haskell.TH (Exp, Name, Q, lookupValueName, mkName, reportWarning, varE)
import Language.Haskell.TH.Quote (QuasiQuoter(..))
import Text.ParserCombinators.ReadP (readP_to_S)
import Text.Read.Lex (lexChar)
-- | Datatype encoding transformations we apply to a quoter input and output.
-- Note the order is important! We first unescape, then unindent, then trim.
data Transformation
= Unescaping
| Unindenting
| Trimming
| Polymorphic
deriving stock (Eq, Ord, Enum, Bounded)
-- | A 'Set' of 'Transformation'
type Transformations = Set Transformation
-- | Used to generate quasi-quoters
transformationsPowerSet :: [Transformations]
transformationsPowerSet = toList $ S.powerSet $ S.fromAscList [minBound..maxBound]
-- | Generate TH 'Name' for a quoter based on a set of transformations
generateName :: Transformations -> Name
generateName ls = mkName $
t Unescaping "" "l" <> "i" <> t Trimming "t" ""
<> t Unindenting "u" "" <> t Polymorphic "" "b"
where t el tru fls = if S.member el ls then tru else fls
-- | Make a 'QuasiQuoter' using a given set of transformaions
mkQuoter :: Transformations -> QuasiQuoter
mkQuoter ts = QuasiQuoter
{ quoteExp = foldr go makeBuilder $ toList ts
, quotePat = \_ -> fail "Cannot be used at pattern position"
, quoteType = \_ -> fail "Cannot be used at type position"
, quoteDec = \_ -> fail "Cannot be used as declaration"
}
where
go Unescaping f = f <=< unescape
go Unindenting f = f . unindent . dropLeadingNewline
go Trimming f = f . trim
go Polymorphic f = \s -> [| fmt $(f s) |]
unescape :: String -> Q String
unescape ('\\':'#':xs) = ('\\':) . ('#':) <$> unescape xs
unescape ('\\':'&':xs) = unescape xs
unescape xs@('\\':c:cs) = case readP_to_S lexChar xs of
(ch, rest):_ -> (ch :) <$> unescape rest
[] -> do
reportWarning $ "Unrecognized escape sequence near '" <> takeWhile (not . isSpace) xs <> "'"
(c :) <$> unescape cs
unescape (c:cs) = (c :) <$> unescape cs
unescape [] = pure []
makeBuilder :: String -> Q Exp
makeBuilder = go mempty
where
go tacc ('\\':'#':xs) = go (tacc <> "#") xs
go tacc ('#':'{':xs)
| (expr, '}':ys) <- List.span (/='}') xs
= lookupValueName expr >>= \case
Just valueName ->
let txt = fmt tacc
ls = splitTextLazy (=='\n') txt
indent = length . last $ ls
in [| build txt <> indentF'' indent (build $(varE valueName)) <> $(go mempty ys) |]
Nothing -> fail $ "Variable '" ++ expr ++ "' is not in scope"
go tacc (c:cs) = case List.span (`notElem` ['\\', '#']) cs of
(str, rest) -> go (tacc <> build (c:str)) rest
go tacc [] = let txt = fmt @Text tacc in [| build txt |]
-- | A version of @Fmt.indentF'@ that doesn't indent the first line, and keeps the final line break
-- intact.
indentF'' :: Int -> Builder -> Builder
indentF'' indent bld = case splitTextLazy (=='\n') $ toLazyText bld of
(x :| xs) -> fromLazyText . TL.intercalate "\n" $ x : map addIndent xs
where
addIndent str | null str = str
| otherwise = spaces <> str
spaces = TL.replicate (fromIntegral indent) " "
splitTextLazy :: (Char -> Bool) -> TL.Text -> NonEmpty TL.Text
splitTextLazy p t = case nonEmpty $ TL.split p t of
Just ne -> ne
Nothing -> error "Morley.Util.Interpolate.splitTextLazy: the impossible happened"
dropLeadingNewline :: String -> String
dropLeadingNewline ('\n':xs) = xs
dropLeadingNewline xs = xs
unindent :: String -> String
unindent s =
case nonEmpty (filter (not . null) ls) of
Just ne ->
let minIndent = minimum $ indentOf <$> ne
-- See Note [lines/unlines] below about the use of 'intercalate'
in intercalate "\n" $ drop minIndent <$> ls
Nothing -> s
where
ls = split (=='\n') s
indentOf = length . takeWhile (== ' ')
-- See Note [lines/unlines] below about the use of this function
split :: (Char -> Bool) -> String -> [String]
split cond s = case break cond s of
(pfx, _:suf) -> pfx : split cond suf
(pfx, "") -> [pfx]
trim :: String -> String
trim = intercalate "\n" . List.dropWhileEnd (all (== ' '))
-- See Note [lines/unlines] below about the use of 'intercalate'
. dropWhile (all (== ' ')) . split (=='\n')
{- Note [lines/unlines]
~~~~~~~~~~~~~~~~~~~~
lines/unlines don't actually satisfy our requirements here due to
some edge case behaviour.
First of all, `lines` and `unlines` aren't strictly speaking inverse
of each other: `unlines` always adds a trailing newline. Consider:
>>> unlines . lines $ "hello\nworld"
"hello\nworld\n"
Furthermore, `lines` itself does "gobble" the trailing empty line, consider:
>>> lines $ "hello\nworld\n"
["hello","world"]
We actually need this to be `["hello","world",""]` for the unindenting
algorithm to work correctly (i.e. not unindent anything if the last indent is 0),
but that doesn't work with `lines` (also we either lose a trailing newline
or get an extra one with `unlines`). I could use `lines` with `trim`,
but for consistency chose not to.
Long story short, I use `split (=='\n')` and `Text.Lazy.split (=='\n')`
to get a list of lines, and `intercalate "\n"` to glue them back.
This has the behaviour we need.
- @lierdakil
-}