tilia-0.1.3.0: src/Tilia/Pragma.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Pragma-related helpers.
module Tilia.Pragma
( movesPositions,
Defaults,
defaultsOf,
effectiveExtensions,
lookupExtension,
lookupEdition,
nameSwitch,
flagSwitches,
optionFlags,
pragmaFlags,
languageNames,
withoutSkippedComments,
sameInForce,
)
where
import Control.Monad ((<=<))
import Data.Char (isAlphaNum, isSpace)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe, maybeToList)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Data.FastString (fsLit)
import GHC.Driver.Session qualified as GHC
import GHC.LanguageExtensions.Type (Extension (..))
import GHC.Parser.Header (toArgs)
import GHC.Types.SrcLoc (mkRealSrcLoc, unLoc)
import Tilia.Utils (tshow)
-- | Recognize @{-# LINE #-}@ and @{-# COLUMN #-}@ pragmas.
movesPositions :: Text -> Bool
movesPositions = any positional . pragmaBodies
where
positional body =
T.toUpper (T.takeWhile (/= ' ') body) `elem` ["LINE", "COLUMN"]
-- | What a component puts in force before the pragmas of a module: its
-- @default-language@ and its @default-extensions@.
data Defaults = Defaults
{ -- | What it turns on and off on top of its edition, in order, and on top
-- of an edition the module names in its place.
defaultsSwitches :: [GHC.OnOff Extension],
-- | What its edition and those switches put in force, worked out once.
defaultsInForce :: [Extension]
}
deriving (Eq, Show)
-- | What a component puts in force, given the extensions of its edition and
-- what it switches on top of them, in order.
defaultsOf :: [Extension] -> [GHC.OnOff Extension] -> Defaults
defaultsOf edition switches = Defaults switches (foldl' switch edition switches)
-- | The extensions actually in force in a module.
--
-- As with GHC, the edition the header names last takes the place of the
-- component's, wherever it is named, and what the component and then the
-- header switch is switched on top of it.
effectiveExtensions ::
-- | What the component the module belongs to puts in force.
Defaults ->
-- | The module's source, read here for the @LANGUAGE@, @OPTIONS_GHC@ and
-- @OPTIONS@ pragmas of its header alone.
Text ->
[Extension]
effectiveExtensions defaults source =
foldl' switch inForce (concat switches)
where
(editions, switches) = unzip (fmap ofPragma (headerPragmaBodies source))
ofPragma body =
let (keyword, rest) = T.break (== ' ') body
in editionsAndSwitches (T.toUpper keyword) rest
inForce = case concat editions of
[] -> defaultsInForce defaults
named ->
foldl'
switch
(GHC.languageExtensions (Just (last named)))
(defaultsSwitches defaults)
-- | Do two orders of the same pragmas, each given by its name and body,
-- leave the same extensions on?
--
-- Where none of them turns an extension off, every order turns on the same.
sameInForce :: [(Text, Text)] -> [(Text, Text)] -> Bool
sameInForce a b = not (turnsOff a) || inForce a == inForce b
where
turnsOff = foldl' (switchWith (const id) (\_ _ -> True)) False . switches
inForce = foldl' (switchWith Set.insert Set.delete) Set.empty . switches
switches = concatMap (uncurry switchesOf)
-- | What a pragma of this name and body turns on and off.
switchesOf :: Text -> Text -> [GHC.OnOff Extension]
switchesOf name = snd . editionsAndSwitches name
-- | The editions a pragma of this name and body names, and what it turns on
-- and off.
editionsAndSwitches :: Text -> Text -> ([GHC.Language], [GHC.OnOff Extension])
editionsAndSwitches name body =
( mapMaybe (lookupEdition <=< T.stripPrefix "-X") flags,
concatMap flagSwitches flags
)
where
flags = pragmaFlags name body
-- | The flags a pragma of this name and body gives GHC: each name of a
-- @LANGUAGE@ pragma after @-X@, and the flags of an options pragma.
pragmaFlags :: Text -> Text -> [Text]
pragmaFlags name body = case name of
"LANGUAGE" -> ["-X" <> n | n <- languageNames body]
"OPTIONS_GHC" -> optionFlags body
"OPTIONS" -> optionFlags body
_ -> []
-- | The flags of the body of an options pragma, split as GHC splits them.
optionFlags :: Text -> [Text]
optionFlags body =
either (const (T.words body)) (fmap (T.pack . unLoc)) $
toArgs (mkRealSrcLoc (fsLit "") 1 1) (T.unpack body)
-- | The extension a name turns on, or off where it puts @No@ in front of one.
nameSwitch :: Text -> Maybe (GHC.OnOff Extension)
nameSwitch name = case T.stripPrefix "No" name >>= lookupExtension of
Just off -> Just (GHC.Off off)
Nothing -> GHC.On <$> lookupExtension name
-- | What a flag of an @OPTIONS_GHC@ or @OPTIONS@ pragma turns on and off.
flagSwitches :: Text -> [GHC.OnOff Extension]
flagSwitches flag
| Just name <- T.stripPrefix "-X" flag = maybeToList (nameSwitch name)
| flag == "-cpp" = [GHC.On Cpp]
| flag == "-fglasgow-exts" = GHC.On <$> GHC.glasgowExtsFlags
| flag == "-fno-glasgow-exts" = GHC.Off <$> GHC.glasgowExtsFlags
| Just name <- T.stripPrefix "-fno-" flag = GHC.Off <$> oldFlag name
| Just name <- T.stripPrefix "-f" flag = GHC.On <$> oldFlag name
| otherwise = []
where
oldFlag name = maybeToList (Map.lookup name extensionsByOldFlag)
-- | Turn an extension on or off in a list kept in the order extensions were
-- turned on in.
switch :: [Extension] -> GHC.OnOff Extension -> [Extension]
switch = switchWith turnOn (filter . (/=))
where
turnOn e acc = if e `elem` acc then acc else acc <> [e]
-- | Turn an extension on, and then what it implies, or turn it off alone, as
-- GHC does.
switchWith ::
-- | How to turn one extension on.
(Extension -> a -> a) ->
-- | How to turn one extension off.
(Extension -> a -> a) ->
-- | What is on so far.
a ->
-- | What to turn on or off.
GHC.OnOff Extension ->
a
switchWith on off acc = \case
GHC.On e ->
foldl' (switchWith on off) (on e acc) (Map.findWithDefault [] e impliedBy)
GHC.Off e -> off e acc
-- | What GHC turns on or off besides when it turns an extension on.
impliedBy :: Map Extension [GHC.OnOff Extension]
impliedBy =
Map.fromListWith
(flip (<>))
[(from, [implied]) | (from, implied) <- GHC.impliedXFlags]
-- | The extensions a @LANGUAGE@ pragma names, as written.
languageNames :: Text -> [Text]
languageNames = filter (not . T.null) . fmap T.strip . T.splitOn ","
-- | Parse the name of an edition of the language, such as @GHC2021@.
lookupEdition :: Text -> Maybe GHC.Language
lookupEdition name = Map.lookup name editionsByName
-- | Every edition of the language this compiler knows, by its name.
editionsByName :: Map Text GHC.Language
editionsByName =
Map.fromList [(tshow e, e) | e <- [minBound .. maxBound]]
-- | Parse an extension name.
lookupExtension :: Text -> Maybe Extension
lookupExtension name = Map.lookup name extensionsByName
-- | Every extension this compiler knows, by the name one writes in a
-- pragma.
extensionsByName :: Map Text Extension
extensionsByName = byFlagName GHC.xFlags
-- | Every extension an old @-f@ flag such as @-fbang-patterns@ turns on, by
-- the flag without its @-f@.
extensionsByOldFlag :: Map Text Extension
extensionsByOldFlag = byFlagName GHC.fLangFlags
-- | Extensions by the names of the flags that turn them on.
byFlagName :: [GHC.FlagSpec Extension] -> Map Text Extension
byFlagName flags =
Map.fromList [(T.pack (GHC.flagSpecName f), GHC.flagSpecFlag f) | f <- flags]
-- | What every @{-# … #-}@ in a module has between its braces, each on one
-- line.
pragmaBodies :: Text -> [Text]
pragmaBodies = scanPragmas past
where
past go t = case T.uncons t of
Just ('"', after) -> go (skipString after)
Just ('\'', after) -> go (skipCharacter after)
_ -> go (skipCode t)
skipString = inString . T.dropWhile (\c -> c /= '"' && c /= '\\')
inString t
| T.null t = t
| Just after <- T.stripPrefix "\\" t = skipString (T.drop 1 after)
| Just after <- T.stripPrefix "\"" t = after
| otherwise = skipString (T.drop 1 t)
skipCharacter t = case T.unpack (T.take 2 t) of
['\\', _] -> T.drop 1 (T.dropWhile (/= '\'') (T.drop 2 t))
[c, '\''] | c /= '\'' -> T.drop 2 t
_ -> t
skipCode t =
let (plain, rest) = T.break opens (T.drop 1 t)
before = maybe (T.head t) snd (T.unsnoc plain)
in if "'" `T.isPrefixOf` rest && inName before then skipCode rest else rest
opens c = c == '{' || c == '-' || c == '"' || c == '\''
inName c = isAlphaNum c || c == '_' || c == '\''
-- | What the @{-# … #-}@ of a module's header have between their braces, each
-- on one line: those above the first thing that is neither a comment, a
-- directive nor a pragma, which are all GHC reads.
headerPragmaBodies :: Text -> [Text]
headerPragmaBodies = scanPragmas past
where
past go t = case T.uncons t of
Just (c, _)
| isSpace c -> go (T.stripStart t)
| c == '#' -> go (T.dropWhile (/= '\n') t)
_ -> []
-- | The bodies of the pragmas of a module, each on one line, carrying on past
-- what is neither a comment nor a pragma as the given function does.
scanPragmas :: ((Text -> [Text]) -> Text -> [Text]) -> Text -> [Text]
scanPragmas past = go
where
go t
| T.null t = []
| Just body <- T.stripPrefix "{-#" t = case T.breakOn "#-}" body of
(_, after) | T.null after -> []
(inner, after) ->
T.unwords (T.words (withoutSkippedComments inner))
: go (T.drop 3 after)
| Just after <- T.stripPrefix "{-" t = go (skipBlock (1 :: Int) after)
| opensLineComment t = go (T.dropWhile (/= '\n') t)
| otherwise = past go t
-- | What a pragma holds between its @{-#@ and its @#-}@, without the
-- comments GHC skips in it: those of a @LANGUAGE@ pragma, which then comes
-- out on one line.
withoutSkippedComments :: Text -> Text
withoutSkippedComments inner
| T.any (\c -> c == '-' || c == '{') inner,
T.toUpper keyword == "LANGUAGE" =
T.unwords (T.words (withoutComments inner))
| otherwise = inner
where
keyword = T.takeWhile (not . isSpace) (T.stripStart inner)
-- | A text without its comments, each of which leaves a space behind.
withoutComments :: Text -> Text
withoutComments t
| T.null t = t
| Just after <- T.stripPrefix "{-" t =
" " <> withoutComments (skipBlock (1 :: Int) after)
| opensLineComment t = withoutComments (T.dropWhile (/= '\n') t)
| otherwise =
let (plain, rest) = T.break (\c -> c == '{' || c == '-') (T.drop 1 t)
in T.take 1 t <> plain <> withoutComments rest
-- | Does a line comment begin here?
opensLineComment :: Text -> Bool
opensLineComment t = case T.stripPrefix "--" t of
Nothing -> False
Just rest ->
maybe True (not . symbolic . fst) (T.uncons (T.dropWhile (== '-') rest))
where
symbolic c = c `elem` ("!#$%&*+./<=>?@\\^|-~:" :: String)
-- | What follows the end of a block comment this deep in comments.
skipBlock :: Int -> Text -> Text
skipBlock 0 t = t
skipBlock n t0 = inBlock (T.dropWhile (\c -> c /= '{' && c /= '-') t0)
where
inBlock t
| T.null t = t
| Just after <- T.stripPrefix "{-" t = skipBlock (n + 1) after
| Just after <- T.stripPrefix "-}" t = skipBlock (n - 1) after
| otherwise = skipBlock n (T.drop 1 t)