packages feed

screp-0.2.0.0: app/Main.hs

{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}

module Main where

import Scrappy.Grep.DSL
import Scrappy.Grep.DSL.Parser (parseExpr)
import Scrappy.Grep.DSL.Interpreter (interpret, InterpreterError(..))
import Scrappy.Grep.Search (searchFilesWithOpts, SearchOptions(..), defaultSearchOptions)
import Scrappy.Grep.Output (formatResultsWithOpts, formatResultsGrouped, OutputFormat(..), OutputOptions(..), ColorMode(..))
import Scrappy.Grep.Config (runParserViaGhc, ConfigError(..))
import Scrappy.Files (listFilesRecursive)
import qualified Scrappy.Scrape
import qualified Text.Parsec

import Options.Applicative hiding ((<|>))
import System.Exit (exitFailure, exitSuccess)
import System.IO (hPutStrLn, stderr, stdin, hGetContents, hIsTerminalDevice, stdout)
import qualified System.Directory
import System.Directory (doesDirectoryExist, doesFileExist)
import System.FilePath (takeFileName, takeDirectory, (</>))
import Data.List (isSuffixOf, nub, isPrefixOf)
import Data.Char (toLower)
import Control.Exception (catch, IOException)
import Control.Monad (forM)
import Control.Applicative ((<|>), (<$))

data Options = Options
  { optPattern      :: String
  , optTargets      :: [FilePath]
  , optRecursive    :: Bool
  , optExtensions   :: [String]
  , optInclude      :: [String]      -- --include
  , optExclude      :: [String]      -- --exclude
  , optExcludeDir   :: [String]      -- --exclude-dir
  , optIgnoreCase   :: Bool          -- -i
  , optVerbose      :: Bool
  , optCount        :: Bool
  , optQuiet        :: Bool
  , optMaxResults   :: Maybe Int
  , optJSON         :: Bool
  , optImport       :: Maybe FilePath
  , optFilesOnly    :: Bool          -- -l
  , optFilesWithout :: Bool          -- -L
  , optNoFilename   :: Bool          -- -h
  , optContextBefore :: Int          -- -B
  , optContextAfter  :: Int          -- -A
  , optContext       :: Int          -- -C (both)
  , optColor        :: Maybe String  -- --color
  } deriving Show

optionsParser :: Parser Options
optionsParser = Options
  <$> strArgument
      ( metavar "PATTERN"
     <> help "Parsec DSL pattern (e.g., 'some digit', 'ref \"email\"')"
      )
  <*> many (strArgument
      ( metavar "FILE..."
     <> help "Files or directories to search (use - for stdin)"
      ))
  <*> switch
      ( long "recursive"
     <> short 'r'
     <> help "Search directories recursively"
      )
  <*> many (strOption
      ( long "extension"
     <> short 'e'
     <> metavar "EXT"
     <> help "Only search files with extension (e.g., .hs, .txt)"
      ))
  <*> many (strOption
      ( long "include"
     <> metavar "GLOB"
     <> help "Only search files matching GLOB pattern (e.g., *.hs, src/*.txt)"
      ))
  <*> 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 "ignore-case"
     <> short 'i'
     <> help "Ignore case distinctions in pattern"
      )
  <*> switch
      ( long "verbose"
     <> short 'v'
     <> help "Verbose output with full match text"
      )
  <*> switch
      ( long "count"
     <> short 'c'
     <> help "Only print count of matches"
      )
  <*> switch
      ( long "quiet"
     <> short 'q'
     <> help "Quiet mode (exit code only)"
      )
  <*> optional (option auto
      ( long "max-results"
     <> short 'm'
     <> metavar "N"
     <> help "Maximum number of results to show"
      ))
  <*> switch
      ( long "json"
     <> help "Output results as JSON"
      )
  <*> optional (strOption
      ( long "import"
     <> metavar "FILE"
     <> help "Haskell file with named parsers (for use with 'ref \"name\"')"
      ))
  <*> switch
      ( long "files-with-matches"
     <> short 'l'
     <> help "Print only names of files with matches"
      )
  <*> switch
      ( long "files-without-match"
     <> short 'L'
     <> help "Print only names of files without matches"
      )
  <*> switch
      ( long "no-filename"
     <> short 'h'
     <> help "Suppress the file name prefix on output"
      )
  <*> option auto
      ( long "before-context"
     <> short 'B'
     <> metavar "NUM"
     <> value 0
     <> help "Print NUM lines of leading context"
      )
  <*> option auto
      ( long "after-context"
     <> short 'A'
     <> metavar "NUM"
     <> value 0
     <> help "Print NUM lines of trailing context"
      )
  <*> option auto
      ( long "context"
     <> short 'C'
     <> metavar "NUM"
     <> value 0
     <> help "Print NUM lines of context"
      )
  <*> optional (strOption
      ( long "color"
     <> long "colour"
     <> metavar "WHEN"
     <> help "Use color: always, never, or auto"
      ))

main :: IO ()
main = do
  opts <- execParser $ info (optionsParser <**> helper)
    ( fullDesc
   <> progDesc "Search files using Parsec parser patterns"
   <> header "screp - grep with parser combinators"
    )

  -- Handle stdin if no targets or "-" specified
  let targets = if null (optTargets opts) then ["-"] else optTargets opts
      opts' = opts { optTargets = targets }

  -- Parse the DSL pattern (with case folding if -i)
  let patternStr = if optIgnoreCase opts'
                   then map toLower (optPattern opts')
                   else optPattern opts'

  case parseExpr patternStr of
    Left err -> do
      hPutStrLn stderr $ "Pattern parse error: " ++ show err
      exitFailure
    Right ast -> do
      -- Check if pattern uses refs
      if containsRef ast
        then runWithRefs opts' ast
        else runWithoutRefs opts' ast

-- | Build SearchOptions from CLI Options
mkSearchOptions :: Options -> SearchOptions
mkSearchOptions opts = SearchOptions
  { soIgnoreCase = optIgnoreCase opts
  , soContextBefore = if optContext opts > 0 then optContext opts else optContextBefore opts
  , soContextAfter = if optContext opts > 0 then optContext opts else optContextAfter opts
  }

-- | Build OutputOptions from CLI Options
mkOutputOptions :: Options -> IO OutputOptions
mkOutputOptions opts = do
  colorMode <- case optColor opts of
    Just "always" -> pure ColorAlways
    Just "never"  -> pure ColorNever
    Just "auto"   -> do
      isTerm <- hIsTerminalDevice stdout
      pure $ if isTerm then ColorAlways else ColorNever
    Nothing -> do
      isTerm <- hIsTerminalDevice stdout
      pure $ if isTerm then ColorAlways else ColorNever
    Just other -> do
      hPutStrLn stderr $ "Invalid --color value: " ++ other ++ " (use always, never, or auto)"
      pure ColorNever
  pure OutputOptions
    { ooColor = colorMode
    , ooNoFilename = optNoFilename opts
    }

-- | Run search using inline DSL only (no refs)
runWithoutRefs :: Options -> ParserExpr -> IO ()
runWithoutRefs opts ast = do
  case interpret ast of
    Left (UnknownRef name) -> do
      hPutStrLn stderr $ "Unknown parser reference: " ++ name
      hPutStrLn stderr "Use --import to provide a Haskell file with named parsers."
      exitFailure
    Right parser -> do
      -- Handle stdin
      if "-" `elem` optTargets opts
        then do
          content <- hGetContents stdin
          let searchOpts = mkSearchOptions opts
              results = searchTextWithOpts searchOpts "(stdin)" parser content
          outputResults opts results
        else do
          files <- gatherFiles opts (optTargets opts)
          let searchOpts = mkSearchOptions opts
          allResults <- searchFilesWithOpts searchOpts parser files
          outputResults opts allResults

-- | Search text with options (helper for stdin)
searchTextWithOpts :: SearchOptions -> FilePath -> Scrappy.Scrape.ScraperT String -> String -> [MatchResult]
searchTextWithOpts opts fp parser content =
  let contentToSearch = if soIgnoreCase opts then map toLower content else content
      contentLines = lines content
  in case Text.Parsec.parse (findAllWithPos parser) "" contentToSearch of
       Left _ -> []
       Right matches -> map (toMatchResultWithContext opts contentLines fp) matches
  where
    findAllWithPos p = go
      where
        go = do
          atEnd <- (True <$ Text.Parsec.eof) <|> pure False
          if atEnd
            then pure []
            else tryMatch <|> skipAndContinue

        tryMatch = do
          pos <- Text.Parsec.getPosition
          let line = Text.Parsec.sourceLine pos
              col = Text.Parsec.sourceColumn pos
          matched <- Text.Parsec.try p
          rest <- go
          pure $ (line, col, matched) : rest

        skipAndContinue = do
          _ <- Text.Parsec.anyChar
          go

    toMatchResultWithContext sopts contentLines fp' (line, col, matched) =
      let ctx = if soContextBefore sopts > 0 || soContextAfter sopts > 0
                then Just $ getContext sopts contentLines line
                else Nothing
      in MatchResult
           { mrFilePath = fp'
           , mrLine = line
           , mrCol = col
           , mrMatchText = matched
           , mrContext = ctx
           }

    getContext sopts contentLines matchLine =
      let beforeStart = max 0 (matchLine - 1 - soContextBefore sopts)
          beforeEnd = matchLine - 1
          afterStart = matchLine
          afterEnd = min (length contentLines) (matchLine + soContextAfter sopts)
          beforeLines = take (beforeEnd - beforeStart) $ drop beforeStart contentLines
          afterLines = take (afterEnd - afterStart) $ drop afterStart contentLines
      in MatchContext { mcBefore = beforeLines, mcAfter = afterLines }

-- | Run search using external parsers via runghc
runWithRefs :: Options -> ParserExpr -> IO ()
runWithRefs opts ast = do
  case optImport opts of
    Nothing -> do
      hPutStrLn stderr "Pattern uses 'ref' but no --import file specified."
      hPutStrLn stderr "Usage: screp --import Parsers.hs 'ref \"email\"' file.txt"
      exitFailure
    Just importPath -> do
      -- Extract the ref names (for now, only support single ref patterns)
      let refs = nub $ extractRefs ast
      case refs of
        [refName] -> do
          files <- gatherFiles opts (optTargets opts)
          allResults <- searchFilesWithRefOpts opts importPath refName files
          outputResults opts allResults
        [] -> do
          -- No refs found, shouldn't happen since containsRef was true
          hPutStrLn stderr "Internal error: no refs found"
          exitFailure
        _ -> do
          hPutStrLn stderr "Currently only single ref patterns are supported."
          hPutStrLn stderr $ "Found refs: " ++ show refs
          exitFailure

-- | Search files using external parser via runghc
searchFilesWithRefOpts :: Options -> FilePath -> String -> [FilePath] -> IO [MatchResult]
searchFilesWithRefOpts opts importPath refName files = do
  results <- mapM searchOne files
  pure $ concat results
  where
    searchOne fp = readAndSearch `catch` handleIOError
      where
        readAndSearch = do
          content <- readFile fp
          -- Force evaluation to catch encoding errors early
          let !_ = length content
              contentToSearch = if optIgnoreCase opts then map toLower content else content
          result <- runParserViaGhc importPath refName contentToSearch
          case result of
            Left err -> do
              hPutStrLn stderr $ "Error searching " ++ fp ++ ": " ++ showError err
              pure []
            Right matches -> pure $ map (toMatchResult fp) matches

        handleIOError :: IOException -> IO [MatchResult]
        handleIOError _ = pure []  -- Skip files we can't read (binary, permissions, etc.)

    toMatchResult fp (line, col, matchText) = MatchResult
      { mrFilePath = fp
      , mrLine = line
      , mrCol = col
      , mrMatchText = matchText
      , mrContext = Nothing  -- TODO: add context support for refs
      }

    showError (ConfigFileNotFound path) = "Import file not found: " ++ path
    showError (GhcRunFailed msg) = "GHC error: " ++ msg
    showError (ParseResultFailed msg) = "Parse error: " ++ msg

-- | Output results based on options
outputResults :: Options -> [MatchResult] -> IO ()
outputResults opts allResults = do
  outOpts <- mkOutputOptions opts

  -- Handle -L (files without matches)
  if optFilesWithout opts
    then do
      files <- gatherFiles opts (optTargets opts)
      let filesWithMatches = nub $ map mrFilePath allResults
          filesWithoutMatches = filter (`notElem` filesWithMatches) files
      mapM_ putStrLn filesWithoutMatches
      if null filesWithoutMatches then exitFailure else exitSuccess
    else do
      let results = case optMaxResults opts of
            Nothing -> allResults
            Just n -> take n allResults

      if optQuiet opts
        then if null results then exitFailure else exitSuccess
        else do
          let fmt | optCount opts = FormatCount
                  | optJSON opts = FormatJSON
                  | optFilesOnly opts = FormatFilesOnly
                  | optVerbose opts = FormatVerbose
                  | otherwise = FormatGrep
              hasContext = optContextBefore opts > 0 || optContextAfter opts > 0 || optContext opts > 0
              output = if hasContext && fmt == FormatGrep
                       then formatResultsGrouped outOpts fmt results
                       else formatResultsWithOpts outOpts fmt results
          putStr output
          if null results then exitFailure else exitSuccess

-- | Gather all files to search based on options
gatherFiles :: Options -> [FilePath] -> IO [FilePath]
gatherFiles opts targets = do
  -- Filter out stdin marker
  let fileTargets = filter (/= "-") targets
  allFiles <- concat <$> mapM (expandTarget opts) fileTargets
  let filtered = filterByExtension (optExtensions opts) allFiles
  pure $ filterExcludes opts $ filterIncludes opts filtered

-- | Filter files by include patterns (if any specified)
filterIncludes :: Options -> [FilePath] -> [FilePath]
filterIncludes opts files =
  case optInclude opts of
    [] -> files  -- No include patterns = include all
    patterns -> filter (matchesAnyGlob patterns . takeFileName) files

-- | Filter files by exclude patterns
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

-- | Check if any parent directory matches exclude patterns
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

-- | Check if a string matches any glob pattern
matchesAnyGlob :: [String] -> String -> Bool
matchesAnyGlob patterns str = any (`simpleGlobMatch` str) patterns

-- | Simple glob matching (supports * and ?)
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

-- | Get all tails of a list
tails :: [a] -> [[a]]
tails [] = [[]]
tails xs@(_:rest) = xs : tails rest

-- | Expand a target (file or directory) to a list of files
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 search recursively."
          pure []
    (_, True) -> pure [target]
    _ -> do
      hPutStrLn stderr $ "Warning: " ++ target ++ " not found"
      pure []

-- | List files recursively, filtering out excluded directories
listFilesRecursiveFiltered :: [String] -> FilePath -> IO [FilePath]
listFilesRecursiveFiltered excludeDirs dir = go dir `catch` (\(_ :: IOException) -> pure [])
  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 pure []
               else listFilesRecursiveFiltered excludeDirs fullPath
          else do
            absPath <- System.Directory.makeAbsolute fullPath
            return [absPath]
      return (concat paths)

-- | Filter files by extension
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