From dcde669baaf8fffc5453993f93e8bfa790d86dae Mon Sep 17 00:00:00 2001 From: neomoth Date: Sat, 22 Aug 2026 21:29:33 +0000 Subject: [PATCH] [Toolshed] Array parsing (#6864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add parsing for arrays of values via ValueArray. * oops forgot command description * add valarr to engine toolshed perms, fix stupidness, remove useless code in parser * make optional not suck * support for skipping optional parameters * Switch to List because I'm dumb and that just works™ * remove optional stuff to separate into its own PR * Remove ValArr command `valarr int [ 1, 2 ]` can just be replaced with ` val List [ 1, 2 ]` or ` val int[] [ 1, 2 ]` * Fix namespace * Add support for array parsing * Make ListTypeParser output nullable * Pass ListLengthAttribute in the CommandArgument struct The current method of trying to fetch the attribute for argument doesn't actually work. i.e., the argIndex calculation isn't always correct. This is still kinda janky and I hate it, but it should work better. * Fix empty list parsing, add tests * rename flag, and clarify description * More tests --------- Co-authored-by: ElectroJr --- RELEASE-NOTES.md | 4 +- .../DebugConsole.xaml.Completions.cs | 17 +- .../Toolshed/TestCommands.cs | 22 ++ .../Toolshed/ToolshedTests.cs | 29 +++ Robust.Shared/Console/CompletionResult.cs | 12 ++ Robust.Shared/Toolshed/Attributes.cs | 11 + .../Toolshed/Syntax/ParserContext.cs | 5 + .../Toolshed/ToolshedCommandImplementor.cs | 12 +- .../Toolshed/ToolshedManager.Parsing.cs | 29 +++ .../Toolshed/TypeParsers/ArrayTypeParser.cs | 24 +++ .../Toolshed/TypeParsers/ListTypeParser.cs | 189 ++++++++++++++++++ 11 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 Robust.Shared/Toolshed/TypeParsers/ArrayTypeParser.cs create mode 100644 Robust.Shared/Toolshed/TypeParsers/ListTypeParser.cs 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}."); +}