using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Text; using Robust.Shared.Exceptions; using Robust.Shared.Localization; using Robust.Shared.Toolshed.Errors; using Robust.Shared.Toolshed.Syntax; using Robust.Shared.Toolshed.TypeParsers; using Robust.Shared.Utility; using Invocable = System.Func; namespace Robust.Shared.Toolshed; internal sealed class ToolshedCommandImplementor { public readonly ToolshedCommand Owner; public readonly string? SubCommand; public readonly string FullName; /// /// The full name of a command for use when fetching localized strings. /// public readonly string LocName; private readonly ToolshedManager _toolshed; private readonly ILocalizationManager _loc; public readonly Dictionary> Implementations = new(); /// /// Cache for . /// private readonly Dictionary _methodCache = new(); /// /// All methods in that correspond to the given . /// internal readonly CommandMethod[] Methods; public CommandSpec Spec => new(Owner, SubCommand); public ToolshedCommandImplementor(string? subCommand, ToolshedCommand owner, ToolshedManager toolshed, ILocalizationManager loc) { Owner = owner; _loc = loc; SubCommand = subCommand; FullName = SubCommand == null ? Owner.Name : $"{Owner.Name}:{SubCommand}"; _toolshed = toolshed; Methods = Owner.GetMethods(SubCommand) .Select(x => new CommandMethod(x, this)) .ToArray(); LocName = Owner.Name.All(char.IsAsciiLetterOrDigit) ? Owner.Name : Owner.GetType().PrettyName(); if (SubCommand != null) LocName = $"{LocName}-{SubCommand}"; } /// /// Attempt to parse the type-arguments and arguments and return an invocable expression. /// public bool TryParse(ParserContext ctx, out Invocable? invocable, [NotNullWhen(true)] out ConcreteCommandMethod? method) { ctx.ConsumeWhitespace(); method = null; invocable = null; if (!TryParseTypeArguments(ctx)) return false; if (!TryGetConcreteMethod(ctx.Bundle.PipedType, ctx.Bundle.TypeArguments, out method)) { if (ctx.GenerateCompletions) return false; ctx.Error = new NoImplementationError(ctx); ctx.Error.Contextualize(ctx.Input, (ctx.Bundle.NameStart, ctx.Bundle.NameEnd)); return false; } var argsStart = ctx.Index; if (!TryParseArguments(ctx, method.Value)) { ctx.Error?.Contextualize(ctx.Input, (argsStart, ctx.Index)); return false; } invocable = GetImplementation(ctx.Bundle, method.Value); return true; } /// /// You who tread upon this dreaded land, what is it that brings you here? /// For this place is not for you, this land of terror and death. /// It brings fear to all who tread within, terror to the homes ahead. /// Begone, foul maintainer, for this place is not for thee. /// public bool TryParseArguments(ParserContext ctx, ConcreteCommandMethod method) { DebugTools.AssertNull(ctx.Error); DebugTools.AssertNull(ctx.Completions); foreach (var arg in method.Args) { object? parsed; if (arg.IsParamsCollection) { DebugTools.Assert(arg == method.Args[^1]); if (!ParseParamsCollection(ctx, arg, out parsed)) return false; } else if (!TryParseArgument(ctx, arg, out parsed)) return false; ctx.Bundle.Arguments ??= new(); ctx.Bundle.Arguments[arg.Name] = parsed; } DebugTools.AssertNull(ctx.Error); DebugTools.AssertNull(ctx.Completions); return true; } private bool ParseParamsCollection(ParserContext ctx, CommandArgument arg, out object? collection) { var list = new List(); collection = list; while (true) { ctx.ConsumeWhitespace(); if (ctx.PeekCommandOrBlockTerminated()) break; if (ctx is {OutOfInput: true, GenerateCompletions: false}) break; if (!TryParseArgument(ctx, arg, out var parsed)) return false; list.Add(parsed); } return true; } private bool TryParseArgument(ParserContext ctx, CommandArgument arg, out object? parsed) { DebugTools.AssertNull(ctx.Error); DebugTools.AssertNull(ctx.Completions); var start = ctx.Index; var save = ctx.Save(); ctx.ConsumeWhitespace(); DebugTools.AssertNotNull(arg.Parser); parsed = null; if (ctx.PeekCommandOrBlockTerminated() || ctx is {OutOfInput: true, GenerateCompletions: false}) { if (!arg.IsOptional) { ctx.Error = new ExpectedArgumentError(arg.Type); ctx.Error.Contextualize(ctx.Input, (start, ctx.Index + 1)); return false; } parsed = arg.DefaultValue; } else if (arg.Parser!.TryParse(ctx, out parsed)) { // All arguments should have been parsed as a ValueRef or Block, unless this is using some custom type parser #if DEBUG var t = parsed.GetType(); if (arg.Parser.GetType().IsCustomParser()) { DebugTools.Assert(t.IsAssignableTo(arg.Type) || t.IsAssignableTo(typeof(Block)) || t.IsValueRef()); } else if (arg.Type.IsAssignableTo(typeof(Block))) DebugTools.Assert(t.IsAssignableTo(typeof(Block))); else DebugTools.Assert(t.IsValueRef()); #endif } else { if (ctx.GenerateCompletions) { // Dont generate completions for the end of a command for an error that occured early on. // I.e., "i asd " should not be suggesting people to enter an integer. if (!ctx.OutOfInput) return false; // Some parsers might already generate completions when they fail the initial parsing. if (ctx.Completions != null) return false; ctx.Restore(save); ctx.Error = null; ctx.Completions ??= arg.Parser.TryAutocomplete(ctx, arg); TrySetArgHint(ctx, arg); return false; } // un-parseable types don't even consume input / modify the index // However for contextualizing the error, we want to at least show where the failing argument was var end = Math.Max(start + 1, ctx.Index); ctx.Error ??= new ArgumentParseError(arg.Type, arg.Parser.GetType()); ctx.Error.Contextualize(ctx.Input, (start, end)); return false; } if (!ctx.GenerateCompletions || !ctx.OutOfInput) return true; // This was the end of the input, so we want to get completions for the current argument, not the next // argument. I.e., if we started writing out a variable name, we want to keep generating variable name // suggestions for the current argument. This is true even if the current string corresponds to a valid // variable. ctx.Restore(save); ctx.Error = null; ctx.Completions ??= arg.Parser!.TryAutocomplete(ctx, arg); TrySetArgHint(ctx, arg); // TODO TOOLSHED invalid-fail // This can technically "fail" to parse a valid command, however this only happens when generating // completions, not when actually executing the command. Still, this is pretty janky and I don't know of a // good fix. return false; } private void TrySetArgHint(ParserContext ctx, CommandArgument arg) { if (ctx.Completions == null) return; if (_loc.TryGetString($"command-arg-hint-{LocName}-{arg.Name}", out var hint)) ctx.Completions.Hint = hint; } internal bool TryParseTypeArguments(ParserContext ctx) { if (Owner.TypeParameterParsers.Length == 0) return true; ref var typeArguments = ref ctx.Bundle.TypeArguments; typeArguments = new Type[Owner.TypeParameterParsers.Length]; for (var i = 0; i < Owner.TypeParameterParsers.Length; i++) { DebugTools.AssertNull(ctx.Error); DebugTools.AssertNull(ctx.Completions); var parserType = Owner.TypeParameterParsers[i]; var start = ctx.Index; ctx.ConsumeWhitespace(); var save = ctx.Save(); if (ctx is {OutOfInput: true, GenerateCompletions: false} || ctx.PeekCommandOrBlockTerminated()) { ctx.Error = new ExpectedTypeArgumentError(); ctx.Error.Contextualize(ctx.Input, (start, ctx.Index+1)); return false; } var parser = (BaseParser) (parserType == typeof(TypeTypeParser) ? _toolshed.GetParserForType(typeof(Type))! : _toolshed.GetCustomParser(parserType)); DebugTools.AssertNull(ctx.Completions); if (!parser.TryParse(ctx, out var parsed)) { typeArguments = null; if (ctx.GenerateCompletions) { // Dont generate completions for the end of a command for an error that occured early on. // I.e., "i asd " should not be suggesting people to enter an integer. if (!ctx.OutOfInput) return false; ctx.Restore(save); ctx.Error = null; ctx.Completions ??= parser.TryAutocomplete(ctx, null); return false; } ctx.Error ??= new TypeArgumentParseError(parserType); ctx.Error.Contextualize(ctx.Input, (start, ctx.Index)); return false; } typeArguments[i] = parsed; if (!ctx.GenerateCompletions || !ctx.OutOfInput) continue; // This was the end of the input, so we want to get completions for the current argument, not the next // argument. I.e., if we started writing out the name of a type, we want to keep generating type name // suggestions for the current argument. This is true even if the current string already corresponds to a // valid type. ctx.Restore(save); ctx.Error = null; ctx.Completions = parser.TryAutocomplete(ctx, null); // TODO TOOLSHED invalid-fail // This can technically "fail" to parse a valid command, however this only happens when generating // completions, not when actually executing the command. Still, this is pretty janky and I don't know of a // good fix. return false; } DebugTools.AssertNull(ctx.Error); DebugTools.AssertNull(ctx.Completions); return true; } /// /// Attempt to get a concrete method that takes in the given generic type arguments. /// internal bool TryGetConcreteMethod( Type? pipedType, Type[]? typeArguments, [NotNullWhen(true)] out ConcreteCommandMethod? method) { var idx = new CommandDiscriminator(pipedType, typeArguments); if (_methodCache.TryGetValue(idx, out method)) return method != null; var result = GetConcreteMethodInternal(pipedType, typeArguments); if (result == null) { _methodCache[idx] = method = null; return false; } var (cmd, info) = result.Value; if (pipedType is {ContainsGenericParameters: true} || typeArguments != null && typeArguments.Any(x => x.ContainsGenericParameters)) { // I hate this method name // its not a real concrete method if the requested types are generic, is it now? // anyways, fuck this I CBF fixing it just return without information about the arguments. _methodCache[idx] = method = new(info, default!, cmd); return true; } var args = info.GetParameters() .Where(x => x.IsCommandArgument()) .Select(GetCommandArgument) .ToArray(); _methodCache[idx] = method = new(info, args, cmd); return true; } internal CommandArgument GetCommandArgument(ParameterInfo arg) { var argType = arg.ParameterType; // Is this a "params T[] argument"? var isParamsCollection = arg.HasCustomAttribute(); if (isParamsCollection) { if (!argType.IsArray) throw new NotSupportedException(".net 9 params collections are not yet supported"); // We parse each element directly, as opposed to trying to parse an array. argType = argType.GetElementType()!; } return new CommandArgument( arg.Name!, argType, GetArgumentParser(arg, argType), arg.IsOptional, arg.DefaultValue, isParamsCollection); } private ITypeParser? GetArgumentParser(ParameterInfo param, Type type) { if (type.ContainsGenericParameters) return null; var attrib = param.GetCustomAttribute(); var parser = attrib?.CustomParser is not {} custom ? _toolshed.GetArgumentParser(type) : _toolshed.GetArgumentParser(_toolshed.GetCustomParser(custom)); if (parser == null) throw new Exception($"No parser for type: {param.ParameterType}"); return parser; } private (CommandMethod, MethodInfo)? GetConcreteMethodInternal(Type? pipedType, Type[]? typeArguments) { return Methods .Where(x => { if (x.PipeArg is not { } param) return pipedType is null; if (pipedType == null) return false; return x.Generic || _toolshed.IsTransformableTo(pipedType, param.ParameterType); }) .OrderByDescending(x => { if (x.PipeArg is not { } param) return 0; return GetMethodRating(pipedType, param.ParameterType); }) .Select(x => { if (!x.Generic) return ((CommandMethod, MethodInfo)?)(x, x.Info); try { if (!x.PipeGeneric) return (x, x.Info.MakeGenericMethod(typeArguments!)); var t = GetGenericTypeFromPiped(pipedType!, x.PipeArg!.ParameterType); return (x, x.Info.MakeGenericMethod(typeArguments?.Append(t).ToArray() ?? [t])); } catch (ArgumentException) { return null; } }) .FirstOrDefault(x => x != null); } private int GetMethodRating(Type? pipedType, Type paramType) { // This method is used to try rate possible command methods to determine how good of a match // they for a given piped input based on the type of the method's piped argument. I.e., if we are // piping an List into a command, in order of most to least preferred we want a method that // takes in a: // - List // - List // - Any concrete type (IEnumerable, IEnumerable, string, EntProtoId, etc) // - List // - constrained generic parameters (e.g., T where T : IEnumerable) // - unconstrained generic parameters // - Any type constructed out of generic types (e.g., List, IEnumerable) // // Finally, subsequent Select() calls in GetConcreteMethodInternal() will effectively discard any methods that // can't actually be used. E.g., List is preferred over List here, but obviously couldn't be used. // // TBH this whole method is pretty janky, but it works well enough. // We want exact match to be preferred. if (pipedType!.IsAssignableTo(paramType)) return 1000; // We prefer non-generic methods if (!paramType.ContainsGenericParameters) { // Next, we also prefer methods that have the same base type. // E.g., given a List we should preferentially match to methods that take in an List if (paramType.GetMostGenericPossible() == pipedType.GetMostGenericPossible()) return 500; return 400; } // Again. we prefer methods that have the same base type. // E.g., given an List we should preferentially match to methods that take in an List if (paramType.GetMostGenericPossible() == pipedType.GetMostGenericPossible()) return 300; // Next we prefer methods that just directly take in some generic type // i.e., we prefer matching the method that takes T over IEnumerable if (paramType.IsGenericParameter) return Math.Min(100 + paramType.GetGenericParameterConstraints().Length, 299); return 0; } /// /// When a method has the , this method is used to actually /// determine the generic type argument given the type of the piped in value. /// /// The type of value that was piped in /// The type as specified in the method public static Type GetGenericTypeFromPiped(Type inputType, Type parameterType) { // inputType!.IntersectWithGeneric(parameterType, _toolshed, true); // I don't really understand the logic behind this // Actually I understand it now, but its just broken or incomplete. Yipeee return inputType.Intersect(parameterType); } /// /// Attempts to fetch a callable shim for a command, aka it's implementation, using the given types. /// public Func GetImplementation(CommandArgumentBundle args, ConcreteCommandMethod method) { var dis = new CommandDiscriminator(args.PipedType, args.TypeArguments); if (!Implementations.TryGetValue(dis, out var impl)) Implementations[dis] = impl = GetImplementationInternal(args, method); return impl; } internal Func GetImplementationInternal(CommandArgumentBundle cmdArgs, ConcreteCommandMethod method) { var args = Expression.Parameter(typeof(CommandInvocationArguments)); var paramList = new List(); foreach (var param in method.Info.GetParameters()) { paramList.Add(GetParamExpr(param, cmdArgs.PipedType, args)); } Expression partialShim = Expression.Call(Expression.Constant(Owner), method.Info, paramList); var returnType = method.Info.ReturnType; if (returnType == typeof(void)) partialShim = Expression.Block(partialShim, Expression.Constant(null)); else if (returnType.IsValueType) partialShim = Expression.Convert(partialShim, typeof(object)); // Have to box primitives. return Expression.Lambda>(partialShim, args).Compile(); } private Expression GetParamExpr(ParameterInfo param, Type? pipedType, ParameterExpression args) { if (param.HasCustomAttribute()) { if (pipedType is null) throw new TypeArgumentException(); // (ParameterType)(args.PipedArgument) return _toolshed.GetTransformer(pipedType, param.ParameterType, Expression.Field(args, nameof(CommandInvocationArguments.PipedArgument))); } if (param.HasCustomAttribute()) { // args.Inverted return Expression.Property(args, nameof(CommandInvocationArguments.Inverted)); } if (param.HasCustomAttribute()) return GetArgExpr(param, args); if (param.HasCustomAttribute() || param.ParameterType == typeof(IInvocationContext)) { // args.Context return Expression.Property(args, nameof(CommandInvocationArguments.Context)); } // Implicit CommandArgumentAttribute return GetArgExpr(param, args); } private Expression GetArgExpr(ParameterInfo param, ParameterExpression args) { // args.Arguments[param.Name] var argValue = Expression.MakeIndex( Expression.Property(args, nameof(CommandInvocationArguments.Arguments)), typeof(Dictionary).FindIndexerProperty(), new[] {Expression.Constant(param.Name)}); // args.Context var ctx = Expression.Property(args, nameof(CommandInvocationArguments.Context)); MethodInfo? evalMethod; var isParamsCollection = param.HasCustomAttribute(); if (isParamsCollection) { // ValueRef.EvaluateParamsCollection evalMethod = typeof(ValueRef<>) .MakeGenericType(param.ParameterType.GetElementType()!) .GetMethod(nameof(ValueRef.EvaluateParamsCollection), BindingFlags.Static | BindingFlags.NonPublic)!; } else { // ValueRef.EvaluateParameter evalMethod = typeof(ValueRef<>) .MakeGenericType(param.ParameterType) .GetMethod(nameof(ValueRef.EvaluateParameter), BindingFlags.Static | BindingFlags.NonPublic)!; } return Expression.Call(evalMethod, argValue, ctx); } public override string ToString() { return FullName; } /// public string GetHelp() { if (_loc.TryGetString($"command-help-{LocName}", out var str)) return str; var builder = new StringBuilder(); // If any of the commands are invertible via the "not" prefix, we point that out in the help string if (Methods.Any(x => x.Invertible)) builder.AppendLine(_loc.GetString($"command-help-invertible")); // List usages by just printing all methods & their arguments builder.Append(_loc.GetString("command-help-usage")); foreach (var method in Methods) { builder.Append(Environment.NewLine + " "); // TODO TOOLSHED // FormattedMessage support for help strings // make the argument type hint colour coded, for easier parsing of help strings. // I.e., in ")> make the "(IEnumerable)" part gray? if (method.PipeArg is {} pipeArg) { var locKey = $"command-arg-sig-{LocName}-{pipeArg.Name}"; if (!_loc.TryGetString(locKey, out var pipeSig)) pipeSig = ToolshedCommand.GetArgHint(pipeArg.Name!, false, false, pipeArg.ParameterType); builder.Append($"{pipeSig} → "); } if (method.Invertible) builder.Append("[not] "); AddMethodSignature(builder, method.Arguments); if (method.Info.ReturnType != typeof(void)) builder.Append($" → {method.Info.ReturnType.PrettyName()}"); } return builder.ToString(); } /// /// Construct the methods signature for help and explain commands. /// internal void AddMethodSignature(StringBuilder builder, CommandArgument[] args, Type[]? typeArgs = null) { builder.Append(FullName); var tParsers = Owner.TypeParameterParsers; var numParsers = 0; foreach (var parserType in tParsers) { if (parserType == typeof(TypeTypeParser)) continue; var parser = _toolshed.GetCustomParser(parserType); if (parser.ShowTypeArgSignature) numParsers++; } // Add "" for methods that take in type arguments. if (numParsers > 0) { builder.Append('<'); for (var i = 0; i < numParsers; i++) { if (i > 0) builder.Append(", "); if (typeArgs != null) { builder.Append(typeArgs[i].PrettyName()); continue; } builder.Append('T'); if (numParsers > 1) builder.Append(i + 1); } builder.Append('>'); } foreach (var arg in args) { builder.Append(' '); if (_loc.TryGetString($"command-arg-sig-{LocName}-{arg.Name}", out var msg)) builder.Append(msg); else builder.Append(ToolshedCommand.GetArgHint(arg, arg.Type)); } } /// public string DescriptionLocKey() => $"command-description-{LocName}"; /// public string Description() { return _loc.GetString(DescriptionLocKey()); } } /// /// Class for caching information about a command's methods. Helps reduce LINQ & reflection calls when attempting /// to find matching methods. /// internal sealed class CommandMethod { /// /// The method associated with some command. /// public readonly MethodInfo Info; /// /// The argument associated with the piped value. /// public readonly ParameterInfo? PipeArg; public readonly bool Generic; public readonly bool Invertible; /// /// Whether the type of the piped value should be used as one of the type parameters for generic methods. /// I.e., whether the method has a . /// public readonly bool PipeGeneric; public readonly CommandArgument[] Arguments; public CommandMethod(MethodInfo info, ToolshedCommandImplementor impl) { Info = info; PipeArg = info.ConsoleGetPipedArgument(); Invertible = info.ConsoleHasInvertedArgument(); Arguments = info.GetParameters() .Where(x => x.IsCommandArgument()) .Select(impl.GetCommandArgument) .ToArray(); if (!info.IsGenericMethodDefinition) return; Generic = true; PipeGeneric = info.HasCustomAttribute(); } } internal readonly record struct ConcreteCommandMethod(MethodInfo Info, CommandArgument[] Args, CommandMethod Base); public readonly record struct CommandArgument( string Name, Type Type, ITypeParser? Parser, bool IsOptional, object? DefaultValue, bool IsParamsCollection); public sealed class ArgumentParseError(Type type, Type parser) : ConError { public override FormattedMessage DescribeInner() { return FormattedMessage.FromUnformatted($"Failed to parse command argument of type {type.PrettyName()} using parser {parser.PrettyName()}"); } } public sealed class ExpectedArgumentError(Type type) : ConError { public override FormattedMessage DescribeInner() { return FormattedMessage.FromUnformatted($"Expected command argument of type {type.PrettyName()}, but ran out of input"); } } public sealed class TypeArgumentParseError(Type parser) : ConError { public override FormattedMessage DescribeInner() { return FormattedMessage.FromUnformatted($"Failed to parse type argument using parser {parser.PrettyName()}"); } } public sealed class ExpectedTypeArgumentError : ConError { public override FormattedMessage DescribeInner() { return FormattedMessage.FromUnformatted($"Expected type argument, but ran out of input"); } }