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 +15/−0
- README.md +49/−1
- app/Main.hs +78/−23
- scripths.cabal +2/−1
- src/ScriptHs/Notebook.hs +15/−10
- src/ScriptHs/Parser.hs +56/−4
- src/ScriptHs/Render.hs +13/−5
- src/ScriptHs/Run.hs +129/−13
- test/Test/Notebook.hs +3/−2
- test/Test/Parser.hs +10/−2
- test/Test/Render.hs +1/−1
- test/Test/Run.hs +21/−1
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) ] ]