packages feed

cgrep-8.0.0: src/Search.hs

--
-- Copyright (c) 2013-2023 Nicola Bonelli <nicola@larthia.com>
--
-- This program is free software; you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation; either version 2 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program; if not, write to the Free Software
-- Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
--nc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
--

module Search (
      parallelSearch
    , isRegexp
) where

import Data.List ( isPrefixOf, isSuffixOf, partition, elemIndex, intersperse )
import Data.List.Split ( chunksOf )
import qualified Data.Map as M
import Data.Maybe ( fromJust, isJust, catMaybes, fromMaybe )
import Data.Function ( fix )
import qualified Data.Set as S

import Control.Exception as E ( catch, SomeException )
import Control.Concurrent ( forkIO, MVar, putMVar, forkOn, threadDelay )

import Control.Monad ( when, forM_, forever, unless, void, forM, replicateM_ )
import Control.Monad.Trans ( MonadIO(liftIO) )
import Control.Monad.Trans.Except ( runExceptT, throwE )
import Control.Monad.Trans.Reader
    ( ReaderT(runReaderT), ask, reader, ask, local )
import Control.Applicative
    ( Applicative(liftA2), Alternative((<|>)) )

import System.Environment ( lookupEnv )
import System.PosixCompat.Files as PC
    ( getFileStatus, getSymbolicLinkStatus, isDirectory, FileStatus )
import System.IO
    ( BufferMode(BlockBuffering),
      hSetBinaryMode,
      hSetBuffering,
      hPutStrLn,
      stderr,
      stdin,
      stdout,
      stderr,
      hPutStrLn )

import System.Process ( runProcess, waitForProcess )

import CGrep.Output
    ( putOutputElements,
      showFileName,
      Output(..),
      showFileName )
import CGrep.Common ( takeN, getTargetName, Text8, takeN )
import Options ( Options(..) )
import Config
    ( Config(Config, configFileTypes, configFileLine, configColorMatch,
             configColorFile, configColors, configPruneDirs),
      dumpPalette )
import Reader
    ( Env(Env, opt, conf),
      ReaderIO,
      Env(..) )

import qualified Data.ByteString as B
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Builder.Extra as B

import qualified Data.ByteString.Lazy.Char8 as LB
import qualified Data.ByteString.Char8 as C
import qualified Codec.Binary.UTF8.String as UC

import Data.Tuple.Extra ( )
import qualified Data.Bifunctor

import qualified Data.Vector as V hiding ((!))
import Data.Vector ((!))

import Data.IORef
    ( modifyIORef, modifyIORef', newIORef, readIORef, IORef, atomicModifyIORef, atomicModifyIORef' )
import Control.Concurrent.Chan.Unagi.Bounded
    ( newChan, readChan, writeChan )

import System.Posix.Directory.Traversals ( getDirectoryContents )
import System.Posix.FilePath ( RawFilePath, takeBaseName, (</>) )
import System.Posix.Directory.Foreign (dtDir)
import System.Directory (makeAbsolute, canonicalizePath)
import Data.Functor ( void, (<&>), ($>))
import RawFilePath.Directory (doesDirectoryExist)
import Control.Arrow ( Arrow((&&&)) )
import Control.Concurrent.Async (forConcurrently, forConcurrently_, mapConcurrently_, async, Async, wait, asyncOn)

import qualified CGrep.Strategy.BoyerMoore       as BoyerMoore
import qualified CGrep.Strategy.Levenshtein      as Levenshtein
import qualified CGrep.Strategy.Regex            as Regex
import qualified CGrep.Strategy.Tokenizer        as Tokenizer
import qualified CGrep.Strategy.Semantic         as Semantic
import Control.Monad.Catch ( SomeException, MonadCatch(catch) )
import Control.Monad.IO.Class ( MonadIO(liftIO) )

import CGrep.FileType ( FileType )
import CGrep.FileTypeMap
    ( fileTypeInfoLookup, fileTypeLookup, FileTypeInfo )

import Control.Monad.Loops ( whileM_ )
import Verbose (putMsgLnVerbose, putMsgLn)
import Control.Concurrent.MVar ( newMVar, takeMVar )
import Data.IORef.Extra (atomicWriteIORef')
import CGrep.FileKind ( FileKind )
import qualified Data.List.NonEmpty as NE (unzip)


withRecursiveContents :: Options
                      -> RawFilePath
                      -> [FileType]
                      -> [FileKind]
                      -> [RawFilePath]
                      -> S.Set RawFilePath
                      -> IORef Int
                      -> ([RawFilePath] -> IO ()) -> IO ()
withRecursiveContents opt@Options{..} dir fTypes fKinds pdirs visited walkers action = do
    xs <- getDirectoryContents dir
    let (dirs, files) = partition ((== dtDir) . fst) xs

    -- filter the list of files
    let files' = (dir </>) . snd <$> filter (\f -> fileFilter opt fTypes  fKinds (snd f) && (not skip_test || isNotTestFile (snd f))) files
    let dirs'  = (dir </>) . snd <$> dirs

    -- run IO action
    mapM_ action (chunksOf 8 files')

    -- process directories recursively...
    foreach <- readIORef walkers >>= \tot -> do
        if tot < 64 then pure (forConcurrently_ @[])
                    else pure forM_

    foreach dirs' $ \dirPath -> do
        unless (isPrunableDir dirPath pdirs) $
             -- this is a good directory, unless already visited...
             -- this is a good directory, unless already visited...
             -- this is a good directory, unless already visited...
             -- this is a good directory, unless already visited...

             -- this is a good directory, unless already visited...

             -- this is a good directory, unless already visited...

             -- this is a good directory, unless already visited...
             -- this is a good directory, unless already visited...

             -- this is a good directory, unless already visited...
            makeRawAbsolute dirPath >>= \cpath ->
                unless (cpath `S.member` visited) $ incrRef walkers *>
                    withRecursiveContents opt dirPath fTypes fKinds pdirs (S.insert cpath visited) walkers action

    decrRef walkers


parallelSearch :: [RawFilePath] -> [C.ByteString] -> [FileType] -> [FileKind] -> Bool -> ReaderIO ()
parallelSearch paths patterns fTypes fKinds isTermIn = do
    Env{..} <- ask

    let Config{..} = conf
        Options{..} = opt

    let multiplier = 4
        jobs' = fromMaybe 1 jobs
        totalJobs = jobs' * multiplier

    -- create channels ...
    fileCh <- liftIO $ newChan 65536

    -- recursively traverse the filesystem ...
    _ <- liftIO . forkOn 0 $ do
        walkers <- newIORef (0 :: Int)
        if recursive || follow
            then forM_ (if null paths then ["."] else paths) $ \p ->
                doesDirectoryExist p >>= \case
                    True -> incrRef walkers *>
                            withRecursiveContents opt p fTypes fKinds
                                (mkPrunableDirName <$> configPruneDirs <> (C.pack <$> prune_dir)) (S.singleton p) walkers (do
                                    writeChan (fst fileCh))
                    _     ->  writeChan (fst fileCh) [p]
            else forM_ (if null paths && not isTermIn
                    then [("", 0)]
                    else paths `zip` [0..]) (\(p, idx) -> writeChan (fst fileCh) [p])

        -- enqueue EOF messages...
        when (verbose > 0) $ putMsgLn @Text8 stderr "filesystem traversal completed!"
        replicateM_ totalJobs $ writeChan (fst fileCh) []

    -- launch the worker threads...
    matchingFiles <- liftIO $ newIORef S.empty

    let env = Env conf opt
        runSearch = getSearcher env

    workers <- forM ([0 .. totalJobs-1] :: [Int]) $ \idx -> do
        let processor = 1 + idx `div` multiplier
        liftIO . asyncOn processor $ void . runExceptT $ do
            asRef <- liftIO $ newIORef ([] :: [Async ()])
            forever $ do
                fs <- liftIO $ readChan (snd fileCh)
                liftIO $ E.catch (
                    case fs of
                        [] -> liftIO $ readIORef asRef >>= mapM_ wait
                        fs -> runReaderT (do
                            out <- catMaybes <$> forM fs (\f -> do
                                    out' <- take max_count <$> runSearch (fileTypeInfoLookup opt f) f patterns
                                    when (vim || editor) $
                                        liftIO $ mapM_ (modifyIORef matchingFiles . S.insert . (outFilePath &&& outLineNumb)) out'
                                    putOutputElements out')
                            unless (null out) $
                                liftIO $ async (do
                                    let !dump = LB.toStrict $ B.toLazyByteString (mconcat ((<> B.char8 '\n') <$> out))
                                    B.hPut stdout dump) >>= \a -> modifyIORef' asRef (a:)
                            ) env
                    ) (\e -> let msg = show (e :: SomeException) in
                            C.hPutStrLn stderr (showFileName conf opt (getTargetName (head fs)) <> ": error: " <> C.pack (takeN 120 msg)))
                when (null fs) $ do
                    when (verbose > 0) $ putMsgLn stderr $ "[" <> C.pack (show idx) <> "]@" <> C.pack (show processor) <>" searcher done!"
                    throwE ()

    -- wait workers to complete the job
    liftIO $ mapM_ wait workers

    -- run editor...
    when (vim || editor ) $ liftIO $ do
        editor' <- if vim
                    then return (Just "vim")
                    else lookupEnv "EDITOR"

        files <- S.toList <$> readIORef matchingFiles
        let filesUnpacked = Data.Bifunctor.first C.unpack <$> files

        let editFiles = (if fileline || configFileLine
                            then fmap (\(a,b) -> a <> ":" <> show b)
                            else fmap fst) filesUnpacked

        putStrLn $ "cgrep: open files " <> unwords editFiles <> "..."

        void $ runProcess (fromJust $ editor' <|> Just "vi")
                          editFiles
                          Nothing
                          Nothing
                          (Just stdin)
                          (Just stdout)
                          (Just stderr) >>= waitForProcess

getSearcher :: Env -> (Maybe (FileType, FileTypeInfo) -> RawFilePath -> [Text8] -> ReaderIO [Output])
getSearcher Env{..} = do
  if | (not . isRegexp) opt && not (hasTokenizerOpt opt) && not (semantic opt) && edit_dist opt -> Levenshtein.search
     | (not . isRegexp) opt && not (hasTokenizerOpt opt) && not (semantic opt)                  -> BoyerMoore.search
     | (not . isRegexp) opt && semantic opt                                                     -> Semantic.search
     | (not . isRegexp) opt                                                                     -> Tokenizer.search
     | isRegexp opt                                                                             -> Regex.search
     | otherwise                                                                                -> undefined


makeRawAbsolute :: RawFilePath -> IO RawFilePath
makeRawAbsolute p = makeAbsolute (C.unpack p) <&> C.pack
{-# INLINE makeRawAbsolute #-}

incrRef  :: IORef Int -> IO ()
incrRef ref = atomicModifyIORef' ref (\n -> (n+1, ()))
{-# INLINE incrRef #-}

decrRef :: IORef Int -> IO ()
decrRef ref = atomicModifyIORef' ref (\n -> (n-1, ()))
{-# INLINE decrRef #-}


fileFilter :: Options -> [FileType] -> [FileKind] -> RawFilePath -> Bool
fileFilter opt fTypes fKinds filename = fileFilterTypes typ && fileFilterKinds kin
    where (typ, kin) = NE.unzip $ fileTypeLookup opt filename
          fileFilterTypes = maybe False (liftA2 (||) (const $ null fTypes) (`elem` fTypes))
          fileFilterKinds = maybe False (liftA2 (||) (const $ null fKinds) (`elem` fKinds))


isNotTestFile :: RawFilePath -> Bool
isNotTestFile f =
    let fs = [("_test" `C.isSuffixOf`), ("-test" `C.isSuffixOf`), ("test-" `C.isPrefixOf`), ("test_" `C.isPrefixOf`), ("test" == )] :: [C.ByteString -> Bool]
        in not $ any ($ takeBaseName f) fs
{-# INLINE isNotTestFile #-}


isPrunableDir:: RawFilePath -> [RawFilePath] -> Bool
isPrunableDir dir = any (`C.isSuffixOf` pdir)
    where pdir = mkPrunableDirName dir
{-# INLINE isPrunableDir #-}


mkPrunableDirName :: RawFilePath -> RawFilePath
mkPrunableDirName xs | "/" `C.isSuffixOf` xs = xs
                     | otherwise = xs <> "/"
{-# INLINE mkPrunableDirName #-}


(.!.) :: V.Vector a -> Int -> a
v .!. i = v ! (i `mod` V.length v)
{-# INLINE  (.!.) #-}


hasFileType :: RawFilePath -> Options -> [FileType] -> Bool
hasFileType path opt xs = isJust $ fileTypeLookup opt path >>= (\(typ, _) -> typ `elemIndex` xs)
{-# INLINE hasFileType #-}


hasTokenizerOpt :: Options -> Bool
hasTokenizerOpt Options{..} =
  identifier ||
  nativeType ||
  keyword    ||
  number     ||
  string     ||
  operator


isRegexp :: Options -> Bool
isRegexp opt = regex_posix opt || regex_pcre opt
{-# INLINE isRegexp #-}