tilia-0.0.1.0: src/Tilia/Render/Literal.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
-- | String literals.
module Tilia.Render.Literal
( stringLiteral,
)
where
import Control.Applicative ((<|>))
import Control.Monad ((>=>))
import Data.List (find)
import Data.Semigroup (Min (..))
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Data.FastString (FastString, unpackFS)
import GHC.Parser.CharClass (is_space)
import Tilia.Doc.Combinators
import Tilia.Render.Layout (Place (..), places)
-- | A string literal, from the text the author wrote.
stringLiteral :: FastString -> Doc
stringLiteral src = case takeApart (T.pack (unpackFS src)) of
Nothing -> error ("Tilia: unparsable string literal: " <> show src)
Just literal -> align (renderLiteral literal)
renderLiteral :: Literal -> Doc
renderLiteral literal =
txt (litOpen literal) <> body <> txt (litClose literal)
where
body = case litKind literal of
Regular -> variant onOneLine acrossLines
Multiline -> sepBy (verbatimBreak AtIndent) (map txt (litParts literal))
onOneLine = txt (joinParts (litParts literal))
acrossLines =
sepBy breakOrSpace (map continued (places (litParts literal)))
continued (place, s) = case place of
Only -> txt s
First -> txt s <> txt "\\"
Middle -> txt "\\" <> txt s <> txt "\\"
Last -> txt "\\" <> txt s
----------------------------------------------------------------------------
-- Taking a literal apart
-- | A literal split into the bits that may be laid out separately.
data Literal = Literal
{ litOpen :: Text,
litClose :: Text,
litKind :: LiteralKind,
-- | For a regular literal, the runs between string gaps; for a
-- multi-line one, the lines.
litParts :: [Text]
}
deriving (Eq, Show)
data LiteralKind
= Regular
| Multiline
deriving (Eq, Show)
takeApart :: Text -> Maybe Literal
takeApart s = do
literal <-
stripMarkers Multiline "\"\"\"" s
<|> stripMarkers Regular "\"" s
let split = case litKind literal of
Regular -> runsBetweenGaps
Multiline -> splitMultiline
pure literal {litParts = concatMap split (litParts literal)}
-- | Peel the quotes off, allowing for the @#@ that marks an unlifted
-- literal.
stripMarkers :: LiteralKind -> Text -> Text -> Maybe Literal
stripMarkers litKind marker s = do
inner <- T.stripPrefix marker s
litClose <- find (`T.isSuffixOf` inner) [marker <> "#", marker]
body <- T.stripSuffix litClose inner
pure Literal {litOpen = marker, litParts = [body], ..}
-- | The runs of a literal either side of its string gaps.
runsBetweenGaps :: Text -> [Text]
runsBetweenGaps s = case gapAt 0 s of
Nothing -> [s]
Just (before, after) -> T.take before s : runsBetweenGaps after
where
-- How much comes before the first gap, and what comes after it.
gapAt n t = case T.uncons t of
Nothing -> Nothing
Just ('\\', rest) -> case afterGap rest of
Just resumes -> Just (n, resumes)
Nothing -> let taken = 1 + escapedWidth rest in gapAt (n + taken) (T.drop taken t)
Just (_, rest) -> gapAt (n + 1) rest
-- Where the literal picks up again, if this backslash opened a gap.
afterGap t = case T.span is_space t of
(blank, rest)
| not (T.null blank), Just ('\\', resumes) <- T.uncons rest -> Just resumes
_ -> Nothing
-- How much follows the backslash of an escape that is not a gap. Only
-- @\\^X@ reaches past the character after the backslash; the numeric
-- escapes run on further, but their digits are not backslashes and do
-- not need skipping.
escapedWidth t = case T.uncons t of
Just ('^', _) -> 2
Just _ -> 1
Nothing -> 0
-- | Split a multi-line literal the way GHC's lexer reads one, so that what
-- comes back out means what went in.
splitMultiline :: Text -> [Text]
splitMultiline =
dropCommonIndent
. map expandTabs
. splitLines
. joinParts
. runsBetweenGaps
-- | The line terminators the Report recognises, not merely @\\n@.
splitLines :: Text -> [Text]
splitLines = T.splitOn "\r\n" >=> T.split newlineish
where
newlineish c = c == '\n' || c == '\r' || c == '\f'
-- | Tabs advance to the next multiple of eight.
expandTabs :: Text -> Text
expandTabs = T.concat . go 0
where
go column s = case T.breakOn "\t" s of
(before, T.uncons -> Just (_, after)) ->
let reached = column + T.length before
fill = 8 - (reached `mod` 8)
in before : T.replicate fill " " : go (reached + fill) after
_ -> [s]
-- | Take the common indentation off every line but the first, and blank the
-- lines that were nothing but whitespace.
dropCommonIndent :: [Text] -> [Text]
dropCommonIndent = \case
[] -> []
firstLine : rest -> firstLine : trimmed
where
(indents, trimmed) = unzip (map measure rest)
common = maybe 0 getMin (mconcat indents)
measure l
| T.all is_space l = (Nothing, "")
| otherwise = (Just (Min (T.length (T.takeWhile is_space l))), T.drop common l)
-- | Rejoin runs with the smallest gap that keeps them apart.
--
-- The gap cannot simply be dropped: it is what stops the end of one run and
-- the start of the next from lexing as a single escape sequence, so
-- @\"\\65\\ \\0\"@ and @\"\\650\"@ are different strings.
joinParts :: [Text] -> Text
joinParts = T.intercalate "\\ \\"