#!/usr/bin/env runghc module Main where {- Uninstall.hs - a Haskell uninstaller for Mac OS X This program is really far too big to be in a single file. However, I wanted it to be easily distributable and runnable, and so have kept it all together. - Mark Lentczner -} import Prelude hiding ((.), id) import Control.Arrow import Control.Category import Control.Monad ((>=>), msum, when) import Data.Char (isDigit) import Data.List (foldl', intercalate, isInfixOf, isPrefixOf, nub, sort) import qualified Data.Map as Map import Data.Maybe (catMaybes, isJust, mapMaybe) import System.Console.GetOpt import System.Directory (doesDirectoryExist, doesFileExist, getDirectoryContents) import System.Environment (getArgs, getEnvironment, getProgName) import System.Exit (exitFailure, exitSuccess) import System.FilePath ((), joinPath, splitDirectories, takeDirectory, takeFileName) import System.IO (hPutStrLn, stderr) import System.Posix.Directory (removeDirectory) import System.Posix.Files (createSymbolicLink, getSymbolicLinkStatus, isSymbolicLink, isDirectory, readSymbolicLink, removeLink, rename) import System.Process (readProcess) -- -- Utilities -- -- | Break a list apart into sections separated by a delimiter element parts :: Eq a => a -> [a] -> [[a]] parts d s = case break (== d) s of ([], []) -> [] (a, []) -> [a] (a, (_:b)) -> a : parts d b -- | Contents of a directory. Like getDirectoryContents, only a) safe, returning -- [] if there is a problem, and b) excludes "." and ".." contents :: FilePath -> IO [FilePath] contents fp = filter notSpecial `fmap` (getDirectoryContents fp `catch` (\_ -> return [])) where notSpecial :: String -> Bool notSpecial n = not $ n `elem` [".", ".."] -- | Entries under a directory. Like contents, but with the dir path prepended. entries :: FilePath -> IO [FilePath] entries fp = map (fp ) `fmap` contents fp -- | FilePath doesn't start with a dot notDot :: FilePath -> Bool notDot = not . ("." `isPrefixOf`) . takeFileName -- | simplifyPath path, elminiating . and .. components (if possible) simplifyPath :: FilePath -> FilePath simplifyPath = joinPath . simp [] . splitDirectories where simp ys [] = reverse ys simp ys ( ".":xs) = simp ys xs simp (y:ys) ("..":xs) | y /= ".." = simp ys xs simp ys ( x:xs) = simp (x:ys) xs -- -- Version Numbers -- type Major = Int type Minor = Int data Rev = DevRev Int | NoRev | Patch Int deriving (Eq, Ord) data Version = Version Major Minor Rev String deriving (Eq, Ord) instance Show Rev where show NoRev = "" show (DevRev p) = '.' : show p show (Patch p) = '.' : show p instance Show Version where show (Version m n p x) = show m ++ '.' : show n ++ show p ++ x version :: String -> Maybe Version version s = case vparts s of ([m], x) | m >= 600 && m < 800 -> Just $ Version (m `div` 100) (m `mod` 100) NoRev x | otherwise -> Nothing -- some old versions were installed in directories named "610" and "612" ([m, n], x) -> Just $ Version m n NoRev x ([m, n, p], x) | p > 19980000 -> Just $ Version m n (DevRev p) x | otherwise -> Just $ Version m n (Patch p) x _ -> Nothing where vparts s' = case span isDigit s' of ("", x) -> ([], x) (n, ('.':r)) -> let (m, x) = vparts r in (read n:m, x) (n, x) -> ([read n], x) ghcVersion :: String -> Maybe Version ghcVersion s = case parts '-' s of ("ghc":v:_) -> version v _ -> Nothing partVersion :: String -> Maybe Version partVersion = msum . map version . parts '-' data VersionTest = VersionAll | VersionOnly Version | VersionUpto Version | VersionThru Version deriving (Eq) versionTest :: VersionTest -> Version -> Bool versionTest rt = case rt of VersionAll -> const True (VersionOnly v) -> (v ==) (VersionUpto v) -> (v >) (VersionThru v) -> (v >=) -- -- Find Arrow: Finding things in the file system -- -- | A Find takes an annotated FilePath to a list of annotated FilePaths -- The annotations in and out can differ. data Find a b = Find { unFind :: (a, FilePath) -> IO [(b, FilePath)] } instance Category Find where id = Find $ return . return fbc . fab = Find $ unFind fab >=> fmap concat . mapM (unFind fbc) instance Arrow Find where arr f = Find $ \(a, fp) -> return [(f a, fp)] first fab = Find $ \((a, x), fp) -> unFind fab (a, fp) >>= return . map (\(b, fp') -> ((b, x), fp')) runFind :: Find () a -> IO [(a, FilePath)] runFind fua = unFind fua ((), "/") runFinds :: [Find () a] -> IO [(a, FilePath)] runFinds = fmap concat . mapM runFind path :: FilePath -> Find a a path p = Find $ \(a, f) -> return [(a, f p)] star :: Find a a star = Find $ \(a, fp) -> entries fp >>= return . map (\gp -> (a, gp)) fileTest :: (FilePath -> IO Bool) -> Find a a fileTest p = Find $ \(a, fp) -> p fp >>= return . (\b -> if b then [(a, fp)] else []) fileExtract :: (a -> FilePath -> IO (Maybe b)) -> Find a b fileExtract p = Find $ \(a, fp) -> p a fp >>= return . maybe [] (\b -> [(b, fp)]) exists :: Find a a exists = fileTest $ \fp -> do dde <- doesDirectoryExist fp dfe <- doesFileExist fp return $ dde || dfe fileExists :: Find a a fileExists = fileTest doesFileExist dirExists :: Find a a dirExists = fileTest doesDirectoryExist findFilter :: (a -> FilePath -> Maybe b) -> Find a b findFilter p = Find $ \(a, fp) -> return $ maybe [] (\b -> [(b, fp)]) $ p a fp test :: (a -> Bool) -> Find a a test p = findFilter $ \a _fp -> if p a then Just a else Nothing match :: (FilePath -> Bool) -> Find a a match p = findFilter $ \a fp -> if p fp then Just a else Nothing extract :: (FilePath -> Maybe b) -> Find a b extract p = findFilter $ const p matches :: (FilePath -> Bool) -> Find a a matches p = star >>> match (p . takeFileName) extracts :: (FilePath -> Maybe b) -> Find a b extracts p = star >>> extract (p . takeFileName) -- -- Finds for various places where Haskell bits are stored -- ghcName :: FilePath -> Bool ghcName = isJust . ghcVersion -- | Find all the per-version installation directories. findVersions :: IO (Map.Map Version [FilePath]) findVersions = makeMap `fmap` runFinds [ path "/Library/Frameworks/GHC.framework/Versions" >>> extracts partVersion , path "/Library/Frameworks/HaskellPlatform.framework/lib" >>> star >>> extracts ghcVersion , path "/Library/Haskell" >>> extracts ghcVersion , path "/Users" >>> star >>> path ".cabal/lib" >>> star >>> extracts ghcVersion , path "/Users" >>> star >>> path ".ghc" >>> extracts partVersion , path "/Users" >>> star >>> path "Library/Haskell" >>> extracts ghcVersion , path "/usr/local/lib" >>> extracts ghcVersion , path "/usr/local/lib" >>> matches (not . ghcName) >>> extracts ghcVersion ] where makeMap :: Ord a => [(a, b)] -> Map.Map a [b] makeMap = Map.fromListWith (++) . map (\(a, b) -> (a, [b])) -- | Find all the top level installation directories. Includes some per-version -- directories where things were stored in common system lib directories. findAll :: IO [FilePath] findAll = map snd `fmap` runFinds [ path "/Library/Frameworks/GHC.framework" >>> exists , path "/Library/Frameworks/HaskellPlatform.framework" >>> exists , path "/Library/Haskell" >>> exists , path "/Users" >>> star >>> path ".cabal" >>> matches (excludePrefix "config") , path "/Users" >>> star >>> path ".ghc" >>> matches (excludePrefix "ghci") , path "/Users" >>> star >>> path "Library/Haskell" >>> exists , path "/usr/local/lib" >>> matches ghcName , path "/usr/local/lib" >>> matches (not . ghcName) >>> matches ghcName ] where excludePrefix :: String -> FilePath -> Bool excludePrefix p fp = not $ p `isPrefixOf` fp -- | Find symlinks on the PATH that point into directories that are going to be -- removed. findOrphanSymlinks :: [FilePath] -> IO [FilePath] findOrphanSymlinks removed = do pathDirs <- (maybe [] (parts ':') . lookup "PATH") `fmap` getEnvironment let placesToLook = map path (pathDirs ++ [ "/usr/bin", "/usr/local/bin" ]) ++ [ path "/Users" >>> star >>> path "Library/Haskell/bin" ] (nub . map snd) `fmap` runFinds (map (\p -> p >>> star >>> sym >>> test orphan) placesToLook) where sym :: Find a FilePath sym = fileExtract $ const $ \fp -> do st <- getSymbolicLinkStatus fp if isSymbolicLink st then (Just . simplifyPath . (takeDirectory fp )) `fmap` readSymbolicLink fp else return Nothing orphan fp = any (`isPrefixOf` fp) removed -- | Find all package directories where removing the per-version directory -- might indicate that the whole package can be removed. findEmptyPackages :: VersionTest -> IO [(Bool, FilePath)] findEmptyPackages rt = libVersions >>= fmap catMaybes . mapM willEmpty where libVersions = map snd `fmap` runFinds packageFind packageFind = case rt of VersionAll -> packagesToAlwaysCheck _ -> packagesToAlwaysCheck ++ packagesCoveredByAll packagesToAlwaysCheck = [ path "/usr/local/lib" >>> matches (not . ghcName) ] packagesCoveredByAll = [ path "/Library/Frameworks/HaskellPlatform.framework/lib" >>> star , path "/Users" >>> star >>> path ".cabal/lib" >>> star ] willEmpty :: FilePath -> IO (Maybe (Bool, FilePath)) willEmpty fp = do names <- filter notDot `fmap` contents fp let ghcVersions = catMaybes $ map ghcVersion names let removingAll = all (versionTest rt) ghcVersions let namesLeft = filter (not . willRemove) names return $ if not (null ghcVersions) && removingAll then Just (null namesLeft, fp) else Nothing willRemove = maybe False (versionTest rt) . ghcVersion -- -- Program Options -- data OptRemove = OptDryRun | OptScript | OptRemove deriving (Eq, Ord) data Options = Options { optVerbose, optHelp :: Bool, optRemove :: OptRemove } optReportRemove :: Options -> Bool optReportRemove opts = case optRemove opts of OptDryRun -> True OptScript -> False OptRemove -> optVerbose opts optionsDescr :: [OptDescr (Options -> Options)] optionsDescr = [ Option ['v'] ["verbose"] (NoArg setVerbose) "report each path" , Option ['n'] ["dry-run"] (NoArg setDryRun) "only report what would be removed" , Option ['s'] ["sh", "script"] (NoArg setScript) "generate a shell script to remove files" , Option ['r'] ["rm", "remove"] (NoArg setRemove) "actually remove files" , Option ['?'] ["help"] (NoArg setHelp) "help (this message)" ] where setVerbose opts = opts { optVerbose = True } setDryRun opts = opts { optRemove = OptDryRun } setScript opts = opts { optRemove = OptScript } setRemove opts = opts { optRemove = OptRemove } setHelp opts = opts { optHelp = True } parseOptions :: [String] -> IO (Options, [String]) parseOptions argv = case getOpt Permute optionsDescr argv of (o,n,[] ) -> return (foldl' (flip ($)) defaultOpts o,n) (_,_,errs) -> usageFailure (concat errs) where defaultOpts = Options { optVerbose = False, optHelp = False, optRemove = OptDryRun } progMessage :: String -> IO () progMessage msg = do prog <- getProgName putStr $ intercalate prog $ parts '$' msg usage :: IO () usage = do progMessage header putStr $ usageInfo "Options (can appear anywhere):" optionsDescr where header = "Usage: $ -- find versions on system\n\ \ $ thru VERSION -- remove VERSION and earlier\n\ \ $ only VERSION -- remove only VERSION\n\ \ $ all -- remove all\n\ \NOTE: Commands are 'dry run' by default and don't actually delete.\n" usageFailure :: String -> IO a usageFailure msg = do mapM_ (putStrLn . ("*** " ++)) $ lines msg usage exitFailure message :: Options -> String -> IO () message opts str = putStrLn $ messagePrefix ++ str where messagePrefix = if (optRemove opts == OptScript) then "echo " else "" -- -- Primitive File Operations -- safely :: FilePath -> IO () -> IO () safely fp = (`catch` (hPutStrLn stderr . fmt . show)) where fmt msg = "** ERROR " ++ (if fp `isInfixOf` msg then "" else fp ++ ": ") ++ msg -- | Recursively remove a directory. Like shell command "rm -rf". -- Unlike System.Directory.removeDirectoryRecursive, doesn't follow symlinks. removeDirectoryRecursive :: Options -> FilePath -> IO () removeDirectoryRecursive opts fp = do when (optReportRemove opts) $ putStrLn fp case (optRemove opts) of OptDryRun -> return () OptScript -> putStrLn ("rm -rf " ++ fp) OptRemove -> rmrf fp where rmrf f = do st <- getSymbolicLinkStatus f if isDirectory st then do entries f >>= mapM_ rmrf safely f $ removeDirectory f else safely f $ removeLink f -- | Remove a file. Like shell command "rm -f". -- If file is a symlinks, removes the symlink, not what it points to. removeFile :: Options -> FilePath -> IO () removeFile opts fp = do when (optReportRemove opts) $ do st <- getSymbolicLinkStatus fp if isSymbolicLink st then readSymbolicLink fp >>= putStrLn . ((fp ++ "@ -> ") ++) else putStrLn fp case (optRemove opts) of OptDryRun -> return () OptScript -> putStrLn ("rm -f " ++ fp) OptRemove -> safely fp $ removeLink fp -- | Symlink a file. Like shell command "ln -sf". -- If file is a symlinks, removes the symlink, not what it points to. symlinkFile :: Options -> FilePath -> FilePath -> IO () symlinkFile opts dest fp = do when (optReportRemove opts) $ putStrLn (fp ++ "@ update to -> " ++ dest) case (optRemove opts) of OptDryRun -> return () OptScript -> putStrLn ("ln -sf " ++ dest ++ " " ++ fp) OptRemove -> safely fp $ removeLink fp >> createSymbolicLink dest fp -- | Archive a file, by giving it a suffix with a unique integer attached archiveFile :: Options -> String -> FilePath -> IO () archiveFile opts suffix fp = do dest <- findFreeArchive 0 when (optReportRemove opts) $ putStrLn (fp ++ " rename to -> " ++ dest) case (optRemove opts) of OptDryRun -> return () OptScript -> putStrLn ("mv " ++ fp ++ " " ++ dest) OptRemove -> safely fp $ rename fp dest where findFreeArchive n = do let dest = fp ++ suffix ++ "." ++ show n dfe <- doesFileExist dest if dfe then findFreeArchive (n + 1) else return dest -- | For each framework, update the Current symlink if the version it points -- to will be removed, or remove the whole framework if nothing will be left. updateFrameworks :: Options -> VersionTest -> IO () updateFrameworks opts rt = when (rt /= VersionAll) $ mapM_ updateFramework frameworks where frameworks = [ ("/Library/Frameworks/GHC.framework", "Versions", "Current") , ("/Library/Haskell", "", "current") ] updateFramework (fp, vp, cp) = do items <- contents $ fp vp let remain = filter (willKeep cp) items let remainVers = reverse . sort . mapMaybe andVersion $ remain let curr = fp vp cp currDest <- readSymbolicLink curr `catch` (\_ -> return "") when (willRemove currDest) $ case (remain, remainVers) of ([], _) -> -- nothing will remain, remove whole framework removeDirectoryRecursive opts fp (_, []) -> do -- no versions will remain, but something will removeFile opts curr message opts $ "** " ++ fp ++ " is not empty, but has no more versions. Consider removing." (_, ((_,newDest):_)) -> -- update to maximal remaining version symlinkFile opts newDest curr willRemove = maybe False (versionTest rt) . partVersion willKeep cp fp = notDot fp && (fp /= cp) && (not $ willRemove fp) andVersion fp = (\v -> (v, fp)) `fmap` partVersion fp -- -- Main Operations -- -- | Display versions found showVersions :: Options -> Map.Map Version [FilePath] -> IO () showVersions opts m = do whenVer blank mapM_ disp (Map.toAscList m) progMessage hints where whenVer = when (optVerbose opts) blank = putStrLn "" disp (v, fp) = do putStrLn $ show v whenVer $ do mapM_ (putStrLn . (" " ++)) $ sort fp blank hints = "-- To remove a version and all earlier: $ thru VERSION\n\ \-- To remove only a single version: $ only VERSION\n\n" alertOlderVersions :: String -> Map.Map Version [FilePath] -> IO () alertOlderVersions app m = when (not $ Map.null m) $ do _ <- readProcess "osascript" [] alert return () where alert = "tell application \"" ++ app ++ "\"\n\ \\tactivate\n\ \\tdisplay alert \"Older Versions\" message \"" ++ msg ++ "\"\n\ \end tell\n" msg = "There are older versions of GHC and/or \ \Haskell Platform on this system.\r\ \\r\ \Run the command line tool uninstall-hs to \ \find out more and how to remove them." -- | Remove file paths and associated other files. -- Must be supplied the predicate used to select versions to remove so that the -- associated files can be correctly identified. remove :: Options -> VersionTest -> [FilePath] -> IO () remove opts rt fps = do case sort fps of [] -> message opts "** Nothing to remove" sfps -> do mapM_ (removeDirectoryRecursive opts) sfps findOrphanSymlinks fps >>= mapM_ (removeFile opts) findEmptyPackages rt >>= mapM_ removePackage updateFrameworks opts rt removeHints where removePackage (empty, fp) = do if empty then removeDirectoryRecursive opts fp else message opts ("** " ++ fp ++ " is not empty, but has no more GHC libs. Consider removing.") removeHints = when (optRemove opts == OptDryRun) $ putStrLn "-- To actually remove these files, \ \sudo run the command again with --remove\n\ \-- To generate a script to remove these files, \ \run the command again with --script\n" -- | Remove all Haskell versions, and the top level directories. removeAll :: Options -> IO () removeAll opts = do runFind cabalConfigs >>= mapM_ (archiveFile opts ".orig" . snd) findAll >>= remove opts VersionAll where cabalConfigs = path "/Users" >>> star >>> path ".cabal/config" >>> exists main :: IO () main = getArgs >>= parseOptions >>= uncurry main' main' :: Options -> [String] -> IO () main' opts args = do when (optHelp opts) $ usage >> exitSuccess case args of [] -> do putStrLn "-- Versions found on this system" findVersionsThat VersionAll >>= showVersions opts ["all"] -> do removePlan "all Haskell directories" removeAll opts ["test"] -> do main' testOpts [] vers <- Map.keys `fmap` findVersions mapM_ (\v -> main' testOpts ["only", show v]) vers mapM_ (\v -> main' testOpts ["thru", show v]) vers main' testOpts ["all"] ["thru", v] -> withVersion v $ \ver -> do removePlan $ "version " ++ show ver ++ " and earlier" removeVersionsThat (VersionThru ver) ["only", v] -> withVersion v $ \ver -> do removePlan $ "just version " ++ show ver removeVersionsThat (VersionOnly ver) ["install-check", v, a] -> withVersion v $ \ver -> do findVersionsThat (VersionUpto ver) >>= alertOlderVersions a _ -> usageFailure "unregcognized args" where removePlan s = message opts $ removePrefix ++ s removePrefix = case optRemove opts of OptDryRun -> "-- Would remove " _ -> "-- Removing " withVersion v a = maybe (usageFailure "couldn't parse version") a $ version v findVersionsThat rt = Map.filterWithKey (const . versionTest rt) `fmap` findVersions removeVersionsThat rt = findVersionsThat rt >>= remove opts rt . concat . Map.elems testOpts = opts { optVerbose = True, optRemove = OptDryRun }