diff --git a/packages/cli_tools/test/better_command_runner/option_group_usage_text_test.dart b/packages/cli_tools/test/better_command_runner/option_group_usage_text_test.dart new file mode 100644 index 0000000..0117c2f --- /dev/null +++ b/packages/cli_tools/test/better_command_runner/option_group_usage_text_test.dart @@ -0,0 +1,342 @@ +import 'package:cli_tools/better_command_runner.dart'; +import 'package:config/config.dart'; +import 'package:test/test.dart'; + +void main() { + const mockCommandName = 'mock'; + const mockCommandDescription = 'A mock CLI for Option Group Usage Text test.'; + + String buildSeparatorView(final String name) => '\n\n$name\n'; + + String buildSuffix([final Object? suffix = '', final String prefix = '']) => + suffix != null && suffix != '' ? '$prefix${suffix.toString()}' : ''; + + String buildMockArgName([final Object? suffix = '']) => + 'mock-arg${buildSuffix(suffix, '-')}'; + + String buildMockGroupName([final Object? suffix = '']) => + 'Mock Group${buildSuffix(suffix, ' ')}'; + + OptionDefinition buildMockOption( + final String argName, + final String? groupName, { + final bool hide = false, + }) => + FlagOption( + argName: argName, + hide: hide, + group: groupName != null ? OptionGroup(groupName) : null, + helpText: 'Help section for $argName.', + ); + + BetterCommandRunner buildRunner(final List options) => + BetterCommandRunner( + mockCommandName, + mockCommandDescription, + globalOptions: options, + ); + + int howManyMatches(final String pattern, final String target) => + RegExp(pattern).allMatches(target).length; + + group('Group Names (of visible Groups) are rendered as-is', () { + final grouplessOptions = [ + for (var i = 0; i < 5; ++i) buildMockOption(buildMockArgName(i), null), + ]; + final groupedOptions = [ + for (var i = 5; i < 10; ++i) + buildMockOption(buildMockArgName(i), buildMockGroupName(i)), + ]; + final expectation = allOf([ + for (var i = 5; i < 10; ++i) contains(buildMockGroupName(i)), + ]); + test( + 'in the presence of Groupless Options', + () { + expect( + buildRunner(grouplessOptions + groupedOptions).usage, + expectation, + ); + }, + ); + test( + 'in the absence of Groupless Options', + () { + expect( + buildRunner(groupedOptions).usage, + expectation, + ); + }, + ); + }); + + group('Group Names (of invisible Groups) are hidden', () { + var testOptionCount = 0; + var testGroupCount = 0; + final grouplessOptions = [ + for (var i = 0; i < 5; ++i) + buildMockOption(buildMockArgName(++testOptionCount), null), + ]; + final groupedOptions = [ + for (var i = 0; i < 5; ++i) + buildMockOption( + buildMockArgName(++testOptionCount), + buildMockGroupName(++testGroupCount), + ), + ]; + final hiddenGroups = [ + for (var i = 0; i < 5; ++i) + buildMockOption( + buildMockArgName(++testOptionCount), + buildMockGroupName(++testGroupCount), + hide: true, + ), + ]; + var expectationGroupCount = 0; + final expectation = allOf([ + for (var i = 0; i < 5; ++i) + contains(buildMockGroupName(++expectationGroupCount)), + for (var i = 0; i < 5; ++i) + isNot(contains(buildMockGroupName(++expectationGroupCount))), + ]); + test( + 'in the presence of Groupless Options', + () { + expect( + buildRunner(grouplessOptions + groupedOptions + hiddenGroups).usage, + expectation, + ); + }, + ); + test( + 'in the absence of Groupless Options', + () { + expect( + buildRunner(groupedOptions + hiddenGroups).usage, + expectation, + ); + }, + ); + }); + + group('Group Names are properly padded with newlines', () { + final grouplessOptions = [ + for (var i = 0; i < 5; ++i) buildMockOption(buildMockArgName(i), null), + ]; + final groupedOptions = [ + for (var i = 5; i < 10; ++i) + buildMockOption(buildMockArgName(i), buildMockGroupName(i)), + ]; + final expectation = allOf([ + for (var i = 5; i < 10; ++i) + contains(buildSeparatorView(buildMockGroupName(i))), + ]); + test( + 'in the presence of Groupless Options', + () { + expect( + buildRunner(grouplessOptions + groupedOptions).usage, + expectation, + ); + }, + ); + test( + 'in the absence of Groupless Options', + () { + expect( + buildRunner(groupedOptions).usage, + expectation, + ); + }, + ); + }); + + group('Only one Separator per unique Group Name', () { + final grouplessOptions = [ + for (var i = 0; i < 5; ++i) buildMockOption(buildMockArgName(i), null), + ]; + final groupedOptions = [ + for (var i = 5; i < 10; ++i) + buildMockOption(buildMockArgName(i), buildMockGroupName('A')), + for (var i = 10; i < 15; ++i) + buildMockOption(buildMockArgName(i), buildMockGroupName('B')), + for (var i = 15; i < 20; ++i) + buildMockOption(buildMockArgName(i), buildMockGroupName('A')), + ]; + void checkExpectation(final String usage) { + expect( + usage, + stringContainsInOrder([ + buildSeparatorView(buildMockGroupName('A')), + for (var i = 5; i < 10; ++i) buildMockArgName(i), + for (var i = 15; i < 20; ++i) buildMockArgName(i), + buildSeparatorView(buildMockGroupName('B')), + for (var i = 10; i < 15; ++i) buildMockArgName(i), + ]), + ); + expect( + howManyMatches(buildSeparatorView(buildMockGroupName('A')), usage), + equals(1), + ); + expect( + howManyMatches(buildSeparatorView(buildMockGroupName('B')), usage), + equals(1), + ); + } + + test( + 'in the presence of Groupless Options', + () { + checkExpectation(buildRunner(grouplessOptions + groupedOptions).usage); + }, + ); + test( + 'in the absence of Groupless Options', + () { + checkExpectation(buildRunner(groupedOptions).usage); + }, + ); + }); + + test( + 'All Groupless Options are shown before Grouped Options', + () { + final groupedOptions = [ + for (var i = 0; i < 5; ++i) + buildMockOption(buildMockArgName(i), buildMockGroupName(i)), + ]; + final grouplessOptions = [ + for (var i = 5; i < 10; ++i) buildMockOption(buildMockArgName(i), null), + ]; + final expectation = stringContainsInOrder([ + '\n', + for (var i = 5; i < 10; ++i) ...[ + buildMockArgName(i), + '\n', + ], + '\n', + for (var i = 0; i < 5; ++i) ...[ + buildMockArgName(i), + '\n', + ], + '\n', + ]); + expect( + buildRunner(groupedOptions + grouplessOptions).usage, + expectation, + ); + }, + ); + + test( + 'Relative order of all Options within a Group is preserved', + () { + var testOptionCount = 0; + final grouplessOptions = [ + for (var i = 0; i < 5; ++i) + buildMockOption(buildMockArgName(++testOptionCount), null), + ]; + final groupedOptions = [ + for (var i = 0; i < 3; ++i) + for (var j = 0; j < 5; ++j) + buildMockOption( + buildMockArgName(++testOptionCount), + buildMockGroupName(i), + ), + ]; + var expectationOptionCount = 0; + final expectation = stringContainsInOrder([ + '\n', + for (var i = 0; i < 5; ++i) ...[ + buildMockArgName(++expectationOptionCount), + '\n', + ], + '\n', + for (var i = 0; i < 3; ++i) + for (var j = 0; j < 5; ++j) ...[ + buildMockArgName(++expectationOptionCount), + '\n', + ], + '\n', + ]); + expect( + buildRunner(grouplessOptions + groupedOptions).usage, + expectation, + ); + }, + ); + + test( + 'Relative order of all Groups is preserved', + () { + var optionCount = 0; + var testGroupCount = 0; + final grouplessOptions = [ + for (var i = 0; i < 5; ++i) + buildMockOption(buildMockArgName(++optionCount), null), + ]; + final groupedOptions = [ + for (var i = 0; i < 3; ++i) + for (var j = 0; j < 5; ++j) + buildMockOption( + buildMockArgName(++optionCount), + buildMockGroupName(++testGroupCount), + ), + ]; + var expectationGroupCount = 0; + final expectation = stringContainsInOrder([ + for (var i = 0; i < testGroupCount; ++i) + buildSeparatorView(buildMockGroupName(++expectationGroupCount)), + ]); + expect( + buildRunner(grouplessOptions + groupedOptions).usage, + expectation, + ); + }, + ); + + test( + 'Combined Behavior check (Groupless Options, Grouped Options, Hidden Groups)', + () { + final usage = buildRunner([ + buildMockOption('option-1', null), + buildMockOption('option-2', 'Group 1'), + buildMockOption('option-3', 'Group 2'), + buildMockOption('option-4', 'Group 1'), + buildMockOption('option-5', null), + buildMockOption('option-6', 'Group 2'), + buildMockOption('option-7', 'Group 3', hide: true), + buildMockOption('option-8', 'Group 4', hide: true), + buildMockOption('option-9', 'Group 4', hide: true), + buildMockOption('option-10', 'Group 5', hide: true), + buildMockOption('option-11', 'Group 5'), + ]).usage; + expect( + usage, + allOf([ + stringContainsInOrder([ + 'option-1', + 'option-5', + 'Group 1', + 'option-2', + 'option-4', + 'Group 2', + 'option-3', + 'option-6', + 'Group 5', + 'option-11', + ]), + isNot(contains('Group 3')), + isNot(contains('option-7')), + isNot(contains('Group 4')), + isNot(contains('option-8')), + isNot(contains('option-9')), + isNot(contains('option-10')), + ]), + ); + expect(howManyMatches('Group 1', usage), equals(1)); + expect(howManyMatches('Group 2', usage), equals(1)); + expect(howManyMatches('Group 5', usage), equals(1)); + }, + ); +} diff --git a/packages/config/lib/src/config/config_parser.dart b/packages/config/lib/src/config/config_parser.dart index dc74e5c..0c34102 100644 --- a/packages/config/lib/src/config/config_parser.dart +++ b/packages/config/lib/src/config/config_parser.dart @@ -210,7 +210,7 @@ class ConfigParser implements ArgParser { void _addOption(final OptionDefinition opt) { _optionDefinitions.add(opt); // added continuously to the parser so separators are placed correctly: - addOptionsToParser([opt], _parser); + addOptionsToParser([opt], _parser, addGroupSeparators: false); } @override diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index e4d4e15..83b7e6b 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -41,7 +41,7 @@ abstract interface class OptionDefinition { /// An option group allows grouping options together under a common name, /// and optionally provide option value validation on the group as a whole. /// -/// [name] might be used as group header in usage information +/// [name] shall be used as group header in usage information /// so it is recommended to format it appropriately, e.g. `File mode`. /// /// An [OptionGroup] is uniquely identified by its [name]. @@ -722,7 +722,7 @@ void prepareOptionsForParsing( final ArgParser argParser, ) { final argNameOpts = validateOptions(options); - addOptionsToParser(argNameOpts, argParser); + addOptionsToParser(argNameOpts, argParser, addGroupSeparators: true); } Iterable validateOptions( @@ -800,13 +800,69 @@ Iterable validateOptions( return argNameOpts.values; } +/// Adds [argNameOpts] to [argParser]. +/// +/// When [addGroupSeparators] is `true`, +/// - options are grouped by [OptionGroup] and a +/// - separator with the group name is inserted +/// - before each group that has at least one visible option. +/// - Note: +/// - groupless options are added first in their original order +/// - relative order of all options within a group is preserved +/// - relative order of all groups is preserved +/// +/// By default, +/// [addGroupSeparators] is `false` to ensure backwards compatibility. void addOptionsToParser( final Iterable argNameOpts, - final ArgParser argParser, -) { + final ArgParser argParser, { + final bool addGroupSeparators = false, +}) { + // plain option-addition without any group separator logic + if (!addGroupSeparators) { + for (final o in argNameOpts) { + o.option._addToArgParser(argParser); + } + return; + } + + // the following containers are ordered by default i.e. + // preserves insertion-order: + // - Map : https://stackoverflow.com/q/79786585/10251345 + // - List : https://api.dart.dev/dart-core/List-class.html + final optionGroups = >{}; + final grouplessOptions = []; + + // gather all necessary option-group information for (final opt in argNameOpts) { - opt.option._addToArgParser(argParser); + final group = opt.option.group; + if (group != null) { + optionGroups.update( + group, + (final options) => options..add(opt), + ifAbsent: () => [opt], + ); + } else { + grouplessOptions.add(opt); + } } + + // add all groupless-options first (in order) + for (final o in grouplessOptions) { + o.option._addToArgParser(argParser); + } + + // add all explicit groups (in order) + optionGroups.forEach((final group, final groupedOptions) { + // add the group-name-separator only if it has at least one visible option + if (groupedOptions.any((final o) => !o.option.hide)) { + argParser.addSeparator(group.name); + } + // add all options within this group (in order) + for (final o in groupedOptions) { + o.option._addToArgParser(argParser); + } + }); } extension PrepareOptions on Iterable {