packages feed

scripths-0.5.0.0: src/ScriptHs/Parser.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Parsing for @.ghci@ scripts understood by ScriptHs.

A script is a sequence of lines, optionally preceded by cabal metadata
directives that declare dependencies, language extensions, and GHC options.

Example script:

@
-- cabal: build-depends: containers
-- cabal: default-extensions: OverloadedStrings
import qualified Data.Map as M

M.fromList (zip [1..10] [2,4..])
@
-}
module ScriptHs.Parser (
    ScriptFile (..),
    CabalMeta (..),
    CompileDirective (..),
    SourceRepoPin (..),
    Line (..),
    parseScript,
    parseScriptNumbered,
    mergeMetas,
) where

import Data.Maybe
import Data.Text (Text)
import qualified Data.Text as T

import ScriptHs.Version (TagStyle (ScriptTag), parseTagLine)

{- | A fully parsed script, consisting of aggregated cabal metadata and an
ordered list of code lines.
-}
data ScriptFile = ScriptFile
    { scriptMeta :: CabalMeta
    -- ^ Aggregated metadata from all @-- cabal:@ directives in the script.
    , scriptCompile :: Maybe CompileDirective
    -- ^ The first @-- compile@ directive in the script, if any.
    , scriptLines :: [Line]
    -- ^ The code lines of the script, in order, with metadata lines removed.
    }
    deriving (Show, Eq)

{- | A @-- compile@ directive marking a script (notebook cell) as compiled:
its declarations are destined for a generated module rather than the GHCi
prompt. @-- compile: Some.Module@ names the target module explicitly.
-}
data CompileDirective
    = CompileDefault
    | CompileNamed Text
    deriving (Show, Eq)

{- | Cabal metadata extracted from @-- cabal:@ directives.

Multiple directives of the same kind are merged:

@
-- cabal: build-depends: text
-- cabal: build-depends: containers
@

produces @CabalMeta { metaDeps = [\"text\", \"containers\"], ... }@.
-}
data CabalMeta = CabalMeta
    { metaDeps :: [Text]
    -- ^ Packages from @build-depends@ directives.
    , metaExts :: [Text]
    -- ^ Extensions from @default-extensions@ directives.
    , metaGhcOptions :: [Text]
    -- ^ Flags from @ghc-options@ directives.
    , metaPackages :: [Text]
    {- ^ Extra local package dirs from @packages@ directives, resolved relative
    to the script file (lets a notebook depend on a second, non-enclosing local
    package, e.g. @-- cabal: packages: ../sibling@).
    -}
    , metaSourceRepos :: [SourceRepoPin]
    -- ^ Pinned git packages from @source-repository-package@ directives.
    , metaUnknownKeys :: [Text]
    -- ^ Unrecognised @-- cabal:@ directive keys (surfaced as warnings).
    }
    deriving (Show, Eq)

{- | A git @source-repository-package@ pin, declared with

@
-- cabal: source-repository-package: <location> <ref> [subdir]
@

so documentation can build against a pinned, pushed commit/tag (reproducible
and commit-safe, unlike a local working-tree path).
-}
data SourceRepoPin = SourceRepoPin
    { srpLocation :: Text
    -- ^ Repository location (e.g. a git URL).
    , srpRef :: Text
    -- ^ Commit hash or tag (becomes cabal's @tag:@).
    , srpSubdir :: Maybe Text
    -- ^ Optional subdirectory within the repository.
    }
    deriving (Show, Eq)

-- | A single logical line from the script body.
data Line
    = -- | An empty or whitespace-only line.
      Blank
    | -- | A GHCi directive, e.g. @:set -XOverloadedStrings@ or a @:{@ \/ @:}@ block.
      GhciCommand Text
    | -- | A GHC language pragma, e.g. @{\-# LANGUAGE OverloadedStrings #-\}@.
      Pragma Text
    | -- | A Haskell import declaration, e.g. @import Data.Text (Text)@.
      Import Text
    | -- | Any other Haskell source line.
      HaskellLine Text
    deriving (Show, Eq)

{- | Parse a ScriptHs script from 'Text'.

Returns a 'ScriptFile' on success, or a human-readable error message on
failure.

Example:

>>> parseScript "-- cabal: build-depends: text\nimport Data.Text (Text)\n"
ScriptFile {scriptMeta = CabalMeta {metaDeps = ["text"], metaExts = [], metaGhcOptions = [], metaPackages = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = [Import "import Data.Text (Text)"]}

>>> parseScript ""
ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = [], metaPackages = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = []}
-}
parseScript :: Text -> ScriptFile
parseScript = fst . parseScriptNumbered

{- | Like 'parseScript', but also returns the code lines paired with their
1-based line numbers in the original input (directive lines are removed from
the stream but the numbering of the survivors is preserved). Used to emit
@{\-# LINE #-\}@ pragmas that point back at the original source.
-}
parseScriptNumbered :: Text -> (ScriptFile, [(Int, Line)])
parseScriptNumbered input =
    let numberedRaw = zip [1 ..] (T.lines input)
        afterTag = dropLeadingVersionTag numberedRaw
        parsed = [(i, parseLine t) | (i, t) <- afterTag]
        metas = [m | (_, RawCabalMeta m) <- parsed]
        compileDir = case [d | (_, RawCompile d) <- parsed] of
            (d : _) -> Just d
            [] -> Nothing
        code = [(i, c) | (i, RawCode c) <- parsed]
        sf =
            ScriptFile
                { scriptMeta = mergeMetas metas
                , scriptCompile = compileDir
                , scriptLines = map snd code
                }
     in (sf, code)

{- | Drop a leading scripths version tag (the first non-blank line, when it is
one) so it is not emitted into the repl. Recognised via the single parser in
"ScriptHs.Version" — so an arbitrary @-- scripths:@ comment elsewhere in the body
is left untouched, and the recognizer cannot drift from the one used to read the
tag.
-}
dropLeadingVersionTag :: [(Int, Text)] -> [(Int, Text)]
dropLeadingVersionTag ls = case span (isBlank . snd) ls of
    (blanks, (_, tag) : rest) | isJust (parseTagLine ScriptTag tag) -> blanks ++ rest
    _ -> ls
  where
    isBlank = T.null . T.strip

data RawLine
    = RawCabalMeta CabalMeta
    | RawCompile CompileDirective
    | RawCode Line

mergeMetas :: [CabalMeta] -> CabalMeta
mergeMetas ms =
    CabalMeta
        { metaDeps = concatMap metaDeps ms
        , metaExts = concatMap metaExts ms
        , metaGhcOptions = concatMap metaGhcOptions ms
        , metaPackages = concatMap metaPackages ms
        , metaSourceRepos = concatMap metaSourceRepos ms
        , metaUnknownKeys = concatMap metaUnknownKeys ms
        }

parseLine :: Text -> RawLine
parseLine line
    | Just meta <- parseCabalMeta line = RawCabalMeta meta
    | Just dir <- parseCompileDirective line = RawCompile dir
    | otherwise = RawCode (parseCodeLine line)

{- | Recognize a @-- compile@ \/ @-- compile: Some.Module@ directive line.
A comment merely starting with the word (e.g. @-- compiled fast@) is not a
directive; the name is taken verbatim (validation happens downstream).
-}
parseCompileDirective :: Text -> Maybe CompileDirective
parseCompileDirective line = do
    rest <- T.stripPrefix "--" (T.stripStart line)
    after <- T.stripPrefix "compile" (T.stripStart rest)
    let a = T.strip after
    if T.null a
        then Just CompileDefault
        else CompileNamed . T.strip <$> T.stripPrefix ":" a

parseCabalMeta :: Text -> Maybe CabalMeta
parseCabalMeta line = do
    rest <- T.stripPrefix "-- cabal:" line
    let rest' = T.stripStart rest
    case T.break (== ':') rest' of
        (key, colonAndValue) | not (T.null colonAndValue) -> do
            let value = T.stripStart (T.drop 1 colonAndValue)
                items = map T.strip (T.splitOn "," value)
            pure $ case T.strip key of
                "build-depends" -> emptyCabal{metaDeps = items}
                "default-extensions" -> emptyCabal{metaExts = items}
                "ghc-options" -> emptyCabal{metaGhcOptions = items}
                "packages" -> emptyCabal{metaPackages = items}
                "source-repository-package" ->
                    emptyCabal{metaSourceRepos = maybeToList (parseSourceRepo value)}
                other -> emptyCabal{metaUnknownKeys = [other]}
        _ -> Nothing
  where
    emptyCabal =
        CabalMeta
            { metaDeps = []
            , metaExts = []
            , metaGhcOptions = []
            , metaPackages = []
            , metaSourceRepos = []
            , metaUnknownKeys = []
            }

{- | Parse a @source-repository-package@ value: whitespace-separated
@\<location\> \<ref\> [subdir]@ (not comma-separated like the other directives).
-}
parseSourceRepo :: Text -> Maybe SourceRepoPin
parseSourceRepo value = case T.words value of
    (loc : ref : rest) ->
        Just
            SourceRepoPin
                { srpLocation = loc
                , srpRef = ref
                , srpSubdir = case rest of
                    (sub : _) -> Just sub
                    [] -> Nothing
                }
    _ -> Nothing

parseCodeLine :: Text -> Line
parseCodeLine line
    | isBlankLine line = Blank
    | Just cmd <- parseGhciCommand line = GhciCommand cmd
    | Just pragma <- parsePragma line = Pragma pragma
    | Just imp <- parseImport line = Import imp
    | Just stripped <- stripTopLevelLet line = HaskellLine stripped
    | otherwise = HaskellLine (rewriteSplice line)

{- | Strip @let@ from top-level bindings.
  GHCi's @:{...}@ blocks don't support @let@ at the top level.
  Preserves @let ... in ...@ expressions and indented @let@ (inside do/where).
-}
stripTopLevelLet :: Text -> Maybe Text
stripTopLevelLet line = do
    rest <- T.stripPrefix "let " line
    if " in " `T.isInfixOf` rest
        then Nothing
        else Just rest

isBlankLine :: Text -> Bool
isBlankLine = T.null . T.strip

parseGhciCommand :: Text -> Maybe Text
parseGhciCommand line =
    let stripped = T.stripStart line
     in case T.uncons stripped of
            Just (':', rest) -> Just (":" <> rest)
            _ -> Nothing

parsePragma :: Text -> Maybe Text
parsePragma line =
    if "{-#" `T.isPrefixOf` line
        then Just line
        else Nothing

parseImport :: Text -> Maybe Text
parseImport line =
    case T.stripPrefix "import" line of
        Just rest
            | not (T.null rest) && (T.head rest == ' ' || T.head rest == '\t') ->
                Just line
        _ -> Nothing

-- https://discourse.haskell.org/t/injecting-variables-into-a-ghci-session/12558/2?u=mchav
rewriteSplice :: Text -> Text
rewriteSplice line =
    case T.stripPrefix "$(" line >>= T.stripSuffix ")" of
        Just inner -> "_ = (); " <> T.strip inner
        Nothing -> line