fuzzily-0.2.1.0: src/Text/Fuzzily.hs
{-|
Fuzzy string search in Haskell.
Uses 'TextualMonoid' to be able to run on different types of strings.
-}
module Text.Fuzzily where
import Protolude (
Bool (True),
Char,
Down (Down),
Eq ((==)),
Int,
Maybe (..),
Monoid (mempty),
Num ((*), (+)),
Ord ((>)),
Semigroup ((<>)),
Show,
const,
identity,
isJust,
map,
mapMaybe,
not,
otherwise,
sortOn,
toLower,
(.),
)
import Data.Monoid.Textual qualified as T
{-|
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)
{-|
Run one-pass algorithm on the given search text.
Returns (rendered, score) if the whole pattern was consumed.
-}
matchOnce
:: (T.TextualMonoid text)
=> (Char -> Char)
-- ^ normalisation function
-> (text, text)
-- ^ (pre, post)
-> text
-- ^ pattern
-> text
-- ^ search text
-> Maybe (text, Int)
-- ^ (rendered, score)
matchOnce norm (pre, post) pat txt = do
let
(tot, _, res, restPat) =
T.foldl_'
( \(tot_, cur, acc, p) c -> case T.splitCharacterPrefix p of
Nothing -> (tot_, 0, acc <> T.singleton c, p)
Just (x, xs)
| norm x == norm c ->
let cur' = cur * 2 + 1
in ( tot_ + cur'
, cur'
, acc <> pre <> T.singleton c <> post
, xs
)
| otherwise -> (tot_, 0, acc <> T.singleton c, p)
)
(0, 0, mempty, pat)
txt
if null restPat then Just (res, tot) else Nothing
{-|
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)
=> CaseSensitivity
-- ^ Handle or ignore case of search text
-> (text, text)
-- ^ Text to add before and after each match
-> (value -> text)
-- ^ Function to extract the text from the container
-> text
-- ^ Pattern
-> value
-- ^ Value containing the text to search in
-> Maybe (Fuzzy value text)
-- ^ Original value, rendered string, and score
match caseSen preAndPost extract pat value = do
let
norm = if caseSen == HandleCase then identity else toLower
searchText = extract value
-- iterate over every suffix while carrying the already-passed prefix
go pref txt best =
case matchOnce norm preAndPost pat txt of
Just (rendSub, sc) ->
let cand = Fuzzy value (pref <> rendSub) sc
best' = chooseBetter cand best
in step best'
Nothing -> step best
where
step b = case T.splitCharacterPrefix txt of
Nothing -> b
Just (c, rest') -> go (pref <> T.singleton c) rest' b
chooseBetter n Nothing = Just n
chooseBetter n (Just o) = if score n > score o then Just n else Just o
go mempty searchText Nothing
{-|
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)
=> CaseSensitivity
-- ^ Handle or ignore case of search text
-> (text, text)
-- ^ Text to add before and after each match
-> (value -> text)
-- ^ Function to extract the text from the container
-> text
-- ^ Pattern
-> [value]
-- ^ List of values containing the text to search in
-> [Fuzzy value text]
-- ^ List of results, sorted, highest score first
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)
=> text
-- ^ Pattern to look for.
-> [text]
-- ^ List of texts to check.
-> [text]
-- ^ The ones that match.
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)