fuzzily-0.2.2.0: src/Text/Fuzzily.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE MultiWayIf #-}
{-|
Fuzzy string search in Haskell.
Uses 'TextualMonoid' to be able to run on different types of strings.
-}
module Text.Fuzzily where
import Protolude (
Bool (False, True),
Char,
Down (Down),
Eq ((/=), (==)),
Int,
Maybe (..),
Monad ((>>=)),
Monoid (mconcat, mempty),
Num ((*), (+), (-)),
Ord ((<), (>=)),
Ordering (GT),
Show,
Text,
chr,
const,
forM_,
identity,
isJust,
map,
mapMaybe,
not,
ord,
otherwise,
pure,
sortOn,
toLower,
($),
(.),
)
import Data.Array.Base (unsafeAt, unsafeRead, unsafeWrite)
import Data.Array.ST (newArray, runSTUArray)
import Data.Array.Unboxed (UArray, listArray)
import Data.Char (isAsciiUpper)
import Data.Monoid.Textual qualified as T
import Data.Text qualified as Text
{-|
Included in the return type of `match` and `filter`.
Contains the original value given, the rendered string
and the matching score.
-}
data Fuzzy val prettyText = Fuzzy
{ original :: val
, rendered :: prettyText
, score :: Int
}
deriving (Show, Eq)
data CaseSensitivity
= IgnoreCase
| HandleCase
deriving (Show, Eq)
null :: (T.TextualMonoid s) => s -> Bool
null =
not . T.any (const True)
-- | Like 'toLower', but faster for ASCII characters
lowerChar :: Char -> Char
lowerChar c
| isAsciiUpper c = chr (ord c + 32)
| c < '\x80' = c
| otherwise = toLower c
{-|
Score of matching the given number of characters,
all consecutive (the maximum possible score).
-}
contiguousScore :: Int -> Int
contiguousScore len =
go len 0 0
where
go :: Int -> Int -> Int -> Int
go 0 !tot !_ = tot
go k !tot !cur = let cur' = cur * 2 + 1 in go (k - 1) (tot + cur') cur'
-- | Whether the pattern is a (not necessarily contiguous) subsequence
isSubsequenceOf :: Text -> Text -> Bool
isSubsequenceOf pat txt =
case Text.uncons pat of
Nothing -> True
Just (p, ps) -> case Text.uncons (Text.dropWhile (/= p) txt) of
Nothing -> False
Just (_, rest) -> isSubsequenceOf ps rest
{-|
Find the start of the greedy match with the highest score
(the earliest one on ties) and return its score
and the positions of the matched characters.
The pattern must be non-empty and a subsequence of the text.
Only starts at occurrences of the first pattern character are tried,
as starting anywhere else yields the same result as starting
at the next occurrence.
Once a start fails, all later ones fail too.
-}
bestGreedyMatch :: Text -> Text -> Maybe (Int, [Int])
bestGreedyMatch pat txt =
-- Building the table only pays off for longer texts
if Text.compareLength txt 128 == GT
then bestGreedyMatchTable pat txt
else bestGreedyMatchScan pat txt
{-|
Implementation of 'bestGreedyMatch' that scans
the text from each start position.
Fast for short texts, but O(n²) in the worst case.
-}
bestGreedyMatchScan :: Text -> Text -> Maybe (Int, [Int])
bestGreedyMatchScan pat txt = do
(p, ps) <- Text.uncons pat
let
-- Score of greedily matching the pattern's tail,
-- given the score and run value after matching its head
scoreFrom :: Int -> Int -> Text -> Text -> Maybe Int
scoreFrom !tot !cur pat' txt' = case Text.uncons pat' of
Nothing -> Just tot
Just (x, xs) -> case Text.uncons txt' of
Nothing -> Nothing
Just (c, cs)
| x == c -> let cur' = cur * 2 + 1 in scoreFrom (tot + cur') cur' xs cs
| otherwise -> scoreFrom tot 0 pat' cs
positionsFrom :: Int -> Text -> Text -> [Int]
positionsFrom !idx pat' txt' = case (Text.uncons pat', Text.uncons txt') of
(Just (x, xs), Just (c, cs))
| x == c -> idx : positionsFrom (idx + 1) xs cs
| otherwise -> positionsFrom (idx + 1) pat' cs
_ -> []
findBest :: Int -> Text -> Maybe (Int, Int) -> Maybe (Int, Int)
findBest !idx txt' best = case Text.uncons txt' of
Nothing -> best
Just (c, cs)
| c /= p -> findBest (idx + 1) cs best
| otherwise -> case scoreFrom 1 1 ps cs of
Nothing -> best
Just sc ->
findBest (idx + 1) cs $ case best of
Just (_, bestSc) | bestSc >= sc -> best
_ -> Just (idx, sc)
(start, sc) <- findBest 0 txt Nothing
pure (sc, positionsFrom start pat (Text.drop start txt))
{-|
Implementation of 'bestGreedyMatch' that uses a table
with the next occurrence of each pattern character
at or after each position, so that greedily matching from a start
takes one lookup per pattern character instead of a scan of the text.
O(n * m) for a text of length n and a pattern of length m.
-}
bestGreedyMatchTable :: Text -> Text -> Maybe (Int, [Int])
bestGreedyMatchTable pat txt = do
let
patLen = Text.length pat
txtLen = Text.length txt
patArr :: UArray Int Char
patArr = listArray (0, patLen - 1) (Text.unpack pat)
txtArr :: UArray Int Char
txtArr = listArray (0, txtLen - 1) (Text.unpack txt)
-- next ! (j * (txtLen + 1) + i): Position of the next occurrence of
-- the j-th pattern character at or after position i (txtLen if none)
next :: UArray Int Int
next = runSTUArray $ do
table <- newArray (0, patLen * (txtLen + 1) - 1) txtLen
forM_ [txtLen - 1, txtLen - 2 .. 0] $ \i ->
forM_ [0 .. patLen - 1] $ \j -> do
let row = j * (txtLen + 1)
if unsafeAt patArr j == unsafeAt txtArr i
then unsafeWrite table (row + i) i
else unsafeRead table (row + i + 1) >>= unsafeWrite table (row + i)
pure table
nextOf j i = unsafeAt next (j * (txtLen + 1) + i)
-- Score of the greedy match starting at the given position
-- (which must contain the first pattern character)
scoreFrom :: Int -> Maybe Int
scoreFrom start = go 1 start 1 1
where
go :: Int -> Int -> Int -> Int -> Maybe Int
go !j !prev !tot !cur
| j == patLen = Just tot
| otherwise =
let pos = nextOf j (prev + 1)
in if pos == txtLen
then Nothing
else
let cur' = if pos == prev + 1 then cur * 2 + 1 else 1
in go (j + 1) pos (tot + cur') cur'
positionsFrom :: Int -> [Int]
positionsFrom start =
start : go 1 start
where
go j prev
| j == patLen = []
| otherwise = let pos = nextOf j (prev + 1) in pos : go (j + 1) pos
findBest :: Int -> Maybe (Int, Int) -> Maybe (Int, Int)
findBest !start best
| start == txtLen = best
| otherwise = case scoreFrom start of
Nothing -> best
Just sc ->
findBest (nextOf 0 (start + 1)) $ case best of
Just (_, bestSc) | bestSc >= sc -> best
_ -> Just (start, sc)
(start, sc) <- findBest (nextOf 0 0) Nothing
pure (sc, positionsFrom start)
{-|
Render the text, wrapping the characters
at the given (ascending) positions in pre and post.
-}
renderAt :: (T.TextualMonoid text) => (text, text) -> Text -> [Int] -> text
renderAt (pre, post) txt0 positions =
-- Concatenate all chunks at once to avoid copying the rest repeatedly
mconcat (go 0 txt0 positions)
where
go _ txt [] = [T.fromText txt]
go offset txt (pos : rest) =
let
(gap, fromPos) = Text.splitAt (pos - offset) txt
(char, after) = Text.splitAt 1 fromPos
in
T.fromText gap
: pre
: T.fromText char
: post
: go (pos + 1) after rest
{-|
Returns the rendered output and the
matching score for a pattern and a text.
Two examples are given below:
>>> match HandleCase ("", "") identity "fnt" "infinite"
Just (Fuzzy
{ original = "infinite"
, rendered = "infinite"
, score = 3
})
>>> match IgnoreCase ("<", ">") fst "hsk" ("Haskell", 1995)
Just (Fuzzy
{ original = ("Haskell", 1995)
, rendered = "<h>a<s><k>ell"
, score = 5
})
-}
{-# INLINEABLE match #-}
match ::
(T.TextualMonoid text) =>
-- | Handle or ignore case of search text
CaseSensitivity ->
-- | Text to add before and after each match
(text, text) ->
-- | Function to extract the text from the container
(value -> text) ->
-- | Pattern
text ->
-- | Value containing the text to search in
value ->
-- | Original value, rendered string, and score
Maybe (Fuzzy value text)
match caseSen preAndPost extract pat value = do
let
-- Non-character factors are ignored
txt = T.toText (const mempty) (extract value)
norm = if caseSen == HandleCase then identity else Text.map lowerChar
-- Mapping single characters keeps the positions aligned with `txt`
txtNorm = norm txt
patNorm = norm (T.toText (const mempty) pat)
patLen = Text.length patNorm
(beforeSub, fromSub) = Text.breakOn patNorm txtNorm
(sc, positions) <-
if
| Text.null patNorm -> Just (0, [])
-- A contiguous match has the highest possible score
-- and `breakOn` finds the earliest one
| not (Text.null fromSub) ->
let start = Text.length beforeSub
in Just (contiguousScore patLen, [start .. start + patLen - 1])
| not (isSubsequenceOf patNorm txtNorm) -> Nothing
| otherwise -> bestGreedyMatch patNorm txtNorm
Just
Fuzzy
{ original = value
, rendered = renderAt preAndPost txt positions
, score = sc
}
{-|
The function to filter a list of values
by fuzzy search on the text extracted from them.
>>> langs = [("Standard ML", 1990), ("OCaml", 1996), ("Scala", 2003)]
>>> filter "ML" langs ("<", ">") fst IgnoreCase
[ Fuzzy
{ original = ("Standard ML", 1990)
, rendered = "standard <m><l>"
, score = 4}
, Fuzzy
{ original = ("OCaml", 1996)
, rendered = "oca<m><l>"
, score = 4
}
]
-}
{-# INLINEABLE filter #-}
filter ::
(T.TextualMonoid text) =>
-- | Handle or ignore case of search text
CaseSensitivity ->
-- | Text to add before and after each match
(text, text) ->
-- | Function to extract the text from the container
(value -> text) ->
-- | Pattern
text ->
-- | List of values containing the text to search in
[value] ->
-- | List of results, sorted, highest score first
[Fuzzy value text]
filter caseSen (pre, post) extractFunc textPattern texts =
sortOn
(Down . score)
( mapMaybe
(match caseSen (pre, post) extractFunc textPattern)
texts
)
{-|
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"]
-}
{-# INLINEABLE simpleFilter #-}
simpleFilter ::
(T.TextualMonoid text) =>
-- | Pattern to look for.
text ->
-- | List of texts to check.
[text] ->
-- | The ones that match.
[text]
simpleFilter textPattern xs =
map
original
(filter IgnoreCase (mempty, mempty) identity textPattern xs)
{-|
Returns false if the pattern and the text do not match at all.
Returns true otherwise.
>>> test "brd" "bread"
True
-}
test :: (T.TextualMonoid text) => text -> text -> Bool
test textPattern text =
isJust (match IgnoreCase (mempty, mempty) identity textPattern text)