cgrep-9.0.0: src/CGrep/Search.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}
--
-- Copyright (c) 2013-2025 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.
--
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE QuasiQuotes #-}
module CGrep.Search (
startSearch,
isRegexp,
)
where
import CGrep.Common (getTargetContents, getTargetName, takeN)
import CGrep.FileKind (FileKind)
import CGrep.FileType (FileType)
import CGrep.FileTypeMap (
FileTypeInfo,
)
import CGrep.FileTypeMapTH (
fileTypeInfoLookup,
fileTypeLookup,
)
import CGrep.Match (
Match (..),
prettyFileName,
putMatches,
)
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.Semantic as Semantic
import qualified CGrep.Strategy.Tokenizer as Tokenizer
import Config (
Config (
Config,
configColorFile,
configColorMatch,
configColors,
configFileLine,
configFileTypes,
configPruneDirs
),
)
import Control.Arrow (Arrow ((&&&)))
import Control.Concurrent (forkIO)
import Control.Concurrent.Async (Async, async, asyncOn, forConcurrently_, wait)
import Control.Exception as E (SomeException, catch)
import Control.Monad (forM, forM_, forever, replicateM_, unless, void, when)
import Control.Monad.Extra (partitionM)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Trans.Except (runExceptT, throwE)
import Control.Monad.Trans.Reader (
ReaderT (runReaderT),
ask,
)
import qualified Data.Bifunctor
import Data.Functor as F (unzip)
import qualified Data.HashSet as S
import Data.IORef (
IORef,
modifyIORef,
modifyIORef',
newIORef,
readIORef,
)
import Data.List.Split (chunksOf)
import Data.Maybe (catMaybes, fromJust, fromMaybe)
import Data.Tuple.Extra ()
import GHC.Conc (getNumCapabilities)
import Options (Options (..))
import PutMessage (putMessageLn)
import Reader (
Env (..),
ReaderIO,
)
import System.Directory.OsPath (doesDirectoryExist, getDirectoryContents, makeAbsolute)
import System.Environment (lookupEnv)
import System.IO (
stderr,
stdin,
stdout,
)
import Control.Applicative ((<|>))
import Control.Concurrent (MVar)
import Control.Concurrent.Classy (newBoundedChan, readBoundedChan)
import Control.Concurrent.Classy.BoundedChan (writeBoundedChan)
import Control.Concurrent.Extra (newMVar)
import Data.Atomics.Counter (incrCounter, newCounter, readCounter)
import Data.List.Extra (notNull)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TLB
import qualified Data.Text.Lazy.IO as LTIO
import qualified OsPath as OS
import System.Clock
import System.OsPath (OsPath, OsString, osp, takeBaseName, unsafeEncodeUtf, (</>))
import System.OsString as OS (isPrefixOf, isSuffixOf)
import System.Process (runProcess, waitForProcess)
data SearcherKind
= Levenshtein
| BoyerMoore
| Semantic
| Tokenizer
| Regex
class Searcher (s :: SearcherKind) where
run :: (MVar () -> Maybe (FileType, FileTypeInfo) -> OsPath -> T.Text -> [T.Text] -> Bool -> ReaderIO [Match])
instance Searcher 'Levenshtein where
run = Levenshtein.search
instance Searcher 'BoyerMoore where
run = BoyerMoore.search
instance Searcher 'Semantic where
run = Semantic.search
instance Searcher 'Tokenizer where
run = Tokenizer.search
instance Searcher 'Regex where
run = Regex.search
getSearcher :: Env -> (MVar () -> Maybe (FileType, FileTypeInfo) -> OsPath -> T.Text -> [T.Text] -> Bool -> ReaderIO [Match])
getSearcher Env{..} = do
if
| (not . isRegexp) opt && not (hasTokenizerOpt opt) && not (semantic opt) && edit_dist opt -> run @Levenshtein
| (not . isRegexp) opt && not (hasTokenizerOpt opt) && not (semantic opt) -> run @BoyerMoore
| (not . isRegexp) opt && semantic opt -> run @Semantic
| (not . isRegexp) opt -> run @Tokenizer
| isRegexp opt -> run @Regex
| otherwise -> undefined
data RecursiveContext = RecursiveContext
{ rcFileTypes :: [FileType]
, rcFileKinds :: [FileKind]
, rcPrunableDirs :: [OsPath]
, rcParallel :: Bool
}
withRecursiveContents ::
RecursiveContext ->
Options ->
OsPath ->
S.HashSet OsPath ->
([OsPath] -> IO ()) ->
IO ()
withRecursiveContents ctx@RecursiveContext{..} opt@Options{..} dir visited action = do
xs <- getDirectoryContents dir
(dirs, files) <- partitionM (\p -> doesDirectoryExist (dir </> p)) xs
-- filter the list of files
let files' :: [OsPath] = (dir </>) <$> filter (\f -> fileFilter opt rcFileTypes rcFileKinds f) files
let dirs' :: [OsPath] = (dir </>) <$> filter (\d -> not $ isDot d) dirs
-- run IO action
mapM_ action (chunksOf 16 files')
foreach <-
if rcParallel
then pure (forConcurrently_ @[])
else pure forM_
foreach dirs' $ \dirPath -> do
unless (isPrunableDir dirPath rcPrunableDirs) $
-- this is a good directory, unless already visited...
makeAbsolute dirPath >>= \cpath -> do
unless (cpath `S.member` visited) $ withRecursiveContents ctx opt dirPath (S.insert cpath visited) action
startSearch :: [OsPath] -> [T.Text] -> [FileType] -> [FileKind] -> Bool -> ReaderIO ()
startSearch paths patterns fTypes fKinds isTermIn = do
Env{..} <- ask
startTime <- liftIO $ getTime Monotonic
totalFiles <- liftIO $ newCounter 0
totalMatchingFiles <- liftIO $ newCounter 0
totalMatches <- liftIO $ newCounter 0
lock <- liftIO $ newMVar ()
let Config{..} = conf
Options{..} = opt
numCaps <- liftIO getNumCapabilities
let multiplier = 4
let totalJobs = multiplier * fromMaybe (max (numCaps - 1) 1) jobs
-- create channels ...
fileCh <- liftIO $ newBoundedChan 65536
-- recursively traverse the filesystem ...
_ <- liftIO . forkIO $ do
if recursive || follow
then forM_ (if null paths then [unsafeEncodeUtf "."] else paths) $ \path ->
doesDirectoryExist path >>= \case
True ->
withRecursiveContents
RecursiveContext
{ rcFileTypes = fTypes
, rcFileKinds = fKinds
, rcPrunableDirs = (mkPrunableDirName <$> (unsafeEncodeUtf <$> configPruneDirs) <> (unsafeEncodeUtf <$> prune_dir))
, rcParallel = True
}
opt
path
(S.singleton path)
( \paths' -> do
when (notNull paths') $
-- putMessageLn @T.Text lock stderr $ "Discovered empty directory!"
incrCounter (length paths') totalFiles >> writeBoundedChan fileCh paths'
)
_ -> incrCounter 1 totalFiles >> writeBoundedChan fileCh [path]
else
forM_
( if null paths && not isTermIn
then [(unsafeEncodeUtf "", 0 :: Int)]
else paths `zip` [0 ..]
)
(\(p, _idx) -> incrCounter 1 totalFiles >> writeBoundedChan fileCh [p])
when verbose $
putMessageLn @T.Text lock stderr $
"File discovery completed..."
replicateM_ totalJobs $ writeBoundedChan fileCh []
-- launch the worker threads...
matchingFiles :: IORef (S.HashSet (OsPath, Int)) <- 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
paths' <- liftIO $ readBoundedChan fileCh
case paths' of
[] -> liftIO $ do
-- when verbose $
-- putMessageLn @T.Text lock stderr $ "worker_" <> T.pack (show idx) <> "@" <> T.pack (show processor) <> " received termination signal!"
readIORef asRef >>= mapM_ wait
fs' -> do
out <-
liftIO $
runReaderT
( catMaybes
<$> forM
fs'
( \f ->
liftIO $
E.catch
( runReaderT
( do
text <- liftIO $ getTargetContents f
!matches <- take max_count <$> runSearch lock (fileTypeInfoLookup opt f) (getTargetName f) text patterns strict
when (vim || editor) $
liftIO $
mapM_ (modifyIORef matchingFiles . S.insert . (mFilePath &&& mLineNumb)) matches
-- putMessage @T.Text lock stderr $ "."
_ <- liftIO $ incrCounter (length matches) totalMatches
when (notNull matches) $ void $ liftIO $ incrCounter 1 totalMatchingFiles
putMatches matches
)
env
)
( \e -> do
let msg = show (e :: SomeException)
putMessageLn lock stderr (prettyFileName conf opt (getTargetName f) <> ": error: " <> TL.pack (takeN 120 msg))
return Nothing
)
)
)
env
unless (null out) $
liftIO $
async
( do
let !dump = TLB.toLazyText (mconcat ((<> TLB.singleton '\n') <$> out))
LTIO.hPutStr stdout dump
)
>>= \a -> modifyIORef' asRef (a :)
when (null paths') $ do
when verbose $
putMessageLn lock stderr $
"worker_" <> T.pack (show idx) <> "@" <> T.pack (show processor) <> " terminated!"
throwE ()
-- wait workers to complete the job
liftIO $ do
mapM_ wait workers
when verbose $ putMessageLn @T.Text lock stderr $ "All workers terminated!"
endTime <- liftIO $ getTime Monotonic
when stats $ liftIO $ do
let diffTime = fromIntegral (toNanoSecs (diffTimeSpec endTime startTime)) / 1_000_000_000 :: Double
total <- (readCounter totalFiles)
matches <- (readCounter totalMatches)
mfiles <- (readCounter totalMatchingFiles)
putMessageLn @T.Text lock stderr $ "\nTotal matches " <> T.pack (show matches)
putMessageLn @T.Text lock stderr $ "Matching files " <> T.pack (show mfiles)
putMessageLn @T.Text lock stderr $ "Total files searched " <> T.pack (show total)
putMessageLn @T.Text lock stderr $ "Elapsed time " <> T.pack (show diffTime) <> " seconds"
-- 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 (T.unpack . OS.toText) <$> files
let editFiles =
if fileline || configFileLine
then fmap (\(a, b) -> a <> ":" <> show b) filesUnpacked
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
fileFilter :: Options -> [FileType] -> [FileKind] -> OsPath -> Bool
fileFilter opt fTypes fKinds filename = (fileFilterTypes typ) && (fileFilterKinds kin)
where
(typ, kin) = F.unzip $ fileTypeLookup opt filename
fileFilterTypes = maybe False (liftA2 (||) (const $ null fTypes) (`elem` fTypes))
fileFilterKinds = maybe False (liftA2 (||) (const $ null fKinds) (`elem` fKinds))
isNotTestFile :: OsPath -> Bool
isNotTestFile f =
let fs =
[ ([osp|_test|] `OS.isSuffixOf`)
, ([osp|-test|] `OS.isSuffixOf`)
, ([osp|test-|] `OS.isPrefixOf`)
, ([osp|test_|] `OS.isPrefixOf`)
, ([osp|test|] ==)
] ::
[OsString -> Bool]
basename = takeBaseName f
in not $ any ($ basename) fs
{-# INLINE isNotTestFile #-}
isDot :: OsPath -> Bool
isDot p = p == [osp|.|] || p == [osp|..|]
{-# INLINE isDot #-}
isPrunableDir :: OsPath -> [OsPath] -> Bool
isPrunableDir dir = any (`OS.isSuffixOf` pdir)
where
pdir = mkPrunableDirName dir
{-# INLINE isPrunableDir #-}
mkPrunableDirName :: OsPath -> OsPath
mkPrunableDirName xs
| unsafeEncodeUtf "/" `OS.isSuffixOf` xs = xs
| otherwise = xs <> unsafeEncodeUtf "/"
{-# INLINE mkPrunableDirName #-}
-- (.!.) :: V.Vector a -> Int -> a
-- v .!. i = v ! (i `mod` V.length v)
-- {-# INLINE (.!.) #-}
-- hasFileType :: OsPath -> 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
#ifdef ENABLE_PCRE
isRegexp :: Options -> Bool
isRegexp opt = regex_posix opt || regex_pcre opt
#else
isRegexp :: Options -> Bool
isRegexp opt = regex_posix opt
#endif
{-# INLINE isRegexp #-}