{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-} module Main where import Control.Applicative import Control.Concurrent (threadDelay, newEmptyMVar, forkIO, putMVar, takeMVar) import Control.Concurrent.Async import qualified Control.Concurrent.MSem as MSem import Control.Exception import Control.Monad (filterM, forM_, unless, void, when, guard) import qualified Control.Monad.Parallel as P import Data.Functor ((<&>)) import Data.List (foldl', isPrefixOf, nub, sort) import Data.Maybe (listToMaybe, fromMaybe, isNothing) import Data.Text (Text, unlines, pack, unpack) import qualified Data.Text as T import Data.Text.IO (putStrLn, putStr, writeFile, hGetContents) import Data.Time (getZonedTime) import Data.Version (showVersion) import Data.Yaml (decodeFileEither, prettyPrintParseException) import GHC.Conc (getNumProcessors, setNumCapabilities) import Prelude hiding (readFile, writeFile, unlines, putStrLn, putStr, show) import System.Console.Concurrent () import System.Console.Regions import System.Directory import System.Environment (getArgs) import System.Environment.XDG.BaseDir (getUserConfigFile, getUserDataDir) import System.Exit (ExitCode(..)) import System.FilePath (()) import System.IO (openFile, IOMode(..), hClose, hFlush, stdout, stderr, hGetLine, hPutStrLn) import System.IO.Error (isDoesNotExistError, tryIOError, isEOFError) import System.PosixCompat.Files (setFileTimes) import System.Process import Git import Paths_miv (version) import Plugin import Setting import ShowText import VimScript hiding (singleQuote) nameversion :: Text nameversion = "miv " <> pack (showVersion version) expandHomeDirectory :: FilePath -> IO FilePath expandHomeDirectory ('~':'/':path) = getHomeDirectory <&> ( path) expandHomeDirectory path = return path getSettingFileCandidates :: IO [FilePath] getSettingFileCandidates = do xdgConfig <- getUserConfigFile "miv" "config.yaml" mapM expandHomeDirectory ([ xdgConfig , "~/.vimrc.yaml" , "~/.vim/.vimrc.yaml" , "~/vimrc.yaml" , "~/.vim/vimrc.yaml" , "~/_vimrc.yaml" , "~/.vim/_vimrc.yaml" , "~/_vim/_vimrc.yaml" , "~/vimfiles/.vimrc.yaml" , "~/vimfiles/vimrc.yaml" , "~/vimfiles/_vimrc.yaml" ]) getFirstExistingFile :: IO [FilePath] -> IO (Maybe FilePath) getFirstExistingFile fs = fmap listToMaybe $ filterM doesFileExist =<< fs getSettingFile :: IO (Maybe FilePath) getSettingFile = getFirstExistingFile $ getSettingFileCandidates getSetting :: IO Setting getSetting = do candidates <- getSettingFileCandidates maybeFile <- getFirstExistingFile $ return candidates case maybeFile of Just file -> do maybeSetting <- decodeFileEither file case maybeSetting of Right setting -> return setting Left err -> error $ prettyPrintParseException err Nothing -> error $ unpack $ unlines $ "No setting file! Tried following locations:" : map (\x -> " - " <> pack x) candidates pluginDirectory :: IO FilePath pluginDirectory = do defaultDir <- expandHomeDirectory "~/.vim/miv" xdgDir <- getUserDataDir "miv" x <- return xdgDir <||> doesDirectoryExist y <- return defaultDir <||> doesDirectoryExist z <- expandHomeDirectory "~/vimfiles/miv" <||> doesDirectoryExist return $ fromMaybe defaultDir $ x <|> y <|> z where (<||>) :: IO a -> (a -> IO Bool) -> IO (Maybe a) (<||>) f g = f >>= \x -> g x >>= \b -> return $ if b then Just x else Nothing createPluginDirectory :: IO () createPluginDirectory = createDirectoryIfMissing True =<< pluginDirectory printUsage :: IO () printUsage = mapM_ putStrLn usage commandHelp :: IO () commandHelp = mapM_ (putStrLn . show) arguments data Argument = Argument (Text, Text) deriving (Eq, Ord) instance ShowText Argument where show (Argument (x, y)) = x <> T.replicate (10 - T.length x) " " <> y arguments :: [Argument] arguments = map Argument [ ("install" , "Installs the uninstalled plugins.") , ("update" , "Updates the plugins.") , ("generate", "Generate the miv files.") , ("ftdetect", "Gather ftdetect scripts.") , ("helptags", "Generate the help tags file.") , ("each" , "Execute command at each plugin directory.") , ("path" , "Print the paths of each plugins.") , ("list" , "List the plugins.") , ("clean" , "Clean up unused plugins.") , ("edit" , "Edit the configuration file.") , ("command" , "Show the sub-commands.") , ("version" , "Show the version of miv.") , ("help" , "Show this help.") ] usage :: [Text] usage = [ nameversion , "" , "Usage: miv COMMAND" , "" , "Commands:" ] <> map (T.append " " . show) arguments <> [ "" , "You can install the plugins by the following command:" , " miv install" , "" , "You can update the plugins by the following command:" , " miv update" , "" , "You can specify the name of plugins:" , " miv update plugin1 plugin2" , "" , "Normally, outdated plugins are ignored but" , "you can update all the plugins with trailing !:" , " miv update!" , "" , "You can use `miv each' to execute some commands at each plugin directory." , " miv each pwd" , " miv each git gc" , " miv each git diff" , " miv each 'echo $(du -sh .) $(basename $(pwd))'" , " miv each 'git diff --quiet || echo ${PWD##*/}'" , "" , "This software is released under the MIT License." , "This software is distributed at https://github.com/itchyny/miv." , "Report a bug of this software at https://github.com/itchyny/miv/issues." , "The author is itchyny ." ] levenshtein :: Eq a => [a] -> [a] -> Int levenshtein a b = last $ foldl' f [0..length a] b where f [] _ = [] f xs@(x:xs') c = scanl (g c) (x + 1) (zip3 a xs xs') g c z (d, x, y) = minimum [y + 1, z + 1, x + fromEnum (c /= d)] suggestCommand :: String -> IO () suggestCommand arg = do let distcommands = [(levenshtein arg (unpack x), Argument (x, y)) | (Argument (x, y)) <- arguments] mindist = fst (minimum distcommands) mincommands = [y | (x, y) <- distcommands, x == mindist] prefixcommands = [y | y@(Argument (x, _)) <- arguments, arg `isPrefixOf` unpack x || unpack x `isPrefixOf` arg] containedcommands = [y | y@(Argument (x, _)) <- arguments, length arg > 1 && arg `isContainedIn` unpack x] putStrLn $ "Unknown command: " <> pack arg putStrLn "Probably:" mapM_ (putStrLn . (" "<>) . show) (if null prefixcommands then nub (containedcommands <> mincommands) else prefixcommands) isContainedIn :: Eq a => [a] -> [a] -> Bool isContainedIn xxs@(x:xs) (y:ys) = x == y && xs `isContainedIn` ys || xxs `isContainedIn` ys isContainedIn [] _ = True isContainedIn _ [] = False suggestPlugin :: [Plugin] -> String -> IO () suggestPlugin plugin arg = do let distplugins = [(levenshtein arg (rtpName p), p) | p <- plugin] mindist = fst (minimum distplugins) minplugins = [y | (x, y) <- distplugins, x == mindist] putStrLn $ "Unknown plugin: " <> pack arg putStrLn "Probably:" mapM_ (putStrLn . (" "<>) . show) minplugins data Update = Install | Update deriving Eq instance ShowText Update where show Install = "installing" show Update = "updating" data UpdateStatus = UpdateStatus { installed :: [Plugin], updated :: [Plugin], nosync :: [Plugin], outdated :: [Plugin], failed :: [Plugin] } instance Semigroup UpdateStatus where UpdateStatus i u n o f <> UpdateStatus i' u' n' o' f' = UpdateStatus (i <> i') (u <> u') (n <> n') (o <> o') (f <> f') instance Monoid UpdateStatus where mempty = UpdateStatus [] [] [] [] [] updatePlugin :: Update -> Maybe [String] -> Setting -> IO () updatePlugin update maybePlugins setting = do setNumCapabilities =<< getNumProcessors let unknownPlugins = filter (`notElem` map rtpName (plugins setting)) (fromMaybe [] maybePlugins) unless (null unknownPlugins) $ mapM_ (suggestPlugin (plugins setting)) unknownPlugins createPluginDirectory dir <- pluginDirectory let specified p = rtpName p `elem` fromMaybe [] maybePlugins || maybePlugins == Just [] let filterplugin p = isNothing maybePlugins || specified p let ps = filter filterplugin (plugins setting) let count xs = if length xs > 1 then "s (" <> show (length xs) <> ")" else "" time <- maximum <$> mapM' (lastUpdatePlugin dir) ps status <- fmap mconcat <$> displayConsoleRegions $ mapM' (\p -> updateOnePlugin time dir update (specified p) p) ps putStrLn $ (if null (failed status) then "Success" else "Error occured") <> " in " <> show update <> "." unless (null (installed status)) $ do putStrLn $ "Installed plugin" <> count (installed status) <> ": " mapM_ (putStrLn . (" "<>) . name) (sort (installed status)) unless (null (updated status)) $ do putStrLn $ "Updated plugin" <> count (updated status) <> ": " mapM_ (putStrLn . (" "<>) . name) (sort (updated status)) unless (null (failed status)) $ do putStrLn $ "Failed plugin" <> count (failed status) <> ": " mapM_ (putStrLn . (" "<>) . name) (sort (failed status)) generatePluginCode setting gatherFtdetectScript setting generateHelpTags setting mapM' :: (a -> IO b) -> [a] -> IO [b] mapM' f xs = MSem.new (16 :: Int) >>= \sem -> mapConcurrently (MSem.with sem . f) xs cleanAndCreateDirectory :: FilePath -> IO () cleanAndCreateDirectory dir = do removeDirectoryIfExists dir createDirectoryIfMissing True dir removeDirectoryIfExists :: FilePath -> IO () removeDirectoryIfExists dir = do exists <- doesDirectoryExist dir when exists $ removeDirectoryRecursive dir generateHelpTags :: Setting -> IO () generateHelpTags setting = do dir <- pluginDirectory let docdir = dir "miv" "doc" cleanAndCreateDirectory docdir P.forM_ (map (\p -> dir rtpName p "doc") (plugins setting)) $ \path -> do exists <- doesDirectoryExist path when exists $ void $ do (_, _, _, ph) <- createProcess (proc "sh" ["-c", "cp -R * " <> singleQuote docdir]) { cwd = Just path } waitForProcess ph _ <- system $ "vim -u NONE -i NONE -N -e -s -c 'helptags " <> docdir <> "' -c quit" putStrLn "Success in processing helptags." lastUpdatePlugin :: FilePath -> Plugin -> IO Integer lastUpdatePlugin dir plugin = do let path = dir rtpName plugin ".git" exists <- doesDirectoryExist path if exists then lastUpdate path else return 0 updateOnePlugin :: Integer -> FilePath -> Update -> Bool -> Plugin -> IO UpdateStatus updateOnePlugin time dir update specified plugin = do let path = dir rtpName plugin repo = vimScriptRepo (name plugin) cloneCommand = if submodule plugin then cloneSubmodule else clone pullCommand = if submodule plugin then pullSubmodule else pull putStrLn' = \region -> setConsoleRegion region . ((name plugin <> ": ") <>) finish' = \region -> finishConsoleRegion region . ((name plugin <> ": ") <>) exists <- doesDirectoryExist path gitstatus <- gitStatus path if not exists || (gitstatus /= ExitSuccess && not (sync plugin)) then withConsoleRegion Linear $ \region -> do putStrLn' region "Installing" when exists $ removeDirectoryRecursive path cloneStatus <- execCommand (unpack $ name plugin) region dir $ cloneCommand repo path created <- doesDirectoryExist path when created $ do buildPlugin path region changeModifiedTime path =<< lastUpdate path if cloneStatus /= ExitSuccess || not created then return mempty { failed = [plugin] } else return mempty { installed = [plugin] } else if update == Install || not (sync plugin) then return mempty { nosync = [plugin] } else withConsoleRegion Linear $ \region -> do lastUpdateTime <- lastUpdate path if lastUpdateTime < time - 60 * 60 * 24 * 30 && not specified then do finish' region "Outdated" return mempty { outdated = [plugin] } else do putStrLn' region "Pulling" pullStatus <- execCommand (unpack $ name plugin) region dir $ pullCommand path newUpdateTime <- lastUpdate path if pullStatus /= ExitSuccess then return $ mempty { failed = [plugin] } else if newUpdateTime <= lastUpdateTime then return mempty else do buildPlugin path region changeModifiedTime path newUpdateTime return mempty { updated = [plugin] } where changeModifiedTime path mtime = when (mtime > 0) $ let ctime = fromInteger mtime in setFileTimes path ctime ctime buildPlugin path region = do let command = build plugin when (not (T.null command)) $ void $ execCommand (unpack $ name plugin) region path (unpack command) singleQuote :: String -> String singleQuote str = "'" <> str <> "'" execCommand :: String -> ConsoleRegion -> String -> String -> IO ExitCode execCommand pluginName region dir command = do (_, Just hout, Just herr, ph) <- createProcess (proc "sh" ["-c", command]) { cwd = Just dir, std_out = CreatePipe, std_err = CreatePipe } outmvar <- newEmptyMVar errmvar <- newEmptyMVar _ <- forkIO $ go herr errmvar "" _ <- forkIO $ go hout outmvar "" errline <- takeMVar errmvar outline <- takeMVar outmvar code <- waitForProcess ph finishConsoleRegion region (pluginName ++ ": " ++ if null outline then errline else outline) return code where go h mvar lastLine = do e <- tryIOError $ hGetLine h case e of Left err -> if isEOFError err then putMVar mvar lastLine else return () Right line -> do setConsoleRegion region (pluginName ++ ": " ++ line) threadDelay 100000 go h mvar line vimScriptRepo :: Text -> FilePath vimScriptRepo pluginname | T.any (=='/') pluginname = unpack pluginname | otherwise = unpack $ "vim-scripts/" <> pluginname listPlugin :: Setting -> IO () listPlugin setting = mapM_ putStrLn $ space $ map format $ plugins setting where format p = [show p, name p, pack $ gitUrl (vimScriptRepo (name p))] space xs = let max0 = maximum (map (T.length . (!!0)) xs) + 1 max1 = maximum (map (T.length . (!!1)) xs) + 1 in map (\(as:bs:cs:_) -> as <> T.replicate (max0 - T.length as) " " <> bs <> T.replicate (max1 - T.length bs) " " <> cs) xs cleanDirectory :: Setting -> IO () cleanDirectory setting = do createPluginDirectory dir <- pluginDirectory createDirectoryIfMissing True dir cnt <- listDirectory dir let paths = "miv" : map (unpack . show) (plugins setting) delpath' = [ dir d | d <- cnt, d `notElem` paths ] deldir <- filterM doesDirectoryExist delpath' delfile <- filterM doesFileExist delpath' let delpath = deldir <> delfile if not (null delpath) then do putStrLn "Remove:" mapM_ (putStrLn . pack . (" "<>)) delpath putStr "Really? [y/N] " hFlush stdout c <- getChar when (c == 'y' || c == 'Y') $ do mapM_ removeDirectoryRecursive deldir mapM_ removeFile delfile else putStrLn "Clean." saveScript :: (FilePath, Place, [Text]) -> IO () saveScript (dir, place, code) = do let relname = show place path = dir unpack relname isAllAscii = all (T.all (<='~')) code body = "" : (if isAllAscii then [] else [ "scriptencoding utf-8", "" ]) <> [ "let s:save_cpo = &cpo" , "set cpo&vim" , "" ] <> code <> [ "" , "let &cpo = s:save_cpo" , "unlet s:save_cpo" ] time <- getZonedTime contents <- fileContents path when (contents /= Just body) $ writeFile path $ unlines $ [ "\" Filename: " <> relname , "\" Last Change: " <> show time , "\" Generated by " <> nameversion ] ++ body fileContents :: String -> IO (Maybe [Text]) fileContents path = do eitherFile <- tryJust (guard . isDoesNotExistError) (openFile path ReadMode) case eitherFile of Right file -> do contentLines <- T.lines <$> hGetContents file hClose file return $ Just $ dropWhile ("\" " `T.isPrefixOf`) contentLines Left _ -> return Nothing generatePluginCode :: Setting -> IO () generatePluginCode setting = do dir <- fmap ("miv") pluginDirectory createDirectoryIfMissing True dir -- TODO: remove unused directories and files createDirectoryIfMissing True (dir "plugin") createDirectoryIfMissing True (dir "autoload" "miv") createDirectoryIfMissing True (dir "ftplugin") when (any (not . null . dependedby) (plugins setting)) $ hPutStrLn stderr "`dependedby` is deprecated in favor of `loadafter`" P.mapM_ (saveScript . (\(t, s) -> (dir, t, s))) (vimScriptToList (gatherScript setting)) putStrLn "Success in generating Vim scripts of miv." gatherFtdetectScript :: Setting -> IO () gatherFtdetectScript setting = do dir <- pluginDirectory cleanAndCreateDirectory (dir "miv" "ftdetect") forM_ (plugins setting) $ \plugin -> do let path = rtpName plugin exists <- doesDirectoryExist (dir path "ftdetect") when exists $ do files <- listDirectory (dir path "ftdetect") forM_ files $ \file -> copyFile (dir path "ftdetect" file) (dir "miv" "ftdetect" file) putStrLn "Success in gathering ftdetect scripts." data EachStatus = EachStatus { failed' :: [Plugin] } instance Semigroup EachStatus where EachStatus f <> EachStatus f' = EachStatus (f <> f') instance Monoid EachStatus where mempty = EachStatus [] eachPlugin :: String -> Setting -> IO () eachPlugin command setting = do createPluginDirectory dir <- pluginDirectory status <- mconcat <$> mapM (eachOnePlugin command dir) (plugins setting) unless (null (failed' status)) $ do putStrLn "Error:" mapM_ (putStrLn . (" "<>) . name) (failed' status) eachOnePlugin :: String -> FilePath -> Plugin -> IO EachStatus eachOnePlugin command dir plugin = do let path = dir rtpName plugin exists <- doesDirectoryExist path if not exists then return mempty else do (_, _, _, ph) <- createProcess (proc "sh" ["-c", command]) { cwd = Just path } exitCode <- waitForProcess ph case exitCode of ExitSuccess -> return mempty _ -> return $ mempty { failed' = [plugin] } eachHelp :: IO () eachHelp = mapM_ putStrLn [ "Specify command:", " miv each [command]" ] pathPlugin :: [String] -> Setting -> IO () pathPlugin plugins' setting = do let ps = filter (\p -> rtpName p `elem` plugins' || null plugins') (plugins setting) dir <- pluginDirectory forM_ ps (\plugin -> putStrLn $ pack (dir unpack (show plugin))) mainProgram :: [String] -> IO () mainProgram [] = printUsage mainProgram ['-':arg] = mainProgram [arg] mainProgram ["help"] = printUsage mainProgram ["version"] = putStrLn nameversion mainProgram ["install"] = getSetting >>= updatePlugin Install Nothing mainProgram ["update"] = getSetting >>= updatePlugin Update Nothing mainProgram ["update!"] = getSetting >>= updatePlugin Update (Just []) mainProgram ["update", "!"] = getSetting >>= updatePlugin Update (Just []) mainProgram ["each"] = eachHelp mainProgram ["list"] = getSetting >>= listPlugin mainProgram ["command"] = commandHelp mainProgram ["clean"] = getSetting >>= cleanDirectory mainProgram ["edit"] = getSettingFile >>= maybe (return ()) (($) void . system . ("vim "<>)) mainProgram ["generate"] = getSetting >>= generatePluginCode mainProgram ["ftdetect"] = getSetting >>= gatherFtdetectScript mainProgram ["helptags"] = getSetting >>= generateHelpTags mainProgram ["path"] = getSetting >>= pathPlugin [] mainProgram [arg] = suggestCommand arg mainProgram ("install":args) = getSetting >>= updatePlugin Install (Just args) mainProgram ("update":args) = getSetting >>= updatePlugin Update (Just args) mainProgram ("each":args) = getSetting >>= eachPlugin (unwords args) mainProgram ("path":args) = getSetting >>= pathPlugin args mainProgram (('-':arg):args) = mainProgram (arg:args) mainProgram _ = printUsage main :: IO () main = getArgs >>= mainProgram