packages feed

scripths 0.3.1.0 → 0.3.2.0

raw patch · 3 files changed

+359/−17 lines, 3 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ ScriptHs.Render: KAction :: Kind
+ ScriptHs.Render: KComment :: Kind
+ ScriptHs.Render: KDeclaration :: Kind
+ ScriptHs.Render: KIOBind :: Kind
+ ScriptHs.Render: KTHSplice :: Kind
+ ScriptHs.Render: LhsCode :: [Text] -> LhsBlock
+ ScriptHs.Render: LhsProse :: Text -> LhsBlock
+ ScriptHs.Render: ModuleParts :: [Text] -> [Text] -> [Text] -> [Text] -> ModuleParts
+ ScriptHs.Render: PBlank :: Piece
+ ScriptHs.Render: PGhciCommand :: Text -> Piece
+ ScriptHs.Render: PImport :: Text -> Piece
+ ScriptHs.Render: PPragma :: Text -> Piece
+ ScriptHs.Render: PUnit :: Kind -> [Line] -> Piece
+ ScriptHs.Render: TrailIOShow :: TrailKind
+ ScriptHs.Render: TrailIOUnit :: TrailKind
+ ScriptHs.Render: TrailPure :: TrailKind
+ ScriptHs.Render: TrailUnknown :: TrailKind
+ ScriptHs.Render: [mpDecls] :: ModuleParts -> [Text]
+ ScriptHs.Render: [mpImports] :: ModuleParts -> [Text]
+ ScriptHs.Render: [mpMain] :: ModuleParts -> [Text]
+ ScriptHs.Render: [mpPragmas] :: ModuleParts -> [Text]
+ ScriptHs.Render: actionExprs :: [Line] -> [Text]
+ ScriptHs.Render: classify :: Text -> [Text] -> Kind
+ ScriptHs.Render: data Kind
+ ScriptHs.Render: data LhsBlock
+ ScriptHs.Render: data ModuleParts
+ ScriptHs.Render: data Piece
+ ScriptHs.Render: data TrailKind
+ ScriptHs.Render: instance GHC.Base.Monoid ScriptHs.Render.ModuleParts
+ ScriptHs.Render: instance GHC.Base.Semigroup ScriptHs.Render.ModuleParts
+ ScriptHs.Render: instance GHC.Classes.Eq ScriptHs.Render.LhsBlock
+ ScriptHs.Render: instance GHC.Classes.Eq ScriptHs.Render.ModuleParts
+ ScriptHs.Render: instance GHC.Classes.Eq ScriptHs.Render.TrailKind
+ ScriptHs.Render: instance GHC.Show.Show ScriptHs.Render.LhsBlock
+ ScriptHs.Render: instance GHC.Show.Show ScriptHs.Render.ModuleParts
+ ScriptHs.Render: instance GHC.Show.Show ScriptHs.Render.TrailKind
+ ScriptHs.Render: lineText :: Line -> Text
+ ScriptHs.Render: renderCabalScript :: CabalMeta -> ModuleParts -> Text
+ ScriptHs.Render: renderCabalScriptHeader :: CabalMeta -> Text
+ ScriptHs.Render: renderLiterate :: [LhsBlock] -> Text
+ ScriptHs.Render: renderModuleText :: Maybe Text -> ModuleParts -> Text
+ ScriptHs.Render: toModule :: TrailingResolver -> [Line] -> ModuleParts
+ ScriptHs.Render: toPieces :: [Line] -> [Piece]
+ ScriptHs.Render: type TrailingResolver = Text -> TrailKind

Files

scripths.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               scripths-version:            0.3.1.0+version:            0.3.2.0 synopsis:           GHCi scripts for standalone execution and Markdown documentation. description:        GHCi scripts for standalone execution (with dependency resolution) and Markdown documentation (produces inline output). homepage:           https://www.datahaskell.org/
src/ScriptHs/Render.hs view
@@ -11,12 +11,32 @@ -} module ScriptHs.Render (     toGhciScript,++    -- * Module rendering (notebook → standalone Haskell)+    ModuleParts (..),+    TrailKind (..),+    TrailingResolver,+    toModule,+    actionExprs,+    renderModuleText,+    renderCabalScriptHeader,+    renderCabalScript,+    LhsBlock (..),+    renderLiterate,++    -- * Reusable line classification (for downstream tooling)+    Kind (..),+    Piece (..),+    toPieces,+    classify,+    lineText, ) where  import Data.Char (isAsciiLower, isAsciiUpper, isDigit)+import Data.List (intercalate) import Data.Text (Text) import qualified Data.Text as T-import ScriptHs.Parser (Line (..))+import ScriptHs.Parser (CabalMeta (..), Line (..))  data Block     = SingleLine Line@@ -242,21 +262,28 @@ -- Step 2: [Piece] -> [Block] --------------------------------------------------------------- +{- | Normalize a piece stream: attach each comment unit forward onto the+following non-comment unit, and merge runs of adjacent declarations into a+single unit. Shared by 'toGhciScript' (block wrapping) and 'toModule'+(bucketing) so both see identical grouping.+-}+mergePieces :: [Piece] -> [Piece]+mergePieces (PUnit KComment l1 : PUnit k l2 : rest)+    | k /= KComment = mergePieces (PUnit k (l1 ++ l2) : rest)+mergePieces (PUnit KDeclaration l1 : PUnit KDeclaration l2 : rest) =+    mergePieces (PUnit KDeclaration (l1 ++ l2) : rest)+mergePieces (p : rest) = p : mergePieces rest+mergePieces [] = []+ piecesToBlocks :: [Piece] -> [Block]-piecesToBlocks [] = []-piecesToBlocks (PBlank : rest) = SingleLine Blank : piecesToBlocks rest-piecesToBlocks (PGhciCommand t : rest) = SingleLine (GhciCommand t) : piecesToBlocks rest-piecesToBlocks (PPragma t : rest) = SingleLine (Pragma t) : piecesToBlocks rest-piecesToBlocks (PImport t : rest) = SingleLine (Import t) : piecesToBlocks rest-piecesToBlocks (PUnit KComment lines1 : PUnit k lines2 : rest)-    | k /= KComment =-        piecesToBlocks (PUnit k (lines1 ++ lines2) : rest)-piecesToBlocks (PUnit KDeclaration lines1 : PUnit KDeclaration lines2 : rest) =-    piecesToBlocks (PUnit KDeclaration (lines1 ++ lines2) : rest)-piecesToBlocks (PUnit _ ls : rest) = toBlock ls : piecesToBlocks rest+piecesToBlocks = map pieceToBlock . mergePieces   where-    toBlock [l] = SingleLine l-    toBlock xs = MultiLine xs+    pieceToBlock PBlank = SingleLine Blank+    pieceToBlock (PGhciCommand t) = SingleLine (GhciCommand t)+    pieceToBlock (PPragma t) = SingleLine (Pragma t)+    pieceToBlock (PImport t) = SingleLine (Import t)+    pieceToBlock (PUnit _ [l]) = SingleLine l+    pieceToBlock (PUnit _ ls) = MultiLine ls  --------------------------------------------------------------- -- Rendering@@ -279,3 +306,224 @@ lineText (Pragma t) = t lineText (Import t) = t lineText (HaskellLine t) = t++---------------------------------------------------------------+-- Module rendering: notebook cells -> standalone Haskell+---------------------------------------------------------------++{- | The four buckets a sequence of notebook 'Line's sorts into when+assembling a compilable module: language pragmas and imports (hoisted to the+top), top-level declarations (order-independent), and statements destined for+a generated @main@ do-block (which must preserve document order).+-}+data ModuleParts = ModuleParts+    { mpPragmas :: [Text]+    , mpImports :: [Text]+    , mpDecls :: [Text]+    , mpMain :: [Text]+    }+    deriving (Show, Eq)++instance Semigroup ModuleParts where+    a <> b =+        ModuleParts+            { mpPragmas = mpPragmas a <> mpPragmas b+            , mpImports = mpImports a <> mpImports b+            , mpDecls = mpDecls a <> mpDecls b+            , mpMain = mpMain a <> mpMain b+            }++instance Monoid ModuleParts where+    mempty = ModuleParts [] [] [] []++{- | How a trailing bare expression (a 'KAction' unit) should be emitted in+the generated @main@. A GHCi cell that ends in a bare expression auto-prints+it; a compiled @main@ cannot, so the caller resolves each expression's intent+(typically by querying a live session) and 'toModule' emits accordingly.+-}+data TrailKind+    = -- | @IO ()@: emit the expression verbatim as a statement.+      TrailIOUnit+    | -- | @IO a@, @a@ showable and @/= ()@: emit @print =<< (e)@.+      TrailIOShow+    | -- | pure and showable: emit @print (e)@ (the GHCi auto-print case).+      TrailPure+    | -- | undeterminable or not printable: emit commented out.+      TrailUnknown+    deriving (Show, Eq)++-- | Decide how a trailing expression (rendered to 'Text') should be emitted.+type TrailingResolver = Text -> TrailKind++{- | Every trailing-action expression in a line sequence, in order, exactly as+'toModule' presents them to a 'TrailingResolver'. Lets a caller pre-resolve+each expression (e.g. via @:type@ against a live session) and build a pure+resolver before calling 'toModule'.+-}+actionExprs :: [Line] -> [Text]+actionExprs = concatMap fromPiece . mergePieces . toPieces+  where+    fromPiece (PUnit KAction ls) = [actionBody ls]+    fromPiece _ = []++{- | Sort a sequence of notebook 'Line's into 'ModuleParts'. Imports and+language pragmas are hoisted; @:set -XExt@ becomes a @LANGUAGE@ pragma; other+GHCi directives and @:{@\/@:}@ delimiters are dropped; declarations and TH+splices become top-level decls; monadic binds and trailing expressions become+@main@ statements (the latter shaped by the 'TrailingResolver').+-}+toModule :: TrailingResolver -> [Line] -> ModuleParts+toModule resolve = foldMap fromPiece . mergePieces . toPieces+  where+    fromPiece PBlank = mempty+    fromPiece (PGhciCommand t) = ghciToParts t+    fromPiece (PPragma t) = mempty{mpPragmas = [t]}+    fromPiece (PImport t) = mempty{mpImports = [t]}+    fromPiece (PUnit KComment ls) = mempty{mpDecls = [renderLines ls]}+    fromPiece (PUnit KDeclaration ls) = mempty{mpDecls = [renderLines ls]}+    fromPiece (PUnit KTHSplice ls) = mempty{mpDecls = [unRewriteSplice (renderLines ls)]}+    fromPiece (PUnit KIOBind ls) = mempty{mpMain = [renderLines ls]}+    fromPiece (PUnit KAction ls) =+        let (comments, body) = spanCommentLines ls+            expr = renderLines body+            stmt = case resolve expr of+                TrailIOUnit -> expr+                TrailIOShow -> wrapApplied "print =<<" expr+                TrailPure -> wrapApplied "print" expr+                TrailUnknown -> commentOut expr+         in mempty{mpMain = map lineText comments ++ [stmt]}++{- | A GHCi directive's contribution to a module: @:set -XExt@ becomes a+pragma; @:{@\/@:}@ and everything else is dropped.+-}+ghciToParts :: Text -> ModuleParts+ghciToParts t+    | s == ":{" || s == ":}" = mempty+    | otherwise = mempty{mpPragmas = map languagePragma (setExtensions s)}+  where+    s = T.strip t++-- | Extensions named by a @:set -XExt@ \/ @:seti -XExt@ directive.+setExtensions :: Text -> [Text]+setExtensions t = case T.words t of+    (cmd : rest)+        | cmd `elem` [":set", ":seti"] ->+            [ext | w <- rest, Just ext <- [T.stripPrefix "-X" w]]+    _ -> []++languagePragma :: Text -> Text+languagePragma ext = "{-# LANGUAGE " <> ext <> " #-}"++-- | Reverse the parser's GHCi TH hack (@$(x)@ -> @_ = (); x@) for a module.+unRewriteSplice :: Text -> Text+unRewriteSplice t+    | "\n" `T.isInfixOf` t = t+    | Just inner <- T.stripPrefix "_ = (); " (T.stripStart t) = "$(" <> inner <> ")"+    | otherwise = t++-- | @prefix (expr)@, wrapping multi-line expressions onto their own lines.+wrapApplied :: Text -> Text -> Text+wrapApplied prefix expr+    | "\n" `T.isInfixOf` expr =+        prefix <> " (\n" <> indentText "    " expr <> "\n    )"+    | otherwise = prefix <> " (" <> expr <> ")"++commentOut :: Text -> Text+commentOut expr =+    T.intercalate "\n" $+        "-- [sabela:export] could not resolve this trailing expression's type;"+            : "-- left commented out — wire it into main as needed:"+            : map ("-- " <>) (T.lines expr)++actionBody :: [Line] -> Text+actionBody = renderLines . snd . spanCommentLines++spanCommentLines :: [Line] -> ([Line], [Line])+spanCommentLines = span isCommentLine++isCommentLine :: Line -> Bool+isCommentLine (HaskellLine t) = isCommentText t+isCommentLine _ = False++renderLines :: [Line] -> Text+renderLines = T.intercalate "\n" . map lineText++indentText :: Text -> Text -> Text+indentText pad = T.intercalate "\n" . map (pad <>) . T.lines++-- | Deduplicate while preserving first-occurrence order (no @containers@ dep).+dedup :: [Text] -> [Text]+dedup = go []+  where+    go _ [] = []+    go seen (x : xs)+        | x `elem` seen = go seen xs+        | otherwise = x : go (x : seen) xs++{- | Assemble 'ModuleParts' into module source: deduped pragmas, an optional+@module … where@ header, deduped imports, declarations (blank-separated), and+a generated @main@ (@pure ()@ when there are no statements).+-}+renderModuleText :: Maybe Text -> ModuleParts -> Text+renderModuleText mModName mp =+    T.unlines . intercalate [""] . filter (not . null) $+        [ dedup (mpPragmas mp)+        , maybe [] (\n -> ["module " <> n <> " where"]) mModName+        , dedup (mpImports mp)+        , joinBlocks (mpDecls mp)+        , mainLines+        ]+  where+    mainLines =+        ("main :: IO ()" :) $+            case mpMain mp of+                [] -> ["main = pure ()"]+                stmts -> "main = do" : concatMap (map ("    " <>) . T.lines) stmts++-- | Flatten multi-line chunks into lines, separated by a single blank line.+joinBlocks :: [Text] -> [Text]+joinBlocks = intercalate [""] . map T.lines++{- | A single-file cabal-script header (@{\- cabal: … -\}@) rendered from+'CabalMeta'. @base@ is always present and @-Wno-unused-imports@ is added since+the exporter over-includes imports to keep dependency slices self-contained.+-}+renderCabalScriptHeader :: CabalMeta -> Text+renderCabalScriptHeader (CabalMeta deps exts opts) =+    T.unlines $+        ["{- cabal:", "build-depends: " <> commaList (dedup ("base" : deps))]+            ++ ["default-extensions: " <> commaList exts' | not (null exts')]+            ++ ["ghc-options: " <> T.unwords opts']+            ++ ["-}"]+  where+    exts' = dedup exts+    opts' = dedup (opts ++ ["-Wno-unused-imports"])+    commaList = T.intercalate ", "++-- | A runnable single-file cabal script: a @{\- cabal: -\}@ header over a module.+renderCabalScript :: CabalMeta -> ModuleParts -> Text+renderCabalScript meta mp =+    renderCabalScriptHeader meta <> "\n" <> renderModuleText (Just "Main") mp++-- | One block of a literate-Haskell document: prose, or Bird-style code.+data LhsBlock+    = LhsProse Text+    | LhsCode [Text]+    deriving (Show, Eq)++{- | Render literate Haskell, Bird-style (@>@-prefixed code). Blocks are+separated by a blank line, satisfying the rule that a code block must be+preceded and followed by a blank line.+-}+renderLiterate :: [LhsBlock] -> Text+renderLiterate = T.intercalate "\n\n" . map render+  where+    render (LhsProse t) = T.intercalate "\n" (map escapeProse (T.lines t))+    render (LhsCode ls) = T.intercalate "\n" (map bird ls)+    bird l = if T.null l then ">" else "> " <> l+    -- A prose line starting with @>@ would be read as code, and one starting+    -- with @#@ as a CPP line directive; indent both by a space to keep them+    -- prose. (GHC's literate preprocessor is sensitive to column 0.)+    escapeProse l+        | T.isPrefixOf ">" l || T.isPrefixOf "#" l = " " <> l+        | otherwise = l
test/Test/Render.hs view
@@ -5,8 +5,18 @@  import Data.Text (Text) import qualified Data.Text as T-import ScriptHs.Parser (Line (..))-import ScriptHs.Render (toGhciScript)+import ScriptHs.Parser (CabalMeta (..), Line (..))+import ScriptHs.Render (+    LhsBlock (..),+    ModuleParts (..),+    TrailKind (..),+    actionExprs,+    renderCabalScriptHeader,+    renderLiterate,+    renderModuleText,+    toGhciScript,+    toModule,+ )  renderTests :: TestTree renderTests =@@ -332,6 +342,90 @@                             ]                 length (splitBlocks result) @?= 1             ]+        , moduleTests+        ]++moduleTests :: TestTree+moduleTests =+    testGroup+        "Module rendering (notebook export)"+        [ testCase "pragmas and imports hoist into their buckets" $ do+            let mp =+                    toModule+                        (const TrailUnknown)+                        [ Pragma "{-# LANGUAGE GADTs #-}"+                        , Import "import Data.Text (Text)"+                        , HaskellLine "x = 5"+                        ]+            mpPragmas mp @?= ["{-# LANGUAGE GADTs #-}"]+            mpImports mp @?= ["import Data.Text (Text)"]+            mpDecls mp @?= ["x = 5"]+            mpMain mp @?= []+        , testCase ":set -XExt becomes LANGUAGE pragma(s)" $ do+            let mp =+                    toModule (const TrailUnknown) [GhciCommand ":set -XOverloadedStrings -XGADTs"]+            mpPragmas mp+                @?= ["{-# LANGUAGE OverloadedStrings #-}", "{-# LANGUAGE GADTs #-}"]+        , testCase "non -X ghci directives and :{ :} are dropped" $ do+            let mp =+                    toModule+                        (const TrailUnknown)+                        [GhciCommand ":type foo", GhciCommand ":{", GhciCommand ":}"]+            mp @?= mempty+        , testCase "value binding routes to decls, not main" $ do+            let mp = toModule (const TrailUnknown) [HaskellLine "f x = x + 1"]+            mpDecls mp @?= ["f x = x + 1"]+            mpMain mp @?= []+        , testCase "IO bind routes to main verbatim" $ do+            let mp = toModule (const TrailUnknown) [HaskellLine "x <- getLine"]+            mpMain mp @?= ["x <- getLine"]+            mpDecls mp @?= []+        , testCase "trailing pure expression is printed" $ do+            let mp = toModule (const TrailPure) [HaskellLine "df"]+            mpMain mp @?= ["print (df)"]+        , testCase "trailing IO () expression is verbatim" $ do+            let mp = toModule (const TrailIOUnit) [HaskellLine "putStrLn \"hi\""]+            mpMain mp @?= ["putStrLn \"hi\""]+        , testCase "trailing IO-show expression binds with print" $ do+            let mp = toModule (const TrailIOShow) [HaskellLine "readLn"]+            mpMain mp @?= ["print =<< (readLn)"]+        , testCase "unresolved trailing expression is commented out" $ do+            let mp = toModule (const TrailUnknown) [HaskellLine "mystery"]+            assertBool "comments out the expr" (any (T.isInfixOf "-- mystery") (mpMain mp))+        , testCase "TH splice is un-rewritten to a top-level decl" $ do+            let mp = toModule (const TrailUnknown) [HaskellLine "_ = (); deriveFoo ''Bar"]+            mpDecls mp @?= ["$(deriveFoo ''Bar)"]+        , testCase "actionExprs lists trailing expressions in order" $+            actionExprs [HaskellLine "putStrLn \"a\"", HaskellLine "result"]+                @?= ["putStrLn \"a\"", "result"]+        , testCase "empty main renders as pure ()" $ do+            let txt = renderModuleText (Just "Main") (mempty{mpDecls = ["x = 1"]})+            assertBool "has module header" (T.isInfixOf "module Main where" txt)+            assertBool "has main = pure ()" (T.isInfixOf "main = pure ()" txt)+        , testCase "main do-block indents statements" $ do+            let txt = renderModuleText (Just "Main") (mempty{mpMain = ["print 1", "print 2"]})+            assertBool "has main = do" (T.isInfixOf "main = do" txt)+            assertBool "indents print 1" (T.isInfixOf "    print 1" txt)+        , testCase "cabal-script header includes base + Wno-unused-imports" $ do+            let hdr = renderCabalScriptHeader (CabalMeta ["dataframe", "text"] [] [])+            assertBool "opens block" (T.isInfixOf "{- cabal:" hdr)+            assertBool+                "base + deps"+                (T.isInfixOf "build-depends: base, dataframe, text" hdr)+            assertBool "suppresses unused imports" (T.isInfixOf "-Wno-unused-imports" hdr)+        , testCase "literate output separates code from prose with a blank line" $+            renderLiterate [LhsProse "Intro paragraph.", LhsCode ["main = pure ()"]]+                @?= "Intro paragraph.\n\n> main = pure ()"+        , testCase+            "literate prose indents lines starting with # or > (else GHC mis-reads them)"+            $ do+                let txt =+                        renderLiterate+                            [LhsProse "# Heading\n> quote\nplain", LhsCode ["main = pure ()"]]+                assertBool "escapes leading #" (T.isPrefixOf " # Heading" txt)+                assertBool "escapes leading >" (T.isInfixOf "\n > quote" txt)+                assertBool "leaves plain prose" (T.isInfixOf "\nplain" txt)+                assertBool "code still bird-prefixed" (T.isInfixOf "> main = pure ()" txt)         ]  splitBlocks :: Text -> [[Text]]