opt-env-conf-0.15.0.1: src/OptEnvConf/Completer.hs
{-# LANGUAGE LambdaCase #-}
module OptEnvConf.Completer
( Completer (..),
mkCompleter,
CompletionFinality (..),
CompletionResult (..),
finalResult,
listCompleter,
listIOCompleter,
filePath,
filePathWithExtension,
filePathWithExtensions,
directoryPath,
)
where
import Data.List
import Data.Maybe
import Path
import Path.IO
-- | Whether a shell should consider a completion result to be complete.
--
-- A final result like @file.txt@ means the user is done typing this
-- argument, so the shell should append a trailing space.
--
-- A non-final result like @dir/@ means the user likely wants to keep
-- typing (e.g. to complete a file inside the directory), so the shell
-- should not append a trailing space.
data CompletionFinality
= -- | The completion is complete; the shell should append a trailing space.
CompletionFinal
| -- | The completion may be extended further; no trailing space.
CompletionNotFinal
deriving (Show, Eq, Ord)
data CompletionResult = CompletionResult
{ completionResultValue :: !String,
completionResultFinality :: !CompletionFinality
}
deriving (Show, Eq, Ord)
finalResult :: String -> CompletionResult
finalResult s =
CompletionResult
{ completionResultValue = s,
completionResultFinality = CompletionFinal
}
newtype Completer = Completer {unCompleter :: String -> IO [CompletionResult]}
-- Forward-compatible synonym for the 'Completer' constructor
mkCompleter :: (String -> IO [CompletionResult]) -> Completer
mkCompleter = Completer
listCompleter :: [String] -> Completer
listCompleter ss = listIOCompleter $ pure ss
listIOCompleter :: IO [String] -> Completer
listIOCompleter act = Completer $ \s -> filterPrefix s . map finalResult <$> act
filePath :: Completer
filePath = Completer $ \fp' -> do
here <- getCurrentDir
let (prefix, fp, baseDir) = splitDotDot here fp'
filePathFromDir baseDir prefix fp fp'
filePathFromDir :: Path Abs Dir -> String -> FilePath -> FilePath -> IO [CompletionResult]
filePathFromDir baseDir prefix fp fp' = do
fmap (filterPrefix fp' . map (addPrefix prefix)) $ do
let listDirForgiving d = fromMaybe ([], []) <$> forgivingAbsence (listDirRel d)
(dirsFromParentListing, filesFromParentListing) <- case parseSomeDir fp of
Nothing -> case fp of
[] -> do
(ds, fs) <- listDirRel baseDir
pure
( map fromRelDir $ filter (not . hiddenRel) ds,
map fromRelFile $ filter (not . hiddenRel) fs
)
_ -> pure ([], [])
Just (Abs ad) -> do
(ds, fs) <- listDirForgiving ad
pure
( map (fromAbsDir . (ad </>)) $ filter (not . hiddenRel) ds,
map (fromAbsFile . (ad </>)) $ filter (not . hiddenRel) fs
)
Just (Rel rd) -> do
let ad = baseDir </> rd
(ds, fs) <- listDirForgiving ad
pure
( map (fromRelDir . (rd </>)) $ filter (not . hiddenRel) ds,
map (fromRelFile . (rd </>)) $ filter (not . hiddenRel) fs
)
(dirsFromPartialListing, filesFromPartialListing) <- case parseSomeFile fp of
Nothing ->
if fp == "."
then do
(ds, fs) <- listDirRel baseDir
pure
( map fromRelDir ds,
map fromRelFile fs
)
else pure ([], [])
Just (Abs af) -> do
let dir = parent af
let filterHidden = if hiddenRel (filename af) then id else filter (not . hiddenRel)
(ds, fs) <- listDirForgiving dir
pure
( map (fromAbsDir . (dir </>)) $ filterHidden ds,
map (fromAbsFile . (dir </>)) $ filterHidden fs
)
Just (Rel rf) -> do
let dir = parent rf
let ad = baseDir </> dir
let filterHidden = if hiddenRel rf then id else filter (not . hiddenRel)
(ds, fs) <- listDirForgiving ad
pure
( map (fromRelDir . (dir </>)) $ filterHidden ds,
map (fromRelFile . (dir </>)) $ filterHidden fs
)
pure $
concat
[ map fileResult filesFromPartialListing,
map fileResult filesFromParentListing,
map dirResult dirsFromPartialListing,
map dirResult dirsFromParentListing
]
where
addPrefix :: String -> CompletionResult -> CompletionResult
addPrefix pfx cr = cr {completionResultValue = pfx <> completionResultValue cr}
filePathWithExtension :: String -> Completer
filePathWithExtension ext = filePathWithExtensions [ext]
filePathWithExtensions :: [String] -> Completer
filePathWithExtensions exts = Completer $ \s -> do
results <- unCompleter filePath s
pure $ filter matchesExtension results
where
matchesExtension cr
| "/" `isSuffixOf` completionResultValue cr = True
| otherwise = any (`isSuffixOf` completionResultValue cr) exts
directoryPath :: Completer
directoryPath = Completer $ \fp' -> do
here <- getCurrentDir
let (prefix, fp, baseDir) = splitDotDot here fp'
directoryPathFromDir baseDir prefix fp fp'
directoryPathFromDir :: Path Abs Dir -> String -> FilePath -> FilePath -> IO [CompletionResult]
directoryPathFromDir baseDir prefix fp fp' = do
fmap (filterPrefix fp' . map (addPrefix prefix . dirResult)) $ do
let listDirForgiving d = fromMaybe ([], []) <$> forgivingAbsence (listDirRel d)
dirsFromParentListing <- case parseSomeDir fp of
Nothing -> case fp of
[] -> do
(ds, _) <- listDirRel baseDir
pure (map fromRelDir $ filter (not . hiddenRel) ds)
_ -> pure []
Just (Abs ad) -> do
(ds, _) <- listDirForgiving ad
pure (map (fromAbsDir . (ad </>)) $ filter (not . hiddenRel) ds)
Just (Rel rd) -> do
let ad = baseDir </> rd
(ds, _) <- listDirForgiving ad
pure (map (fromRelDir . (rd </>)) $ filter (not . hiddenRel) ds)
dirsFromPartialListing <- case parseSomeDir fp of
Nothing -> pure []
Just (Abs af) -> do
let dir = parent af
let filterHidden = if hiddenRel (dirname af) then id else filter (not . hiddenRel)
(ds, _) <- listDirForgiving dir
pure (map (fromAbsDir . (dir </>)) $ filterHidden ds)
Just (Rel rf) ->
if fp == "."
then do
(ds, _) <- listDirRel baseDir
pure (map fromRelDir ds)
else do
let dir = parent rf
let ad = baseDir </> dir
let filterHidden = if hiddenRel rf then id else filter (not . hiddenRel)
(ds, _) <- listDirForgiving ad
pure (map (fromRelDir . (dir </>)) $ filterHidden ds)
pure $
concat
[ dirsFromPartialListing,
dirsFromParentListing
]
where
addPrefix :: String -> CompletionResult -> CompletionResult
addPrefix pfx cr = cr {completionResultValue = pfx <> completionResultValue cr}
fileResult :: String -> CompletionResult
fileResult s =
CompletionResult
{ completionResultValue = s,
completionResultFinality = CompletionFinal
}
dirResult :: String -> CompletionResult
dirResult s =
CompletionResult
{ completionResultValue = s,
completionResultFinality = CompletionNotFinal
}
hiddenRel :: Path Rel f -> Bool
hiddenRel p = case toFilePath p of
('.' : _) -> True
_ -> False
stripCurDir :: FilePath -> (FilePath, FilePath)
stripCurDir = \case
'.' : '/' : rest' ->
let (pf, rest) = stripCurDir rest'
in ("./" <> pf, rest)
p -> ("", p)
-- | Split a path at @..@ components.
--
-- Returns @(prefix, remainder, baseDir)@ where:
--
-- * @prefix@ is the literal text up to and including the last @..\/@ (to
-- prepend to completion results)
-- * @remainder@ is the part after that last @..\/@ (to pass to
-- @parseSomeDir@ \/ @parseSomeFile@, which reject @..@)
-- * @baseDir@ is the absolute directory that @remainder@ should be resolved
-- relative to
--
-- If there are no @..@ components, @prefix@ is just the stripped @.\/@ prefix,
-- @remainder@ is the rest, and @baseDir@ is the current directory.
splitDotDot :: Path Abs Dir -> FilePath -> (FilePath, FilePath, Path Abs Dir)
splitDotDot here fp' =
let (curDirPrefix, afterCurDir) = stripCurDir fp'
components = splitOnSlash afterCurDir
-- Find the index after the last "../" component
lastDotDotIdx = case [i | (i, c) <- zip [1 ..] components, c == ".."] of
[] -> 0
ixs -> maximum ixs
in if lastDotDotIdx == 0
then (curDirPrefix, afterCurDir, here)
else
let prefixComponents = take lastDotDotIdx components
remainderComponents = drop lastDotDotIdx components
prefix = curDirPrefix <> concatMap (<> "/") prefixComponents
-- Preserve trailing slash from the original input
trailingSlash
| "/" `isSuffixOf` afterCurDir, not (null remainderComponents) = "/"
| otherwise = ""
remainder = intercalate "/" remainderComponents <> trailingSlash
baseDir = foldl' applyComponent here prefixComponents
in (prefix, remainder, baseDir)
-- | Split a filepath on @\/@ separators, dropping empty segments.
splitOnSlash :: FilePath -> [String]
splitOnSlash [] = []
splitOnSlash s =
let (seg, rest) = break (== '/') s
in case rest of
[] -> [seg | not (null seg)]
_ : rest' -> [seg | not (null seg)] <> splitOnSlash rest'
-- | Apply a single path component to an absolute directory.
applyComponent :: Path Abs Dir -> String -> Path Abs Dir
applyComponent d ".." = parent d
applyComponent d c = case parseRelDir c of
Nothing -> d
Just rd -> d </> rd
filterPrefix :: String -> [CompletionResult] -> [CompletionResult]
filterPrefix s = filter ((s `isPrefixOf`) . completionResultValue)