packages feed

scripths 0.3.0.1 → 0.3.1.0

raw patch · 4 files changed

+331/−208 lines, 4 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ ScriptHs.Render: instance GHC.Classes.Eq ScriptHs.Render.Kind
+ ScriptHs.Render: instance GHC.Show.Show ScriptHs.Render.Kind
+ ScriptHs.Render: instance GHC.Show.Show ScriptHs.Render.Piece

Files

CHANGELOG.md view
@@ -1,5 +1,9 @@ # Revision history for scripths +## 0.3.1.0 -- 2026-04-23++* Better block parsing when there are function signatures and comments.+ ## 0.3.0.1 -- 2026-03-14  * improve block parsing
scripths.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               scripths-version:            0.3.0.1+version:            0.3.1.0 synopsis:           GHCi scripts for standalone execution and Markdown documentation. description:        GHCi scripts for standalone execution (with dependency resolution) and Markdown documentation (produces inline output). homepage:           https://www.datahaskell.org/
src/ScriptHs/Render.hs view
@@ -1,15 +1,19 @@+{-# LANGUAGE OverloadedStrings #-}+ {- | Rendering 'ScriptHs.Parser.Line' sequences into GHCi-compatible scripts.  GHCi imposes constraints that raw Haskell source does not: multi-line-definitions must be wrapped in @:{@ \/ @:}@ blocks, and monadic binds-(@<-@) or Template Haskell splices (@$(@) must be issued as individual-statements rather than grouped with pure definitions. 'toGhciScript'-handles all of this automatically+definitions must be wrapped in @:{@ \/ @:}@ blocks, and each+expression-statement or monadic bind (@<-@) must be a separate GHCi+statement. 'toGhciScript' groups input lines into logical units+(a lead non-indented line plus its indented continuations), classifies+each unit, then assembles blocks accordingly. -} module ScriptHs.Render (     toGhciScript, ) where +import Data.Char (isAsciiLower, isAsciiUpper, isDigit) import Data.Text (Text) import qualified Data.Text as T import ScriptHs.Parser (Line (..))@@ -21,142 +25,250 @@  {- | Render a list of 'Line's as a GHCi script. -Lines are grouped into blocks and wrapped in @:{@ \/ @:}@ where necessary.-Monadic bind expressions (@<-@) and Template Haskell splices (@$(@) are-always emitted as individual GHCi statements, since GHCi does not allow-them inside multi-line blocks.+Classification rules: -Example — a multi-line definition is wrapped in a single block:+* A run of @--@ comment lines forms a 'KComment' unit that attaches+  forward to the next non-comment unit.+* A type signature, value binding, or clause-head-with-guards forms a+  'KDeclaration' unit; consecutive declarations merge into a single+  @:{ … :}@ block.+* A monadic bind (@<-@ on the lead line) forms a 'KIOBind' unit; each+  such unit is its own block.+* A Template Haskell splice (@$(…)@ or the rewritten @_ = (); …@ form)+  forms a 'KTHSplice' unit; each is its own block.+* Anything else is a 'KAction' (expression-statement, @do@-block, etc.);+  each action unit becomes its own block.+-}+toGhciScript :: [Line] -> Text+toGhciScript = T.unlines . concatMap renderBlock . piecesToBlocks . toPieces -@-toGhciScript-  [ HaskellLine "double :: Int -> Int"-  , HaskellLine "double = (*2)"-  ]--- :{--- double :: Int -> Int--- double = (*2)--- :}-@+---------------------------------------------------------------+-- Kind and Piece+--------------------------------------------------------------- -Example — an IO bind is kept as a standalone statement:+data Kind+    = KComment+    | KDeclaration+    | KAction+    | KIOBind+    | KTHSplice+    deriving (Show, Eq) -@-toGhciScript-  [ HaskellLine "x <- getLine"-  , HaskellLine "putStrLn x"-  ]--- x <- getLine--- putStrLn x-@+data Piece+    = PBlank+    | PGhciCommand Text+    | PPragma Text+    | PImport Text+    | PUnit Kind [Line]+    deriving (Show)++---------------------------------------------------------------+-- Step 1: [Line] -> [Piece]+---------------------------------------------------------------++toPieces :: [Line] -> [Piece]+toPieces [] = []+toPieces (Blank : rest) = PBlank : toPieces rest+toPieces (GhciCommand t : rest) = PGhciCommand t : toPieces rest+toPieces (Pragma t : rest) = PPragma t : toPieces rest+toPieces (Import t : rest) = PImport t : toPieces rest+toPieces (HaskellLine t : rest)+    | isCommentText t =+        let (more, rest') = spanComments rest+            unit = HaskellLine t : more+         in PUnit KComment unit : toPieces rest'+    | otherwise =+        let (cont, rest') = takeContinuations rest+            unit = HaskellLine t : cont+            k = classify t (map lineText cont)+         in PUnit k unit : toPieces rest'++spanComments :: [Line] -> ([Line], [Line])+spanComments (HaskellLine t : rest)+    | isCommentText t =+        let (more, rest') = spanComments rest+         in (HaskellLine t : more, rest')+spanComments xs = ([], xs)++{- | Take indented continuations after a lead, absorbing interior blank+lines only when followed by more indented non-blank content. Trailing+blanks are left behind so they can serve as unit separators (which is+what makes the "double blank still breaks" case work). -}-toGhciScript :: [Line] -> Text-toGhciScript = T.unlines . concatMap renderBlock . groupBlocks+takeContinuations :: [Line] -> ([Line], [Line])+takeContinuations [] = ([], [])+takeContinuations xs@(l : rest)+    | isIndentedNonBlank l =+        let (more, rest') = takeContinuations rest+         in (l : more, rest')+    | isBlankLine l =+        let (blanks, afterBlanks) = span isBlankLine xs+         in case afterBlanks of+                (x : _)+                    | isIndentedNonBlank x ->+                        let (more, rest') = takeContinuations afterBlanks+                         in (blanks ++ more, rest')+                _ -> ([], xs)+    | otherwise = ([], xs) -groupBlocks :: [Line] -> [Block]-groupBlocks = concatMap splitIOBinds . groupRaw+isIndentedNonBlank :: Line -> Bool+isIndentedNonBlank (HaskellLine t) = T.isPrefixOf " " t || T.isPrefixOf "\t" t+isIndentedNonBlank _ = False -groupRaw :: [Line] -> [Block]-groupRaw [] = []-groupRaw (Blank : rest) = SingleLine Blank : groupRaw rest-groupRaw (GhciCommand t : rest) = SingleLine (GhciCommand t) : groupRaw rest-groupRaw ls =-    let (block, rest) = span isBlockLine ls-        (block', rest') = takeIfIndented block rest-     in classifyBlock block' : groupRaw rest'+isBlankLine :: Line -> Bool+isBlankLine Blank = True+isBlankLine _ = False -takeIfIndented :: [Line] -> [Line] -> ([Line], [Line])-takeIfIndented block rest = (block ++ takeWhile isIndented rest, dropWhile isIndented rest)+---------------------------------------------------------------+-- Classification+--------------------------------------------------------------- -isIndented :: Line -> Bool-isIndented Blank = True-isIndented (HaskellLine t) = T.isPrefixOf " " t || T.isPrefixOf "\t" t-isIndented _ = False+classify :: Text -> [Text] -> Kind+classify leadText contTexts+    | isTHSplice leadText = KTHSplice+    | isDeclaration leadText contTexts = KDeclaration+    | isIOBindLead leadText = KIOBind+    | otherwise = KAction -splitIOBinds :: Block -> [Block]-splitIOBinds (MultiLine ls) =-    concatMap (splitDefIO . classifyBlock) (splitOn isIOLine ls)-  where-    isIOLine l = isIOorTH (lineText l)-splitIOBinds b = [b]+isTHSplice :: Text -> Bool+isTHSplice t =+    let s = T.stripStart t+     in "$(" `T.isPrefixOf` s || "_ = ();" `T.isPrefixOf` s -{- | Split a block that mixes definitions and IO actions.-  Only splits when there's a clear boundary: a non-indented line-  without @=@ or @::@ following a line that has @=@ or @::@, or vice versa.-  Indented lines always attach to the preceding line's group.+isIOBindLead :: Text -> Bool+isIOBindLead = T.isInfixOf "<-"++isDeclaration :: Text -> [Text] -> Bool+isDeclaration leadText contTexts =+    startsWithDeclKeyword leadText+        || isTypeSig leadText+        || isValueBinding leadText+        || isClauseHead leadText && any contHasBinding contTexts++startsWithDeclKeyword :: Text -> Bool+startsWithDeclKeyword t =+    any+        (`T.isPrefixOf` T.stripStart t)+        ["data ", "newtype ", "type ", "class ", "instance ", "default "]++isTypeSig :: Text -> Bool+isTypeSig t = case afterLeadIdent t of+    Just rest -> "::" `T.isPrefixOf` T.stripStart rest+    Nothing -> False++isValueBinding :: Text -> Bool+isValueBinding t = maybe False hasTopLevelEquals (afterLeadIdent t)++{- | A line like @isPrime n@ — identifier + args, no @=@ / @::@ / @<-@ on+this line. Only counts as a declaration when accompanied by indented+continuations that supply the RHS (guards, a @where@ clause, etc.). -}-splitDefIO :: Block -> [Block]-splitDefIO (MultiLine ls)-    | hasMix ls = map classifyBlock (groupByKind ls)-    | otherwise = [MultiLine ls]-splitDefIO b = [b]+isClauseHead :: Text -> Bool+isClauseHead t = case afterLeadIdent t of+    Just rest ->+        let rest' = T.stripEnd (T.stripStart rest)+         in not (T.null rest')+                && not (hasTopLevelEquals rest)+                && not ("::" `T.isInfixOf` rest)+                && not ("<-" `T.isInfixOf` rest)+    Nothing -> False --- | Check if a block has both definitions and IO actions at the top level.-hasMix :: [Line] -> Bool-hasMix ls =-    let topLevel = filter (not . isIndented) ls-        defs = filter (isDef . lineText) topLevel-        actions = filter (not . isDef . lineText) topLevel-     in not (null defs) && not (null actions)+contHasBinding :: Text -> Bool+contHasBinding t =+    let s = T.stripStart t+     in "| " `T.isPrefixOf` s && hasTopLevelEquals s+            || hasTopLevelEquals t+            || "where" `T.isPrefixOf` s -groupByKind :: [Line] -> [[Line]]-groupByKind [] = []-groupByKind (l : ls) =-    let kind = lineKindOf l-        -- Collect lines of the same kind, plus any indented continuations-        (same, rest) = spanSameKind kind ls-     in (l : same) : groupByKind rest+{- | Parse a leading lowercase identifier. Returns the text that follows+it (with any leading whitespace) or 'Nothing' if the stripped line does+not begin with a lowercase identifier, or begins with a Haskell keyword.+-}+afterLeadIdent :: Text -> Maybe Text+afterLeadIdent t =+    let s = T.stripStart t+     in case T.uncons s of+            Just (c, _)+                | isIdentStart c ->+                    let (ident, rest) = T.span isIdentCont s+                     in if T.null ident || isHaskellKeyword ident+                            then Nothing+                            else Just rest+            _ -> Nothing -spanSameKind :: Bool -> [Line] -> ([Line], [Line])-spanSameKind _ [] = ([], [])-spanSameKind kind (l : ls)-    | isIndented l =-        -- Indented lines attach to the current group-        let (more, rest) = spanSameKind kind ls-         in (l : more, rest)-    | lineKindOf l == kind =-        let (more, rest) = spanSameKind kind ls-         in (l : more, rest)-    | otherwise = ([], l : ls)+isIdentStart :: Char -> Bool+isIdentStart c = c == '_' || isAsciiLower c --- | True if the line looks like a definition (has @=@ or @::@).-lineKindOf :: Line -> Bool-lineKindOf l = isDef (lineText l)+isIdentCont :: Char -> Bool+isIdentCont c =+    isIdentStart c+        || isAsciiUpper c+        || isDigit c+        || c == '\'' -isDef :: Text -> Bool-isDef t =-    hasTopLevelEquals t || " :: " `T.isInfixOf` t+isHaskellKeyword :: Text -> Bool+isHaskellKeyword t =+    t+        `elem` [ "do"+               , "let"+               , "in"+               , "if"+               , "then"+               , "else"+               , "case"+               , "of"+               , "where"+               , "data"+               , "newtype"+               , "type"+               , "class"+               , "instance"+               , "module"+               , "import"+               , "default"+               , "deriving"+               , "infix"+               , "infixl"+               , "infixr"+               ]  hasTopLevelEquals :: Text -> Bool-hasTopLevelEquals t =-    " = " `T.isInfixOf` t || T.isSuffixOf " =" t+hasTopLevelEquals t = " = " `T.isInfixOf` t || T.isSuffixOf " =" t -splitOn :: (a -> Bool) -> [a] -> [[a]]-splitOn _ [] = []-splitOn p (x : xs)-    | p x = [x] : splitOn p xs-    | otherwise =-        let (run, rest) = break p xs-         in (x : run) : splitOn p rest+isCommentText :: Text -> Bool+isCommentText t = "--" `T.isPrefixOf` T.stripStart t -isBlockLine :: Line -> Bool-isBlockLine Blank = False-isBlockLine (GhciCommand _) = False-isBlockLine _ = True+---------------------------------------------------------------+-- Step 2: [Piece] -> [Block]+--------------------------------------------------------------- -classifyBlock :: [Line] -> Block-classifyBlock [l] = SingleLine l-classifyBlock ls = MultiLine ls+piecesToBlocks :: [Piece] -> [Block]+piecesToBlocks [] = []+piecesToBlocks (PBlank : rest) = SingleLine Blank : piecesToBlocks rest+piecesToBlocks (PGhciCommand t : rest) = SingleLine (GhciCommand t) : piecesToBlocks rest+piecesToBlocks (PPragma t : rest) = SingleLine (Pragma t) : piecesToBlocks rest+piecesToBlocks (PImport t : rest) = SingleLine (Import t) : piecesToBlocks rest+piecesToBlocks (PUnit KComment lines1 : PUnit k lines2 : rest)+    | k /= KComment =+        piecesToBlocks (PUnit k (lines1 ++ lines2) : rest)+piecesToBlocks (PUnit KDeclaration lines1 : PUnit KDeclaration lines2 : rest) =+    piecesToBlocks (PUnit KDeclaration (lines1 ++ lines2) : rest)+piecesToBlocks (PUnit _ ls : rest) = toBlock ls : piecesToBlocks rest+  where+    toBlock [l] = SingleLine l+    toBlock xs = MultiLine xs +---------------------------------------------------------------+-- Rendering+---------------------------------------------------------------+ renderBlock :: Block -> [Text] renderBlock (SingleLine Blank) = [""] renderBlock (SingleLine (GhciCommand t)) = [t] renderBlock (SingleLine (Pragma t)) = [t] renderBlock (SingleLine (Import t)) = [t] renderBlock (SingleLine (HaskellLine t)) = wrapMulti [t]-renderBlock (MultiLine ls)-    | allIOorTH ls = concatMap (\l -> wrapMulti [lineText l]) ls-    | otherwise = wrapMulti (map lineText ls)+renderBlock (MultiLine ls) = wrapMulti (map lineText ls)  wrapMulti :: [Text] -> [Text] wrapMulti ls = [":{"] ++ ls ++ [":}"]@@ -167,14 +279,3 @@ lineText (Pragma t) = t lineText (Import t) = t lineText (HaskellLine t) = t--isIOorTH :: Text -> Bool-isIOorTH t =-    not (T.isPrefixOf " " t)-        && ( T.isInfixOf "<-" t-                || T.isInfixOf "$(" t-                || T.isPrefixOf "_ = ();" (T.stripStart t)-           )--allIOorTH :: [Line] -> Bool-allIOorTH = all (isIOorTH . lineText)
test/Test/Render.hs view
@@ -13,7 +13,7 @@     testGroup         "Render"         [ testGroup-            "Single lines"+            "Line-level rendering"             [ testCase "plain expression stays unwrapped" $ do                 let result = toGhciScript [HaskellLine "print 42"]                 assertWrapped result ["print 42"]@@ -21,12 +21,11 @@                 let result = toGhciScript [Import "import Data.Text"]                 assertNotWrapped result             , testCase "pragma stays unwrapped" $ do-                let result = toGhciScript [Import "{-# LANGUAGE GADTs #-}"]+                let result = toGhciScript [Pragma "{-# LANGUAGE GADTs #-}"]                 assertNotWrapped result             , testCase "ghci command stays unwrapped" $ do                 let result = toGhciScript [GhciCommand ":set -XOverloadedStrings"]-                let ls = nonEmpty result-                ls @?= [":set -XOverloadedStrings"]+                nonEmpty result @?= [":set -XOverloadedStrings"]             , testCase "IO bind gets wrapped" $ do                 let result = toGhciScript [HaskellLine "x <- getLine"]                 assertWrapped result ["x <- getLine"]@@ -35,17 +34,37 @@                 assertBool "has blank" (T.isInfixOf "\n\n" result || result == "\n")             ]         , testGroup-            "IO line isolation"+            "Isolation: one block per statement"             [ testCase "consecutive IO binds each get own block" $ do                 let result =                         toGhciScript                             [ HaskellLine "x <- getLine"                             , HaskellLine "y <- getLine"                             ]-                let blocks = splitBlocks result-                assertBool-                    ("expected 2 blocks, got " ++ show (length blocks))-                    (length blocks == 2)+                length (splitBlocks result) @?= 2+            , testCase "consecutive IO expression-statements each get own block" $ do+                let result =+                        toGhciScript+                            [ HaskellLine "print 1"+                            , HaskellLine "putStrLn \"hi\""+                            , HaskellLine "displayLatex \"hi\""+                            , HaskellLine "displayLatex \"hi\""+                            ]+                length (splitBlocks result) @?= 4+            , testCase "inline type annotation does not flip statement into declaration" $ do+                let result =+                        toGhciScript+                            [ HaskellLine "print (1 :: Int)"+                            , HaskellLine "print \"x\""+                            ]+                length (splitBlocks result) @?= 2+            , testCase "TH splice gets its own block" $ do+                let result =+                        toGhciScript+                            [ HaskellLine "$(deriveJSON defaultOptions ''Foo)"+                            , HaskellLine "print \"after\""+                            ]+                length (splitBlocks result) @?= 2             , testCase "IO bind between pure code splits correctly" $ do                 let result =                         toGhciScript@@ -67,8 +86,7 @@                             , HaskellLine "  x = 5"                             , HaskellLine "  y = 10"                             ]-                let blocks = splitBlocks result-                length blocks @?= 1+                length (splitBlocks result) @?= 1             , testCase "consecutive do-notation lines grouped" $ do                 let result =                         toGhciScript@@ -77,21 +95,18 @@                             , HaskellLine "  y <- pure 10"                             , HaskellLine "  pure $ x + y"                             ]-                let blocks = splitBlocks result-                length blocks @?= 1+                length (splitBlocks result) @?= 1             , testCase "consecutive do-notation lines with space are grouped" $ do                 let result =                         toGhciScript                             [ HaskellLine "do"                             , HaskellLine "  x <- pure 5"-                            , -- indentation should ignore these blank lines.-                              Blank                             , Blank+                            , Blank                             , HaskellLine "  y <- pure 10"                             , HaskellLine "  pure $ x + y"                             ]-                let blocks = splitBlocks result-                length blocks @?= 1+                length (splitBlocks result) @?= 1             , testCase "blank separates blocks" $ do                 let result =                         toGhciScript@@ -121,7 +136,7 @@                 assertBool "has print" (T.isInfixOf "print iris" result)             ]         , testGroup-            "Continuation across blanks (don't break)"+            "Continuation across blanks"             [ testCase "blank between independent IO statements still separates" $ do                 let result =                         toGhciScript@@ -129,10 +144,7 @@                             , Blank                             , HaskellLine "putStrLn \"b\""                             ]-                let blocks = splitBlocks result-                assertBool-                    ("expected 2 blocks, got " ++ show (length blocks))-                    (length blocks == 2)+                length (splitBlocks result) @?= 2             , testCase "blank between imports still separates" $ do                 let result =                         toGhciScript@@ -140,13 +152,9 @@                             , Blank                             , Import "import Data.Map"                             ]-                -- Both imports should appear                 assertBool "has Text" (T.isInfixOf "Data.Text" result)                 assertBool "has Map" (T.isInfixOf "Data.Map" result)-            ]-        , testGroup-            "Continuation across blanks (new behavior)"-            [ testCase "blank before where stays in same block" $ do+            , testCase "blank before where stays in same block" $ do                 let result =                         toGhciScript                             [ HaskellLine "foo x = bar x"@@ -154,14 +162,7 @@                             , HaskellLine "  where"                             , HaskellLine "    bar = id"                             ]-                let blocks = splitBlocks result-                assertBool-                    ( "expected 1 block for where clause, got "-                        ++ show (length blocks)-                        ++ ": "-                        ++ show blocks-                    )-                    (length blocks == 1)+                length (splitBlocks result) @?= 1             , testCase "blank before guards stays in same block" $ do                 let result =                         toGhciScript@@ -170,14 +171,7 @@                             , HaskellLine "  | n > 0 = 1"                             , HaskellLine "  | otherwise = 0"                             ]-                let blocks = splitBlocks result-                assertBool-                    ( "expected 1 block for guards, got "-                        ++ show (length blocks)-                        ++ ": "-                        ++ show blocks-                    )-                    (length blocks == 1)+                length (splitBlocks result) @?= 1             , testCase "blank before deriving stays in same block" $ do                 let result =                         toGhciScript@@ -185,14 +179,7 @@                             , Blank                             , HaskellLine "  deriving (Show, Eq)"                             ]-                let blocks = splitBlocks result-                assertBool-                    ( "expected 1 block for deriving, got "-                        ++ show (length blocks)-                        ++ ": "-                        ++ show blocks-                    )-                    (length blocks == 1)+                length (splitBlocks result) @?= 1             , testCase "double blank still breaks (intentional separation)" $ do                 let result =                         toGhciScript@@ -201,35 +188,25 @@                             , Blank                             , HaskellLine "y = 2"                             ]-                let blocks = splitBlocks result-                assertBool-                    ("expected 2 blocks for double blank, got " ++ show (length blocks))-                    (length blocks == 2)+                length (splitBlocks result) @?= 2             ]         , testGroup-            "Mixed block splitting (don't break)"+            "Mixed block splitting"             [ testCase "pure definitions stay grouped" $ do                 let result =                         toGhciScript                             [ HaskellLine "f x = x + 1"                             , HaskellLine "g y = y * 2"                             ]-                let blocks = splitBlocks result-                assertBool-                    ("expected 1 block for definitions, got " ++ show (length blocks))-                    (length blocks == 1)+                length (splitBlocks result) @?= 1             , testCase "indented continuation stays grouped" $ do                 let result =                         toGhciScript                             [ HaskellLine "f x ="                             , HaskellLine "  x + 1"                             ]-                let blocks = splitBlocks result-                length blocks @?= 1-            ]-        , testGroup-            "Mixed block splitting (new behavior)"-            [ testCase "definition then IO splits into two blocks" $ do+                length (splitBlocks result) @?= 1+            , testCase "definition then IO splits into two blocks" $ do                 let result =                         toGhciScript                             [ HaskellLine "x = 5"@@ -274,29 +251,77 @@                             [ HaskellLine "f :: Int -> Int"                             , HaskellLine "f x = x + 1"                             ]+                length (splitBlocks result) @?= 1+            , testCase "comment + type sig + guarded body + where stays together" $ do+                let result =+                        toGhciScript+                            [ HaskellLine "-- Helper function to check if a number is prime"+                            , HaskellLine "isPrime :: Int -> Bool"+                            , HaskellLine "isPrime n"+                            , HaskellLine "  | n < 2 = False"+                            , HaskellLine "  | n == 2 = True"+                            , HaskellLine "  | even n = False"+                            , HaskellLine "  | otherwise = all (\\k -> n `mod` k /= 0) [3, 5 .. isqrt n]"+                            , HaskellLine "  where isqrt = floor . sqrt . fromIntegral"+                            ]+                length (splitBlocks result) @?= 1+            , testCase "multi-section script: commented + typed functions + do block" $ do+                let result =+                        toGhciScript+                            [ HaskellLine "-- Helper function to check if a number is prime"+                            , HaskellLine "isPrime :: Int -> Bool"+                            , HaskellLine "isPrime n"+                            , HaskellLine "  | n < 2 = False"+                            , HaskellLine "  | n == 2 = True"+                            , HaskellLine "  | even n = False"+                            , HaskellLine "  | otherwise = all (\\k -> n `mod` k /= 0) [3, 5 .. isqrt n]"+                            , HaskellLine "  where isqrt = floor . sqrt . fromIntegral"+                            , Blank+                            , HaskellLine "-- Generate list of primes up to n"+                            , HaskellLine "primesUpTo :: Int -> [Int]"+                            , HaskellLine "primesUpTo n = filter isPrime [2 .. n]"+                            , Blank+                            , HaskellLine "-- Compute gaps between consecutive primes"+                            , HaskellLine "primeGaps :: [Int] -> [Int]"+                            , HaskellLine "primeGaps ps = zipWith (-) (tail ps) ps"+                            , Blank+                            , HaskellLine "-- Test"+                            , HaskellLine "do"+                            , HaskellLine "  let testPrimes = primesUpTo 10"+                            ]                 let blocks = splitBlocks result                 assertBool-                    ("expected 1 block for sig+def, got " ++ show (length blocks))-                    (length blocks == 1)+                    ( "expected 4 blocks (one per logical section), got "+                        ++ show (length blocks)+                        ++ ": "+                        ++ show blocks+                    )+                    (length blocks == 4)             ]         , testGroup-            "Bracket counting (don't break)"+            "Comments"+            [ testCase "comment attaches to following declaration" $ do+                let result =+                        toGhciScript+                            [ HaskellLine "-- doc comment"+                            , HaskellLine "f x = x + 1"+                            ]+                length (splitBlocks result) @?= 1+                assertWrapped result ["-- doc comment", "f x = x + 1"]+            ]+        , testGroup+            "Bracket counting"             [ testCase "complete expression in parens" $ do                 let result = toGhciScript [HaskellLine "f x = (x + 1)"]-                let blocks = splitBlocks result-                length blocks @?= 1+                length (splitBlocks result) @?= 1             , testCase "balanced multiline already grouped by indentation" $ do                 let result =                         toGhciScript                             [ HaskellLine "f x ="                             , HaskellLine "  (x + 1)"                             ]-                let blocks = splitBlocks result-                length blocks @?= 1-            ]-        , testGroup-            "Bracket counting (new behavior)"-            [ testCase "unclosed bracket extends block" $ do+                length (splitBlocks result) @?= 1+            , testCase "unclosed bracket extends block" $ do                 let result =                         toGhciScript                             [ HaskellLine "xs = ["@@ -305,14 +330,7 @@                             , HaskellLine "  3"                             , HaskellLine "  ]"                             ]-                let blocks = splitBlocks result-                assertBool-                    ( "expected 1 block for list literal, got "-                        ++ show (length blocks)-                        ++ ": "-                        ++ show blocks-                    )-                    (length blocks == 1)+                length (splitBlocks result) @?= 1             ]         ]