packages feed

scrod-0.2026.2.21: source/library/Scrod/Ghc/Parse.hs

{-# LANGUAGE TemplateHaskellQuotes #-}

-- | Parse Haskell source into a GHC AST without a full GHC session.
--
-- Bootstraps a minimal set of GHC internals ('DynFlags.empty',
-- 'ParserOpts.fromExtensions') to run the GHC lexer and parser
-- stand-alone. The pipeline is:
--
-- 1. Discover @LANGUAGE@ pragmas and extensions from the source.
-- 2. If CPP is enabled, preprocess with 'Cpp.cpp'.
-- 3. Parse into @HsModule GhcPs@ using GHC's parser.
module Scrod.Ghc.Parse where

import qualified Control.Monad.Catch as Exception
import qualified Data.Bifunctor as Bifunctor
import qualified GHC.Data.EnumSet as EnumSet
import qualified GHC.Data.FastString as FastString
import qualified GHC.Data.StringBuffer as StringBuffer
import qualified GHC.Driver.DynFlags as DynFlags
import qualified GHC.Driver.Flags as Flags
import qualified GHC.Driver.Session as Session
import qualified GHC.Hs.Extension as Ghc
import qualified GHC.LanguageExtensions.Type as Extension
import qualified GHC.Parser as Parser
import qualified GHC.Parser.Header as Header
import qualified GHC.Parser.Lexer as Lexer
import qualified GHC.Types.SourceError as SourceError
import qualified GHC.Types.SrcLoc as SrcLoc
import qualified GHC.Utils.Logger as Logger
import qualified GHC.Utils.Outputable as Outputable
import qualified Language.Haskell.Syntax as Hs
import qualified Scrod.Cabal as Cabal
import qualified Scrod.Cpp as Cpp
import qualified Scrod.Ghc.ArchOS as ArchOS
import qualified Scrod.Ghc.DynFlags as DynFlags
import qualified Scrod.Ghc.OnOff as OnOff
import qualified Scrod.Ghc.ParserOpts as ParserOpts
import qualified Scrod.Spec as Spec
import qualified System.IO.Unsafe as Unsafe

parse ::
  Bool ->
  [String] ->
  String ->
  Either
    String
    ( (Maybe Session.Language, [DynFlags.OnOff Extension.Extension]),
      SrcLoc.Located (Hs.HsModule Ghc.GhcPs)
    )
parse isSignature extraOptions string = do
  let originalStringBuffer = StringBuffer.stringToStringBuffer string
  let cabalOptions = cabalExtensionOptions string
  let options = fmap SrcLoc.noLoc $ extraOptions <> cabalOptions
  languageAndExtensions <- Bifunctor.first Exception.displayException $ discoverExtensions options originalStringBuffer
  let extensions = uncurry resolveExtensions languageAndExtensions
  source <-
    if EnumSet.member Extension.Cpp extensions
      then Cpp.cpp string
      else Right string
  let modifiedStringBuffer = StringBuffer.stringToStringBuffer source
  let parserOpts = ParserOpts.fromExtensions extensions
  let fastString = FastString.fsLit interactiveFilePath
  let realSrcLoc = SrcLoc.mkRealSrcLoc fastString 1 1
  let pState = Lexer.initParserState parserOpts modifiedStringBuffer realSrcLoc
  let parser = if isSignature then Parser.parseSignature else Parser.parseModule
  case Lexer.unP parser pState of
    Lexer.PFailed newPState -> Left . Outputable.showSDocUnsafe . Outputable.ppr $ Lexer.getPsErrorMessages newPState
    Lexer.POk _ lHsModule -> pure (languageAndExtensions, lHsModule)

cabalExtensionOptions :: String -> [String]
cabalExtensionOptions = fmap ("-X" <>) . Cabal.discoverExtensions

discoverExtensions ::
  [SrcLoc.Located String] ->
  StringBuffer.StringBuffer ->
  Either
    SourceError.SourceError
    (Maybe Flags.Language, [DynFlags.OnOff Extension.Extension])
discoverExtensions cabalOptions =
  Unsafe.unsafePerformIO
    . Exception.try
    . discoverExtensionsIO cabalOptions

discoverExtensionsIO ::
  [SrcLoc.Located String] ->
  StringBuffer.StringBuffer ->
  IO (Maybe Flags.Language, [DynFlags.OnOff Extension.Extension])
discoverExtensionsIO cabalOptions stringBuffer = do
  logger <- Logger.initLogger
  (dynFlags, _, _) <- Session.parseDynamicFilePragma logger DynFlags.empty $ cabalOptions <> discoverOptions stringBuffer
  pure (DynFlags.language dynFlags, DynFlags.extensions dynFlags)

discoverOptions :: StringBuffer.StringBuffer -> [SrcLoc.Located String]
discoverOptions stringBuffer =
  snd $ Header.getOptions ParserOpts.empty supportedLanguages stringBuffer interactiveFilePath

supportedLanguages :: [String]
supportedLanguages = Session.supportedLanguagesAndExtensions ArchOS.empty

interactiveFilePath :: FilePath
interactiveFilePath = "<interactive>"

resolveExtensions ::
  Maybe Session.Language ->
  [DynFlags.OnOff Extension.Extension] ->
  EnumSet.EnumSet Extension.Extension
resolveExtensions =
  foldr (OnOff.onOff EnumSet.insert EnumSet.delete)
    . EnumSet.fromList
    . Session.languageExtensions

spec :: (Applicative m, Monad n) => Spec.Spec m n -> n ()
spec s = do
  Spec.named s 'parse $ do
    Spec.it s "succeeds with empty input" $ do
      Spec.assertEq s (fst <$> parse False [] "") $ Right (Nothing, [])

    Spec.it s "fails with invalid input" $ do
      Spec.assertEq s (fst <$> parse False [] "!") $ Left "{Resolved: ErrorWithoutFlag\n ErrorWithoutFlag\n   parse error on input `!'}"

    Spec.it s "fails with unknown language extension" $ do
      Spec.assertEq s (fst <$> parse False [] "{-# language Unknown #-}") $ Left "<interactive>:1:14: error: [GHC-46537]\n    Unsupported extension: Unknown"

    Spec.it s "succeeds with a language" $ do
      Spec.assertEq s (fst <$> parse False [] "{-# language Haskell98 #-}") $ Right (Just Session.Haskell98, [])

    Spec.it s "succeeds with an enabled extension" $ do
      Spec.assertEq s (fst <$> parse False [] "{-# language CPP #-}") $ Right (Nothing, [Session.On Extension.Cpp])

    Spec.it s "succeeds with a disabled extension" $ do
      Spec.assertEq s (fst <$> parse False [] "{-# language NoCPP #-}") $ Right (Nothing, [Session.Off Extension.Cpp])

    Spec.it s "succeeds with a signature" $ do
      Spec.assertEq s (fst <$> parse True [] "signature Foo where") $ Right (Nothing, [])

    Spec.it s "succeeds with cabal script header extension" $ do
      Spec.assertEq s (fst <$> parse False [] "{- cabal:\ndefault-extensions: CPP\n-}") $ Right (Nothing, [Session.On Extension.Cpp])

    Spec.it s "succeeds with extra options" $ do
      Spec.assertEq s (fst <$> parse False ["-XOverloadedStrings"] "") $ Right (Nothing, [Session.On Extension.OverloadedStrings])

    Spec.it s "succeeds with cabal script header and LANGUAGE pragma" $ do
      Spec.assertEq s (fst <$> parse False [] "{- cabal:\ndefault-extensions: CPP\n-}\n{-# language OverloadedStrings #-}") $ Right (Nothing, [Session.On Extension.OverloadedStrings, Session.On Extension.Cpp])