packages feed

okf-cli-0.2.0.0: src/Okf/Cli/Fzf.hs

-- | A thin wrapper around the external @fzf@ fuzzy finder.
--
-- fzf is an optional runtime dependency: it is a separate executable the user
-- installs, and every entry point here degrades to a typed failure when it is
-- missing rather than throwing. fzf reads its candidate list from standard
-- input, draws its interface on the terminal, reads keystrokes from
-- @/dev/tty@, and prints the chosen line to standard output.
--
-- Selection never parses display text back into a value. Each line written to
-- fzf is @\<index\>\\t\<display\>@ and fzf is told to hide the first field
-- (@--with-nth 2..@), so the selected line always begins with the integer that
-- indexes back into the caller's values.
module Okf.Cli.Fzf
  ( -- * Availability
    FzfConfig (..),
    detectFzfConfig,
    isFzfAvailable,

    -- * Options
    FzfOpts (..),
    withPrompt,
    withHeader,
    withHeight,
    withPreview,
    withAnsi,
    withNoSort,
    optsToArgs,

    -- * Running
    Candidate (..),
    FzfResult (..),
    runFzf,

    -- * Pure helpers, exposed for tests
    renderCandidateLines,
    parseSelectionIndex,
    shellQuote,
  )
where

import Control.Applicative ((<|>))
import Control.Exception (SomeException, try)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Data.Text.Read qualified as Text.Read
import System.Directory (findExecutable)
import System.Exit (ExitCode (..))
import System.IO (IOMode (ReadMode), hClose, hIsTerminalDevice, hSetEncoding, openFile, stdin, stdout, utf8)
import System.Process
  ( CreateProcess (..),
    StdStream (CreatePipe, Inherit),
    createProcess,
    proc,
    waitForProcess,
  )

-- | Everything needed to decide whether an interactive picker can run, probed
-- once per process.
data FzfConfig = FzfConfig
  { -- | Resolved path to the fzf executable, or the bare name as a fallback.
    fzfBinary :: !FilePath,
    -- | Was an fzf executable found on PATH?
    fzfAvailable :: !Bool,
    -- | Is standard input a terminal?
    stdinIsTerminal :: !Bool,
    -- | Is standard output a terminal? Recorded for diagnostics only: fzf draws
    -- on the terminal device, so a redirected stdout does not prevent a picker.
    stdoutIsTerminal :: !Bool,
    -- | Can @/dev/tty@ be opened? fzf reads keystrokes from there, which is why
    -- a picker still works inside a pipeline.
    ttyAvailable :: !Bool
  }
  deriving stock (Show, Eq)

detectFzfConfig :: IO FzfConfig
detectFzfConfig = do
  found <- findExecutable "fzf"
  stdinTerminal <- hIsTerminalDevice stdin
  stdoutTerminal <- hIsTerminalDevice stdout
  ttyOk <- checkTtyAvailable
  pure
    FzfConfig
      { fzfBinary = fromMaybe "fzf" found,
        fzfAvailable = isJust found,
        stdinIsTerminal = stdinTerminal,
        stdoutIsTerminal = stdoutTerminal,
        ttyAvailable = ttyOk
      }

checkTtyAvailable :: IO Bool
checkTtyAvailable = do
  opened <- try @SomeException (openFile "/dev/tty" ReadMode)
  case opened of
    Left _ -> pure False
    Right handle -> hClose handle >> pure True

-- | An interactive picker is possible when fzf exists and there is a terminal
-- to draw on.
isFzfAvailable :: FzfConfig -> Bool
isFzfAvailable config =
  fzfAvailable config && (stdinIsTerminal config || ttyAvailable config)

-- | fzf options, combined with '<>'. Later options win for the @Maybe@ fields;
-- boolean flags are sticky once set.
data FzfOpts = FzfOpts
  { fzfPrompt :: !(Maybe Text),
    fzfHeader :: !(Maybe Text),
    fzfHeight :: !(Maybe Text),
    fzfPreview :: !(Maybe Text),
    fzfAnsi :: !Bool,
    fzfNoSort :: !Bool
  }
  deriving stock (Show, Eq)

instance Semigroup FzfOpts where
  earlier <> later =
    FzfOpts
      { fzfPrompt = fzfPrompt later <|> fzfPrompt earlier,
        fzfHeader = fzfHeader later <|> fzfHeader earlier,
        fzfHeight = fzfHeight later <|> fzfHeight earlier,
        fzfPreview = fzfPreview later <|> fzfPreview earlier,
        fzfAnsi = fzfAnsi earlier || fzfAnsi later,
        fzfNoSort = fzfNoSort earlier || fzfNoSort later
      }

instance Monoid FzfOpts where
  mempty =
    FzfOpts
      { fzfPrompt = Nothing,
        fzfHeader = Nothing,
        fzfHeight = Nothing,
        fzfPreview = Nothing,
        fzfAnsi = False,
        fzfNoSort = False
      }

withPrompt :: Text -> FzfOpts
withPrompt value = mempty {fzfPrompt = Just value}

withHeader :: Text -> FzfOpts
withHeader value = mempty {fzfHeader = Just value}

withHeight :: Text -> FzfOpts
withHeight value = mempty {fzfHeight = Just value}

withPreview :: Text -> FzfOpts
withPreview value = mempty {fzfPreview = Just value}

withAnsi :: FzfOpts
withAnsi = mempty {fzfAnsi = True}

withNoSort :: FzfOpts
withNoSort = mempty {fzfNoSort = True}

optsToArgs :: FzfOpts -> [String]
optsToArgs opts =
  concat
    [ maybe [] (\value -> ["--prompt", Text.unpack value]) (fzfPrompt opts),
      maybe [] (\value -> ["--header", Text.unpack value]) (fzfHeader opts),
      maybe [] (\value -> ["--height", Text.unpack value]) (fzfHeight opts),
      maybe [] (\value -> ["--preview", Text.unpack value]) (fzfPreview opts),
      ["--ansi" | fzfAnsi opts],
      ["--no-sort" | fzfNoSort opts]
    ]

-- | What the user sees, paired with what the caller gets back.
data Candidate a = Candidate
  { candidateDisplay :: !Text,
    candidateValue :: !a
  }
  deriving stock (Functor)

data FzfResult a
  = FzfSelected !a
  | -- | Nothing to choose from, or fzf matched nothing.
    FzfNoMatch
  | -- | The user pressed Esc or Ctrl-C.
    FzfCancelled
  | FzfError !Text
  deriving stock (Functor, Show, Eq)

-- | Numbered, tab-prefixed input lines. Newlines are stripped from display text
-- because a candidate must occupy exactly one line; tabs are preserved because
-- they separate the display columns.
renderCandidateLines :: [Candidate a] -> [Text]
renderCandidateLines candidates =
  [ Text.pack (show index) <> "\t" <> oneLine (candidateDisplay candidate)
  | (index, candidate) <- zip [0 :: Int ..] candidates
  ]
  where
    oneLine = Text.filter (\character -> character /= '\n' && character /= '\r')

-- | The leading integer of fzf's output line, if there is one.
parseSelectionIndex :: Text -> Maybe Int
parseSelectionIndex output =
  case Text.Read.decimal (Text.takeWhile (/= '\t') (Text.strip firstLine)) of
    Right (index, rest) | Text.null rest -> Just index
    _ -> Nothing
  where
    firstLine = Text.takeWhile (/= '\n') output

-- | Wrap a string in POSIX single quotes so it can be embedded in a shell
-- command line, such as the one given to fzf's @--preview@.
shellQuote :: Text -> Text
shellQuote value = "'" <> Text.replace "'" "'\\''" value <> "'"

-- | Show a menu and return the chosen value. Never throws: every failure is a
-- 'FzfError'. An empty candidate list short-circuits without spawning fzf.
runFzf :: FzfConfig -> FzfOpts -> [Candidate a] -> IO (FzfResult a)
runFzf config opts candidates
  | null candidates = pure FzfNoMatch
  | not (isFzfAvailable config) = pure (FzfError "fzf is not available")
  | otherwise = do
      let values = Map.fromList (zip [0 :: Int ..] (map candidateValue candidates))
          inputLines = renderCandidateLines candidates
          args = ["--select-1", "--delimiter", "\t", "--with-nth", "2.."] <> optsToArgs opts
          spec =
            (proc (fzfBinary config) args)
              { std_in = CreatePipe,
                std_out = CreatePipe,
                std_err = Inherit,
                delegate_ctlc = True
              }
      outcome <- try @SomeException $ do
        (maybeIn, maybeOut, _, processHandle) <- createProcess spec
        case (maybeIn, maybeOut) of
          (Just inHandle, Just outHandle) -> do
            hSetEncoding inHandle utf8
            hSetEncoding outHandle utf8
            mapM_ (Text.IO.hPutStrLn inHandle) inputLines
            hClose inHandle
            selected <- Text.IO.hGetContents outHandle
            exitCode <- waitForProcess processHandle
            pure (exitCode, selected)
          _ -> fail "fzf: standard input and output pipes were not created"
      pure $ case outcome of
        Left exception -> FzfError (Text.pack (show exception))
        Right (ExitSuccess, selected) ->
          case parseSelectionIndex selected >>= (`Map.lookup` values) of
            Just value -> FzfSelected value
            Nothing -> FzfError ("unrecognised fzf selection: " <> Text.strip selected)
        Right (ExitFailure 1, _) -> FzfNoMatch
        Right (ExitFailure 130, _) -> FzfCancelled
        Right (ExitFailure code, _) -> FzfError ("fzf exited with code " <> Text.pack (show code))