packages feed

scripths 0.4.0.1 → 0.5.0.0

raw patch · 19 files changed

+1603/−146 lines, 19 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- ScriptHs.Run: autoPrintDirective :: Text
- ScriptHs.Run: captureGhc :: FilePath -> FilePath -> FilePath -> IO Text
- ScriptHs.Run: cdDirective :: FilePath -> Text
- ScriptHs.Run: compileCdSetup :: Text
- ScriptHs.Run: compileCdTo :: FilePath -> Text
- ScriptHs.Run: depPkgName :: Text -> Text
- ScriptHs.Run: enclosingProjectFor :: FilePath -> [Text] -> IO [FilePath]
- ScriptHs.Run: ensureProject :: RunOptions -> FilePath -> FilePath -> CabalMeta -> [Line] -> IO FilePath
- ScriptHs.Run: findCabalFiles :: FilePath -> IO [FilePath]
- ScriptHs.Run: localPackageNames :: [FilePath] -> IO [Text]
- ScriptHs.Run: managedSentinel :: Text
- ScriptHs.Run: packageNameInDir :: FilePath -> IO (Maybe Text)
- ScriptHs.Run: readCabalPackageName :: FilePath -> IO (Maybe Text)
- ScriptHs.Run: runGhc :: FilePath -> FilePath -> FilePath -> IO ()
- ScriptHs.Run: runWithCont :: (FilePath -> FilePath -> FilePath -> IO a) -> RunOptions -> FilePath -> ScriptFile -> IO a
- ScriptHs.Run: warnUnknownKeys :: [Text] -> IO ()
- ScriptHs.Run: writeManagedCabalProject :: FilePath -> Text -> IO ()
+ ScriptHs.Compiled: CellChunk :: Text -> [(Int, Line)] -> CellChunk
+ ScriptHs.Compiled: CompileIssue :: Int -> Text -> CompileIssue
+ ScriptHs.Compiled: [ccLines] :: CellChunk -> [(Int, Line)]
+ ScriptHs.Compiled: [ccTag] :: CellChunk -> Text
+ ScriptHs.Compiled: [ciLine] :: CompileIssue -> Int
+ ScriptHs.Compiled: [ciReason] :: CompileIssue -> Text
+ ScriptHs.Compiled: checkCompilable :: [(Int, Line)] -> [CompileIssue]
+ ScriptHs.Compiled: data CellChunk
+ ScriptHs.Compiled: data CompileIssue
+ ScriptHs.Compiled: instance GHC.Classes.Eq ScriptHs.Compiled.CellChunk
+ ScriptHs.Compiled: instance GHC.Classes.Eq ScriptHs.Compiled.CompileIssue
+ ScriptHs.Compiled: instance GHC.Show.Show ScriptHs.Compiled.CellChunk
+ ScriptHs.Compiled: instance GHC.Show.Show ScriptHs.Compiled.CompileIssue
+ ScriptHs.Compiled: isValidModuleName :: Text -> Bool
+ ScriptHs.Compiled: linePragmaTag :: Int -> Text
+ ScriptHs.Compiled: parseLinePragmaTag :: Text -> Maybe Int
+ ScriptHs.Compiled: renderCompiledModule :: Text -> [Text] -> [Text] -> [CellChunk] -> Text
+ ScriptHs.Notebook: fnv1a :: Text -> Word32
+ ScriptHs.Notebook: makeNonce :: [Line] -> IO Text
+ ScriptHs.Notebook: markerStatement :: Text -> Text
+ ScriptHs.Notebook: scrubCellOutput :: Text -> [Int] -> Text -> Text
+ ScriptHs.Notebook: stripBom :: Text -> Text
+ ScriptHs.Parser: CompileDefault :: CompileDirective
+ ScriptHs.Parser: CompileNamed :: Text -> CompileDirective
+ ScriptHs.Parser: [scriptCompile] :: ScriptFile -> Maybe CompileDirective
+ ScriptHs.Parser: data CompileDirective
+ ScriptHs.Parser: instance GHC.Classes.Eq ScriptHs.Parser.CompileDirective
+ ScriptHs.Parser: instance GHC.Show.Show ScriptHs.Parser.CompileDirective
+ ScriptHs.Parser: parseScriptNumbered :: Text -> (ScriptFile, [(Int, Line)])
+ ScriptHs.Render: unRewriteSplice :: Text -> Text
+ ScriptHs.Repl: autoPrintDirective :: Text
+ ScriptHs.Repl: cdDirective :: FilePath -> Text
+ ScriptHs.Repl: compileCdSetup :: Text
+ ScriptHs.Repl: compileCdTo :: FilePath -> Text
+ ScriptHs.Repl: replEvalExpr :: Text
+ ScriptHs.Repl: scripthsIO :: Text
+ ScriptHs.Repl: scripthsInternalQualifiers :: [Text]
+ ScriptHs.Repl: scrubInternalNames :: Text -> Text
+ ScriptHs.Version: NotebookTag :: TagStyle
+ ScriptHs.Version: ScriptTag :: TagStyle
+ ScriptHs.Version: data TagStyle
+ ScriptHs.Version: instance GHC.Classes.Eq ScriptHs.Version.TagStyle
+ ScriptHs.Version: instance GHC.Show.Show ScriptHs.Version.TagStyle
+ ScriptHs.Version: newerVersionWarning :: Version -> Maybe Text
+ ScriptHs.Version: parseTagLine :: TagStyle -> Text -> Maybe Version
+ ScriptHs.Version: scripthsVersion :: Version
+ ScriptHs.Version: scripthsVersionText :: Text
+ ScriptHs.Version: stampVersion :: TagStyle -> Text -> Text
+ ScriptHs.Version: tagStyleFor :: FilePath -> TagStyle
+ ScriptHs.Version: tagVersion :: TagStyle -> Text -> Maybe Version
+ ScriptHs.Version: versionTag :: TagStyle -> Text
- ScriptHs.Notebook: generatedMarkedScript :: IndexedBlocks -> [Line]
+ ScriptHs.Notebook: generatedMarkedScript :: Text -> IndexedBlocks -> [Line]
- ScriptHs.Notebook: mkMarker :: Int -> Text
+ ScriptHs.Notebook: mkMarker :: Text -> Int -> Text
- ScriptHs.Notebook: splitByMarkers :: Text -> [Int] -> [(Int, Text)]
+ ScriptHs.Notebook: splitByMarkers :: Text -> Text -> [Int] -> [(Int, Text)]
- ScriptHs.Parser: ScriptFile :: CabalMeta -> [Line] -> ScriptFile
+ ScriptHs.Parser: ScriptFile :: CabalMeta -> Maybe CompileDirective -> [Line] -> ScriptFile

Files

CHANGELOG.md view
@@ -1,6 +1,27 @@ # Revision history for scripths  +## 0.4.1.0 -- 2056-05-31+* **Custom-prelude support**: auto print (our hook to rout evething but strings to shw)+  now doesn't assume a prelude.+* **Cleaner error rendering** (notebooks): don't leak internals when printing errors.+* **UTF-8 output**: captured notebook output is decoded as UTF-8.+* **Friendlier errors**: a missing input file reports `No such file or directory`+  instead of a raw exception; a failing `.ghci` script's stderr is scrubbed too;+  cell-end markers carry a per-run nonce so cell output can't spoof a boundary.+* **Version tag**: files may carry a first-line tag recording the scripths that+  wrote them — `-- scripths: X` in `.ghci`/`.hs` scripts, `<!-- scripths: X -->`+  in `.md`/`.markdown` notebooks. Running a file that declares a *newer* scripths+  than the binary prints a warning and continues. scripths stamps/refreshes the+  tag when it writes a notebook (`-o`/`--in-place`); stdout output is left+  unstamped. The tag is recognised after a leading `#!` shebang (scripts) or YAML+  frontmatter block (notebooks), tolerating a BOM, CRLF, and indentation.+  Behaviour change: the first `--in-place` run on an existing notebook inserts the+  `<!-- scripths: X -->` line (and normalises leading blank lines), a one-time+  visible diff in tracked files; it is invisible when rendered and idempotent+  thereafter.+* **`scripths --version` / `-v`** prints the scripths version.+ ## 0.4.0.1 -- 2026-05-30 * Parse pandoc markdown code fences as haskel code fences. 
README.md view
@@ -7,11 +7,13 @@ ## Features  - **Standalone `.ghci` execution** — Run GHCi scripts directly from the command line, with automatic dependency.+- **Compiled or intepreted** - you can either compile or interpret cells. Heavier functions can be compiled so they move fast and interactive parts can be kept interpreted. - **Cabal metadata directives** — Declare `build-depends`, `default-extensions`, `ghc-options`, and local `packages` inline using `-- cabal:` comments. - **Markdown notebooks** — Execute Haskell code blocks inside Markdown files and render the output back into the document as block quotes. Re-run in place with `-i` — output is replaced cleanly, with no accumulating blank lines. - **Inline errors** — A block that fails to compile renders its GHC error beneath the block instead of producing silent empty output. - **Smart GHCi rendering** — Multi-line definitions are automatically wrapped in `:{`/`:}` blocks, and IO binds / Template Haskell splices are handled correctly as individual statements. - **Compile-time TH reads your tree** — A splice that reads a file at compile time (e.g. `$(declareTable "./data/x.db" …)`) resolves `./relative` paths against the directory you ran `scripths` from, not the internal build directory.+- **Version tag** — Files record the scripths that wrote them on their first line; running a file authored by a newer scripths than your binary prints a warning rather than failing.  ## Installation @@ -22,12 +24,23 @@ ## CLI Usage  ```-scripths [-o FILE | --output=FILE] [-i | --in-place] [-p DIR | --package DIR]... [--no-local-project] [-h | --help] <script>+scripths [-o FILE | --output=FILE] [-i | --in-place] [-p DIR | --package DIR]... [--no-local-project] [-h | --help] [-v | --version] <script> ```  When `-o` / `--output` is provided for Markdown files, the result is written to that path. Otherwise it is printed to stdout. With `-i` / `--in-place` the notebook is rewritten in place: any previously rendered output is stripped and replaced, and re-running is idempotent (it does not accumulate blank lines). `-i` is only valid for `.md` / `.markdown` notebooks and cannot be combined with `-o`. -The file extension determines the mode. `.ghci` / `hs` files are parsed and executed as a standalone GHCi script. `.md` / `.markdown` files are processed as a notebook with captured output. Run `scripths --help` for the full option and directive list.+The file extension determines the mode. `.ghci` / `hs` files are parsed and executed as a standalone GHCi script. `.md` / `.markdown` files are processed as a notebook with captured output. Run `scripths --help` for the full option and directive list, or `scripths --version` to print the version.++## Version tag++Each file can carry a first-line tag recording the scripths version that authored it, so you can tell whether your binary is current enough to parse it. The spelling matches each file type's comment syntax:++```+-- scripths: 0.4.1.0          # .ghci / .hs scripts+<!-- scripths: 0.4.1.0 -->    # .md / .markdown notebooks (invisible when rendered)+```++When scripths writes a notebook (`-o` / `--in-place`) it stamps or refreshes this tag at the top of the document; output streamed to stdout is left untouched. When you run a file whose tag declares a **newer** scripths than your binary, scripths warns on stderr and proceeds anyway (the syntax may not fully parse). The tag is recognised after a leading `#!` shebang (scripts) or YAML frontmatter block (notebooks).  Requires GHC and cabal-install on your PATH. 
app/Main.hs view
@@ -3,9 +3,11 @@ import Control.Monad (unless, when) import Data.List (isPrefixOf) import Data.Maybe (isJust)+import qualified Data.Text as T import qualified Data.Text.IO as TIO import System.Directory (     doesDirectoryExist,+    doesFileExist,     listDirectory,     makeAbsolute,  )@@ -17,6 +19,12 @@ import ScriptHs.Notebook (runNotebook) import ScriptHs.Parser (parseScript) import ScriptHs.Run (RunOptions (..), defaultRunOptions, runScript)+import ScriptHs.Version (+    newerVersionWarning,+    scripthsVersionText,+    tagStyleFor,+    tagVersion,+ )  -- | Parsed command-line options. data Args = Args@@ -26,10 +34,11 @@     , argNoLocalProject :: Bool     , argInPlace :: Bool     , argHelp :: Bool+    , argVersion :: Bool     }  emptyArgs :: Args-emptyArgs = Args Nothing Nothing [] False False False+emptyArgs = Args Nothing Nothing [] False False False False  main :: IO () main = do@@ -37,10 +46,14 @@     case parseArgs raw of         Left err -> hPutStrLn stderr ("scripths: " ++ err) >> usage         Right a+            | argVersion a -> printVersion             | argHelp a -> help             | otherwise -> case argScript a of                 Nothing -> usage                 Just path -> do+                    exists <- doesFileExist path+                    unless exists $ die (path ++ ": No such file or directory")+                    warnIfNewer path                     outPath <- resolveOutput a path                     pkgs <- mapM resolvePackageDir (argPackages a)                     let opts =@@ -97,6 +110,7 @@         | tok == "--no-local-project" = go a{argNoLocalProject = True} rest         | tok == "-i" || tok == "--in-place" = go a{argInPlace = True} rest         | tok == "-h" || tok == "--help" = go a{argHelp = True} rest+        | tok == "-v" || tok == "--version" = go a{argVersion = True} rest         | "-" `isPrefixOf` tok = Left ("unknown flag: " ++ tok)         | otherwise = case argScript a of             Nothing -> go a{argScript = Just tok} rest@@ -117,6 +131,23 @@ die :: String -> IO a die msg = hPutStrLn stderr ("scripths: " ++ msg) >> exitFailure +-- | Print @\<prog\> \<version\>@ to stdout, exit success.+printVersion :: IO ()+printVersion = do+    prog <- getProgName+    putStrLn (prog ++ " " ++ T.unpack scripthsVersionText)+    exitSuccess++{- | Warn (to stderr, then continue) if the file's first-line version tag+declares a scripths newer than this binary — its syntax may not fully parse.+-}+warnIfNewer :: FilePath -> IO ()+warnIfNewer path = do+    contents <- TIO.readFile path+    case tagVersion (tagStyleFor path) contents >>= newerVersionWarning of+        Just w -> hPutStrLn stderr ("scripths: warning: " ++ T.unpack w)+        Nothing -> pure ()+ -- | Full help, to stdout, exit success. help :: IO () help = getProgName >>= putStr . helpText >> exitSuccess@@ -139,6 +170,11 @@         , "  -p DIR, --package DIR     add a local package dir (also --package=DIR)"         , "  --no-local-project        do not auto-include the enclosing cabal project"         , "  -h, --help                show this help"+        , "  -v, --version             show the scripths version"+        , ""+        , "Files may carry a first-line version tag recording the scripths that wrote"+        , "them ('-- scripths: X' in scripts, '<!-- scripths: X -->' in notebooks); a"+        , "file declaring a newer scripths than this binary is run with a warning."         , ""         , "In-script directives (lines beginning '-- cabal:'):"         , "  -- cabal: build-depends: pkg1, pkg2"
scripths.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               scripths-version:            0.4.0.1+version:            0.5.0.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/@@ -24,10 +24,15 @@ library     import:           warnings     exposed-modules:  ScriptHs.Parser,+                      ScriptHs.Compiled,                       ScriptHs.Render,                       ScriptHs.Markdown,                       ScriptHs.Notebook,-                      ScriptHs.Run+                      ScriptHs.Repl,+                      ScriptHs.Run,+                      ScriptHs.Version+    other-modules:    Paths_scripths+    autogen-modules:  Paths_scripths     build-depends:    base >= 4 && < 5,                       directory >= 1.3 && < 1.4,                       filepath >= 1.4 && < 1.6,@@ -57,10 +62,14 @@     hs-source-dirs:   test     main-is:          Main.hs     other-modules:    Test.Parser,+                      Test.Compiled,                       Test.Render,                       Test.Markdown,                       Test.Notebook,-                      Test.Run+                      Test.Repl,+                      Test.Run,+                      Test.Version,+                      Test.Integration     build-depends:         base >= 4 && < 5,         scripths,
+ src/ScriptHs/Compiled.hs view
@@ -0,0 +1,182 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Rendering compiled notebook cells into a generated Haskell module.++A cell carrying a @-- compile@ directive contributes its declarations to a+module that the host loads with @:add@, so GHC compiles it to native object+code instead of interpreting it at the prompt. Every import and declaration+unit is preceded by a @{\-# LINE #-\}@ pragma whose \"file\" is a per-cell tag+('linePragmaTag'), so GHC diagnostics come back cell-relative.+-}+module ScriptHs.Compiled (+    CellChunk (..),+    CompileIssue (..),+    checkCompilable,+    renderCompiledModule,+    linePragmaTag,+    parseLinePragmaTag,+    isValidModuleName,+) where++import Data.Char (isAlphaNum, isAsciiUpper)+import Data.List (intercalate)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Read as TR++import ScriptHs.Parser (Line (..))+import ScriptHs.Render (Kind (..), Piece (..), lineText, toPieces, unRewriteSplice)++-- | One cell's contribution to a generated module.+data CellChunk = CellChunk+    { ccTag :: Text+    -- ^ LINE-pragma file tag for this cell (see 'linePragmaTag').+    , ccLines :: [(Int, Line)]+    -- ^ The cell's code lines with their 1-based cell-relative numbers.+    }+    deriving (Show, Eq)++-- | Why a cell cannot be compiled, pointing at the offending line.+data CompileIssue = CompileIssue+    { ciLine :: Int+    , ciReason :: Text+    }+    deriving (Show, Eq)++{- | Validate that a cell contains only module-top-level content: imports,+pragmas, declarations, TH splices, comments, and @:set -X@ directives.+Expressions, monadic binds, and other GHCi commands are rejected.+-}+checkCompilable :: [(Int, Line)] -> [CompileIssue]+checkCompilable nls =+    [ CompileIssue i reason+    | (i, p) <- numberedPieces nls+    , Just reason <- [pieceIssue p]+    ]++pieceIssue :: Piece -> Maybe Text+pieceIssue PBlank = Nothing+pieceIssue (PPragma _) = Nothing+pieceIssue (PImport _) = Nothing+pieceIssue (PGhciCommand t)+    | isSetExtension t = Nothing+    | otherwise =+        Just "GHCi commands are not allowed in compiled cells (only :set -XExtension)"+pieceIssue (PUnit k _) = case k of+    KComment -> Nothing+    KDeclaration -> Nothing+    KTHSplice -> Nothing+    KAction ->+        Just+            "compiled cells contain only declarations — move this expression to an interpreted cell below, or remove '-- compile'"+    KIOBind ->+        Just+            "monadic binds (x <- …) are not allowed in compiled cells — move this to an interpreted cell below, or remove '-- compile'"++{- | Pair each piece from 'toPieces' with the original line number of its+first line. Relies on 'toPieces' being line-count preserving: every piece+consumes exactly the lines it embeds.+-}+numberedPieces :: [(Int, Line)] -> [(Int, Piece)]+numberedPieces nls = go nls (toPieces (map snd nls))+  where+    go _ [] = []+    go rest (p : ps) = case rest of+        ((i, _) : _) -> (i, p) : go (drop (pieceLen p) rest) ps+        [] -> []+    pieceLen (PUnit _ ls) = length ls+    pieceLen _ = 1++-- | Extensions named by a @:set -X@\/@:seti -X@ line whose args are all @-X@.+setExtensions :: Text -> [Text]+setExtensions t = case T.words (T.strip t) of+    (cmd : rest)+        | cmd `elem` [":set", ":seti"]+        , exts <- [e | w <- rest, Just e <- [T.stripPrefix "-X" w]]+        , length exts == length rest ->+            exts+    _ -> []++isSetExtension :: Text -> Bool+isSetExtension = not . null . setExtensions++-- | The LINE-pragma \"file\" tag for a cell id, e.g. @sabela-cell-12@.+linePragmaTag :: Int -> Text+linePragmaTag cid = "sabela-cell-" <> T.pack (show cid)++-- | Inverse of 'linePragmaTag'; the error parser uses this to route diagnostics.+parseLinePragmaTag :: Text -> Maybe Int+parseLinePragmaTag t = do+    rest <- T.stripPrefix "sabela-cell-" t+    case TR.decimal rest of+        Right (n, leftover) | T.null leftover -> Just n+        _ -> Nothing++-- | Valid Haskell module name: dot-separated capitalized identifier segments.+isValidModuleName :: Text -> Bool+isValidModuleName name =+    not (T.null name) && all validSegment (T.splitOn "." name)+  where+    validSegment seg = case T.uncons seg of+        Just (c, rest) -> isAsciiUpper c && T.all segChar rest+        Nothing -> False+    segChar c = isAlphaNum c || c == '_' || c == '\''++{- | Assemble a generated module: deduped LANGUAGE pragmas (defaults, cell+pragmas, @:set -X@ contributions), the module header, synthetic imports (e.g.+cross-module dependencies the host inferred), cell imports and declarations —+each cell unit preceded by a LINE pragma pointing back at its source cell.+-}+renderCompiledModule ::+    -- | Module name+    Text ->+    -- | Default extensions (rendered as LANGUAGE pragmas)+    [Text] ->+    -- | Synthetic imports, verbatim (e.g. @import OtherModule@)+    [Text] ->+    [CellChunk] ->+    Text+renderCompiledModule modName defaultExts extraImports chunks =+    T.unlines . intercalate [""] . filter (not . null) $+        [ dedup (map languagePragma defaultExts ++ chunkPragmas)+            ++ ["{-# OPTIONS_GHC -Wno-unused-imports #-}"]+        , ["-- Generated by Sabela compile mode. Do not edit."]+        , ["module " <> modName <> " where"]+        , extraImports ++ concatMap chunkImports chunks+        , intercalate [""] (concatMap chunkDecls chunks)+        ]+  where+    chunkPragmas = concatMap pragmasOf chunks+    pragmasOf c =+        concat+            [ case p of+                PPragma t -> [t]+                PGhciCommand t -> map languagePragma (setExtensions t)+                _ -> []+            | (_, p) <- numberedPieces (ccLines c)+            ]+    chunkImports c =+        concat+            [ [linePragma i (ccTag c), t]+            | (i, PImport t) <- numberedPieces (ccLines c)+            ]+    chunkDecls c =+        [ linePragma i (ccTag c) : declLines u+        | (i, PUnit k u) <- numberedPieces (ccLines c)+        , k `elem` [KComment, KDeclaration, KTHSplice]+        ]+    declLines = T.lines . unRewriteSplice . T.intercalate "\n" . map lineText++linePragma :: Int -> Text -> Text+linePragma n tag = "{-# LINE " <> T.pack (show n) <> " \"" <> tag <> "\" #-}"++languagePragma :: Text -> Text+languagePragma ext = "{-# LANGUAGE " <> ext <> " #-}"++dedup :: [Text] -> [Text]+dedup = go []+  where+    go _ [] = []+    go seen (x : xs)+        | x `elem` seen = go seen xs+        | otherwise = x : go (x : seen) xs
src/ScriptHs/Notebook.hs view
@@ -1,9 +1,15 @@ module ScriptHs.Notebook where  import Data.Bifunctor (Bifunctor (second))+import Data.Bits (xor)+import Data.Char (ord)+import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO+import Data.Word (Word32)+import Numeric (showHex)+import System.CPUTime (getCPUTime)  import ScriptHs.Markdown (     CodeOutput (..),@@ -19,19 +25,30 @@     mergeMetas,     parseScript,  )+import ScriptHs.Render (lineText)+import ScriptHs.Repl (scripthsIO, scrubInternalNames) import ScriptHs.Run (RunOptions, runScriptCapture)+import ScriptHs.Version (TagStyle (NotebookTag), stampVersion)  type IndexedSegments = [(Int, Segment)] type IndexedBlocks = [(Int, [Line])] +{- | Run a notebook. Output streamed to stdout is left as-is so pipelines stay+clean; output written to a file (@-o@ or @--in-place@) is stamped with the+current scripths version tag at the top.+-} runNotebook :: RunOptions -> FilePath -> Maybe FilePath -> IO () runNotebook opts path outputPath = do-    contents <- TIO.readFile path+    contents <- stripBom <$> TIO.readFile path     outputMd <- processNotebook opts path contents     case outputPath of         Nothing -> TIO.putStr outputMd-        Just output -> TIO.writeFile output outputMd+        Just output -> TIO.writeFile output (stampVersion NotebookTag outputMd) +-- | Drop a leading UTF-8 BOM so it never sits in front of the version tag.+stripBom :: Text -> Text+stripBom t = fromMaybe t (T.stripPrefix "\65279" t)+ processNotebook :: RunOptions -> FilePath -> Text -> IO Text processNotebook opts notebookPath contents = do     let indexedSegments = zip [0 ..] (parseMarkdown contents)@@ -48,13 +65,44 @@     IndexedBlocks ->     IO Text executeCodeCells opts notebookPath meta allSegments codeBlocks = do-    let ghciScript = generatedMarkedScript codeBlocks-        sf = ScriptFile{scriptMeta = meta, scriptLines = ghciScript}+    let ghciScript0 = generatedMarkedScript "" codeBlocks+    nonce <- makeNonce ghciScript0+    let ghciScript = generatedMarkedScript nonce codeBlocks+        sf = ScriptFile{scriptMeta = meta, scriptCompile = Nothing, scriptLines = ghciScript}     rawOutput <- runScriptCapture opts notebookPath sf-    let outputs = splitByMarkers rawOutput (map fst codeBlocks)+    let indices = map fst codeBlocks+        outputs =+            map+                (fmap (scrubCellOutput nonce indices))+                (splitByMarkers nonce rawOutput indices)         blocksWithOutput = addOutputToSegments outputs allSegments     pure $ reassemble blocksWithOutput +{- | A per-run hex nonce woven into every cell-end marker so a cell's own output+cannot spoof a marker and misattribute another cell's output. Mixes the process+CPU time with a hash of the cell text.+-}+makeNonce :: [Line] -> IO Text+makeNonce ls = do+    t <- getCPUTime+    -- TODO: mchavinda - if this is slow then drop the text hashing and just use time.+    let seed = T.pack (show t) <> T.concat (map lineText ls)+    pure (T.pack (showHex (fnv1a seed) ""))++fnv1a :: Text -> Word32+fnv1a = T.foldl' (\h c -> (h `xor` fromIntegral (ord c)) * 16777619) 2166136261++{- | Clean a cell's captured output before it is rendered into the document:+strip scripths' internal identifiers (so a diagnostic reads like vanilla GHCi)+and remove any cell-end block marker that survived 'splitByMarkers' (so it can+never appear interleaved with the cell's stdout/error).+-}+scrubCellOutput :: Text -> [Int] -> Text -> Text+scrubCellOutput nonce indices =+    scrubInternalNames . stripMarkers+  where+    stripMarkers t = foldr (\i acc -> T.replace (mkMarker nonce i) "" acc) t indices+ addOutputToSegments :: [(Int, Text)] -> IndexedSegments -> [Segment] addOutputToSegments outputs = map addOutput   where@@ -72,31 +120,43 @@         [(i, parseScript code) | (i, CodeBlock lang code _) <- blocks, isHaskell lang]     metas = mergeMetas (map (scriptMeta . snd) sfs) -generatedMarkedScript :: IndexedBlocks -> [Line]-generatedMarkedScript = concatMap renderWithMarker+generatedMarkedScript :: Text -> IndexedBlocks -> [Line]+generatedMarkedScript nonce = concatMap renderWithMarker   where     renderWithMarker :: (Int, [Line]) -> [Line]     renderWithMarker (idx, ls) =         ls             ++ [ Blank-               , HaskellLine ("putStrLn " <> T.pack (show (T.unpack (mkMarker idx))))+               , HaskellLine (markerStatement (mkMarker nonce idx))                , Blank                ] -splitByMarkers :: Text -> [Int] -> [(Int, Text)]-splitByMarkers _ [] = []-splitByMarkers remaining (idx : rest) =-    let marker = mkMarker idx+{- | A GHCi statement printing a cell-end marker. @putStrLn@ is qualified through+the synthetic @System.IO@ alias ('scripthsIO') so the marker still prints under a+notebook that enables @NoImplicitPrelude@ or imports a custom prelude.+-}+markerStatement :: Text -> Text+markerStatement marker =+    scripthsIO <> ".putStrLn " <> T.pack (show (T.unpack marker))++splitByMarkers :: Text -> Text -> [Int] -> [(Int, Text)]+splitByMarkers _ _ [] = []+splitByMarkers nonce remaining (idx : rest) =+    let marker = mkMarker nonce idx         (before, after) = T.breakOn marker remaining      in if T.null after             then [(idx, T.strip before)]             else-                (idx, T.strip before) : splitByMarkers (T.drop (T.length marker) after) rest+                (idx, T.strip before)+                    : splitByMarkers nonce (T.drop (T.length marker) after) rest --- A marker we'll print to GHCi output to--- denote the end of a cell execution block.-mkMarker :: Int -> Text-mkMarker n = "---SCRIPTHS_BLOCK_" <> T.pack (show n) <> "_END---"+{- | The marker printed to GHCi output to denote the end of a cell's execution.+Carries a per-run @nonce@ (see 'makeNonce') so a cell's own output cannot forge+a marker and steal the boundary between cells.+-}+mkMarker :: Text -> Int -> Text+mkMarker nonce n =+    "---SCRIPTHS_BLOCK_" <> nonce <> "_" <> T.pack (show n) <> "_END---"  isHaskell :: Text -> Bool isHaskell lang = fenceLanguage lang `elem` ["haskell", "hs"]
src/ScriptHs/Parser.hs view
@@ -18,9 +18,11 @@ module ScriptHs.Parser (     ScriptFile (..),     CabalMeta (..),+    CompileDirective (..),     SourceRepoPin (..),     Line (..),     parseScript,+    parseScriptNumbered,     mergeMetas, ) where @@ -28,17 +30,30 @@ 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:@@ -116,23 +131,49 @@ ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = [], metaPackages = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = []} -} parseScript :: Text -> ScriptFile-parseScript input =-    let textLines = T.lines input-        parsedLines = map parseLine textLines-        (metas, code) = partitionLines parsedLines-        meta = mergeMetas metas-     in ScriptFile{scriptMeta = meta, scriptLines = code}+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 -partitionLines :: [RawLine] -> ([CabalMeta], [Line])-partitionLines = foldr go ([], [])-  where-    go (RawCabalMeta m) (ms, cs) = (m : ms, cs)-    go (RawCode c) (ms, cs) = (ms, c : cs)- mergeMetas :: [CabalMeta] -> CabalMeta mergeMetas ms =     CabalMeta@@ -147,7 +188,21 @@ 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
src/ScriptHs/Render.hs view
@@ -30,6 +30,7 @@     toPieces,     classify,     lineText,+    unRewriteSplice, ) where  import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
+ src/ScriptHs/Repl.hs view
@@ -0,0 +1,166 @@+{-# LANGUAGE OverloadedStrings #-}++{- | The GHCi code scripths injects into the throwaway repl session — the+auto-print preamble, the working-directory and Template Haskell @cd@ splices, and+the batch terminator — plus scrubbing of scripths' own identifiers out of+captured output.++Every injected name is qualified against a base module /other than/ @Prelude@.+Importing @Prelude@ — even @qualified@ — suppresses GHC's implicit @import+Prelude@ and would strip unqualified names from the user's own cells, whereas+importing any other base module does not. That is what lets a notebook run under+@NoImplicitPrelude@ or a custom prelude. The @ScripthsInternal*@ aliases are+listed once in 'scripthsInternalQualifiers' so the generators and+'scrubInternalNames' cannot drift apart.+-}+module ScriptHs.Repl (+    scripthsIO,+    scripthsInternalQualifiers,+    scrubInternalNames,+    autoPrintDirective,+    cdDirective,+    compileCdSetup,+    compileCdTo,+    replEvalExpr,+) where++import qualified Data.Text as T++{- | Qualified alias for base's @System.IO@ in scripths-injected GHCi statements+(autoprint and the notebook block markers). We qualify against @System.IO@ (not+@Prelude@) so the injected code resolves @print@\/@putStrLn@ under+@NoImplicitPrelude@ without suppressing the user's implicit @Prelude@.+-}+scripthsIO :: T.Text+scripthsIO = "ScripthsInternalIO"++{- | Each @ScripthsInternal*@ alias scripths injects, paired with the real base+module it stands for. Single source of truth shared by the generators above and+'scrubInternalNames' so they cannot drift.+-}+scripthsInternalAliases :: [(T.Text, T.Text)]+scripthsInternalAliases =+    [ (scripthsIO, "System.IO")+    , ("ScripthsInternalShow", "Text.Show")+    , ("ScripthsInternalStr", "Data.String")+    , ("ScripthsInternalMonad", "Control.Monad")+    , ("ScripthsInternalApplicative", "Control.Applicative")+    , ("ScripthsInternalTH", "Language.Haskell.TH.Syntax")+    , ("ScripthsInternalDir", "System.Directory")+    ]++-- | The qualified module aliases scripths injects into the GHCi session.+scripthsInternalQualifiers :: [T.Text]+scripthsInternalQualifiers = map fst scripthsInternalAliases++{- | Strip scripths' internal identifiers from captured GHCi output so a user's+diagnostic reads like a vanilla GHCi one rather than exposing the synthetic+preamble:++  * a /qualified/ use loses its qualifier (@ScripthsInternalStr.IsString@ -> @IsString@);+  * a /bare/ alias (e.g. @Could not load module \'ScripthsInternalDir\'@) becomes+    the real module it stands for (@System.Directory@);+  * the auto-print method @scripthsAutoPrint@ becomes @print@ and its class+    @ScripthsAutoPrint@ becomes @Show@ (what GHCi shows without scripths).++These tokens are deliberately exotic, so a blind 'T.replace' over user-visible+output is very unlikely to collide with real content (a cell that literally+prints one of them as data would be rewritten — an accepted trade-off).+-}+scrubInternalNames :: T.Text -> T.Text+scrubInternalNames t0 = foldl (\acc (n, r) -> T.replace n r acc) t0 replacements+  where+    -- Order matters: strip qualified @Alias.@ uses first, then the method and+    -- class names, then map any /remaining/ bare alias to its real module.+    replacements =+        [(a <> ".", "") | (a, _) <- scripthsInternalAliases]+            ++ [ ("scripthsAutoPrint", "print")+               , ("ScripthsAutoPrint", "Show")+               ]+            ++ [(a, m) | (a, m) <- scripthsInternalAliases]++{- | Make GHCi auto-print a trailing 'String' result /raw/ (via 'putStr')+instead of @show@-escaping it into a quoted one-line literal. This lets a+notebook cell end in @df |> toMarkdown'@ and have the markdown actually render,+with no explicit 'putStrLn'. Every non-'String' result still prints via @show@,+so tuples, @Either@, dataframes, etc. are unaffected.++Every name is qualified against a base module other than @Prelude@ (so the+directive compiles under @NoImplicitPrelude@ / a custom prelude without+suppressing the user's implicit @Prelude@).+-}+autoPrintDirective :: T.Text+autoPrintDirective =+    T.unlines+        [ ":set -XFlexibleInstances -XUndecidableInstances"+        , "import qualified System.IO as " <> scripthsIO+        , "import qualified Text.Show as ScripthsInternalShow"+        , "import qualified Data.String as ScripthsInternalStr"+        , "class ScripthsAutoPrint a where scripthsAutoPrint :: a -> "+            <> scripthsIO+            <> ".IO ()"+        , "instance {-# OVERLAPPING #-} ScripthsAutoPrint ScripthsInternalStr.String where scripthsAutoPrint = "+            <> scripthsIO+            <> ".putStr"+        , "instance {-# OVERLAPPABLE #-} ScripthsInternalShow.Show a => ScripthsAutoPrint a where scripthsAutoPrint = "+            <> scripthsIO+            <> ".print"+        , ":set -interactive-print scripthsAutoPrint"+        ]++{- | A GHCi preamble that changes the /process/ working directory to the dir+@scripths@ was invoked from, so a script can read @./data/foo.csv@ relative to+the user's working tree (the throwaway project lives under @~\/.scripths@).++We use @System.Directory.setCurrentDirectory@ rather than GHCi's @:cd@: @:cd@+unloads every loaded module (which breaks the @cabal repl@ session), whereas a+plain IO action does not. @show@ renders a correctly escaped Haskell string+literal, so paths containing spaces or quotes are safe. @directory@ is added to+the synthetic package's dependencies (see @renderCabalFile@) so the import+always resolves.+-}+cdDirective :: FilePath -> T.Text+cdDirective dir =+    T.pack $+        unlines+            [ "import qualified System.Directory as ScripthsInternalDir"+            , "ScripthsInternalDir.setCurrentDirectory " <> show dir+            ]++{- | Enable @TemplateHaskell@ and bring TH's @runIO@ (plus the @(>>)@\/@pure@ the+splice sequences with) into scope for the compile-time chdir splices, all+qualified so they resolve under @NoImplicitPrelude@. Emitted only when the+notebook uses Template Haskell.+-}+compileCdSetup :: T.Text+compileCdSetup =+    T.unlines+        [ ":set -XTemplateHaskell"+        , "import qualified Language.Haskell.TH.Syntax as ScripthsInternalTH"+        , "import qualified Control.Monad as ScripthsInternalMonad"+        , "import qualified Control.Applicative as ScripthsInternalApplicative"+        ]++{- | A GHCi top-level auto-splice (type @Q [Dec]@) running @setCurrentDirectory@+at compile time, moving the process the /later/ splices compile in. Used pointed+at the user's cwd before the blocks and back at the project dir after them.+-}+compileCdTo :: FilePath -> T.Text+compileCdTo dir =+    T.unlines+        [ ":{"+        , "_ = (); ScripthsInternalTH.runIO (ScripthsInternalDir.setCurrentDirectory "+            <> T.pack (show dir)+            <> ") ScripthsInternalMonad.>> ScripthsInternalApplicative.pure []"+        , ":}"+        ]++{- | The expression GHCi evaluates (via @-e@) after sourcing the script, to run+the session non-interactively and exit. A silent @hFlush stdout@ rather than+@return ()@, qualified through 'scripthsIO' (which @-e@ sees from the script's+imports) so it needs no Prelude under @NoImplicitPrelude@ / a custom prelude.+Written @hFlush(stdout)@ with no space: cabal splits a @--repl-option@ value on+whitespace, and Haskell's @f(x)@ juxtaposition keeps it a single application.+-}+replEvalExpr :: T.Text+replEvalExpr = scripthsIO <> ".hFlush(" <> scripthsIO <> ".stdout)"
src/ScriptHs/Run.hs view
@@ -1,13 +1,32 @@ {-# LANGUAGE NamedFieldPuns #-} -module ScriptHs.Run where+{- | Assembling and running the throwaway cabal project scripths drives for a+script or notebook. The GHCi code injected into that project's repl lives in+"ScriptHs.Repl".+-}+module ScriptHs.Run (+    RunOptions (..),+    defaultRunOptions,+    runScript,+    runScriptCapture, -import Control.Monad (filterM, unless, when)-import Data.Char (isAlphaNum)-import Data.List (intercalate, nub)+    -- * Internals exposed for testing+    cabalArgs,+    deriveProjectName,+    renderCabalFile,+    renderCabalProject,+    usesTemplateHaskell,+) where++import Control.Monad (filterM, when)+import Data.Bits (xor)+import Data.Char (isAlphaNum, isAscii, ord)+import Data.List (foldl', intercalate, nub) import Data.Maybe (listToMaybe) import qualified Data.Text as T import qualified Data.Text.IO as TIO+import Data.Word (Word32)+import Numeric (showHex) import ScriptHs.Parser (     CabalMeta (..),     Line (..),@@ -15,6 +34,14 @@     SourceRepoPin (..),  ) import ScriptHs.Render (toGhciScript)+import ScriptHs.Repl (+    autoPrintDirective,+    cdDirective,+    compileCdSetup,+    compileCdTo,+    replEvalExpr,+    scrubInternalNames,+ ) import System.Directory (     createDirectoryIfMissing,     doesFileExist,@@ -25,7 +52,7 @@  ) import System.Exit (ExitCode (..), exitWith) import System.FilePath (takeDirectory, takeExtension, (</>))-import System.IO (stderr)+import System.IO (hSetEncoding, stderr, utf8) import System.Process (     CreateProcess (cwd, delegate_ctlc, std_err, std_out),     StdStream (UseHandle),@@ -70,6 +97,30 @@         ensureProject opts userCwd scriptAbsPath (scriptMeta sf) (scriptLines sf)     cont userCwd projectDir (projectDir </> "script.ghci") +{- | Source for the throwaway executable's @Main.hs@. It only needs to typecheck+(the repl never runs it), and is written prelude-agnostically so it compiles even+when the notebook enables @NoImplicitPrelude@ for the executable.+-}+syntheticMainHs :: T.Text+syntheticMainHs =+    T.unlines+        [ "import qualified System.IO"+        , "import qualified Control.Applicative"+        , "main :: System.IO.IO ()"+        , "main = Control.Applicative.pure ()"+        ]++{- | Write @content@ to @path@ only when it differs from what's already there, so+a stable file (the synthetic @Main.hs@) is not rewritten — and so recompiled — on+every run, while a stale one from an older scripths is healed. Reads strictly+(via "Data.Text.IO") so the handle is closed before the rewrite.+-}+writeFileIfChanged :: FilePath -> T.Text -> IO ()+writeFileIfChanged path content = do+    exists <- doesFileExist path+    existing <- if exists then TIO.readFile path else pure ""+    when (existing /= content) (TIO.writeFile path content)+ -- | Warn (once each) about unrecognised @-- cabal:@ directive keys. warnUnknownKeys :: [T.Text] -> IO () warnUnknownKeys keys =@@ -77,19 +128,36 @@         (\k -> TIO.hPutStrLn stderr ("scripths: warning: unknown '-- cabal:' key: " <> k))         (nub keys) -{- | Turn a script path into a valid cabal package name: non-alphanumerics become-@-@, runs of @-@ collapse to one, and leading/trailing @-@ are stripped (so a name-like @_probe.md@ does not yield an internal @--@, which cabal rejects).+{- | Turn an /absolute/ script path into a valid, collision-free cabal package+name (also used as the @~\/.scripths@ subdir). Only ASCII alphanumerics survive —+other characters (incl. non-ASCII letters cabal would reject) become @-@, runs of+@-@ collapse, and leading\/trailing @-@ are stripped. A readable stem alone would+collide (@foo.md@ and @foo-md@ both sanitise to @foo-md@) and could be empty, so+a stable hash of the full path is appended after an @-h@ marker — distinct paths+never share a project dir, and the trailing component always begins with a letter+(a valid cabal name). -} deriveProjectName :: FilePath -> String-deriveProjectName =-    trimDash . collapseDashes . map (\c -> if isAlphaNum c then c else '-')+deriveProjectName path = stem ++ "-h" ++ pathHash path   where+    sanitised = trimDash (collapseDashes (map asciiAlnumOrDash path))+    stem = if null sanitised then "scripths-script" else sanitised+    asciiAlnumOrDash c = if isAscii c && isAlphaNum c then c else '-'     collapseDashes ('-' : xs) = '-' : collapseDashes (dropWhile (== '-') xs)     collapseDashes (x : xs) = x : collapseDashes xs     collapseDashes [] = []     trimDash = f . f where f = reverse . dropWhile (== '-') +{- | A short, stable hex hash of a string (FNV-1a over 'Word32'); used to keep+distinct script paths from colliding on the same @~\/.scripths@ project dir+without pulling in a hashing dependency.+-}+pathHash :: String -> String+pathHash s = showHex (foldl' step 2166136261 s) ""+  where+    step :: Word32 -> Char -> Word32+    step h c = (h `xor` fromIntegral (ord c)) * 16777619+ ensureProject ::     RunOptions -> FilePath -> FilePath -> CabalMeta -> [Line] -> IO FilePath ensureProject opts userCwd scriptAbsPath meta scriptCode = do@@ -118,8 +186,7 @@     let th = usesTemplateHaskell meta scriptCode         extraDeps = localNames ++ ["template-haskell" | th]     let mainHsPath = projectDir </> "Main.hs"-    mainHsExists <- doesFileExist mainHsPath-    unless mainHsExists $ writeFile mainHsPath "main :: IO ()\nmain = pure ()\n"+    writeFileIfChanged mainHsPath syntheticMainHs     writeFile         (projectDir </> (name ++ ".cabal"))         (renderCabalFile name extraDeps meta)@@ -135,41 +202,6 @@         (autoPrintDirective <> cdDirective userCwd <> body)     pure projectDir -{- | Make GHCi auto-print a trailing 'String' result /raw/ (via 'putStr')-instead of @show@-escaping it into a quoted one-line literal. This lets a-notebook cell end in @df |> toMarkdown'@ and have the markdown actually render,-with no explicit 'putStrLn'. Every non-'String' result still prints via @show@,-so tuples, @Either@, dataframes, etc. are unaffected.--}-autoPrintDirective :: T.Text-autoPrintDirective =-    T.unlines-        [ ":set -XFlexibleInstances -XUndecidableInstances"-        , "class ScripthsAutoPrint a where scripthsAutoPrint :: a -> IO ()"-        , "instance {-# OVERLAPPING #-} ScripthsAutoPrint String where scripthsAutoPrint = putStr"-        , "instance {-# OVERLAPPABLE #-} Show a => ScripthsAutoPrint a where scripthsAutoPrint = print"-        , ":set -interactive-print scripthsAutoPrint"-        ]--{- | A GHCi preamble that changes the /process/ working directory to the dir-@scripths@ was invoked from, so a script can read @./data/foo.csv@ relative to-the user's working tree (the throwaway project lives under @~\/.scripths@).--We use @System.Directory.setCurrentDirectory@ rather than GHCi's @:cd@: @:cd@-unloads every loaded module (which breaks the @cabal repl@ session), whereas a-plain IO action does not. @show@ renders a correctly escaped Haskell string-literal, so paths containing spaces or quotes are safe. @directory@ is added to-the synthetic package's dependencies (see 'renderCabalFile') so the import-always resolves.--}-cdDirective :: FilePath -> T.Text-cdDirective dir =-    T.pack $-        unlines-            [ "import qualified System.Directory as ScripthsInternalDir"-            , "ScripthsInternalDir.setCurrentDirectory " <> show dir-            ]- {- | Does this notebook use Template Haskell (a @TemplateHaskell@ ext/pragma, a @template-haskell@ dep, or a @$(…)@ splice line)? If so its splices read files at compile time, so we bracket the body with compile-time chdirs ('compileCdTo').@@ -185,30 +217,6 @@     isSpliceLine (Pragma t) = "TemplateHaskell" `T.isInfixOf` t     isSpliceLine _ = False -{- | Enable @TemplateHaskell@ and bring TH's @runIO@ into scope for the-compile-time chdir splices. Emitted only when 'usesTemplateHaskell'.--}-compileCdSetup :: T.Text-compileCdSetup =-    T.unlines-        [ ":set -XTemplateHaskell"-        , "import qualified Language.Haskell.TH.Syntax as ScripthsInternalTH"-        ]--{- | A GHCi top-level auto-splice (type @Q [Dec]@) running @setCurrentDirectory@-at compile time, moving the process the /later/ splices compile in. Used pointed-at the user's cwd before the blocks and back at the project dir after them.--}-compileCdTo :: FilePath -> T.Text-compileCdTo dir =-    T.unlines-        [ ":{"-        , "_ = (); ScripthsInternalTH.runIO (ScripthsInternalDir.setCurrentDirectory "-            <> T.pack (show dir)-            <> ") Prelude.>> Prelude.pure []"-        , ":}"-        ]- {- | First line of a scripths-generated @cabal.project@. We regenerate only files that carry it (or the legacy @packages: .@ default), never hand-edited ones. -}@@ -345,7 +353,7 @@     , "--project-dir=" ++ projectDir     , "--repl-option=-ghci-script=" ++ ghciPath     , "--repl-option=-e"-    , "--repl-option=return()"+    , "--repl-option=" ++ T.unpack replEvalExpr     ]  {- | Run the repl, capturing stdout and stderr as one /ordered/ stream so a@@ -356,6 +364,9 @@ captureGhc userCwd projectDir ghciPath = do     let args = "-v0" : cabalArgs projectDir ghciPath     (readEnd, writeEnd) <- createPipe+    -- GHC writes UTF-8 (cell output and the Unicode punctuation in its+    -- diagnostics); decode the pipe as UTF-8 so it is not mangled into mojibake.+    hSetEncoding readEnd utf8     (_, _, _, ph) <-         createProcess             (proc "cabal" args)@@ -368,15 +379,31 @@     case code of         ExitSuccess -> pure out         ExitFailure _ -> do-            TIO.hPutStr stderr out+            TIO.hPutStr stderr (scrubInternalNames out)             exitWith code +{- | Run the repl for @.ghci@\/@.hs@ scripts: stdout is inherited so it streams+live (and @Ctrl-C@ works via @delegate_ctlc@), while stderr is captured, decoded+as UTF-8, and passed through 'scrubInternalNames' before being forwarded — so a+failing script no longer leaks scripths-internal names in its diagnostics. (We+read stderr to EOF as we go, so the pipe never fills; the trade-off is that+stderr is flushed after the run rather than perfectly interleaved with stdout.)+-} runGhc :: FilePath -> FilePath -> FilePath -> IO () runGhc userCwd projectDir ghciPath = do     let args = "-v0" : cabalArgs projectDir ghciPath-        cp = (proc "cabal" args){cwd = Just userCwd, delegate_ctlc = True}+    (errRead, errWrite) <- createPipe+    hSetEncoding errRead utf8+    let cp =+            (proc "cabal" args)+                { cwd = Just userCwd+                , delegate_ctlc = True+                , std_err = UseHandle errWrite+                }     (_, _, _, ph) <- createProcess cp+    errOut <- TIO.hGetContents errRead     code <- waitForProcess ph+    TIO.hPutStr stderr (scrubInternalNames errOut)     case code of         ExitSuccess -> pure ()         ExitFailure _ -> exitWith code
+ src/ScriptHs/Version.hs view
@@ -0,0 +1,205 @@+{-# LANGUAGE OverloadedStrings #-}++{- | The scripths version and the first-line /version tag/ that marks a script or+notebook with the scripths version it was written for.++The tag lets scripths warn when a file was authored by a newer release than the+binary running it (so a user knows parsing may be incomplete). It is spelled the+way each file type's comments are spelled:++@+-- scripths: 0.4.1.0          (.ghci \/ .hs)+\<!-- scripths: 0.4.1.0 --\>    (.md \/ .markdown)+@++The emitted version is always 'scripthsVersion', a PVP value (dot-separated+decimals), so the notebook tag's comment text can never contain @--@ or a+trailing @-@ — it is a well-formed HTML comment by construction.+-}+module ScriptHs.Version (+    scripthsVersion,+    scripthsVersionText,+    TagStyle (..),+    tagStyleFor,+    versionTag,+    parseTagLine,+    tagVersion,+    stampVersion,+    newerVersionWarning,+) where++import Data.Char (isAlphaNum, isDigit, isSpace)+import Data.Maybe (fromMaybe, isJust)+import Data.Text (Text)+import qualified Data.Text as T+import Data.Version (Version, makeVersion, showVersion)+import System.FilePath (takeExtension)+import Text.Read (readMaybe)++import Paths_scripths (version)++-- | The running scripths version (single source of truth: the @.cabal@ @version@).+scripthsVersion :: Version+scripthsVersion = version++scripthsVersionText :: Text+scripthsVersionText = T.pack (showVersion scripthsVersion)++-- | How the version tag is spelled in a given file type.+data TagStyle+    = -- | @-- scripths: X@, for @.ghci@\/@.hs@ scripts.+      ScriptTag+    | -- | @\<!-- scripths: X --\>@, for @.md@\/@.markdown@ notebooks.+      NotebookTag+    deriving (Show, Eq)++-- | The tag style for a path, by extension (@.md@\/@.markdown@ are notebooks).+tagStyleFor :: FilePath -> TagStyle+tagStyleFor path+    | ext `elem` [".md", ".markdown"] = NotebookTag+    | otherwise = ScriptTag+  where+    ext = T.unpack (T.toLower (T.pack (takeExtension path)))++-- | The tag line for the current version, in the given style.+versionTag :: TagStyle -> Text+versionTag ScriptTag = "-- scripths: " <> scripthsVersionText+versionTag NotebookTag = "<!-- scripths: " <> scripthsVersionText <> " -->"++{- | The scripths version a single line declares, if it is a version tag of the+given style. Tolerant of a UTF-8 BOM, a trailing @\\r@ (CRLF), trailing+whitespace, and up to three leading spaces. The notebook output marker+@\<!-- scripths:mime … --\>@ is /not/ a version tag (its post-colon token is+@mime@, not a version), so it parses to 'Nothing'.+-}+parseTagLine :: TagStyle -> Text -> Maybe Version+parseTagLine style line = do+    let trimmed = normalizeLine line+    inner <- case style of+        ScriptTag -> T.stripPrefix "-- scripths:" trimmed+        NotebookTag ->+            T.stripPrefix "<!-- scripths:" trimmed+                >>= fmap T.stripEnd . T.stripSuffix "-->" . T.stripEnd+    -- Require whitespace after the colon, so @scripths:mime@ never matches.+    rest <- requireLeadingSpace inner+    parseVersionToken rest++-- | Strip a leading BOM, a trailing @\\r@\/whitespace, and up to three leading spaces.+normalizeLine :: Text -> Text+normalizeLine =+    dropLeadingSpaces (3 :: Int) . T.stripEnd . T.dropWhileEnd (== '\r') . dropBom+  where+    dropBom t = fromMaybe t (T.stripPrefix "\65279" t)+    dropLeadingSpaces 0 t = t+    dropLeadingSpaces n t = case T.uncons t of+        Just (' ', r) -> dropLeadingSpaces (n - 1) r+        _ -> t++requireLeadingSpace :: Text -> Maybe Text+requireLeadingSpace t = case T.uncons t of+    Just (c, _) | isSpace c -> Just (T.stripStart t)+    _ -> Nothing++{- | Parse a leading dotted-decimal version (e.g. @0.4.1.0@), ignoring trailing+text. Components are parsed as 'Integer' and clamped to 'maxBound' so an absurdly+long number cannot silently overflow 'Int' (which could otherwise wrap to a small+value and defeat the newer-than-binary check) — a too-big component stays "newer".+-}+parseVersionToken :: Text -> Maybe Version+parseVersionToken t =+    case T.splitOn "." token of+        parts@(_ : _)+            | not (any T.null parts) ->+                makeVersion . map clampToInt <$> traverse parseComponent parts+        _ -> Nothing+  where+    token = T.takeWhile (\c -> c == '.' || isDigit c) (T.stripStart t)+    parseComponent :: Text -> Maybe Integer+    parseComponent = readMaybe . T.unpack+    clampToInt :: Integer -> Int+    clampToInt = fromInteger . min (toInteger (maxBound :: Int))++{- | The scripths version a file declares, if any. Scans a small top-of-file+window: skip a leading @#!@ shebang (scripts) or a leading @---@ YAML+frontmatter block (notebooks), then read the first non-blank line.+-}+tagVersion :: TagStyle -> Text -> Maybe Version+tagVersion style contents =+    case dropWhile isBlank (snd (splitPreamble style (T.lines contents))) of+        (l : _) -> parseTagLine style l+        [] -> Nothing+  where+    isBlank = T.null . T.strip++{- | Split off a leading preamble that the tag sits /after/: a @#!@ shebang for+scripts, or a @---@…@---@ YAML frontmatter block for notebooks. Returns+@(preamble, rest)@.++A leading @---@ is treated as frontmatter only when the fenced block actually+looks like YAML (it closes with @---@ and contains at least one @key:@ line).+Otherwise the @---@ is an ordinary Markdown /thematic break/ and is left in the+body — so 'stampVersion' inserts the tag /above/ it rather than corrupting the+document by splicing into the middle (the @-i@ data-loss trap).+-}+splitPreamble :: TagStyle -> [Text] -> ([Text], [Text])+splitPreamble ScriptTag ls@(l : rest)+    | "#!" `T.isPrefixOf` T.stripStart l = ([l], rest)+    | otherwise = ([], ls)+splitPreamble NotebookTag ls@(l : rest)+    | T.strip l == "---"+    , (block, closing : afterClosing) <- break ((== "---") . T.strip) rest+    , any looksLikeYamlKey block =+        (l : block ++ [closing], afterClosing)+    | otherwise = ([], ls)+splitPreamble _ [] = ([], [])++{- | Does a line look like a YAML mapping entry (@key:@)? Used to tell a real+frontmatter block from a Markdown thematic break delimited by @---@.+-}+looksLikeYamlKey :: Text -> Bool+looksLikeYamlKey line =+    let (key, afterKey) = T.span (\c -> isAlphaNum c || c == '_' || c == '-') (T.stripStart line)+     in not (T.null key) && ":" `T.isPrefixOf` afterKey++{- | Ensure @contents@ carries the current version tag at the top of its body:+replace an existing tag line in place, or insert one after any shebang\/+frontmatter preamble. Exactly one blank line separates the tag from the body.+Idempotent.+-}+stampVersion :: TagStyle -> Text -> Text+stampVersion style contents =+    rejoin (preamble ++ tag : separator ++ body)+  where+    tag = versionTag style+    (preamble, rest) = splitPreamble style (T.lines contents)+    body = dropWhile isBlankLine (dropExistingTag rest)+    separator = ["" | not (null body)]+    dropExistingTag ls =+        let (_blanks, rest') = span isBlankLine ls+         in case rest' of+                (l : more) | isJustTag l -> more+                _ -> ls+    isJustTag l = isJust (parseTagLine style l)+    isBlankLine = T.null . T.strip++{- | Re-join lines, trimming leading\/trailing blank lines and re-adding a single+trailing newline iff there is content ('T.lines' drops the final newline).+-}+rejoin :: [Text] -> Text+rejoin ls =+    let trimmed = reverse (dropBlank (reverse (dropBlank ls)))+     in if null trimmed then "" else T.intercalate "\n" trimmed <> "\n"+  where+    dropBlank = dropWhile (T.null . T.strip)++-- | @Just@ a warning iff @declared@ is newer than the running scripths.+newerVersionWarning :: Version -> Maybe Text+newerVersionWarning declared+    | declared > scripthsVersion =+        Just $+            "file declares scripths "+                <> T.pack (showVersion declared)+                <> " but you are running "+                <> scripthsVersionText+                <> "; parsing may be incomplete \8212 consider upgrading scripths"+    | otherwise = Nothing
test/Main.hs view
@@ -2,11 +2,15 @@  import Test.Tasty (defaultMain, testGroup) +import Test.Compiled (compiledTests)+import Test.Integration (integrationTests) import Test.Markdown (markdownTests) import Test.Notebook (notebookTests) import Test.Parser (parseTests) import Test.Render (renderTests)+import Test.Repl (replTests) import Test.Run (runTests)+import Test.Version (versionTests)  main :: IO () main =@@ -14,8 +18,12 @@         testGroup             "ScriptHs"             [ parseTests+            , compiledTests             , renderTests             , markdownTests             , notebookTests+            , replTests             , runTests+            , versionTests+            , integrationTests             ]
+ test/Test/Compiled.hs view
@@ -0,0 +1,186 @@+module Test.Compiled (compiledTests) where++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++import Data.Text (Text)+import qualified Data.Text as T+import ScriptHs.Compiled (+    CellChunk (..),+    CompileIssue (..),+    checkCompilable,+    isValidModuleName,+    linePragmaTag,+    parseLinePragmaTag,+    renderCompiledModule,+ )+import ScriptHs.Parser (+    CompileDirective (..),+    ScriptFile (scriptCompile, scriptLines),+    Line (..),+    parseScript,+    parseScriptNumbered,+ )++numbered :: Text -> [(Int, Line)]+numbered = snd . parseScriptNumbered++compiledTests :: TestTree+compiledTests =+    testGroup+        "Compiled"+        [ directiveTests+        , numberedTests+        , checkTests+        , nameTests+        , tagTests+        , renderTests+        ]++directiveTests :: TestTree+directiveTests =+    testGroup+        "compile directive"+        [ testCase "absent by default" $+            scriptCompile (parseScript "x = 1\n") @?= Nothing+        , testCase "bare directive" $+            scriptCompile (parseScript "-- compile\nx = 1\n")+                @?= Just CompileDefault+        , testCase "named directive" $+            scriptCompile (parseScript "-- compile: Training\nx = 1\n")+                @?= Just (CompileNamed "Training")+        , testCase "dotted name" $+            scriptCompile (parseScript "-- compile: Training.Core\nx = 1\n")+                @?= Just (CompileNamed "Training.Core")+        , testCase "directive removed from script lines" $+            scriptLines (parseScript "-- compile\nx = 1\n")+                @?= [HaskellLine "x = 1"]+        , testCase "directive after cabal lines" $ do+            let sf = parseScript "-- cabal: build-depends: text\n-- compile\nx = 1\n"+            scriptCompile sf @?= Just CompileDefault+        , testCase "whitespace tolerated" $+            scriptCompile (parseScript "--  compile:  Training \nx = 1\n")+                @?= Just (CompileNamed "Training")+        , testCase "unrelated comment is not a directive" $+            scriptCompile (parseScript "-- compiled fast\nx = 1\n") @?= Nothing+        ]++numberedTests :: TestTree+numberedTests =+    testGroup+        "parseScriptNumbered"+        [ testCase "plain lines numbered from 1" $+            numbered "x = 1\ny = 2\n"+                @?= [(1, HaskellLine "x = 1"), (2, HaskellLine "y = 2")]+        , testCase "directive lines keep following numbering" $+            numbered "-- cabal: build-depends: text\n-- compile\nx = 1\n"+                @?= [(3, HaskellLine "x = 1")]+        , testCase "agrees with parseScript lines" $ do+            let src = "-- compile\nimport Data.List\n\nf :: Int -> Int\nf x = x\n"+            map snd (numbered src) @?= scriptLines (parseScript src)+        ]++checkTests :: TestTree+checkTests =+    testGroup+        "checkCompilable"+        [ testCase "declarations pass" $+            checkCompilable (numbered "f :: Int -> Int\nf x = x + 1\n") @?= []+        , testCase "imports and pragmas pass" $+            checkCompilable+                (numbered "{-# LANGUAGE LambdaCase #-}\nimport Data.List\n")+                @?= []+        , testCase "data declaration passes" $+            checkCompilable (numbered "data T = A | B\n") @?= []+        , testCase "bare expression rejected with its line" $ do+            let issues = checkCompilable (numbered "f x = x\n\nprint 3\n")+            map ciLine issues @?= [3]+        , testCase "monadic bind rejected" $ do+            let issues = checkCompilable (numbered "x <- readLn\n")+            map ciLine issues @?= [1]+        , testCase ":set -X allowed, other ghci commands rejected" $ do+            checkCompilable (numbered ":set -XLambdaCase\nf x = x\n") @?= []+            map ciLine (checkCompilable (numbered ":type f\nf x = x\n")) @?= [1]+        , testCase "TH splice allowed" $+            checkCompilable (numbered "$(pure [])\n") @?= []+        ]++nameTests :: TestTree+nameTests =+    testGroup+        "isValidModuleName"+        [ testCase "simple" $ isValidModuleName "Training" @?= True+        , testCase "dotted" $ isValidModuleName "Training.Core" @?= True+        , testCase "lowercase rejected" $ isValidModuleName "training" @?= False+        , testCase "empty segment rejected" $ isValidModuleName "A..B" @?= False+        , testCase "empty rejected" $ isValidModuleName "" @?= False+        , testCase "spaces rejected" $ isValidModuleName "A B" @?= False+        ]++tagTests :: TestTree+tagTests =+    testGroup+        "line pragma tags"+        [ testCase "round trip" $+            parseLinePragmaTag (linePragmaTag 42) @?= Just 42+        , testCase "non-tag rejected" $+            parseLinePragmaTag "Training" @?= Nothing+        ]++renderTests :: TestTree+renderTests =+    testGroup+        "renderCompiledModule"+        [ testCase "module header and decl with LINE pragma" $ do+            let src = renderCompiledModule "Training" [] [] [chunk 7 "f :: Int -> Int\nf x = x + 1\n"]+            assertContains src "module Training where"+            assertContains src ("{-# LINE 1 \"" <> linePragmaTag 7 <> "\" #-}")+            assertContains src "f x = x + 1"+        , testCase "default extensions become pragmas" $ do+            let src = renderCompiledModule "M" ["LambdaCase"] [] [chunk 1 "f x = x\n"]+            assertContains src "{-# LANGUAGE LambdaCase #-}"+        , testCase "imports hoisted with their own pragma and line" $ do+            let src =+                    renderCompiledModule+                        "M"+                        []+                        []+                        [chunk 3 "f x = x\nimport Data.List (sort)\ng y = sort y\n"]+            assertContains src ("{-# LINE 2 \"" <> linePragmaTag 3 <> "\" #-}")+            assertContains src "import Data.List (sort)"+            -- imports appear before declarations+            assertBool "import before decl" $+                textIndex "import Data.List" src < textIndex "f x = x" src+        , testCase "extra imports rendered before cell imports" $ do+            let src = renderCompiledModule "M" [] ["import Helpers"] [chunk 1 "f x = x\n"]+            assertContains src "import Helpers"+        , testCase ":set -X contributes a pragma" $ do+            let src = renderCompiledModule "M" [] [] [chunk 1 ":set -XGADTs\nf x = x\n"]+            assertContains src "{-# LANGUAGE GADTs #-}"+        , testCase "pragmas deduped" $ do+            let src =+                    renderCompiledModule+                        "M"+                        ["GADTs"]+                        []+                        [chunk 1 "{-# LANGUAGE GADTs #-}\nf x = x\n"]+            T.count "LANGUAGE GADTs" src @?= 1+        , testCase "unused-import warnings suppressed" $ do+            let src = renderCompiledModule "M" [] [] [chunk 1 "f x = x\n"]+            assertContains src "{-# OPTIONS_GHC -Wno-unused-imports #-}"+        , testCase "two chunks keep their own tags" $ do+            let src =+                    renderCompiledModule+                        "M"+                        []+                        []+                        [chunk 1 "f x = x\n", chunk 2 "g y = y\n"]+            assertContains src (linePragmaTag 1)+            assertContains src (linePragmaTag 2)+        ]+  where+    chunk cid src = CellChunk{ccTag = linePragmaTag cid, ccLines = numbered src}+    assertContains hay needle =+        assertBool (T.unpack ("missing: " <> needle <> "\nin:\n" <> hay)) $+            needle `T.isInfixOf` hay+    textIndex needle hay = T.length (fst (T.breakOn needle hay))
+ test/Test/Integration.hs view
@@ -0,0 +1,47 @@+module Test.Integration (integrationTests) where++import qualified Data.Text as T+import System.Environment (lookupEnv)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase)++import ScriptHs.Notebook (processNotebook)+import ScriptHs.Run (defaultRunOptions)++{- | Out-of-band integration test: it actually drives @cabal repl@ (slow, needs a+toolchain), so it is gated behind @SCRIPTHS_INTEGRATION=1@ and is a no-op in the+default fast/pure suite. It is the only test that proves the injected preamble+genuinely /compiles and runs/ under @NoImplicitPrelude@ — what the string-level+"prelude-agnostic" checks in "Test.Repl" can only approximate — and that the+rendered notebook leaks none of scripths' internals end-to-end.+-}+integrationTests :: TestTree+integrationTests =+    testGroup+        "Integration (gated by SCRIPTHS_INTEGRATION=1)"+        [ testCase "a NoImplicitPrelude notebook runs and renders, no internals leaked" $ do+            gated <- lookupEnv "SCRIPTHS_INTEGRATION"+            case gated of+                Nothing -> pure () -- skipped in the default suite+                Just _ -> do+                    let nb =+                            T.unlines+                                [ "# Integration"+                                , ""+                                , "```haskell"+                                , "-- cabal: default-extensions: NoImplicitPrelude"+                                , "import Prelude (String)"+                                , "\"rendered-ok\" :: String"+                                , "```"+                                ]+                    out <- processNotebook defaultRunOptions "/tmp/scripths-integration.md" nb+                    assertBool "renders the cell value" ("rendered-ok" `T.isInfixOf` out)+                    assertBool+                        "no ScripthsInternal leak"+                        (not ("ScripthsInternal" `T.isInfixOf` out))+                    assertBool+                        "no scripthsAutoPrint leak"+                        (not ("scripthsAutoPrint" `T.isInfixOf` out))+                    assertBool "no block-marker leak" (not ("SCRIPTHS_BLOCK" `T.isInfixOf` out))+                    assertBool "no compile error" (not ("error:" `T.isInfixOf` out))+        ]
test/Test/Notebook.hs view
@@ -11,16 +11,22 @@     generatedMarkedScript,     isHaskell,     isPython,+    markerStatement,     mkIndexedCodeSegments,     mkMarker,     parseBlocks,     processNotebook,+    scrubCellOutput,     splitByMarkers,  )  import ScriptHs.Parser (CabalMeta (metaDeps), Line (..)) import ScriptHs.Run (defaultRunOptions) +-- | A fixed nonce for marker tests (the real one is per-run; see 'makeNonce').+tn :: T.Text+tn = "TESTNONCE"+ notebookTests :: TestTree notebookTests =     testGroup@@ -63,32 +69,34 @@             ]         , testGroup             "mkMarker"-            [ testCase "format includes index" $ do-                mkMarker 0 @?= "---SCRIPTHS_BLOCK_0_END---"-                mkMarker 12 @?= "---SCRIPTHS_BLOCK_12_END---"+            [ testCase "format includes nonce and index" $ do+                mkMarker tn 0 @?= "---SCRIPTHS_BLOCK_" <> tn <> "_0_END---"+                mkMarker tn 12 @?= "---SCRIPTHS_BLOCK_" <> tn <> "_12_END---"+            , testCase "different nonces give different markers (anti-spoofing)" $+                assertBool "differ" (mkMarker "aaaa" 0 /= mkMarker "bbbb" 0)             ]         , testGroup             "splitByMarkers"             [ testCase "empty marker list => []" $ do-                splitByMarkers "anything" [] @?= []+                splitByMarkers tn "anything" [] @?= []             , testCase "marker not found => strip remaining as output for that idx" $ do                 let out = "  hello world  \n"-                splitByMarkers out [0] @?= [(0, "hello world")]+                splitByMarkers tn out [0] @?= [(0, "hello world")]             , testCase "single marker splits before it" $ do-                let out = T.unlines ["42", mkMarker 0, "ignored trailing"]-                splitByMarkers out [0] @?= [(0, "42")]+                let out = T.unlines ["42", mkMarker tn 0, "ignored trailing"]+                splitByMarkers tn out [0] @?= [(0, "42")]             , testCase "multiple markers split sequentially" $ do                 let out =                         T.concat                             [ "cell0\n"-                            , mkMarker 0+                            , mkMarker tn 0                             , "\ncell1\n"-                            , mkMarker 1+                            , mkMarker tn 1                             , "\ncell2\n"-                            , mkMarker 2+                            , mkMarker tn 2                             , "\n"                             ]-                splitByMarkers out [0, 1, 2]+                splitByMarkers tn out [0, 1, 2]                     @?= [ (0, "cell0")                         , (1, "cell1")                         , (2, "cell2")@@ -97,13 +105,17 @@                 let out =                         T.concat                             [ "cell0\n"-                            , mkMarker 0+                            , mkMarker tn 0                             , "\ncell1-no-marker-at-end\n"                             ]-                splitByMarkers out [0, 1]+                splitByMarkers tn out [0, 1]                     @?= [ (0, "cell0")                         , (1, "cell1-no-marker-at-end")                         ]+            , testCase "output spoofing a DIFFERENT nonce's marker is not split on" $ do+                -- A cell printing a marker with the wrong nonce can't steal a boundary.+                let out = "real" <> mkMarker "OTHER" 0 <> "data\n" <> mkMarker tn 0+                splitByMarkers tn out [0] @?= [(0, "real" <> mkMarker "OTHER" 0 <> "data")]             ]         , testGroup             "mkIndexedCodeSegments"@@ -164,22 +176,26 @@                         [ (10, [HaskellLine "print 10"])                         , (11, [HaskellLine "print 11"])                         ]-                let ls = generatedMarkedScript blocks+                let ls = generatedMarkedScript tn blocks                  assertBool "has first line" (HaskellLine "print 10" `elem` ls)                 assertBool "has second line" (HaskellLine "print 11" `elem` ls) -                let m10 = mkMarker 10-                    m11 = mkMarker 11+                let m10 = mkMarker tn 10+                    m11 = mkMarker tn 11                  assertBool-                    "has putStrLn marker 10"-                    (HaskellLine ("putStrLn " <> T.pack (show (T.unpack m10))) `elem` ls)+                    "has marker statement 10"+                    (HaskellLine (markerStatement m10) `elem` ls)                  assertBool-                    "has putStrLn marker 11"-                    (HaskellLine ("putStrLn " <> T.pack (show (T.unpack m11))) `elem` ls)+                    "has marker statement 11"+                    (HaskellLine (markerStatement m11) `elem` ls) +                assertBool+                    "marker print is prelude-qualified"+                    (T.isInfixOf ".putStrLn" (markerStatement m10))+                 assertBool "has Blank separators" (Blank `elem` ls)             ]         , testGroup@@ -230,5 +246,23 @@                             ]                 out <- processNotebook defaultRunOptions "" input                 out @?= input+            ]+        , testGroup+            "scrubCellOutput"+            [ testCase "removes a block marker that survived splitting" $ do+                let out = "line one\n" <> mkMarker tn 1 <> "\nline two"+                    scrubbed = scrubCellOutput tn [0, 1, 2] out+                assertBool "no marker leaks" (not (T.isInfixOf "SCRIPTHS_BLOCK" scrubbed))+                assertBool "keeps the stdout" (T.isInfixOf "line one" scrubbed)+            , testCase "scrubs scripths internal identifiers from a cell error" $ do+                let scrubbed =+                        scrubCellOutput tn [0] "No instance for Show .. use of scripthsAutoPrint"+                assertBool "no autoprint name" (not (T.isInfixOf "scripthsAutoPrint" scrubbed))+            , testCase "strips markers for every listed index" $ do+                let out = "a" <> mkMarker tn 0 <> "b" <> mkMarker tn 2 <> "c"+                scrubCellOutput tn [0, 1, 2] out @?= "abc"+            , testCase "a marker whose index is NOT a real cell is left intact" $ do+                let out = "real data: " <> mkMarker tn 5+                scrubCellOutput tn [0] out @?= out             ]         ]
test/Test/Parser.hs view
@@ -58,6 +58,31 @@                 case scriptLines sf of                     [Pragma t] -> assertBool "pragma" (T.isPrefixOf "{-#" t)                     other -> assertFailure $ "expected Pragma, got: " ++ show other+            , testCase "a leading -- scripths: version tag is dropped, not treated as code" $ do+                let sf = parseScript "-- scripths: 0.4.1.0\nimport Data.Text (Text)\n"+                case scriptLines sf of+                    [Import _] -> pure ()+                    other -> assertFailure $ "expected the tag dropped, got: " ++ show other+            , testCase "a -- scripths: comment in the body is kept, not silently dropped" $ do+                let sf = parseScript "x = 1\n-- scripths: just a note\ny = 2\n"+                    kept = any (lineHasText "-- scripths: just a note") (scriptLines sf)+                assertBool "mid-body comment retained" kept+            , testCase "a leading -- scripths:mime line is not a tag (kept)" $ do+                let sf = parseScript "-- scripths:mime text/plain\nimport Data.Text (Text)\n"+                    kept = any (lineHasText "-- scripths:mime text/plain") (scriptLines sf)+                assertBool "mime line retained" kept+            , testCase "leading tag then a -- cabal: line: tag dropped, dep still parsed" $ do+                let sf = parseScript "-- scripths: 1.2.3\n-- cabal: build-depends: text\nimport X\n"+                (metaDeps . scriptMeta) sf @?= ["text"]+                scriptLines sf @?= [Import "import X"]+            , testCase+                "a tag AFTER a shebang is not dropped (only the first non-blank is checked)"+                $ do+                    -- Pins current behaviour: dropLeadingVersionTag inspects the first+                    -- non-blank line (the shebang), so the tag survives as a comment line.+                    let sf = parseScript "#!/usr/bin/env scripths\n-- scripths: 1.2.3\nimport X\n"+                    assertBool "tag line retained" $+                        any (lineHasText "-- scripths: 1.2.3") (scriptLines sf)             , testCase "haskell line" $ do                 let sf = parseScript "print (5 + 5)\n"                 case scriptLines sf of@@ -225,3 +250,12 @@ notBlank :: Line -> Bool notBlank Blank = False notBlank _ = True++-- | Does a parsed line carry text containing the needle?+lineHasText :: T.Text -> Line -> Bool+lineHasText s ln = case ln of+    HaskellLine t -> s `T.isInfixOf` t+    GhciCommand t -> s `T.isInfixOf` t+    Pragma t -> s `T.isInfixOf` t+    Import t -> s `T.isInfixOf` t+    Blank -> False
+ test/Test/Repl.hs view
@@ -0,0 +1,152 @@+module Test.Repl (replTests) where++import qualified Data.Text as T+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++import ScriptHs.Repl (+    autoPrintDirective,+    cdDirective,+    compileCdSetup,+    compileCdTo,+    replEvalExpr,+    scripthsInternalQualifiers,+    scrubInternalNames,+ )++replTests :: TestTree+replTests =+    testGroup+        "Repl"+        [ compileCdToTests+        , cdDirectiveTests+        , preludeAgnosticTests+        , scrubTests+        ]++cdDirectiveTests :: TestTree+cdDirectiveTests =+    testGroup+        "cdDirective"+        [ testCase "runtime chdir via qualified System.Directory, show-escaped path" $ do+            let txt = cdDirective "/data/my project"+            assertBool "imports Directory qualified" $+                T.isInfixOf "import qualified System.Directory as ScripthsInternalDir" txt+            assertBool "calls setCurrentDirectory" $+                T.isInfixOf "ScripthsInternalDir.setCurrentDirectory" txt+            assertBool "path is show-escaped (quoted)" $+                T.isInfixOf "\"/data/my project\"" txt+            assertBool "no bare Prelude" (not (T.isInfixOf "Prelude" txt))+        ]++compileCdToTests :: TestTree+compileCdToTests =+    testGroup+        "compileCdTo"+        [ testCase "renders a compile-time setCurrentDirectory splice" $ do+            let txt = compileCdTo "/some/dir"+            assertBool "splice block" (T.isInfixOf ":{" txt)+            assertBool "chdir call" (T.isInfixOf "setCurrentDirectory" txt)+            assertBool "the target dir" (T.isInfixOf "/some/dir" txt)+        , testCase "sequences via qualified >>/pure, not bare Prelude" $ do+            let txt = compileCdTo "/some/dir"+            assertBool "qualified (>>)" (T.isInfixOf "ScripthsInternalMonad.>>" txt)+            assertBool "qualified pure" (T.isInfixOf "ScripthsInternalApplicative.pure" txt)+            assertBool "no bare Prelude" (not (T.isInfixOf "Prelude." txt))+        ]++preludeAgnosticTests :: TestTree+preludeAgnosticTests =+    testGroup+        "prelude-agnostic injection"+        -- Every name scripths injects into the repl is qualified against a base+        -- module other than Prelude, so a notebook under NoImplicitPrelude / a+        -- custom prelude still runs (and the implicit Prelude is not suppressed).+        [ testCase "autoPrintDirective qualifies through System.IO, not Prelude" $ do+            assertBool "imports System.IO qualified" $+                T.isInfixOf+                    "import qualified System.IO as ScripthsInternalIO"+                    autoPrintDirective+            assertBool "sets the auto-print" $+                T.isInfixOf "-interactive-print scripthsAutoPrint" autoPrintDirective+            assertBool "never mentions Prelude" $+                not (T.isInfixOf "Prelude" autoPrintDirective)+        , testCase "compileCdSetup imports the sequencing modules qualified" $ do+            assertBool "Control.Monad" $+                T.isInfixOf+                    "import qualified Control.Monad as ScripthsInternalMonad"+                    compileCdSetup+            assertBool "Control.Applicative" $+                T.isInfixOf+                    "import qualified Control.Applicative as ScripthsInternalApplicative"+                    compileCdSetup+        , testCase "the -e terminator is prelude-free and space-free" $ do+            assertBool+                "uses qualified hFlush"+                (T.isInfixOf "ScripthsInternalIO.hFlush" replEvalExpr)+            assertBool "no Prelude" (not (T.isInfixOf "Prelude" replEvalExpr))+            assertBool "no return" (not (T.isInfixOf "return" replEvalExpr))+            assertBool "no spaces" (not (T.isInfixOf " " replEvalExpr))+        , testCase "the full injected preamble names no Prelude dependency" $ do+            -- The whole script.ghci prefix, assembled, must not mention Prelude.+            let injected =+                    autoPrintDirective+                        <> cdDirective "/some/dir"+                        <> compileCdSetup+                        <> compileCdTo "/some/dir"+            assertBool "no Prelude anywhere" (not (T.isInfixOf "Prelude" injected))+        ]++scrubTests :: TestTree+scrubTests =+    testGroup+        "scrubInternalNames"+        -- Captured GHCi diagnostics must read like vanilla GHCi, never exposing+        -- the synthetic preamble's identifiers.+        [ testCase "rewrites scripthsAutoPrint to print" $ do+            let s =+                    scrubInternalNames+                        "arising from a use of scripthsAutoPrint\nscripthsAutoPrint it"+            assertBool "no scripthsAutoPrint" (not (T.isInfixOf "scripthsAutoPrint" s))+            assertBool "reads as print it" (T.isInfixOf "print it" s)+        , testCase "strips an internal qualifier (the IsString leak)" $+            scrubInternalNames "No instance for (ScripthsInternalStr.IsString Int)"+                @?= "No instance for (IsString Int)"+        , testCase "removes every internal qualifier" $ do+            let sample = T.unwords [q <> ".foo" | q <- scripthsInternalQualifiers]+            assertBool+                "no ScripthsInternal remains"+                (not (T.isInfixOf "ScripthsInternal" (scrubInternalNames sample)))+        , testCase "rewrites the ScripthsAutoPrint class to Show" $ do+            -- The common non-Showable-cell error is `No instance for (ScripthsAutoPrint …)`.+            let s =+                    scrubInternalNames+                        "No instance for (ScripthsAutoPrint Foo) arising from a use of scripthsAutoPrint"+            assertBool "class gone" (not (T.isInfixOf "ScripthsAutoPrint" s))+            assertBool "reads as Show" (T.isInfixOf "No instance for (Show Foo)" s)+        , testCase "a bare alias (no trailing dot) becomes its real module" $+            scrubInternalNames "Could not load module 'ScripthsInternalDir'"+                @?= "Could not load module 'System.Directory'"+        , testCase "leaves ordinary output untouched" $+            scrubInternalNames "Variable not in scope: frobnicate"+                @?= "Variable not in scope: frobnicate"+        , testCase "preserves multibyte Unicode while scrubbing (guards the UTF-8 path)" $ do+            -- GHC diagnostics carry Unicode (curly quotes, the • bullet); scrubbing an+            -- internal name beside them must not mangle the surrounding text.+            let s =+                    scrubInternalNames+                        "Variable not in scope: \8216caf\233\8217 \8226 ScripthsInternalDir.x"+            assertBool "keeps the curly-quoted café" (T.isInfixOf "\8216caf\233\8217" s)+            assertBool "keeps the bullet" (T.isInfixOf "\8226" s)+            assertBool "still scrubbed" (not (T.isInfixOf "ScripthsInternal" s))+        , testCase "scrubbing is idempotent (scrub . scrub == scrub)" $ do+            let sample =+                    T.unwords+                        [ "ScripthsInternalStr.IsString"+                        , "bare ScripthsInternalDir"+                        , "scripthsAutoPrint"+                        , "ScripthsAutoPrint"+                        ]+                once = scrubInternalNames sample+            scrubInternalNames once @?= once+        ]
test/Test/Run.hs view
@@ -1,6 +1,6 @@ module Test.Run (runTests) where -import Data.List (isPrefixOf)+import Data.List (isInfixOf, isPrefixOf) import qualified Data.Text as T import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, testCase, (@?=))@@ -8,7 +8,6 @@ import ScriptHs.Parser (CabalMeta (..), Line (..), SourceRepoPin (..)) import ScriptHs.Run (     cabalArgs,-    compileCdTo,     deriveProjectName,     renderCabalFile,     renderCabalProject,@@ -24,14 +23,20 @@         "Run"         [ testGroup             "cabalArgs"-            [ testCase "uses -ghci-script= and -e return() for batch mode" $ do+            [ testCase "uses -ghci-script= and a prelude-free -e terminator for batch mode" $ do                 let args = cabalArgs "/proj" "/proj/script.ghci"                     replyOpts = filter ("--repl-option=" `isPrefixOf`) args                 replyOpts                     @?= [ "--repl-option=-ghci-script=/proj/script.ghci"                         , "--repl-option=-e"-                        , "--repl-option=return()"+                        , "--repl-option=ScripthsInternalIO.hFlush(ScripthsInternalIO.stdout)"                         ]+            , testCase+                "the -e terminator has no spaces (cabal splits --repl-option on whitespace)"+                $ do+                    let args = cabalArgs "/proj" "/proj/script.ghci"+                        eTerm = last (filter ("--repl-option=" `isPrefixOf`) args)+                    assertBool "no space in terminator" (not (' ' `elem` eTerm))             , testCase "includes --project-dir" $ do                 let args = cabalArgs "/proj" "/proj/script.ghci"                 assertBool "--project-dir present" $@@ -57,13 +62,29 @@             ]         , testGroup             "deriveProjectName"-            [ testCase "sanitizes non-alphanumerics to dashes" $-                deriveProjectName "/a/b/probe.md" @?= "a-b-probe-md"+            [ testCase "sanitises to a readable stem, then a -h<hash> suffix" $ do+                let n = deriveProjectName "/a/b/probe.md"+                assertBool "readable stem" ("a-b-probe-md-h" `isPrefixOf` n)+                assertBool "no double dash" (not ("--" `isInfixOf` n))             , testCase "collapses runs and trims so a leading _ is valid" $                 -- '/' then '_' would yield '--' (an empty cabal name component).-                deriveProjectName "/x/_probe.md" @?= "x-probe-md"-            , testCase "strips trailing separators" $-                deriveProjectName "/x/note." @?= "x-note"+                assertBool "stem" ("x-probe-md-h" `isPrefixOf` deriveProjectName "/x/_probe.md")+            , testCase "distinct paths that sanitise alike do NOT collide" $+                -- foo.md and foo-md both sanitise to home-u-foo-md; the hash keeps+                -- them in separate ~/.scripths dirs.+                assertBool "differ" $+                    deriveProjectName "/home/u/foo.md" /= deriveProjectName "/home/u/foo-md"+            , testCase "is stable for the same path" $+                deriveProjectName "/home/u/x.md" @?= deriveProjectName "/home/u/x.md"+            , testCase "non-ASCII letters become dashes (cabal needs ASCII names)" $ do+                let n = deriveProjectName "/проект/данные.md"+                assertBool+                    "ASCII only"+                    (all (\c -> c == '-' || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) n)+            , testCase "an all-symbol path still yields a valid name" $+                assertBool+                    "fallback stem"+                    ("scripths-script-h" `isPrefixOf` deriveProjectName "/---")             ]         , testGroup             "renderCabalFile"@@ -102,14 +123,6 @@             , testCase "false for a plain notebook" $                 usesTemplateHaskell emptyMeta [HaskellLine "1 + 1", Import "import Data.List"]                     @?= False-            ]-        , testGroup-            "compileCdTo"-            [ testCase "renders a compile-time setCurrentDirectory splice" $ do-                let txt = compileCdTo "/some/dir"-                assertBool "splice block" (T.isInfixOf ":{" txt)-                assertBool "chdir call" (T.isInfixOf "setCurrentDirectory" txt)-                assertBool "the target dir" (T.isInfixOf "/some/dir" txt)             ]         ]   where
+ test/Test/Version.hs view
@@ -0,0 +1,208 @@+module Test.Version (versionTests) where++import qualified Data.Text as T+import Data.Version (makeVersion)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++import ScriptHs.Version (+    TagStyle (..),+    newerVersionWarning,+    parseTagLine,+    scripthsVersion,+    scripthsVersionText,+    stampVersion,+    tagStyleFor,+    tagVersion,+    versionTag,+ )++versionTests :: TestTree+versionTests =+    testGroup+        "Version"+        [ tagStyleForTests+        , versionTagTests+        , parseTagLineTests+        , tagVersionTests+        , stampVersionTests+        , newerVersionWarningTests+        ]++tagStyleForTests :: TestTree+tagStyleForTests =+    testGroup+        "tagStyleFor"+        [ testCase "md/markdown => NotebookTag (case-insensitive)" $ do+            tagStyleFor "x.md" @?= NotebookTag+            tagStyleFor "x.markdown" @?= NotebookTag+            tagStyleFor "X.MD" @?= NotebookTag+            tagStyleFor "x.MARKDOWN" @?= NotebookTag+            tagStyleFor "x.Md" @?= NotebookTag+        , testCase "ghci/hs/other => ScriptTag" $ do+            tagStyleFor "x.ghci" @?= ScriptTag+            tagStyleFor "x.hs" @?= ScriptTag+            tagStyleFor "noext" @?= ScriptTag+        ]++versionTagTests :: TestTree+versionTagTests =+    testGroup+        "versionTag"+        [ testCase "script form" $+            versionTag ScriptTag @?= "-- scripths: " <> scripthsVersionText+        , testCase "notebook form" $+            versionTag NotebookTag @?= "<!-- scripths: " <> scripthsVersionText <> " -->"+        ]++parseTagLineTests :: TestTree+parseTagLineTests =+    testGroup+        "parseTagLine"+        [ testCase "script tag" $+            parseTagLine ScriptTag "-- scripths: 1.2.3.4"+                @?= Just (makeVersion [1, 2, 3, 4])+        , testCase "notebook tag" $+            parseTagLine NotebookTag "<!-- scripths: 1.2.3.4 -->"+                @?= Just (makeVersion [1, 2, 3, 4])+        , testCase "tolerates trailing CRLF and whitespace" $ do+            parseTagLine NotebookTag "<!-- scripths: 1.2.3 -->\r"+                @?= Just (makeVersion [1, 2, 3])+            parseTagLine ScriptTag "-- scripths: 1.2.3   " @?= Just (makeVersion [1, 2, 3])+        , testCase "tolerates a leading BOM and up to 3 leading spaces" $ do+            parseTagLine NotebookTag "\65279<!-- scripths: 1.0 -->"+                @?= Just (makeVersion [1, 0])+            parseTagLine NotebookTag "   <!-- scripths: 1.0 -->"+                @?= Just (makeVersion [1, 0])+        , testCase "4+ leading spaces is an indented code block, not a tag" $+            parseTagLine NotebookTag "    <!-- scripths: 1.0 -->" @?= Nothing+        , testCase "scripths:mime output marker is not a version tag" $+            parseTagLine NotebookTag "<!-- scripths:mime text/plain -->" @?= Nothing+        , testCase "ordinary lines are not tags" $ do+            parseTagLine ScriptTag "import Data.Text" @?= Nothing+            parseTagLine NotebookTag "# Title" @?= Nothing+        , testCase "an over-long component clamps instead of silently overflowing Int" $ do+            let huge = parseTagLine NotebookTag "<!-- scripths: 99999999999999999999999999.0 -->"+            assertBool "still parses" (huge /= Nothing)+            assertBool "stays larger than a normal version (no wrap)" $+                maybe False (> makeVersion [1, 0]) huge+        , testCase "leading zeros in a component are tolerated" $+            parseTagLine ScriptTag "-- scripths: 01.2" @?= Just (makeVersion [1, 2])+        , testCase "trailing non-numeric junk after the version is ignored" $+            parseTagLine ScriptTag "-- scripths: 1.2.3-beta"+                @?= Just (makeVersion [1, 2, 3])+        , testCase "empty/edge components are rejected" $ do+            parseTagLine ScriptTag "-- scripths: 1..2" @?= Nothing+            parseTagLine ScriptTag "-- scripths: .1" @?= Nothing+            parseTagLine ScriptTag "-- scripths: 1." @?= Nothing+            parseTagLine ScriptTag "-- scripths: " @?= Nothing+        ]++tagVersionTests :: TestTree+tagVersionTests =+    testGroup+        "tagVersion"+        [ testCase "reads the first line" $+            tagVersion NotebookTag "<!-- scripths: 2.0 -->\n# Title\n"+                @?= Just (makeVersion [2, 0])+        , testCase "skips a leading shebang in a script" $+            tagVersion ScriptTag "#!/usr/bin/env scripths\n-- scripths: 2.0\nfoo\n"+                @?= Just (makeVersion [2, 0])+        , testCase "skips a leading YAML frontmatter block in a notebook" $+            tagVersion+                NotebookTag+                "---\ntitle: x\n---\n<!-- scripths: 2.0 -->\n# Title\n"+                @?= Just (makeVersion [2, 0])+        , testCase "no tag => Nothing" $+            tagVersion NotebookTag "# Title\n\nprose\n" @?= Nothing+        , testCase "unterminated '---' (no closing fence) is not frontmatter" $+            -- First non-blank line is '---' (a thematic break), so there is no tag.+            tagVersion NotebookTag "---\ntitle: x\n# Body\n" @?= Nothing+        ]++stampVersionTests :: TestTree+stampVersionTests =+    testGroup+        "stampVersion"+        [ testCase "prepends when missing (script)" $ do+            let out = stampVersion ScriptTag "import X\n"+            tagVersion ScriptTag out @?= Just scripthsVersion+            assertBool "keeps body" ("import X" `T.isInfixOf` out)+        , testCase "prepends when missing (notebook)" $ do+            let out = stampVersion NotebookTag "# Title\n"+            tagVersion NotebookTag out @?= Just scripthsVersion+            assertBool "keeps body" ("# Title" `T.isInfixOf` out)+        , testCase "idempotent: stamp . stamp == stamp" $ do+            let once = stampVersion NotebookTag "# Title\n\nprose\n"+                twice = stampVersion NotebookTag once+            twice @?= once+        , testCase "refreshes an old tag in place without stacking" $ do+            let out = stampVersion NotebookTag "<!-- scripths: 0.0.0.1 -->\n\n# Title\n"+            tagVersion NotebookTag out @?= Just scripthsVersion+            assertBool "old version gone" (not ("0.0.0.1" `T.isInfixOf` out))+            assertBool "keeps body" ("# Title" `T.isInfixOf` out)+            assertBool "single tag" (T.count "scripths:" out == 1)+        , testCase "inserts after a YAML frontmatter block" $ do+            let src = "---\ntitle: x\n---\n# Body\n"+                out = stampVersion NotebookTag src+            tagVersion NotebookTag out @?= Just scripthsVersion+            assertBool "frontmatter preserved" ("title: x" `T.isInfixOf` out)+            assertBool "tag sits after the closing ---" $+                T.isInfixOf "---\n" out+                    && maybe False (T.isInfixOf "<!-- scripths:") (lastFrontmatterTail out)+        , testCase "frontmatter stamping is idempotent" $ do+            let once = stampVersion NotebookTag "---\ntitle: x\n---\n# Body\n"+            stampVersion NotebookTag once @?= once+        , testCase "a leading Markdown thematic break is not treated as frontmatter" $ do+            -- Regression: a `---` rule is NOT YAML, so the tag must go ABOVE it,+            -- never spliced into the body (the in-place data-corruption trap).+            let src = "---\nIntro prose under a rule.\n\nSection.\n\n---\n\nMore.\n"+                out = stampVersion NotebookTag src+            tagVersion NotebookTag out @?= Just scripthsVersion+            assertBool "body intact" ("Intro prose under a rule." `T.isInfixOf` out)+            assertBool "single tag" (T.count "<!-- scripths:" out == 1)+            assertBool "tag is above the first rule" $+                maybe+                    False+                    (T.isInfixOf "---")+                    (T.stripPrefix (versionTag NotebookTag <> "\n") out)+        , testCase "thematic-break stamping is idempotent (no creeping corruption)" $ do+            let src = "---\nIntro prose under a rule.\n\n---\n\nMore.\n"+                once = stampVersion NotebookTag src+            stampVersion NotebookTag once @?= once+        , testCase "a file that is only '---' is a rule, not frontmatter: tag goes above" $ do+            let out = stampVersion NotebookTag "---\n"+            tagVersion NotebookTag out @?= Just scripthsVersion+            assertBool "rule kept" ("---" `T.isInfixOf` out)+            assertBool "tag above the rule" $+                maybe+                    False+                    (T.isInfixOf "---")+                    (T.stripPrefix (versionTag NotebookTag <> "\n") out)+        , testCase "unterminated frontmatter is not spliced into (tag goes to the top)" $ do+            let out = stampVersion NotebookTag "---\ntitle: x\n# Body\n"+            tagVersion NotebookTag out @?= Just scripthsVersion+            assertBool "body intact" ("# Body" `T.isInfixOf` out)+            assertBool "single tag" (T.count "<!-- scripths:" out == 1)+            assertBool "tag is the first line" $+                maybe False (const True) (T.stripPrefix (versionTag NotebookTag) out)+        ]+  where+    -- Text after the second "---" line, where the tag must live.+    lastFrontmatterTail t = case T.splitOn "---" t of+        (_ : _ : tl : _) -> Just tl+        _ -> Nothing++newerVersionWarningTests :: TestTree+newerVersionWarningTests =+    testGroup+        "newerVersionWarning"+        [ testCase "newer => warns" $+            assertBool+                "warns"+                (maybe False (const True) (newerVersionWarning (makeVersion [99, 0])))+        , testCase "current => silent" $+            newerVersionWarning scripthsVersion @?= Nothing+        , testCase "older => silent" $+            newerVersionWarning (makeVersion [0]) @?= Nothing+        ]