mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-09-01 17:47:24 +02:00
* Include argument name in completion suggestions * Support optional args * It (not so shrimply) works * Add tests * Add TestGenericPipeInference * Fix tests * Release notes * Overzealous YAMLLinter * Improve help signatures, fix map command * Improve NoImplementationError * Better type argument help signatures * better pipe syntax * fix NRE * Add test * a * Fix silent toolshed failure * Fix GetConcreteMethodInternal * Improve vars command * EntProtoId IAsType * More GetConcreteMethodInternal fixes * I hate this so much * update tp command description The command arguments call the the "other" entity the "target" * Support localized argument hints/signatures
348 lines
12 KiB
C#
348 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Linq;
|
|
using Robust.Shared.Console;
|
|
using Robust.Shared.Maths;
|
|
using Robust.Shared.Toolshed.Errors;
|
|
using Robust.Shared.Utility;
|
|
|
|
namespace Robust.Shared.Toolshed.Syntax;
|
|
|
|
using Invocable = Func<CommandInvocationArguments, object?>;
|
|
|
|
public sealed class ParsedCommand
|
|
{
|
|
public ToolshedCommand Command => Implementor.Owner;
|
|
public Type ReturnType => Method.Info.ReturnType;
|
|
|
|
public Type? PipedType => Bundle.PipedType;
|
|
public string? SubCommand => Bundle.SubCommand;
|
|
|
|
internal readonly ToolshedCommandImplementor Implementor;
|
|
internal Invocable Invocable { get; }
|
|
internal CommandArgumentBundle Bundle { get; }
|
|
|
|
internal readonly ConcreteCommandMethod Method;
|
|
|
|
public static bool TryParse(ParserContext ctx, Type? piped, [NotNullWhen(true)] out ParsedCommand? result)
|
|
{
|
|
var checkpoint = ctx.Save();
|
|
var oldBundle = ctx.Bundle;
|
|
DebugTools.AssertNull(ctx.Error);
|
|
DebugTools.AssertNull(ctx.Completions);
|
|
ctx.Bundle = new CommandArgumentBundle
|
|
{
|
|
Inverted = false,
|
|
PipedType = piped
|
|
};
|
|
|
|
ctx.ConsumeWhitespace();
|
|
|
|
if (!TryDigestModifiers(ctx))
|
|
{
|
|
result = null;
|
|
ctx.Restore(checkpoint);
|
|
return false;
|
|
}
|
|
|
|
// TODO TOOLSHED
|
|
// completion suggestions for modifiers?
|
|
// I.e., if parsing a command name fails, we should take into account that they might be trying to type out
|
|
// "not" or some other command modifier?
|
|
|
|
if (!TryParseCommand(ctx, out var invocable, out var method, out var implementor))
|
|
{
|
|
result = null;
|
|
ctx.Restore(checkpoint);
|
|
return false;
|
|
}
|
|
|
|
// No errors or completions should have been generated if the parse was successful.
|
|
DebugTools.AssertNull(ctx.Error);
|
|
DebugTools.AssertNull(ctx.Completions);
|
|
result = new(ctx.Bundle, invocable, method.Value, implementor);
|
|
ctx.Bundle = oldBundle;
|
|
return true;
|
|
}
|
|
|
|
private ParsedCommand(CommandArgumentBundle bundle, Invocable invocable, ConcreteCommandMethod method, ToolshedCommandImplementor implementor)
|
|
{
|
|
Invocable = invocable;
|
|
Bundle = bundle;
|
|
Implementor = implementor;
|
|
Method = method;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempt to process any modifer tokens that modify how a command behaves or how it's arguments are parsed and
|
|
/// store the results in the <see cref="CommandArgumentBundle"/>.
|
|
/// </summary>
|
|
private static bool TryDigestModifiers(ParserContext ctx)
|
|
{
|
|
if (ctx.EatMatch("not"))
|
|
{
|
|
ctx.ConsumeWhitespace();
|
|
ctx.Bundle.Inverted = true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseCommand(
|
|
ParserContext ctx,
|
|
[NotNullWhen(true)] out Invocable? invocable,
|
|
[NotNullWhen(true)] out ConcreteCommandMethod? method,
|
|
[NotNullWhen(true)] out ToolshedCommandImplementor? implementor)
|
|
{
|
|
invocable = null;
|
|
implementor = null;
|
|
method = null;
|
|
var cmdNameStart = ctx.Index;
|
|
DebugTools.AssertNull(ctx.Error);
|
|
DebugTools.AssertNull(ctx.Completions);
|
|
|
|
// Try to parse the command name
|
|
if (!TryParseCommandName(ctx, out var cmdName))
|
|
return false;
|
|
|
|
// Attempt to find the command with the given name
|
|
if (!ctx.Environment.TryGetCommand(cmdName, out var command))
|
|
{
|
|
if (ctx.GenerateCompletions)
|
|
{
|
|
if (ctx.OutOfInput)
|
|
ctx.Completions = ctx.Environment.CommandCompletionsForType(ctx.Bundle.PipedType);
|
|
return false;
|
|
}
|
|
|
|
ctx.Error ??= new UnknownCommandError(cmdName);
|
|
ctx.Error.Contextualize(ctx.Input, (cmdNameStart, ctx.Index));
|
|
return false;
|
|
}
|
|
|
|
// Attempt to parse the subcommand, if applicable.
|
|
if (!TryParseImplementor(ctx, command, out implementor))
|
|
return false;
|
|
|
|
// This is a safeguard to try help prevent information from being accidentally leaked by poorly validated
|
|
// auto completion for commands. I.e., if there is a command that operates on all minds/players, we don't want
|
|
// to send the client a list of all players.
|
|
if (!ctx.CheckInvokable(implementor.Spec))
|
|
{
|
|
if (ctx.GenerateCompletions)
|
|
ctx.Completions = CompletionResult.FromHint($"Insufficient permissions for command: {implementor.FullName}");
|
|
return false;
|
|
}
|
|
|
|
// If the name command is currently still being typed, we continue to give command name completions, not
|
|
// argument completions.
|
|
if (ctx.GenerateCompletions && ctx.OutOfInput)
|
|
{
|
|
ctx.Completions = ctx.Bundle.SubCommand == null
|
|
? ctx.Environment.CommandCompletionsForType(ctx.Bundle.PipedType)
|
|
: ctx.Environment.SubCommandCompletionsForType(ctx.Bundle.PipedType, command);
|
|
|
|
// TODO TOOLSHED invalid-fail
|
|
// This technically "fails" to parse what might otherwise be a valid command that takes no argument.
|
|
// 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;
|
|
}
|
|
|
|
return implementor.TryParse(ctx, out invocable, out method);
|
|
}
|
|
|
|
private static bool TryParseCommandName(ParserContext ctx, [NotNullWhen(true)] out string? name)
|
|
{
|
|
ctx.Bundle.NameStart = ctx.Index;
|
|
name = ctx.GetWord(ParserContext.IsCommandToken);
|
|
if (name != null)
|
|
{
|
|
ctx.Bundle.Command = name;
|
|
ctx.Bundle.NameEnd = ctx.Index;
|
|
return true;
|
|
}
|
|
|
|
if (ctx.OutOfInput)
|
|
{
|
|
if (ctx.GenerateCompletions)
|
|
{
|
|
ctx.Completions = ctx.Environment.CommandCompletionsForType(ctx.Bundle.PipedType);
|
|
}
|
|
else
|
|
{
|
|
ctx.Error = new OutOfInputError();
|
|
ctx.Error.Contextualize(ctx.Input, (ctx.Index, ctx.Index));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (ctx.GenerateCompletions)
|
|
return false;
|
|
|
|
ctx.Error = new NotValidCommandError();
|
|
ctx.Error.Contextualize(ctx.Input, (ctx.Bundle.NameStart, ctx.Index+1));
|
|
return false;
|
|
}
|
|
|
|
private static bool TryParseImplementor(ParserContext ctx, ToolshedCommand cmd, [NotNullWhen(true)] out ToolshedCommandImplementor? impl)
|
|
{
|
|
if (!cmd.HasSubCommands)
|
|
{
|
|
impl = cmd.CommandImplementors[string.Empty];
|
|
return true;
|
|
}
|
|
|
|
impl = null;
|
|
if (!ctx.EatMatch(':'))
|
|
{
|
|
if (ctx.GenerateCompletions)
|
|
{
|
|
ctx.Completions = ctx.Environment.SubCommandCompletionsForType(ctx.Bundle.PipedType, cmd);
|
|
return false;
|
|
}
|
|
ctx.Error = new OutOfInputError();
|
|
ctx.Error.Contextualize(ctx.Input, (ctx.Index, ctx.Index));
|
|
return false;
|
|
}
|
|
|
|
var subCmdStart = ctx.Index;
|
|
if (ctx.GetWord(ParserContext.IsToken) is not { } subcmd)
|
|
{
|
|
if (ctx.GenerateCompletions)
|
|
{
|
|
ctx.Completions = ctx.Environment.SubCommandCompletionsForType(ctx.Bundle.PipedType, cmd);
|
|
return false;
|
|
}
|
|
ctx.Error = new OutOfInputError();
|
|
ctx.Error.Contextualize(ctx.Input, (ctx.Index, ctx.Index));
|
|
return false;
|
|
}
|
|
|
|
if (!cmd.CommandImplementors.TryGetValue(subcmd, out impl!))
|
|
{
|
|
if (ctx.GenerateCompletions)
|
|
{
|
|
ctx.Completions = ctx.Environment.SubCommandCompletionsForType(ctx.Bundle.PipedType, cmd);
|
|
return false;
|
|
}
|
|
ctx.Error = new UnknownSubcommandError(subcmd, cmd);
|
|
ctx.Error.Contextualize(ctx.Input, (subCmdStart, ctx.Index));
|
|
return false;
|
|
}
|
|
|
|
ctx.Bundle.NameEnd = ctx.Index;
|
|
ctx.Bundle.SubCommand = subcmd;
|
|
return true;
|
|
}
|
|
|
|
private bool _passedInvokeTest;
|
|
|
|
public object? Invoke(object? pipedIn, IInvocationContext ctx)
|
|
{
|
|
if (!_passedInvokeTest && !ctx.CheckInvokable(new CommandSpec(Command, SubCommand), out var error))
|
|
{
|
|
// Could not invoke the command for whatever reason, i.e. permission errors.
|
|
if (error is not null)
|
|
ctx.ReportError(error);
|
|
return null;
|
|
}
|
|
|
|
// TODO: This optimization might be dangerous if blocks can be passed to other people through vars.
|
|
// Or not if it can only be done deliberately, but social engineering is a thing.
|
|
_passedInvokeTest = true;
|
|
|
|
try
|
|
{
|
|
return Invocable.Invoke(new CommandInvocationArguments()
|
|
{Bundle = Bundle, PipedArgument = pipedIn, Context = ctx});
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
ctx.ReportError(new UnhandledExceptionError(e));
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public record struct UnknownCommandError(string Cmd) : IConError
|
|
{
|
|
public FormattedMessage DescribeInner()
|
|
{
|
|
return FormattedMessage.FromUnformatted($"Got unknown command {Cmd}.");
|
|
}
|
|
|
|
public string? Expression { get; set; }
|
|
public Vector2i? IssueSpan { get; set; }
|
|
public StackTrace? Trace { get; set; }
|
|
}
|
|
|
|
public sealed class NoImplementationError(ParserContext ctx) : ConError
|
|
{
|
|
public readonly ToolshedEnvironment Env = ctx.Environment;
|
|
public readonly string Cmd = ctx.Bundle.Command!;
|
|
public readonly string? SubCommand = ctx.Bundle.SubCommand;
|
|
public readonly Type[]? Types = ctx.Bundle.TypeArguments;
|
|
public readonly Type? PipedType = ctx.Bundle.PipedType;
|
|
|
|
public override FormattedMessage DescribeInner()
|
|
{
|
|
var msg = FormattedMessage.FromUnformatted($"Could not find an implementation of the '{Cmd}' command given the input type '{PipedType?.PrettyName() ?? "void"}'.\n");
|
|
|
|
var cmdImpl = Env.GetCommand(Cmd);
|
|
var accepted = cmdImpl.AcceptedTypes(SubCommand);
|
|
|
|
// If one of the signatures just takes T Or IEnumerable<T> we just don't print anything, as it doesn't provide any useful information.
|
|
// TODO TOOLSHED list accepted generic types
|
|
var isGeneric = accepted.Any(x => x.IsGenericParameter);
|
|
if (isGeneric)
|
|
return msg;
|
|
|
|
var isGenericEnumerable = accepted.Any(x=> x.IsGenericType
|
|
&& x.GetGenericTypeDefinition() == typeof(IEnumerable<>)
|
|
&& x.GetGenericArguments()[0].IsGenericParameter);
|
|
if (isGenericEnumerable)
|
|
return msg;
|
|
|
|
msg.AddText($"Accepted types: '{string.Join("','", accepted.Select(x => x.PrettyName()))}'.\n");
|
|
return msg;
|
|
}
|
|
}
|
|
|
|
public record UnknownSubcommandError(string SubCmd, ToolshedCommand Command) : IConError
|
|
{
|
|
public FormattedMessage DescribeInner()
|
|
{
|
|
var msg = new FormattedMessage();
|
|
msg.AddText($"The command group {Command.Name} doesn't have command {SubCmd}.");
|
|
msg.PushNewline();
|
|
msg.AddText($"The valid commands are: {string.Join(", ", Command.Subcommands)}.");
|
|
return msg;
|
|
}
|
|
|
|
public string? Expression { get; set; }
|
|
public Vector2i? IssueSpan { get; set; }
|
|
public StackTrace? Trace { get; set; }
|
|
}
|
|
|
|
public sealed class NotValidCommandError : ConError
|
|
{
|
|
public Type? TargetType;
|
|
|
|
public override FormattedMessage DescribeInner()
|
|
{
|
|
var msg = new FormattedMessage();
|
|
msg.AddText("Ran into an invalid command, could not parse.");
|
|
if (TargetType is not null && TargetType != typeof(void))
|
|
{
|
|
msg.PushNewline();
|
|
msg.AddText($"The parser was trying to obtain a run of type {TargetType.PrettyName()}, make sure your run actually returns that value.");
|
|
}
|
|
|
|
return msg;
|
|
}
|
|
}
|