From eec9f64026c0019d0af9a466a87b922e93bbfbd9 Mon Sep 17 00:00:00 2001 From: dusk Date: Sun, 5 Jan 2025 13:00:06 +0900 Subject: [PATCH] feat: implement proper ("static") parameters handling command parser -> bot feat: handle few more commands bot side fix(commands): handle missing parameters and return error refactor(commands): use ordermap instead of relying on a sort function to sort tokens --- Cargo.lock | 22 +- PluralKit.Bot/CommandMeta/CommandTree.cs | 218 +++++++++--------- .../CommandSystem/Context/Context.cs | 16 -- .../Context/ContextArgumentsExt.cs | 82 +------ .../Context/ContextEntityArgumentsExt.cs | 25 +- PluralKit.Bot/CommandSystem/Parameters.cs | 185 --------------- PluralKit.Bot/CommandSystem/ParametersFFI.cs | 72 ++++-- PluralKit.Bot/Commands/Member.cs | 4 +- PluralKit.Bot/Handlers/MessageCreated.cs | 18 +- crates/commands/Cargo.toml | 1 + crates/commands/src/commands.udl | 13 ++ crates/commands/src/commands/member.rs | 16 +- crates/commands/src/commands/system.rs | 2 +- crates/commands/src/lib.rs | 99 ++++++-- crates/commands/src/token.rs | 55 +++-- crates/commands/src/tree.rs | 32 +-- 16 files changed, 358 insertions(+), 502 deletions(-) delete mode 100644 PluralKit.Bot/CommandSystem/Parameters.cs diff --git a/Cargo.lock b/Cargo.lock index 62650368..4e9094bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,6 +569,7 @@ name = "commands" version = "0.1.0" dependencies = [ "lazy_static", + "ordermap", "smol_str", "uniffi", ] @@ -1341,6 +1342,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "hashlink" version = "0.9.1" @@ -1719,12 +1726,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.15.2", ] [[package]] @@ -2235,6 +2242,15 @@ dependencies = [ "hashbrown 0.13.2", ] +[[package]] +name = "ordermap" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f80a48eb68b6a7da9829b8b0429011708f775af80676a91063d023a66a656106" +dependencies = [ + "indexmap", +] + [[package]] name = "os_info" version = "3.8.2" diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 3ecdc070..554e623c 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -4,22 +4,27 @@ namespace PluralKit.Bot; public partial class CommandTree { - public Task ExecuteCommand(Context ctx) + public Task ExecuteCommand(Context ctx, ResolvedParameters parameters) { - switch (ctx.Parameters.Callback()) + switch (parameters.Raw.Callback()) { case "fun_thunder": return ctx.Execute(null, m => m.Thunder(ctx)); case "help": return ctx.Execute(Help, m => m.HelpRoot(ctx)); case "help_commands": - return ctx.Reply("For the list of commands, see the website: "); + return ctx.Reply( + "For the list of commands, see the website: "); case "help_proxy": return ctx.Reply( "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"); + case "member_show": + return ctx.Execute(MemberInfo, m => m.ViewMember(ctx, parameters.MemberParams["target"])); + case "member_new": + return ctx.Execute(MemberNew, m => m.NewMember(ctx, parameters.Raw.Params()["name"])); default: - // remove compiler warning - return ctx.Reply($"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!"); + return ctx.Reply( + $"{Emojis.Error} Parsed command {parameters.Raw.Callback().AsCode()} not implemented in PluralKit.Bot!"); } if (ctx.Match("system", "s")) return HandleSystemCommand(ctx); @@ -224,43 +229,44 @@ public partial class CommandTree // finally, parse commands that *can* take a system target else { - // try matching a system ID - var target = await ctx.MatchSystem(); - var previousPtr = ctx.Parameters._ptr; + // TODO: actually implement this + // // try matching a system ID + // var target = await ctx.MatchSystem(); + // var previousPtr = ctx.Parameters._ptr; - // if we have a parsed target and no more commands, don't bother with the command flow - // we skip the `target != null` check here since the argument isn't be popped if it's not a system - if (!ctx.HasNext()) - { - await ctx.Execute(SystemInfo, m => m.Query(ctx, target ?? ctx.System)); - return; - } + // // if we have a parsed target and no more commands, don't bother with the command flow + // // we skip the `target != null` check here since the argument isn't be popped if it's not a system + // if (!ctx.HasNext()) + // { + // await ctx.Execute(SystemInfo, m => m.Query(ctx, target ?? ctx.System)); + // return; + // } - // hacky, but we need to CheckSystem(target) which throws a PKError - // normally PKErrors are only handled in ctx.Execute - try - { - await HandleSystemCommandTargeted(ctx, target ?? ctx.System); - } - catch (PKError e) - { - await ctx.Reply($"{Emojis.Error} {e.Message}"); - return; - } + // // hacky, but we need to CheckSystem(target) which throws a PKError + // // normally PKErrors are only handled in ctx.Execute + // try + // { + // await HandleSystemCommandTargeted(ctx, target ?? ctx.System); + // } + // catch (PKError e) + // { + // await ctx.Reply($"{Emojis.Error} {e.Message}"); + // return; + // } - // if we *still* haven't matched anything, the user entered an invalid command name or system reference - if (ctx.Parameters._ptr == previousPtr) - { - if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _)) - { - await PrintCommandNotFoundError(ctx, SystemCommands); - return; - } + // // if we *still* haven't matched anything, the user entered an invalid command name or system reference + // if (ctx.Parameters._ptr == previousPtr) + // { + // if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _)) + // { + // await PrintCommandNotFoundError(ctx, SystemCommands); + // return; + // } - var list = CreatePotentialCommandList(ctx.DefaultPrefix, SystemCommands); - await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n" - + $"Perhaps you meant to use one of the following commands?\n{list}"); - } + // var list = CreatePotentialCommandList(ctx.DefaultPrefix, SystemCommands); + // await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n" + // + $"Perhaps you meant to use one of the following commands?\n{list}"); + // } } } @@ -324,20 +330,21 @@ public partial class CommandTree private async Task HandleMemberCommand(Context ctx) { - if (ctx.Match("new", "n", "add", "create", "register")) - await ctx.Execute(MemberNew, m => m.NewMember(ctx)); - else if (ctx.Match("list")) - await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "members", MemberCommands); - else if (await ctx.MatchMember() is PKMember target) - await HandleMemberCommandTargeted(ctx, target); - else if (!ctx.HasNext()) - await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName, - MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar); - else - await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", ctx.PopArgument())}"); + // TODO: implement + // if (ctx.Match("new", "n", "add", "create", "register")) + // await ctx.Execute(MemberNew, m => m.NewMember(ctx)); + // else if (ctx.Match("list")) + // await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); + // else if (ctx.Match("commands", "help")) + // await PrintCommandList(ctx, "members", MemberCommands); + // else if (await ctx.MatchMember() is PKMember target) + // await HandleMemberCommandTargeted(ctx, target); + // else if (!ctx.HasNext()) + // await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName, + // MemberServerName, MemberDesc, MemberPronouns, + // MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar); + // else + // await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", ctx.PopArgument())}"); } private async Task HandleMemberCommandTargeted(Context ctx, PKMember target) @@ -408,59 +415,60 @@ public partial class CommandTree private async Task HandleGroupCommand(Context ctx) { - // Commands with no group argument - if (ctx.Match("n", "new")) - await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); - else if (ctx.Match("list", "l")) - await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "groups", GroupCommands); - else if (await ctx.MatchGroup() is { } target) - { - // Commands with group argument - if (ctx.Match("rename", "name", "changename", "setname", "rn")) - await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); - else if (ctx.Match("nick", "dn", "displayname", "nickname")) - await ctx.Execute(GroupDisplayName, g => g.GroupDisplayName(ctx, target)); - else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro")) - await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); - else if (ctx.Match("add", "a")) - await ctx.Execute(GroupAdd, - g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add)); - else if (ctx.Match("remove", "rem")) - await ctx.Execute(GroupRemove, - g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); - else if (ctx.Match("members", "list", "ms", "l", "ls")) - await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); - else if (ctx.Match("random", "rand", "r")) - await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); - else if (ctx.Match("privacy")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); - else if (ctx.Match("public", "pub")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public)); - else if (ctx.Match("private", "priv")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private)); - else if (ctx.Match("delete", "destroy", "erase", "yeet")) - await ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, target)); - else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.Execute(GroupIcon, g => g.GroupIcon(ctx, target)); - else if (ctx.Match("banner", "splash", "cover")) - await ctx.Execute(GroupBannerImage, g => g.GroupBannerImage(ctx, target)); - else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) - await ctx.Execute(GroupFrontPercent, g => g.FrontPercent(ctx, group: target)); - else if (ctx.Match("color", "colour")) - await ctx.Execute(GroupColor, g => g.GroupColor(ctx, target)); - else if (ctx.Match("id")) - await ctx.Execute(GroupId, g => g.DisplayId(ctx, target)); - else if (!ctx.HasNext()) - await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); - else - await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); - } - else if (!ctx.HasNext()) - await PrintCommandExpectedError(ctx, GroupCommands); - else - await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}"); + // TODO: implement + // // Commands with no group argument + // if (ctx.Match("n", "new")) + // await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); + // else if (ctx.Match("list", "l")) + // await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); + // else if (ctx.Match("commands", "help")) + // await PrintCommandList(ctx, "groups", GroupCommands); + // else if (await ctx.MatchGroup() is { } target) + // { + // // Commands with group argument + // if (ctx.Match("rename", "name", "changename", "setname", "rn")) + // await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); + // else if (ctx.Match("nick", "dn", "displayname", "nickname")) + // await ctx.Execute(GroupDisplayName, g => g.GroupDisplayName(ctx, target)); + // else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro")) + // await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); + // else if (ctx.Match("add", "a")) + // await ctx.Execute(GroupAdd, + // g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add)); + // else if (ctx.Match("remove", "rem")) + // await ctx.Execute(GroupRemove, + // g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); + // else if (ctx.Match("members", "list", "ms", "l", "ls")) + // await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); + // else if (ctx.Match("random", "rand", "r")) + // await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); + // else if (ctx.Match("privacy")) + // await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); + // else if (ctx.Match("public", "pub")) + // await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public)); + // else if (ctx.Match("private", "priv")) + // await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private)); + // else if (ctx.Match("delete", "destroy", "erase", "yeet")) + // await ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, target)); + // else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) + // await ctx.Execute(GroupIcon, g => g.GroupIcon(ctx, target)); + // else if (ctx.Match("banner", "splash", "cover")) + // await ctx.Execute(GroupBannerImage, g => g.GroupBannerImage(ctx, target)); + // else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) + // await ctx.Execute(GroupFrontPercent, g => g.FrontPercent(ctx, group: target)); + // else if (ctx.Match("color", "colour")) + // await ctx.Execute(GroupColor, g => g.GroupColor(ctx, target)); + // else if (ctx.Match("id")) + // await ctx.Execute(GroupId, g => g.DisplayId(ctx, target)); + // else if (!ctx.HasNext()) + // await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); + // else + // await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); + // } + // else if (!ctx.HasNext()) + // await PrintCommandExpectedError(ctx, GroupCommands); + // else + // await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}"); } private async Task HandleSwitchCommand(Context ctx) diff --git a/PluralKit.Bot/CommandSystem/Context/Context.cs b/PluralKit.Bot/CommandSystem/Context/Context.cs index 244725f8..344e85d5 100644 --- a/PluralKit.Bot/CommandSystem/Context/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context/Context.cs @@ -50,21 +50,6 @@ public class Context DefaultPrefix = prefixes[0]; Rest = provider.Resolve(); Cluster = provider.Resolve(); - - try - { - Parameters = new ParametersFFI(message.Content?.Substring(commandParseOffset)); - } - catch (PKError e) - { - // don't send an "invalid command" response if the guild has those turned off - if (!(GuildConfig != null && GuildConfig!.InvalidCommandResponseEnabled != true)) - { - // todo: not this - Reply($"{Emojis.Error} {e.Message}"); - } - throw; - } } public readonly IDiscordCache Cache; @@ -90,7 +75,6 @@ public class Context public readonly string CommandPrefix; public readonly string DefaultPrefix; - public readonly ParametersFFI Parameters; internal readonly IDatabase Database; internal readonly ModelRepository Repository; diff --git a/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs index 982eec77..293cc118 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs @@ -8,20 +8,15 @@ namespace PluralKit.Bot; public static class ContextArgumentsExt { - public static string PopArgument(this Context ctx) => - ctx.Parameters.Pop(); + public static string PopArgument(this Context ctx) => throw new PKError("todo: PopArgument"); - public static string PeekArgument(this Context ctx) => - ctx.Parameters.Peek(); + public static string PeekArgument(this Context ctx) => throw new PKError("todo: PeekArgument"); - public static string RemainderOrNull(this Context ctx, bool skipFlags = true) => - ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags); + public static string RemainderOrNull(this Context ctx, bool skipFlags = true) => throw new PKError("todo: RemainderOrNull"); - public static bool HasNext(this Context ctx, bool skipFlags = true) => - ctx.RemainderOrNull(skipFlags) != null; + public static bool HasNext(this Context ctx, bool skipFlags = true) => throw new PKError("todo: HasNext"); - public static string FullCommand(this Context ctx) => - ctx.Parameters.FullCommand; + public static string FullCommand(this Context ctx) => throw new PKError("todo: FullCommand"); /// /// Checks if the next parameter is equal to one of the given keywords and pops it from the stack. Case-insensitive. @@ -53,12 +48,7 @@ public static class ContextArgumentsExt /// public static bool PeekMatch(this Context ctx, ref int ptr, string[] potentialMatches) { - var arg = ctx.Parameters.PeekWithPtr(ref ptr); - foreach (var match in potentialMatches) - if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) - return true; - - return false; + throw new PKError("todo: PeekMatch"); } /// @@ -69,23 +59,14 @@ public static class ContextArgumentsExt /// public static bool MatchMultiple(this Context ctx, params string[][] potentialParametersMatches) { - int ptr = ctx.Parameters._ptr; - - foreach (var param in potentialParametersMatches) - if (!ctx.PeekMatch(ref ptr, param)) return false; - - ctx.Parameters._ptr = ptr; - - return true; + throw new PKError("todo: MatchMultiple"); } public static bool MatchFlag(this Context ctx, params string[] potentialMatches) { // Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here. // Can assume the caller array only contains lowercase *and* the set below only contains lowercase - - var flags = ctx.Parameters.Flags(); - return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch)); + throw new NotImplementedException(); } public static bool MatchClear(this Context ctx) @@ -100,11 +81,7 @@ public static class ContextArgumentsExt public static ReplyFormat PeekMatchFormat(this Context ctx) { - int ptr1 = ctx.Parameters._ptr; - int ptr2 = ctx.Parameters._ptr; - if (ctx.PeekMatch(ref ptr1, new[] { "r", "raw" }) || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw; - if (ctx.PeekMatch(ref ptr2, new[] { "pt", "plaintext" }) || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext; - return ReplyFormat.Standard; + throw new PKError("todo: PeekMatchFormat"); } public static bool MatchToggle(this Context ctx, bool? defaultValue = null) @@ -153,49 +130,12 @@ public static class ContextArgumentsExt public static async Task> ParseMemberList(this Context ctx, SystemId? restrictToSystem) { - var members = new List(); - - // Loop through all the given arguments - while (ctx.HasNext()) - { - // and attempt to match a member - var member = await ctx.MatchMember(restrictToSystem); - - if (member == null) - // if we can't, big error. Every member name must be valid. - throw new PKError(ctx.CreateNotFoundError("Member", ctx.PopArgument())); - - members.Add(member); // Then add to the final output list - } - - if (members.Count == 0) throw new PKSyntaxError("You must input at least one member."); - - return members; + throw new NotImplementedException(); } public static async Task> ParseGroupList(this Context ctx, SystemId? restrictToSystem) { - var groups = new List(); - - // Loop through all the given arguments - while (ctx.HasNext()) - { - // and attempt to match a group - var group = await ctx.MatchGroup(restrictToSystem); - if (group == null) - // if we can't, big error. Every group name must be valid. - throw new PKError(ctx.CreateNotFoundError("Group", ctx.PopArgument())); - - // todo: remove this, the database query enforces the restriction - if (restrictToSystem != null && group.System != restrictToSystem) - throw Errors.NotOwnGroupError; // TODO: name *which* group? - - groups.Add(group); // Then add to the final output list - } - - if (groups.Count == 0) throw new PKSyntaxError("You must input at least one group."); - - return groups; + throw new NotImplementedException(); } } diff --git a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs index 533e374f..13d31cdb 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs @@ -34,19 +34,15 @@ public static class ContextEntityArgumentsExt return id != 0; } - public static Task PeekSystem(this Context ctx) => ctx.MatchSystemInner(); + public static Task PeekSystem(this Context ctx) => throw new NotImplementedException(); public static async Task MatchSystem(this Context ctx) { - var system = await ctx.MatchSystemInner(); - if (system != null) ctx.PopArgument(); - return system; + throw new NotImplementedException(); } - private static async Task MatchSystemInner(this Context ctx) + public static async Task ParseSystem(this Context ctx, string input) { - var input = ctx.PeekArgument(); - // System references can take three forms: // - The direct user ID of an account connected to the system // - A @mention of an account connected to the system (<@uid>) @@ -63,10 +59,8 @@ public static class ContextEntityArgumentsExt return null; } - public static async Task PeekMember(this Context ctx, SystemId? restrictToSystem = null) + public static async Task ParseMember(this Context ctx, Parameters parameters, string input, SystemId? restrictToSystem = null) { - var input = ctx.PeekArgument(); - // Member references can have one of three forms, depending on // whether you're in a system or not: // - A member hid @@ -75,7 +69,7 @@ public static class ContextEntityArgumentsExt // Skip name / display name matching if the user does not have a system // or if they specifically request by-HID matching - if (ctx.System != null && !ctx.MatchFlag("id", "by-id")) + if (ctx.System != null && !parameters.HasFlag("id", "by-id")) { // First, try finding by member name in system if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName) @@ -124,6 +118,11 @@ public static class ContextEntityArgumentsExt return null; } + public static async Task PeekMember(this Context ctx, SystemId? restrictToSystem = null) + { + throw new NotImplementedException(); + } + /// /// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be /// resolved by the next word in the argument stack, does *not* touch the stack, and returns null. @@ -170,9 +169,9 @@ public static class ContextEntityArgumentsExt return group; } - public static string CreateNotFoundError(this Context ctx, string entity, string input) + public static string CreateNotFoundError(this Context ctx, Parameters parameters, string entity, string input) { - var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id"); + var isIDOnlyQuery = ctx.System == null || parameters.HasFlag("id", "by-id"); var inputIsHid = HidUtils.ParseHid(input) != null; if (isIDOnlyQuery) diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs deleted file mode 100644 index 05e1bdb5..00000000 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ /dev/null @@ -1,185 +0,0 @@ -namespace PluralKit.Bot; - -public class Parameters -{ - // Dictionary of (left, right) quote pairs - // Each char in the string is an individual quote, multi-char strings imply "one of the following chars" - private static readonly Dictionary _quotePairs = new() - { - // Basic - { "'", "'" }, // ASCII single quotes - { "\"", "\"" }, // ASCII double quotes - - // "Smart quotes" - // Specifically ignore the left/right status of the quotes and match any combination of them - // Left string also includes "low" quotes to allow for the low-high style used in some locales - { "\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F" }, // double quotes - { "\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B" }, // single quotes - - // Chevrons (normal and "fullwidth" variants) - { "\u00AB\u300A", "\u00BB\u300B" }, // double chevrons, pointing away (<>) - { "\u00BB\u300B", "\u00AB\u300A" }, // double chevrons, pointing together (>>text<<) - { "\u2039\u3008", "\u203A\u3009" }, // single chevrons, pointing away () - { "\u203A\u3009", "\u2039\u3008" }, // single chevrons, pointing together (>text<) - - // Other - { "\u300C\u300E", "\u300D\u300F" } // corner brackets (Japanese/Chinese) - }; - - private ISet _flags; // Only parsed when requested first time - public int _ptr; - - public string FullCommand { get; } - - private struct WordPosition - { - // Start of the word - internal readonly int startPos; - - // End of the word - internal readonly int endPos; - - // How much to advance word pointer afterwards to point at the start of the *next* word - internal readonly int advanceAfterWord; - - internal readonly bool wasQuoted; - - public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted) - { - this.startPos = startPos; - this.endPos = endPos; - this.advanceAfterWord = advanceAfterWord; - this.wasQuoted = wasQuoted; - } - } - - public Parameters(string cmd) - { - // This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below - // Instead, we just add a space before every newline (which then gets stripped out later). - FullCommand = cmd.Replace("\n", " \n"); - _ptr = 0; - } - - private void ParseFlags() - { - _flags = new HashSet(); - - var ptr = 0; - while (NextWordPosition(ptr) is { } wp) - { - ptr = wp.endPos + wp.advanceAfterWord; - - // Is this word a *flag* (as in, starts with a - AND is not quoted) - if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word) - - // Find the *end* of the flag start (technically allowing arbitrary amounts of dashes) - var flagNameStart = wp.startPos; - while (flagNameStart < FullCommand.Length && FullCommand[flagNameStart] == '-') - flagNameStart++; - - // Then add the word to the flag set - var word = FullCommand.Substring(flagNameStart, wp.endPos - flagNameStart).Trim(); - if (word.Length > 0) - _flags.Add(word.ToLowerInvariant()); - } - } - - public string Pop() - { - // Loop to ignore and skip past flags - while (NextWordPosition(_ptr) is { } pos) - { - _ptr = pos.endPos + pos.advanceAfterWord; - if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue; - return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); - } - - return ""; - } - - public string Peek() - { - // temp ptr so we don't move the real ptr - int ptr = _ptr; - - return PeekWithPtr(ref ptr); - } - - public string PeekWithPtr(ref int ptr) - { - // Loop to ignore and skip past flags - while (NextWordPosition(ptr) is { } pos) - { - ptr = pos.endPos + pos.advanceAfterWord; - if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue; - return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); - } - - return ""; - } - - public ISet Flags() - { - if (_flags == null) ParseFlags(); - return _flags; - } - - public string Remainder(bool skipFlags = true) - { - if (skipFlags) - // Skip all *leading* flags when taking the remainder - while (NextWordPosition(_ptr) is { } wp) - { - if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) break; - _ptr = wp.endPos + wp.advanceAfterWord; - } - - // *Then* get the remainder - return FullCommand.Substring(Math.Min(_ptr, FullCommand.Length)).Trim(); - } - - private WordPosition? NextWordPosition(int position) - { - // Skip leading spaces before actual content - while (position < FullCommand.Length && FullCommand[position] == ' ') position++; - - // Is this the end of the string? - if (FullCommand.Length <= position) return null; - - // Is this a quoted word? - if (TryCheckQuote(FullCommand[position], out var endQuotes)) - { - // We found a quoted word - find an instance of one of the corresponding end quotes - var endQuotePosition = -1; - for (var i = position + 1; i < FullCommand.Length; i++) - if (endQuotePosition == -1 && endQuotes.Contains(FullCommand[i])) - endQuotePosition = i; // need a break; don't feel like brackets tho lol - - // Position after the end quote should be EOL or a space - // Otherwise we fallthrough to the unquoted word handler below - if (FullCommand.Length == endQuotePosition + 1 || FullCommand[endQuotePosition + 1] == ' ') - return new WordPosition(position + 1, endQuotePosition, 2, true); - } - - // Not a quoted word, just find the next space and return if it's the end of the command - var wordEnd = FullCommand.IndexOf(' ', position + 1); - - return wordEnd == -1 - ? new WordPosition(position, FullCommand.Length, 0, false) - : new WordPosition(position, wordEnd, 1, false); - } - - private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes) - { - foreach (var (left, right) in _quotePairs) - if (left.Contains(potentialLeftQuote)) - { - correspondingRightQuotes = right; - return true; - } - - correspondingRightQuotes = null; - return false; - } -} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/ParametersFFI.cs b/PluralKit.Bot/CommandSystem/ParametersFFI.cs index 474b5833..ac97af75 100644 --- a/PluralKit.Bot/CommandSystem/ParametersFFI.cs +++ b/PluralKit.Bot/CommandSystem/ParametersFFI.cs @@ -1,18 +1,19 @@ +using PluralKit.Core; using uniffi.commands; namespace PluralKit.Bot; -public class ParametersFFI +public class Parameters { private string _cb { get; init; } private List _args { get; init; } - public int _ptr = -1; private Dictionary _flags { get; init; } + private Dictionary _params { get; init; } // just used for errors, temporarily public string FullCommand { get; init; } - public ParametersFFI(string cmd) + public Parameters(string cmd) { FullCommand = cmd; var result = CommandsMethods.ParseCommand(cmd); @@ -22,6 +23,7 @@ public class ParametersFFI _cb = command.@commandRef; _args = command.@args; _flags = command.@flags; + _params = command.@params; } else { @@ -29,43 +31,67 @@ public class ParametersFFI } } + public async Task ResolveParameters(Context ctx) + { + var parsed_members = await MemberParams().ToAsyncEnumerable().ToDictionaryAwaitAsync(async item => item.Key, async item => + await ctx.ParseMember(this, item.Value) ?? throw new PKError(ctx.CreateNotFoundError(this, "Member", item.Value)) + ); + var parsed_systems = await SystemParams().ToAsyncEnumerable().ToDictionaryAwaitAsync(async item => item.Key, async item => + await ctx.ParseSystem(item.Value) ?? throw new PKError(ctx.CreateNotFoundError(this, "System", item.Value)) + ); + return new ResolvedParameters(this, parsed_members, parsed_systems); + } + public string Callback() { return _cb; } - public string Pop() + public IDictionary Flags() { - if (_args.Count > _ptr + 1) Console.WriteLine($"pop: {_ptr + 1}, {_args[_ptr + 1]}"); - else Console.WriteLine("pop: no more arguments"); - if (_args.Count() == _ptr + 1) return ""; - _ptr++; - return _args[_ptr]; + return _flags; } - public string Peek() + private Dictionary Params(Func filter) { - if (_args.Count > _ptr + 1) Console.WriteLine($"peek: {_ptr + 1}, {_args[_ptr + 1]}"); - else Console.WriteLine("peek: no more arguments"); - if (_args.Count() == _ptr + 1) return ""; - return _args[_ptr + 1]; + return _params.Where(item => filter(item.Value.@kind)).ToDictionary(item => item.Key, item => item.Value.@raw); } - // this might not work quite right - public string PeekWithPtr(ref int ptr) + public IDictionary Params() { - return _args[ptr]; + return Params(_ => true); } - public ISet Flags() + public IDictionary MemberParams() { - return new HashSet(_flags.Keys); + return Params(kind => kind == ParameterKind.MemberRef); } - // parsed differently in new commands, does this work right? - // note: skipFlags here does nothing - public string Remainder(bool skipFlags = false) + public IDictionary SystemParams() { - return Pop(); + return Params(kind => kind == ParameterKind.SystemRef); + } +} + +public class ResolvedParameters +{ + public readonly Parameters Raw; + public readonly Dictionary MemberParams; + public readonly Dictionary SystemParams; + + public ResolvedParameters(Parameters parameters, Dictionary member_params, Dictionary system_params) + { + Raw = parameters; + MemberParams = member_params; + SystemParams = system_params; + } +} + +// TODO: move this to another file +public static class ParametersExt +{ + public static bool HasFlag(this Parameters parameters, params string[] potentialMatches) + { + return potentialMatches.Any(parameters.Flags().ContainsKey); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 2a82f85c..a32b1e58 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -27,10 +27,10 @@ public class Member _avatarHosting = avatarHosting; } - public async Task NewMember(Context ctx) + public async Task NewMember(Context ctx, string memberName) { if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); - var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name."); + memberName = memberName ?? throw new PKSyntaxError("You must pass a member name."); // Hard name length cap if (memberName.Length > Limits.MaxMemberNameLength) diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index 2bb6bfcf..70ae757b 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -137,8 +137,22 @@ public class MessageCreated: IEventHandler var system = await _repo.GetSystemByAccount(evt.Author.Id); var config = system != null ? await _repo.GetSystemConfig(system.Id) : null; var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null; - - await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes)); + var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes); + try + { + var parameters = new Parameters(evt.Content?.Substring(cmdStart)); + var resolved_parameters = await parameters.ResolveParameters(ctx); + await _tree.ExecuteCommand(ctx, resolved_parameters); + } + catch (PKError e) + { + // don't send an "invalid command" response if the guild has those turned off + if (!(ctx.GuildConfig != null && ctx.GuildConfig!.InvalidCommandResponseEnabled != true)) + { + await ctx.Reply($"{Emojis.Error} {e.Message}"); + } + throw; + } } catch (PKError) { diff --git a/crates/commands/Cargo.toml b/crates/commands/Cargo.toml index 65e28ad4..04a68b9c 100644 --- a/crates/commands/Cargo.toml +++ b/crates/commands/Cargo.toml @@ -11,6 +11,7 @@ lazy_static = { workspace = true } uniffi = { version = "0.25" } smol_str = "0.3.2" +ordermap = "0.5" [build-dependencies] uniffi = { version = "0.25", features = [ "build" ] } diff --git a/crates/commands/src/commands.udl b/crates/commands/src/commands.udl index cc7af428..22396de4 100644 --- a/crates/commands/src/commands.udl +++ b/crates/commands/src/commands.udl @@ -6,8 +6,21 @@ interface CommandResult { Ok(ParsedCommand command); Err(string error); }; +[Enum] +interface ParameterKind { + MemberRef(); + SystemRef(); + MemberPrivacyTarget(); + PrivacyLevel(); + OpaqueString(); +}; +dictionary Parameter { + string raw; + ParameterKind kind; +}; dictionary ParsedCommand { string command_ref; sequence args; + record params; record flags; }; diff --git a/crates/commands/src/commands/member.rs b/crates/commands/src/commands/member.rs index ae5c1e85..4ea763a1 100644 --- a/crates/commands/src/commands/member.rs +++ b/crates/commands/src/commands/member.rs @@ -10,37 +10,37 @@ pub fn cmds() -> impl Iterator { [ command!( - [member, new, MemberRef], + [member, new, FullString("name")], "member_new", "Creates a new system member" ), command!( - [member, MemberRef], + [member, MemberRef("target")], "member_show", "Shows information about a member" ), command!( - [member, MemberRef, description], + [member, MemberRef("target"), description], "member_desc_show", "Shows a member's description" ), command!( - [member, MemberRef, description, FullString], + [member, MemberRef("target"), description, FullString("description")], "member_desc_update", "Changes a member's description" ), command!( - [member, MemberRef, privacy], + [member, MemberRef("target"), privacy], "member_privacy_show", "Displays a member's current privacy settings" ), command!( [ member, - MemberRef, + MemberRef("target"), privacy, - MemberPrivacyTarget, - PrivacyLevel + MemberPrivacyTarget("privacy_target"), + PrivacyLevel("new_privacy_level") ], "member_privacy_update", "Changes a member's privacy settings" diff --git a/crates/commands/src/commands/system.rs b/crates/commands/src/commands/system.rs index 3e10e992..37a67613 100644 --- a/crates/commands/src/commands/system.rs +++ b/crates/commands/src/commands/system.rs @@ -14,7 +14,7 @@ pub fn cmds() -> impl Iterator { ), command!([system, new], "system_new", "Creates a new system"), command!( - [system, new, SystemRef], + [system, new, FullString("name")], "system_new", "Creates a new system" ), diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs index 4b239cd1..e9f3bd7a 100644 --- a/crates/commands/src/lib.rs +++ b/crates/commands/src/lib.rs @@ -10,7 +10,8 @@ uniffi::include_scaffolding!("commands"); use core::panic; use std::collections::HashMap; -use smol_str::SmolStr; +use ordermap::OrderMap; +use smol_str::{format_smolstr, SmolStr}; use tree::TreeBranch; pub use commands::Command; @@ -21,27 +22,70 @@ lazy_static::lazy_static! { let mut tree = TreeBranch { current_command_key: None, possible_tokens: vec![], - branches: HashMap::new(), + branches: OrderMap::new(), }; - crate::commands::all().iter().for_each(|x| tree.register_command(x.clone())); - - tree.sort_tokens(); - - // println!("{{tree:#?}}"); + crate::commands::all().into_iter().for_each(|x| tree.register_command(x)); tree }; } +#[derive(Debug)] pub enum CommandResult { Ok { command: ParsedCommand }, Err { error: String }, } +#[derive(Debug)] +pub enum ParameterKind { + MemberRef, + SystemRef, + MemberPrivacyTarget, + PrivacyLevel, + OpaqueString, +} + +#[derive(Debug)] +pub struct Parameter { + raw: String, + kind: ParameterKind, +} + +impl Parameter { + fn new(raw: impl ToString, kind: ParameterKind) -> Self { + Self { + raw: raw.to_string(), + kind, + } + } +} + +macro_rules! parameter_impl { + ($($name:ident $kind:ident),*) => { + impl Parameter { + $( + fn $name(raw: impl ToString) -> Self { + Self::new(raw, $crate::ParameterKind::$kind) + } + )* + } + }; +} + +parameter_impl! { + opaque OpaqueString, + member MemberRef, + system SystemRef, + member_privacy_target MemberPrivacyTarget, + privacy_level PrivacyLevel +} + +#[derive(Debug)] pub struct ParsedCommand { pub command_ref: String, pub args: Vec, + pub params: HashMap, pub flags: HashMap>, } @@ -53,9 +97,11 @@ fn parse_command(input: String) -> CommandResult { let mut current_pos = 0; let mut args: Vec = Vec::new(); + let mut params: HashMap = HashMap::new(); let mut flags: HashMap> = HashMap::new(); loop { + println!("{:?}", local_tree.possible_tokens); let next = next_token( local_tree.possible_tokens.clone(), input.clone(), @@ -70,8 +116,22 @@ fn parse_command(input: String) -> CommandResult { continue; } - if let Some(arg) = arg { - args.push(arg.into()); + if let Some(arg) = arg.as_ref() { + // get param name from token + // TODO: idk if this should be on token itself, doesn't feel right, but does work + let param = match &found_token { + Token::FullString(n) => Some((n, Parameter::opaque(arg))), + Token::MemberRef(n) => Some((n, Parameter::member(arg))), + Token::MemberPrivacyTarget(n) => Some((n, Parameter::member_privacy_target(arg))), + Token::SystemRef(n) => Some((n, Parameter::system(arg))), + Token::PrivacyLevel(n) => Some((n, Parameter::privacy_level(arg))), + _ => None, + }; + // insert arg as paramater if this is a parameter + if let Some((param_name, param)) = param { + params.insert(param_name.to_string(), param); + } + args.push(arg.to_string()); } if let Some(next_tree) = local_tree.branches.get(&found_token) { @@ -82,9 +142,11 @@ fn parse_command(input: String) -> CommandResult { } Err(None) => { if let Some(command_ref) = local_tree.current_command_key { + println!("{command_ref} {params:?}"); return CommandResult::Ok { command: ParsedCommand { command_ref: command_ref.into(), + params, args, flags, }, @@ -136,19 +198,12 @@ fn next_token( // iterate over tokens and run try_match for token in possible_tokens { - if let TokenMatchResult::Match(value) = - // for FullString just send the whole string - token.try_match(if matches!(token, Token::FullString) { - if input.is_empty() { - None - } else { - Some(input.clone()) - } - } else { - param.clone().map(|v| v.0) - }) - { - return Ok((token, value, param.map(|v| v.1).unwrap_or(current_pos))); + // for FullString just send the whole string + let input_to_match = param.clone().map(|v| v.0); + match token.try_match(input_to_match) { + TokenMatchResult::Match(value) => return Ok((token, value, param.map(|v| v.1).unwrap_or(current_pos))), + TokenMatchResult::MissingParameter { name } => return Err(Some(format_smolstr!("Missing parameter `{name}` in command `{input} [{name}]`."))), + TokenMatchResult::NoMatch => {} } } diff --git a/crates/commands/src/token.rs b/crates/commands/src/token.rs index 3d933668..6588fc8d 100644 --- a/crates/commands/src/token.rs +++ b/crates/commands/src/token.rs @@ -1,5 +1,7 @@ use smol_str::{SmolStr, ToSmolStr}; +type ParamName = &'static str; + #[derive(Debug, Clone, Eq, Hash, PartialEq)] pub enum Token { /// Token used to represent a finished command (i.e. no more parameters required) @@ -12,16 +14,16 @@ pub enum Token { // todo! MultiValue(Vec>), - FullString, + FullString(ParamName), /// Member reference (hid or member name) - MemberRef, - MemberPrivacyTarget, + MemberRef(ParamName), + MemberPrivacyTarget(ParamName), /// System reference - SystemRef, + SystemRef(ParamName), - PrivacyLevel, + PrivacyLevel(ParamName), // currently not included in command definitions // todo: flags with values @@ -32,6 +34,9 @@ pub enum TokenMatchResult { NoMatch, /// Token matched, optionally with a value. Match(Option), + MissingParameter { + name: ParamName, + }, } // move this somewhere else @@ -43,36 +48,38 @@ impl Token { if matches!(self, Self::Empty) && input.is_none() { return TokenMatchResult::Match(None); } else if input.is_none() { - return TokenMatchResult::NoMatch; + return match self { + Self::FullString(param_name) => TokenMatchResult::MissingParameter { name: param_name }, + Self::MemberRef(param_name) => TokenMatchResult::MissingParameter { name: param_name }, + Self::MemberPrivacyTarget(param_name) => TokenMatchResult::MissingParameter { name: param_name }, + Self::SystemRef(param_name) => TokenMatchResult::MissingParameter { name: param_name }, + Self::PrivacyLevel(param_name) => TokenMatchResult::MissingParameter { name: param_name }, + _ => TokenMatchResult::NoMatch, + } } - let input = input.unwrap(); + let input = input.as_ref().map(|s| s.trim()).unwrap(); // try actually matching stuff match self { Self::Empty => return TokenMatchResult::NoMatch, Self::Flag => unreachable!(), // matched upstream - Self::Value(values) => { - for v in values { - if input.trim() == v { - // c# bot currently needs subcommands provided as arguments - // todo!: remove this - return TokenMatchResult::Match(Some(v.clone())); - } - } + Self::Value(values) if values.iter().any(|v| v.eq(input)) => { + return TokenMatchResult::Match(None); } + Self::Value(_) => {} Self::MultiValue(_) => todo!(), - Self::FullString => return TokenMatchResult::Match(Some(input)), - Self::SystemRef => return TokenMatchResult::Match(Some(input)), - Self::MemberRef => return TokenMatchResult::Match(Some(input)), - Self::MemberPrivacyTarget if MEMBER_PRIVACY_TARGETS.contains(&input.trim()) => { - return TokenMatchResult::Match(Some(input)) + Self::FullString(_) => return TokenMatchResult::Match(Some(input.into())), + Self::SystemRef(_) => return TokenMatchResult::Match(Some(input.into())), + Self::MemberRef(_) => return TokenMatchResult::Match(Some(input.into())), + Self::MemberPrivacyTarget(_) if MEMBER_PRIVACY_TARGETS.contains(&input) => { + return TokenMatchResult::Match(Some(input.into())) } - Self::MemberPrivacyTarget => {} - Self::PrivacyLevel if input == "public" || input == "private" => { - return TokenMatchResult::Match(Some(input)) + Self::MemberPrivacyTarget(_) => {} + Self::PrivacyLevel(_) if input == "public" || input == "private" => { + return TokenMatchResult::Match(Some(input.into())) } - Self::PrivacyLevel => {} + Self::PrivacyLevel(_) => {} } // note: must not add a _ case to the above match // instead, for conditional matches, also add generic cases with no return diff --git a/crates/commands/src/tree.rs b/crates/commands/src/tree.rs index 56c53336..6dee7f0a 100644 --- a/crates/commands/src/tree.rs +++ b/crates/commands/src/tree.rs @@ -1,14 +1,15 @@ +use ordermap::OrderMap; use smol_str::SmolStr; use crate::{commands::Command, Token}; -use std::{cmp::Ordering, collections::HashMap}; +use std::cmp::Ordering; #[derive(Debug, Clone)] pub struct TreeBranch { pub current_command_key: Option, /// branches.keys(), but sorted by specificity pub possible_tokens: Vec, - pub branches: HashMap, + pub branches: OrderMap, } impl TreeBranch { @@ -20,7 +21,7 @@ impl TreeBranch { current_branch = current_branch.branches.entry(token).or_insert(TreeBranch { current_command_key: None, possible_tokens: vec![], - branches: HashMap::new(), + branches: OrderMap::new(), }) } // when we're out of tokens, add an Empty branch with the callback and no sub-branches @@ -29,31 +30,8 @@ impl TreeBranch { TreeBranch { current_command_key: Some(command.cb), possible_tokens: vec![], - branches: HashMap::new(), + branches: OrderMap::new(), }, ); } - - pub fn sort_tokens(&mut self) { - for branch in self.branches.values_mut() { - branch.sort_tokens(); - } - - // put Value tokens at the end - // i forget exactly how this works - // todo!: document this before PR mergs - self.possible_tokens = self - .branches - .keys() - .into_iter() - .map(|v| v.clone()) - .collect(); - self.possible_tokens.sort_by(|v, _| { - if matches!(v, Token::Value(_)) { - Ordering::Greater - } else { - Ordering::Less - } - }); - } }