packages feed

seihou-cli-0.6.0.0: src/Seihou/Fzf.hs

module Seihou.Fzf
  ( -- * Config
    FzfConfig (..),
    detectFzfConfig,
    isFzfUsable,

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

    -- * Candidates and Results
    Candidate (..),
    FzfResult (..),

    -- * Running
    runFzf,
  )
where

import Control.Exception (SomeException, try)
import Control.Lens ((&), (.~), (?~), (^.))
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import GHC.Generics (Generic)
import System.Directory (findExecutable)
import System.Exit (ExitCode (..))
import System.IO (IOMode (..), hClose, hIsTerminalDevice, openFile, stdin)
import System.Process
  ( CreateProcess (..),
    StdStream (..),
    createProcess,
    proc,
    waitForProcess,
  )
import Text.Read (readMaybe)

-- | Runtime configuration for fzf, detected once at CLI startup.
data FzfConfig = FzfConfig
  { binary :: !FilePath,
    available :: !Bool,
    stdinIsTerminal :: !Bool,
    ttyAvailable :: !Bool
  }
  deriving stock (Eq, Generic, Show)

-- | Detect fzf availability and terminal state.
detectFzfConfig :: IO FzfConfig
detectFzfConfig = do
  mFzf <- findExecutable "fzf"
  stdinTerm <- hIsTerminalDevice stdin
  ttyOk <- checkTtyAvailable
  pure
    FzfConfig
      { binary = maybe "fzf" id mFzf,
        available = case mFzf of Nothing -> False; Just _ -> True,
        stdinIsTerminal = stdinTerm,
        ttyAvailable = ttyOk
      }

-- | Check whether /dev/tty can be opened (for fzf in piped contexts).
checkTtyAvailable :: IO Bool
checkTtyAvailable = do
  result <- try @SomeException $ openFile "/dev/tty" ReadMode
  case result of
    Left _ -> pure False
    Right h -> hClose h >> pure True

-- | Whether fzf can be used for interactive selection.
isFzfUsable :: FzfConfig -> Bool
isFzfUsable cfg = cfg ^. #available && (cfg ^. #stdinIsTerminal || cfg ^. #ttyAvailable)

-- | Composable fzf options. Combine with '<>'.
data FzfOpts = FzfOpts
  { prompt :: !(Maybe Text),
    header :: !(Maybe Text),
    preview :: !(Maybe Text),
    height :: !(Maybe Text),
    ansi :: !Bool,
    noSort :: !Bool
  }
  deriving stock (Eq, Generic, Show)

instance Semigroup FzfOpts where
  a <> b =
    FzfOpts
      { -- (<|>) below is local to this instance and carries no fixity
        -- declaration, so it defaults to infixl 9 -- tighter than (^.) at
        -- infixl 8. Hence the parentheses.
        prompt = (b ^. #prompt) <|> (a ^. #prompt),
        header = (b ^. #header) <|> (a ^. #header),
        preview = (b ^. #preview) <|> (a ^. #preview),
        height = (b ^. #height) <|> (a ^. #height),
        ansi = a ^. #ansi || b ^. #ansi,
        noSort = a ^. #noSort || b ^. #noSort
      }
    where
      (<|>) :: Maybe a -> Maybe a -> Maybe a
      (<|>) Nothing y = y
      (<|>) x _ = x

instance Monoid FzfOpts where
  mempty = FzfOpts Nothing Nothing Nothing Nothing False False

withPrompt :: Text -> FzfOpts
withPrompt p = mempty & #prompt ?~ p

withHeader :: Text -> FzfOpts
withHeader h = mempty & #header ?~ h

withHeight :: Text -> FzfOpts
withHeight h = mempty & #height ?~ h

withAnsi :: FzfOpts
withAnsi = mempty & #ansi .~ True

withNoSort :: FzfOpts
withNoSort = mempty & #noSort .~ True

withPreview :: Text -> FzfOpts
withPreview p = mempty & #preview ?~ p

-- | Convert options to fzf CLI arguments.
optsToArgs :: FzfOpts -> [String]
optsToArgs opts =
  concat
    [ maybe [] (\p -> ["--prompt", T.unpack p]) (opts ^. #prompt),
      maybe [] (\h -> ["--header", T.unpack h]) (opts ^. #header),
      maybe [] (\p -> ["--preview", T.unpack p]) (opts ^. #preview),
      maybe [] (\h -> ["--height", T.unpack h]) (opts ^. #height),
      ["--ansi" | opts ^. #ansi],
      ["--no-sort" | opts ^. #noSort]
    ]

-- | A selectable candidate with display text and an associated value.
data Candidate a = Candidate
  { display :: !Text,
    value :: !a
  }
  deriving stock (Functor, Generic)

-- | Result of an fzf selection.
data FzfResult a
  = FzfSelected !a
  | FzfNoMatch
  | FzfCancelled
  | FzfError !Text
  deriving stock (Functor)

-- | Run fzf as a subprocess with the given candidates.
--
-- Uses index-based selection: each candidate line is prefixed with a hidden
-- integer index. The index is parsed back from fzf's output to look up the
-- original value, avoiding any issues with special characters in display text.
runFzf :: FzfConfig -> FzfOpts -> [Candidate a] -> IO (FzfResult a)
runFzf _ _ [] = pure FzfNoMatch
runFzf cfg opts candidates
  | not (isFzfUsable cfg) = pure (FzfError "fzf is not available")
  | otherwise = do
      let indexed = zip [0 :: Int ..] candidates
          valueMap = Map.fromList [(i, c ^. #value) | (i, c) <- indexed]
          inputLines = [show i <> "\t" <> T.unpack (c ^. #display) | (i, c) <- indexed]
          args = ["-1", "--with-nth=2.."] ++ optsToArgs opts

      let processSpec =
            (proc (cfg ^. #binary) args)
              { std_in = CreatePipe,
                std_out = CreatePipe,
                std_err = Inherit,
                delegate_ctlc = True
              }

      result <- try @SomeException $ do
        (Just hIn, Just hOut, _, ph) <- createProcess processSpec
        mapM_ (TIO.hPutStrLn hIn . T.pack) inputLines
        hClose hIn
        outputStr <- TIO.hGetContents hOut
        exitCode <- waitForProcess ph
        pure (exitCode, T.strip outputStr)

      case result of
        Left err -> pure (FzfError (T.pack (show err)))
        Right (exitCode, output) -> case exitCode of
          ExitSuccess -> pure (parseSelection valueMap output)
          ExitFailure 1 -> pure FzfNoMatch
          ExitFailure 130 -> pure FzfCancelled
          ExitFailure n -> pure (FzfError ("fzf exited with code " <> T.pack (show n)))

-- | Parse the index from fzf output and look up the value.
parseSelection :: Map.Map Int a -> Text -> FzfResult a
parseSelection valueMap output =
  let indexText = T.takeWhile (/= '\t') output
   in case readMaybe (T.unpack indexText) of
        Just idx -> case Map.lookup idx valueMap of
          Just val -> FzfSelected val
          Nothing -> FzfError ("invalid index in fzf output: " <> indexText)
        Nothing -> FzfError ("could not parse fzf output: " <> output)