ghcide-1.8.0.0: src/Text/Fuzzy/Parallel.hs
-- | Parallel versions of 'filter' and 'simpleFilter'
module Text.Fuzzy.Parallel
( filter,
simpleFilter,
match,
Scored(..)
) where
import Control.Parallel.Strategies (evalList, parList, rseq, using)
import Data.Bits ((.|.))
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.Array as TA
import qualified Data.Text.Internal as T
import Prelude hiding (filter)
data Scored a = Scored {score :: !Int, original:: !a}
deriving (Functor, Show)
-- | Returns the rendered output and the
-- matching score for a pattern and a text.
-- Two examples are given below:
--
-- >>> match "fnt" "infinite"
-- Just 3
--
-- >>> match "hsk" "Haskell"
-- Just 5
--
{-# INLINABLE match #-}
match :: T.Text -- ^ Pattern in lowercase except for first character
-> T.Text -- ^ The text to search in.
-> Maybe Int -- ^ The score
match (T.Text pArr pOff pLen) (T.Text sArr sOff sLen) = go 0 1 pOff sOff
where
pTotal = pOff + pLen
sDelta = sOff + sLen - pTotal
go !totalScore !currScore !currPOff !currSOff
-- If pattern has been matched in full
| currPOff >= pTotal
= Just totalScore
-- If there is not enough left to match the rest of the pattern, equivalent to
-- (sOff + sLen - currSOff) < (pOff + pLen - currPOff)
| currSOff > currPOff + sDelta
= Nothing
-- This is slightly broken for non-ASCII:
-- 1. If code units, consisting a single pattern code point, are found as parts
-- of different code points, it counts as a match. Unless you use a ton of emojis
-- as identifiers, such false positives should not be be a big deal,
-- and anyways HLS does not currently support such use cases, because it uses
-- code point and UTF-16 code unit positions interchangeably.
-- 2. Case conversions is not applied to non-ASCII code points, because one has
-- to call T.toLower (not T.map toLower), reallocating the string in full, which
-- is too much of performance penalty for fuzzy search. Again, anyway HLS does not
-- attempt to do justice to Unicode: proper Unicode text matching requires
-- `unicode-transforms` and friends.
-- Altogether we sacrifice correctness for the sake of performance, which
-- is a right trade-off for fuzzy search.
| pByte <- TA.unsafeIndex pArr currPOff
, sByte <- TA.unsafeIndex sArr currSOff
-- First byte (currPOff == pOff) should match exactly, otherwise - up to case.
, pByte == sByte || (currPOff /= pOff && pByte == toLowerAscii sByte)
= let curr = currScore * 2 + 1 in
go (totalScore + curr) curr (currPOff + 1) (currSOff + 1)
| otherwise
= go totalScore 0 currPOff (currSOff + 1)
toLowerAscii w = if (w - 65) < 26 then w .|. 0x20 else w
-- | The function to filter a list of values by fuzzy search on the text extracted from them.
filter :: Int -- ^ Chunk size. 1000 works well.
-> Int -- ^ Max. number of results wanted
-> T.Text -- ^ Pattern.
-> [t] -- ^ The list of values containing the text to search in.
-> (t -> T.Text) -- ^ The function to extract the text from the container.
-> [Scored t] -- ^ The list of results, sorted, highest score first.
filter chunkSize maxRes pattern ts extract = partialSortByAscScore maxRes perfectScore (concat vss)
where
-- Preserve case for the first character, make all others lowercase
pattern' = case T.uncons pattern of
Just (c, rest) -> T.cons c (T.toLower rest)
_ -> pattern
vss = map (mapMaybe (\t -> flip Scored t <$> match pattern' (extract t))) (chunkList chunkSize ts)
`using` parList (evalList rseq)
perfectScore = fromMaybe (error $ T.unpack pattern) $ match pattern' pattern'
-- | Return all elements of the list that have a fuzzy
-- match against the pattern. Runs with default settings where
-- nothing is added around the matches, as case insensitive.
--
-- >>> simpleFilter "vm" ["vim", "emacs", "virtual machine"]
-- ["vim","virtual machine"]
{-# INLINABLE simpleFilter #-}
simpleFilter :: Int -- ^ Chunk size. 1000 works well.
-> Int -- ^ Max. number of results wanted
-> T.Text -- ^ Pattern to look for.
-> [T.Text] -- ^ List of texts to check.
-> [Scored T.Text] -- ^ The ones that match.
simpleFilter chunk maxRes pattern xs =
filter chunk maxRes pattern xs id
--------------------------------------------------------------------------------
chunkList :: Int -> [a] -> [[a]]
chunkList chunkSize = go
where
go [] = []
go xs = ys : go zs
where
(ys, zs) = splitAt chunkSize xs
-- | A stable partial sort ascending by score. O(N) best case, O(wanted*N) worst case
partialSortByAscScore
:: Int -- ^ Number of items needed
-> Int -- ^ Value of a perfect score
-> [Scored t]
-> [Scored t]
partialSortByAscScore wantedCount perfectScore orig = loop orig (SortState minBound perfectScore 0) [] where
loop [] st@SortState{..} acc
| foundCount == wantedCount = reverse acc
| otherwise = if bestScoreSeen < scoreWanted
then loop orig st{scoreWanted = bestScoreSeen, bestScoreSeen = minBound} acc
else reverse acc
loop (x : xs) st@SortState{..} acc
| foundCount == wantedCount = reverse acc
| score x == scoreWanted
= loop xs st{foundCount = foundCount+1} (x:acc)
| score x < scoreWanted && score x > bestScoreSeen
= loop xs st{bestScoreSeen = score x} acc
| otherwise
= loop xs st acc
data SortState a = SortState
{ bestScoreSeen :: !Int
, scoreWanted :: !Int
, foundCount :: !Int
}
deriving Show