diff --git a/analysis_options.yaml b/analysis_options.yaml index 9d4376c..65336e6 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,5 @@ -include: package:serverpod_lints/cli.yaml \ No newline at end of file +include: package:serverpod_lints/cli.yaml + +analyzer: + errors: + unnecessary_final: false diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..f66c639 --- /dev/null +++ b/lib/config.dart @@ -0,0 +1 @@ +export 'src/config/config.dart'; diff --git a/lib/src/better_command_runner/better_command.dart b/lib/src/better_command_runner/better_command.dart index 96fa35b..5be8ade 100644 --- a/lib/src/better_command_runner/better_command.dart +++ b/lib/src/better_command_runner/better_command.dart @@ -1,14 +1,32 @@ +import 'dart:async' show FutureOr; + import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:cli_tools/better_command_runner.dart'; +import 'package:cli_tools/config.dart'; + +import 'config_resolver.dart'; -abstract class BetterCommand extends Command { +abstract class BetterCommand extends Command { final MessageOutput? _messageOutput; final ArgParser _argParser; - BetterCommand({MessageOutput? messageOutput, int? wrapTextColumn}) - : _messageOutput = messageOutput, - _argParser = ArgParser(usageLineLength: wrapTextColumn); + /// The configuration resolver for this command. + final ConfigResolver _configResolver; + + /// The option definitions for this command. + final List options; + + BetterCommand({ + MessageOutput? messageOutput, + int? wrapTextColumn, + this.options = const [], + final ConfigResolver? configResolver, + }) : _messageOutput = messageOutput, + _argParser = ArgParser(usageLineLength: wrapTextColumn), + _configResolver = configResolver ?? DefaultConfigResolver() { + prepareOptionsForParsing(options, argParser); + } @override ArgParser get argParser => _argParser; @@ -17,4 +35,44 @@ abstract class BetterCommand extends Command { void printUsage() { _messageOutput?.logUsage(usage); } + + /// Runs this command. + /// Resolves the configuration (args, env, etc) and runs the command + /// subclass via [runWithConfig]. + /// + /// Subclasses should override [runWithConfig], + /// unless they want to handle the configuration resolution themselves. + @override + FutureOr? run() { + final config = resolveConfiguration(argResults); + + return runWithConfig(config); + } + + /// Resolves the configuration for this command + /// using the preset [ConfigResolver]. + /// If there are errors resolving the configuration, + /// a UsageException is thrown with appropriate error messages. + /// + /// This method can be overridden to change the configuration resolution + /// or error handling behavior. + Configuration resolveConfiguration(ArgResults? argResults) { + final config = _configResolver.resolveConfiguration( + options: options, + argResults: argResults, + ); + + if (config.errors.isNotEmpty) { + final buffer = StringBuffer(); + final errors = config.errors.map(formatConfigError); + buffer.writeAll(errors, '\n'); + usageException(buffer.toString()); + } + + return config; + } + + /// Runs this command with prepared configuration (options). + /// Subclasses should override this method. + FutureOr? runWithConfig(final Configuration commandConfig); } diff --git a/lib/src/better_command_runner/better_command_runner.dart b/lib/src/better_command_runner/better_command_runner.dart index a6b0735..83d5583 100644 --- a/lib/src/better_command_runner/better_command_runner.dart +++ b/lib/src/better_command_runner/better_command_runner.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:args/args.dart'; import 'package:args/command_runner.dart'; +import 'package:cli_tools/config.dart'; +import 'package:cli_tools/src/better_command_runner/config_resolver.dart'; /// A function type for executing code before running a command. typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); @@ -53,7 +55,13 @@ typedef OnAnalyticsEvent = void Function(String event); /// /// The [BetterCommandRunner] class provides a more enhanced command line interface /// for running commands and handling command line arguments. -class BetterCommandRunner extends CommandRunner { +class BetterCommandRunner + extends CommandRunner { + static const foo = [ + BetterCommandRunnerFlags.verboseOption, + BetterCommandRunnerFlags.quietOption, + ]; + /// Process exit code value for command not found - /// The specified command was not found or couldn't be located. static const int exitCodeCommandNotFound = 127; @@ -63,7 +71,23 @@ class BetterCommandRunner extends CommandRunner { final OnBeforeRunCommand? _onBeforeRunCommand; OnAnalyticsEvent? _onAnalyticsEvent; - final ArgParser _argParser; + /// The gloabl option definitions. + late final List _globalOptions; + + /// The resolver for the global configuration. + final ConfigResolver _configResolver; + + Configuration? _globalConfiguration; + + /// The current global configuration. + /// (Since this object is re-entrant, the global config is regenerated each call to [runCommand].) + Configuration get globalConfiguration { + final globalConfig = _globalConfiguration; + if (globalConfig == null) { + throw StateError('Global configuration not initialized'); + } + return globalConfig; + } /// Creates a new instance of [BetterCommandRunner]. /// @@ -74,53 +98,71 @@ class BetterCommandRunner extends CommandRunner { /// - [onBeforeRunCommand] function is executed before running a command. /// - [onAnalyticsEvent] function is used to track events. /// - [wrapTextColumn] is the column width for wrapping text in the command line interface. + /// - [globalOptions] is an optional list of global options. + /// - [configResolver] is an optional custom [ConfigResolver] implementation. + /// + /// If [globalOptions] is not provided then the default global options will be used. + /// If no global options are desired then an empty list can be provided. + /// + /// To define a bespoke set of global options, it is recommended to define + /// a proper options enum. It can included any of the default global options + /// as well as any custom options. Example: + /// + /// ```dart + /// enum BespokeGlobalOption implements OptionDefinition { + /// quiet(BetterCommandRunnerFlags.quietOption), + /// verbose(BetterCommandRunnerFlags.verboseOption), + /// analytics(BetterCommandRunnerFlags.analyticsOption), + /// name(StringOption( + /// argName: 'name', + /// allowedValues: ['serverpod', 'stockholm'], + /// defaultsTo: 'serverpod', + /// )), + /// age(IntOption(argName: 'age', helpText: 'Required age', min: 0, max: 100)); + /// + /// const BespokeGlobalOption(this.option); + /// + /// @override + /// final ConfigOptionBase option; + /// } + /// ``` + /// + /// If [configResolver] is not provided then [DefaultConfigResolver] will be used, + /// which uses the command line arguments and environment variables as input sources. BetterCommandRunner( super.executableName, super.description, { + super.suggestionDistanceLimit, MessageOutput? messageOutput, SetLogLevel? setLogLevel, OnBeforeRunCommand? onBeforeRunCommand, OnAnalyticsEvent? onAnalyticsEvent, int? wrapTextColumn, + List? globalOptions, + ConfigResolver? configResolver, }) : _messageOutput = messageOutput, _setLogLevel = setLogLevel, _onBeforeRunCommand = onBeforeRunCommand, _onAnalyticsEvent = onAnalyticsEvent, - _argParser = ArgParser(usageLineLength: wrapTextColumn) { - argParser.addFlag( - BetterCommandRunnerFlags.quiet, - abbr: BetterCommandRunnerFlags.quietAbbr, - defaultsTo: false, - negatable: false, - help: 'Suppress all cli output. Is overridden by ' - ' -${BetterCommandRunnerFlags.verboseAbbr}, --${BetterCommandRunnerFlags.verbose}.', - ); - - argParser.addFlag( - BetterCommandRunnerFlags.verbose, - abbr: BetterCommandRunnerFlags.verboseAbbr, - defaultsTo: false, - negatable: false, - help: 'Prints additional information useful for development. ' - 'Overrides --${BetterCommandRunnerFlags.quietAbbr}, --${BetterCommandRunnerFlags.quiet}.', - ); - - if (_onAnalyticsEvent != null) { - argParser.addFlag( - BetterCommandRunnerFlags.analytics, - abbr: BetterCommandRunnerFlags.analyticsAbbr, - defaultsTo: true, - negatable: true, - help: 'Toggles if analytics data is sent. ', - ); + _configResolver = configResolver ?? DefaultConfigResolver(), + super( + usageLineLength: wrapTextColumn, + ) { + if (globalOptions != null) { + _globalOptions = globalOptions; + } else if (_onAnalyticsEvent != null) { + _globalOptions = BasicGlobalOption.values as List; + } else { + _globalOptions = [ + BasicGlobalOption.quiet as O, + BasicGlobalOption.verbose as O, + ]; } + prepareOptionsForParsing(_globalOptions, argParser); } - @override - ArgParser get argParser => _argParser; - /// Adds a list of commands to the command runner. - void addCommands(List commands) { + void addCommands(List> commands) { for (var command in commands) { addCommand(command); } @@ -146,14 +188,23 @@ class BetterCommandRunner extends CommandRunner { } @override - Future runCommand(ArgResults topLevelResults) async { + Future runCommand(ArgResults topLevelResults) async { + try { + _globalConfiguration = resolveConfiguration(topLevelResults); + } on UsageException catch (e) { + _messageOutput?.logUsageException(e); + _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.invalid); + rethrow; + } + _setLogLevel?.call( - parsedLogLevel: _parseLogLevel(topLevelResults), + parsedLogLevel: _determineLogLevel(globalConfiguration), commandName: topLevelResults.command?.name, ); - if (argParser.options.containsKey(BetterCommandRunnerFlags.analytics) && - !topLevelResults.flag(BetterCommandRunnerFlags.analytics)) { + if (globalConfiguration.findValueOf( + argName: BetterCommandRunnerFlags.analytics) == + false) { _onAnalyticsEvent = null; } @@ -188,7 +239,7 @@ class BetterCommandRunner extends CommandRunner { await _onBeforeRunCommand?.call(this); try { - await super.runCommand(topLevelResults); + return super.runCommand(topLevelResults); } on UsageException catch (e) { _messageOutput?.logUsageException(e); _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.invalid); @@ -196,10 +247,34 @@ class BetterCommandRunner extends CommandRunner { } } - CommandRunnerLogLevel _parseLogLevel(ArgResults topLevelResults) { - if (topLevelResults[BetterCommandRunnerFlags.verbose]) { + /// Resolves the global configuration for this command runner + /// using the preset [ConfigResolver]. + /// If there are errors resolving the configuration, + /// a UsageException is thrown with appropriate error messages. + /// + /// This method can be overridden to change the configuration resolution + /// or error handling behavior. + Configuration resolveConfiguration(ArgResults? argResults) { + final config = _configResolver.resolveConfiguration( + options: _globalOptions, + argResults: argResults, + ); + + if (config.errors.isNotEmpty) { + final buffer = StringBuffer(); + final errors = config.errors.map(formatConfigError); + buffer.writeAll(errors, '\n'); + usageException(buffer.toString()); + } + + return config; + } + + static CommandRunnerLogLevel _determineLogLevel(Configuration config) { + if (config.findValueOf(argName: BetterCommandRunnerFlags.verbose) == true) { return CommandRunnerLogLevel.verbose; - } else if (topLevelResults[BetterCommandRunnerFlags.quiet]) { + } else if (config.findValueOf(argName: BetterCommandRunnerFlags.quiet) == + true) { return CommandRunnerLogLevel.quiet; } @@ -215,6 +290,43 @@ abstract class BetterCommandRunnerFlags { static const verboseAbbr = 'v'; static const analytics = 'analytics'; static const analyticsAbbr = 'a'; + + static const quietOption = FlagOption( + argName: quiet, + argAbbrev: quietAbbr, + defaultsTo: false, + negatable: false, + helpText: 'Suppress all cli output. Is overridden by ' + ' -$verboseAbbr, --$verbose.', + ); + + static const verboseOption = FlagOption( + argName: verbose, + argAbbrev: verboseAbbr, + defaultsTo: false, + negatable: false, + helpText: 'Prints additional information useful for development. ' + 'Overrides --$quietAbbr, --$quiet.', + ); + + static const analyticsOption = FlagOption( + argName: analytics, + argAbbrev: analyticsAbbr, + defaultsTo: true, + negatable: true, + helpText: 'Toggles if analytics data is sent. ', + ); +} + +enum BasicGlobalOption implements OptionDefinition { + quiet(BetterCommandRunnerFlags.quietOption), + verbose(BetterCommandRunnerFlags.verboseOption), + analytics(BetterCommandRunnerFlags.analyticsOption); + + const BasicGlobalOption(this.option); + + @override + final ConfigOptionBase option; } /// Constants for the command runner analytics events. diff --git a/lib/src/better_command_runner/config_resolver.dart b/lib/src/better_command_runner/config_resolver.dart new file mode 100644 index 0000000..c203330 --- /dev/null +++ b/lib/src/better_command_runner/config_resolver.dart @@ -0,0 +1,54 @@ +import 'dart:io' show Platform; + +import 'package:args/args.dart' show ArgResults; +import 'package:cli_tools/config.dart' show Configuration, OptionDefinition; + +/// {@template config_resolver} +/// Resolves a configuration for the provided options and arguments. +/// Subclasses can add additional configuration sources. +/// {@endtemplate} +/// +/// The purpose of this class is to delegate the configuration resolution +/// in BetterCommandRunner and BetterCommand to a separate object +/// they can be composed with. +abstract interface class ConfigResolver { + /// {@macro config_resolver} + Configuration resolveConfiguration({ + required Iterable options, + ArgResults? argResults, + }); +} + +/// The default behavior is to invoke this using the [argResults] and +/// [Platform.environment] as input. +class DefaultConfigResolver + implements ConfigResolver { + final Map _env; + + DefaultConfigResolver({Map? env}) + : _env = env ?? Platform.environment; + + @override + Configuration resolveConfiguration({ + required Iterable options, + ArgResults? argResults, + }) { + return Configuration.resolve( + options: options, + argResults: argResults, + env: _env, + ); + } +} + +/// Formats a configuration error message. +String formatConfigError(final String error) { + if (error.isEmpty) return error; + final suffix = _isPunctuation(error.substring(error.length - 1)) ? '' : '.'; + return '${error[0].toUpperCase()}${error.substring(1)}$suffix'; +} + +/// Returns true if the character is a punctuation mark. +bool _isPunctuation(final String char) { + return RegExp(r'\p{P}', unicode: true).hasMatch(char); +} diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart new file mode 100644 index 0000000..69f935f --- /dev/null +++ b/lib/src/config/config.dart @@ -0,0 +1,10 @@ +export 'config_parser.dart'; +export 'config_source_provider.dart'; +export 'config_source.dart'; +export 'configuration_parser.dart'; +export 'configuration.dart'; +export 'file_system_options.dart'; +export 'multi_config_source.dart'; +export 'options.dart'; +export 'option_groups.dart'; +export 'source_type.dart'; diff --git a/lib/src/config/config_parser.dart b/lib/src/config/config_parser.dart new file mode 100644 index 0000000..c1fd048 --- /dev/null +++ b/lib/src/config/config_parser.dart @@ -0,0 +1,406 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart' show UsageException; + +import 'configuration.dart'; +import 'options.dart'; +import 'source_type.dart'; + +/// A backwards compatible parser for command args and configuration sources. +/// +/// This class is designed as a drop-in replacement for [ArgParser] +/// of the `args` package. It is almost entirely compatible except for: +/// - addCommand() is not supported +/// +/// The [parse] method returns a [ConfigResults] object which is a backwards +/// compatible with [ArgResults] of the args package. +/// +/// The purpose of this class is to enable an easy transition to the +/// configuration package. This can be dropped in place of existing `args` +/// code and gradually extended to use multiple configuration sources +/// such as environment variables and configuration files. +/// +/// It is recommended to migrate to the [Configuration] class to enable the +/// full range of typed configuration values. +class ConfigParser implements ArgParser { + final ArgParser _parser; + final List _optionDefinitions = []; + final Map _flagCallbacks = {}; + final Map _optionCallbacks = {}; + final Map)> _multiOptionCallbacks = {}; + + ConfigParser({ + final bool allowTrailingOptions = true, + final int? usageLineLength, + }) : _parser = ArgParser( + allowTrailingOptions: allowTrailingOptions, + usageLineLength: usageLineLength, + ); + + ArgParser get parser => _parser; + + List get optionDefinitions => _optionDefinitions; + + @override + void addFlag( + final String name, { + final String? abbr, + final String? help, + final bool? defaultsTo = false, + final bool negatable = true, + @Deprecated('Use parse results instead') + final void Function(bool)? callback, + final bool hide = false, + final bool hideNegatedUsage = false, + final List aliases = const [], + final String? envName, + final String? configKey, + final bool? Function(Configuration cfg)? fromCustom, + final bool Function()? fromDefault, + final String? valueHelp, + final bool mandatory = false, + final OptionGroup? group, + final void Function(bool value)? customValidator, + }) { + _addOption( + FlagOption( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo, + helpText: help, + valueHelp: valueHelp, + group: group, + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + negatable: negatable, + hideNegatedUsage: hideNegatedUsage, + ), + ); + if (callback != null) { + _flagCallbacks[name] = callback; + } + } + + @override + void addOption( + final String name, { + final String? abbr, + final String? help, + final String? valueHelp, + final Iterable? allowed, + final Map? allowedHelp, + final String? defaultsTo, + @Deprecated('Use parse results instead') + final void Function(String?)? callback, + final bool mandatory = false, + final bool hide = false, + final List aliases = const [], + final int? argPos, + final String? envName, + final String? configKey, + final String? Function(Configuration cfg)? fromCustom, + final String Function()? fromDefault, + final OptionGroup? group, + final void Function(String value)? customValidator, + }) { + _addOption( + StringOption( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + argPos: argPos, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo, + helpText: help, + valueHelp: valueHelp, + allowedHelp: allowedHelp, + group: group, + allowedValues: allowed?.toList(), + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + ), + ); + if (callback != null) { + _optionCallbacks[name] = callback; + } + } + + @override + void addMultiOption( + final String name, { + final String? abbr, + final String? help, + final String? valueHelp, + final Iterable? allowed, + final Map? allowedHelp, + final Iterable? defaultsTo, + @Deprecated('Use parse results instead') + final void Function(List)? callback, + final bool splitCommas = true, + final bool hide = false, + final List aliases = const [], + final String? envName, + final String? configKey, + final List? Function(Configuration cfg)? fromCustom, + final List Function()? fromDefault, + final OptionGroup? group, + final void Function(List value)? customValidator, + final bool mandatory = false, + }) { + if (splitCommas) { + _addOption( + MultiStringOption( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo?.toList(), + helpText: help, + valueHelp: valueHelp, + allowedHelp: allowedHelp, + group: group, + allowedValues: allowed?.toList(), + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + ), + ); + } else { + _addOption( + MultiStringOption.noSplit( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo?.toList(), + helpText: help, + valueHelp: valueHelp, + allowedHelp: allowedHelp, + group: group, + allowedValues: allowed?.toList(), + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + ), + ); + } + if (callback != null) { + _multiOptionCallbacks[name] = callback; + } + } + + void _addOption(final OptionDefinition opt) { + _optionDefinitions.add(opt); + // added continuously to the parser so separators are placed correctly: + addOptionsToParser([opt], _parser); + } + + @override + void addSeparator(final String text) => parser.addSeparator(text); + + @override + ConfigResults parse( + final Iterable args, { + final Map? env, + final ConfigurationBroker? configBroker, + final Map? presetValues, + }) { + validateOptions(_optionDefinitions); + + final argResults = parser.parse(args); + + final configuration = Configuration.resolve( + options: _optionDefinitions, + argResults: argResults, + env: env, + configBroker: configBroker, + presetValues: presetValues, + ignoreUnexpectedPositionalArgs: true, + ); + + if (configuration.errors.isNotEmpty) { + throw UsageException( + configuration.errors.join('\n'), + usage, + ); + } + + _invokeCallbacks(configuration); + + return ConfigResults( + configuration, + argResults.arguments, + argResults.rest, + ); + } + + void _invokeCallbacks(final Configuration cfg) { + for (final entry in _flagCallbacks.entries) { + entry.value(cfg.findValueOf(argName: entry.key) ?? false); + } + for (final entry in _optionCallbacks.entries) { + entry.value(cfg.findValueOf(argName: entry.key)); + } + for (final entry in _multiOptionCallbacks.entries) { + entry.value(cfg.findValueOf>(argName: entry.key) ?? []); + } + } + + @override + String get usage => parser.usage; + + @override + int? get usageLineLength => parser.usageLineLength; + + @override + dynamic defaultFor(final String option) => parser.defaultFor(option); + + @override + @Deprecated('Use defaultFor instead.') + dynamic getDefault(final String option) => parser.getDefault(option); + + @override + Option? findByAbbreviation(final String abbr) => + parser.findByAbbreviation(abbr); + + @override + Option? findByNameOrAlias(final String name) => + parser.findByNameOrAlias(name); + + @override + bool get allowTrailingOptions => parser.allowTrailingOptions; + + @override + bool get allowsAnything => parser.allowsAnything; + + @override + ArgParser addCommand(final String name, [final ArgParser? parser]) { + throw UnsupportedError('addCommand is not supported'); + } + + @override + Map get commands => parser.commands; + + @override + @Deprecated('Use optionDefinitions instead.') + Map get options => parser.options; +} + +/// A wrapper around a [Configuration] object that implements the [ArgResults] +/// interface. This is returned by the [ConfigParser.parse] method. +/// +/// This is backwards compatible with [ArgResults] except for the name and +/// command properties, which are always null since commands are not supported. +class ConfigResults implements ArgResults { + final Configuration _configuration; + + ConfigResults( + this._configuration, + this.arguments, + this.rest, + ); + + /// The original arguments that were parsed. + @override + final List arguments; + + /// The remaining command-line arguments that were not parsed. + @override + final List rest; + + /// The name of the command for which these options are parsed. + /// Currently always null. + @override + String? get name => null; + + /// The name of the command for which these options are parsed. + /// Currently always null. + @override + ArgResults? get command => null; + + @override + dynamic operator [](final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg option named "--$name".'); + } + return _configuration.optionalValue(option); + } + + @override + String? option(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg option named "--$name".'); + } + final value = _configuration.optionalValue(option); + if (value is! String?) { + throw ArgumentError('Arg option $name is not a string option.'); + } + return value; + } + + @override + bool flag(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg flag named "--$name".'); + } + final value = _configuration.optionalValue(option); + if (value is! bool) { + throw ArgumentError('Arg flag $name is not a boolean flag.'); + } + return value; + } + + @override + List multiOption(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg option named "--$name".'); + } + final value = _configuration.optionalValue(option); + if (value is! List) { + throw ArgumentError('Arg option $name is not a multi-string option.'); + } + return value; + } + + /// The arg names of the available options, + /// i.e. the ones that have a value. + /// Options that do not have arg names are omitted. + @override + Iterable get options { + return _configuration + .optionsWhereSource((final source) => source != ValueSourceType.noValue) + .map((final o) => o.option.argName) + .whereType(); + } + + /// Returns `true` if the option with [name] was parsed from an actual + /// argument. + @override + bool wasParsed(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('Could not find an arg option named "--$name".'); + } + final sourceType = _configuration.valueSourceType(option); + return sourceType == ValueSourceType.arg; + } +} diff --git a/lib/src/config/config_source.dart b/lib/src/config/config_source.dart new file mode 100644 index 0000000..77f41fb --- /dev/null +++ b/lib/src/config/config_source.dart @@ -0,0 +1,22 @@ +/// A source of configuration values. +/// +/// {@template config_source.valueOrNull} +/// Returns the value for the given key, or `null` if the key is not found +/// or has no value. +/// {@endtemplate} +abstract interface class ConfigurationSource { + /// {@macro config_source.valueOrNull} + Object? valueOrNull(final String key); +} + +/// Simple [ConfigurationSource] adapter for a [Map]. +class MapConfigSource implements ConfigurationSource { + final Map entries; + + MapConfigSource(this.entries); + + @override + String? valueOrNull(final String key) { + return entries[key]; + } +} diff --git a/lib/src/config/config_source_provider.dart b/lib/src/config/config_source_provider.dart new file mode 100644 index 0000000..e042216 --- /dev/null +++ b/lib/src/config/config_source_provider.dart @@ -0,0 +1,42 @@ +import 'config_source.dart'; +import 'configuration.dart'; +import 'configuration_parser.dart'; + +/// Provider of a [ConfigurationSource] that is dynamically +/// based on the current configuration. +/// +/// It is lazily invoked and should cache the [ConfigurationSource]. +abstract class ConfigSourceProvider { + /// Get the [ConfigurationSource] given the current configuration. + /// This is lazily invoked and should cache the [ConfigurationSource] + /// for subsequent invocations. + ConfigurationSource getConfigSource(final Configuration cfg); +} + +/// A [ConfigSourceProvider] that uses the value of an option +/// as data source. +class OptionContentConfigProvider> + extends ConfigSourceProvider { + final O contentOption; + final ConfigEncoding format; + ConfigurationSource? _configProvider; + + OptionContentConfigProvider({ + required this.contentOption, + required this.format, + }); + + @override + ConfigurationSource getConfigSource(final Configuration cfg) { + var provider = _configProvider; + if (provider == null) { + final optionValue = cfg.optionalValue(contentOption) ?? ''; + provider = ConfigurationParser.fromString( + optionValue, + format: format, + ); + _configProvider = provider; + } + return provider; + } +} diff --git a/lib/src/config/configuration.dart b/lib/src/config/configuration.dart new file mode 100644 index 0000000..a1285ba --- /dev/null +++ b/lib/src/config/configuration.dart @@ -0,0 +1,1037 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'option_resolution.dart'; +import 'source_type.dart'; + +/// Common interface to enable same treatment for [ConfigOptionBase] and option enums. +abstract class OptionDefinition { + ConfigOptionBase get option; +} + +/// 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 +/// so it is recommended to format it appropriately, e.g. `File mode`. +/// +/// An [OptionGroup] is uniquely identified by its [name]. +class OptionGroup { + final String name; + + const OptionGroup(this.name); + + /// Validates the values of the options in this group, + /// returning a descriptive error message if the values are invalid. + /// + /// Subclasses may override this method to perform specific validations. + String? validate( + final Map optionResolutions, + ) { + return null; + } + + @override + bool operator ==(final Object other) { + if (identical(this, other)) return true; + return other is OptionGroup && other.name == name; + } + + @override + int get hashCode => name.hashCode; +} + +/// A [ValueParser] converts a source string value to the specific option value type. +/// +/// {@template value_parser} +/// Must throw a [FormatException] with an appropriate message +/// if the value cannot be parsed. +/// {@endtemplate} +abstract class ValueParser { + const ValueParser(); + + /// Converts a source string value to the specific option value type. + /// {@macro value_parser} + V parse(final String value); + + /// Returns a usage documentation friendly string representation of the value. + /// The default implementation simply invokes [toString]. + String format(final V value) { + return value.toString(); + } +} + +/// Defines a configuration option that can be set from configuration sources. +/// +/// When an option can be set in multiple ways, the precedence is as follows: +/// +/// 1. Named command line argument +/// 2. Positional command line argument +/// 3. Environment variable +/// 4. By lookup key in configuration sources (such as files) +/// 5. A custom callback function +/// 6. Default value +/// +/// ### Typed values, parsing, and validation +/// +/// Option values are typed, and parsed using the [ValueParser]. +/// Subclasses of [ConfigOptionBase] may also override [validateValue] +/// to perform additional validation such as range checking. +/// +/// The subclasses implement specific option value types, +/// e.g. [StringOption], [FlagOption] (boolean), [IntOption], etc. +/// +/// ### Positional arguments +/// +/// If multiple positional arguments are defined, +/// follow these restrictions to prevent ambiguity: +/// - all but the last one must be mandatory +/// - all but the last one must have no non-argument configuration sources +/// +/// If an argument is defined as both named and positional, +/// and the named argument is provided, the positional index +/// is still consumed so that subsequent positional arguments +/// will get the correct value. +/// +/// Note that this prevents an option from being provided both +/// named and positional on the same command line. +/// +/// ### Mandatory and Default +/// +/// If [mandatory] is true, the option must be provided in the +/// configuration sources, i.e. be explicitly set. +/// This cannot be used together with [defaultsTo] or [fromDefault]. +/// +/// If no value is provided from the configuration sources, +/// the [fromDefault] callback is used if available, +/// otherwise the [defaultsTo] value is used. +/// [fromDefault] must return the same value if called multiple times. +/// +/// If an option is either mandatory or has a default value, +/// it is guaranteed to have a value and can be retrieved using +/// the non-nullable [value] method. +/// Otherwise it may be retrieved using the nullable [valueOrNull] method. +class ConfigOptionBase implements OptionDefinition { + final ValueParser valueParser; + + final String? argName; + final List? argAliases; + final String? argAbbrev; + final int? argPos; + final String? envName; + final String? configKey; + final V? Function(Configuration cfg)? fromCustom; + final V Function()? fromDefault; + final V? defaultsTo; + + final String? helpText; + final String? valueHelp; + final Map? allowedHelp; + final OptionGroup? group; + + final List? allowedValues; + final void Function(V value)? customValidator; + final bool mandatory; + final bool hide; + + const ConfigOptionBase({ + required this.valueParser, + this.argName, + this.argAliases, + this.argAbbrev, + this.argPos, + this.envName, + this.configKey, + this.fromCustom, + this.fromDefault, + this.defaultsTo, + this.helpText, + this.valueHelp, + this.allowedHelp, + this.group, + this.allowedValues, + this.customValidator, + this.mandatory = false, + this.hide = false, + }); + + V? defaultValue() { + final df = fromDefault; + return (df != null ? df() : defaultsTo); + } + + String? defaultValueString() { + return defaultValue()?.toString(); + } + + String? valueHelpString() { + return valueHelp; + } + + /// Adds this configuration option to the provided argument parser. + void _addToArgParser(final ArgParser argParser) { + final argName = this.argName; + if (argName == null) { + throw StateError("Can't add option without arg name to arg parser."); + } + argParser.addOption( + argName, + abbr: argAbbrev, + help: helpText, + valueHelp: valueHelpString(), + allowed: allowedValues?.map(valueParser.format), + allowedHelp: allowedHelp, + defaultsTo: defaultValueString(), + mandatory: mandatory, + hide: hide, + aliases: argAliases ?? const [], + ); + } + + /// Validates the configuration option definition. + /// + /// This method is called by [prepareOptionsForParsing] to validate + /// the configuration option definition. + /// Throws an error if the definition is invalid. + /// + /// Subclasses may override this method to perform specific validations. + /// If they do, they must also call the super implementation. + @mustCallSuper + void validateDefinition() { + if (argName == null && argAbbrev != null) { + throw InvalidOptionConfigurationError(this, + "An argument option can't have an abbreviation but not a full name"); + } + + if ((fromDefault != null || defaultsTo != null) && mandatory) { + throw InvalidOptionConfigurationError( + this, "Mandatory options can't have default values"); + } + } + + /// Validates the parsed value, + /// throwing a [FormatException] if the value is invalid, + /// or a [UsageException] if the value is invalid for other reasons. + /// + /// Subclasses may override this method to perform specific validations. + /// If they do, they must also call the super implementation. + @mustCallSuper + void validateValue(final V value) { + if (allowedValues?.contains(value) == false) { + throw UsageException( + '`$value` is not an allowed value for ${qualifiedString()}', ''); + } + + customValidator?.call(value); + } + + /// Returns self. + @override + ConfigOptionBase get option => this; + + @override + String toString() => argName ?? envName ?? ''; + + String qualifiedString() { + if (argName != null) { + return V is bool ? 'flag `$argName`' : 'option `$argName`'; + } + if (envName != null) { + return 'environment variable `$envName`'; + } + if (argPos != null) { + return 'positional argument $argPos'; + } + if (configKey != null) { + return 'configuration key `$configKey`'; + } + return _unnamedOptionString; + } + + static const _unnamedOptionString = ''; + + ///////////////////// + // Value resolution + + /// Returns the resolved value of this configuration option from the provided context. + /// For options with positional arguments this must be invoked in ascending position order. + /// Returns the result with the resolved value or error. + OptionResolution _resolveValue( + final Configuration cfg, { + final ArgResults? args, + final Iterator? posArgs, + final Map? env, + final ConfigurationBroker? configBroker, + }) { + OptionResolution res; + try { + res = _doResolve( + cfg, + args: args, + posArgs: posArgs, + env: env, + configBroker: configBroker, + ); + } on Exception catch (e) { + return OptionResolution.error( + 'Failed to resolve ${option.qualifiedString()}: $e', + ); + } + + if (res.error != null) { + return res; + } + + final stringValue = res.stringValue; + if (stringValue != null) { + // value provided by string-based config source, parse to the designated type + try { + res = res.copyWithValue( + option.option.valueParser.parse(stringValue), + ); + } on FormatException catch (e) { + return res.copyWithError( + _makeFormatErrorMessage(e), + ); + } + } + + final error = _validateOptionValue(res.value); + if (error != null) return res.copyWithError(error); + + return res; + } + + OptionResolution _doResolve( + final Configuration cfg, { + final ArgResults? args, + final Iterator? posArgs, + final Map? env, + final ConfigurationBroker? configBroker, + }) { + OptionResolution? result; + + result = _resolveNamedArg(args); + if (result != null) return result; + + result = _resolvePosArg(posArgs); + if (result != null) return result; + + result = _resolveEnvVar(env); + if (result != null) return result; + + result = _resolveConfigValue(cfg, configBroker); + if (result != null) return result; + + result = _resolveCustomValue(cfg); + if (result != null) return result; + + result = _resolveDefaultValue(); + if (result != null) return result; + + return OptionResolution.noValue(); + } + + OptionResolution? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; + } + return OptionResolution( + stringValue: args.option(argOptName), + source: ValueSourceType.arg, + ); + } + + OptionResolution? _resolvePosArg(final Iterator? posArgs) { + final argOptPos = argPos; + if (argOptPos == null || posArgs == null) return null; + if (!posArgs.moveNext()) return null; + return OptionResolution( + stringValue: posArgs.current, + source: ValueSourceType.arg, + ); + } + + OptionResolution? _resolveEnvVar(final Map? env) { + final envVarName = envName; + if (envVarName == null || env == null || !env.containsKey(envVarName)) { + return null; + } + return OptionResolution( + stringValue: env[envVarName], + source: ValueSourceType.envVar, + ); + } + + OptionResolution? _resolveConfigValue( + final Configuration cfg, + final ConfigurationBroker? configBroker, + ) { + final key = configKey; + if (configBroker == null || key == null) return null; + final value = configBroker.valueOrNull(key, cfg); + if (value == null) return null; + if (value is String) { + return OptionResolution( + stringValue: value, + source: ValueSourceType.config, + ); + } + if (value is V) { + return OptionResolution( + value: value as V, + source: ValueSourceType.config, + ); + } + return OptionResolution.error( + '${option.qualifiedString()} value $value ' + 'is of type ${value.runtimeType}, not $V.', + ); + } + + OptionResolution? _resolveCustomValue(final Configuration cfg) { + final value = fromCustom?.call(cfg); + if (value == null) return null; + return OptionResolution( + value: value, + source: ValueSourceType.custom, + ); + } + + OptionResolution? _resolveDefaultValue() { + final value = fromDefault?.call() ?? defaultsTo; + if (value == null) return null; + return OptionResolution( + value: value, + source: ValueSourceType.defaultValue, + ); + } + + /// Returns an error message if the value is invalid, or null if valid. + String? _validateOptionValue(final V? value) { + if (value == null && mandatory) { + return '${qualifiedString()} is mandatory'; + } + + if (value != null) { + try { + validateValue(value); + } on FormatException catch (e) { + return _makeFormatErrorMessage(e); + } on UsageException catch (e) { + return _makeErrorMessage(e.message); + } + } + return null; + } + + String _makeFormatErrorMessage(final FormatException e) { + const prefix = 'FormatException: '; + var message = e.toString(); + if (message.startsWith(prefix)) { + message = message.substring(prefix.length); + } + return _makeErrorMessage(message); + } + + String _makeErrorMessage(final String message) { + final help = valueHelp != null ? ' <$valueHelp>' : ''; + return 'Invalid value for ${qualifiedString()}$help: $message'; + } +} + +/// Parses a boolean value from a string. +class BoolParser extends ValueParser { + const BoolParser(); + + @override + bool parse(final String value) { + return bool.parse(value, caseSensitive: false); + } +} + +/// Boolean value configuration option. +class FlagOption extends ConfigOptionBase { + final bool negatable; + final bool hideNegatedUsage; + + const FlagOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.group, + super.customValidator, + super.mandatory, + super.hide, + this.negatable = true, + this.hideNegatedUsage = false, + }) : super( + valueParser: const BoolParser(), + ); + + @override + void _addToArgParser(final ArgParser argParser) { + final argName = this.argName; + if (argName == null) { + throw StateError("Can't add flag without arg name to arg parser."); + } + argParser.addFlag( + argName, + abbr: argAbbrev, + help: helpText, + defaultsTo: defaultValue(), + negatable: negatable, + hideNegatedUsage: hideNegatedUsage, + hide: hide, + aliases: argAliases ?? const [], + ); + } + + @override + OptionResolution? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; + } + return OptionResolution( + value: args.flag(argOptName), + source: ValueSourceType.arg, + ); + } +} + +/// Parses a list of values from a comma-separated string. +/// +/// The [elementParser] is used to parse the individual elements. +/// +/// The [separator] is the pattern that separates the elements, +/// if the input is a single string. It is comma by default. +/// If it is null, the input is treated as a single element. +/// +/// The [joiner] is the string that joins the elements in the +/// formatted display string, also comma by default. +class MultiParser extends ValueParser> { + final ValueParser elementParser; + final Pattern? separator; + final String joiner; + + const MultiParser({ + required this.elementParser, + this.separator = ',', + this.joiner = ',', + }); + + @override + List parse(final String value) { + final sep = separator; + if (sep == null) return [elementParser.parse(value)]; + return value.split(sep).map(elementParser.parse).toList(); + } + + @override + String format(final List value) { + return value.map(elementParser.format).join(joiner); + } +} + +/// Multi-value configuration option. +class MultiOption extends ConfigOptionBase> { + final List? allowedElementValues; + + const MultiOption({ + required final MultiParser multiParser, + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + final List? allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : allowedElementValues = allowedValues, + super( + valueParser: multiParser, + ); + + @override + void _addToArgParser(final ArgParser argParser) { + final argName = this.argName; + if (argName == null) { + throw StateError("Can't add option without arg name to arg parser."); + } + + final multiParser = valueParser as MultiParser; + argParser.addMultiOption( + argName, + abbr: argAbbrev, + help: helpText, + valueHelp: valueHelpString(), + allowed: allowedElementValues?.map(multiParser.elementParser.format), + allowedHelp: allowedHelp, + defaultsTo: defaultValue()?.map(multiParser.elementParser.format), + hide: hide, + splitCommas: multiParser.separator == ',', + aliases: argAliases ?? const [], + ); + } + + @override + OptionResolution>? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; + } + final multiParser = valueParser as MultiParser; + return OptionResolution( + value: args + .multiOption(argOptName) + .map(multiParser.elementParser.parse) + .toList(), + source: ValueSourceType.arg, + ); + } + + @override + @mustCallSuper + void validateValue(final List value) { + super.validateValue(value); + + final allowed = allowedElementValues; + if (allowed != null) { + for (final v in value) { + if (allowed.contains(v) == false) { + throw UsageException( + '`$v` is not an allowed value for ${qualifiedString()}', ''); + } + } + } + } +} + +/// Extension to add a [qualifiedString] shorthand method to [OptionDefinition]. +/// Since enum classes that implement [OptionDefinition] don't inherit +/// its method implementations, this extension provides this method +/// implementation instead. +extension QualifiedString on OptionDefinition { + String qualifiedString() { + final str = option.qualifiedString(); + if (str == ConfigOptionBase._unnamedOptionString && this is Enum) { + return (this as Enum).name; + } + return str; + } +} + +/// Validates and prepares a set of options for the provided argument parser. +void prepareOptionsForParsing( + final Iterable options, + final ArgParser argParser, +) { + final argNameOpts = validateOptions(options); + addOptionsToParser(argNameOpts, argParser); +} + +Iterable validateOptions( + final Iterable options, +) { + final argNameOpts = {}; + final argPosOpts = {}; + final envNameOpts = {}; + + for (final opt in options) { + opt.option.validateDefinition(); + + final argName = opt.option.argName; + if (argName != null) { + if (argNameOpts.containsKey(opt.option.argName)) { + throw InvalidOptionConfigurationError( + opt, 'Duplicate argument name: ${opt.option.argName} for $opt'); + } + argNameOpts[argName] = opt; + } + + final argPos = opt.option.argPos; + if (argPos != null) { + if (argPosOpts.containsKey(opt.option.argPos)) { + throw InvalidOptionConfigurationError( + opt, 'Duplicate argument position: ${opt.option.argPos} for $opt'); + } + argPosOpts[argPos] = opt; + } + + final envName = opt.option.envName; + if (envName != null) { + if (envNameOpts.containsKey(opt.option.envName)) { + throw InvalidOptionConfigurationError(opt, + 'Duplicate environment variable name: ${opt.option.envName} for $opt'); + } + envNameOpts[envName] = opt; + } + } + + if (argPosOpts.isNotEmpty) { + final orderedPosOpts = argPosOpts.values.sorted( + (final a, final b) => a.option.argPos!.compareTo(b.option.argPos!)); + + if (orderedPosOpts.first.option.argPos != 0) { + throw InvalidOptionConfigurationError( + orderedPosOpts.first, + 'First positional argument must have index 0.', + ); + } + + if (orderedPosOpts.last.option.argPos != orderedPosOpts.length - 1) { + throw InvalidOptionConfigurationError( + orderedPosOpts.last, + 'The positional arguments must have consecutive indices without gaps.', + ); + } + } + + return argNameOpts.values; +} + +void addOptionsToParser( + final Iterable argNameOpts, + final ArgParser argParser, +) { + for (final opt in argNameOpts) { + opt.option._addToArgParser(argParser); + } +} + +extension PrepareOptions on Iterable { + /// Validates and prepares these options for the provided argument parser. + void prepareForParsing(final ArgParser argParser) => + prepareOptionsForParsing(this, argParser); +} + +/// Resolves configuration values dynamically +/// and possibly from multiple sources. +abstract interface class ConfigurationBroker { + /// Returns the value for the given key, or `null` if the key is not found + /// or has no value. + /// + /// Resolution may depend on the value of other options, accessed via [cfg]. + Object? valueOrNull(final String key, final Configuration cfg); +} + +/// A configuration object that holds the values for a set of configuration options. +class Configuration { + final List _options; + final Map _config; + final List _errors; + + /// Creates a configuration with the provided option values. + /// + /// This does not throw upon value parsing or validation errors, + /// instead the caller is responsible for checking if [errors] is non-empty. + Configuration.fromValues({ + required final Map values, + }) : this.resolve( + options: values.keys, + presetValues: values, + ); + + /// Creates a configuration with option values resolved from the provided context. + /// + /// [argResults] is used if provided. Otherwise [args] is used if provided. + /// + /// If [presetValues] is provided, the values present will override the other sources, + /// including if they are null. + /// + /// This does not throw upon value parsing or validation errors, + /// instead the caller is responsible for checking if [errors] is non-empty. + Configuration.resolve({ + required final Iterable options, + ArgResults? argResults, + final Iterable? args, + final Map? env, + final ConfigurationBroker? configBroker, + final Map? presetValues, + final bool ignoreUnexpectedPositionalArgs = false, + }) : _options = List.from(options), + _config = {}, + _errors = [] { + if (argResults == null && args != null) { + final parser = ArgParser(); + options.prepareForParsing(parser); + + try { + argResults = parser.parse(args); + } on FormatException catch (e) { + _errors.add(e.message); + for (var o in _options) { + _config[o] = const OptionResolution.error('Previous ArgParser error'); + } + return; + } + } + + _resolveWithArgResults( + args: argResults, + env: env, + configBroker: configBroker, + presetValues: presetValues, + ignoreUnexpectedPositionalArgs: ignoreUnexpectedPositionalArgs, + ); + } + + /// Gets the option definitions for this configuration. + Iterable get options => _config.keys; + + /// Gets the errors that occurred during configuration resolution. + Iterable get errors => _errors; + + /// Returns the option definition for the given enum name, + /// or any provided argument name, position, + /// environment variable name, or configuration key. + /// The first one that matches is returned, or null if none match. + /// + /// The recommended practice is to define options as enums and identify them by the enum name. + O? findOption({ + final String? enumName, + final String? argName, + final int? argPos, + final String? envName, + final String? configKey, + }) { + return _options.firstWhereOrNull((final o) { + return (enumName != null && o is Enum && (o as Enum).name == enumName) || + (argName != null && o.option.argName == argName) || + (argPos != null && o.option.argPos == argPos) || + (envName != null && o.option.envName == envName) || + (configKey != null && o.option.configKey == configKey); + }); + } + + /// Returns the value of a configuration option + /// identified by name, position, or key. + /// + /// Returns `null` if the option is not found or is not set. + V? findValueOf({ + final String? enumName, + final String? argName, + final int? argPos, + final String? envName, + final String? configKey, + }) { + final option = findOption( + enumName: enumName, + argName: argName, + argPos: argPos, + envName: envName, + configKey: configKey, + ); + if (option == null) return null; + return optionalValue(option as OptionDefinition); + } + + /// Returns the options that have a value matching + /// the source type test. + Iterable optionsWhereSource( + final bool Function(ValueSourceType source) test, + ) { + return _config.entries + .where((final e) => test(e.value.source)) + .map((final e) => e.key); + } + + /// Returns the value of a configuration option + /// that is guaranteed to be non-null. + /// + /// Throws [UsageException] if the option is mandatory and no value is provided. + /// + /// If called for an option that is neither mandatory nor has defaults, + /// [StateError] is thrown. See also [optionalValue]. + /// + /// Throws [ArgumentError] if the option is unknown. + V value(final OptionDefinition option) { + if (!(option.option.mandatory || + option.option.fromDefault != null || + option.option.defaultsTo != null)) { + throw StateError( + "Can't invoke non-nullable value() for ${option.qualifiedString()} " + 'which is neither mandatory nor has a default value.'); + } + final val = optionalValue(option); + if (val != null) return val; + + throw InvalidParseStateError( + 'No value available for ${option.qualifiedString()} due to previous errors'); + } + + /// Returns the value of an optional configuration option. + /// Returns `null` if the option is not set. + /// + /// Throws [ArgumentError] if the option is unknown. + V? optionalValue(final OptionDefinition option) { + final resolution = _getOptionResolution(option); + + return resolution.value as V?; + } + + /// Returns the source type of the given option's value. + ValueSourceType valueSourceType(final O option) { + final resolution = _getOptionResolution(option); + + return resolution.source; + } + + OptionResolution _getOptionResolution(final OptionDefinition option) { + if (!_options.contains(option)) { + throw ArgumentError( + '${option.qualifiedString()} is not part of this configuration'); + } + + final resolution = _config[option]; + + if (resolution == null) { + throw InvalidOptionConfigurationError(option, + 'Out-of-order dependency on not-yet-resolved ${option.qualifiedString()}'); + } + + if (resolution.error != null) { + throw InvalidParseStateError( + 'No value available for ${option.qualifiedString()} due to previous errors'); + } + + return resolution; + } + + void _resolveWithArgResults({ + final ArgResults? args, + final Map? env, + final ConfigurationBroker? configBroker, + final Map? presetValues, + final bool ignoreUnexpectedPositionalArgs = false, + }) { + final posArgs = (args?.rest ?? []).iterator; + final orderedOpts = _options.sorted((final a, final b) => + (a.option.argPos ?? -1).compareTo(b.option.argPos ?? -1)); + + final optionGroups = >{}; + + for (final opt in orderedOpts) { + OptionResolution resolution; + try { + if (presetValues != null && presetValues.containsKey(opt)) { + resolution = _resolvePresetValue(opt, presetValues[opt]); + } else { + resolution = opt.option._resolveValue( + this, + args: args, + posArgs: posArgs, + env: env, + configBroker: configBroker, + ); + } + + final group = opt.option.group; + if (group != null) { + optionGroups.update( + group, + (final value) => {...value, opt: resolution}, + ifAbsent: () => {opt: resolution}, + ); + } + + final error = resolution.error; + if (error != null) { + _errors.add(error); + } + } on InvalidParseStateError catch (e) { + // Represents an option resolution that depends on another option + // whose resolution failed, so this resolution fails in turn. + // Not adding to _errors to avoid double reporting. + resolution = OptionResolution.error(e.message); + } + + _config[opt] = resolution; + } + + _validateGroups(optionGroups); + + final remainingPosArgs = posArgs.restAsList(); + if (remainingPosArgs.isNotEmpty && !ignoreUnexpectedPositionalArgs) { + _errors.add( + "Unexpected positional argument(s): '${remainingPosArgs.join("', '")}'"); + } + } + + OptionResolution _resolvePresetValue( + final O option, + final Object? value, + ) { + final resolution = value == null + ? const OptionResolution.noValue() + : OptionResolution(value: value, source: ValueSourceType.preset); + + final error = option.option._validateOptionValue(value); + if (error != null) return resolution.copyWithError(error); + return resolution; + } + + void _validateGroups( + final Map> optionGroups, + ) { + optionGroups.forEach((final group, final optionResolutions) { + final error = group.validate(optionResolutions); + if (error != null) { + _errors.add(error); + } + }); + } +} + +extension _RestAsList on Iterator { + /// Returns the remaining elements of this iterator as a list. + /// Consumes the iterator. + List restAsList() { + final list = []; + while (moveNext()) { + list.add(current); + } + return list; + } +} + +/// Indicates that the option definition is invalid. +class InvalidOptionConfigurationError extends Error { + final OptionDefinition option; + final String? message; + + InvalidOptionConfigurationError(this.option, [this.message]); + + @override + String toString() { + return message != null + ? 'Invalid configuration for ${option.qualifiedString()}: $message' + : 'Invalid configuration for ${option.qualifiedString()}'; + } +} + +/// Specialized [StateError] that indicates that the configuration +/// has not been successfully parsed and this prevents accessing +/// some or all of the configuration values. +class InvalidParseStateError extends StateError { + InvalidParseStateError(super.message); +} diff --git a/lib/src/config/configuration_parser.dart b/lib/src/config/configuration_parser.dart new file mode 100644 index 0000000..e6e478d --- /dev/null +++ b/lib/src/config/configuration_parser.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'config_source.dart'; +import 'json_yaml_document.dart'; + +/// Encoding format of a configuration data source. +enum ConfigEncoding { + json, + yaml, +} + +/// Parsers for various configuration data sources. +/// +/// Produces a [ConfigurationSource] from a data source and a format. +/// +/// Supports the formats in [ConfigEncoding]. +abstract final class ConfigurationParser { + /// Parses a configuration from a string. + static ConfigurationSource fromString( + final String source, { + required final ConfigEncoding format, + }) { + switch (format) { + case ConfigEncoding.json: + return _JyConfigSource(JsonYamlDocument.fromJson(source)); + case ConfigEncoding.yaml: + return _JyConfigSource(JsonYamlDocument.fromYaml(source)); + } + } + + /// Parses a configuration from a file. + static ConfigurationSource fromFile( + final String filePath, + ) { + if (filePath.endsWith('.json')) { + return fromString( + _loadFile(filePath), + format: ConfigEncoding.json, + ); + } else if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { + return fromString( + _loadFile(filePath), + format: ConfigEncoding.yaml, + ); + } + throw UnsupportedError('Unsupported file extension: $filePath'); + } + + static String _loadFile(final String filePath) { + final file = File(filePath); + if (!file.existsSync()) { + throw ArgumentError('File not found: $filePath'); + } + return file.readAsStringSync(); + } +} + +/// [ConfigurationSource] adapter for a [JsonYamlDocument]. +class _JyConfigSource implements ConfigurationSource { + final JsonYamlDocument _jyDocument; + + _JyConfigSource(this._jyDocument); + + @override + Object? valueOrNull(final String pointerKey) { + return _jyDocument.valueAtPointer(pointerKey); + } +} diff --git a/lib/src/config/file_system_options.dart b/lib/src/config/file_system_options.dart new file mode 100644 index 0000000..1fe0668 --- /dev/null +++ b/lib/src/config/file_system_options.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; + +import 'configuration.dart'; + +enum PathExistMode { + mayExist, + mustExist, + mustNotExist, +} + +class DirParser extends ValueParser { + const DirParser(); + + @override + Directory parse(final String value) { + return Directory(value); + } +} + +/// Directory path configuration option. +/// +/// If the input is not valid according to the [mode], +/// the validation throws a [UsageException]. +class DirOption extends ConfigOptionBase { + final PathExistMode mode; + + const DirOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.group, + super.customValidator, + super.mandatory, + super.hide, + this.mode = PathExistMode.mayExist, + }) : super(valueParser: const DirParser()); + + @override + void validateValue(final Directory value) { + super.validateValue(value); + + final type = FileSystemEntity.typeSync(value.path); + switch (mode) { + case PathExistMode.mayExist: + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.directory) { + throw UsageException('Path "${value.path}" is not a directory', ''); + } + break; + case PathExistMode.mustExist: + if (type == FileSystemEntityType.notFound) { + throw UsageException('Directory "${value.path}" does not exist', ''); + } + if (type != FileSystemEntityType.directory) { + throw UsageException('Path "${value.path}" is not a directory', ''); + } + break; + case PathExistMode.mustNotExist: + if (type != FileSystemEntityType.notFound) { + throw UsageException('Path "${value.path}" already exists', ''); + } + break; + } + } +} + +class FileParser extends ValueParser { + const FileParser(); + + @override + File parse(final String value) { + return File(value); + } +} + +/// File path configuration option. +/// +/// If the input is not valid according to the [mode], +/// the validation throws a [UsageException]. +class FileOption extends ConfigOptionBase { + final PathExistMode mode; + + const FileOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.group, + super.customValidator, + super.mandatory, + super.hide, + this.mode = PathExistMode.mayExist, + }) : super(valueParser: const FileParser()); + + @override + void validateValue(final File value) { + super.validateValue(value); + + final type = FileSystemEntity.typeSync(value.path); + switch (mode) { + case PathExistMode.mayExist: + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.file) { + throw UsageException('Path "${value.path}" is not a file', ''); + } + break; + case PathExistMode.mustExist: + if (type == FileSystemEntityType.notFound) { + throw UsageException('File "${value.path}" does not exist', ''); + } + if (type != FileSystemEntityType.file) { + throw UsageException('Path "${value.path}" is not a file', ''); + } + break; + case PathExistMode.mustNotExist: + if (type != FileSystemEntityType.notFound) { + throw UsageException('Path "${value.path}" already exists', ''); + } + break; + } + } +} diff --git a/lib/src/config/json_yaml_document.dart b/lib/src/config/json_yaml_document.dart new file mode 100644 index 0000000..da7555d --- /dev/null +++ b/lib/src/config/json_yaml_document.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:rfc_6901/rfc_6901.dart'; +import 'package:yaml_codec/yaml_codec.dart'; + +/// A parsed JSON or YAML document. +/// +/// {@template json_yaml_document.valueAtPointer} +/// Supports accessing values identified by JSON pointers (RFC 6901). +/// See: https://datatracker.ietf.org/doc/html/rfc6901 +/// +/// Example: '/foo/0/bar' +/// {@endtemplate} +class JsonYamlDocument { + final Object? _document; + + JsonYamlDocument.fromJson(final String jsonSource) + : _document = jsonSource.isEmpty ? null : jsonDecode(jsonSource); + + JsonYamlDocument.fromYaml(final String yamlSource) + : _document = yamlDecode(yamlSource); + + /// {@macro json_yaml_document.valueAtPointer} + Object? valueAtPointer(final String pointerKey) { + final pointer = JsonPointer(pointerKey); + return pointer.read(_document, orElse: () => null); + } +} diff --git a/lib/src/config/multi_config_source.dart b/lib/src/config/multi_config_source.dart new file mode 100644 index 0000000..92bb3b4 --- /dev/null +++ b/lib/src/config/multi_config_source.dart @@ -0,0 +1,95 @@ +import 'config_source_provider.dart'; +import 'configuration.dart'; + +/// A [ConfigurationBroker] that combines configuration sources +/// from multiple providers, called configuration *domains*. +/// +/// Each configuration value is identified by a *qualified key*. +/// The first domain that matches the qualified key is used to retrieve the value. +/// This means that the order of the domains is significant if the +/// matching patterns overlap. +/// +/// Domains are matched using [Pattern], e.g. string prefixes or regular expressions. +/// +/// ### Regex domains +/// {@template multi_domain_config_broker.regex} +/// When using regular expressions to identify the domain, the value key is derived from +/// the qualified key depending on the capturing groups in the regex. +/// +/// - If the regex has no capturing groups: +/// - If the regex matches a shorter string than the qualified key, the value key is the remainder after the match.\ +/// This makes prefix matching simple. +/// - If the regex matches the entire qualified key, the value key is the entire qualified key.\ +/// This can be used for specific syntaxes like URLs. +/// +/// - If the regex has one or more capturing groups:\ +/// The value key is the string captured by the first group. +/// {@endtemplate} +class MultiDomainConfigBroker + implements ConfigurationBroker { + final Map> _configSourceProviders; + + /// Creates a [MultiDomainConfigBroker] with the given + /// configuration source providers, each identified by matching the + /// qualified key against a [Pattern]. + MultiDomainConfigBroker._(this._configSourceProviders); + + /// Creates a [MultiDomainConfigBroker] with the given + /// configuration source providers, each identified by matching the + /// qualified key against a [RegExp]. + /// + /// {@macro multi_domain_config_broker.regex} + MultiDomainConfigBroker.regex( + final Map> regexDomains, + ) : this._({ + for (final entry in regexDomains.entries) + RegExp(entry.key): entry.value, + }); + + /// Creates a [MultiDomainConfigBroker] from a map with domain prefixes as keys. + /// Each configuration value will be identified by a qualified key, + /// which consists of a domain name and a value key separated by a colon. + /// + /// E.g. `myapp:my_setting_name` + /// + /// The domain prefixes must not contain colons, and it is recommended + /// to only use lowercase letters, numbers, and underscores. + MultiDomainConfigBroker.prefix( + final Map> prefixDomains, + ) : this._({ + for (final entry in prefixDomains.entries) + '${entry.key}:': entry.value, + }); + + @override + Object? valueOrNull( + final String qualifiedKey, + final Configuration cfg, + ) { + final matchingProvider = _configSourceProviders.entries + .map( + (final entry) => (entry, entry.key.matchAsPrefix(qualifiedKey)), + ) + .firstWhere( + (final matches) => matches.$2 != null, + orElse: () => throw StateError( + 'No matching configuration domain for key: $qualifiedKey'), + ); + + final configSource = matchingProvider.$1.value.getConfigSource(cfg); + final match = matchingProvider.$2!; + + final String valueKey; + if (match.groupCount > 0) { + valueKey = match.group(1)!; + } else { + if (match.end < qualifiedKey.length) { + valueKey = qualifiedKey.substring(match.end); + } else { + valueKey = qualifiedKey; + } + } + + return configSource.valueOrNull(valueKey); + } +} diff --git a/lib/src/config/option_groups.dart b/lib/src/config/option_groups.dart new file mode 100644 index 0000000..b9b91eb --- /dev/null +++ b/lib/src/config/option_groups.dart @@ -0,0 +1,42 @@ +import 'configuration.dart'; +import 'option_resolution.dart'; + +/// An option group for mutually exclusive options. +/// +/// No more than one of the options in the group can be specified. +/// +/// Optionally the group can allow defaults, i.e. default values +/// are disregarded when counting the number of specified options. +/// +/// Optionally the group can be made mandatory, in which case +/// one of its options must be specified. +class MutuallyExclusive extends OptionGroup { + final bool mandatory; + final bool allowDefaults; + + const MutuallyExclusive( + super.name, { + this.mandatory = false, + this.allowDefaults = false, + }); + + @override + String? validate( + final Map optionResolutions, + ) { + final providedCount = optionResolutions.values + .where((final r) => allowDefaults ? r.isSpecified : r.hasValue) + .length; + + if (providedCount > 1) { + final opts = optionResolutions.keys.map((final o) => o.option); + return 'These options are mutually exclusive: ${opts.join(', ')}'; + } + + if (mandatory && providedCount == 0) { + return 'Option group $name requires one of the options to be provided'; + } + + return null; + } +} diff --git a/lib/src/config/option_resolution.dart b/lib/src/config/option_resolution.dart new file mode 100644 index 0000000..bba7af4 --- /dev/null +++ b/lib/src/config/option_resolution.dart @@ -0,0 +1,55 @@ +import 'source_type.dart'; + +final class OptionResolution { + final String? stringValue; + final V? value; + final String? error; + final ValueSourceType source; + + const OptionResolution._({ + required this.source, + this.stringValue, + this.value, + this.error, + }); + + const OptionResolution({ + required this.source, + this.stringValue, + this.value, + }) : error = null; + + const OptionResolution.noValue() + : source = ValueSourceType.noValue, + stringValue = null, + value = null, + error = null; + + const OptionResolution.error(this.error) + : source = ValueSourceType.noValue, + stringValue = null, + value = null; + + OptionResolution copyWithValue(final V newValue) => OptionResolution._( + source: source, + stringValue: stringValue, + value: newValue, + error: error, + ); + + OptionResolution copyWithError(final String error) => OptionResolution._( + source: source, + stringValue: stringValue, + value: value, + error: error, + ); + + /// Whether the option has a proper value (without errors). + bool get hasValue => source != ValueSourceType.noValue && error == null; + + /// Whether the option has a value that was specified explicitly (not default). + bool get isSpecified => hasValue && source != ValueSourceType.defaultValue; + + /// Whether the option has the default value. + bool get isDefault => hasValue && source == ValueSourceType.defaultValue; +} diff --git a/lib/src/config/options.dart b/lib/src/config/options.dart new file mode 100644 index 0000000..1352e63 --- /dev/null +++ b/lib/src/config/options.dart @@ -0,0 +1,388 @@ +import 'package:meta/meta.dart'; + +import 'configuration.dart'; + +/// ValueParser that returns the input string unchanged. +class StringParser extends ValueParser { + const StringParser(); + + @override + String parse(final String value) { + return value; + } +} + +/// String value configuration option. +class StringOption extends ConfigOptionBase { + const StringOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super( + valueParser: const StringParser(), + ); +} + +/// Convenience class for multi-value configuration option for strings. +class MultiStringOption extends MultiOption { + /// Creates a MultiStringOption which splits input strings on commas. + const MultiStringOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super( + multiParser: const MultiParser(elementParser: StringParser()), + ); + + /// Creates a MultiStringOption which treats input strings as single elements. + const MultiStringOption.noSplit({ + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super( + multiParser: const MultiParser( + elementParser: StringParser(), + separator: null, + ), + ); +} + +/// Parses a string value into an enum value. +/// Currently requires an exact, case-sensitive match. +class EnumParser extends ValueParser { + final List enumValues; + + const EnumParser(this.enumValues); + + @override + E parse(final String value) { + return enumValues.firstWhere( + (final e) => e.name == value, + orElse: () => throw FormatException( + '"$value" is not in ${valueHelpString()}', + ), + ); + } + + String valueHelpString() { + return enumValues.map((final e) => e.name).join('|'); + } +} + +/// Enum value configuration option. +/// +/// If the input is not one of the enum names, +/// the validation throws a [FormatException]. +class EnumOption extends ConfigOptionBase { + const EnumOption({ + required final EnumParser enumParser, + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super(valueParser: enumParser); + + @override + String? valueHelpString() { + return valueHelp ?? (valueParser as EnumParser).valueHelpString(); + } +} + +/// Base class for configuration options that +/// support minimum and maximum range checking. +/// +/// If the input is outside the specified limits +/// the validation throws a [FormatException]. +class ComparableValueOption extends ConfigOptionBase { + final V? min; + final V? max; + + const ComparableValueOption({ + required super.valueParser, + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + this.min, + this.max, + }); + + @override + @mustCallSuper + void validateValue(final V value) { + super.validateValue(value); + + final mininum = min; + if (mininum != null && value.compareTo(mininum) < 0) { + throw FormatException( + '${valueParser.format(value)} is below the minimum ' + '(${valueParser.format(mininum)})', + ); + } + final maximum = max; + if (maximum != null && value.compareTo(maximum) > 0) { + throw FormatException( + '${valueParser.format(value)} is above the maximum ' + '(${valueParser.format(maximum)})', + ); + } + } +} + +class IntParser extends ValueParser { + const IntParser(); + + @override + int parse(final String value) { + return int.parse(value); + } +} + +/// Integer value configuration option. +/// +/// Supports minimum and maximum range checking. +class IntOption extends ComparableValueOption { + const IntOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'integer', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const IntParser()); +} + +/// Parses a date string into a [DateTime] object. +/// Throws [FormatException] if parsing failed. +/// +/// This implementation is more forgiving than [DateTime.parse]. +/// In addition to the standard T and space separators between +/// date and time it also allows [-_/:t]. +class DateTimeParser extends ValueParser { + const DateTimeParser(); + + @override + DateTime parse(final String value) { + final val = DateTime.tryParse(value); + if (val != null) return val; + if (value.length >= 11 && '-_/:t'.contains(value[10])) { + final val = + DateTime.tryParse('${value.substring(0, 10)}T${value.substring(11)}'); + if (val != null) return val; + } + throw FormatException('Invalid date-time "$value"'); + } +} + +/// Date-time value configuration option. +/// +/// Supports minimum and maximum range checking. +class DateTimeOption extends ComparableValueOption { + const DateTimeOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'YYYY-MM-DDtHH:MM:SSz', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const DateTimeParser()); +} + +/// Parses a duration string into a [Duration] object. +/// +/// The input string must be a number followed by an optional unit +/// which is one of: seconds (s), minutes (m), hours (h), days (d), +/// milliseconds (ms), or microseconds (us). +/// If no unit is specified, seconds are assumed. +/// Examples: +/// - `10`, equivalent to `10s` +/// - `10m` +/// - `10h` +/// - `10d` +/// - `10ms` +/// - `10us` +/// +/// Throws [FormatException] if parsing failed. +class DurationParser extends ValueParser { + const DurationParser(); + + @override + Duration parse(final String value) { + // integer followed by an optional unit (s, m, h, d, ms, us) + const pattern = r'^(-?\d+)([smhd]|ms|us)?$'; + final regex = RegExp(pattern); + final match = regex.firstMatch(value); + + if (match == null || match.groupCount != 2) { + throw FormatException('Invalid duration value "$value"'); + } + final valueStr = match.group(1); + final unit = match.group(2) ?? 's'; + final val = int.parse(valueStr ?? ''); + switch (unit) { + case 's': + return Duration(seconds: val); + case 'm': + return Duration(minutes: val); + case 'h': + return Duration(hours: val); + case 'd': + return Duration(days: val); + case 'ms': + return Duration(milliseconds: val); + case 'us': + return Duration(microseconds: val); + default: + throw FormatException('Invalid duration unit "$unit".'); + } + } + + @override + String format(final Duration value) { + if (value == Duration.zero) return '0s'; + + final sign = value.isNegative ? '-' : ''; + final d = _unitStr(value.inDays, null, 'd'); + final h = _unitStr(value.inHours, 24, 'h'); + final m = _unitStr(value.inMinutes, 60, 'm'); + final s = _unitStr(value.inSeconds, 60, 's'); + final ms = _unitStr(value.inMilliseconds, 1000, 'ms'); + final us = _unitStr(value.inMicroseconds, 1000, 'us'); + + return '$sign$d$h$m$s$ms$us'; + } + + static String _unitStr(final int value, final int? mod, final String unit) { + final absValue = value.abs(); + if (mod == null) { + return absValue > 0 ? '$absValue$unit' : ''; + } + return absValue % mod > 0 ? '${absValue.remainder(mod)}$unit' : ''; + } +} + +/// Duration value configuration option. +/// +/// Supports minimum and maximum range checking. +class DurationOption extends ComparableValueOption { + const DurationOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'integer[s|m|h|d]', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const DurationParser()); + + @override + String? defaultValueString() { + final defValue = defaultValue(); + if (defValue == null) return null; + return valueParser.format(defValue); + } +} diff --git a/lib/src/config/source_type.dart b/lib/src/config/source_type.dart new file mode 100644 index 0000000..b6058bc --- /dev/null +++ b/lib/src/config/source_type.dart @@ -0,0 +1,10 @@ +/// The type of source that an option's value was resolved from. +enum ValueSourceType { + noValue, + preset, + arg, + envVar, + config, + custom, + defaultValue, +} diff --git a/pubspec.lock b/pubspec.lock index bfea99a..55a172d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -26,10 +26,10 @@ packages: dependency: "direct main" description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: @@ -63,7 +63,7 @@ packages: source: hosted version: "0.1.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -215,7 +215,7 @@ packages: source: hosted version: "0.12.16+1" meta: - dependency: transitive + dependency: "direct main" description: name: meta sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c @@ -294,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + rfc_6901: + dependency: "direct main" + description: + name: rfc_6901 + sha256: df1bbfa3d023009598f19636d6114c6ac1e0b7bb7bf6a260f0e6e6ce91416820 + url: "https://pub.dev" + source: hosted + version: "0.2.0" serverpod_lints: dependency: "direct dev" description: @@ -422,6 +430,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.8" + test_descriptor: + dependency: "direct dev" + description: + name: test_descriptor + sha256: "9ce468c97ae396e8440d26bb43763f84e2a2a5331813ee5a397cb4da481aaf9a" + url: "https://pub.dev" + source: hosted + version: "2.0.2" type_plus: dependency: transitive description: @@ -490,9 +506,25 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" + yaml_codec: + dependency: "direct main" + description: + name: yaml_codec + sha256: a75848e1ccd9526959d1b3dd41b3b14a652f93aeadfb9261a7d6446072ef214c + url: "https://pub.dev" + source: hosted + version: "1.0.0" + yaml_writer: + dependency: transitive + description: + name: yaml_writer + sha256: "69651cd7238411179ac32079937d4aa9a2970150d6b2ae2c6fe6de09402a5dc5" + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.5.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 36ee76e..35784ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,14 +15,19 @@ environment: sdk: ^3.1.0 dependencies: - args: ^2.4.0 + args: ^2.7.0 ci: ^0.1.0 + collection: ^1.19.1 http: '>=0.13.0 <2.0.0' + meta: ^1.16.0 path: ^1.8.2 pub_api_client: '>=2.4.0 <4.0.0' pub_semver: ^2.1.4 + rfc_6901: ^0.2.0 super_string: ^1.0.3 + yaml_codec: ^1.0.0 dev_dependencies: test: ^1.24.0 serverpod_lints: '>=1.2.6' + test_descriptor: ^2.0.2 diff --git a/test/better_command_runner/logging_test.dart b/test/better_command_runner/logging_test.dart index 7661d93..e7abfa0 100644 --- a/test/better_command_runner/logging_test.dart +++ b/test/better_command_runner/logging_test.dart @@ -71,7 +71,12 @@ void main() { test('then could not find message is logged to error.', () async { expect(errors, hasLength(1)); - expect(errors.first, contains('Could not find')); + expect( + errors.first, + contains( + 'Unexpected positional argument(s): \'this it not a valid command\'', + ), + ); }); }); diff --git a/test/better_command_test.dart b/test/better_command_test.dart index e76d91b..aa2323b 100644 --- a/test/better_command_test.dart +++ b/test/better_command_test.dart @@ -1,16 +1,32 @@ +import 'dart:async'; + import 'package:cli_tools/better_command_runner.dart'; +import 'package:cli_tools/config.dart'; import 'package:test/test.dart'; +enum BespokeGlobalOption implements OptionDefinition { + quiet(BetterCommandRunnerFlags.quietOption), + verbose(BetterCommandRunnerFlags.verboseOption), + analytics(BetterCommandRunnerFlags.analyticsOption), + age(IntOption(argName: 'age', helpText: 'Required age', min: 0, max: 100)); + + const BespokeGlobalOption(this.option); + + @override + final ConfigOptionBase option; +} + class MockCommand extends BetterCommand { static String commandName = 'mock-command'; - MockCommand({super.messageOutput}) { - argParser.addOption( - 'name', - defaultsTo: 'serverpod', - allowed: ['serverpod', 'stockholm'], - ); - } + MockCommand({super.messageOutput}) + : super(options: [ + const StringOption( + argName: 'name', + defaultsTo: 'serverpod', + allowedValues: ['serverpod', 'stockholm'], + ) + ]); @override String get description => 'Mock command used for testing'; @@ -19,27 +35,313 @@ class MockCommand extends BetterCommand { String get name => commandName; @override - void run() {} + Future run() async {} + + @override + FutureOr? runWithConfig(Configuration commandConfig) { + throw UnimplementedError(); + } } void main() { - group('Given a better command registered in the better command runner', () { + group( + 'Given a better command registered in the better command runner ' + 'with analytics set up and default global options', () { + var infos = []; + var analyticsEvents = []; + var messageOutput = MessageOutput( + logUsage: (u) => infos.add(u), + ); + + var betterCommand = MockCommand( + messageOutput: messageOutput, + ); + var runner = BetterCommandRunner( + 'test', + 'test project', + onAnalyticsEvent: (e) => analyticsEvents.add(e), + messageOutput: messageOutput, + )..addCommand(betterCommand); + + setUp(() { + infos.clear(); + analyticsEvents.clear(); + }); + + test( + 'when running base command with --help flag ' + 'then global usage is printed to commands logInfo', () async { + await runner.run(['--help']); + + expect(infos, hasLength(1)); + expect(infos.single, runner.usage); + expect( + infos.single, + stringContainsInOrder([ + 'Usage: test [arguments]', + 'Global options:', + '-h, --help', + 'Print this usage information.', + '-q, --quiet', + 'Suppress all cli output. Is overridden by -v, --verbose.', + '-v, --verbose', + 'Prints additional information useful for development. Overrides --q, --quiet.', + '-a, --[no-]analytics', + 'Toggles if analytics data is sent.', + ]), + ); + }); + + test( + 'when running base command with --help flag ' + 'then help analytics is sent', () async { + await runner.run(['--help']); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(analyticsEvents, hasLength(1)); + expect(analyticsEvents.single, 'help'); + }); + + test( + 'when running with subcommand `help` ' + 'then help analytics is sent', () async { + await runner.run(['help']); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(analyticsEvents, hasLength(1)); + expect(analyticsEvents.single, 'help'); + }); + + test( + 'when running with subcommand `mock-command` ' + 'then subcommand analytics is sent', () async { + await runner.run([MockCommand.commandName]); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(analyticsEvents, hasLength(1)); + expect(analyticsEvents.single, 'mock-command'); + }); + + test( + 'when running with invalid subcommand ' + 'then invalid command analytics is sent', () async { + await runner.run(['no-such-command']).catchError((_) {}); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(analyticsEvents, hasLength(1)); + expect(analyticsEvents.single, 'invalid'); + }); + + test( + 'when running with subcommand `mock-command` ' + 'and --no-analytics flag ' + 'then subcommand analytics is not sent', () async { + await runner.run([MockCommand.commandName, '--no-analytics']); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(analyticsEvents, isEmpty); + }); + }); + + group( + 'Given a better command registered in the better command runner ' + 'with analytics set up and empty global options', () { + var infos = []; + var analyticsEvents = []; + var messageOutput = MessageOutput( + logUsage: (u) => infos.add(u), + ); + + var betterCommand = MockCommand( + messageOutput: messageOutput, + ); + var runner = BetterCommandRunner( + 'test', + 'test project', + onAnalyticsEvent: (e) => analyticsEvents.add(e), + globalOptions: [], + messageOutput: messageOutput, + )..addCommand(betterCommand); + + setUp(() { + infos.clear(); + analyticsEvents.clear(); + }); + + test( + 'when running base command with --help flag ' + 'then global usage is printed to commands logInfo', + () async { + await runner.run(['--help']); + + expect(infos, hasLength(1)); + expect(infos.single, runner.usage); + expect( + infos.single, + allOf( + stringContainsInOrder([ + 'Usage: test [arguments]', + 'Global options:', + '-h, --help', + 'Print this usage information.', + ]), + isNot(contains('-q, --quiet')), + isNot(contains('-v, --verbose')), + isNot(contains('-a, --[no-]analytics')), + ), + ); + }, + ); + }); + + group( + 'Given a better command registered in the better command runner ' + 'without analytics set up and default global options', () { + var infos = []; + var messageOutput = MessageOutput( + logUsage: (u) => infos.add(u), + ); + + var betterCommand = MockCommand( + messageOutput: messageOutput, + ); + var runner = BetterCommandRunner( + 'test', + 'test project', + messageOutput: messageOutput, + )..addCommand(betterCommand); + + setUp(() { + infos.clear(); + }); + + test( + 'when running base command with --help flag ' + 'then global usage is printed to commands logInfo', () async { + await runner.run(['--help']); + + expect(infos, hasLength(1)); + expect(infos.single, runner.usage); + expect( + infos.single, + allOf( + stringContainsInOrder([ + 'Usage: test [arguments]', + 'Global options:', + '-h, --help', + 'Print this usage information.', + '-q, --quiet', + 'Suppress all cli output. Is overridden by -v, --verbose.', + '-v, --verbose', + 'Prints additional information useful for development. Overrides --q, --quiet.', + ]), + isNot(contains( + '-a, --[no-]analytics', + )), + ), + ); + }); + }); + + group( + 'Given a better command registered in the better command runner ' + 'with additional global options', () { var infos = []; + var messageOutput = MessageOutput( + logUsage: (u) => infos.add(u), + ); + var betterCommand = MockCommand( - messageOutput: MessageOutput( - logUsage: (u) => infos.add(u), - ), + messageOutput: messageOutput, + ); + var runner = BetterCommandRunner( + 'test', + 'test project', + globalOptions: BespokeGlobalOption.values, + messageOutput: messageOutput, + )..addCommand(betterCommand); + + setUp(() { + infos.clear(); + }); + + test( + 'when running base command with --help flag ' + 'then global usage is printed to commands logInfo', + () async { + await runner.run(['--help']); + + expect(infos, hasLength(1)); + expect(infos.single, runner.usage); + expect( + infos.single, + stringContainsInOrder([ + 'Usage: test [arguments]', + 'Global options:', + '-h, --help', + 'Print this usage information.', + '-q, --quiet', + 'Suppress all cli output. Is overridden by -v, --verbose.', + '-v, --verbose', + 'Prints additional information useful for development. Overrides --q, --quiet.', + '-a, --[no-]analytics', + 'Toggles if analytics data is sent.', + '--age=', + 'Required age', + 'Available commands:', + 'mock-command', + 'Mock command used for testing', + ])); + }, + ); + + test( + 'when running with subcommand `help` ' + 'then global usage is printed to commands logInfo', + () async { + await runner.run(['help']); + + expect(infos, hasLength(1)); + expect(infos.single, runner.usage); + expect( + infos.single, + stringContainsInOrder([ + 'Usage: test [arguments]', + 'Global options:', + '-h, --help', + 'Print this usage information.', + '-q, --quiet', + 'Suppress all cli output. Is overridden by -v, --verbose.', + '-v, --verbose', + 'Prints additional information useful for development. Overrides --q, --quiet.', + '-a, --[no-]analytics', + 'Toggles if analytics data is sent.', + '--age=', + 'Required age', + 'Available commands:', + 'mock-command', + 'Mock command used for testing', + ])); + }, ); - var runner = BetterCommandRunner('test', 'test project') - ..addCommand(betterCommand); test( - 'when running command option --help flag then usage is printed to commands logInfo', + 'when running with subcommand `mock-command` and option --help flag ' + 'then subcommand usage is printed to commands logInfo', () async { await runner.run([MockCommand.commandName, '--help']); expect(infos, hasLength(1)); - expect(infos.first, betterCommand.usage); + expect(infos.single, betterCommand.usage); + expect( + infos.single, + stringContainsInOrder([ + 'Usage: test mock-command [arguments]', + '-h, --help', + 'Print this usage information.', + '--name', + '[serverpod (default), stockholm]', + ])); }, ); }); diff --git a/test/config/args_compatibility/allow_anything_test.dart b/test/config/args_compatibility/allow_anything_test.dart new file mode 100644 index 0000000..2755569 --- /dev/null +++ b/test/config/args_compatibility/allow_anything_test.dart @@ -0,0 +1,70 @@ +@Skip('ArgParser.allowAnything() not supported') +library; + +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('new ArgParser.allowAnything()', () { + late ArgParser parser; + setUp(() { + parser = ArgParser.allowAnything(); + }); + + test('exposes empty values', () { + expect(parser.options, isEmpty); + expect(parser.commands, isEmpty); + expect(parser.allowTrailingOptions, isFalse); + expect(parser.allowsAnything, isTrue); + expect(parser.usage, isEmpty); + expect(parser.findByAbbreviation('a'), isNull); + }); + + test('mutation methods throw errors', () { + expect(() => parser.addCommand('command'), throwsUnsupportedError); + expect(() => parser.addFlag('flag'), throwsUnsupportedError); + expect(() => parser.addOption('option'), throwsUnsupportedError); + expect(() => parser.addSeparator('==='), throwsUnsupportedError); + }); + + test('getDefault() throws an error', () { + expect(() => parser.defaultFor('option'), throwsArgumentError); + }); + + test('parses all values as rest arguments', () { + var results = parser.parse(['--foo', '-abc', '--', 'bar']); + expect(results.options, isEmpty); + expect(results.rest, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.arguments, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.command, isNull); + expect(results.name, isNull); + }); + + test('works as a subcommand', () { + var commandParser = ArgParser()..addCommand('command', parser); + var results = + commandParser.parse(['command', '--foo', '-abc', '--', 'bar']); + expect(results.command!.options, isEmpty); + expect(results.command!.rest, equals(['--foo', '-abc', '--', 'bar'])); + expect( + results.command!.arguments, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.command!.name, equals('command')); + }); + + test('works as a subcommand in a CommandRunner', () async { + var commandRunner = + CommandRunner('command', 'Description of command'); + var command = AllowAnythingCommand(); + commandRunner.addCommand(command); + + await commandRunner.run([command.name, '--foo', '--bar', '-b', 'qux']); + }); + }); +} diff --git a/test/config/args_compatibility/args_test.dart b/test/config/args_compatibility/args_test.dart new file mode 100644 index 0000000..926c4fe --- /dev/null +++ b/test/config/args_compatibility/args_test.dart @@ -0,0 +1,353 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:args/args.dart'; +import 'package:test/test.dart'; + +import 'package:cli_tools/config.dart'; + +import 'test_utils.dart'; + +void main() { + group('ConfigParser.addFlag()', () { + test('throws ArgumentError if the flag already exists', () { + var parser = ConfigParser(); + parser.addFlag('foo'); + throwsIllegalArg(() => parser.addFlag('foo')); + }); + + test('throws ArgumentError if the option already exists', () { + var parser = ConfigParser(); + parser.addOption('foo'); + throwsIllegalArg(() => parser.addFlag('foo')); + }); + + test('throws ArgumentError if the abbreviation exists', () { + var parser = ConfigParser(); + parser.addFlag('foo', abbr: 'f'); + throwsIllegalArg(() => parser.addFlag('flummox', abbr: 'f')); + }); + + test( + 'throws ArgumentError if the abbreviation is longer ' + 'than one character', () { + var parser = ConfigParser(); + throwsIllegalArg(() => parser.addFlag('flummox', abbr: 'flu')); + }); + + test('throws ArgumentError if a flag name is invalid', () { + var parser = ConfigParser(); + + for (var name in _invalidOptions) { + var reason = '${Error.safeToString(name)} is not valid'; + throwsIllegalArg(() => parser.addFlag(name), reason: reason); + } + }); + + test('accepts valid flag names', () { + var parser = ConfigParser(); + + for (var name in _validOptions) { + var reason = '${Error.safeToString(name)} is valid'; + expect(() => parser.addFlag(name), returnsNormally, reason: reason); + } + }); + }); + + group('ConfigParser.addOption()', () { + test('throws ArgumentError if the flag already exists', () { + var parser = ConfigParser(); + parser.addFlag('foo'); + throwsIllegalArg(() => parser.addOption('foo')); + }); + + test('throws ArgumentError if the option already exists', () { + var parser = ConfigParser(); + parser.addOption('foo'); + throwsIllegalArg(() => parser.addOption('foo')); + }); + + test('throws ArgumentError if the abbreviation exists', () { + var parser = ConfigParser(); + parser.addFlag('foo', abbr: 'f'); + throwsIllegalArg(() => parser.addOption('flummox', abbr: 'f')); + }); + + test( + 'throws ArgumentError if the abbreviation is longer ' + 'than one character', () { + var parser = ConfigParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: 'flu')); + }); + + test('throws ArgumentError if the abbreviation is empty', () { + var parser = ConfigParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: '')); + }); + + test('throws ArgumentError if the abbreviation is an invalid value', () { + var parser = ConfigParser(); + for (var name in _invalidOptions) { + throwsIllegalArg(() => parser.addOption('flummox', abbr: name)); + } + }); + + test('throws ArgumentError if the abbreviation is a dash', () { + var parser = ConfigParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: '-')); + }); + + test('allows explict null value for "abbr"', () { + var parser = ConfigParser(); + expect(() => parser.addOption('flummox', abbr: null), returnsNormally); + }); + + test('throws ArgumentError if an option name is invalid', () { + var parser = ConfigParser(); + + for (var name in _invalidOptions) { + var reason = '${Error.safeToString(name)} is not valid'; + throwsIllegalArg(() => parser.addOption(name), reason: reason); + } + }); + + test('accepts valid option names', () { + var parser = ConfigParser(); + + for (var name in _validOptions) { + var reason = '${Error.safeToString(name)} is valid'; + expect(() => parser.addOption(name), returnsNormally, reason: reason); + } + }); + }); + + group('ConfigParser.getDefault()', () { + test('returns the default value for an option', () { + var parser = ConfigParser(); + parser.addOption('mode', defaultsTo: 'debug'); + expect(parser.defaultFor('mode'), 'debug'); + }); + + test('throws if the option is unknown', () { + var parser = ConfigParser(); + parser.addOption('mode', defaultsTo: 'debug'); + throwsIllegalArg(() => parser.defaultFor('undefined')); + }); + }); + + group('ConfigParser.commands', () { + test('returns an empty map if there are no commands', () { + var parser = ConfigParser(); + expect(parser.commands, isEmpty); + }); + + test('returns the commands that were added', () { + var parser = ConfigParser(); + parser.addCommand('hide'); + parser.addCommand('seek'); + expect(parser.commands, hasLength(2)); + expect(parser.commands['hide'], isNotNull); + expect(parser.commands['seek'], isNotNull); + }, skip: 'commands not supported'); + + test('iterates over the commands in the order they were added', () { + var parser = ConfigParser(); + parser.addCommand('a'); + parser.addCommand('d'); + parser.addCommand('b'); + parser.addCommand('c'); + expect(parser.commands.keys, equals(['a', 'd', 'b', 'c'])); + }, skip: 'commands not supported'); + }); + + group('ConfigParser.options', () { + test('returns an empty map if there are no options', () { + var parser = ConfigParser(); + expect(parser.options, isEmpty); + }); + + test('returns the options that were added', () { + var parser = ConfigParser(); + parser.addFlag('hide'); + parser.addOption('seek'); + expect(parser.options, hasLength(2)); + expect(parser.options['hide'], isNotNull); + expect(parser.options['seek'], isNotNull); + }); + + test('iterates over the options in the order they were added', () { + var parser = ConfigParser(); + parser.addFlag('a'); + parser.addOption('d'); + parser.addFlag('b'); + parser.addOption('c'); + expect(parser.options.keys, equals(['a', 'd', 'b', 'c'])); + }); + }); + + group('ConfigParser.findByNameOrAlias', () { + test('returns null if there is no match', () { + var parser = ConfigParser(); + expect(parser.findByNameOrAlias('a'), isNull); + }); + + test('can find options by alias', () { + var parser = ConfigParser()..addOption('a', aliases: ['b']); + expect(parser.findByNameOrAlias('b'), + isA