packages feed

tilia-0.0.1.0: src/Tilia/Parser.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Turning source text into a syntax tree and a comment stream.
module Tilia.Parser
  ( ParsedModule (..),
    parseModule,
    parseConfiguration,
    ParseError (..),
    describeParseError,
    ParserConfig (..),
    defaultParserConfig,
    parserConfigFor,
    ghcLibParserVersion,
  )
where

import Data.Foldable (toList)
import Data.List (isSuffixOf, nub, sortOn)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Data.EnumSet qualified as EnumSet
import GHC.Data.FastString (mkFastString)
import GHC.Data.StringBuffer qualified as GHC
import GHC.Driver.Session qualified as GHC
import GHC.Hs (HsModule (..))
import GHC.Hs.Extension (GhcPs)
import GHC.LanguageExtensions.Type (Extension)
import GHC.Parser qualified as GHC
import GHC.Parser.Annotation (getLocA)
import GHC.Parser.Lexer qualified as GHC
import GHC.Types.Error qualified as GHC
import GHC.Types.SrcLoc qualified as GHC
import GHC.Unit.Module.Warnings (emptyWarningCategorySet)
import GHC.Utils.Error qualified as GHC
import GHC.Utils.Outputable qualified as GHC
import Tilia.Pragma (effectiveExtensions)
import Tilia.Source (Lines, Source, SourceType (..), Written (..), lineTexts, linesOf, sourceOf)
import Tilia.Span (Span (..))
import Tilia.Span.Ghc (spanOfReal)

-- | A module that parsed, together with the comments found in it.
data ParsedModule = ParsedModule
  { -- | The syntax tree, exactly as GHC produced it.
    pmModule :: HsModule GhcPs,
    -- | The module as its author wrote it.
    pmSource :: Source,
    -- | Whether GHC read this as a module or as a Backpack signature.
    pmSourceType :: SourceType,
    -- | The lines above the module that the parser never sees.
    --
    -- The lexer skips a @#!@ line, which puts it in no annotation and no
    -- node, so nothing downstream could put it back. Whatever empty line
    -- follows the last of them is kept too: it is what holds the module off
    -- the interpreter line, and it is the author's to decide.
    pmPrologue :: [Text],
    -- | Where the file header stops and the module proper begins, if the
    -- module has anything after its header.
    --
    -- GHC reads pragmas from the header and nowhere else, so this is the
    -- line that decides whether a @{-# … #-}@ is a pragma at all. One
    -- written below it has no effect on compilation, and hoisting it to the
    -- top of the module would change its meaning.
    pmHeaderEnd :: Maybe Span
  }

-- | Parse a module.
parseModule ::
  ParserConfig ->
  -- | Path, used only in positions reported back
  FilePath ->
  -- | The source
  Text ->
  Either ParseError ParsedModule
parseModule config path source =
  parseConfiguration config path (linesOf (Written source)) source

-- | Parse one configuration of a module.
--
-- The text to parse is one configuration of the module; the 'Written' text
-- is the module the author wrote, which is what every question about the
-- source is answered against. Without the preprocessor the two are the same
-- text and this is 'parseModule'.
parseConfiguration ::
  ParserConfig ->
  -- | Path, used only in positions reported back
  FilePath ->
  -- | The lines of the module as written, except for the lines that do not
  -- belong to this configuration
  Lines ->
  -- | The configuration of it to parse
  Text ->
  Either ParseError ParsedModule
parseConfiguration config path written source =
  case GHC.unP entryPoint initialState of
    GHC.PFailed pstate -> Left (whyNot pstate)
    GHC.POk pstate (GHC.L _ hsModule)
      | not (GHC.isEmptyMessages (GHC.getPsErrorMessages pstate)) ->
          Left (whyNot pstate)
      | otherwise ->
          Right
            ParsedModule
              { pmModule = hsModule,
                pmSource = sourceOf written (headerComments pstate) hsModule,
                pmSourceType = sourceType,
                pmPrologue = prologueOf (lineTexts written),
                pmHeaderEnd = headerEndOf hsModule
              }
  where
    -- Everything above the @signature@ keyword: the parser leaves those
    -- here rather than in the tree. A module's are in both.
    headerComments = concat . GHC.header_comments

    sourceType = sourceTypeOf path

    entryPoint = case sourceType of
      ModuleSource -> GHC.parseModule
      SignatureSource -> GHC.parseSignature

    whyNot pstate =
      case sortOn at (toList (GHC.getMessages (GHC.getPsErrorMessages pstate))) of
        m : _ -> ParseError {peSpan = GHC.errMsgSpan m, peProblem = saying m}
        [] ->
          ParseError
            { peSpan = GHC.mkSrcSpanPs (GHC.last_loc pstate),
              peProblem = "parse error"
            }

    at m = case GHC.srcSpanToRealSrcSpan (GHC.errMsgSpan m) of
      Just s -> (GHC.srcSpanStartLine s, GHC.srcSpanStartCol s)
      Nothing -> (maxBound, maxBound)

    saying =
      T.pack
        . GHC.showSDocUnsafe
        . GHC.vcat
        . GHC.unDecorated
        . GHC.diagnosticMessage GHC.NoDiagnosticOpts
        . GHC.errMsgDiagnostic

    config' =
      config
        { pcExtensions = withImplied (effectiveExtensions (pcExtensions config) source)
        }

    initialState =
      GHC.initParserState
        (parserOpts config')
        (GHC.stringToStringBuffer (T.unpack source))
        (GHC.mkRealSrcLoc (mkFastString path) 1 1)

-- | Close a set of extensions under what they imply.
withImplied :: [Extension] -> [Extension]
withImplied = settle . nub
  where
    settle es =
      let es' = nub (es <> concatMap implied es)
       in if length es' == length es then es else settle es'
    implied e = [to | (from, GHC.On to) <- GHC.impliedXFlags, from == e]

-- | Options to parse with.
parserOpts :: ParserConfig -> GHC.ParserOpts
parserOpts ParserConfig {pcExtensions} =
  GHC.mkParserOpts
    (EnumSet.fromList pcExtensions)
    quietDiagnostics
    False -- safe imports
    True -- keep Haddock tokens
    True -- keep ordinary comment tokens
    True -- let @LINE@ and @COLUMN@ pragmas move the source position

-- | Diagnostics are not reported, so the settings only have to be
-- well-formed.
quietDiagnostics :: GHC.DiagOpts
quietDiagnostics =
  GHC.DiagOpts
    { GHC.diag_warning_flags = EnumSet.empty,
      GHC.diag_fatal_warning_flags = EnumSet.empty,
      GHC.diag_custom_warning_categories = emptyWarningCategorySet,
      GHC.diag_fatal_custom_warning_categories = emptyWarningCategorySet,
      GHC.diag_warn_is_error = False,
      GHC.diag_reverse_errors = False,
      GHC.diag_max_errors = Nothing,
      GHC.diag_ppr_ctx = GHC.defaultSDocContext
    }

-- | The @#!@ lines a file begins with, and the empty line after them.
--
-- At most one empty line is taken: the rest would only be collapsed
-- wherever they were reproduced, so keeping them would be keeping a
-- distinction that cannot survive.
prologueOf :: [Text] -> [Text]
prologueOf ls = case span isShebang ls of
  ([], _) -> []
  (shebangs, rest) -> shebangs <> filter T.null (take 1 rest)
  where
    isShebang = T.isPrefixOf "#!"

-- | The start of the first thing that is not part of the header.
--
-- Imports and declarations are the only things that can end a header, and
-- either may come first, so both are consulted.
headerEndOf :: HsModule GhcPs -> Maybe Span
headerEndOf hsModule =
  foldl' earliest Nothing $
    map getLocA (hsmodImports hsModule)
      <> map getLocA (hsmodDecls hsModule)
  where
    earliest acc l = case GHC.srcSpanToRealSrcSpan l of
      Nothing -> acc
      Just s ->
        let this = spanOfReal s
         in Just (maybe this (keepEarlier this) acc)
    keepEarlier a b
      | (spanStartLine a, spanStartColumn a) <= (spanStartLine b, spanStartColumn b) = a
      | otherwise = b

-- | Why a module did not parse.
data ParseError = ParseError
  { -- | Where the parser gave up.
    peSpan :: GHC.SrcSpan,
    -- | GHC's rendered error message.
    peProblem :: Text
  }

-- | Present 'ParseError' in a human-friendly form.
describeParseError :: ParseError -> Text
describeParseError e =
  T.pack (GHC.showSDocUnsafe (GHC.ppr (peSpan e))) <> ": " <> peProblem e

-- | What the parser is allowed to accept.
newtype ParserConfig = ParserConfig
  { -- | Extensions to enable before parsing.
    pcExtensions :: [Extension]
  }

-- | What to parse with when there is no package to ask.
defaultParserConfig :: ParserConfig
defaultParserConfig = parserConfigFor []

-- | What to parse with, given whatever the package had to say.
parserConfigFor ::
  -- | What the package puts in force, or nothing if there is no package
  [Extension] ->
  -- | The resulting parser config
  ParserConfig
parserConfigFor package =
  ParserConfig
    { pcExtensions =
        if null package
          then GHC.languageExtensions (Just GHC.GHC2021)
          else package
    }

-- | What a file's name says it holds.
sourceTypeOf :: FilePath -> SourceType
sourceTypeOf path
  | ".hsig" `isSuffixOf` path = SignatureSource
  | otherwise = ModuleSource

-- | The version of @ghc-lib-parser@ this was built against.
ghcLibParserVersion :: String
ghcLibParserVersion = VERSION_ghc_lib_parser