diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index 4e31369..9ea518a 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -8,6 +8,8 @@ import 'package:config/config.dart'; import 'completion/completion_command.dart'; import 'completion/completion_tool.dart' show CompletionScript; +import 'help_command_workaround.dart' show HelpCommandWorkaround; + /// A function type for executing code before running a command. typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); @@ -72,7 +74,7 @@ class BetterCommandRunner /// The environment variables used for configuration resolution. final Map envVariables; - /// The gloabl option definitions. + /// The global option definitions. late final List _globalOptions; Configuration? _globalConfiguration; @@ -346,6 +348,13 @@ class BetterCommandRunner await _onBeforeRunCommand?.call(this); try { + // an edge case regarding `help -h` + final helpProxy = HelpCommandWorkaround(runner: this); + if (helpProxy.isUsageOfHelpCommandRequested(topLevelResults)) { + messageOutput?.logUsage(helpProxy.usage); + return null; + } + // normal cases return await super.runCommand(topLevelResults); } on UsageException catch (e) { messageOutput?.logUsageException(e); diff --git a/packages/cli_tools/lib/src/better_command_runner/help_command_workaround.dart b/packages/cli_tools/lib/src/better_command_runner/help_command_workaround.dart new file mode 100644 index 0000000..68bb3a3 --- /dev/null +++ b/packages/cli_tools/lib/src/better_command_runner/help_command_workaround.dart @@ -0,0 +1,64 @@ +import 'package:args/args.dart' show ArgResults; +import 'package:args/command_runner.dart' show Command, CommandRunner; + +/// A dummy to replicate the usage-text of upstream private `HelpCommand`. +/// +/// It is intended to be used for an internal patch only and is +/// intentionally not part of the public API of this package. +final class HelpCommandWorkaround extends Command { + HelpCommandWorkaround({required this.runner}); + + /// Checks whether the main command seeks the + /// usage-text for `help` command. + /// + /// Specifically, for a program `mock`, it checks + /// whether [topLevelResults] is of the form: + /// * `mock help -h` + /// * `mock help help` + bool isUsageOfHelpCommandRequested(final ArgResults topLevelResults) { + // check whether `help` command is chosen + final topLevelCommand = topLevelResults.command; + if (topLevelCommand == null) { + return false; + } + if (topLevelCommand.name != name) { + return false; + } + final helpCommand = topLevelCommand; + // check whether it's allowed to get the usage-text for `help` + if (!helpCommand.options.contains(name)) { + // extremely rare scenario (e.g. if `package:args` has a breaking change) + // fortunately, corresponding test-cases shall fail as it + // - tests the current behavior (e.g. args = ['help', '-h']) + // - notifies the publisher(s) of this breaking change + return false; + } + // case: `mock help -h` + if (helpCommand.flag(name)) { + return true; + } + // case: `mock help help` + if ((helpCommand.arguments.contains(name))) { + return true; + } + // aside: more cases may be added if necessary in future + return false; + } + + @override + final CommandRunner runner; + + @override + final name = 'help'; + + @override + String get description => + 'Display help information for ${runner.executableName}.'; + + @override + String get invocation => '${runner.executableName} $name [command]'; + + @override + Never run() => throw UnimplementedError( + 'This class is meant to only obtain the Usage Text for `$name` command'); +} diff --git a/packages/cli_tools/test/better_command_runner/help_command_usage_text_test.dart b/packages/cli_tools/test/better_command_runner/help_command_usage_text_test.dart new file mode 100644 index 0000000..259bc42 --- /dev/null +++ b/packages/cli_tools/test/better_command_runner/help_command_usage_text_test.dart @@ -0,0 +1,168 @@ +import 'dart:async' show ZoneSpecification, runZoned; + +import 'package:args/command_runner.dart' show CommandRunner, UsageException; +import 'package:cli_tools/better_command_runner.dart' + show BetterCommandRunner, MessageOutput; +import 'package:test/test.dart'; + +void main() => _runTests(); + +const _exeName = 'mock'; +const _exeDescription = 'A mock command to test `help -h` with MessageOutput.'; +const _moMockText = 'SUCCESS: `MessageOutput.usageLogger`'; +const _moExceptionText = 'SUCCESS: `MessageOutput.usageExceptionLogger`'; +const _invalidOption = '-z'; + +CommandRunner _buildUpstreamRunner() => CommandRunner( + _exeName, + _exeDescription, + ); + +BetterCommandRunner _buildBetterRunner() => BetterCommandRunner( + _exeName, + _exeDescription, + messageOutput: MessageOutput( + usageLogger: (final usage) { + print(_moMockText); + print(usage); + }, + usageExceptionLogger: (final exception) { + print(_moExceptionText); + print(exception.message); + print(exception.usage); + }, + ), + ); + +void _runTests() { + group('Given a BetterCommandRunner with custom MessageOutput', () { + for (final args in [ + ['help', '-h'], + ['help', 'help'], + ['help', '-h', 'help'], + ['help', '-h', 'help', '-h'], + ['help', '-h', 'help', '-h', 'help'], + ['help', '-h', 'help', '-h', 'help', '-h'], + ]) { + _testsForValidHelpUsageRequests(args); + } + for (final args in [ + ['help', '-h', _invalidOption], + ['help', 'help', _invalidOption], + ['help', _invalidOption, 'help'], + ['help', 'help', '-h', _invalidOption], + ['help', _invalidOption, 'help', '-h', _invalidOption], + ]) { + _testsForInvalidHelpUsageRequests(args); + } + }); +} + +void _testsForValidHelpUsageRequests(final List args) { + group('when $args is received', () { + final betterRunnerOutput = StringBuffer(); + final upstreamRunnerOutput = StringBuffer(); + late final Object? betterRunnerExitCode; + late final Object? upstreamRunnerExitCode; + setUpAll(() async { + betterRunnerExitCode = await runZoned( + () async => await _buildBetterRunner().run(args), + zoneSpecification: ZoneSpecification( + print: (final _, final __, final ___, final String line) { + betterRunnerOutput.writeln(line); + }), + ); + upstreamRunnerExitCode = await runZoned( + () async => await _buildUpstreamRunner().run(args), + zoneSpecification: ZoneSpecification( + print: (final _, final __, final ___, final String line) { + upstreamRunnerOutput.writeln(line); + }), + ); + }); + test('then MessageOutput is not bypassed', () { + expect( + betterRunnerOutput.toString(), + contains(_moMockText), + ); + }); + test('then it can subsume upstream HelpCommand output', () { + expect( + betterRunnerOutput.toString(), + stringContainsInOrder([ + _moMockText, + upstreamRunnerOutput.toString(), + ]), + ); + }); + test('then Exit Code (null) matches that of upstream HelpCommand', () { + expect(betterRunnerExitCode, equals(null)); + expect(betterRunnerExitCode, equals(upstreamRunnerExitCode)); + }); + }); +} + +void _testsForInvalidHelpUsageRequests(final List args) { + group('when $args is received', () { + final betterRunnerOutput = StringBuffer(); + final upstreamRunnerOutput = StringBuffer(); + UsageException? betterRunnerException; + UsageException? upstreamRunnerException; + setUpAll(() async { + try { + await runZoned( + () async => await _buildBetterRunner().run(args), + zoneSpecification: ZoneSpecification( + print: (final _, final __, final ___, final String line) { + betterRunnerOutput.writeln(line); + }, + ), + ); + } on UsageException catch (e) { + betterRunnerException = e; + } + try { + await runZoned( + () async => await _buildUpstreamRunner().run(args), + zoneSpecification: ZoneSpecification( + print: (final _, final __, final ___, final String line) { + upstreamRunnerOutput.writeln(line); + }, + ), + ); + } on UsageException catch (e) { + upstreamRunnerException = e; + } + }); + test('then it throws UsageException exactly like CommandRunner', () { + expect(betterRunnerException, isA()); + expect(upstreamRunnerException, isA()); + expect( + betterRunnerException!.message, + equals(upstreamRunnerException!.message), + ); + expect( + betterRunnerException!.usage, + equals(upstreamRunnerException!.usage), + ); + }); + test('then MessageOutput is not bypassed', () { + expect( + betterRunnerOutput.toString(), + stringContainsInOrder( + [ + _moExceptionText, + betterRunnerException!.message, + betterRunnerException!.usage, + ], + ), + ); + }); + test('then it can subsume upstream HelpCommand output', () { + expect( + betterRunnerOutput.toString(), + contains(upstreamRunnerOutput.toString()), + ); + }); + }); +}