diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 8c6d48ec58..a261504b8c 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -39,7 +39,7 @@ END TEMPLATE--> ### New features -*None yet* +* Toolshed now supports parsing array and list arguments. ### Bugfixes @@ -58,7 +58,7 @@ END TEMPLATE--> ### Breaking changes -* PrototypeIdSerializers have been removed in lieu of directly using `ProtoId`. +* PrototypeIdSerializers and prototype ID validation attributes have been removed in lieu of directly using `ProtoId`. ### New features diff --git a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs index 3e49d7afad..78c2ec305f 100644 --- a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs +++ b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs @@ -284,7 +284,7 @@ public sealed partial class DebugConsole private CompletionOption[] FilterCompletions(IEnumerable completions, string curTyping) { return completions - .Where(c => c.Value.Contains(curTyping, StringComparison.CurrentCultureIgnoreCase)) + .Where(c => c.Value.Contains(curTyping, StringComparison.CurrentCultureIgnoreCase) || (c.Flags & CompletionOptionFlags.NoFilter) != 0x0) .OrderByDescending(c => c.Value.StartsWith(curTyping, StringComparison.CurrentCultureIgnoreCase)) .ToArray(); } @@ -363,10 +363,17 @@ public sealed partial class DebugConsole var (completion, _, completionFlags) = _compFiltered[index]; var (_, _, lastRange, _) = CalcTypingArgs(); - // Replace the full word from the start. - // This means that letter casing will match the completion suggestion. - CommandBar.CursorPosition = lastRange.end; - CommandBar.SelectionStart = lastRange.start; + if ((completionFlags & CompletionOptionFlags.AppendOnly) != 0) + { + CommandBar.SelectionStart = CommandBar.CursorPosition; + } + else + { + // Replace the full word from the start. + // This means that letter casing will match the completion suggestion. + CommandBar.CursorPosition = lastRange.end; + CommandBar.SelectionStart = lastRange.start; + } var insertValue = (completionFlags & CompletionOptionFlags.NoEscape) == 0 ? CommandParsing.Escape(completion) diff --git a/Robust.Shared.IntegrationTests/Toolshed/TestCommands.cs b/Robust.Shared.IntegrationTests/Toolshed/TestCommands.cs index 6b1c33ac74..cbf5abbdb4 100644 --- a/Robust.Shared.IntegrationTests/Toolshed/TestCommands.cs +++ b/Robust.Shared.IntegrationTests/Toolshed/TestCommands.cs @@ -231,3 +231,25 @@ internal sealed class TestExplicitImplCommand : ToolshedCommand [CommandImplementation] public int Impl2() => 2; } + +[ToolshedCommand] +internal sealed class TestArrayParseCommand : ToolshedCommand +{ + [CommandImplementation] + public int[] Impl(int[] val) => val; +} + +[ToolshedCommand] +internal sealed class TestListParseCommand : ToolshedCommand +{ + [CommandImplementation] + public List Impl(List val) => val; +} + +[ToolshedCommand] +internal sealed class TestListLengthCommand : ToolshedCommand +{ + [CommandImplementation] + public List Impl([ListLength(MinLength = 1, MaxLength = 2)] List val) => val; +} + diff --git a/Robust.Shared.IntegrationTests/Toolshed/ToolshedTests.cs b/Robust.Shared.IntegrationTests/Toolshed/ToolshedTests.cs index 24297ea815..966a5f7f51 100644 --- a/Robust.Shared.IntegrationTests/Toolshed/ToolshedTests.cs +++ b/Robust.Shared.IntegrationTests/Toolshed/ToolshedTests.cs @@ -402,6 +402,35 @@ internal sealed class ToolshedTests : ToolshedTest }); } + [Test] + public async Task TestArrayParsing() + { + await Server.WaitAssertion(() => + { + AssertResult("testarrayparse []", Array.Empty()); + AssertResult("testarrayparse [ ]", Array.Empty()); + AssertResult("testarrayparse [1]", new[] {1}); + AssertResult("testarrayparse [1,2]", new[] {1, 2}); + AssertResult("testarrayparse [ 1 , 2 ]", new[] {1, 2}); + AssertCompletionSingle("testarrayparse ", "["); + AssertCompletionContains("testarrayparse [ 1 ", "]", ","); + + ParseError("testarrayparse [ 1 "); + + AssertResult("testlistparse []", new List()); + AssertResult("testlistparse [1]", new List {1}); + AssertResult("testlistparse [1,2]", new List {1, 2}); + AssertResult("testlistparse [ 1 , 2 ]", new List {1, 2}); + AssertCompletionSingle("testlistparse ", "["); + AssertCompletionContains("testlistparse [ 1 ", "]", ","); + + AssertResult("testlistlength [ 1 ]", new[] {1}); + AssertResult("testlistlength [ 1, 2 ]", new[] {1, 2}); + ParseError("testlistlength [ ]"); + ParseError("testlistlength [ 1, 2, 3 ]"); + }); + } + [Test] public async Task TestCompletions() { diff --git a/Robust.Shared/Console/CompletionResult.cs b/Robust.Shared/Console/CompletionResult.cs index 11920f2283..903b41a996 100644 --- a/Robust.Shared/Console/CompletionResult.cs +++ b/Robust.Shared/Console/CompletionResult.cs @@ -85,4 +85,16 @@ public enum CompletionOptionFlags /// Prevents suggestions from being escaped using . /// NoEscape = 1 << 2, + + /// + /// Prevents suggestions from being filtered based on what the client has "currently typed" + /// so that your completion shows up anyway based on the defined rules in your parser. + /// + NoFilter = 1 << 3, + + /// + /// Instead of replacing the entire argument, the suggestion will + /// be appended to what already exists. + /// + AppendOnly = 1 << 4, } diff --git a/Robust.Shared/Toolshed/Attributes.cs b/Robust.Shared/Toolshed/Attributes.cs index 72b343491c..61bdb9b333 100644 --- a/Robust.Shared/Toolshed/Attributes.cs +++ b/Robust.Shared/Toolshed/Attributes.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using JetBrains.Annotations; using Robust.Shared.Toolshed.TypeParsers; using Robust.Shared.Utility; @@ -93,3 +94,13 @@ public sealed class CommandInvocationContextAttribute : Attribute; /// [AttributeUsage(AttributeTargets.Method)] public sealed class TakesPipedTypeAsGenericAttribute : Attribute; + +/// +/// Sets the min/max length for a in a bit cleaner of a way than the existing attributes for doing so. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class ListLengthAttribute : Attribute +{ + public int MinLength { get; init; } + public int MaxLength { get; init; } = -1; // -1 = no max length +} diff --git a/Robust.Shared/Toolshed/Syntax/ParserContext.cs b/Robust.Shared/Toolshed/Syntax/ParserContext.cs index d7ff9d38cd..10144248a4 100644 --- a/Robust.Shared/Toolshed/Syntax/ParserContext.cs +++ b/Robust.Shared/Toolshed/Syntax/ParserContext.cs @@ -36,6 +36,11 @@ public sealed partial class ParserContext /// public CommandArgumentBundle Bundle; + /// + /// The current argument trying to be parsed. + /// + public CommandArgument? CurrentArgument; + /// /// Whether or not to generate auto-completion options. /// diff --git a/Robust.Shared/Toolshed/ToolshedCommandImplementor.cs b/Robust.Shared/Toolshed/ToolshedCommandImplementor.cs index 7f8939a94b..1dda32fd59 100644 --- a/Robust.Shared/Toolshed/ToolshedCommandImplementor.cs +++ b/Robust.Shared/Toolshed/ToolshedCommandImplementor.cs @@ -109,6 +109,7 @@ internal sealed class ToolshedCommandImplementor foreach (var arg in method.Args) { + ctx.CurrentArgument = arg; object? parsed; if (arg.IsParamsCollection) { @@ -123,6 +124,7 @@ internal sealed class ToolshedCommandImplementor ctx.Bundle.Arguments[arg.Name] = parsed; } + ctx.CurrentArgument = null; DebugTools.AssertNull(ctx.Error); DebugTools.AssertNull(ctx.Completions); return true; @@ -376,13 +378,18 @@ internal sealed class ToolshedCommandImplementor argType = argType.GetElementType()!; } + // TODO TOOLSHED + // Find a good way to give parsers access to argument attributes. + // I don't want to give just content access to all (possibly internal) attributes/ + // Currently ListLengthAttribute is just hardcoded. return new CommandArgument( arg.Name!, argType, GetArgumentParser(arg, argType), arg.IsOptional, arg.DefaultValue, - isParamsCollection); + isParamsCollection, + arg.GetCustomAttribute()); } private ITypeParser? GetArgumentParser(ParameterInfo param, Type type) @@ -763,7 +770,8 @@ public readonly record struct CommandArgument( ITypeParser? Parser, bool IsOptional, object? DefaultValue, - bool IsParamsCollection); + bool IsParamsCollection, + ListLengthAttribute? ListLengthAttribute); public sealed class ArgumentParseError(Type type, Type parser) : ConError { diff --git a/Robust.Shared/Toolshed/ToolshedManager.Parsing.cs b/Robust.Shared/Toolshed/ToolshedManager.Parsing.cs index 187ba3dbbe..ad5fcdcd85 100644 --- a/Robust.Shared/Toolshed/ToolshedManager.Parsing.cs +++ b/Robust.Shared/Toolshed/ToolshedManager.Parsing.cs @@ -17,6 +17,7 @@ namespace Robust.Shared.Toolshed; public sealed partial class ToolshedManager { private readonly Dictionary _consoleTypeParsers = new(); + private readonly Dictionary _arrayParsers = new(); private readonly Dictionary _argParsers = new(); private readonly Dictionary _customParsers = new(); private readonly Dictionary _genericTypeParsers = new(); @@ -79,6 +80,9 @@ public sealed partial class ToolshedManager internal ITypeParser? GetParserForType(Type t) { + if (t.IsArray) + return GetArrayParser(t); + if (_consoleTypeParsers.TryGetValue(t, out var parser)) return parser; @@ -88,6 +92,31 @@ public sealed partial class ToolshedManager return parser; } + private ITypeParser? GetArrayParser(Type t) + { + if (t.GetArrayRank() != 1) + { + // Multidimensional arrays are not supported yet. + return null; + } + + var elementType = t.GetElementType(); + if (elementType == null || elementType.ContainsGenericParameters) + return null; + + if (_arrayParsers.TryGetValue(elementType, out var parser)) + return parser; + + var concreteParser = typeof(ArrayTypeParser<>).MakeGenericType(elementType); + var builtParser = (ITypeParser) _typeFactory.CreateInstanceUnchecked(concreteParser, true); + + if (builtParser is IPostInjectInit inj) + inj.PostInject(); + + _arrayParsers[elementType] = builtParser; + return builtParser; + } + /// /// Variant of that will return a parser that also attempts to resolve a type from a /// variable or block via the and parsers. diff --git a/Robust.Shared/Toolshed/TypeParsers/ArrayTypeParser.cs b/Robust.Shared/Toolshed/TypeParsers/ArrayTypeParser.cs new file mode 100644 index 0000000000..35cbd3aa28 --- /dev/null +++ b/Robust.Shared/Toolshed/TypeParsers/ArrayTypeParser.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Robust.Shared.Console; +using Robust.Shared.Toolshed.Syntax; + +namespace Robust.Shared.Toolshed.TypeParsers; + +public sealed class ArrayTypeParser : TypeParser +{ + public override bool TryParse(ParserContext ctx, [NotNullWhen(true)] out T[]? result) + { + result = null; + if (!Toolshed.TryParse(ctx, out List? list)) + return false; + + result = list.ToArray(); + return true; + } + + public override CompletionResult? TryAutocomplete(ParserContext ctx, CommandArgument? arg) + { + return Toolshed.TryAutocomplete(ctx, typeof(List), arg); + } +} diff --git a/Robust.Shared/Toolshed/TypeParsers/ListTypeParser.cs b/Robust.Shared/Toolshed/TypeParsers/ListTypeParser.cs new file mode 100644 index 0000000000..ced367d560 --- /dev/null +++ b/Robust.Shared/Toolshed/TypeParsers/ListTypeParser.cs @@ -0,0 +1,189 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using Robust.Shared.Console; +using Robust.Shared.Toolshed.Errors; +using Robust.Shared.Toolshed.Syntax; +using Robust.Shared.Toolshed.TypeParsers.Math; +using Robust.Shared.Utility; + +namespace Robust.Shared.Toolshed.TypeParsers; + +public sealed class ListTypeParser : TypeParser> +{ + public override bool TryParse(ParserContext ctx, [NotNullWhen(true)] out List? result) + { + ctx.ConsumeWhitespace(); + result = null; + + if (!ctx.EatMatch('[')) + { + ctx.Error = new ExpectedOpenBrace(); + return false; + } + + var values = new List(); + + var (minLength, maxLength) = GetLengthParameters(ctx.CurrentArgument); + + ctx.ConsumeWhitespace(); + if (ctx.EatMatch(']')) + { + if (minLength > 0) + { + ctx.Error = new NotEnoughElementsError(minLength); + return false; + } + + result = values; + return true; + } + + while (true) + { + ctx.ConsumeWhitespace(); + + if (!Toolshed.TryParse(ctx, out T? value)) + return false; + + values.Add(value); + + if (maxLength >= 0 && values.Count > maxLength) + { + ctx.Error = new TooManyElementsError(maxLength); + return false; + } + + ctx.ConsumeWhitespace(); + + if (ctx.EatMatch(',')) + continue; + + if (ctx.EatMatch(']')) + { + if (values.Count < minLength) + { + ctx.Error = new NotEnoughElementsError(minLength); + return false; + } + + result = values; + return true; + } + + ctx.Error = new ExpectedTokenError([",", "]"]); + return false; + } + } + + public override CompletionResult? TryAutocomplete(ParserContext ctx, CommandArgument? arg) + { + var hint = GetArgHint(arg); + + ctx.ConsumeWhitespace(); + + if (!ctx.EatMatch('[')) + { + return CompletionResult.FromHintOptions([ + new CompletionOption("[", + Flags: CompletionOptionFlags.PartialCompletion | CompletionOptionFlags.NoEscape | + CompletionOptionFlags.AppendOnly) + ], + hint); + } + + var (minLength, maxLength) = GetLengthParameters(arg); + int count = 0; + + while (true) + { + ctx.ConsumeWhitespace(); + + if (ctx.PeekRune() == + new Rune(']')) // this doesn't show in autocomplete, but I can't be bothered to touch anything below here again with a 20000000ft pole. + return CompletionResult.FromHint(hint); + + var restore = ctx.Save(); + + if (!Toolshed.TryParse(ctx, out T? _)) + { + ctx.Restore(restore); + + // TODO TOOLSHED fix + ctx.Error = null; + + var result = Toolshed.TryAutocomplete(ctx, typeof(T), arg); + if (result is null) return result; + var opts = result.Options.Select(opt => + new CompletionOption(opt.Value, + opt.Hint, + opt.Flags | CompletionOptionFlags.NoFilter | CompletionOptionFlags.AppendOnly)); + return new CompletionResult(opts.ToArray(), result.Hint); + } + + ctx.ConsumeWhitespace(); + count++; + + if (ctx.PeekRune() is null) + { + List opts = []; + + if (maxLength < 0 || maxLength > count) + { + opts.Add(new CompletionOption(",", + Flags: CompletionOptionFlags.NoEscape | CompletionOptionFlags.NoFilter | + CompletionOptionFlags.AppendOnly)); + } + + if (count >= minLength || count >= maxLength) + { + opts.Add(new CompletionOption("]", + Flags: CompletionOptionFlags.NoEscape | CompletionOptionFlags.NoFilter | + CompletionOptionFlags.AppendOnly)); + } + + return CompletionResult.FromHintOptions(opts, hint); + } + + if (ctx.EatMatch(',')) + continue; + + if (ctx.EatMatch(']')) + return CompletionResult.FromHint(hint); + + return CompletionResult.FromHintOptions([ + new CompletionOption("]", + Flags: CompletionOptionFlags.NoEscape | CompletionOptionFlags.NoFilter | + CompletionOptionFlags.AppendOnly) + ], + hint); + } + } + + private (int minLength, int maxLength) GetLengthParameters(CommandArgument? arg) + { + return ( + arg?.ListLengthAttribute?.MinLength ?? 0, + arg?.ListLengthAttribute?.MaxLength ?? -1 + ); + } +} + +public sealed class ExpectedTokenError(string[] expectedTokens) : ConError +{ + public override FormattedMessage DescribeInner() => + FormattedMessage.FromUnformatted($"Expected one of the following tokens: {string.Join(", ", expectedTokens)}"); +} + +public sealed class TooManyElementsError(int max) : ConError +{ + public override FormattedMessage DescribeInner() => + FormattedMessage.FromUnformatted($"Too many elements, maximum length is {max}."); +} + +public sealed class NotEnoughElementsError(int min) : ConError +{ + public override FormattedMessage DescribeInner() => + FormattedMessage.FromUnformatted($"Not enough elements, minimum length is {min}."); +}