misfortune-0.1.2: src/Data/Fortune/FortuneFile.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE RecordWildCards #-}
module Data.Fortune.FortuneFile
( FortuneFile
, fortuneFilePath
, fortuneIndexPath
, openFortuneFile
, closeFortuneFile
, getIndex
, rebuildIndex
, getFortune
, getFortunes
, getNumFortunes
, appendFortune
) where
import Control.Concurrent
import Control.Exception
import Control.Monad
import qualified Data.ByteString as BS
import qualified Data.ByteString.UTF8 as U
import Data.Fortune.Index
import Data.Fortune.Stats
import Data.IORef
import Data.Semigroup
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.Encoding.Error as T
import qualified Data.Text.IO as T
import System.Directory
import System.FilePath
import System.IO
-- |A handle to an open fortune database.
data FortuneFile = FortuneFile
{ fortunePath :: !FilePath
, fortuneDelim :: !Char
, fortuneWritable :: !Bool
, fortuneFile :: !(MVar (Maybe Handle))
, fortuneIndex :: !(MVar (Maybe Index))
}
-- |Get the path of the text part of an open fortune database.
fortuneFilePath :: FortuneFile -> FilePath
fortuneFilePath = fortunePath
-- |Get the path of the index part of an open fortune database.
fortuneIndexPath :: FortuneFile -> FilePath
fortuneIndexPath f = fortunePath f <.> "ix"
-- |@openFortuneFile path delim writeMode@: Open a fortune file at @path@,
-- using @delim@ as the character between strings, allowing writing if
-- @writeMode@ is set. If no file exists at the specified path, an error
-- will be thrown or the file will be created, depending on @writeMode@.
openFortuneFile :: Char -> Bool -> FilePath -> IO FortuneFile
openFortuneFile fortuneDelim fortuneWritable fortunePath = do
exists <- doesFileExist fortunePath
when (not (exists || fortuneWritable))
(fail ("openFortuneFile: file does not exist: " ++ show fortunePath))
fortuneFile <- newMVar Nothing
fortuneIndex <- newMVar Nothing
return FortuneFile{..}
-- |Close a fortune file. Subsequent accesses will fail.
closeFortuneFile :: FortuneFile -> IO ()
closeFortuneFile f = do
maybe (return ()) hClose =<< takeMVar (fortuneFile f)
putMVar (fortuneFile f) (error "Fortune file is closed")
maybe (return ()) closeIndex =<< takeMVar (fortuneIndex f)
putMVar (fortuneIndex f) (error "Fortune file is closed")
withFortuneFile f action = modifyMVar (fortuneFile f) $ \mbFile ->
case mbFile of
Nothing -> do
file <- openFile (fortunePath f) (if fortuneWritable f then ReadWriteMode else ReadMode)
res <- action file
return (Just file, res)
Just file -> do
res <- action file
return (Just file, res)
withIndex f action =
modifyMVar (fortuneIndex f) $ \mbIx ->
case mbIx of
Nothing -> do
let path = fortuneIndexPath f
writeMode = fortuneWritable f
-- if read-only, create an in-memory index if the real one exists but can't be opened
-- (Don't do that for read-write mode, because the writes would silently be dropped)
-- If building the in-memory one fails, re-throw the original exception; it's more
-- informative because it tells why the index couldn't be opened in the first place.
onExc e = if writeMode
then throwIO (e :: SomeException)
else handle (rethrow e) $ do
ix <- createVirtualIndex
withFortuneFile f (\file -> rebuildIndex' (fortuneDelim f) file ix)
return ix
rethrow e other = throwIO (e `asTypeOf` other)
ix <- handle onExc (openIndex path writeMode)
res <- action ix
return (Just ix, res)
Just ix -> do
res <- action ix
return (Just ix, res)
withFileAndIndex f action = withFortuneFile f (withIndex f . action)
-- |Get the 'Index' of a 'FortuneFile', opening it if necessary.
getIndex :: FortuneFile -> IO Index
getIndex fortunes = withIndex fortunes return
-- |Clear a 'FortuneFile's 'Index' and rebuild it from the contents
-- of the text file.
rebuildIndex :: FortuneFile -> IO ()
rebuildIndex f = withFileAndIndex f (rebuildIndex' (fortuneDelim f))
rebuildIndex' delim file ix = do
clearIndex ix
hSeek file AbsoluteSeek 0
getEntry <- enumFortuneLocs file delim
unfoldEntries ix getEntry
-- |scan an open handle for UTF8 chars. For each one found, returns the byte
-- location, the char, and the byte width of the char.
-- WARNING: seeks through file. Do not perform any other IO on the same file until the returned thingy says "Nothing".
enumUTF8 :: Handle -> IO (IO (Maybe (Int, Char, Int)))
enumUTF8 file = do
let getChunk = BS.hGet file 4096
refill buf
| BS.null buf = getChunk
| otherwise = return buf
bytePosRef <- hTell file >>= newIORef . fromInteger
bufRef <- getChunk >>= newIORef
let getOne = do
buf <- readIORef bufRef
if BS.null buf
then return Nothing
else case tryDecode buf of
Nothing -> do
-- this case occurs when there is a partial char at the
-- end of the buffer; check for more input; if there is none,
-- discard the partial char.
more <- getChunk
writeIORef bufRef $! if BS.null more
then BS.empty
else BS.append buf more
getOne
Just (c, n, rest) -> do
refill rest >>= writeIORef bufRef
bytePos <- readIORef bytePosRef
writeIORef bytePosRef $! bytePos + n
return (Just (bytePos, c, n))
return getOne
-- try to decode the first UTF-8 char in a buffer. If the decoding fails
-- (returns replacement_char), then check if the whole buffer was used.
-- if it was, we probably just need more data so return Nothing.
tryDecode bs = case U.decode bs of
Just (c, n)
| c /= U.replacement_char || n /= BS.length bs
-> Just (c, n, BS.drop n bs)
_ -> Nothing
-- WARNING: seeks through file. Do not perform any other IO on the same file until the returned thingy says "Nothing".
enumFortuneLocs :: Handle -> Char -> IO (IO (Maybe IndexEntry))
enumFortuneLocs file delim = do
curStart <- hTell file >>= newIORef . fromInteger
prev <- newIORef Nothing
curBytes <- newIORef 0
curChars <- newIORef 0
curLines <- newIORef 0
nextChar <- enumUTF8 file
let nextFortune = do
mbP <- readIORef prev
mbC <- nextChar
writeIORef prev mbC
case (mbP, mbC) of
(Nothing, Nothing) -> return Nothing
(Just (_, p, pN), Nothing)
| p == '\n' -> emit pN 1
| otherwise -> newline >> emit 0 0
(Just (_, p, pN), Just (_, c, n))
| p == '\n' && c == delim -> do
mbN <- nextChar
case mbN of
Just (loc,'\n',n) -> emit pN 1 <* reset (loc + n)
_ -> advance n
(_, Just (_, c, n)) -> do
when (c == '\n') newline
advance n
newline = modifyIORef' curLines (1 +)
advance n = do
modifyIORef' curBytes (n +)
modifyIORef' curChars (1 +)
nextFortune
reset loc = do
writeIORef curStart $! loc
writeIORef curBytes 0
writeIORef curChars 0
writeIORef curLines 0
-- the params are the amount to 'rewind' to cut off the final
-- newline in a quote, if necessary
emit dB dC = do
start <- readIORef curStart
bytes <- readIORef curBytes
chars <- readIORef curChars
ls <- readIORef curLines
return (Just (IndexEntry start (bytes - dB) (chars - dC) ls))
return nextFortune
#if !MIN_VERSION_base(4,6,0)
modifyIORef' r f = do
x <- readIORef r
writeIORef r $! f x
#endif
getByIndex file (IndexEntry loc len _ _) = do
hSeek file AbsoluteSeek (toInteger loc)
BS.hGet file len
-- |@getFortune f i@ retrieves the text of the @i@'th fortune
-- (according to the order in the index file) in the 'FortuneFile' @f@.
getFortune :: FortuneFile -> Int -> IO T.Text
getFortune f i = do
ix <- getIndex f
entry <- getEntry ix i
T.decodeUtf8With T.lenientDecode <$>
withFortuneFile f (flip getByIndex entry)
-- |Get the text of every fortune in a fortune file,
-- in the order they occur in the file. Ignores the index
-- entirely.
getFortunes :: FortuneFile -> IO [T.Text]
getFortunes f = withFortuneFile f $ \file -> do
hSeek file AbsoluteSeek 0
T.splitOn (T.pack ['\n', fortuneDelim f, '\n']) <$> T.hGetContents file
-- |Get the number of fortunes in a fortune file, as recorded
-- in the index.
getNumFortunes :: FortuneFile -> IO Int
getNumFortunes f = do
ix <- getIndex f
getSum . numFortunes <$> getStats ix
-- |Append a fortune to a fortune file, inserting a delimiter if
-- needed and updating the index.
appendFortune :: FortuneFile -> T.Text -> IO ()
appendFortune f fortune = do
rebuildIndex f
withFileAndIndex f $ \file ix -> do
offset <- max 0 . getMax . offsetAfter <$> getStats ix
hSeek file AbsoluteSeek (toInteger offset)
let enc = T.encodeUtf8
sep | offset == 0 = BS.empty
| otherwise = enc (T.pack ['\n', fortuneDelim f, '\n'])
encoded = enc fortune
BS.hPut file sep
BS.hPut file encoded
BS.hPut file (enc (T.pack "\n"))
-- just to be nice to people with @cat@s
hFlush file
appendEntry ix IndexEntry
{ stringOffset = offset + BS.length sep
, stringBytes = BS.length encoded
, stringChars = T.length fortune
, stringLines = length (T.lines fortune)
}