packages feed

hackage-server-0.4: Distribution/Server/Util/TextSearch.hs

module Distribution.Server.Util.TextSearch (
    TextSearch(..),
    constructTextIndex,
    searchText
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS -- TODO: Deal with UTF8
import Data.ByteString.Search
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Char
import Data.Maybe (catMaybes)
import Control.DeepSeq

import Distribution.Server.Framework.MemSize


-- Basic full text search. This works best when there are plenty of entries
-- and all of them are short. I'd use something like Hayoo here, but there's
-- no easy way to integrate it into the site.
--
-- At present this uses Bayer-Moore. Something with multiple search keys
-- might be more flexible. Or, even better, a Lucene-like engine.
data TextSearch = TextSearch {
    fullText  :: !ByteString,
    textIndex :: !(Map Int (String, String))
} deriving Show

instance NFData TextSearch

constructTextIndex :: [(String, String)] -> TextSearch
constructTextIndex strs = case go strs 0 of
    (bs, texts) -> TextSearch (BS.concat bs) (Map.fromList texts)
  where
    go :: [(String, String)] -> Int -> ([ByteString], [(Int, (String, String))])
    go [] _ = ([], [])
    go (pair@(_, text):xs) pos =
        let text' = BS.pack $ "\0" ++ stripText text
        in case go xs (BS.length text' + pos) of
            ~(bs, texts) -> (text':bs, (pos, pair):texts)

stripText :: String -> String
stripText = map toLower . filter (\c -> isSpace c || isAlphaNum c)

searchText :: TextSearch -> String -> [(String, String)]
searchText (TextSearch theText theIndex) str =
    Map.toList . Map.fromAscListWith const
  . catMaybes . map (\i -> getIndexEntry (fromIntegral i) theIndex)
  $ nonOverlappingIndices (BS.pack $ stripText str) theText

-- TODO: offset might be useful for determining whether the match was whole-word
-- or no
getIndexEntry :: Int -> Map Int a -> Maybe a
getIndexEntry index theIndex = case Map.splitLookup index theIndex of
    (_, Just entry, _) -> Just entry
    (beforeMap, _, afterMap) -> case (Map.null beforeMap, Map.null afterMap) of
        (True, True)  -> Nothing
        (True, False) -> Just $ snd $ Map.findMin afterMap
        (False, _) -> Just $ snd $ Map.findMax beforeMap

instance MemSize TextSearch where
    memSize (TextSearch a b) = memSize2 a b