Skip to content

Commit

Permalink
Introduce mapped options as defined in natemcmaster#334
Browse files Browse the repository at this point in the history
  • Loading branch information
TheConstructor committed May 25, 2021
1 parent 6ccfee6 commit 586c3c1
Show file tree
Hide file tree
Showing 9 changed files with 966 additions and 2 deletions.
91 changes: 91 additions & 0 deletions src/CommandLineUtils/Attributes/MappedOptionAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Reflection;

namespace McMaster.Extensions.CommandLineUtils.Attributes
{
/// <summary>
/// Represents one or many command line option that is identified by flag proceeded by '-' or '--'.
/// Options are not positional. Compare to <see cref="ArgumentAttribute"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class MappedOptionAttribute : OptionAttributeBase
{
/// <summary>
/// Initializes a new <see cref="MappedOptionAttribute"/>.
/// </summary>
public MappedOptionAttribute(object constantValue)
{
ConstantValue = constantValue;
}

/// <summary>
/// Initializes a new <see cref="MappedOptionAttribute"/>.
/// </summary>
/// <param name="template">The string template. This is parsed into <see cref="CommandOption.ShortName"/> and <see cref="CommandOption.LongName"/>.</param>
/// <param name="constantValue">The value to assign the the option is given.</param>
public MappedOptionAttribute(string template, object constantValue)
{
Template = template;
ConstantValue = constantValue;
}

/// <summary>
/// Initializes a new <see cref="MappedOptionAttribute"/>.
/// </summary>
/// <param name="template">The template</param>
/// <param name="description">The option description</param>
/// <param name="constantValue">The value to assign the the option is given.</param>
public MappedOptionAttribute(string template, string? description, object constantValue)
{
Template = template;
Description = description;
ConstantValue = constantValue;
}

/// <summary>
/// Defines the type of the option. When not set, this will be inferred from the CLR type of the property.
/// </summary>
/// <seealso cref="CommandOption.OptionType"/>
public CommandOptionType? OptionType { get; set; }

/// <summary>
/// Defines the value assigned to the property when the option is given.
/// </summary>
/// <seealso cref="ConstantValueOption{T}.ConstantValue"/>
public object? ConstantValue { get; set; }

internal CommandOption Configure<T>(MappedOption<T> mappedOption, PropertyInfo prop)
{
T constantValue;
try
{
constantValue = (T)ConstantValue!;
}
catch (InvalidCastException e)
{
throw new InvalidOperationException(Strings.CannotDetermineOptionType(prop), e);
}
ConstantValueOption<T> option;
if (Template != null)
{
option = mappedOption.Add(Template, constantValue);
}
else
{
option = mappedOption.Add(constantValue);
var stringValue = constantValue?.ToString().ToKebabCase();
if (stringValue != null)
{
option.LongName = stringValue;
option.ShortName = stringValue.Substring(0, 1);
}
}

Configure(option);
return option;
}
}
}
20 changes: 19 additions & 1 deletion src/CommandLineUtils/CommandLineApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ public char[] OptionNameValueSeparators
public TextWriter Error { get; set; }

/// <summary>
/// Gets all command line options available to this command, including any inherited options.
/// Gets all command line options available to this command, including any inherited options and <see cref="MappedOption{T}"/>s.
/// </summary>
/// <returns>Command line options.</returns>
public IEnumerable<IOption> GetAnyOptions()
Expand Down Expand Up @@ -618,6 +618,24 @@ public CommandOption<T> Option<T>(string template, string description, CommandOp
return option;
}

/// <summary>
/// Add a new set of mapped options
/// </summary>
/// <param name="optionType"></param>
/// <param name="configuration"></param>
/// <param name="inherited"></param>
/// <typeparam name="T">The type of the values on the option</typeparam>
/// <returns>The option</returns>
public MappedOption<T> MappedOption<T>(CommandOptionType optionType, Action<MappedOption<T>> configuration, bool inherited)
{
var mappedOption = new MappedOption<T>(this, optionType)
{
Inherited = inherited
};
configuration(mappedOption);
return mappedOption;
}

#pragma warning disable RS0026 // Do not add multiple public overloads with optional parameters
/// <summary>
/// Adds a command line argument
Expand Down
85 changes: 85 additions & 0 deletions src/CommandLineUtils/ConstantValueOption{T}.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace McMaster.Extensions.CommandLineUtils
{
/// <summary>
/// An option that returns a constant <see cref="ParsedValue"/> when specified on the command-line
/// </summary>
/// <typeparam name="T">type of <see cref="ConstantValue"/></typeparam>
public class ConstantValueOption<T> : CommandOption, IOption<T>, IInternalCommandParamOfT
{
private readonly List<T> _parsedValues = new List<T>();
private readonly T _constantValue;
private bool _hasBeenParsed;

/// <summary>
/// Initializes a new <see cref="ConstantValueOption{T}"/>
/// </summary>
/// <param name="template">The option template.</param>
/// <param name="constantValue">The value to return is specified on the command line</param>
/// <see cref="MappedOption{T}"/>
public ConstantValueOption(string template, T constantValue) : base(template, CommandOptionType.NoValue)
{
_constantValue = constantValue;
UnderlyingType = typeof(T);
}

internal ConstantValueOption(T constantValue) : base(CommandOptionType.NoValue)
{
_constantValue = constantValue;
UnderlyingType = typeof(T);
}

/// <summary>
/// The value that is returned for any usage of this option
/// </summary>
public T ConstantValue => _constantValue;

/// <summary>
/// The parsed value.
/// </summary>
public T ParsedValue => ParsedValues.FirstOrDefault();

/// <summary>
/// All parsed values;
/// </summary>
public IReadOnlyList<T> ParsedValues
{
get
{
if (!_hasBeenParsed)
{
((IInternalCommandParamOfT)this).Parse(CultureInfo.CurrentCulture);
}

return _parsedValues;
}
}

/// <summary>
/// "Parse" the user-given values into the constant value
/// </summary>
void IInternalCommandParamOfT.Parse(CultureInfo culture)
{
_hasBeenParsed = true;
_parsedValues.Clear();
foreach (var t in base._values)
{
_parsedValues.Add(_constantValue);
}
}

/// <inheritdoc/>
public override void Reset()
{
_hasBeenParsed = false;
_parsedValues.Clear();
base.Reset();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ public static IConventionBuilder UseHelpOptionAttribute(this IConventionBuilder
/// <param name="builder">The builder.</param>
/// <returns>The builder.</returns>
public static IConventionBuilder UseOptionAttributes(this IConventionBuilder builder)
=> builder.AddConvention(new OptionAttributeConvention());
=> builder.AddConvention(new OptionAttributeConvention())
.AddConvention(new MappedOptionAttributeConvention());

/// <summary>
/// Applies settings from <see cref="ArgumentAttribute" /> on the model type.
Expand Down
147 changes: 147 additions & 0 deletions src/CommandLineUtils/Conventions/MappedOptionAttributeConvention.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using McMaster.Extensions.CommandLineUtils.Abstractions;
using McMaster.Extensions.CommandLineUtils.Attributes;

namespace McMaster.Extensions.CommandLineUtils.Conventions
{
/// <summary>
/// Adds an <see cref="CommandOption"/> to match each usage of <see cref="MappedOptionAttribute"/>
/// on the model type of <see cref="CommandLineApplication{TModel}"/>.
/// </summary>
public class MappedOptionAttributeConvention : OptionAttributeConventionBase<MappedOptionAttribute>, IConvention
{
/// <inheritdoc />
public virtual void Apply(ConventionContext context)
{
if (context.ModelType == null)
{
return;
}

var props = ReflectionHelper.GetProperties(context.ModelType);
foreach (var prop in props)
{
using var enumerator = prop.GetCustomAttributes<MappedOptionAttribute>().GetEnumerator();
if (!enumerator.MoveNext())
{
continue;
}

EnsureDoesNotHaveHelpOptionAttribute(prop);
EnsureDoesNotHaveVersionOptionAttribute(prop);
EnsureDoesNotHaveArgumentAttribute(prop);

var modelAccessor = context.ModelAccessor;
if (modelAccessor == null)
{
throw new InvalidOperationException(Strings.ConventionRequiresModel);
}

var method = s_applyGeneric.MakeGenericMethod(context.ModelType, prop.PropertyType);
try
{
method.Invoke(this, new object[] { context, prop, enumerator });
}
catch (TargetInvocationException e)
{
var innerException = e.InnerException;
if (innerException != null)
{
ExceptionDispatchInfo.Capture(innerException).Throw();
}
throw;
}
}
}

private static readonly MethodInfo s_applyGeneric
= typeof(MappedOptionAttributeConvention)
.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic)
.Single(m => m.Name == nameof(Apply) && m.IsGenericMethod);

private void Apply<TModel, TValue>(ConventionContext context, PropertyInfo prop,
IEnumerator<MappedOptionAttribute> enumerator)
where TModel : class
{
var setterDelegate = ReflectionHelper.GetPropertySetter(prop);

var option = new SingularPropertyMappedOption<TModel, TValue>(context.Application, setterDelegate)
{
Description = prop.Name
};
context.Application.AddOption(option);
do
{
enumerator.Current.Configure(option, prop);
} while (enumerator.MoveNext());
}

private static void EnsureDoesNotHaveVersionOptionAttribute(PropertyInfo prop)
{
var versionOptionAttr = prop.GetCustomAttribute<VersionOptionAttribute>();
if (versionOptionAttr != null)
{
throw new InvalidOperationException(
Strings.BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(prop));
}
}

private static void EnsureDoesNotHaveHelpOptionAttribute(PropertyInfo prop)
{
var versionOptionAttr = prop.GetCustomAttribute<VersionOptionAttribute>();
if (versionOptionAttr != null)
{
throw new InvalidOperationException(
Strings.BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(prop));
}
}

class SingularPropertyMappedOption<TModel, TValue>
: MappedOption<TValue>, IInternalCommandParamOfT
where TModel : class
{
private readonly SetPropertyDelegate _propertySetter;

public SingularPropertyMappedOption(CommandLineApplication commandLineApplication,
SetPropertyDelegate propertySetter)
: base(commandLineApplication, CommandOptionType.SingleValue)
{
_propertySetter = propertySetter;
}

public void Parse(CultureInfo culture)
{
if (!HasValue())
{
return;
}

switch (_commandLineApplication)
{
case CommandLineApplication<TModel> appT:
{
_propertySetter(appT.Model, ParsedValue);
break;
}
case IModelAccessor modelAccessor:
{
_propertySetter((TModel)modelAccessor.GetModel(), ParsedValue);
break;
}
default:
{
throw new InvalidOperationException("Can not get the model of the current command");
}
}
}
}
}
}
Loading

0 comments on commit 586c3c1

Please sign in to comment.