watcher-0.0.3.0: src/Watcher.hs
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
module Watcher
( watch
, unwatch
, Action(Added, Changed, Moved, Removed)
, Warning(MovedOutOfScope)
, Watcher
, Handler
) where
import BasicPrelude hiding (stripPrefix, mapM)
import Control.Concurrent
( MVar
, newMVar
, modifyMVar_
, withMVar )
import qualified Data.Map as Map
import Data.Traversable (mapM)
import Filesystem
import Filesystem.Path.CurrentOS
import System.INotify
( Cookie
, EventVariety(Create, Modify, Delete, MoveIn, MoveOut)
, Event(Created, Modified, Deleted, MovedIn, MovedOut)
, INotify
, WatchDescriptor
, addWatch
, initINotify
, killINotify
, removeWatch )
-- |The types of actions that are reported.
data Action
= Added
| Changed
-- NOTE: It is the user's responsibility to check if the destination already
-- existed and, in the event that a file was overwritten, handle the
-- overwritten file's removal.
| Moved FilePath
| Removed
deriving (Eq, Show)
-- |Badness that happened during a watch, usually due to inotify limitations.
data Warning
= MovedOutOfScope FilePath
deriving (Eq, Show)
-- |Functions that handle events
-- The filepaths passed will be joined with the path used to set up the watcher.
-- If you have
-- mydir/
-- file1
-- file2
-- And you do (watch myHandler "mydir") and file1 changes, myHandler will be
-- passed "mydir/file1" as the FilePath.
type Handler = Action -> FilePath -> IO ()
-- |A handler used to mutate and reference watchers.
data Watcher
= Watcher
{ notifier :: INotify
, descriptors :: MVar (Map FilePath WatchDescriptor)
, moves :: MVar (Map Cookie FilePath)
} deriving Eq
-- |Monadic variant of if.
ifM :: Monad m => m Bool -> m a -> m a -> m a
ifM mpred t f = do
predicate <- mpred
if predicate then t else f
-- |Makes path relative to base.
makeRelative :: FilePath -> FilePath -> FilePath
makeRelative base path = fromMaybe path stripped
where
stripped = stripPrefix (commonPrefix [base, path]) path
-- |Whether or not path starts with prefix.
startsWith :: FilePath -> FilePath -> Bool
startsWith prefix path = commonPrefix [prefix, path] == prefix
-- |Like listDirectory, but partitioned: directories left, files right.
directoriesAndFiles :: FilePath -> IO [Either FilePath FilePath]
directoriesAndFiles path = mapM divvy =<< listDirectory path
where
divvy x = ifM (isDirectory x) (dir x) (file x)
dir = pure . Left
file = pure . Right
-- |Creates a watch for a single handler on a single directory.
watch :: Handler -> FilePath -> IO Watcher
watch handler filepath = do
-- Ensures that the path is treated as a directory.
let dirpath = filepath </> ""
isDir <- isDirectory dirpath
unless isDir $ ioError $
userError $ "Not a directory: " ++ encodeString dirpath
watcher <- Watcher
<$> initINotify
<*> newMVar Map.empty
<*> newMVar Map.empty
watchDir watcher handler dirpath
pure watcher
-- |Used to generate watchDir and moveWatchedDir
watchDirWithInitializer
:: (FilePath -> IO ())
-> Watcher
-> Handler
-> FilePath
-> IO ()
watchDirWithInitializer initialize watcher handler filepath = do
-- The filepath, guaranteed to be treated like a directory.
let strdir = encodeString dir
watchd <- addWatch (notifier watcher) relevantEvents strdir event
modifyMVar_ (descriptors watcher) (pure . Map.insert dir watchd)
mapM_ (either recurse initialize) =<< directoriesAndFiles dir
where
dir = filepath </> ""
into child = dir </> decodeString child
recurse = watchDirWithInitializer initialize watcher handler
descend = recurse . into
ascend = unwatchDir watcher . into
add = handler Added . into
remove = handler Removed . into
change = handler Changed . into
prepMove child cookie = modifyMVar_ (moves watcher)
(pure . Map.insert cookie (into child))
moveFile child cookie = modifyMVar_ (moves watcher) (\dict ->
let source = dict Map.! cookie in
if cookie `Map.member` dict
then do
handler (Moved source) (into child)
pure $ Map.delete cookie dict
else handler Added (into child) *> pure dict)
moveDir child cookie = modifyMVar_ (moves watcher) (\dict ->
let source = dict Map.! cookie in
if cookie `Map.member` dict
then do
moveWatchedDir source watcher handler (into child)
pure $ Map.delete cookie dict
else recurse (into child) *> pure dict)
event ev = case ev of
-- New file created
Created False child -> add child
-- New directory created.
Created True child -> descend child
-- This file modified.
Modified False Nothing -> handler Changed dir
-- Child file modified.
Modified False (Just child) -> change child
-- Directory added via move.
MovedIn True child cookie -> moveDir child cookie
-- File added via move.
MovedIn False child cookie -> moveFile child cookie
-- Something moved out. If it was moved out of scope, we can't fire
-- events on it, because we don't know where it moved to. If you
-- move watched/foo to ../bar, the MovedOut event only knows that
-- watched/foo/ is gone. There's no way for us to figure out that
-- it went to bar/, recurse bar/, and fire the moved events manually
-- on the files there. Using inotify, there's no way for us to send
-- move events unless the moved file lands in a watched directory.
MovedOut _ child cookie -> prepMove child cookie
-- Directory removed.
Deleted True child -> ascend child
-- File removed.
Deleted False child -> remove child
-- Not relevant.
-- We don't in general care about directory modification. Self
-- events are ignored and passed on to be handled by the parent.
-- Other non-created/moved/modified/deleted events are ignored.
_ -> pure ()
-- |Creates watchers on one directory, recursively.
watchDir :: Watcher -> Handler -> FilePath -> IO ()
watchDir watcher handler
= watchDirWithInitializer (handler Added) watcher handler
-- |Moves a directory, firing the appropriate move events recursively.
-- The subdirectory watchers will be killed and recreated, which is a bit
-- expensive, but is far cleaner than manually maintaining the root-relative
-- path of each directory watcher.
moveWatchedDir :: FilePath -> Watcher -> Handler -> FilePath -> IO ()
moveWatchedDir source watcher handler dest = do
unwatchDir watcher source
watchDirWithInitializer moved watcher handler dest
where
oldPath path = source </> makeRelative (dest </> "") path
moved path = handler (Moved $ oldPath path) path
-- |Removes the watcher on one subdir and updates the watch dict accordingly.
-- Might be called after the directory has been moved/removed. It is not
-- possible in general to check the contents of the directory.
unwatchDir :: Watcher -> FilePath -> IO ()
unwatchDir watcher dir = modifyMVar_ (descriptors watcher) killChildren
where
killChildren dict = do
let dying = Map.filterWithKey (\k _ -> startsWith dir k) dict
mapM_ removeWatch $ Map.elems dying
pure $ dict Map.\\ dying
-- |Shuts down all watching and the inotifier.
unwatch :: Watcher -> IO [Warning]
unwatch watcher = do
lost <- withMVar (moves watcher) (pure . map MovedOutOfScope . Map.elems)
modifyMVar_ (descriptors watcher) killDescriptors
killINotify (notifier watcher)
pure lost
where
killDescriptors dict = mapM removeWatch dict *> pure Map.empty
-- |inotify events that are listened to.
relevantEvents :: [EventVariety]
relevantEvents =
[ Modify
, Create
, MoveIn
, MoveOut
, Delete
]