Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow nullables and params arrays in console commands #505

Merged
merged 2 commits into from
Dec 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 68 additions & 39 deletions Nautilus/Commands/ConsoleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Linq;
using System.Reflection;
using HarmonyLib;
using Nautilus.Extensions;

namespace Nautilus.Commands;

Expand All @@ -24,7 +25,11 @@ internal class ConsoleCommand
/// <summary>
/// The parameters for the command.
/// </summary>
public IEnumerable<Parameter> Parameters { get; }
public IReadOnlyList<Parameter> Parameters { get; }
/// <summary>
/// The minimum number of parameters required to invoke the command.
/// </summary>
public int RequiredParameterCount { get; }

/// <summary>
/// The types of the parameters.
Expand Down Expand Up @@ -53,8 +58,9 @@ public ConsoleCommand(string trigger, MethodInfo targetMethod, bool isDelegate =
IsDelegate = isDelegate;
Instance = instance;
ModName = DeclaringType.Assembly.GetName().Name;
Parameters = targetMethod.GetParameters().Select(param => new Parameter(param));
Parameters = targetMethod.GetParameters().Select(param => new Parameter(param)).ToList();
ParameterTypes = Parameters.Select(param => param.ParameterType).ToArray();
RequiredParameterCount = Parameters.Count(param => !param.IsOptional);
}

/// <summary>
Expand All @@ -66,73 +72,96 @@ public bool HasValidInvoke()
return IsDelegate || Instance != null || IsMethodStatic;
}

/// <summary>
/// Determines whether the target methods parameters are valid.
/// </summary>
/// <returns></returns>
public bool HasValidParameterTypes()
{
foreach (Parameter parameter in Parameters)
{
if (!parameter.IsValidParameterType)
{
return false;
}
}

return true;
}

/// <summary>
/// Returns a list of all invalid parameters.
/// </summary>
/// <returns></returns>
public IEnumerable<Parameter> GetInvalidParameters()
{
return Parameters.Where(param => !param.IsValidParameterType);
return Parameters.Where(p => p.ValidState != Parameter.ValidationError.Valid);
}

/// <summary>
/// Attempts to parse input parameters into appropriate types as defined in the target method.
/// </summary>
/// <param name="inputParameters">The parameters as input by the user.</param>
/// <param name="input">The parameters as input by the user.</param>
/// <param name="parsedParameters">The parameters that have been successfully parsed.</param>
/// <returns>Whether or not all parameters were succesfully parsed.</returns>
public bool TryParseParameters(IEnumerable<string> inputParameters, out object[] parsedParameters)
/// <returns>
/// A tuple containing:
/// <list type="number">
/// <item>The number of input items consumed.</item>
/// <item>The number of command parameters that were successfully parsed.</item>
/// </list>
/// </returns>
public (int consumed, int parsed) TryParseParameters(IReadOnlyList<string> input, out object[] parsedParameters)
{
parsedParameters = null;

// Detect incorrect number of parameters (allow for optional)
if (Parameters.Count() < inputParameters.Count() ||
Parameters.Where(param => !param.IsOptional).Count() > inputParameters.Count())
int paramCount = Parameters.Count;
int inputCount = input.Count;
int paramsArrayLength = Math.Max(0, input.Count - (paramCount - 1));

if (inputCount < RequiredParameterCount)
{
return false;
return default;
}

parsedParameters = new object[Parameters.Count()];
for (int i = 0; i < Parameters.Count(); i++)
parsedParameters = new object[paramCount];
for (int i = 0; i < paramCount; i++)
{
Parameter parameter = Parameters.ElementAt(i);

if (i >= inputParameters.Count()) // It's an optional parameter that wasn't passed by the user
{
parsedParameters[i] = Type.Missing;
continue;
}
Type paramType = Parameters[i].ParameterType;
parsedParameters[i] = paramType.TryUnwrapArrayType(out Type elementType)
? Array.CreateInstance(elementType, paramsArrayLength)
: DBNull.Value;
}

string input = inputParameters.ElementAt(i);
int consumed = 0;
int parsed = 0;
while (consumed < inputCount)
{
if (parsed >= paramCount) break;

Parameter parameter = Parameters[parsed];
string inputItem = input[consumed];

object parsedItem;
try
{
parsedParameters[i] = parameter.Parse(input);
parsedItem = parameter.Parse(inputItem);
}
catch (Exception)
{
return false; // couldn't parse, wasn't a valid conversion
return (consumed, parsed);
}
consumed++;

if (parameter.ParameterType.IsArray)
{
Array parsedArr = (Array)parsedParameters[parsed];
parsedArr.SetValue(parsedItem, consumed - parsed - 1);
if (consumed >= inputCount)
{
parsed++;
}
}
else
{
parsedParameters[parsed] = parsedItem;
parsed++;
}
}

return true;
// Optional parameters that weren't passed by the user
// at this point all required parameters should've been parsed
for (int i = parsed; i < paramCount; i++)
{
if (parsedParameters[i] == DBNull.Value)
parsedParameters[i] = Type.Missing;
parsed++;
}

return (consumed, parsed);
}

/// <summary>
Expand Down
46 changes: 38 additions & 8 deletions Nautilus/Commands/Parameter.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using Nautilus.Extensions;

namespace Nautilus.Commands;

internal struct Parameter
{
private static Dictionary<Type, Func<string, object>> TypeConverters = new()
[Flags]
public enum ValidationError
{
Valid = 0,
UnsupportedType = 1,
ArrayNotParams = 2,
}
private static Dictionary<Type, Func<string, object>> _typeConverters = new()
{
[typeof(string)] = (s) => s,
[typeof(bool)] = (s) => bool.Parse(s),
Expand All @@ -18,23 +24,47 @@ internal struct Parameter
[typeof(double)] = (s) => double.Parse(s, CultureInfo.InvariantCulture.NumberFormat)
};

public static IEnumerable<Type> SupportedTypes => TypeConverters.Keys;
public static IEnumerable<Type> SupportedTypes => _typeConverters.Keys;

public Type ParameterType { get; }
public Type UnderlyingValueType { get; }
public bool IsOptional { get; }
public string Name { get; }
public bool IsValidParameterType { get; }
public ValidationError ValidState { get; }

public Parameter(ParameterInfo parameter)
{
ParameterType = parameter.ParameterType;
IsOptional = parameter.IsOptional;
UnderlyingValueType = ParameterType.GetUnderlyingType();
IsOptional = parameter.IsOptional || ParameterType.IsArray;
Name = parameter.Name;
IsValidParameterType = SupportedTypes.Contains(ParameterType);
ValidState = ValidateParameter(parameter);
}

private readonly ValidationError ValidateParameter(ParameterInfo paramInfo)
{
ValidationError valid = ValidationError.Valid;
// arrays MUST be a "params T[]" parameter
// this enforces them being last *and* only having one
if (ParameterType.IsArray && !paramInfo.IsDefined(typeof(ParamArrayAttribute), false))
valid |= ValidationError.ArrayNotParams;
if (!_typeConverters.ContainsKey(UnderlyingValueType))
valid |= ValidationError.UnsupportedType;

return valid;
}

public object Parse(string input)
{
return TypeConverters[ParameterType](input);
Type paramType = ParameterType;
if (paramType.TryUnwrapArrayType(out Type elementType))
paramType = elementType;
if (paramType.TryUnwrapNullableType(out _))
{
if (string.Equals(input, "null", StringComparison.OrdinalIgnoreCase))
return null;
}

return _typeConverters[UnderlyingValueType](input);
}
}
16 changes: 16 additions & 0 deletions Nautilus/Extensions/GeneralExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;

namespace Nautilus.Extensions;
Expand Down Expand Up @@ -43,4 +45,18 @@ public static void AddHint(this ErrorMessage @this, string message)
else if (msg.timeEnd <= Time.time + @this.timeFadeOut)
msg.timeEnd += @this.timeFadeOut + @this.timeInvisible;
}

/// <summary>
/// Concatenates string representations of the provided <paramref name="values"/>,
/// using the specified <paramref name="separator"/> between them.
/// </summary>
/// <typeparam name="T">Type of value that will be converted to <see cref="string"/>.</typeparam>
/// <param name="builder">The <see cref="StringBuilder"/>.</param>
/// <param name="separator">The <see cref="string"/> to insert between each pair of values.</param>
/// <param name="values">Values to concatenate into the <paramref name="builder"/>.</param>
/// <returns>The provided <see cref="StringBuilder"/>.</returns>
public static StringBuilder AppendJoin<T>(this StringBuilder builder, string separator, IEnumerable<T> values)
{
return builder.Append(string.Join(separator, values));
}
}
85 changes: 85 additions & 0 deletions Nautilus/Extensions/TypeExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HarmonyLib;

namespace Nautilus.Extensions;

internal static class TypeExtensions
{
private static readonly List<string> _builtinTypeAliases = new()
{
"void",
null, // all other types
"DBNull",
"bool",
"char",
"sbyte",
"byte",
"short",
"ushort",
"int",
"uint",
"long",
"ulong",
"float",
"double",
"decimal",
null, // DateTime?
null, // ???
"string"
};

/// <summary>
/// Format the given <paramref name="type"/>'s name into a more developer-friendly form.
/// </summary>
/// <param name="type"></param>
/// <returns></returns>
public static string GetFriendlyName(this Type type)
{
if (type.TryUnwrapArrayType(out Type elementType))
return GetFriendlyName(elementType) + "[]";

if (type.TryUnwrapNullableType(out Type valueType))
return GetFriendlyName(valueType) + "?";

// TODO: format tuples as well

if (type.IsConstructedGenericType)
return type.Name[..type.Name.LastIndexOf('`')]
+ $"<{type.GenericTypeArguments.Select(GetFriendlyName).Join()}>";

return _builtinTypeAliases[(int) Type.GetTypeCode(type)] ?? type.Name;
}

/// <summary>
/// "Unwraps" the inner <paramref name="type"/> from an array and/or nullable type.
/// </summary>
/// <param name="type"></param>
/// <returns>
/// The inner type - for example, <see cref="string"/> from a <see cref="Array">string[]</see>, or <see cref="bool"/> from <see cref="Nullable{T}">bool?</see>.<br/>
/// If the <paramref name="type"/> isn't wrapped, it is returned as-is.
/// </returns>
public static Type GetUnderlyingType(this Type type)
{
if (type.TryUnwrapArrayType(out Type elementType))
type = elementType;
if (type.TryUnwrapNullableType(out Type valueType))
type = valueType;
return type;
}

public static bool TryUnwrapArrayType(this Type type, out Type elementType)
{
// GetElementType checks if it's an array, pointer, or reference
elementType = type.GetElementType();
return type.IsArray // restrict to arrays only
&& elementType != null;
}

public static bool TryUnwrapNullableType(this Type type, out Type valueType)
{
valueType = Nullable.GetUnderlyingType(type);
return valueType != null;
}
}
Loading