Skip to content

Commit

Permalink
feat: Allow nullables and params arrays in console commands (#505)
Browse files Browse the repository at this point in the history
* feat: Allow nullables and params arrays in console commands

* fix: Forgot a newline

Clarify parameter parsing error message
  • Loading branch information
Govorunb authored Dec 2, 2023
1 parent 6477816 commit 0d038fd
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 80 deletions.
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

0 comments on commit 0d038fd

Please sign in to comment.