packages feed

hdmenu-0.3.0: src/Core/Select.hs

module Core.Select
    ( -- * Interacting with dmenu
      selectWith       -- :: [String] -> [ByteString] -> String -> IO (Either ProcessError ByteString)

      -- * Interacting with the history file
    , tryRead          -- :: FilePath -> IO Items
    , runUpdate        -- :: ByteString -> Config -> Items -> IO ()

      -- * Interacting with the system
    , getExecutables   -- :: IO [ByteString]
    , evalDirs         -- :: [ByteString] -> IO [ByteString]

      -- * Pretty printing
    , showItems        -- :: Items -> ByteString

      -- * Utility
    , makeNewEntries   -- :: [ByteString] -> Items
    , sortByValues     -- :: Items -> [ByteString]
    ) where

import Core.Parser (getHist)
import Core.Toml (Config (Config, decay, files, histPath, open, term, tty))
import Core.Util hiding ((</>))

import Data.ByteString.Char8 qualified as BS
import Data.Map.Strict       qualified as Map

import Data.Double.Conversion.ByteString (toShortest)
import System.Posix.Directory.Foreign (DirType, dtDir, dtUnknown)
import System.Posix.Directory.Traversals (allDirectoryContents', traverseDirectoryContents)
import System.Posix.Env.ByteString (getEnvDefault)
import System.Posix.FilePath (RawFilePath, (</>))
import System.Posix.Files.ByteString (fileExist, getFileStatus, isDirectory)
import System.Process.ByteString (readCreateProcessWithExitCode)


{- | When a spawned process fails, this type is used to represent the
exit code and @stderr@ output.

See <https://github.com/m0rphism/haskell-dmenu/blob/master/src/DMenu/Run.hs>.
-}
type ProcessError :: Type
type ProcessError = (Int, ByteString)

-- | Do the appropriate things with the user selection and update the
-- history file.
runUpdate
    :: ByteString  -- ^ What the user picked.
    -> Config      -- ^ User config containing things that interest us.
    -> Items       -- ^ Map prior to selection.
    -> IO ()
runUpdate selection cfg@Config{ histPath, decay } itemMap = do
    spawn $ decideSelection selection cfg     -- execute selected command
    histPath `BS.writeFile` showItems update  -- update map and write to file
  where
    -- Update the items based on the users selection.
    update :: Items = Map.adjust (+ 1) selection (Map.map (* decay) itemMap)

{- | Run dmenu with the given command line options and a list of entries
from which the user should choose.

   Originally <https://github.com/m0rphism/haskell-dmenu/blob/master/src/DMenu/Run.hs select>.
-}
selectWith
    :: [String]     -- ^ List of options to give to dmenu.
    -> [ByteString] -- ^ List of executables from which the user should select.
    -> String       -- ^ The dmenu executable.
    -> IO (Either ProcessError ByteString)
    -- ^ The selection made by the user, or a 'ProcessError', if the user
    -- canceled.
selectWith opts entries dmenu = do
    -- Spawn the process with the available options and entries.
    (exitCode, sOut, sErr) <-
        readCreateProcessWithExitCode (proc dmenu opts) (BS.unlines entries)

    pure case exitCode of
        -- Take first (= selected) word or return the error message.
        ExitFailure i -> Left (i, sErr)
        ExitSuccess   -> Right $! BS.takeWhile (/= '\n') sOut

-- | Try to read a file that contains a map.  Return an empty map if the
-- file doesn't exist.
tryRead :: FilePath -> IO Items
tryRead file = ifM (doesFileExist file) (getHist file) (pure mempty)

-- | Get all executables from all dirs in $PATH.
getExecutables :: IO [ByteString]
getExecutables = fmap concat
               . traverse listExecutables
               . BS.split ':'
             =<< getEnvDefault "PATH" ""

-- | List all executables in the given directory. Only try listing the
-- directory if it actually exists; this is necessary, since people may
-- have non-existent things in their path.
listExecutables :: ByteString -> IO [ByteString]
listExecutables dir = ifM (fileExist dir) (getDirContents dir) (pure [])
  where
    getDirContents :: ByteString -> IO [ByteString]
    getDirContents =
        traverseDirectoryContents
            (\xs (dt, name) -> isDirDT dt name <&> \d -> if d then xs else name : xs)
            []

-- | Apply 'evalDir' to some list of file paths.
evalDirs :: [ByteString] -> IO [ByteString]
evalDirs = fmap concat . traverse evalDir

-- | How to eval a directory.
type EvalMode :: Type
data EvalMode = NoRecursion | Recurse

-- | If the given file path is a directory, try to list all of its
-- contents; otherwise just return the file path as is.
evalDir :: ByteString -> IO [ByteString]
evalDir dir = do
    (r, pth) <- shapePath
    isDir <- catch (isDirectory <$> getFileStatus pth)
                   (\(_ :: SomeException) -> pure False)
    if isDir
    then case r of      -- list directory
            NoRecursion -> map (dir </>) <$> listExecutables pth
            Recurse     -> allDirectoryContents' pth
    else pure [dir]
  where
    shapePath :: IO (EvalMode, ByteString)
    shapePath = do
        -- Try to make the path absolute.
        absPath <- do
            home <- getEnvDefault "HOME" ""
            pure $ if "~/" `BS.isPrefixOf` dir then home </> BS.drop 2 dir else dir
        let recurse | "**" `BS.isSuffixOf` absPath = (Recurse, BS.dropEnd 2 absPath)
                    | otherwise                    = (NoRecursion, absPath)
        pure recurse

-- | Check if the given file path is a directory. The given directory
-- type @dt@ is assumed to be the actual one for @fp@.
isDirDT :: DirType -> RawFilePath -> IO Bool
isDirDT dt fp
    | dt == dtDir     = pure True
    | dt == dtUnknown = isDirectory <$> getFileStatus fp
    | otherwise       = pure False

-- | Pretty print our items.
showItems :: Items -> ByteString
showItems = BS.unlines . map showItem . toList
  where
    -- Pretty print a single (application, score) tuple.
    showItem :: (ByteString, Double) -> ByteString
    showItem (k, v) = k <> " " <> toShortest v

-- | Decide what to actually do with the user selection from dmenu.
decideSelection :: ByteString -> Config -> ByteString
decideSelection sel Config{ files, tty, term, open }
    | sel `elem` files = openWith (Open open) sel
    | sel `elem` tty   = openWith (Term term) sel
    | otherwise        = sel

-- | Turn a list into 'Items' and set all starting values to 0.
makeNewEntries :: [ByteString] -> Items
makeNewEntries = Map.fromSet (const 0) . fromList

-- | Sort 'Items' by its values and return the list of keys.
-- This will make often used commands bubble up to the top.
sortByValues :: Items -> [ByteString]
sortByValues = map fst . sortBy (flip compare `on` snd) . toList