packages feed

ymonad-0.1.0.0: src/YMonad/Prompt/Internal.hs

module YMonad.Prompt.Internal (
  choosePrompt,
  getCommands,
  getSshHosts,
  getMans,
  getCommandOutput,
  runInConfiguredTerminal,
  split,
) where

import Control.Concurrent (forkIO)
import Data.Char (isDigit, isSpace)
import Data.List (dropWhileEnd)
import Data.String qualified as String
import System.Directory (doesDirectoryExist, doesFileExist, executable, findExecutable, getDirectoryContents, getHomeDirectory, getPermissions, listDirectory)
import System.Exit (ExitCode (..))
import System.FilePath (dropExtensions, takeFileName, (</>))
import System.IO qualified as SIO
import System.IO.Error (catchIOError)
import System.Process (createProcess, proc, readCreateProcessWithExitCode, shell, waitForProcess)

import YMonad.Config (YConfig (..))
import YMonad.Prompt (YPConfig (..))

choosePrompt :: YPConfig -> String -> [String] -> IO (Maybe String)
choosePrompt xpConfig promptLabel rawChoices = do
  resolved <- resolvePromptProgram xpConfig promptLabel
  case resolved of
    Nothing -> pure Nothing
    Just (programPath, args) -> do
      let choices = ordNub rawChoices
          stdinPayload = if null choices then "\n" else String.unlines choices
      (exitCode, stdoutText, _stderrText) <- readCreateProcessWithExitCode (proc programPath args) stdinPayload
      case exitCode of
        ExitSuccess -> pure (nonEmptySelection stdoutText)
        ExitFailure _ -> pure Nothing

getCommands :: IO [String]
getCommands = do
  pathValue <- fromMaybe "" <$> lookupEnv "PATH"
  let directories = filter (not . null) (split ':' pathValue)
  (fmap (ordNub . concat) . forM directories) $ \dir -> do
    exists <- doesDirectoryExist dir
    if not exists
      then pure []
      else do
        names <- catchIOError (listDirectory dir) (const (pure []))
        filterM (isExecutableFile . (dir </>)) names

getSshHosts :: IO [String]
getSshHosts = do
  localHosts <- getLocalSshHosts
  globalHosts <- getGlobalSshHosts
  pure (ordNub (localHosts ++ globalHosts))

getMans :: IO [String]
getMans = do
  pathsText <- do
    p1 <- getCommandOutput "manpath -g 2>/dev/null"
    p2 <- getCommandOutput "manpath 2>/dev/null"
    pure (intercalate ":" (String.lines (p1 ++ p2)))
  let sections = ["man" <> show n | n <- [(1 :: Int) .. 9]]
      directories = [baseDir </> section | baseDir <- split ':' pathsText, not (null baseDir), section <- sections]
  nested <- forM (ordNub directories) $ \dir -> do
    exists <- doesDirectoryExist dir
    if exists
      then map dropExtensions <$> getDirectoryContents dir
      else pure []
  pure (filter validEntry (ordNub (concat nested)))

getCommandOutput :: String -> IO String
getCommandOutput cmd = do
  (exitCode, stdoutText, _stderrText) <- readCreateProcessWithExitCode (shell cmd) ""
  pure $ case exitCode of
    ExitSuccess -> stdoutText
    ExitFailure _ -> ""

runInConfiguredTerminal :: YConfig -> YPConfig -> String -> IO ()
runInConfiguredTerminal yConfig xpConfig commandText = do
  let terminalCommand = fromMaybe (terminal yConfig) (promptTerminal xpConfig)
      fullCommand = terminalCommand <> " -e sh -lc " <> shellQuote commandText
  (_, _, _, processHandle) <- createProcess (shell fullCommand)
  void (forkIO (void (waitForProcess processHandle)))

resolvePromptProgram :: YPConfig -> String -> IO (Maybe (FilePath, [String]))
resolvePromptProgram xpConfig promptLabel = do
  resolvedProgram <- case promptProgram xpConfig of
    Just explicit -> pure (Just explicit)
    Nothing -> firstJustM findExecutable ["wmenu", "bemenu", "fuzzel", "tofi", "rofi", "dmenu"]
  pure $ fmap (\programPath -> (programPath, promptArgs (takeFileName programPath) xpConfig promptLabel)) resolvedProgram

promptArgs :: FilePath -> YPConfig -> String -> [String]
promptArgs programName xpConfig promptLabel =
  case programName of
    "wmenu" -> promptProgramArgs xpConfig ++ ignoreCaseArg ++ listLinesArg ++ ["-p", promptLabel]
    "bemenu" -> promptProgramArgs xpConfig ++ ignoreCaseArg ++ ["-p", promptLabel]
    "fuzzel" -> promptProgramArgs xpConfig ++ ["--dmenu", "--prompt", promptLabel]
    "tofi" -> promptProgramArgs xpConfig ++ ["--prompt-text", promptLabel]
    "rofi" -> promptProgramArgs xpConfig ++ ["-dmenu", "-p", promptLabel] ++ ignoreCaseArg
    _ -> promptProgramArgs xpConfig ++ ["-p", promptLabel] ++ ignoreCaseArg
  where
    ignoreCaseArg = ["-i" | not (promptCaseSensitive xpConfig) && programName `elem` ["wmenu", "bemenu", "rofi", "dmenu"]]
    listLinesArg = maybe [] (\lineCount -> ["-l", show lineCount]) (promptListLines xpConfig)

nonEmptySelection :: String -> Maybe String
nonEmptySelection output =
  let stripped = strip output
   in if null stripped then Nothing else Just stripped

{-
ordNub :: (Ord a) => [a] -> [a]
ordNub = go S.empty
  where
    go _seen [] = []
    go seen (x : xs)
      | S.member x seen = go seen xs
      | otherwise = x : go (S.insert x seen) xs
-}

split :: (Eq a) => a -> [a] -> [[a]]
split delimiter = go
  where
    go [] = [[]]
    go (x : xs)
      | x == delimiter = [] : go xs
      | otherwise =
          case go xs of
            [] -> [[x]]
            (segment : segments) -> (x : segment) : segments

strip :: String -> String
strip = dropWhileEnd isSpace . dropWhile isSpace

shellQuote :: String -> String
shellQuote value = "'" <> concatMap escapeChar value <> "'"
  where
    escapeChar '\'' = "'\\''"
    escapeChar ch = [ch]

validEntry :: String -> Bool
validEntry entry =
  not (null entry)
    && entry /= "."
    && entry /= ".."

isExecutableFile :: FilePath -> IO Bool
isExecutableFile path = do
  exists <- doesFileExist path
  if not exists
    then pure False
    else do
      permissions <- getPermissions path
      pure (executable permissions)

getLocalSshHosts :: IO [String]
getLocalSshHosts = do
  homeDir <- getHomeDirectory
  knownHosts <- sshHostsFromKnownHosts (homeDir </> ".ssh" </> "known_hosts")
  configHosts <- sshHostsFromConfig (homeDir </> ".ssh" </> "config")
  pure (knownHosts ++ configHosts)

getGlobalSshHosts :: IO [String]
getGlobalSshHosts = do
  envKnownHosts <- lookupEnv "SSH_KNOWN_HOSTS"
  let candidates = maybeToList envKnownHosts ++ ["/usr/local/etc/ssh/ssh_known_hosts", "/usr/local/etc/ssh_known_hosts", "/etc/ssh/ssh_known_hosts", "/etc/ssh_known_hosts"]
  existing <- filterM doesFileExist candidates
  case existing of
    [] -> pure []
    firstKnownHosts : _rest -> sshHostsFromKnownHosts firstKnownHosts

sshHostsFromKnownHosts :: FilePath -> IO [String]
sshHostsFromKnownHosts path = do
  exists <- doesFileExist path
  if not exists
    then pure []
    else do
      contents <- catchIOError (SIO.readFile path) (const (pure ""))
      pure
        [ getWithPort host
        | line <- String.lines contents
        , nonComment line
        , firstField <- take 1 (String.words line)
        , host <- split ',' firstField
        , not (null host)
        , not ("|" `isPrefixOf` host)
        ]

sshHostsFromConfig :: FilePath -> IO [String]
sshHostsFromConfig path = do
  exists <- doesFileExist path
  if not exists
    then pure []
    else do
      contents <- catchIOError (SIO.readFile path) (const (pure ""))
      pure
        [ candidate
        | ws <- map String.words (String.lines contents)
        , isHostDirective ws
        , candidate <- drop 1 ws
        , isConcreteHost candidate
        ]

isHostDirective :: [String] -> Bool
isHostDirective (directive : _values) = directive == "Host"
isHostDirective _ = False

isConcreteHost :: String -> Bool
isConcreteHost value = not (null value) && not (any (`elem` ['*', '?']) value) && value /= "!"

getWithPort :: String -> String
getWithPort host =
  case break (== ':') host of
    (baseHost, []) -> baseHost
    (baseHost, suffix) ->
      case suffix of
        ':' : portDigits | all isDigit portDigits -> baseHost
        _other -> host

nonComment :: String -> Bool
nonComment line =
  case dropWhile isSpace line of
    [] -> False
    ('#' : _) -> False
    _ -> True

firstJustM :: (Monad m) => (a -> m (Maybe b)) -> [a] -> m (Maybe b)
firstJustM _ [] = pure Nothing
firstJustM f (x : xs) = do
  result <- f x
  case result of
    Just value -> pure (Just value)
    Nothing -> firstJustM f xs