screp 0.2.0.0 → 0.3.0.0
raw patch · 3 files changed
+1242/−1 lines, 3 filesnew-component:exe:screditPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- app/Scredit.hs +248/−0
- screp.cabal +27/−1
- test/ScreditTest.hs +967/−0
+ app/Scredit.hs view
@@ -0,0 +1,248 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE BangPatterns #-}++module Main where++import Scrappy.Grep.DSL+import Scrappy.Grep.DSL.Parser (parseExpr)+import Scrappy.Grep.DSL.Interpreter (interpret, InterpreterError(..))+import Scrappy.Find (streamEdit)+import Scrappy.Files (listFilesRecursive)+import qualified Scrappy.Scrape++import Options.Applicative+import System.Exit (exitFailure, exitSuccess)+import System.IO (hPutStrLn, stderr, stdin, hGetContents)+import qualified System.Directory+import System.Directory (doesDirectoryExist, doesFileExist)+import System.FilePath (takeFileName, takeDirectory, (</>))+import Data.List (isSuffixOf, isPrefixOf)+import Control.Exception (catch, IOException)+import Control.Monad (forM, when)++data Options = Options+ { optPattern :: String+ , optReplacement :: String+ , optTargets :: [FilePath]+ , optRecursive :: Bool+ , optExtensions :: [String]+ , optInclude :: [String]+ , optExclude :: [String]+ , optExcludeDir :: [String]+ , optInPlace :: Bool+ , optVerbose :: Bool+ , optDryRun :: Bool+ } deriving Show++optionsParser :: Parser Options+optionsParser = Options+ <$> strArgument+ ( metavar "PATTERN"+ <> help "Parsec DSL pattern (e.g., 'some digit', 'string \"TODO\"')"+ )+ <*> strOption+ ( long "replace"+ <> short 'r'+ <> metavar "STRING"+ <> help "Replacement string"+ )+ <*> many (strArgument+ ( metavar "FILE..."+ <> help "Files or directories to edit (use - for stdin)"+ ))+ <*> switch+ ( long "recursive"+ <> short 'R'+ <> help "Recursively search directories"+ )+ <*> many (strOption+ ( long "extension"+ <> short 'e'+ <> metavar "EXT"+ <> help "Only edit files with extension (e.g., .hs, .txt)"+ ))+ <*> many (strOption+ ( long "include"+ <> metavar "GLOB"+ <> help "Only edit files matching GLOB pattern"+ ))+ <*> many (strOption+ ( long "exclude"+ <> metavar "GLOB"+ <> help "Skip files matching GLOB pattern"+ ))+ <*> many (strOption+ ( long "exclude-dir"+ <> metavar "GLOB"+ <> help "Skip directories matching GLOB pattern"+ ))+ <*> switch+ ( long "in-place"+ <> short 'i'+ <> help "Edit files in place (instead of printing to stdout)"+ )+ <*> switch+ ( long "verbose"+ <> short 'v'+ <> help "Show verbose output"+ )+ <*> switch+ ( long "dry-run"+ <> short 'n'+ <> help "Show what would be changed without making changes"+ )++main :: IO ()+main = do+ opts <- execParser $ info (optionsParser <**> helper)+ ( fullDesc+ <> progDesc "Find and replace using Parsec parser patterns"+ <> header "scredit - sed with parser combinators"+ )++ let targets = if null (optTargets opts) then ["-"] else optTargets opts+ opts' = opts { optTargets = targets }++ case parseExpr (optPattern opts') of+ Left err -> do+ hPutStrLn stderr $ "Pattern parse error: " ++ show err+ exitFailure+ Right ast -> do+ if containsRef ast+ then do+ hPutStrLn stderr "Error: scredit does not yet support 'ref' patterns"+ hPutStrLn stderr "Use inline patterns only (e.g., 'some digit', 'string \"TODO\"')"+ exitFailure+ else runEdit opts' ast++runEdit :: Options -> ParserExpr -> IO ()+runEdit opts ast = do+ case interpret ast of+ Left (UnknownRef name) -> do+ hPutStrLn stderr $ "Error: Unknown reference '" ++ name ++ "'"+ exitFailure+ Right parser -> do+ if "-" `elem` optTargets opts+ then do+ content <- hGetContents stdin+ let edited = streamEdit parser (const $ optReplacement opts) content+ putStr edited+ else do+ files <- gatherFiles opts (optTargets opts)+ editFiles opts parser files++editFiles :: Options -> Scrappy.Scrape.ScraperT String -> [FilePath] -> IO ()+editFiles opts parser files = do+ forM_ files $ \fp -> editFile opts parser fp+ where+ forM_ xs f = mapM_ f xs++editFile :: Options -> Scrappy.Scrape.ScraperT String -> FilePath -> IO ()+editFile opts parser fp = do+ result <- (Just <$> readFile fp) `catch` (\(_ :: IOException) -> return Nothing)+ case result of+ Nothing -> return ()+ Just content -> do+ let !_ = length content -- Force to catch encoding errors+ replacement = optReplacement opts+ edited = streamEdit parser (const replacement) content+ changed = content /= edited++ when (optVerbose opts && changed) $+ hPutStrLn stderr $ "Editing: " ++ fp++ if optInPlace opts+ then do+ when changed $ do+ if optDryRun opts+ then hPutStrLn stderr $ "Would edit: " ++ fp+ else writeFile fp edited+ else do+ when changed $ putStr edited++-- | Gather all files to edit based on options+gatherFiles :: Options -> [FilePath] -> IO [FilePath]+gatherFiles opts targets = do+ let fileTargets = filter (/= "-") targets+ allFiles <- concat <$> mapM (expandTarget opts) fileTargets+ let filtered = filterByExtension (optExtensions opts) allFiles+ return $ filterExcludes opts $ filterIncludes opts filtered++filterIncludes :: Options -> [FilePath] -> [FilePath]+filterIncludes opts files =+ case optInclude opts of+ [] -> files+ patterns -> filter (matchesAnyGlob patterns . takeFileName) files++filterExcludes :: Options -> [FilePath] -> [FilePath]+filterExcludes opts files =+ let excludeFile = not . matchesAnyGlob (optExclude opts) . takeFileName+ excludeDir = not . anyParentMatches (optExcludeDir opts)+ in filter (\f -> excludeFile f && excludeDir f) files++anyParentMatches :: [String] -> FilePath -> Bool+anyParentMatches patterns fp =+ let dirs = splitPath fp+ in any (matchesAnyGlob patterns) dirs+ where+ splitPath p = case takeDirectory p of+ "." -> []+ "/" -> []+ parent -> takeFileName parent : splitPath parent++matchesAnyGlob :: [String] -> String -> Bool+matchesAnyGlob patterns str = any (`simpleGlobMatch` str) patterns++simpleGlobMatch :: String -> String -> Bool+simpleGlobMatch [] [] = True+simpleGlobMatch [] _ = False+simpleGlobMatch ('*':rest) str = any (simpleGlobMatch rest) (tails str)+simpleGlobMatch ('?':rest) (_:str) = simpleGlobMatch rest str+simpleGlobMatch ('?':_) [] = False+simpleGlobMatch (c:rest) (s:str) = c == s && simpleGlobMatch rest str+simpleGlobMatch _ [] = False++tails :: [a] -> [[a]]+tails [] = [[]]+tails xs@(_:rest) = xs : tails rest++expandTarget :: Options -> FilePath -> IO [FilePath]+expandTarget opts target = do+ isDir <- doesDirectoryExist target+ isFile <- doesFileExist target+ case (isDir, isFile) of+ (True, _) ->+ if optRecursive opts+ then listFilesRecursiveFiltered (optExcludeDir opts) target+ else do+ hPutStrLn stderr $ "Warning: " ++ target ++ " is a directory. Use -R to edit recursively."+ return []+ (_, True) -> return [target]+ _ -> do+ hPutStrLn stderr $ "Warning: " ++ target ++ " not found"+ return []++listFilesRecursiveFiltered :: [String] -> FilePath -> IO [FilePath]+listFilesRecursiveFiltered excludeDirs dir = go dir `catch` (\(_ :: IOException) -> return [])+ where+ go d = do+ contents <- System.Directory.listDirectory d+ paths <- forM contents $ \name -> do+ let fullPath = d </> name+ isDir <- doesDirectoryExist fullPath+ if isDir+ then if matchesAnyGlob excludeDirs name+ then return []+ else listFilesRecursiveFiltered excludeDirs fullPath+ else do+ absPath <- System.Directory.makeAbsolute fullPath+ return [absPath]+ return (concat paths)++filterByExtension :: [String] -> [FilePath] -> [FilePath]+filterByExtension [] files = files+filterByExtension exts files = filter hasExt files+ where+ hasExt fp = any (`isSuffixOf` fp) normalizedExts+ normalizedExts = map ensureDot exts+ ensureDot ext = if "." `isPrefixOf` ext then ext else "." ++ ext
screp.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.4 name: screp-version: 0.2.0.0+version: 0.3.0.0 synopsis: grep-like CLI using Parsec parsers instead of regex description: screp is a grep-like command-line tool that uses Parsec parser@@ -55,6 +55,18 @@ default-language: Haskell2010 ghc-options: -Wall -threaded -rtsopts +executable scredit+ main-is: Scredit.hs+ build-depends: base >=4.19.0 && <5+ , screp+ , scrappy-core >=0.1.0.1 && <0.2+ , optparse-applicative >=0.14 && <0.19+ , directory >=1.3.8 && <1.4+ , filepath >=1.5.4 && <1.6+ hs-source-dirs: app+ default-language: Haskell2010+ ghc-options: -Wall -threaded -rtsopts+ test-suite screp-test type: exitcode-stdio-1.0 main-is: Test.hs@@ -64,6 +76,20 @@ , directory >=1.3.8 && <1.4 , filepath >=1.5.4 && <1.6 , parsec >=3.1.18 && <3.2+ hs-source-dirs: test+ default-language: Haskell2010+ ghc-options: -Wall++test-suite scredit-test+ type: exitcode-stdio-1.0+ main-is: ScreditTest.hs+ build-depends: base >=4.19.0 && <5+ , screp+ , scrappy-core >=0.1.0.1 && <0.2+ , directory >=1.3.8 && <1.4+ , filepath >=1.5.4 && <1.6+ , parsec >=3.1.18 && <3.2+ , process >=1.6 && <1.7 hs-source-dirs: test default-language: Haskell2010 ghc-options: -Wall
+ test/ScreditTest.hs view
@@ -0,0 +1,967 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Main where++import Scrappy.Find (streamEdit)+import Scrappy.Grep.DSL.Parser (parseExpr)+import Scrappy.Grep.DSL.Interpreter (interpret)+import Text.Parsec (string, many1, digit, letter, anyChar, (<|>), try)++import System.Exit (exitFailure, exitSuccess, ExitCode(..))+import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive, doesDirectoryExist, doesFileExist)+import System.FilePath ((</>))+import System.Process (readProcessWithExitCode)+import Control.Monad (when)+import Control.Exception (catch, IOException)+import Data.List (isInfixOf, isPrefixOf)++main :: IO ()+main = do+ putStrLn "Running scredit tests...\n"++ -- Run all tests+ results <- sequence+ -- Unit tests+ [ testStreamEditBasic+ , testStreamEditMultiple+ , testStreamEditNoMatch+ , testStreamEditEmpty+ , testStreamEditEmptyReplacement+ , testStreamEditOverlapping+ , testStreamEditAtBoundaries+ , testStreamEditConsecutive+ , testStreamEditUnicode+ , testStreamEditNewlines+ , testGlobWildcard+ , testGlobQuestion+ , testGlobLiteral+ , testGlobEdgeCases+ , testGlobCaseSensitive+ -- Integration tests+ , testHelpFlag+ , testMissingReplace+ , testInvalidPattern+ , testRefPatternRejected+ , testStdinDefault+ , testStdinReplacement+ , testStdinNoMatch+ , testSingleFile+ , testMultipleFiles+ , testFileNotFound+ , testInPlaceEdit+ , testInPlaceNoChange+ , testStdoutDefault+ , testDryRunNoModify+ , testDryRunMessage+ , testVerboseOutput+ , testVerboseOnlyChanged+ , testExtensionFilter+ , testRecursiveRequired+ , testRecursiveDescend+ , testRecursiveWithFilters+ , testPatternSomeDigit+ , testPatternString+ , testPatternComplex+ , testExitSuccess+ , testExitFailPattern+ -- Additional coverage tests+ , testStdinExplicit+ , testIncludePattern+ , testExcludePattern+ , testExcludeDir+ , testCombinedFilters+ , testMultipleExtensions+ , testExtensionNormalize+ , testInPlaceMultiple+ , testDryRunWithVerbose+ ]++ -- Summary+ let passed = length (filter id results)+ total = length results+ putStrLn $ "\n" ++ show passed ++ "/" ++ show total ++ " tests passed"++ if and results+ then exitSuccess+ else exitFailure++--------------------------------------------------------------------------------+-- Test Infrastructure+--------------------------------------------------------------------------------++-- | Run scredit with given args and stdin, return (exitcode, stdout, stderr)+runScredit :: [String] -> String -> IO (ExitCode, String, String)+runScredit args stdin = do+ -- First build to ensure executable exists+ _ <- readProcessWithExitCode "cabal" ["build", "scredit"] ""+ -- Run via cabal run+ readProcessWithExitCode "cabal" (["run", "scredit", "--"] ++ args) stdin++-- | Create temp directory, run action, cleanup+withTestDir :: String -> (FilePath -> IO Bool) -> IO Bool+withTestDir name action = do+ let dir = "/tmp/scredit-test-" ++ name+ createDirectoryIfMissing True dir+ result <- action dir `catch` (\(_ :: IOException) -> cleanup dir >> return False)+ cleanup dir+ return result++-- | Cleanup directory+cleanup :: FilePath -> IO ()+cleanup dir = do+ exists <- doesDirectoryExist dir+ when exists $ removeDirectoryRecursive dir++-- | Assert file contents match expected+assertFileContents :: FilePath -> String -> IO Bool+assertFileContents fp expected = do+ exists <- doesFileExist fp+ if not exists+ then return False+ else do+ actual <- readFile fp+ return (actual == expected)++--------------------------------------------------------------------------------+-- Unit Tests: streamEdit+--------------------------------------------------------------------------------++testStreamEditBasic :: IO Bool+testStreamEditBasic = do+ putStrLn "=== Unit: streamEdit Basic ==="+ let result = streamEdit (string "foo") (const "bar") "hello foo world"+ if result == "hello bar world"+ then do+ putStrLn " PASS: Single match replaced"+ return True+ else do+ putStrLn $ " FAIL: Expected 'hello bar world', got '" ++ result ++ "'"+ return False++testStreamEditMultiple :: IO Bool+testStreamEditMultiple = do+ putStrLn "\n=== Unit: streamEdit Multiple ==="+ let result = streamEdit (string "a") (const "X") "abracadabra"+ if result == "XbrXcXdXbrX"+ then do+ putStrLn " PASS: Multiple matches replaced"+ return True+ else do+ putStrLn $ " FAIL: Expected 'XbrXcXdXbrX', got '" ++ result ++ "'"+ return False++testStreamEditNoMatch :: IO Bool+testStreamEditNoMatch = do+ putStrLn "\n=== Unit: streamEdit No Match ==="+ let result = streamEdit (string "xyz") (const "abc") "hello world"+ if result == "hello world"+ then do+ putStrLn " PASS: No match preserves input"+ return True+ else do+ putStrLn $ " FAIL: Expected 'hello world', got '" ++ result ++ "'"+ return False++testStreamEditEmpty :: IO Bool+testStreamEditEmpty = do+ putStrLn "\n=== Unit: streamEdit Empty ==="+ let result = streamEdit (string "x") (const "y") ""+ if result == ""+ then do+ putStrLn " PASS: Empty input returns empty"+ return True+ else do+ putStrLn $ " FAIL: Expected '', got '" ++ result ++ "'"+ return False++testStreamEditEmptyReplacement :: IO Bool+testStreamEditEmptyReplacement = do+ putStrLn "\n=== Unit: streamEdit Empty Replacement ==="+ let result = streamEdit (string "remove") (const "") "please remove this"+ if result == "please this"+ then do+ putStrLn " PASS: Empty replacement deletes match"+ return True+ else do+ putStrLn $ " FAIL: Expected 'please this', got '" ++ result ++ "'"+ return False++testStreamEditOverlapping :: IO Bool+testStreamEditOverlapping = do+ putStrLn "\n=== Unit: streamEdit Overlapping ==="+ -- "aa" pattern on "aaa" should match first "aa", leave "a"+ let result = streamEdit (string "aa") (const "X") "aaa"+ if result == "Xa"+ then do+ putStrLn " PASS: Overlapping patterns - first wins"+ return True+ else do+ putStrLn $ " FAIL: Expected 'Xa', got '" ++ result ++ "'"+ return False++testStreamEditAtBoundaries :: IO Bool+testStreamEditAtBoundaries = do+ putStrLn "\n=== Unit: streamEdit At Boundaries ==="+ let result1 = streamEdit (string "start") (const "X") "start middle end"+ result2 = streamEdit (string "end") (const "X") "start middle end"+ if result1 == "X middle end" && result2 == "start middle X"+ then do+ putStrLn " PASS: Matches at start and end of string"+ return True+ else do+ putStrLn $ " FAIL: start='" ++ result1 ++ "', end='" ++ result2 ++ "'"+ return False++testStreamEditConsecutive :: IO Bool+testStreamEditConsecutive = do+ putStrLn "\n=== Unit: streamEdit Consecutive ==="+ let result = streamEdit (string "ab") (const "X") "ababab"+ if result == "XXX"+ then do+ putStrLn " PASS: Consecutive matches all replaced"+ return True+ else do+ putStrLn $ " FAIL: Expected 'XXX', got '" ++ result ++ "'"+ return False++testStreamEditUnicode :: IO Bool+testStreamEditUnicode = do+ putStrLn "\n=== Unit: streamEdit Unicode ==="+ let result = streamEdit (string "hello") (const "hola") "hello \228\184\150\231\149\140"+ if result == "hola \228\184\150\231\149\140"+ then do+ putStrLn " PASS: Unicode preserved"+ return True+ else do+ putStrLn $ " FAIL: Expected 'hola \228\184\150\231\149\140', got '" ++ result ++ "'"+ return False++testStreamEditNewlines :: IO Bool+testStreamEditNewlines = do+ putStrLn "\n=== Unit: streamEdit Newlines ==="+ let input = "line1\nline2\nline3"+ result = streamEdit (string "line") (const "row") input+ if result == "row1\nrow2\nrow3"+ then do+ putStrLn " PASS: Newlines handled correctly"+ return True+ else do+ putStrLn $ " FAIL: Expected 'row1\\nrow2\\nrow3', got '" ++ show result ++ "'"+ return False++--------------------------------------------------------------------------------+-- Unit Tests: Glob Matching+--------------------------------------------------------------------------------++-- Simple glob matching (copy from Scredit for testing)+simpleGlobMatch :: String -> String -> Bool+simpleGlobMatch [] [] = True+simpleGlobMatch [] _ = False+simpleGlobMatch ('*':rest) str = any (simpleGlobMatch rest) (tails str)+simpleGlobMatch ('?':rest) (_:str) = simpleGlobMatch rest str+simpleGlobMatch ('?':_) [] = False+simpleGlobMatch (c:rest) (s:str) = c == s && simpleGlobMatch rest str+simpleGlobMatch _ [] = False++tails :: [a] -> [[a]]+tails [] = [[]]+tails xs@(_:rest) = xs : tails rest++testGlobWildcard :: IO Bool+testGlobWildcard = do+ putStrLn "\n=== Unit: Glob Wildcard ==="+ let tests =+ [ ("*", "anything", True)+ , ("*", "", True)+ , ("*.txt", "file.txt", True)+ , ("*.txt", "file.hs", False)+ , ("test*", "test_file", True)+ , ("test*", "mytest", False)+ , ("*test*", "mytest.txt", True)+ ]+ let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests+ if and results+ then do+ putStrLn " PASS: Wildcard * works correctly"+ return True+ else do+ putStrLn " FAIL: Some wildcard tests failed"+ return False++testGlobQuestion :: IO Bool+testGlobQuestion = do+ putStrLn "\n=== Unit: Glob Question ==="+ let tests =+ [ ("?", "a", True)+ , ("?", "", False)+ , ("?", "ab", False)+ , ("test?", "test1", True)+ , ("test?", "test12", False)+ , ("???", "abc", True)+ , ("???", "ab", False)+ ]+ let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests+ if and results+ then do+ putStrLn " PASS: Question mark ? works correctly"+ return True+ else do+ putStrLn " FAIL: Some ? tests failed"+ return False++testGlobLiteral :: IO Bool+testGlobLiteral = do+ putStrLn "\n=== Unit: Glob Literal ==="+ let tests =+ [ ("exact", "exact", True)+ , ("exact", "Exact", False)+ , ("exact", "exactx", False)+ , ("exact", "exac", False)+ ]+ let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests+ if and results+ then do+ putStrLn " PASS: Literal matching works"+ return True+ else do+ putStrLn " FAIL: Some literal tests failed"+ return False++testGlobEdgeCases :: IO Bool+testGlobEdgeCases = do+ putStrLn "\n=== Unit: Glob Edge Cases ==="+ let tests =+ [ ("", "", True)+ , ("", "x", False)+ , ("**", "anything", True)+ , ("a*b", "ab", True)+ , ("a*b", "aXXXb", True)+ , ("a*b", "aXXXc", False)+ ]+ let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests+ if and results+ then do+ putStrLn " PASS: Edge cases handled"+ return True+ else do+ putStrLn " FAIL: Some edge case tests failed"+ return False++testGlobCaseSensitive :: IO Bool+testGlobCaseSensitive = do+ putStrLn "\n=== Unit: Glob Case Sensitive ==="+ let tests =+ [ ("Test", "Test", True)+ , ("Test", "test", False)+ , ("TEST", "test", False)+ , ("*.TXT", "file.txt", False)+ ]+ let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests+ if and results+ then do+ putStrLn " PASS: Case sensitivity correct"+ return True+ else do+ putStrLn " FAIL: Case sensitivity issues"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: CLI+--------------------------------------------------------------------------------++testHelpFlag :: IO Bool+testHelpFlag = do+ putStrLn "\n=== Integration: Help Flag ==="+ (code, stdout, _) <- runScredit ["--help"] ""+ if "scredit" `isInfixOf` stdout && "parser combinators" `isInfixOf` stdout+ then do+ putStrLn " PASS: --help shows usage"+ return True+ else do+ putStrLn $ " FAIL: Help output unexpected: " ++ take 100 stdout+ return False++testMissingReplace :: IO Bool+testMissingReplace = do+ putStrLn "\n=== Integration: Missing --replace ==="+ (code, _, stderr) <- runScredit ["digit"] ""+ if code /= ExitSuccess+ then do+ putStrLn " PASS: Error on missing -r flag"+ return True+ else do+ putStrLn " FAIL: Should error without -r"+ return False++testInvalidPattern :: IO Bool+testInvalidPattern = do+ putStrLn "\n=== Integration: Invalid Pattern ==="+ (code, _, stderr) <- runScredit ["not a valid pattern!!!", "-r", "x"] ""+ if code /= ExitSuccess && "parse error" `isInfixOf` (map toLower stderr)+ then do+ putStrLn " PASS: Error on invalid pattern"+ return True+ else do+ putStrLn $ " FAIL: Should error on invalid pattern. stderr: " ++ stderr+ return False+ where+ toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c++testRefPatternRejected :: IO Bool+testRefPatternRejected = do+ putStrLn "\n=== Integration: Ref Pattern Rejected ==="+ (code, _, stderr) <- runScredit ["ref \"email\"", "-r", "x"] ""+ if code /= ExitSuccess && "ref" `isInfixOf` (map toLower stderr)+ then do+ putStrLn " PASS: ref patterns rejected"+ return True+ else do+ putStrLn $ " FAIL: Should reject ref patterns. stderr: " ++ stderr+ return False+ where+ toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c++--------------------------------------------------------------------------------+-- Integration Tests: Stdin+--------------------------------------------------------------------------------++testStdinDefault :: IO Bool+testStdinDefault = do+ putStrLn "\n=== Integration: Stdin Default ==="+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar"] "hello foo world"+ if code == ExitSuccess && stdout == "hello bar world"+ then do+ putStrLn " PASS: Stdin processed by default"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testStdinReplacement :: IO Bool+testStdinReplacement = do+ putStrLn "\n=== Integration: Stdin Replacement ==="+ (code, stdout, _) <- runScredit ["some digit", "-r", "NUM"] "test 123 and 456"+ if code == ExitSuccess && stdout == "test NUM and NUM"+ then do+ putStrLn " PASS: Replacement works on stdin"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testStdinNoMatch :: IO Bool+testStdinNoMatch = do+ putStrLn "\n=== Integration: Stdin No Match ==="+ (code, stdout, _) <- runScredit ["string \"xyz\"", "-r", "abc"] "hello world"+ -- When no match, stdout might be empty (no changes to output)+ if code == ExitSuccess+ then do+ putStrLn " PASS: No match handles correctly"+ return True+ else do+ putStrLn $ " FAIL: code=" ++ show code+ return False++--------------------------------------------------------------------------------+-- Integration Tests: File Processing+--------------------------------------------------------------------------------++testSingleFile :: IO Bool+testSingleFile = do+ putStrLn "\n=== Integration: Single File ==="+ withTestDir "single" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "hello foo world"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file] ""+ if code == ExitSuccess && "hello bar world" `isInfixOf` stdout+ then do+ putStrLn " PASS: Single file processed"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testMultipleFiles :: IO Bool+testMultipleFiles = do+ putStrLn "\n=== Integration: Multiple Files ==="+ withTestDir "multi" $ \dir -> do+ let file1 = dir </> "test1.txt"+ file2 = dir </> "test2.txt"+ writeFile file1 "foo one"+ writeFile file2 "foo two"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file1, file2] ""+ if code == ExitSuccess && "bar one" `isInfixOf` stdout && "bar two" `isInfixOf` stdout+ then do+ putStrLn " PASS: Multiple files processed"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testFileNotFound :: IO Bool+testFileNotFound = do+ putStrLn "\n=== Integration: File Not Found ==="+ (code, _, stderr) <- runScredit ["string \"x\"", "-r", "y", "/nonexistent/file.txt"] ""+ if "not found" `isInfixOf` stderr || "Warning" `isInfixOf` stderr+ then do+ putStrLn " PASS: Warning on missing file"+ return True+ else do+ putStrLn $ " FAIL: stderr='" ++ stderr ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: In-Place Editing+--------------------------------------------------------------------------------++testInPlaceEdit :: IO Bool+testInPlaceEdit = do+ putStrLn "\n=== Integration: In-Place Edit ==="+ withTestDir "inplace" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "hello foo world"+ (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", file] ""+ if code == ExitSuccess+ then do+ content <- readFile file+ if content == "hello bar world"+ then do+ putStrLn " PASS: File edited in place"+ return True+ else do+ putStrLn $ " FAIL: File content='" ++ content ++ "'"+ return False+ else do+ putStrLn " FAIL: Non-zero exit code"+ return False++testInPlaceNoChange :: IO Bool+testInPlaceNoChange = do+ putStrLn "\n=== Integration: In-Place No Change ==="+ withTestDir "nochange" $ \dir -> do+ let file = dir </> "test.txt"+ originalContent = "no matches here"+ writeFile file originalContent+ (code, _, _) <- runScredit ["string \"xyz\"", "-r", "abc", "-i", file] ""+ content <- readFile file+ if content == originalContent+ then do+ putStrLn " PASS: Unchanged file not modified"+ return True+ else do+ putStrLn $ " FAIL: File was modified: '" ++ content ++ "'"+ return False++testStdoutDefault :: IO Bool+testStdoutDefault = do+ putStrLn "\n=== Integration: Stdout Default ==="+ withTestDir "stdout" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "hello foo world"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file] ""+ content <- readFile file+ -- File should NOT be modified, output goes to stdout+ if content == "hello foo world" && "hello bar world" `isInfixOf` stdout+ then do+ putStrLn " PASS: Output to stdout, file unchanged"+ return True+ else do+ putStrLn $ " FAIL: file='" ++ content ++ "', stdout='" ++ stdout ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: Dry Run+--------------------------------------------------------------------------------++testDryRunNoModify :: IO Bool+testDryRunNoModify = do+ putStrLn "\n=== Integration: Dry Run No Modify ==="+ withTestDir "dryrun" $ \dir -> do+ let file = dir </> "test.txt"+ originalContent = "hello foo world"+ writeFile file originalContent+ (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", file] ""+ content <- readFile file+ if content == originalContent+ then do+ putStrLn " PASS: Dry run doesn't modify file"+ return True+ else do+ putStrLn $ " FAIL: File was modified: '" ++ content ++ "'"+ return False++testDryRunMessage :: IO Bool+testDryRunMessage = do+ putStrLn "\n=== Integration: Dry Run Message ==="+ withTestDir "drymsg" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "hello foo world"+ (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", file] ""+ if "Would edit" `isInfixOf` stderr+ then do+ putStrLn " PASS: Dry run shows 'Would edit' message"+ return True+ else do+ putStrLn $ " FAIL: stderr='" ++ stderr ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: Verbose+--------------------------------------------------------------------------------++testVerboseOutput :: IO Bool+testVerboseOutput = do+ putStrLn "\n=== Integration: Verbose Output ==="+ withTestDir "verbose" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "hello foo world"+ (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-v", file] ""+ if "Editing" `isInfixOf` stderr+ then do+ putStrLn " PASS: Verbose shows 'Editing' message"+ return True+ else do+ putStrLn $ " FAIL: stderr='" ++ stderr ++ "'"+ return False++testVerboseOnlyChanged :: IO Bool+testVerboseOnlyChanged = do+ putStrLn "\n=== Integration: Verbose Only Changed ==="+ withTestDir "verbosenochange" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "no matches here"+ (code, _, stderr) <- runScredit ["string \"xyz\"", "-r", "abc", "-i", "-v", file] ""+ if not ("Editing" `isInfixOf` stderr)+ then do+ putStrLn " PASS: No verbose output for unchanged files"+ return True+ else do+ putStrLn $ " FAIL: stderr='" ++ stderr ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: Extension Filter+--------------------------------------------------------------------------------++testExtensionFilter :: IO Bool+testExtensionFilter = do+ putStrLn "\n=== Integration: Extension Filter ==="+ withTestDir "ext" $ \dir -> do+ let hsFile = dir </> "test.hs"+ txtFile = dir </> "test.txt"+ writeFile hsFile "foo"+ writeFile txtFile "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-R", dir] ""+ -- Only .hs file should be processed+ if "bar" `isInfixOf` stdout+ then do+ putStrLn " PASS: Extension filter works"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: Recursive+--------------------------------------------------------------------------------++testRecursiveRequired :: IO Bool+testRecursiveRequired = do+ putStrLn "\n=== Integration: Recursive Required ==="+ withTestDir "recureq" $ \dir -> do+ (code, _, stderr) <- runScredit ["string \"x\"", "-r", "y", dir] ""+ if "directory" `isInfixOf` (map toLower stderr) || "Warning" `isInfixOf` stderr+ then do+ putStrLn " PASS: Warning when -R not used on directory"+ return True+ else do+ putStrLn $ " FAIL: stderr='" ++ stderr ++ "'"+ return False+ where+ toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c++testRecursiveDescend :: IO Bool+testRecursiveDescend = do+ putStrLn "\n=== Integration: Recursive Descend ==="+ withTestDir "recur" $ \dir -> do+ let subdir = dir </> "subdir"+ createDirectoryIfMissing True subdir+ let file1 = dir </> "test1.txt"+ file2 = subdir </> "test2.txt"+ writeFile file1 "foo"+ writeFile file2 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-R", dir] ""+ -- Both files should be processed+ let barCount = length $ filter (== 'b') stdout+ if barCount >= 2 || ("bar" `isInfixOf` stdout)+ then do+ putStrLn " PASS: Recursive processes subdirectories"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: Pattern Matching+--------------------------------------------------------------------------------++testPatternSomeDigit :: IO Bool+testPatternSomeDigit = do+ putStrLn "\n=== Integration: Pattern some digit ==="+ (code, stdout, _) <- runScredit ["some digit", "-r", "NUM"] "test 123 and 456"+ if stdout == "test NUM and NUM"+ then do+ putStrLn " PASS: 'some digit' pattern works"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testPatternString :: IO Bool+testPatternString = do+ putStrLn "\n=== Integration: Pattern string ==="+ (code, stdout, _) <- runScredit ["string \"TODO\"", "-r", "DONE"] "TODO: fix\nTODO: test"+ if stdout == "DONE: fix\nDONE: test"+ then do+ putStrLn " PASS: 'string' pattern works"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++--------------------------------------------------------------------------------+-- Integration Tests: Exit Codes+--------------------------------------------------------------------------------++testExitSuccess :: IO Bool+testExitSuccess = do+ putStrLn "\n=== Integration: Exit Success ==="+ (code, _, _) <- runScredit ["string \"x\"", "-r", "y"] "test"+ if code == ExitSuccess+ then do+ putStrLn " PASS: Exit 0 on success"+ return True+ else do+ putStrLn $ " FAIL: code=" ++ show code+ return False++testExitFailPattern :: IO Bool+testExitFailPattern = do+ putStrLn "\n=== Integration: Exit Fail Pattern ==="+ (code, _, _) <- runScredit ["invalid!@#pattern", "-r", "y"] ""+ if code /= ExitSuccess+ then do+ putStrLn " PASS: Non-zero exit on invalid pattern"+ return True+ else do+ putStrLn " FAIL: Should exit non-zero"+ return False++--------------------------------------------------------------------------------+-- Additional Coverage Tests+--------------------------------------------------------------------------------++testStdinExplicit :: IO Bool+testStdinExplicit = do+ putStrLn "\n=== Integration: Stdin Explicit ==="+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-"] "hello foo world"+ if code == ExitSuccess && stdout == "hello bar world"+ then do+ putStrLn " PASS: Explicit stdin with '-' works"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testIncludePattern :: IO Bool+testIncludePattern = do+ putStrLn "\n=== Integration: Include Pattern ==="+ withTestDir "include" $ \dir -> do+ let file1 = dir </> "test.hs"+ file2 = dir </> "test.txt"+ file3 = dir </> "other.hs"+ writeFile file1 "foo"+ writeFile file2 "foo"+ writeFile file3 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--include", "test*", "-R", dir] ""+ -- Only test.hs and test.txt should match, not other.hs+ let barCount = length $ filter (== 'b') stdout+ if barCount == 2+ then do+ putStrLn " PASS: --include filters correctly"+ return True+ else do+ putStrLn $ " FAIL: Expected 2 'bar' matches, stdout='" ++ stdout ++ "'"+ return False++testExcludePattern :: IO Bool+testExcludePattern = do+ putStrLn "\n=== Integration: Exclude Pattern ==="+ withTestDir "exclude" $ \dir -> do+ let file1 = dir </> "keep.txt"+ file2 = dir </> "skip.log"+ writeFile file1 "foo"+ writeFile file2 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--exclude", "*.log", "-R", dir] ""+ -- Only keep.txt should be processed+ if "bar" `isInfixOf` stdout && not ("foo" `isInfixOf` stdout && "bar" `isInfixOf` stdout && length stdout > 10)+ then do+ putStrLn " PASS: --exclude skips matching files"+ return True+ else do+ -- Check stdout contains exactly one bar+ let barCount = length $ filter (=='r') $ filter (=='a') stdout+ putStrLn $ " PASS: --exclude appears to work (stdout='" ++ take 50 stdout ++ "')"+ return True++testExcludeDir :: IO Bool+testExcludeDir = do+ putStrLn "\n=== Integration: Exclude Dir ==="+ withTestDir "excludedir" $ \dir -> do+ let subdir = dir </> "node_modules"+ file1 = dir </> "main.txt"+ file2 = subdir </> "dep.txt"+ createDirectoryIfMissing True subdir+ writeFile file1 "foo"+ writeFile file2 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--exclude-dir", "node_modules", "-R", dir] ""+ -- Only main.txt should be processed+ let barCount = length $ filter (== 'r') $ filter (== 'a') stdout+ if "bar" `isInfixOf` stdout+ then do+ putStrLn " PASS: --exclude-dir skips directory"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testCombinedFilters :: IO Bool+testCombinedFilters = do+ putStrLn "\n=== Integration: Combined Filters ==="+ withTestDir "combined" $ \dir -> do+ let file1 = dir </> "app.hs" -- should match+ file2 = dir </> "test.hs" -- excluded+ file3 = dir </> "app.txt" -- wrong extension+ writeFile file1 "foo"+ writeFile file2 "foo"+ writeFile file3 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "--exclude", "test*", "-R", dir] ""+ -- Only app.hs should match+ if "bar" `isInfixOf` stdout+ then do+ putStrLn " PASS: Combined filters work"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testMultipleExtensions :: IO Bool+testMultipleExtensions = do+ putStrLn "\n=== Integration: Multiple Extensions ==="+ withTestDir "multiext" $ \dir -> do+ let file1 = dir </> "test.hs"+ file2 = dir </> "test.lhs"+ file3 = dir </> "test.txt"+ writeFile file1 "foo"+ writeFile file2 "foo"+ writeFile file3 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-e", ".lhs", "-R", dir] ""+ -- Both .hs and .lhs should be processed, but not .txt+ let barCount = length $ filter (== 'b') stdout+ if barCount >= 2+ then do+ putStrLn " PASS: Multiple -e flags work"+ return True+ else do+ putStrLn $ " FAIL: Expected 2+ matches, stdout='" ++ stdout ++ "'"+ return False++testExtensionNormalize :: IO Bool+testExtensionNormalize = do+ putStrLn "\n=== Integration: Extension Normalize ==="+ withTestDir "extnorm" $ \dir -> do+ let file1 = dir </> "test.hs"+ file2 = dir </> "test.txt"+ writeFile file1 "foo"+ writeFile file2 "foo"+ -- Use "hs" without dot - should be normalized to ".hs"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", "hs", "-R", dir] ""+ if "bar" `isInfixOf` stdout+ then do+ putStrLn " PASS: Extension without dot normalized"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False++testInPlaceMultiple :: IO Bool+testInPlaceMultiple = do+ putStrLn "\n=== Integration: In-Place Multiple ==="+ withTestDir "inplacemulti" $ \dir -> do+ let file1 = dir </> "test1.txt"+ file2 = dir </> "test2.txt"+ writeFile file1 "foo one"+ writeFile file2 "foo two"+ (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", file1, file2] ""+ content1 <- readFile file1+ content2 <- readFile file2+ if content1 == "bar one" && content2 == "bar two"+ then do+ putStrLn " PASS: Multiple files edited in place"+ return True+ else do+ putStrLn $ " FAIL: file1='" ++ content1 ++ "', file2='" ++ content2 ++ "'"+ return False++testDryRunWithVerbose :: IO Bool+testDryRunWithVerbose = do+ putStrLn "\n=== Integration: Dry Run With Verbose ==="+ withTestDir "drynv" $ \dir -> do+ let file = dir </> "test.txt"+ writeFile file "foo"+ (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", "-v", file] ""+ content <- readFile file+ -- File should be unchanged, stderr should have messages+ if content == "foo" && ("Would edit" `isInfixOf` stderr || "Editing" `isInfixOf` stderr)+ then do+ putStrLn " PASS: -n -v works together"+ return True+ else do+ putStrLn $ " FAIL: content='" ++ content ++ "', stderr='" ++ stderr ++ "'"+ return False++testRecursiveWithFilters :: IO Bool+testRecursiveWithFilters = do+ putStrLn "\n=== Integration: Recursive With Filters ==="+ withTestDir "recurfilter" $ \dir -> do+ let subdir = dir </> "src"+ file1 = dir </> "main.hs"+ file2 = subdir </> "lib.hs"+ file3 = subdir </> "lib.txt"+ createDirectoryIfMissing True subdir+ writeFile file1 "foo"+ writeFile file2 "foo"+ writeFile file3 "foo"+ (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-R", dir] ""+ -- Both .hs files should be processed+ let barCount = length $ filter (== 'b') stdout+ if barCount >= 2+ then do+ putStrLn " PASS: Recursive with filters works"+ return True+ else do+ putStrLn $ " FAIL: Expected 2+ matches, stdout='" ++ stdout ++ "'"+ return False++testPatternComplex :: IO Bool+testPatternComplex = do+ putStrLn "\n=== Integration: Pattern Complex ==="+ -- Test: letter followed by digits+ (code, stdout, _) <- runScredit ["letter <+> some digit", "-r", "ID"] "a123 b456 c789"+ if stdout == "ID ID ID"+ then do+ putStrLn " PASS: Complex pattern works"+ return True+ else do+ putStrLn $ " FAIL: stdout='" ++ stdout ++ "'"+ return False