diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index 69f935f..086c40a 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -3,8 +3,11 @@ export 'config_source_provider.dart'; export 'config_source.dart'; export 'configuration_parser.dart'; export 'configuration.dart'; +export 'configuration_broker.dart'; +export 'exceptions.dart'; export 'file_system_options.dart'; export 'multi_config_source.dart'; -export 'options.dart'; export 'option_groups.dart'; +export 'option_types.dart'; +export 'options.dart'; export 'source_type.dart'; diff --git a/lib/src/config/config_parser.dart b/lib/src/config/config_parser.dart index 7a1f24e..45ada3b 100644 --- a/lib/src/config/config_parser.dart +++ b/lib/src/config/config_parser.dart @@ -4,6 +4,8 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart' show UsageException; import 'configuration.dart'; +import 'configuration_broker.dart'; +import 'option_types.dart'; import 'options.dart'; import 'source_type.dart'; diff --git a/lib/src/config/config_source_provider.dart b/lib/src/config/config_source_provider.dart index e042216..519eb15 100644 --- a/lib/src/config/config_source_provider.dart +++ b/lib/src/config/config_source_provider.dart @@ -1,6 +1,7 @@ import 'config_source.dart'; import 'configuration.dart'; import 'configuration_parser.dart'; +import 'options.dart'; /// Provider of a [ConfigurationSource] that is dynamically /// based on the current configuration. diff --git a/lib/src/config/configuration.dart b/lib/src/config/configuration.dart index ae73d08..ed31da8 100644 --- a/lib/src/config/configuration.dart +++ b/lib/src/config/configuration.dart @@ -1,776 +1,45 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; +import 'configuration_broker.dart'; +import 'exceptions.dart'; +import 'options.dart'; import 'option_resolution.dart'; import 'source_type.dart'; -/// Common interface to enable same treatment for [ConfigOptionBase] -/// and option enums. -/// -/// [V] is the type of the value this option provides. -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 configuration option definitions as a group. - /// - /// This method is called by [prepareOptionsForParsing] to validate - /// the configuration option definitions as a group. - /// Throws an error if any definition is invalid as part of this group. - /// - /// Subclasses may override this method to perform specific validations. - void validateDefinitions(List options) {} - - /// 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? validateValues( - 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 -/// -/// [V] is the type of the value this option provides. -/// Option values are parsed to this type 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. -/// -/// A [customValidator] may be provided for an individual option. -/// If a value was provided the customValidator is invoked, -/// and shall throw a [FormatException] if its format is invalid, -/// or a [UsageException] if the it is invalid for other reasons. -/// -/// ### 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 +/// A configuration object that holds the values for a set of configuration options. /// -/// 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. +/// The typical usage pattern is to use an enum with the options +/// and implement the configuration like so: +/// ```dart +/// import 'dart:io' show Platform; +/// import 'package:cli_tools/config.dart'; /// -/// Note that this prevents an option from being provided both -/// named and positional on the same command line. +/// enum MyAppOption implements OptionDefinition { +/// username(StringOption( +/// argName: 'username', +/// envName: 'USERNAME', +/// mandatory: true, +/// )); /// -/// ### Mandatory and Default +/// const MyAppOption(this.option); /// -/// 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]. +/// @override +/// final ConfigOptionBase option; +/// } /// -/// 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. +/// void main(final List args) async { +/// final config = Configuration.resolve( +/// options: MyAppOption.values, +/// args: args, +/// env: Platform.environment, +/// ); /// -/// 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() { - final defValue = defaultValue(); - if (defValue == null) return null; - return valueParser.format(defValue); - } - - 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 const 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( - 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 = {}; - - final optionGroups = >{}; - - 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; - } - - final group = opt.option.group; - if (group != null) { - optionGroups.update( - group, - (final value) => [...value, opt], - ifAbsent: () => [opt], - ); - } - } - - optionGroups.forEach((final group, final options) { - group.validateDefinitions(options); - }); - - 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. +/// final username = config.value(MyAppOption.username); +/// print('Provided username is $username'); +/// } +/// ``` class Configuration { final List _options; final Map _config; @@ -840,6 +109,9 @@ class Configuration { ); } + /// Returns the usage help text for the options of this configuration. + String get usage => _options.usage; + /// Gets the option definitions for this configuration. Iterable get options => _config.keys; @@ -981,7 +253,7 @@ class Configuration { if (presetValues != null && presetValues.containsKey(opt)) { resolution = _resolvePresetValue(opt, presetValues[opt]); } else { - resolution = opt.option._resolveValue( + resolution = opt.option.resolveValue( this, args: args, posArgs: posArgs, @@ -1030,7 +302,7 @@ class Configuration { ? const OptionResolution.noValue() : OptionResolution(value: value, source: ValueSourceType.preset); - final error = option.option._validateOptionValue(value); + final error = option.option.validateOptionValue(value); if (error != null) return resolution.copyWithError(error); return resolution; } @@ -1059,21 +331,6 @@ extension _RestAsList on Iterator { } } -/// 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. diff --git a/lib/src/config/configuration_broker.dart b/lib/src/config/configuration_broker.dart new file mode 100644 index 0000000..a8b1a3f --- /dev/null +++ b/lib/src/config/configuration_broker.dart @@ -0,0 +1,12 @@ +import 'configuration.dart'; +import 'options.dart'; + +/// 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); +} diff --git a/lib/src/config/exceptions.dart b/lib/src/config/exceptions.dart new file mode 100644 index 0000000..810c9b3 --- /dev/null +++ b/lib/src/config/exceptions.dart @@ -0,0 +1,16 @@ +import 'package:cli_tools/src/config/options.dart'; + +/// 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()}'; + } +} diff --git a/lib/src/config/file_system_options.dart b/lib/src/config/file_system_options.dart index 1fe0668..c553ef3 100644 --- a/lib/src/config/file_system_options.dart +++ b/lib/src/config/file_system_options.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import 'configuration.dart'; +import 'options.dart'; enum PathExistMode { mayExist, diff --git a/lib/src/config/multi_config_source.dart b/lib/src/config/multi_config_source.dart index 92bb3b4..07f8af2 100644 --- a/lib/src/config/multi_config_source.dart +++ b/lib/src/config/multi_config_source.dart @@ -1,5 +1,7 @@ import 'config_source_provider.dart'; import 'configuration.dart'; +import 'configuration_broker.dart'; +import 'options.dart'; /// A [ConfigurationBroker] that combines configuration sources /// from multiple providers, called configuration *domains*. diff --git a/lib/src/config/option_groups.dart b/lib/src/config/option_groups.dart index 49bdb2a..2514ae2 100644 --- a/lib/src/config/option_groups.dart +++ b/lib/src/config/option_groups.dart @@ -1,5 +1,6 @@ -import 'configuration.dart'; +import 'exceptions.dart'; import 'option_resolution.dart'; +import 'options.dart'; enum MutuallyExclusiveMode { noDefaults, diff --git a/lib/src/config/option_types.dart b/lib/src/config/option_types.dart new file mode 100644 index 0000000..c4b6a17 --- /dev/null +++ b/lib/src/config/option_types.dart @@ -0,0 +1,439 @@ +import 'package:meta/meta.dart'; + +import 'options.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(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(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()}', + ), + ); + } + + @override + String format(final E value) { + return value.name; + } + + 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]. +/// +/// Due to Dart's const semantics, the EnumParser must be +/// provided by the caller, like so: +/// +/// ```dart +/// EnumOption( +/// enumParser: EnumParser(AnimalEnum.values), +/// argName: 'animal', +/// ) +/// ``` +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() { + if (valueHelp != null) return valueHelp; + if (allowedValues != null || allowedHelp != null) return null; + // if no other value help is provided, auto-generate it with the enum names + return (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()); +} + +/// The time units supported by the [DurationParser]. +enum DurationUnit { + microseconds('us'), + milliseconds('ms'), + seconds('s'), + minutes('m'), + hours('h'), + days('d'); + + final String suffix; + + const DurationUnit(this.suffix); +} + +/// 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, the [defaultUnit] is assumed, which is in +/// seconds if not specified otherwise. +/// Examples: +/// - `10`, equivalent to `10s` +/// - `10m` +/// - `10h` +/// - `10d` +/// - `10ms` +/// - `10us` +/// +/// Throws [FormatException] if parsing failed. +class DurationParser extends ValueParser { + final DurationUnit defaultUnit; + + const DurationParser({this.defaultUnit = DurationUnit.seconds}); + + @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 = _determineUnit(match.group(2)); + final val = int.parse(valueStr ?? ''); + return switch (unit) { + DurationUnit.seconds => Duration(seconds: val), + DurationUnit.minutes => Duration(minutes: val), + DurationUnit.hours => Duration(hours: val), + DurationUnit.days => Duration(days: val), + DurationUnit.milliseconds => Duration(milliseconds: val), + DurationUnit.microseconds => Duration(microseconds: val), + }; + } + + DurationUnit _determineUnit(final String? suffix) { + if (suffix == null) return defaultUnit; + + return DurationUnit.values.firstWhere( + (final unit) => unit.suffix == suffix, + orElse: () => throw FormatException('Invalid duration unit "$suffix".'), + ); + } + + @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[us|ms|s|m|h|d]', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const DurationParser()); + + /// Creates a DurationOption with a custom duration parser, + /// e.g. with a specific default unit. + const DurationOption.custom({ + required final DurationParser parser, + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'integer[us|ms|s|m|h|d]', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: parser); +} diff --git a/lib/src/config/options.dart b/lib/src/config/options.dart index af2c697..86d31df 100644 --- a/lib/src/config/options.dart +++ b/lib/src/config/options.dart @@ -1,439 +1,816 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:cli_tools/src/config/exceptions.dart'; +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'configuration.dart'; +import 'configuration_broker.dart'; +import 'option_resolution.dart'; +import 'source_type.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(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(StringParser(), separator: null), - ); +/// Common interface to enable same treatment for [ConfigOptionBase] +/// and option enums. +/// +/// [V] is the type of the value this option provides. +/// +/// ## Example +/// +/// The typical usage pattern is to use an enum with the options +/// and implement this interface like so: +/// ```dart +/// enum MyAppOption implements OptionDefinition { +/// username(StringOption( +/// argName: 'username', +/// envName: 'USERNAME', +/// )); +/// +/// const MyAppOption(this.option); +/// +/// @override +/// final ConfigOptionBase option; +/// } +/// ``` +/// +/// See [ConfigOptionBase] for more information on options, +/// and [Configuration] on how to initialize the configuration. +abstract interface class OptionDefinition { + ConfigOptionBase get option; } -/// 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); +/// 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 configuration option definitions as a group. + /// + /// This method is called by [prepareOptionsForParsing] to validate + /// the configuration option definitions as a group. + /// Throws an error if any definition is invalid as part of this group. + /// + /// Subclasses may override this method to perform specific validations. + void validateDefinitions(List options) {} + + /// 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? validateValues( + final Map optionResolutions, + ) { + return null; + } @override - E parse(final String value) { - return enumValues.firstWhere( - (final e) => e.name == value, - orElse: () => throw FormatException( - '"$value" is not in ${valueHelpString()}', - ), - ); + bool operator ==(final Object other) { + if (identical(this, other)) return true; + return other is OptionGroup && other.name == name; } @override - String format(final E value) { - return value.name; - } + int get hashCode => name.hashCode; +} - String valueHelpString() { - return enumValues.map((final e) => e.name).join('|'); +/// 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(); } } -/// Enum value configuration option. +/// 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: /// -/// If the input is not one of the enum names, -/// the validation throws a [FormatException]. +/// 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 /// -/// Due to Dart's const semantics, the EnumParser must be -/// provided by the caller, like so: +/// ### Typed values, parsing, and validation /// +/// [V] is the type of the value this option provides. +/// Option values are parsed to this type 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. +/// +/// A [customValidator] may be provided for an individual option. +/// If a value was provided the customValidator is invoked, +/// and shall throw a [FormatException] if its format is invalid, +/// or a [UsageException] if the it is invalid for other reasons. +/// +/// ### 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. +/// +/// ## Example +/// +/// The typical usage pattern is to use an enum with the options +/// and instantiate subclasses of [ConfigOptionBase] like so: /// ```dart -/// EnumOption( -/// enumParser: EnumParser(AnimalEnum.values), -/// argName: 'animal', -/// ) +/// enum MyAppOption implements OptionDefinition { +/// username(StringOption( +/// argName: 'username', +/// envName: 'USERNAME', +/// )); +/// +/// const MyAppOption(this.option); +/// +/// @override +/// final ConfigOptionBase option; +/// } /// ``` -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); +/// +/// See [Configuration] on how to initialize the configuration. +abstract 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() { + final defValue = defaultValue(); + if (defValue == null) return null; + return valueParser.format(defValue); + } - @override String? valueHelpString() { - if (valueHelp != null) return valueHelp; - if (allowedValues != null || allowedHelp != null) return null; - // if no other value help is provided, auto-generate it with the enum names - return (valueParser as EnumParser).valueHelpString(); + return valueHelp; } -} -/// 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, - }); + /// 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 [], + ); + } - @override + /// 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) { - super.validateValue(value); + if (allowedValues?.contains(value) == false) { + throw UsageException( + '`$value` is not an allowed value for ${qualifiedString()}', ''); + } - final mininum = min; - if (mininum != null && value.compareTo(mininum) < 0) { - throw FormatException( - '${valueParser.format(value)} is below the minimum ' - '(${valueParser.format(mininum)})', + 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. + /// + /// This method is intended for internal use. + 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 const 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, ); } - final maximum = max; - if (maximum != null && value.compareTo(maximum) > 0) { - throw FormatException( - '${valueParser.format(value)} is above the maximum ' - '(${valueParser.format(maximum)})', + 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. + /// + /// This method is intended for internal use. + 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'; } } -class IntParser extends ValueParser { - const IntParser(); +/// Parses a boolean value from a string. +class BoolParser extends ValueParser { + const BoolParser(); @override - int parse(final String value) { - return int.parse(value); + bool parse(final String value) { + return bool.parse(value, caseSensitive: false); } } -/// Integer value configuration option. -/// -/// Supports minimum and maximum range checking. -class IntOption extends ComparableValueOption { - const IntOption({ +/// Boolean value configuration option. +class FlagOption extends ConfigOptionBase { + final bool negatable; + final bool hideNegatedUsage; + + const FlagOption({ 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.valueHelp, super.group, - super.allowedValues, super.customValidator, super.mandatory, super.hide, - super.min, - super.max, - }) : super(valueParser: const IntParser()); -} + this.negatable = true, + this.hideNegatedUsage = false, + }) : super( + valueParser: const BoolParser(), + ); -/// 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 + 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 - 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; + OptionResolution? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; } - throw FormatException('Invalid date-time "$value"'); + return OptionResolution( + value: args.flag(argOptName), + source: ValueSourceType.arg, + ); } } -/// Date-time value configuration option. +/// 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. /// -/// Supports minimum and maximum range checking. -class DateTimeOption extends ComparableValueOption { - const DateTimeOption({ +/// 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( + 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.argPos, super.envName, super.configKey, super.fromCustom, super.fromDefault, super.defaultsTo, super.helpText, - super.valueHelp = 'YYYY-MM-DDtHH:MM:SSz', + super.valueHelp, super.allowedHelp, super.group, - super.allowedValues, + final List? allowedValues, super.customValidator, super.mandatory, super.hide, - super.min, - super.max, - }) : super(valueParser: const DateTimeParser()); -} - -/// The time units supported by the [DurationParser]. -enum DurationUnit { - microseconds('us'), - milliseconds('ms'), - seconds('s'), - minutes('m'), - hours('h'), - days('d'); - - final String suffix; - - const DurationUnit(this.suffix); -} + }) : allowedElementValues = allowedValues, + super( + valueParser: multiParser, + ); -/// 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, the [defaultUnit] is assumed, which is in -/// seconds if not specified otherwise. -/// Examples: -/// - `10`, equivalent to `10s` -/// - `10m` -/// - `10h` -/// - `10d` -/// - `10ms` -/// - `10us` -/// -/// Throws [FormatException] if parsing failed. -class DurationParser extends ValueParser { - final DurationUnit defaultUnit; + @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."); + } - const DurationParser({this.defaultUnit = DurationUnit.seconds}); + 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 - 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"'); + OptionResolution>? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; } - final valueStr = match.group(1); - final unit = _determineUnit(match.group(2)); - final val = int.parse(valueStr ?? ''); - return switch (unit) { - DurationUnit.seconds => Duration(seconds: val), - DurationUnit.minutes => Duration(minutes: val), - DurationUnit.hours => Duration(hours: val), - DurationUnit.days => Duration(days: val), - DurationUnit.milliseconds => Duration(milliseconds: val), - DurationUnit.microseconds => Duration(microseconds: val), - }; - } - - DurationUnit _determineUnit(final String? suffix) { - if (suffix == null) return defaultUnit; - - return DurationUnit.values.firstWhere( - (final unit) => unit.suffix == suffix, - orElse: () => throw FormatException('Invalid duration unit "$suffix".'), + final multiParser = valueParser as MultiParser; + return OptionResolution( + value: args + .multiOption(argOptName) + .map(multiParser.elementParser.parse) + .toList(), + source: ValueSourceType.arg, ); } @override - String format(final Duration value) { - if (value == Duration.zero) return '0s'; + @mustCallSuper + void validateValue(final List value) { + super.validateValue(value); - 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'); + 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()}', ''); + } + } + } + } +} - return '$sign$d$h$m$s$ms$us'; +/// 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 = {}; + + final optionGroups = >{}; + + 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; + } - static String _unitStr(final int value, final int? mod, final String unit) { - final absValue = value.abs(); - if (mod == null) { - return absValue > 0 ? '$absValue$unit' : ''; + 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; + } + + final group = opt.option.group; + if (group != null) { + optionGroups.update( + group, + (final value) => [...value, opt], + ifAbsent: () => [opt], + ); } - return absValue % mod > 0 ? '${absValue.remainder(mod)}$unit' : ''; } + + optionGroups.forEach((final group, final options) { + group.validateDefinitions(options); + }); + + 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; } -/// 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[us|ms|s|m|h|d]', - super.allowedHelp, - super.group, - super.allowedValues, - super.customValidator, - super.mandatory, - super.hide, - super.min, - super.max, - }) : super(valueParser: const DurationParser()); - - /// Creates a DurationOption with a custom duration parser, - /// e.g. with a specific default unit. - const DurationOption.custom({ - required final DurationParser parser, - super.argName, - super.argAliases, - super.argAbbrev, - super.argPos, - super.envName, - super.configKey, - super.fromCustom, - super.fromDefault, - super.defaultsTo, - super.helpText, - super.valueHelp = 'integer[us|ms|s|m|h|d]', - super.allowedHelp, - super.group, - super.allowedValues, - super.customValidator, - super.mandatory, - super.hide, - super.min, - super.max, - }) : super(valueParser: parser); +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); + + /// Returns the usage help text for these options. + String get usage { + final parser = ArgParser(); + prepareForParsing(parser); + return parser.usage; + } } diff --git a/test/config/configuration_test.dart b/test/config/configuration_test.dart index 6f6d26b..3e409e1 100644 --- a/test/config/configuration_test.dart +++ b/test/config/configuration_test.dart @@ -122,6 +122,28 @@ void main() async { defaultsTo: 'constDefaultValue', ); + test( + 'when getting the usage from a list with the option ' + 'then the usage is returned', () async { + final options = [projectIdOpt]; + expect( + options.usage, + equals('--project (defaults to "defaultValueFunction")'), + ); + }); + + test( + 'when getting the usage from a resolved configuration ' + 'then the usage is returned', () async { + final config = Configuration.resolve( + options: [projectIdOpt], + ); + expect( + config.usage, + equals('--project (defaults to "defaultValueFunction")'), + ); + }); + test('then command line argument has first precedence', () async { final args = ['--project', '123']; final envVars = {'PROJECT_ID': '456'};