diff --git a/CHANGELOG.md b/CHANGELOG.md index d397e8c2..87d71419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Version 0.19.0.0 + +- Add `parserOptionGroup` for grouping Options together, similar to command + groups. Requires the breaking change of adding the `propGroup :: OptGroup` + field to `OptProperties`. + ## Version 0.18.1.0 (29 May 2023) - Change pretty printer layout algorithm used. diff --git a/README.md b/README.md index 8c61e555..8615f901 100644 --- a/README.md +++ b/README.md @@ -748,6 +748,52 @@ main = customExecParser p opts p = prefs showHelpOnEmpty ``` +#### Option groups + +The `parserOptionGroup` function can be used to group options together under +a common heading. For example, if we have: + +```haskell +Args + <$> parseMain + <*> parserOptionGroup "Group A" parseA + <*> parserOptionGroup "Group B" parseB + <*> parseOther +``` + +Then the `--help` page `Available options` will look like: + +``` +Available options: +
+ + +Group A + + +Group B + +``` + +Caveats: + +- Parser groups are like command groups in that groups are listed in creation + order, and duplicate groups are consolidated. + +- Nested groups are indented: + + ```haskell + parserOptionGroup "Group Outer" (parserOptionGroup "Group Inner" parseA) + ``` + + Will render as: + + ``` + Group Outer + - Group Inner + ... + ``` + ### Command groups One experimental feature which may be useful for programs with many diff --git a/optparse-applicative.cabal b/optparse-applicative.cabal index 7da4eda5..03000207 100644 --- a/optparse-applicative.cabal +++ b/optparse-applicative.cabal @@ -39,6 +39,12 @@ extra-source-files: CHANGELOG.md tests/formatting-long-subcommand.err.txt tests/nested.err.txt tests/optional.err.txt + tests/parser_group_all_grouped.err.txt + tests/parser_group_basic.err.txt + tests/parser_group_command_groups.err.txt + tests/parser_group_duplicate_command_groups.err.txt + tests/parser_group_duplicates.err.txt + tests/parser_group_nested.err.txt tests/nested_optional.err.txt tests/subparsers.err.txt @@ -131,6 +137,12 @@ test-suite tests , Examples.Formatting , Examples.Hello , Examples.LongSub + , Examples.ParserGroup.AllGrouped + , Examples.ParserGroup.Basic + , Examples.ParserGroup.CommandGroups + , Examples.ParserGroup.DuplicateCommandGroups + , Examples.ParserGroup.Duplicates + , Examples.ParserGroup.Nested build-depends: base , optparse-applicative diff --git a/src/Options/Applicative.hs b/src/Options/Applicative.hs index d428badf..a76de748 100644 --- a/src/Options/Applicative.hs +++ b/src/Options/Applicative.hs @@ -105,6 +105,7 @@ module Options.Applicative ( completer, idm, mappend, + parserOptionGroup, OptionFields, FlagFields, diff --git a/src/Options/Applicative/Builder.hs b/src/Options/Applicative/Builder.hs index bc12b5f2..6f57b66e 100644 --- a/src/Options/Applicative/Builder.hs +++ b/src/Options/Applicative/Builder.hs @@ -49,6 +49,7 @@ module Options.Applicative.Builder ( completer, idm, mappend, + parserOptionGroup, -- * Readers -- @@ -107,8 +108,8 @@ module Options.Applicative.Builder ( ) where import Control.Applicative -#if __GLASGOW_HASKELL__ <= 802 -import Data.Semigroup hiding (option) +#if __GLASGOW_HASKELL__ < 804 +import Data.Semigroup hiding (Option, option) #endif import Data.String (fromString, IsString) @@ -118,6 +119,7 @@ import Options.Applicative.Common import Options.Applicative.Types import Options.Applicative.Help.Pretty import Options.Applicative.Help.Chunk +import Options.Applicative.Internal (mapParserOptions) -- Readers -- @@ -379,6 +381,56 @@ option r m = mkParser d g rdr crdr = CReader (optCompleter fields) r rdr = OptReader (optNames fields) crdr (optNoArgError fields) +-- | Prepends a group to 'OptProperties'. Nested groups are indented e.g. +-- +-- @ +-- optPropertiesGroup "Group Outer" (optPropertiesGroup "Group Inner" o) +-- @ +-- +-- will render as: +-- +-- @ +-- Group Outer +-- - Group Inner +-- ... +-- @ +optPropertiesGroup :: String -> OptProperties -> OptProperties +optPropertiesGroup g o = o { propGroup = OptGroup (g : oldGroup) } + where + OptGroup oldGroup = propGroup o + +-- | Prepends a group per 'optPropertiesGroup'. +optionGroup :: String -> Option a -> Option a +optionGroup grp o = o { optProps = props' } + where + props' = optPropertiesGroup grp (optProps o) + +-- | Group options together under a common heading in the help text. +-- +-- For example, if we have: +-- +-- > Args +-- > <$> parseMain +-- > <*> parserOptionGroup "Group A" parseA +-- > <*> parserOptionGroup "Group B" parseB +-- > <*> parseOther +-- +-- Then the help page will look like: +-- +-- > Available options: +-- >
+-- > +-- > +-- > Group A +-- > +-- > +-- > Group B +-- > +-- +-- @since 0.19.0.0 +parserOptionGroup :: String -> Parser a -> Parser a +parserOptionGroup g = mapParserOptions (optionGroup g) + -- | Modifier for 'ParserInfo'. newtype InfoMod a = InfoMod { applyInfoMod :: ParserInfo a -> ParserInfo a } diff --git a/src/Options/Applicative/Builder/Internal.hs b/src/Options/Applicative/Builder/Internal.hs index 82d06780..2110067b 100644 --- a/src/Options/Applicative/Builder/Internal.hs +++ b/src/Options/Applicative/Builder/Internal.hs @@ -151,6 +151,7 @@ baseProps = OptProperties , propShowDefault = Nothing , propDescMod = Nothing , propShowGlobal = True + , propGroup = OptGroup [] } mkCommand :: Mod CommandFields a -> (Maybe String, [(String, ParserInfo a)]) diff --git a/src/Options/Applicative/Help/Core.hs b/src/Options/Applicative/Help/Core.hs index ce89070f..eb97416f 100644 --- a/src/Options/Applicative/Help/Core.hs +++ b/src/Options/Applicative/Help/Core.hs @@ -19,19 +19,17 @@ module Options.Applicative.Help.Core ( parserGlobals ) where -import Control.Applicative -import Control.Monad (guard) -import Data.Function (on) -import Data.List (sort, intersperse, groupBy) -import Data.Foldable (any, foldl') -import Data.Maybe (catMaybes, fromMaybe) -#if !MIN_VERSION_base(4,8,0) -import Data.Monoid (mempty) -#endif -#if !MIN_VERSION_base(4,11,0) -import Data.Semigroup (Semigroup (..)) -#endif -import Prelude hiding (any) +import Control.Applicative +import Control.Monad (guard) + +import Data.Foldable (any, foldl') +import Data.Function (on) +import qualified Data.List as List +import Data.List.NonEmpty (NonEmpty ((:|))) +import qualified Data.List.NonEmpty as NE +import Data.Maybe (fromMaybe, catMaybes) + +import Prelude hiding (any) import Options.Applicative.Common import Options.Applicative.Types @@ -50,16 +48,17 @@ safelast :: [a] -> Maybe a safelast = foldl' (const Just) Nothing -- | Generate description for a single option. -optDesc :: ParserPrefs -> OptDescStyle -> ArgumentReachability -> Option a -> (Chunk Doc, Parenthetic) +optDesc :: ParserPrefs -> OptDescStyle -> ArgumentReachability -> Option a -> (OptGroup, Chunk Doc, Parenthetic) optDesc pprefs style _reachability opt = let names = - sort . optionNames . optMain $ opt + List.sort . optionNames . optMain $ opt meta = stringChunk $ optMetaVar opt + grp = propGroup $ optProps opt descs = map (pretty . showOption) names descriptions = - listToChunk (intersperse (descSep style) descs) + listToChunk (List.intersperse (descSep style) descs) desc | prefHelpLongEquals pprefs && not (isEmpty meta) && any isLongName (safelast names) = descriptions <> stringChunk "=" <> meta @@ -86,7 +85,7 @@ optDesc pprefs style _reachability opt = desc modified = maybe id fmap (optDescMod opt) rendered - in (modified, wrapping) + in (grp, modified, wrapping) -- | Generate descriptions for commands. cmdDesc :: ParserPrefs -> Parser a -> [(Maybe String, Chunk Doc)] @@ -118,7 +117,7 @@ briefDesc' showOptional pprefs = wrapOver NoDefault MaybeRequired . foldTree pprefs style . mfilterOptional - . treeMapParser (optDesc pprefs style) + . treeMapParser (\a -> (\(_, x, y) -> (x, y)) . optDesc pprefs style a) where mfilterOptional | showOptional = @@ -152,11 +151,11 @@ foldTree prefs s (MultNode xs) = x = foldr go mempty xs wrapLevel = - mult_wrap xs + multi_wrap xs in (x, wrapLevel) where - mult_wrap [_] = NeverRequired - mult_wrap _ = MaybeRequired + multi_wrap [_] = NeverRequired + multi_wrap _ = MaybeRequired foldTree prefs s (AltNode b xs) = (\x -> (x, NeverRequired)) . fmap groupOrNestLine @@ -193,14 +192,136 @@ globalDesc = optionsDesc True -- | Common generator for full descriptions and globals optionsDesc :: Bool -> ParserPrefs -> Parser a -> Chunk Doc -optionsDesc global pprefs = tabulate (prefTabulateFill pprefs) . catMaybes . mapParser doc +optionsDesc global pprefs p = + vsepChunks + . formatTitle' + . fmap tabulateGroup + . groupByTitle + $ docs where + docs :: [Maybe (OptGroup, (Doc, Doc))] + docs = mapParser doc p + + groupByTitle :: [Maybe (OptGroup, (Doc, Doc))] -> [[(OptGroup, (Doc, Doc))]] + groupByTitle xs = groupFstAll . catMaybes $ xs + + -- NOTE: [Nested group alignment] + -- + -- For nested groups, we want to produce output like: + -- + -- Group 1 + -- --opt-1 INT Option 1 + -- + -- - Group 2 + -- --opt-2 INT Option 2 + -- + -- - Group 3 + -- - opt-3 INT Option 3 + -- + -- That is, we have the following constraints: + -- + -- 1. Nested groups are prefixed with a hyphen '- ', where the hyphen + -- starts on the same column as the parent group. + -- + -- 2. We still want the listed options to be indented twice under the + -- group name, so this means nested options need to be indented + -- again by the standard amount (2), due to the hyphen. + -- + -- 3. Help text should be __globally__ aligned. + + tabulateGroup :: [(OptGroup, (Doc, Doc))] -> (OptGroup, Chunk Doc) + tabulateGroup l@((title,_):_) = + (title, tabulate (prefTabulateFill pprefs) (getGroup <$> l)) + where + -- Handle NOTE: [Nested group alignment] 3. here i.e. indent the + -- right Doc (help text) according to its indention level and + -- global maxGroupLevel. Notice there is an inverse relationship here, + -- as the further the entire group is indented, the less we need to + -- indent the help text. + getGroup :: (OptGroup, (Doc, Doc)) -> (Doc, Doc) + getGroup o@(_, (x, y)) = + let helpIndent = calcOptHelpIndent o + in (x, indent helpIndent y) + + -- Indents the option help text, taking the option's group level and + -- maximum group level into account. + calcOptHelpIndent :: (OptGroup, a) -> Int + calcOptHelpIndent g = + let groupLvl = optGroupToLevel g + in lvlIndent * (maxGroupLevel - groupLvl) + + tabulateGroup [] = (OptGroup [], mempty) + + -- Fold so we can update the (printedGroups :: [String]) arg as we + -- iterate. End with a reverse since we use foldl'. + formatTitle' :: [(OptGroup, Chunk Doc)] -> [Chunk Doc] + formatTitle' = reverse . snd . foldl' formatTitle ([], []) + + formatTitle :: ([String], [Chunk Doc]) -> (OptGroup, Chunk Doc) -> ([String], [Chunk Doc]) + formatTitle (printedGroups, acc) o@(OptGroup groups, opts) = + case parentGroups of + -- No nested groups: No special logic. + [] -> (groupTitle : printedGroups, ((\d -> pretty groupTitle .$. d) <$> opts) : acc) + -- We have at least one parent group title P for current group G: P has + -- already been printed iff it is attached to another (non-grouped) + -- option. In other words, P has __not__ been printed if its only + -- member is another group. + -- + -- The parameter (printedGroups :: [String]) holds all groups that + -- have already been printed. + parents@(_ : _) -> + let groupLvl = optGroupToLevel o + -- indent opts an extra lvlIndent to account for hyphen + indentOpts = indent lvlIndent + + -- new printedGroups is all previous + this and parents. + printedGroups' = groupTitle : parents ++ printedGroups + + parentsWithIndent = zip [0 .. ] parents + + -- docs for unprinted parent title groups + parentDocs = pure $ mkParentDocs printedGroups parentsWithIndent + + -- docs for the current group + thisDocs = + (\d -> lvlIndentNSub1 groupLvl $ (hyphenate groupTitle) .$. indentOpts d) + <$> opts + + allDocs = parentDocs <> thisDocs + + in (printedGroups', allDocs : acc) + where + -- Separate parentGroups and _this_ group, in case we need to also + -- print parent groups. + (parentGroups, groupTitle) = case unsnoc groups of + Nothing -> ([], defTitle) + Just (parentGrps, grp) -> (parentGrps, grp) + + defTitle = + if global + then "Global options:" + else "Available options:" + + maxGroupLevel :: Int + maxGroupLevel = findMaxGroupLevel docs + + -- Finds the maxium OptGroup level. + findMaxGroupLevel :: [Maybe (OptGroup, (Doc, Doc))] -> Int + findMaxGroupLevel = foldl' (\acc -> max acc . optGroupToLevel) 0 . catMaybes + + optGroupToLevel :: (OptGroup, a) -> Int + -- 0 (defTitle) and 1 (custom group name) are handled identically + -- w.r.t indenation (not indented). Hence the subtraction here. + optGroupToLevel (OptGroup [], _) = 0 + optGroupToLevel (OptGroup xs@(_ : _), _) = length xs - 1 + + doc :: ArgumentReachability -> Option a -> Maybe (OptGroup, (Doc, Doc)) doc info opt = do guard . not . isEmpty $ n guard . not . isEmpty $ h - return (extractChunk n, align . extractChunk $ h <> hdef) + return (grp, (extractChunk n, align . extractChunk $ h <> hdef)) where - n = fst $ optDesc pprefs style info opt + (grp, n, _) = optDesc pprefs style info opt h = optHelp opt hdef = Chunk . fmap show_def . optShowDefault $ opt show_def s = parens (pretty "default:" <+> pretty s) @@ -210,6 +331,30 @@ optionsDesc global pprefs = tabulate (prefTabulateFill pprefs) . catMaybes . map descGlobal = global } + -- Prints all parent titles that have not already been printed + -- (i.e. in printedGroups). + mkParentDocs :: [String] -> [(Int, String)] -> Doc + mkParentDocs printedGroups = foldl' g (pretty "") . reverse + where + g :: Doc -> (Int, String) -> Doc + g acc (i, s) = + if s `List.elem` printedGroups + then acc + else + if i == 0 + -- Top-level parent has no special formatting + then pretty s .$. acc + -- Nested parent is hyphenated and possibly indented. + else lvlIndentNSub1 i $ hyphenate s .$. acc + + hyphenate s = pretty ("- " <> s) + + lvlIndentNSub1 :: Int -> Doc -> Doc + lvlIndentNSub1 n = indent (lvlIndent * (n - 1)) + + lvlIndent :: Int + lvlIndent = 2 + errorHelp :: Chunk Doc -> ParserHelp errorHelp chunk = mempty { helpError = chunk } @@ -238,11 +383,11 @@ footerHelp chunk = mempty { helpFooter = chunk } parserHelp :: ParserPrefs -> Parser a -> ParserHelp parserHelp pprefs p = bodyHelp . vsepChunks $ - with_title "Available options:" (fullDesc pprefs p) + fullDesc pprefs p : (group_title <$> cs) where def = "Available commands:" - cs = groupBy ((==) `on` fst) $ cmdDesc pprefs p + cs = groupFstAll $ cmdDesc pprefs p group_title a@((n, _) : _) = with_title (fromMaybe def n) $ @@ -255,9 +400,7 @@ parserHelp pprefs p = parserGlobals :: ParserPrefs -> Parser a -> ParserHelp parserGlobals pprefs p = - globalsHelp $ - (.$.) <$> stringChunk "Global options:" - <*> globalDesc pprefs p + globalsHelp $ globalDesc pprefs p @@ -286,3 +429,61 @@ data Parenthetic | AlwaysRequired -- ^ Parenthesis should always be used. deriving (Eq, Ord, Show) + +-- | Groups on the first element of the tuple. This differs from the simple +-- @groupBy ((==) `on` fst)@ in that non-adjacent groups are __also__ grouped +-- together. For example: +-- +-- @ +-- groupFst = groupBy ((==) `on` fst) +-- +-- let xs = [(1, "a"), (1, "b"), (3, "c"), (2, "d"), (3, "e"), (2, "f")] +-- +-- groupFst xs === [[(1,"a"),(1,"b")],[(3,"c")],[(2,"d")],[(3,"e")],[(2,"f")]] +-- groupFstAll xs === [[(1,"a"),(1,"b")],[(3,"c"),(3,"e")],[(2,"d"),(2,"f")]] +-- @ +-- +-- Notice that the original order is preserved i.e. we do not first sort on +-- the first element. +-- +-- @since 0.19.0.0 +groupFstAll :: Ord a => [(a, b)] -> [[(a, b)]] +groupFstAll = + -- In order to group all (adjacent + non-adjacent) Eq elements together, we + -- sort the list so that the Eq elements are in fact adjacent, _then_ group. + -- We don't want to destroy the original order, however, so we add a + -- temporary index that maintains this original order. The full logic is: + -- + -- 1. Add index i that preserves original order. + -- 2. Sort on tuple's fst. + -- 3. Group by fst. + -- 4. Sort by i, restoring original order. + -- 5. Drop index i. + fmap (NE.toList . dropIdx) + . List.sortOn toIdx + . NE.groupBy ((==) `on` fst') + . List.sortOn fst' + . zipWithIndex + where + dropIdx :: NonEmpty (Int, (a, b)) -> NonEmpty (a, b) + dropIdx = fmap snd + + toIdx :: NonEmpty (Int, (a, b)) -> Int + toIdx ((x, _) :| _) = x + + -- Like fst, ignores our added index + fst' :: (Int, (a, b)) -> a + fst' (_, (x, _)) = x + + zipWithIndex :: [(a, b)] -> [(Int, (a, b))] + zipWithIndex = zip [1 ..] + +unsnoc :: [a] -> Maybe ([a], a) +unsnoc [] = Nothing +unsnoc [x] = Just ([], x) +unsnoc (x:xs) = Just (x:a, b) + where + (a, b) = case unsnoc xs of + Just y -> y + Nothing -> + error "Options.Applicative.Help.Core.unsnoc: impossible" diff --git a/src/Options/Applicative/Internal.hs b/src/Options/Applicative/Internal.hs index b4831447..5b9a3a7f 100644 --- a/src/Options/Applicative/Internal.hs +++ b/src/Options/Applicative/Internal.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE RankNTypes #-} + module Options.Applicative.Internal ( P , MonadP(..) @@ -24,6 +26,8 @@ module Options.Applicative.Internal , cut , () , disamb + + , mapParserOptions ) where import Control.Applicative @@ -36,6 +40,7 @@ import Control.Monad.Trans.Reader (mapReaderT, runReader, runReaderT, Reader, ReaderT, ask) import Control.Monad.Trans.State (StateT, get, put, modify, evalStateT, runStateT) + import Options.Applicative.Types class (Alternative m, MonadPlus m) => MonadP m where @@ -266,3 +271,16 @@ hoistList :: Alternative m => [a] -> m a hoistList = foldr cons empty where cons x xs = pure x <|> xs + +-- | Maps an Option modifying function over the Parser. +-- +-- @since 0.19.0.0 +mapParserOptions :: (forall x. Option x -> Option x) -> Parser a -> Parser a +mapParserOptions f = go + where + go :: forall y. Parser y -> Parser y + go (NilP x) = NilP x + go (OptP o) = OptP (f o) + go (MultP p1 p2) = MultP (go p1) (go p2) + go (AltP p1 p2) = AltP (go p1) (go p2) + go (BindP p1 p2) = BindP (go p1) (\x -> go (p2 x)) diff --git a/src/Options/Applicative/Types.hs b/src/Options/Applicative/Types.hs index 7e11ead4..caaa6a08 100644 --- a/src/Options/Applicative/Types.hs +++ b/src/Options/Applicative/Types.hs @@ -11,6 +11,7 @@ module Options.Applicative.Types ( OptReader(..), OptProperties(..), + OptGroup(..), OptVisibility(..), Backtracking(..), ReadM(..), @@ -147,6 +148,12 @@ data OptVisibility | Visible -- ^ visible both in the full and brief descriptions deriving (Eq, Ord, Show) +-- | Groups for optionals. Can be multiple in the case of nested groups. +-- +-- @since 0.19.0.0 +newtype OptGroup = OptGroup [String] + deriving (Eq, Ord, Show) + -- | Specification for an individual parser option. data OptProperties = OptProperties { propVisibility :: OptVisibility -- ^ whether this flag is shown in the brief description @@ -155,17 +162,23 @@ data OptProperties = OptProperties , propShowDefault :: Maybe String -- ^ what to show in the help text as the default , propShowGlobal :: Bool -- ^ whether the option is presented in global options text , propDescMod :: Maybe ( Doc -> Doc ) -- ^ a function to run over the brief description + , propGroup :: OptGroup + -- ^ optional group(s) + -- + -- @since 0.19.0.0 } instance Show OptProperties where - showsPrec p (OptProperties pV pH pMV pSD pSG _) + showsPrec p (OptProperties pV pH pMV pSD pSG _ pGrp) = showParen (p >= 11) $ showString "OptProperties { propVisibility = " . shows pV . showString ", propHelp = " . shows pH . showString ", propMetaVar = " . shows pMV . showString ", propShowDefault = " . shows pSD . showString ", propShowGlobal = " . shows pSG - . showString ", propDescMod = _ }" + . showString ", propDescMod = _" + . showString ", propGroup = " . shows pGrp + . showString "}" -- | A single option of a parser. data Option a = Option diff --git a/tests/Examples/ParserGroup/AllGrouped.hs b/tests/Examples/ParserGroup/AllGrouped.hs new file mode 100644 index 00000000..a3f718d1 --- /dev/null +++ b/tests/Examples/ParserGroup/AllGrouped.hs @@ -0,0 +1,91 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Examples.ParserGroup.AllGrouped (opts, main) where + +import Data.Semigroup ((<>)) +import Options.Applicative + +-- Tests the help page when every option belongs to some group i.e. there are +-- no top-level options. Notice we put the helper (<**> helper) __inside__ +-- one of the groups, so that it is not a top-level option. +-- +-- Also notice that although we add cmdParser to the same group, it is __not__ +-- rendered as part of this group. This is what we want, as it is an Argument +-- and should not be rendered with the Options. + +data LogGroup = LogGroup + { logPath :: Maybe String, + logVerbosity :: Maybe Int + } + deriving (Show) + +data SystemGroup = SystemGroup + { poll :: Bool, + timeout :: Int + } + deriving (Show) + +data Sample = Sample + { logGroup :: LogGroup, + systemGroup :: SystemGroup, + cmd :: String + } + deriving (Show) + +sample :: Parser Sample +sample = + Sample + <$> parseLogGroup + <*> parseSystemGroup + <*> parseCmd + + where + parseLogGroup = + parserOptionGroup "Logging" $ + LogGroup + <$> optional + ( strOption + ( long "file-log-path" + <> metavar "PATH" + <> help "Log file path" + ) + ) + <*> optional + ( option + auto + ( long "file-log-verbosity" + <> metavar "INT" + <> help "File log verbosity" + ) + ) + <**> helper + + parseSystemGroup = + parserOptionGroup "System Options" $ + SystemGroup + <$> switch + ( long "poll" + <> help "Whether to poll" + ) + <*> option + auto + ( long "timeout" + <> metavar "INT" + <> help "Whether to time out" + ) + + parseCmd = argument str (metavar "Command") + +opts :: ParserInfo Sample +opts = + info + sample + ( fullDesc + <> progDesc "Every option is grouped" + <> header "parser_group.all_grouped - a test for optparse-applicative" + ) + +main :: IO () +main = do + r <- customExecParser (prefs helpShowGlobals) opts + print r diff --git a/tests/Examples/ParserGroup/Basic.hs b/tests/Examples/ParserGroup/Basic.hs new file mode 100644 index 00000000..f68e78d8 --- /dev/null +++ b/tests/Examples/ParserGroup/Basic.hs @@ -0,0 +1,111 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Examples.ParserGroup.Basic (opts, main) where + +import Data.Semigroup ((<>)) +import Options.Applicative + +data LogGroup = LogGroup + { logPath :: Maybe String, + logVerbosity :: Maybe Int + } + deriving (Show) + +data SystemGroup = SystemGroup + { poll :: Bool, + timeout :: Int + } + deriving (Show) + +data Sample = Sample + { hello :: String, + logGroup :: LogGroup, + quiet :: Bool, + systemGroup :: SystemGroup, + verbosity :: Int, + cmd :: String + } + deriving (Show) + +sample :: Parser Sample +sample = + Sample + <$> parseHello + <*> parseLogGroup + <*> parseQuiet + <*> parseSystemGroup + <*> parseVerbosity + <*> parseCmd + + where + parseHello = + strOption + ( long "hello" + <> metavar "TARGET" + <> help "Target for the greeting" + ) + + parseLogGroup = + parserOptionGroup "Logging" $ + LogGroup + <$> optional + ( strOption + ( long "file-log-path" + <> metavar "PATH" + <> help "Log file path" + ) + ) + <*> optional + ( option + auto + ( long "file-log-verbosity" + <> metavar "INT" + <> help "File log verbosity" + ) + ) + + parseQuiet = + switch + ( long "quiet" + <> short 'q' + <> help "Whether to be quiet" + ) + + parseSystemGroup = + parserOptionGroup "System Options" $ + SystemGroup + <$> switch + ( long "poll" + <> help "Whether to poll" + ) + <*> ( option + auto + ( long "timeout" + <> metavar "INT" + <> help "Whether to time out" + ) + ) + + parseVerbosity = + option + auto + ( long "verbosity" + <> short 'v' + <> help "Console verbosity" + ) + + parseCmd = argument str (metavar "Command") + +opts :: ParserInfo Sample +opts = + info + (sample <**> helper) + ( fullDesc + <> progDesc "Shows parser groups" + <> header "parser_group.basic - a test for optparse-applicative" + ) + +main :: IO () +main = do + r <- customExecParser (prefs helpShowGlobals) opts + print r diff --git a/tests/Examples/ParserGroup/CommandGroups.hs b/tests/Examples/ParserGroup/CommandGroups.hs new file mode 100644 index 00000000..9f0679f9 --- /dev/null +++ b/tests/Examples/ParserGroup/CommandGroups.hs @@ -0,0 +1,134 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Examples.ParserGroup.CommandGroups (opts, main) where + +import Data.Semigroup ((<>)) +import Options.Applicative + +data LogGroup = LogGroup + { logPath :: Maybe String, + logVerbosity :: Maybe Int + } + deriving (Show) + +data SystemGroup = SystemGroup + { poll :: Bool, + timeout :: Int + } + deriving (Show) + +data Command + = Delete + | List + | Print + | Query + deriving (Show) + +data Sample = Sample + { hello :: String, + logGroup :: LogGroup, + quiet :: Bool, + systemGroup :: SystemGroup, + verbosity :: Int, + cmd :: Command + } + deriving (Show) + +sample :: Parser Sample +sample = + Sample + <$> parseHello + <*> parseLogGroup + <*> parseQuiet + <*> parseSystemGroup + <*> parseVerbosity + <*> parseCommand + + where + parseHello = + strOption + ( long "hello" + <> metavar "TARGET" + <> help "Target for the greeting" + ) + + parseLogGroup = + parserOptionGroup "Logging" $ + LogGroup + <$> optional + ( strOption + ( long "file-log-path" + <> metavar "PATH" + <> help "Log file path" + ) + ) + <*> optional + ( option + auto + ( long "file-log-verbosity" + <> metavar "INT" + <> help "File log verbosity" + ) + ) + + parseQuiet = + switch + ( long "quiet" + <> short 'q' + <> help "Whether to be quiet" + ) + + parseSystemGroup = + parserOptionGroup "System Options" $ + SystemGroup + <$> switch + ( long "poll" + <> help "Whether to poll" + ) + <*> ( option + auto + ( long "timeout" + <> metavar "INT" + <> help "Whether to time out" + ) + ) + + parseVerbosity = + option + auto + ( long "verbosity" + <> short 'v' + <> help "Console verbosity" + ) + + parseCommand = + hsubparser + ( command "list 2" (info (pure List) $ progDesc "Lists elements") + ) + <|> hsubparser + ( command "list" (info (pure List) $ progDesc "Lists elements") + <> command "print" (info (pure Print) $ progDesc "Prints table") + <> commandGroup "Info commands" + ) + <|> hsubparser + ( command "delete" (info (pure Delete) $ progDesc "Deletes elements") + ) + <|> hsubparser + ( command "query" (info (pure Query) $ progDesc "Runs a query") + <> commandGroup "Query commands" + ) + +opts :: ParserInfo Sample +opts = + info + (sample <**> helper) + ( fullDesc + <> progDesc "Option and command groups" + <> header "parser_group.command_groups - a test for optparse-applicative" + ) + +main :: IO () +main = do + r <- customExecParser (prefs helpShowGlobals) opts + print r diff --git a/tests/Examples/ParserGroup/DuplicateCommandGroups.hs b/tests/Examples/ParserGroup/DuplicateCommandGroups.hs new file mode 100644 index 00000000..56f4c1d0 --- /dev/null +++ b/tests/Examples/ParserGroup/DuplicateCommandGroups.hs @@ -0,0 +1,92 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Examples.ParserGroup.DuplicateCommandGroups (opts, main) where + +import Data.Semigroup ((<>)) +import Options.Applicative + +-- This test demonstrates that duplicate + consecutive groups are merged, +-- while duplicate + non-consecutive groups are not merged. + +data Command + = Delete + | Insert + | List + | Print + | Query + deriving (Show) + +data Sample = Sample + { hello :: String, + quiet :: Bool, + verbosity :: Int, + cmd :: Command + } + deriving (Show) + +sample :: Parser Sample +sample = + Sample + <$> parseHello + <*> parseQuiet + <*> parseVerbosity + <*> parseCommand + + where + parseHello = + strOption + ( long "hello" + <> metavar "TARGET" + <> help "Target for the greeting" + ) + + parseQuiet = + switch + ( long "quiet" + <> short 'q' + <> help "Whether to be quiet" + ) + + parseVerbosity = + option + auto + ( long "verbosity" + <> short 'v' + <> help "Console verbosity" + ) + + parseCommand = + hsubparser + ( command "list" (info (pure List) $ progDesc "Lists elements") + <> commandGroup "Info commands" + ) + <|> hsubparser + ( command "delete" (info (pure Delete) $ progDesc "Deletes elements") + <> commandGroup "Update commands" + ) + <|> hsubparser + ( command "insert" (info (pure Insert) $ progDesc "Inserts elements") + <> commandGroup "Update commands" + ) + <|> hsubparser + ( command "query" (info (pure Query) $ progDesc "Runs a query") + ) + <|> hsubparser + ( command "print" (info (pure Print) $ progDesc "Prints table") + <> commandGroup "Info commands" + ) + +opts :: ParserInfo Sample +opts = + info + (sample <**> helper) + ( fullDesc + <> progDesc "Duplicate consecutive command groups consolidated" + <> header "parser_group.duplicate_command_groups - a test for optparse-applicative" + ) + +main :: IO () +main = do + r <- customExecParser (prefs helpShowGlobals) opts + print r diff --git a/tests/Examples/ParserGroup/Duplicates.hs b/tests/Examples/ParserGroup/Duplicates.hs new file mode 100644 index 00000000..f1b34126 --- /dev/null +++ b/tests/Examples/ParserGroup/Duplicates.hs @@ -0,0 +1,153 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Examples.ParserGroup.Duplicates (opts, main) where + +import Data.Semigroup ((<>)) +import Options.Applicative + +-- NOTE: This is the same structure as ParserGroup.Basic __except__ +-- we have two (non-consecutive) "Logging" groups and two (consecutive) +-- System groups. This test demonstrates two things: +-- +-- 1. Non-consecutive groups are not merged (i.e. we display two "Logging" +-- sections). +-- 2. Consecutive groups are merged (i.e. we display only one "System" group). +-- +-- This is like command groups. + +data LogGroup1 = LogGroup1 + { logPath :: Maybe String, + logVerbosity :: Maybe Int + } + deriving (Show) + +data LogGroup2 = LogGroup2 + { logNamespace :: String + } + deriving (Show) + +data SystemGroup1 = SystemGroup1 + { poll :: Bool, + timeout :: Int + } + deriving (Show) + +newtype SystemGroup2 = SystemGroup2 + { sysFlag :: Bool + } + deriving (Show) + +data Sample = Sample + { hello :: String, + logGroup1 :: LogGroup1, + quiet :: Bool, + systemGroup1 :: SystemGroup1, + systemGroup2 :: SystemGroup2, + logGroup2 :: LogGroup2, + verbosity :: Int, + cmd :: String + } + deriving (Show) + +sample :: Parser Sample +sample = + Sample + <$> parseHello + <*> parseLogGroup1 + <*> parseQuiet + <*> parseSystemGroup1 + <*> parseSystemGroup2 + <*> parseLogGroup2 + <*> parseVerbosity + <*> parseCmd + + where + parseHello = + strOption + ( long "hello" + <> metavar "TARGET" + <> help "Target for the greeting" + ) + + parseLogGroup1 = + parserOptionGroup "Logging" $ + LogGroup1 + <$> optional + ( strOption + ( long "file-log-path" + <> metavar "PATH" + <> help "Log file path" + ) + ) + <*> optional + ( option + auto + ( long "file-log-verbosity" + <> metavar "INT" + <> help "File log verbosity" + ) + ) + + parseQuiet = + switch + ( long "quiet" + <> short 'q' + <> help "Whether to be quiet" + ) + + parseSystemGroup1 = + parserOptionGroup "System" $ + SystemGroup1 + <$> switch + ( long "poll" + <> help "Whether to poll" + ) + <*> option + auto + ( long "timeout" + <> metavar "INT" + <> help "Whether to time out" + ) + + parseSystemGroup2 = + parserOptionGroup "System" $ + SystemGroup2 + <$> switch + ( long "sysFlag" + <> help "Some flag" + ) + + parseLogGroup2 = + parserOptionGroup "Logging" $ + LogGroup2 + <$> + strOption + ( long "log-namespace" + <> metavar "STR" + <> help "Log namespace" + ) + + parseVerbosity = + option + auto + ( long "verbosity" + <> short 'v' + <> help "Console verbosity" + ) + + parseCmd = argument str (metavar "Command") + +opts :: ParserInfo Sample +opts = + info + (sample <**> helper) + ( fullDesc + <> progDesc "Duplicate consecutive groups consolidated" + <> header "parser_group.duplicates - a test for optparse-applicative" + ) + +main :: IO () +main = do + r <- customExecParser (prefs helpShowGlobals) opts + print r + diff --git a/tests/Examples/ParserGroup/Nested.hs b/tests/Examples/ParserGroup/Nested.hs new file mode 100644 index 00000000..05311494 --- /dev/null +++ b/tests/Examples/ParserGroup/Nested.hs @@ -0,0 +1,139 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Examples.ParserGroup.Nested (opts, main) where + +import Data.Semigroup ((<>)) +import Options.Applicative + +-- Nested groups. Demonstrates that group can nest. + +data LogGroup = LogGroup + { logPath :: Maybe String, + systemGroup :: SystemGroup, + logVerbosity :: Maybe Int + } + deriving (Show) + +data SystemGroup = SystemGroup + { poll :: Bool, + deepNested :: Nested2, + timeout :: Int + } + deriving (Show) + +data Nested2 = Nested2 + { nested2Str :: String, + nested3 :: Nested3 + } + deriving (Show) + +newtype Nested3 = Nested3 + { nested3Str :: String + } + deriving (Show) + +data Sample = Sample + { hello :: String, + logGroup :: LogGroup, + quiet :: Bool, + verbosity :: Int, + group2 :: (Int, Int), + cmd :: String + } + deriving (Show) + +sample :: Parser Sample +sample = + Sample + <$> parseHello + <*> parseLogGroup + <*> parseQuiet + <*> parseVerbosity + <*> parseGroup2 + <*> parseCmd + + where + parseHello = + strOption + ( long "hello" + <> metavar "TARGET" + <> help "Target for the greeting" + ) + + parseLogGroup = + parserOptionGroup "First group" $ + parserOptionGroup "Second group" $ + parserOptionGroup "Logging" $ + LogGroup + <$> parseLogPath + <*> parseSystemGroup + <*> parseLogVerbosity + + where + parseLogPath = + optional + ( strOption + ( long "file-log-path" + <> metavar "PATH" + <> help "Log file path" + ) + ) + parseLogVerbosity = + optional + ( option + auto + ( long "file-log-verbosity" + <> metavar "INT" + <> help "File log verbosity" + ) + ) + + parseQuiet = + switch + ( long "quiet" + <> short 'q' + <> help "Whether to be quiet" + ) + + parseSystemGroup = + parserOptionGroup "System Options" $ + SystemGroup + <$> switch (long "poll" <> help "Whether to poll") + <*> parseNested2 + <*> option auto (long "timeout" <> metavar "INT" <> help "Whether to time out") + + parseNested2 = + parserOptionGroup "Nested2" $ + Nested2 + <$> option auto (long "double-nested" <> metavar "STR" <> help "Some nested option") + <*> parseNested3 + + parseNested3 = + parserOptionGroup "Nested3" $ + Nested3 <$> option auto (long "triple-nested" <> metavar "STR" <> help "Another option") + + parseGroup2 :: Parser (Int, Int) + parseGroup2 = parserOptionGroup "Group 2" $ + (,) + <$> parserOptionGroup "G 2.1" (option auto (long "one" <> help "Option 1")) + <*> parserOptionGroup "G 2.2" (option auto (long "two" <> help "Option 2")) + + parseVerbosity = + option auto (long "verbosity" <> short 'v' <> help "Console verbosity") + + parseCmd = + argument str (metavar "Command") + +opts :: ParserInfo Sample +opts = + info + (sample <**> helper) + ( fullDesc + <> progDesc "Nested parser groups" + <> header "parser_group.nested - a test for optparse-applicative" + ) + +main :: IO () +main = do + r <- customExecParser (prefs helpShowGlobals) opts + print r diff --git a/tests/parser_group_all_grouped.err.txt b/tests/parser_group_all_grouped.err.txt new file mode 100644 index 00000000..1a5d975d --- /dev/null +++ b/tests/parser_group_all_grouped.err.txt @@ -0,0 +1,16 @@ +parser_group.all_grouped - a test for optparse-applicative + +Usage: parser_group_all_grouped [--file-log-path PATH] + [--file-log-verbosity INT] [--poll] + --timeout INT Command + + Every option is grouped + +Logging + --file-log-path PATH Log file path + --file-log-verbosity INT File log verbosity + -h,--help Show this help text + +System Options + --poll Whether to poll + --timeout INT Whether to time out diff --git a/tests/parser_group_basic.err.txt b/tests/parser_group_basic.err.txt new file mode 100644 index 00000000..860a5e10 --- /dev/null +++ b/tests/parser_group_basic.err.txt @@ -0,0 +1,21 @@ +parser_group.basic - a test for optparse-applicative + +Usage: parser_group_basic --hello TARGET [--file-log-path PATH] + [--file-log-verbosity INT] [-q|--quiet] [--poll] + --timeout INT (-v|--verbosity ARG) Command + + Shows parser groups + +Available options: + --hello TARGET Target for the greeting + -q,--quiet Whether to be quiet + -v,--verbosity ARG Console verbosity + -h,--help Show this help text + +Logging + --file-log-path PATH Log file path + --file-log-verbosity INT File log verbosity + +System Options + --poll Whether to poll + --timeout INT Whether to time out diff --git a/tests/parser_group_command_groups.err.txt b/tests/parser_group_command_groups.err.txt new file mode 100644 index 00000000..d5414df0 --- /dev/null +++ b/tests/parser_group_command_groups.err.txt @@ -0,0 +1,33 @@ +parser_group.command_groups - a test for optparse-applicative + +Usage: parser_group_command_groups --hello TARGET [--file-log-path PATH] + [--file-log-verbosity INT] [-q|--quiet] + [--poll] --timeout INT (-v|--verbosity ARG) + (COMMAND | COMMAND | COMMAND | COMMAND) + + Option and command groups + +Available options: + --hello TARGET Target for the greeting + -q,--quiet Whether to be quiet + -v,--verbosity ARG Console verbosity + -h,--help Show this help text + +Logging + --file-log-path PATH Log file path + --file-log-verbosity INT File log verbosity + +System Options + --poll Whether to poll + --timeout INT Whether to time out + +Available commands: + list 2 Lists elements + delete Deletes elements + +Info commands + list Lists elements + print Prints table + +Query commands + query Runs a query diff --git a/tests/parser_group_duplicate_command_groups.err.txt b/tests/parser_group_duplicate_command_groups.err.txt new file mode 100644 index 00000000..b8e6ca9c --- /dev/null +++ b/tests/parser_group_duplicate_command_groups.err.txt @@ -0,0 +1,24 @@ +parser_group.duplicate_command_groups - a test for optparse-applicative + +Usage: parser_group_duplicate_command_groups + --hello TARGET [-q|--quiet] (-v|--verbosity ARG) + (COMMAND | COMMAND | COMMAND | COMMAND | COMMAND) + + Duplicate consecutive command groups consolidated + +Available options: + --hello TARGET Target for the greeting + -q,--quiet Whether to be quiet + -v,--verbosity ARG Console verbosity + -h,--help Show this help text + +Available commands: + query Runs a query + +Info commands + list Lists elements + print Prints table + +Update commands + delete Deletes elements + insert Inserts elements diff --git a/tests/parser_group_duplicates.err.txt b/tests/parser_group_duplicates.err.txt new file mode 100644 index 00000000..453ded4c --- /dev/null +++ b/tests/parser_group_duplicates.err.txt @@ -0,0 +1,24 @@ +parser_group.duplicates - a test for optparse-applicative + +Usage: parser_group_duplicates --hello TARGET [--file-log-path PATH] + [--file-log-verbosity INT] [-q|--quiet] [--poll] + --timeout INT [--sysFlag] --log-namespace STR + (-v|--verbosity ARG) Command + + Duplicate consecutive groups consolidated + +Available options: + --hello TARGET Target for the greeting + -q,--quiet Whether to be quiet + -v,--verbosity ARG Console verbosity + -h,--help Show this help text + +Logging + --file-log-path PATH Log file path + --file-log-verbosity INT File log verbosity + --log-namespace STR Log namespace + +System + --poll Whether to poll + --timeout INT Whether to time out + --sysFlag Some flag diff --git a/tests/parser_group_nested.err.txt b/tests/parser_group_nested.err.txt new file mode 100644 index 00000000..c96f5ec3 --- /dev/null +++ b/tests/parser_group_nested.err.txt @@ -0,0 +1,37 @@ +parser_group.nested - a test for optparse-applicative + +Usage: parser_group_nested --hello TARGET [--file-log-path PATH] [--poll] + --double-nested STR --triple-nested STR --timeout INT + [--file-log-verbosity INT] [-q|--quiet] + (-v|--verbosity ARG) --one ARG --two ARG Command + + Nested parser groups + +Available options: + --hello TARGET Target for the greeting + -q,--quiet Whether to be quiet + -v,--verbosity ARG Console verbosity + -h,--help Show this help text + +First group +- Second group + - Logging + --file-log-path PATH Log file path + --file-log-verbosity INT File log verbosity + + - System Options + --poll Whether to poll + --timeout INT Whether to time out + + - Nested2 + --double-nested STR Some nested option + + - Nested3 + --triple-nested STR Another option + +Group 2 +- G 2.1 + --one ARG Option 1 + +- G 2.2 + --two ARG Option 2 diff --git a/tests/test.hs b/tests/test.hs index 4c888dca..2736be54 100644 --- a/tests/test.hs +++ b/tests/test.hs @@ -10,9 +10,16 @@ import qualified Examples.Cabal as Cabal import qualified Examples.Alternatives as Alternatives import qualified Examples.Formatting as Formatting import qualified Examples.LongSub as LongSub +import qualified Examples.ParserGroup.AllGrouped as ParserGroup.AllGrouped +import qualified Examples.ParserGroup.Basic as ParserGroup.Basic +import qualified Examples.ParserGroup.CommandGroups as ParserGroup.CommandGroups +import qualified Examples.ParserGroup.DuplicateCommandGroups as ParserGroup.DuplicateCommandGroups +import qualified Examples.ParserGroup.Duplicates as ParserGroup.Duplicates +import qualified Examples.ParserGroup.Nested as ParserGroup.Nested import Control.Applicative import Control.Monad +import Data.Function (on) import Data.List hiding (group) import Data.List.NonEmpty (NonEmpty ((:|))) import Data.Semigroup hiding (option) @@ -946,6 +953,30 @@ prop_long_command_line_flow = once $ , "to fit the size of the terminal" ]) ) in checkHelpTextWith ExitSuccess (prefs (columns 50)) "formatting-long-subcommand" i ["hello-very-long-sub", "--help"] +prop_parser_group_basic :: Property +prop_parser_group_basic = once $ + checkHelpText "parser_group_basic" ParserGroup.Basic.opts ["--help"] + +prop_parser_group_command_groups :: Property +prop_parser_group_command_groups = once $ + checkHelpText "parser_group_command_groups" ParserGroup.CommandGroups.opts ["--help"] + +prop_parser_group_duplicate_command_groups :: Property +prop_parser_group_duplicate_command_groups = once $ + checkHelpText "parser_group_duplicate_command_groups" ParserGroup.DuplicateCommandGroups.opts ["--help"] + +prop_parser_group_duplicates :: Property +prop_parser_group_duplicates = once $ + checkHelpText "parser_group_duplicates" ParserGroup.Duplicates.opts ["--help"] + +prop_parser_group_all_grouped :: Property +prop_parser_group_all_grouped = once $ + checkHelpText "parser_group_all_grouped" ParserGroup.AllGrouped.opts ["--help"] + +prop_parser_group_nested :: Property +prop_parser_group_nested = once $ + checkHelpText "parser_group_nested" ParserGroup.Nested.opts ["--help"] + --- deriving instance Arbitrary a => Arbitrary (Chunk a)