packages feed

scripths 0.3.2.0 → 0.3.3.0

raw patch · 12 files changed

+392/−63 lines, 12 filesPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

API changes (from Hackage documentation)

+ ScriptHs.Parser: SourceRepoPin :: Text -> Text -> Maybe Text -> SourceRepoPin
+ ScriptHs.Parser: [metaSourceRepos] :: CabalMeta -> [SourceRepoPin]
+ ScriptHs.Parser: [metaUnknownKeys] :: CabalMeta -> [Text]
+ ScriptHs.Parser: [srpLocation] :: SourceRepoPin -> Text
+ ScriptHs.Parser: [srpRef] :: SourceRepoPin -> Text
+ ScriptHs.Parser: [srpSubdir] :: SourceRepoPin -> Maybe Text
+ ScriptHs.Parser: data SourceRepoPin
+ ScriptHs.Parser: instance GHC.Classes.Eq ScriptHs.Parser.SourceRepoPin
+ ScriptHs.Parser: instance GHC.Show.Show ScriptHs.Parser.SourceRepoPin
+ ScriptHs.Run: RunOptions :: [FilePath] -> Bool -> RunOptions
+ ScriptHs.Run: [roEnclosingProject] :: RunOptions -> Bool
+ ScriptHs.Run: [roPackages] :: RunOptions -> [FilePath]
+ ScriptHs.Run: data RunOptions
+ ScriptHs.Run: defaultRunOptions :: RunOptions
+ ScriptHs.Run: depPkgName :: Text -> Text
+ ScriptHs.Run: enclosingProjectFor :: FilePath -> [Text] -> IO [FilePath]
+ ScriptHs.Run: findCabalFiles :: FilePath -> IO [FilePath]
+ ScriptHs.Run: instance GHC.Classes.Eq ScriptHs.Run.RunOptions
+ ScriptHs.Run: instance GHC.Show.Show ScriptHs.Run.RunOptions
+ ScriptHs.Run: managedSentinel :: Text
+ ScriptHs.Run: readCabalPackageName :: FilePath -> IO (Maybe Text)
+ ScriptHs.Run: renderCabalProject :: [FilePath] -> [SourceRepoPin] -> Text
+ ScriptHs.Run: warnUnknownKeys :: [Text] -> IO ()
+ ScriptHs.Run: writeManagedCabalProject :: FilePath -> Text -> IO ()
- ScriptHs.Notebook: executeCodeCells :: FilePath -> CabalMeta -> IndexedSegments -> IndexedBlocks -> IO Text
+ ScriptHs.Notebook: executeCodeCells :: RunOptions -> FilePath -> CabalMeta -> IndexedSegments -> IndexedBlocks -> IO Text
- ScriptHs.Notebook: processNotebook :: FilePath -> Text -> IO Text
+ ScriptHs.Notebook: processNotebook :: RunOptions -> FilePath -> Text -> IO Text
- ScriptHs.Notebook: runNotebook :: FilePath -> Maybe FilePath -> IO ()
+ ScriptHs.Notebook: runNotebook :: RunOptions -> FilePath -> Maybe FilePath -> IO ()
- ScriptHs.Parser: CabalMeta :: [Text] -> [Text] -> [Text] -> CabalMeta
+ ScriptHs.Parser: CabalMeta :: [Text] -> [Text] -> [Text] -> [SourceRepoPin] -> [Text] -> CabalMeta
- ScriptHs.Run: ensureProject :: FilePath -> CabalMeta -> [Line] -> IO FilePath
+ ScriptHs.Run: ensureProject :: RunOptions -> FilePath -> CabalMeta -> [Line] -> IO FilePath
- ScriptHs.Run: runScript :: FilePath -> ScriptFile -> IO ()
+ ScriptHs.Run: runScript :: RunOptions -> FilePath -> ScriptFile -> IO ()
- ScriptHs.Run: runScriptCapture :: FilePath -> ScriptFile -> IO Text
+ ScriptHs.Run: runScriptCapture :: RunOptions -> FilePath -> ScriptFile -> IO Text
- ScriptHs.Run: runWithCont :: (FilePath -> FilePath -> FilePath -> IO a) -> FilePath -> ScriptFile -> IO a
+ ScriptHs.Run: runWithCont :: (FilePath -> FilePath -> FilePath -> IO a) -> RunOptions -> FilePath -> ScriptFile -> IO a

Files

CHANGELOG.md view
@@ -1,5 +1,20 @@ # Revision history for scripths +## 0.3.3.0 -- 2026-05-24++* Build against **local packages**: auto-include the enclosing cabal project when a+  `build-depends` names it (zero-config; `--no-local-project` to disable), and a+  repeatable `--package DIR` / `-p DIR` flag for sibling/sub-library checkouts. Lets+  documentation build against an unreleased local working tree.+* New `-- cabal: source-repository-package: <location> <ref> [subdir]` directive for+  pinning a pushed git commit/tag reproducibly; rendered into the generated+  `cabal.project`.+* `CabalMeta` gains `metaSourceRepos` and `metaUnknownKeys`; unrecognised `-- cabal:`+  keys now warn to stderr (instead of being silently dropped).+* The generated `cabal.project` is regenerated each run (guarded by a `-- managed by+  scripths` sentinel so hand-edited project files are preserved), replacing the old+  write-once behaviour. Rewrote the CLI argument parser.+ ## 0.3.1.0 -- 2026-04-23  * Better block parsing when there are function signatures and comments.
README.md view
@@ -20,7 +20,7 @@ ## CLI Usage  ```-scripths [--output=<filename>] [-o <filename>] <script>+scripths [-o FILE | --output=FILE] [-p DIR | --package DIR]... [--no-local-project] <script> ```  When `-o` / `--output` is provided for Markdown files, the result is written to that path. Otherwise it is printed to stdout. 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.@@ -97,3 +97,51 @@ -- cabal: default-extensions: OverloadedStrings, TypeApplications -- cabal: ghc-options: -Wall ```++## Local packages++By default `build-depends` resolve from Hackage. To build a script or notebook+against a **local checkout** instead — e.g. to document a library version that+only exists on a branch — there are two mechanisms.++### The enclosing project (zero-config)++If a script/notebook lives inside a cabal project and a `build-depends` names that+project's package (or one of its sub-libraries, `pkg:sublib`), scripths builds+against the local working tree automatically — so documenting your own repo just+works:++```bash+scripths -o docs/MANUAL.md docs/base/MANUAL.md+```++Pass `--no-local-project` to suppress this and resolve every `build-depends` from+Hackage instead — e.g. to verify the doc's examples against the *released*+version of your package rather than the working tree. Explicit `--package` dirs+are unaffected.++### `--package DIR`++Point at another local checkout (a sibling package, or a sub-library project).+`DIR` must be a package root (contain a `.cabal`); it is resolved to an absolute+path at the invocation site, so nothing machine-specific is committed to the+shared document. Repeatable.++```bash+scripths --package ../granite -o out.md in.md+```++### Pinned git packages (`source-repository-package`)++To pin a **pushed** commit/tag reproducibly (and commit the pin into the shared+document), declare a `source-repository-package` directive as+`<location> <ref> [subdir]`:++```+-- cabal: source-repository-package: https://github.com/owner/repo v1.2.3+-- cabal: source-repository-package: https://github.com/owner/mono abc123 sub/dir+```++scripths writes these as `source-repository-package` stanzas in the generated+`cabal.project`. Use a local checkout (above) for an **unpushed** branch, and a git+pin for a **pushed**, reproducible reference.
app/Main.hs view
@@ -1,48 +1,103 @@ module Main where +import Control.Monad (unless, when) import Data.List (isPrefixOf) import qualified Data.Text.IO as TIO+import System.Directory (+    doesDirectoryExist,+    listDirectory,+    makeAbsolute,+ ) import System.Environment (getArgs, getProgName) import System.Exit (exitFailure) import System.FilePath (takeExtension)+import System.IO (hPutStrLn, stderr)  import ScriptHs.Notebook (runNotebook) import ScriptHs.Parser (parseScript)-import ScriptHs.Run (runScript)+import ScriptHs.Run (RunOptions (..), defaultRunOptions, runScript) +-- | Parsed command-line options.+data Args = Args+    { argScript :: Maybe FilePath+    , argOutput :: Maybe FilePath+    , argPackages :: [FilePath]+    , argNoLocalProject :: Bool+    }++emptyArgs :: Args+emptyArgs = Args Nothing Nothing [] False+ main :: IO () main = do-    args <- getArgs-    case args of-        [path] -> dispatch path Nothing-        [] -> usage-        _ -> dispatch (last args) (outputFile (init args))+    raw <- getArgs+    case parseArgs raw of+        Left err -> hPutStrLn stderr ("scripths: " ++ err) >> usage+        Right a -> case argScript a of+            Nothing -> usage+            Just path -> do+                pkgs <- mapM resolvePackageDir (argPackages a)+                let opts =+                        defaultRunOptions+                            { roPackages = pkgs+                            , roEnclosingProject = not (argNoLocalProject a)+                            }+                dispatch opts path (argOutput a) -dispatch :: FilePath -> Maybe FilePath -> IO ()-dispatch path outputPath =+dispatch :: RunOptions -> FilePath -> Maybe FilePath -> IO ()+dispatch opts path outputPath =     case takeExtension path of-        ".md" -> runNotebook path outputPath-        ".markdown" -> runNotebook path outputPath+        ".md" -> runNotebook opts path outputPath+        ".markdown" -> runNotebook opts path outputPath         _ -> do             contents <- TIO.readFile path             let sf = parseScript contents-            runScript path sf+            runScript opts path sf --- TODO: This is kinda brittle but I don't wanna--- include the whole opt parser lib just to--- get the output file. Maybe there's a cheaper--- way to do this.-outputFile :: [String] -> Maybe FilePath-outputFile [] = Nothing-outputFile ("-o" : f : _) = Just f-outputFile (x : xs) =-    if "--output=" `isPrefixOf` x-        then Just (drop (length "--output=") x)-        else outputFile (drop 1 xs)+{- | Parse args: @[-o FILE | --output=FILE] [--package DIR | -p DIR |+--package=DIR]... [--no-local-project] <script>@. The script is the sole+non-flag argument.+-}+parseArgs :: [String] -> Either String Args+parseArgs = go emptyArgs+  where+    go a [] = Right a+    go a ("-o" : f : rest) = go a{argOutput = Just f} rest+    go _ ["-o"] = Left "-o requires a filename"+    go a (tok : rest)+        | tok == "-p" || tok == "--package" = case rest of+            (d : rest') -> go a{argPackages = argPackages a ++ [d]} rest'+            [] -> Left (tok ++ " requires a directory")+        | Just f <- stripFlag "--output=" tok = go a{argOutput = Just f} rest+        | Just d <- stripFlag "--package=" tok =+            go a{argPackages = argPackages a ++ [d]} rest+        | tok == "--no-local-project" = go a{argNoLocalProject = True} rest+        | "-" `isPrefixOf` tok = Left ("unknown flag: " ++ tok)+        | otherwise = case argScript a of+            Nothing -> go a{argScript = Just tok} rest+            Just _ -> Left ("unexpected extra argument: " ++ tok)+    stripFlag p s = if p `isPrefixOf` s then Just (drop (length p) s) else Nothing +-- | Resolve a @--package@ dir to absolute, requiring a package root (a @.cabal@).+resolvePackageDir :: FilePath -> IO FilePath+resolvePackageDir dir = do+    path <- makeAbsolute dir+    isDir <- doesDirectoryExist path+    unless isDir (die ("--package: not a directory: " ++ path))+    cabals <- filter ((== ".cabal") . takeExtension) <$> listDirectory path+    when (null cabals) $+        die ("--package: no .cabal file in " ++ path ++ " (expected a package root)")+    pure path++die :: String -> IO a+die msg = hPutStrLn stderr ("scripths: " ++ msg) >> exitFailure+ usage :: IO () usage = do     prog <- getProgName     putStrLn $-        "Usage: " ++ prog ++ " [--output=<filename>] [-o <filename>] <script>"+        "Usage: "+            ++ prog+            ++ " [-o FILE | --output=FILE] [-p DIR | --package DIR]..."+            ++ " [--no-local-project] <script>"     exitFailure
scripths.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               scripths-version:            0.3.2.0+version:            0.3.3.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/@@ -43,6 +43,7 @@     build-depends:         base >= 4 && < 5,         scripths,+        directory >= 1.3 && < 1.4,         filepath >= 1.4 && < 1.6,         text >= 2.0 && < 3, 
src/ScriptHs/Notebook.hs view
@@ -19,33 +19,38 @@     mergeMetas,     parseScript,  )-import ScriptHs.Run (runScriptCapture)+import ScriptHs.Run (RunOptions, runScriptCapture)  type IndexedSegments = [(Int, Segment)] type IndexedBlocks = [(Int, [Line])] -runNotebook :: FilePath -> Maybe FilePath -> IO ()-runNotebook path outputPath = do+runNotebook :: RunOptions -> FilePath -> Maybe FilePath -> IO ()+runNotebook opts path outputPath = do     contents <- TIO.readFile path-    outputMd <- processNotebook path contents+    outputMd <- processNotebook opts path contents     case outputPath of         Nothing -> TIO.putStr outputMd         Just output -> TIO.writeFile output outputMd -processNotebook :: FilePath -> Text -> IO Text-processNotebook notebookPath contents = do+processNotebook :: RunOptions -> FilePath -> Text -> IO Text+processNotebook opts notebookPath contents = do     let indexedSegments = zip [0 ..] (parseMarkdown contents)         (metas, indexedCodeBlocks) = parseBlocks indexedSegments     if null indexedCodeBlocks         then pure contents-        else executeCodeCells notebookPath metas indexedSegments indexedCodeBlocks+        else executeCodeCells opts notebookPath metas indexedSegments indexedCodeBlocks  executeCodeCells ::-    FilePath -> CabalMeta -> IndexedSegments -> IndexedBlocks -> IO Text-executeCodeCells notebookPath meta allSegments codeBlocks = do+    RunOptions ->+    FilePath ->+    CabalMeta ->+    IndexedSegments ->+    IndexedBlocks ->+    IO Text+executeCodeCells opts notebookPath meta allSegments codeBlocks = do     let ghciScript = generatedMarkedScript codeBlocks         sf = ScriptFile{scriptMeta = meta, scriptLines = ghciScript}-    rawOutput <- runScriptCapture notebookPath sf+    rawOutput <- runScriptCapture opts notebookPath sf     let outputs = splitByMarkers rawOutput (map fst codeBlocks)         blocksWithOutput = addOutputToSegments outputs allSegments     pure $ reassemble blocksWithOutput
src/ScriptHs/Parser.hs view
@@ -18,11 +18,13 @@ module ScriptHs.Parser (     ScriptFile (..),     CabalMeta (..),+    SourceRepoPin (..),     Line (..),     parseScript,     mergeMetas, ) where +import Data.Maybe import Data.Text (Text) import qualified Data.Text as T @@ -55,9 +57,32 @@     -- ^ Extensions from @default-extensions@ directives.     , metaGhcOptions :: [Text]     -- ^ Flags from @ghc-options@ directives.+    , metaSourceRepos :: [SourceRepoPin]+    -- ^ Pinned git packages from @source-repository-package@ directives.+    , metaUnknownKeys :: [Text]+    -- ^ Unrecognised @-- cabal:@ directive keys (surfaced as warnings).     }     deriving (Show, Eq) +{- | A git @source-repository-package@ pin, declared with++@+-- cabal: source-repository-package: <location> <ref> [subdir]+@++so documentation can build against a pinned, pushed commit/tag (reproducible+and commit-safe, unlike a local working-tree path).+-}+data SourceRepoPin = SourceRepoPin+    { srpLocation :: Text+    -- ^ Repository location (e.g. a git URL).+    , srpRef :: Text+    -- ^ Commit hash or tag (becomes cabal's @tag:@).+    , srpSubdir :: Maybe Text+    -- ^ Optional subdirectory within the repository.+    }+    deriving (Show, Eq)+ -- | A single logical line from the script body. data Line     = -- | An empty or whitespace-only line.@@ -80,10 +105,10 @@ Example:  >>> parseScript "-- cabal: build-depends: text\nimport Data.Text (Text)\n"-Right (ScriptFile {scriptMeta = CabalMeta {metaDeps = ["text"], metaExts = [], metaGhcOptions = []}, scriptLines = [Import "import Data.Text (Text)"]})+ScriptFile {scriptMeta = CabalMeta {metaDeps = ["text"], metaExts = [], metaGhcOptions = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = [Import "import Data.Text (Text)"]}  >>> parseScript ""-Right (ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = []}, scriptLines = []})+ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = [], metaSourceRepos = [], metaUnknownKeys = []}, scriptLines = []} -} parseScript :: Text -> ScriptFile parseScript input =@@ -109,6 +134,8 @@         { metaDeps = concatMap metaDeps ms         , metaExts = concatMap metaExts ms         , metaGhcOptions = concatMap metaGhcOptions ms+        , metaSourceRepos = concatMap metaSourceRepos ms+        , metaUnknownKeys = concatMap metaUnknownKeys ms         }  parseLine :: Text -> RawLine@@ -128,10 +155,35 @@                 "build-depends" -> emptyCabal{metaDeps = items}                 "default-extensions" -> emptyCabal{metaExts = items}                 "ghc-options" -> emptyCabal{metaGhcOptions = items}-                _ -> emptyCabal+                "source-repository-package" ->+                    emptyCabal{metaSourceRepos = maybeToList (parseSourceRepo value)}+                other -> emptyCabal{metaUnknownKeys = [other]}         _ -> Nothing   where-    emptyCabal = CabalMeta{metaDeps = [], metaExts = [], metaGhcOptions = []}+    emptyCabal =+        CabalMeta+            { metaDeps = []+            , metaExts = []+            , metaGhcOptions = []+            , metaSourceRepos = []+            , metaUnknownKeys = []+            }++{- | Parse a @source-repository-package@ value: whitespace-separated+@\<location\> \<ref\> [subdir]@ (not comma-separated like the other directives).+-}+parseSourceRepo :: Text -> Maybe SourceRepoPin+parseSourceRepo value = case T.words value of+    (loc : ref : rest) ->+        Just+            SourceRepoPin+                { srpLocation = loc+                , srpRef = ref+                , srpSubdir = case rest of+                    (sub : _) -> Just sub+                    [] -> Nothing+                }+    _ -> Nothing  parseCodeLine :: Text -> Line parseCodeLine line
src/ScriptHs/Render.hs view
@@ -34,9 +34,10 @@  import Data.Char (isAsciiLower, isAsciiUpper, isDigit) import Data.List (intercalate)+import Data.Maybe import Data.Text (Text) import qualified Data.Text as T-import ScriptHs.Parser (CabalMeta (..), Line (..))+import ScriptHs.Parser (CabalMeta (..), Line (..), SourceRepoPin (..))  data Block     = SingleLine Line@@ -489,16 +490,23 @@ the exporter over-includes imports to keep dependency slices self-contained. -} renderCabalScriptHeader :: CabalMeta -> Text-renderCabalScriptHeader (CabalMeta deps exts opts) =+renderCabalScriptHeader meta =     T.unlines $-        ["{- cabal:", "build-depends: " <> commaList (dedup ("base" : deps))]+        ["{- cabal:", "build-depends: " <> commaList (dedup ("base" : metaDeps meta))]             ++ ["default-extensions: " <> commaList exts' | not (null exts')]             ++ ["ghc-options: " <> T.unwords opts']+            ++ map renderRepo (metaSourceRepos meta)             ++ ["-}"]   where-    exts' = dedup exts-    opts' = dedup (opts ++ ["-Wno-unused-imports"])+    exts' = dedup (metaExts meta)+    opts' = dedup (metaGhcOptions meta ++ ["-Wno-unused-imports"])     commaList = T.intercalate ", "+    renderRepo r =+        T.unwords $+            "source-repository-package:"+                : srpLocation r+                : srpRef r+                : maybeToList (srpSubdir r)  -- | A runnable single-file cabal script: a @{\- cabal: -\}@ header over a module. renderCabalScript :: CabalMeta -> ModuleParts -> Text
src/ScriptHs/Run.hs view
@@ -2,15 +2,17 @@  module ScriptHs.Run where -import Control.Monad (unless)+import Control.Monad (filterM, unless, when) import Data.Char (isAlphaNum)-import Data.List (intercalate)+import Data.List (intercalate, nub)+import Data.Maybe (listToMaybe) import qualified Data.Text as T import qualified Data.Text.IO as TIO import ScriptHs.Parser (     CabalMeta (..),     Line,     ScriptFile (scriptLines, scriptMeta),+    SourceRepoPin (..),  ) import ScriptHs.Render (toGhciScript) import System.Directory (@@ -18,10 +20,11 @@     doesFileExist,     getCurrentDirectory,     getHomeDirectory,+    listDirectory,     makeAbsolute,  ) import System.Exit (ExitCode (..), exitWith)-import System.FilePath ((</>))+import System.FilePath (takeDirectory, takeExtension, (</>)) import System.IO (stderr) import System.Process (     CreateProcess (cwd, delegate_ctlc),@@ -31,40 +34,153 @@     waitForProcess,  ) -runScript :: FilePath -> ScriptFile -> IO ()+-- | Options controlling how the throwaway cabal project is assembled.+data RunOptions = RunOptions+    { roPackages :: [FilePath]+    -- ^ Local package directories to add to @packages:@ (already absolute).+    , roEnclosingProject :: Bool+    {- ^ Auto-include the enclosing cabal project when a @build-depends@ names+    it (the zero-config default). Disabled by @--no-local-project@.+    -}+    }+    deriving (Show, Eq)++-- | No explicit @--package@ dirs; enclosing-project auto-detection on.+defaultRunOptions :: RunOptions+defaultRunOptions = RunOptions{roPackages = [], roEnclosingProject = True}++runScript :: RunOptions -> FilePath -> ScriptFile -> IO () runScript = runWithCont runGhc -runScriptCapture :: FilePath -> ScriptFile -> IO T.Text+runScriptCapture :: RunOptions -> FilePath -> ScriptFile -> IO T.Text runScriptCapture = runWithCont captureGhc  runWithCont ::-    (FilePath -> FilePath -> FilePath -> IO a) -> FilePath -> ScriptFile -> IO a-runWithCont cont scriptPath sf = do+    (FilePath -> FilePath -> FilePath -> IO a) ->+    RunOptions ->+    FilePath ->+    ScriptFile ->+    IO a+runWithCont cont opts scriptPath sf = do     scriptAbsPath <- makeAbsolute scriptPath     userCwd <- getCurrentDirectory-    projectDir <- ensureProject scriptAbsPath (scriptMeta sf) (scriptLines sf)+    warnUnknownKeys (metaUnknownKeys (scriptMeta sf))+    projectDir <- ensureProject opts scriptAbsPath (scriptMeta sf) (scriptLines sf)     cont userCwd projectDir (projectDir </> "script.ghci") +-- | Warn (once each) about unrecognised @-- cabal:@ directive keys.+warnUnknownKeys :: [T.Text] -> IO ()+warnUnknownKeys keys =+    mapM_+        (\k -> TIO.hPutStrLn stderr ("scripths: warning: unknown '-- cabal:' key: " <> k))+        (nub keys)+ deriveProjectName :: FilePath -> String deriveProjectName path =     let sanitized = map (\c -> if isAlphaNum c then c else '-') path      in dropWhile (== '-') sanitized -ensureProject :: FilePath -> CabalMeta -> [Line] -> IO FilePath-ensureProject scriptAbsPath meta scriptCode = do+ensureProject :: RunOptions -> FilePath -> CabalMeta -> [Line] -> IO FilePath+ensureProject opts scriptAbsPath meta scriptCode = do     home <- getHomeDirectory     let name = deriveProjectName scriptAbsPath         projectDir = home </> ".scripths" </> name     createDirectoryIfMissing True projectDir-    let cabalProjectPath = projectDir </> "cabal.project"-    cabalProjectExists <- doesFileExist cabalProjectPath-    unless cabalProjectExists $ writeFile cabalProjectPath "packages: .\n"+    enclosing <-+        if roEnclosingProject opts+            then enclosingProjectFor scriptAbsPath (metaDeps meta)+            else pure []+    let localPkgs = nub (roPackages opts ++ enclosing)+    writeManagedCabalProject+        (projectDir </> "cabal.project")+        (renderCabalProject localPkgs (metaSourceRepos meta))     let mainHsPath = projectDir </> "Main.hs"     mainHsExists <- doesFileExist mainHsPath     unless mainHsExists $ writeFile mainHsPath "main :: IO ()\nmain = pure ()\n"     writeFile (projectDir </> (name ++ ".cabal")) (renderCabalFile name meta)     TIO.writeFile (projectDir </> "script.ghci") (toGhciScript scriptCode)     pure projectDir++{- | First line of a scripths-generated @cabal.project@. We regenerate only+files that carry it (or the legacy @packages: .@ default), never hand-edited ones.+-}+managedSentinel :: T.Text+managedSentinel =+    "-- managed by scripths (regenerated each run; remove this line to hand-edit)"++{- | Render a @cabal.project@ from local package dirs + git source-repo pins.+Always includes @.@ (the synthetic script package).+-}+renderCabalProject :: [FilePath] -> [SourceRepoPin] -> T.Text+renderCabalProject localPkgs repos =+    T.unlines $+        [managedSentinel, "packages: ."]+            ++ map (\p -> "          " <> T.pack p) localPkgs+            ++ concatMap renderRepo repos+  where+    renderRepo r =+        [ "source-repository-package"+        , "    type: git"+        , "    location: " <> srpLocation r+        , "    tag: " <> srpRef r+        ]+            ++ maybe [] (\s -> ["    subdir: " <> s]) (srpSubdir r)++{- | Write @cabal.project@ only if absent, scripths-managed (carries the+sentinel), or the legacy @packages: .@ default — never clobber a hand-edited one.+-}+writeManagedCabalProject :: FilePath -> T.Text -> IO ()+writeManagedCabalProject path content = do+    exists <- doesFileExist path+    managed <-+        if not exists+            then pure True+            else do+                existing <- TIO.readFile path+                pure (managedSentinel `T.isPrefixOf` existing || existing == "packages: .\n")+    when managed $ TIO.writeFile path content++{- | If, enclosing the script, there is a cabal project whose package name is+named by a @build-depends@, return that project root (so docs build against the+local working tree). Empty otherwise.+-}+enclosingProjectFor :: FilePath -> [T.Text] -> IO [FilePath]+enclosingProjectFor scriptAbsPath deps = go (takeDirectory scriptAbsPath)+  where+    names = map depPkgName deps+    go dir = do+        cabals <- findCabalFiles dir+        mName <- case cabals of+            (cf : _) -> readCabalPackageName (dir </> cf)+            [] -> pure Nothing+        case mName of+            Just nm | nm `elem` names -> pure [dir]+            _ ->+                let parent = takeDirectory dir+                 in if parent == dir then pure [] else go parent++-- | Package name from a build-depends item (drops version bounds and @:sublib@).+depPkgName :: T.Text -> T.Text+depPkgName = T.takeWhile (\c -> isAlphaNum c || c == '-' || c == '_') . T.stripStart++findCabalFiles :: FilePath -> IO [FilePath]+findCabalFiles dir = do+    entries <- listDirectory dir+    -- Keep only regular files: a *directory* named @.cabal@ (cabal's home dir,+    -- which an upward walk reaches) also matches @takeExtension == ".cabal"@.+    filterM+        (doesFileExist . (dir </>))+        [e | e <- entries, takeExtension e == ".cabal"]++readCabalPackageName :: FilePath -> IO (Maybe T.Text)+readCabalPackageName path = do+    contents <- TIO.readFile path+    pure $+        listToMaybe+            [ T.strip (T.drop 1 (T.dropWhile (/= ':') ln))+            | ln <- T.lines contents+            , "name:" `T.isPrefixOf` T.toLower (T.stripStart ln)+            ]  renderCabalFile :: String -> CabalMeta -> String renderCabalFile name CabalMeta{metaDeps, metaExts, metaGhcOptions} =
test/Test/Notebook.hs view
@@ -18,6 +18,7 @@  )  import ScriptHs.Parser (CabalMeta (metaDeps), Line (..))+import ScriptHs.Run (defaultRunOptions)  notebookTests :: TestTree notebookTests =@@ -191,7 +192,7 @@             "processNotebook"             [ testCase "no code blocks => returns input unchanged" $ do                 let input = T.unlines ["# Title", "", "Just prose.", ""]-                out <- processNotebook "" input+                out <- processNotebook defaultRunOptions "" input                 out @?= input             , testCase "non-haskell code blocks only => returns input unchanged" $ do                 let input =@@ -204,7 +205,7 @@                             , ""                             , "more prose"                             ]-                out <- processNotebook "" input+                out <- processNotebook defaultRunOptions "" input                 out @?= input             ]         ]
test/Test/Parser.hs view
@@ -10,9 +10,10 @@  import qualified Data.Text as T import ScriptHs.Parser (-    CabalMeta (metaDeps, metaExts, metaGhcOptions),+    CabalMeta (metaDeps, metaExts, metaGhcOptions, metaSourceRepos, metaUnknownKeys),     Line (..),     ScriptFile (scriptLines, scriptMeta),+    SourceRepoPin (..),     parseScript,  ) @@ -99,10 +100,17 @@                 let sf = parseScript input                 (metaDeps . scriptMeta) sf @?= ["base", "text", "containers"]                 (metaExts . scriptMeta) sf @?= ["GADTs"]-            , testCase "unknown cabal key is ignored" $ do+            , testCase "unknown cabal key is recorded (and warned), not a dep" $ do                 let sf = parseScript "-- cabal: foo: bar, baz\n"                 (metaDeps . scriptMeta) sf @?= []                 (metaExts . scriptMeta) sf @?= []+                (metaUnknownKeys . scriptMeta) sf @?= ["foo"]+            , testCase "source-repository-package directive parses a git pin" $ do+                let sf =+                        parseScript+                            "-- cabal: source-repository-package: https://x/repo abc123 sub\n"+                (metaSourceRepos . scriptMeta) sf+                    @?= [SourceRepoPin "https://x/repo" "abc123" (Just "sub")]             ]         , testGroup             "Multi-line scripts"
test/Test/Render.hs view
@@ -407,7 +407,7 @@             assertBool "has main = do" (T.isInfixOf "main = do" txt)             assertBool "indents print 1" (T.isInfixOf "    print 1" txt)         , testCase "cabal-script header includes base + Wno-unused-imports" $ do-            let hdr = renderCabalScriptHeader (CabalMeta ["dataframe", "text"] [] [])+            let hdr = renderCabalScriptHeader (CabalMeta ["dataframe", "text"] [] [] [] [])             assertBool "opens block" (T.isInfixOf "{- cabal:" hdr)             assertBool                 "base + deps"
test/Test/Run.hs view
@@ -1,10 +1,12 @@ module Test.Run (runTests) where  import Data.List (isPrefixOf)+import qualified Data.Text as T import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, testCase, (@?=)) -import ScriptHs.Run (cabalArgs)+import ScriptHs.Parser (SourceRepoPin (..))+import ScriptHs.Run (cabalArgs, renderCabalProject)  runTests :: TestTree runTests =@@ -24,5 +26,23 @@                 let args = cabalArgs "/proj" "/proj/script.ghci"                 assertBool "--project-dir present" $                     any ("--project-dir=" `isPrefixOf`) args+            ]+        , testGroup+            "renderCabalProject"+            [ testCase "base case has the managed sentinel and packages: ." $ do+                let txt = renderCabalProject [] []+                assertBool "sentinel" (T.isInfixOf "managed by scripths" txt)+                assertBool "packages ." (T.isInfixOf "packages: ." txt)+            , testCase "local package dirs appear as packages entries" $ do+                let txt = renderCabalProject ["/abs/granite"] []+                assertBool "abs path" (T.isInfixOf "/abs/granite" txt)+            , testCase "git pin renders a source-repository-package stanza" $ do+                let pin = SourceRepoPin "https://x/repo" "abc123" (Just "sub")+                    txt = renderCabalProject [] [pin]+                assertBool "stanza" (T.isInfixOf "source-repository-package" txt)+                assertBool "type" (T.isInfixOf "type: git" txt)+                assertBool "location" (T.isInfixOf "location: https://x/repo" txt)+                assertBool "tag" (T.isInfixOf "tag: abc123" txt)+                assertBool "subdir" (T.isInfixOf "subdir: sub" txt)             ]         ]