diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ea05b0..6b80d0ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Remove `fullDesc` and `briefDesc` builder modifiers – they have not had an effect since version 0.8. +- Add `commandWithAliases` for defining multiple aliases for the same command. + ## Version 0.19.0.0 (03 June 2025) - Add `briefHangPoint` modifier. This allows one to specify the command length diff --git a/optparse-applicative.cabal b/optparse-applicative.cabal index 27a74c73..7342b94d 100644 --- a/optparse-applicative.cabal +++ b/optparse-applicative.cabal @@ -45,6 +45,9 @@ extra-source-files: CHANGELOG.md tests/parser_group_duplicate_command_groups.err.txt tests/parser_group_duplicates.err.txt tests/parser_group_nested.err.txt + tests/prop_cmd_alias_dupes.err.txt + tests/prop_cmd_aliases.err.txt + tests/prop_cmd_dupes.err.txt tests/nested_optional.err.txt tests/subparsers.err.txt @@ -135,7 +138,10 @@ test-suite tests other-modules: Examples.Alternatives , Examples.Cabal + , Examples.CommandAliasDupes + , Examples.CommandDupes , Examples.Commands + , Examples.CommandAliases , Examples.Formatting , Examples.Hello , Examples.LongSub diff --git a/src/Options/Applicative.hs b/src/Options/Applicative.hs index eb94060d..3c0df53d 100644 --- a/src/Options/Applicative.hs +++ b/src/Options/Applicative.hs @@ -99,6 +99,7 @@ module Options.Applicative ( internal, style, command, + commandWithAliases, commandGroup, completeWith, action, diff --git a/src/Options/Applicative/BashCompletion.hs b/src/Options/Applicative/BashCompletion.hs index 22e4ba78..13225f48 100644 --- a/src/Options/Applicative/BashCompletion.hs +++ b/src/Options/Applicative/BashCompletion.hs @@ -17,8 +17,11 @@ import Prelude #if !defined(__MHS__) import Data.Foldable ( asum ) #endif + +import qualified Data.Foldable as Foldable import Data.List ( isPrefixOf ) -import Data.Maybe ( fromMaybe, listToMaybe ) +import Data.List.NonEmpty (NonEmpty) +import Data.Maybe ( fromMaybe, listToMaybe, mapMaybe ) import Options.Applicative.Builder import Options.Applicative.Common @@ -120,7 +123,7 @@ bashCompletionQuery pinfo pprefs richness ws i _ = case runCompletion compl ppre | argumentIsUnreachable reachability -> return [] | otherwise - -> return . with_cmd_help $ filter (is_completion . fst) ns + -> return . with_cmd_help $ filter_completions ns -- When doing enriched completions, add any help specified -- to the completion variables (tab separated). @@ -148,6 +151,30 @@ bashCompletionQuery pinfo pprefs richness ws i _ = case runCompletion compl ppre show_names :: [OptName] -> [String] show_names = filter is_completion . map showOption + -- Filter commands and aliases matching the completion e.g. if we have + -- commands: + -- + -- - ([retry], p1) + -- - ([run, r, go], p2) + -- - ([search], p3) + -- + -- and the user types 'r', we should return + -- + -- [(retry, p1), (run, p2)] + -- + -- If the first entry (command name) is the canonical one, we should + -- favour it in completions, as if we were to provide all options there + -- would be a good chance we'd be forcing the user to tab through and + -- disambiguate between functionally identical options. + -- + -- In zsh and fish we also provide the help doc in the completions, which + -- we don't want to repeat a whole bunch of times. + filter_completions :: [(NonEmpty String, ParserInfo a)] -> [(String, ParserInfo a)] + filter_completions = + let findAlias :: (NonEmpty String, b) -> Maybe (String, b) + findAlias (aliases, painfo) = (\x -> (x, painfo)) <$> Foldable.find is_completion aliases + in mapMaybe findAlias + -- We only want to show a single line in the completion results description. -- If there was a line break, it would come across as a different completion -- possibility. diff --git a/src/Options/Applicative/Builder.hs b/src/Options/Applicative/Builder.hs index ada526d1..52e0bf9d 100644 --- a/src/Options/Applicative/Builder.hs +++ b/src/Options/Applicative/Builder.hs @@ -43,6 +43,7 @@ module Options.Applicative.Builder ( internal, style, command, + commandWithAliases, commandGroup, completeWith, action, @@ -107,6 +108,7 @@ module Options.Applicative.Builder ( ) where import Control.Applicative +import Data.List.NonEmpty (NonEmpty ((:|))) #if __GLASGOW_HASKELL__ < 804 import Data.Semigroup hiding (Option, option) #endif @@ -240,7 +242,28 @@ style x = optionMod $ \p -> -- @ command :: String -> ParserInfo a -> Mod CommandFields a command cmd pinfo = fieldMod $ \p -> - p { cmdCommands = (cmd, pinfo) : cmdCommands p } + p { cmdCommands = (cmd :| [], pinfo) : cmdCommands p } + +-- | Add a command and possible aliases to a subparser option. +-- +-- @ +-- sample :: Parser Sample +-- sample = subparser +-- ( commandWithAliases ("hello" :| ["hi"]) +-- (info hello (progDesc "Print greeting")) +-- <> command "goodbye" +-- (info goodbye (progDesc "Say goodbye")) +-- ) +-- @ +-- +-- > Available commands: +-- > hello, hi Print greeting +-- > goodbye Say goodbye +-- +-- @since 0.20.0.0 +commandWithAliases :: NonEmpty String -> ParserInfo a -> Mod CommandFields a +commandWithAliases aliases pinfo = fieldMod $ \p -> + p { cmdCommands = (aliases, pinfo) : cmdCommands p } -- | Add a description to a group of commands. -- diff --git a/src/Options/Applicative/Builder/Internal.hs b/src/Options/Applicative/Builder/Internal.hs index 2110067b..68984c26 100644 --- a/src/Options/Applicative/Builder/Internal.hs +++ b/src/Options/Applicative/Builder/Internal.hs @@ -26,6 +26,7 @@ module Options.Applicative.Builder.Internal ( import Control.Applicative import Control.Monad (mplus) +import Data.List.NonEmpty (NonEmpty) import Data.Semigroup hiding (Option) import Prelude @@ -42,7 +43,7 @@ data FlagFields a = FlagFields , flagActive :: a } data CommandFields a = CommandFields - { cmdCommands :: [(String, ParserInfo a)] + { cmdCommands :: [(NonEmpty String, ParserInfo a)] , cmdGroup :: Maybe String } data ArgumentFields a = ArgumentFields @@ -154,7 +155,7 @@ baseProps = OptProperties , propGroup = OptGroup [] } -mkCommand :: Mod CommandFields a -> (Maybe String, [(String, ParserInfo a)]) +mkCommand :: Mod CommandFields a -> (Maybe String, [(NonEmpty String, ParserInfo a)]) mkCommand m = (group, cmds) where Mod f _ _ = m diff --git a/src/Options/Applicative/Common.hs b/src/Options/Applicative/Common.hs index e6d6cda0..d58a1938 100644 --- a/src/Options/Applicative/Common.hs +++ b/src/Options/Applicative/Common.hs @@ -1,4 +1,5 @@ {-# LANGUAGE Rank2Types #-} + module Options.Applicative.Common ( -- * Option parsers -- @@ -55,7 +56,9 @@ import Control.Applicative import Control.Monad (guard, mzero, msum, when) import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.State (StateT(..), get, put, runStateT) +import qualified Data.Foldable as F import Data.List (isPrefixOf) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (maybeToList, isJust, isNothing) import Prelude @@ -195,8 +198,16 @@ searchArg prefs arg = where cmdMatches cs - | prefDisambiguate prefs = snd <$> filter (isPrefixOf arg . fst) cs - | otherwise = maybeToList (lookup arg cs) + | prefDisambiguate prefs = snd <$> filter (F.any (isPrefixOf arg) . fst) cs + | otherwise = maybeToList (lookupCmd arg cs) + +lookupCmd :: String -> [(NonEmpty String, a)] -> Maybe a +lookupCmd k = foldr go Nothing + where + go (aliases, y) acc = + if F.any (== k) aliases + then Just y + else acc stepParser :: MonadP m => ParserPrefs -> ArgPolicy -> String -> Parser a -> NondetT (StateT Args m) (Parser a) diff --git a/src/Options/Applicative/Extra.hs b/src/Options/Applicative/Extra.hs index 97ed572d..5bed4931 100644 --- a/src/Options/Applicative/Extra.hs +++ b/src/Options/Applicative/Extra.hs @@ -25,6 +25,7 @@ import Control.Applicative import Control.Monad (void) import Data.Monoid import Data.Foldable (traverse_) +import qualified Data.List.NonEmpty as NE import Prelude import System.Environment (getArgs, getProgName) import System.Exit (exitSuccess, exitWith, ExitCode(..)) @@ -320,7 +321,7 @@ parserFailure pprefs pinfo msg ctx0 = ParserFailure $ \progn -> CmdReader _ ns | argumentIsUnreachable reachability -> [] | otherwise - -> fst <$> ns + -> ns >>= NE.toList . fst _ -> mempty diff --git a/src/Options/Applicative/Help/Core.hs b/src/Options/Applicative/Help/Core.hs index f48600c3..202eaf07 100644 --- a/src/Options/Applicative/Help/Core.hs +++ b/src/Options/Applicative/Help/Core.hs @@ -96,11 +96,16 @@ cmdDesc pprefs = mapParser desc CmdReader gn cmds -> (,) gn $ tabulate (prefTabulateFill pprefs) - [ (pretty nm, align (extractChunk (infoProgDesc cmd))) + [ (pCmds nm, align (extractChunk (infoProgDesc cmd))) | (nm, cmd) <- reverse cmds ] _ -> mempty + pCmds = + pretty + . List.intercalate "," + . NE.toList + -- | Generate a brief help text for a parser. briefDesc :: ParserPrefs -> Parser a -> Chunk Doc briefDesc = briefDesc' True diff --git a/src/Options/Applicative/Types.hs b/src/Options/Applicative/Types.hs index 7f491507..012c1989 100644 --- a/src/Options/Applicative/Types.hs +++ b/src/Options/Applicative/Types.hs @@ -56,6 +56,7 @@ import Control.Monad.Trans.Except (Except, throwE) import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Reader (ReaderT, ask) import qualified Control.Monad.Fail as Fail +import Data.List.NonEmpty (NonEmpty) import Data.Semigroup hiding (Option) import Prelude @@ -273,7 +274,7 @@ data OptReader a -- ^ flag reader | ArgReader (CReader a) -- ^ argument reader - | CmdReader (Maybe String) [(String, ParserInfo a)] + | CmdReader (Maybe String) [(NonEmpty String, ParserInfo a)] -- ^ command reader instance Functor OptReader where diff --git a/tests/Examples/CommandAliasDupes.hs b/tests/Examples/CommandAliasDupes.hs new file mode 100644 index 00000000..aad73c55 --- /dev/null +++ b/tests/Examples/CommandAliasDupes.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE CPP #-} +module Examples.CommandAliasDupes where + +import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.Monoid +import Options.Applicative + +#if __GLASGOW_HASKELL__ <= 702 +(<>) :: Monoid a => a -> a -> a +(<>) = mappend +#endif + +data Sample + = Hello + | Goodbye + deriving (Eq, Show) + +sample :: Parser Sample +sample = subparser + ( commandWithAliases ("hello" :| ["hi", "h"]) + (info (pure Hello) + (progDesc "Print greeting")) + <> commandWithAliases ("goodbye" :| ["g", "h"]) + (info (pure Goodbye) + (progDesc "Say goodbye")) + ) + +run :: Sample -> IO () +run Hello = putStrLn "Hello." +run Goodbye = putStrLn "Goodbye." + +opts :: ParserInfo Sample +opts = info (sample <**> helper) idm + +main :: IO () +main = execParser opts >>= run diff --git a/tests/Examples/CommandAliases.hs b/tests/Examples/CommandAliases.hs new file mode 100644 index 00000000..8b8a17cb --- /dev/null +++ b/tests/Examples/CommandAliases.hs @@ -0,0 +1,63 @@ +{-# LANGUAGE CPP #-} +module Examples.CommandAliases where + +import Data.List +import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.Monoid +import Options.Applicative + +#if __GLASGOW_HASKELL__ <= 702 +(<>) :: Monoid a => a -> a -> a +(<>) = mappend +#endif + +data Sample + = Aux + | Health + | Hello [String] + | Goodbye + deriving (Eq, Show) + +hello :: Parser Sample +hello = Hello <$> many (argument str (metavar "TARGET...")) + +sample :: Parser Sample +sample = subparser + ( commandWithAliases ("hello" :| ["hi"]) + (info hello + (progDesc "Print greeting")) + <> command "goodbye" + (info (pure Goodbye) + (progDesc "Say goodbye")) + ) + <|> subparser + ( command "bonjour" + (info hello + (progDesc "Print greeting")) + <> commandWithAliases ("au-revoir" :| ["adieu", "ciao"]) + (info (pure Goodbye) + (progDesc "Say goodbye")) + <> commandGroup "French commands:" + <> hidden + ) + <|> subparser + ( command "health" + (info (pure Health) + (progDesc "Check health")) + <> command "aux" + (info (pure Aux) + (progDesc "Auxiliary")) + <> commandGroup "Other commands:" + ) + +run :: Sample -> IO () +run Aux = putStrLn "Aux" +run Health = putStrLn "health check" +run (Hello targets) = putStrLn $ "Hello, " ++ intercalate ", " targets ++ "!" +run Goodbye = putStrLn "Goodbye." + +opts :: ParserInfo Sample +opts = info (sample <**> helper) idm + +main :: IO () +main = execParser opts >>= run diff --git a/tests/Examples/CommandDupes.hs b/tests/Examples/CommandDupes.hs new file mode 100644 index 00000000..9d4c1565 --- /dev/null +++ b/tests/Examples/CommandDupes.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE CPP #-} +module Examples.CommandDupes where + +import Data.Monoid +import Options.Applicative + +#if __GLASGOW_HASKELL__ <= 702 +(<>) :: Monoid a => a -> a -> a +(<>) = mappend +#endif + +data Sample + = Hello + | Goodbye + deriving (Eq, Show) + +sample :: Parser Sample +sample = subparser + ( command "hello" + (info (pure Hello) + (progDesc "Print greeting")) + <> command "hello" + (info (pure Goodbye) + (progDesc "Say goodbye")) + ) + +run :: Sample -> IO () +run Hello = putStrLn "Hello." +run Goodbye = putStrLn "Goodbye." + +opts :: ParserInfo Sample +opts = info (sample <**> helper) idm + +main :: IO () +main = execParser opts >>= run diff --git a/tests/prop_cmd_alias_dupes.err.txt b/tests/prop_cmd_alias_dupes.err.txt new file mode 100644 index 00000000..030bb0d4 --- /dev/null +++ b/tests/prop_cmd_alias_dupes.err.txt @@ -0,0 +1,8 @@ +Usage: prop_cmd_alias_dupes COMMAND + +Available options: + -h,--help Show this help text + +Available commands: + hello,hi,h Print greeting + goodbye,g,h Say goodbye diff --git a/tests/prop_cmd_aliases.err.txt b/tests/prop_cmd_aliases.err.txt new file mode 100644 index 00000000..8054f121 --- /dev/null +++ b/tests/prop_cmd_aliases.err.txt @@ -0,0 +1,16 @@ +Usage: prop_cmd_aliases (COMMAND | COMMAND) + +Available options: + -h,--help Show this help text + +Available commands: + hello,hi Print greeting + goodbye Say goodbye + +French commands: + bonjour Print greeting + au-revoir,adieu,ciao Say goodbye + +Other commands: + health Check health + aux Auxiliary diff --git a/tests/prop_cmd_dupes.err.txt b/tests/prop_cmd_dupes.err.txt new file mode 100644 index 00000000..d0761294 --- /dev/null +++ b/tests/prop_cmd_dupes.err.txt @@ -0,0 +1,8 @@ +Usage: prop_cmd_dupes COMMAND + +Available options: + -h,--help Show this help text + +Available commands: + hello Print greeting + hello Say goodbye diff --git a/tests/test.hs b/tests/test.hs index a89a61c7..55adee55 100644 --- a/tests/test.hs +++ b/tests/test.hs @@ -6,6 +6,9 @@ module Main where import qualified Examples.Hello as Hello import qualified Examples.Commands as Commands +import qualified Examples.CommandDupes as CommandDupes +import qualified Examples.CommandAliases as CommandAliases +import qualified Examples.CommandAliasDupes as CommandAliasDupes import qualified Examples.Cabal as Cabal import qualified Examples.Alternatives as Alternatives import qualified Examples.Formatting as Formatting @@ -60,6 +63,14 @@ assertResult x f = case x of counterexample ("unexpected parse error\n" ++ msg) failed CompletionInvoked _ -> counterexample "expected result, got completion" failed +assertCompletions :: (Show a) => ParserResult a -> ([String] -> Property) -> IO Property +assertCompletions result onCompletions = case result of + CompletionInvoked (CompletionResult err) -> do + completions <- lines <$> err "test" + pure $ onCompletions completions + Failure _ -> pure $ counterexample "unexpected failure" failed + Success val -> pure $ counterexample ("unexpected result " ++ show val) failed + assertHasLine :: String -> String -> Property assertHasLine l s = counterexample ("expected line:\n\t" ++ l ++ "\nnot found") $ l `elem` lines s @@ -112,6 +123,148 @@ prop_args_ddash = once $ let result = run Commands.opts ["hello", "foo", "--", "--bar", "--", "baz"] in assertResult result ((===) (Commands.Hello ["foo", "--bar", "--", "baz"])) +prop_cmd_dupes :: Property +prop_cmd_dupes = once $ + checkHelpText "prop_cmd_dupes" CommandDupes.opts ["--help"] + +prop_cmd_dupes_arg :: Property +prop_cmd_dupes_arg = once $ + assertResult (run CommandDupes.opts ["hello"]) (=== CommandDupes.Goodbye) + +prop_cmd_dupes_completion :: Property +prop_cmd_dupes_completion = once . ioProperty $ + let p = CommandDupes.sample + i = info p idm + result = run i ["--bash-completion-index", "0"] + in assertCompletions result (=== expected) + where + expected = + [ "hello", + "hello" + ] + +prop_cmd_dupes_completion_cmd :: Property +prop_cmd_dupes_completion_cmd = once . ioProperty $ + let p = CommandDupes.sample + i = info p idm + result = run i [ "--bash-completion-index", "0" + , "--bash-completion-word", "h" + ] + in assertCompletions result (=== expected) + where + expected = + [ "hello", + "hello" + ] + +prop_cmd_aliases :: Property +prop_cmd_aliases = once $ + checkHelpText "prop_cmd_aliases" CommandAliases.opts ["--help"] + +prop_cmd_aliases_arg :: Property +prop_cmd_aliases_arg = once $ + conjoin [ assertResult (run CommandAliases.opts ["hello", "b", "c"]) (=== CommandAliases.Hello ["b", "c"]) + , assertResult (run CommandAliases.opts ["hi", "b", "c"]) (=== CommandAliases.Hello ["b", "c"]) + , assertResult (run CommandAliases.opts ["goodbye"]) (=== CommandAliases.Goodbye) + , assertResult (run CommandAliases.opts ["bonjour", "a", "b"]) (=== CommandAliases.Hello ["a","b"]) + , assertResult (run CommandAliases.opts ["au-revoir"]) (=== CommandAliases.Goodbye) + , assertResult (run CommandAliases.opts ["ciao"]) (=== CommandAliases.Goodbye) + , assertResult (run CommandAliases.opts ["adieu"]) (=== CommandAliases.Goodbye) + , assertResult (run CommandAliases.opts ["health"]) (=== CommandAliases.Health) + , assertResult (run CommandAliases.opts ["aux"]) (=== CommandAliases.Aux) + ] + +prop_cmd_aliases_completion :: Property +prop_cmd_aliases_completion = once . ioProperty $ + let p = CommandAliases.sample + i = info p idm + result = run i ["--bash-completion-index", "0"] + in case result of + CompletionInvoked (CompletionResult err) -> do + completions <- lines <$> err "test" + return $ expected === completions + Failure _ -> return $ counterexample "unexpected failure" failed + Success val -> return $ counterexample ("unexpected result " ++ show val) failed + where + expected = + ["goodbye","hello","au-revoir","bonjour","aux","health"] + +prop_cmd_aliases_completion_alias1 :: Property +prop_cmd_aliases_completion_alias1 = once . ioProperty $ + let p = CommandAliases.sample + i = info p idm + result = run i [ "--bash-completion-index", "0" + , "--bash-completion-word", "he" + ] + in assertCompletions result (=== expected) + where + expected = + [ "hello" + , "health" + ] + +prop_cmd_aliases_completion_alias2 :: Property +prop_cmd_aliases_completion_alias2 = once . ioProperty $ + let p = CommandAliases.sample + i = info p idm + result = run i [ "--bash-completion-index", "0" + , "--bash-completion-word", "a" + ] + in assertCompletions result (=== expected) + where + expected = + ["au-revoir","aux"] + +prop_cmd_aliases_completion_alias3 :: Property +prop_cmd_aliases_completion_alias3 = once . ioProperty $ + let p = CommandAliases.sample + i = info p idm + result = run i [ "--bash-completion-index", "0" + , "--bash-completion-word", "au" + ] + in assertCompletions result (=== expected) + where + expected = + [ "au-revoir" + , "aux" + ] + +prop_cmd_alias_dupes :: Property +prop_cmd_alias_dupes = once $ + checkHelpText "prop_cmd_alias_dupes" CommandAliasDupes.opts ["--help"] + +prop_cmd_alias_dupes_arg :: Property +prop_cmd_alias_dupes_arg = once $ + conjoin [ assertResult (run CommandAliasDupes.opts ["hello"]) (=== CommandAliasDupes.Hello) + , assertResult (run CommandAliasDupes.opts ["hi"]) (=== CommandAliasDupes.Hello) + , assertResult (run CommandAliasDupes.opts ["h"]) (=== CommandAliasDupes.Goodbye) + , assertResult (run CommandAliasDupes.opts ["goodbye"]) (=== CommandAliasDupes.Goodbye) + , assertResult (run CommandAliasDupes.opts ["g"]) (=== CommandAliasDupes.Goodbye) + ] + +prop_cmd_aliases_dupes_completion :: Property +prop_cmd_aliases_dupes_completion = once . ioProperty $ + let p = CommandAliasDupes.sample + i = info p idm + result = run i [ "--bash-completion-index", "0" + ] + in assertCompletions result (=== expected) + where + expected = + ["goodbye","hello"] + +prop_cmd_aliases_dupes_completion_alias1 :: Property +prop_cmd_aliases_dupes_completion_alias1 = once . ioProperty $ + let p = CommandAliasDupes.sample + i = info p idm + result = run i [ "--bash-completion-index", "0" + , "--bash-completion-word", "h" + ] + in assertCompletions result (=== expected) + where + expected = + ["h","hello"] + prop_alts :: Property prop_alts = once $ let result = run Alternatives.opts ["-b", "-a", "-b", "-a", "-a", "-b"]