471 lines
22 KiB
C#
471 lines
22 KiB
C#
namespace AcDream.Runtime.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase I.4: pure-function parse of a chat-input line into the
|
|
/// <c>(channel, target?, text)</c> triple a <see cref="SendChatCmd"/>
|
|
/// needs. Ported from holtburger
|
|
/// <c>references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs</c>
|
|
/// — the alias table around line ~457 and the <c>parse_targeted_chat_command</c>
|
|
/// / <c>parse_message_only_command</c> helpers near the top of the file.
|
|
///
|
|
/// <para>
|
|
/// Edge cases handled (each pinned by a test in
|
|
/// <c>ChatInputParserTests</c>):
|
|
/// <list type="bullet">
|
|
/// <item>empty / whitespace-only → <c>null</c></item>
|
|
/// <item><c>/say</c> with no message → <c>null</c></item>
|
|
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
|
|
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
|
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> when this pure
|
|
/// parser is called directly. The production <see
|
|
/// cref="ChatCommandRouter"/> intercepts it first and publishes a typed
|
|
/// <see cref="SendServerCommandCmd"/>.</item>
|
|
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
|
|
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ChatInputParser
|
|
{
|
|
/// <summary>
|
|
/// Outcome of a successful parse. Pass straight into
|
|
/// <see cref="SendChatCmd"/>'s constructor.
|
|
/// </summary>
|
|
public readonly record struct ParsedInput(
|
|
ChatChannelKind Channel,
|
|
string? TargetName,
|
|
string Text);
|
|
|
|
// Alias tables. Order matters only for error messages — verb
|
|
// matching is exact-token, not prefix.
|
|
private static readonly string[] SayAliases = { "/say", "/s" };
|
|
// Campaign CH slice CH4 (2026-08-09): retail's DoTell @0x00577E40
|
|
// registers "tell" under FOUR verb strings — tell/t/send/whisper/w —
|
|
// per the command-registry doc §2.2.
|
|
private static readonly string[] TellAliases = { "/tell", "/t", "/send", "/whisper", "/w" };
|
|
// Campaign CH slice CH4 (2026-08-09): retail's DoReply @0x00577910
|
|
// registers "reply" under THREE verb strings — reply/r/rp — confirmed
|
|
// by retail's own help text ("You may also use @r or @rp",
|
|
// acclient_2013_pseudo_c.txt:1030742). "/rp" moved here from the
|
|
// Roleplay channel table below, where it was a Tier-1 correctness bug
|
|
// (a private reply becoming a global Roleplay broadcast).
|
|
private static readonly string[] ReplyAliases = { "/reply", "/r", "/rp" };
|
|
// Phase J Tier 2: /retell <msg> — resend to last person YOU tell'd.
|
|
// Mirrors retail's @retell. Distinct from /reply which targets the
|
|
// last person who tell'd US. Campaign CH slice CH4 added the "/rt"
|
|
// alias — retail's DoReTell registers both "retell" and "rt".
|
|
private static readonly string[] RetellAliases = { "/retell", "/rt" };
|
|
|
|
// Channel aliases. Each maps a single verb token to a channel kind.
|
|
// The same list drives both the verb test and the prefix-strip.
|
|
// Long-form aliases mirror retail muscle memory (e.g. "/patron" for
|
|
// "/p"). Phase J added the long forms after a 2026-04-25 live session
|
|
// showed "/patron hello" falling through as plain Say with the literal
|
|
// "/patron " prefix.
|
|
//
|
|
// Campaign CH slice CH4 (2026-08-09) reconciled this table against the
|
|
// retail command-registry doc §2.3/§2.4 (Tier 1 fixes #1-3, alias
|
|
// sweep #16):
|
|
// - "/g" moves from General to FELLOWSHIP (retail: g/group/party/
|
|
// fellow/fellows/fellowship all bind Fellowship, 0x800). Sending
|
|
// fellowship chatter to General was a live correctness bug.
|
|
// - "/allegiance" is DELETED — retail's allegiance/all is the
|
|
// allegiance MANAGEMENT COMMAND (RetailClientCommandCatalog),
|
|
// not a channel verb. The channel-send verbs are a/ab/guild/gu.
|
|
// - "/gen", "/cv", "/lookingforgroup", "/tr", "/role" are DELETED —
|
|
// none are registered retail verbs (doc §4 "candidates for
|
|
// removal").
|
|
// - Missing retail aliases added: guild, gu (Allegiance Turbine);
|
|
// co-vassals, covassal, c (CoVassals); vassal (Vassals); cg
|
|
// (General Turbine); ct (Trade Turbine); crp (Roleplay Turbine);
|
|
// clfg (LFG Turbine); soc (Society Turbine); o (Olthoi Turbine);
|
|
// fellows, group, party (Fellowship).
|
|
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
|
|
{
|
|
("/general", ChatChannelKind.General),
|
|
("/cg", ChatChannelKind.General),
|
|
("/f", ChatChannelKind.Fellowship),
|
|
("/fellow", ChatChannelKind.Fellowship),
|
|
("/fellows", ChatChannelKind.Fellowship),
|
|
("/fellowship", ChatChannelKind.Fellowship),
|
|
("/g", ChatChannelKind.Fellowship),
|
|
("/group", ChatChannelKind.Fellowship),
|
|
("/party", ChatChannelKind.Fellowship),
|
|
("/a", ChatChannelKind.Allegiance),
|
|
("/guild", ChatChannelKind.Allegiance),
|
|
("/gu", ChatChannelKind.Allegiance),
|
|
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
|
|
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
|
|
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
|
|
// was deliberately NOT added: it is not a registered retail verb
|
|
// (only "ab" is), and CH3's scope is "cross-check the registry;
|
|
// do not add verbs it doesn't have."
|
|
("/ab", ChatChannelKind.AllegianceBroadcast),
|
|
("/m", ChatChannelKind.Monarch),
|
|
("/monarch", ChatChannelKind.Monarch),
|
|
("/p", ChatChannelKind.Patron),
|
|
("/patron", ChatChannelKind.Patron),
|
|
("/v", ChatChannelKind.Vassals),
|
|
("/vassal", ChatChannelKind.Vassals),
|
|
("/vassals", ChatChannelKind.Vassals),
|
|
("/c", ChatChannelKind.CoVassals),
|
|
("/covassal", ChatChannelKind.CoVassals),
|
|
("/covassals", ChatChannelKind.CoVassals),
|
|
("/co-vassals", ChatChannelKind.CoVassals),
|
|
("/lfg", ChatChannelKind.Lfg),
|
|
("/clfg", ChatChannelKind.Lfg),
|
|
("/trade", ChatChannelKind.Trade),
|
|
("/ct", ChatChannelKind.Trade),
|
|
("/crp", ChatChannelKind.Roleplay),
|
|
("/roleplay", ChatChannelKind.Roleplay),
|
|
("/society", ChatChannelKind.Society),
|
|
("/soc", ChatChannelKind.Society),
|
|
("/olthoi", ChatChannelKind.Olthoi),
|
|
("/o", ChatChannelKind.Olthoi),
|
|
};
|
|
|
|
/// <summary>
|
|
/// The six retail channel groups whose registered-verb handler is
|
|
/// <c>ClientCommunicationSystem::DoStupidChannelHack @0x0057B144</c>
|
|
/// (command-registry doc §2.3) — the legacy 0x0147 channels. The
|
|
/// Turbine-only channels (General/Trade/Lfg/Roleplay/Society/Olthoi)
|
|
/// are never this handler's clients. <see cref="ChatChannelKind.Allegiance"/>
|
|
/// (retail's <c>@a</c>) still belongs here even though acdream always
|
|
/// sends it over Turbine once connected (<see cref="ChatChannelKind"/>'s
|
|
/// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED
|
|
/// VERB, before Turbine send-time routing decides where the text goes;
|
|
/// retail's <c>"a"</c>/<c>"ab"</c> are one registered verb group.
|
|
/// </summary>
|
|
private static readonly HashSet<ChatChannelKind> LegacyChannelHackKinds =
|
|
[
|
|
ChatChannelKind.Fellowship,
|
|
ChatChannelKind.Allegiance,
|
|
ChatChannelKind.AllegianceBroadcast,
|
|
ChatChannelKind.Vassals,
|
|
ChatChannelKind.Patron,
|
|
ChatChannelKind.Monarch,
|
|
ChatChannelKind.CoVassals,
|
|
];
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="trimmed"/> is one of the
|
|
/// <see cref="LegacyChannelHackKinds"/> verbs with NO message — retail's
|
|
/// <c>DoStupidChannelHack</c> "You must specify the text you wish to
|
|
/// say!" refusal (<c>0x1A</c> ClientLocal,
|
|
/// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays
|
|
/// pure (no side effects — <see cref="Parse"/> just returns
|
|
/// <see langword="null"/> for this shape); <see cref="ChatCommandRouter"/>
|
|
/// owns the actual refusal text and routes it to the SpewBox.
|
|
/// </summary>
|
|
public static bool IsBareRegisteredChannelVerb(string trimmed)
|
|
{
|
|
foreach (var (verb, channel) in ChannelVerbs)
|
|
{
|
|
if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb]))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="trimmed"/> is <c>/reply</c>/<c>/r</c>/
|
|
/// <c>/rp</c> WITH a message but no prior incoming Tell — retail's
|
|
/// <c>ClientCommunicationSystem::DoReply @0x00577910</c> "Someone must
|
|
/// @tell you first!" refusal (<c>0x1A</c> ClientLocal,
|
|
/// acclient_2013_pseudo_c.txt:387538, data_7da974), the
|
|
/// <c>gmCCommunicationSystem::GetLastTeller() == 0</c> branch. Bare
|
|
/// <c>/r</c> with no message at all is a DIFFERENT retail branch (its
|
|
/// own copy of the "you must specify text" string) and is deliberately
|
|
/// out of scope here — not named by register row AP-183 / issue #363.
|
|
/// </summary>
|
|
public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) =>
|
|
string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _);
|
|
|
|
/// <summary>
|
|
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
|
|
/// triple, or <c>null</c> if the input is empty / whitespace /
|
|
/// missing required arguments (e.g. <c>/t</c> with no target).
|
|
/// </summary>
|
|
/// <param name="raw">User input. The caller should
|
|
/// <see cref="string.Trim()"/> before passing.</param>
|
|
/// <param name="defaultChannel">Channel to use for unprefixed text
|
|
/// or unrecognised slash commands.</param>
|
|
/// <param name="lastTellSender">Sender of the most recent incoming
|
|
/// Tell, used to resolve <c>/r</c> reply target. Null if no Tell
|
|
/// has arrived this session.</param>
|
|
/// <param name="lastOutgoingTellTarget">Target of the most recent
|
|
/// outgoing Tell, used to resolve <c>/retell</c>. Null if the
|
|
/// player hasn't sent a Tell yet this session.</param>
|
|
public static ParsedInput? Parse(
|
|
string raw,
|
|
ChatChannelKind defaultChannel,
|
|
string? lastTellSender,
|
|
string? lastOutgoingTellTarget = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
var trimmed = raw.Trim();
|
|
|
|
// Phase J Tier 1: ACE accepts both / and @ as equivalent verb
|
|
// prefixes (per ACE help: "Note: You may substitute a forward
|
|
// slash (/) for the at symbol (@)."). For verbs WE recognize,
|
|
// normalize @ to / and re-enter parsing. For unknown @-verbs
|
|
// (e.g. @acehelp, @tele, @die), pass the literal @-prefixed
|
|
// text through to the default channel so ACE's CommandManager
|
|
// server-side handler intercepts it.
|
|
if (trimmed.StartsWith("@"))
|
|
{
|
|
string substituted = "/" + trimmed.Substring(1);
|
|
string verb = ExtractVerb(substituted);
|
|
if (IsKnownVerb(verb))
|
|
{
|
|
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
|
}
|
|
// Unknown @-verb — keep the original @ so ACE recognizes
|
|
// it server-side. Always emit as Say: ACE's GameActionTalk
|
|
// (the 0x0015 Talk action) is the ONLY wire path that parses
|
|
// @commands — on a chat channel the text would broadcast as
|
|
// ordinary channel speech. Retail likewise resolves commands
|
|
// before channel routing.
|
|
return new ParsedInput(ChatChannelKind.Say, null, trimmed);
|
|
}
|
|
|
|
// /say <msg>
|
|
if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
|
|
return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
|
|
if (IsBareVerb(trimmed, SayAliases))
|
|
return null;
|
|
|
|
// /tell <target> <msg> or /t <target> <msg>
|
|
if (TryParseTargeted(trimmed, TellAliases, out var tellTarget, out var tellMsg))
|
|
return new ParsedInput(ChatChannelKind.Tell, tellTarget, tellMsg);
|
|
if (IsBareVerb(trimmed, TellAliases) || IsVerbWithSingleToken(trimmed, TellAliases))
|
|
return null;
|
|
|
|
// /r <msg> / /reply <msg> — needs prior incoming tell.
|
|
if (TryParseMessageOnly(trimmed, ReplyAliases, out var replyMsg))
|
|
{
|
|
if (string.IsNullOrEmpty(lastTellSender)) return null;
|
|
return new ParsedInput(ChatChannelKind.Tell, lastTellSender, replyMsg);
|
|
}
|
|
if (IsBareVerb(trimmed, ReplyAliases))
|
|
return null;
|
|
|
|
// /retell <msg> — needs prior outgoing tell.
|
|
if (TryParseMessageOnly(trimmed, RetellAliases, out var retellMsg))
|
|
{
|
|
if (string.IsNullOrEmpty(lastOutgoingTellTarget)) return null;
|
|
return new ParsedInput(ChatChannelKind.Tell, lastOutgoingTellTarget, retellMsg);
|
|
}
|
|
if (IsBareVerb(trimmed, RetellAliases))
|
|
return null;
|
|
|
|
// Channel verbs (/g, /f, /a, ...).
|
|
foreach (var (verb, channel) in ChannelVerbs)
|
|
{
|
|
if (TryParseMessageOnly(trimmed, new[] { verb }, out var msg))
|
|
return new ParsedInput(channel, null, msg);
|
|
if (IsBareVerb(trimmed, new[] { verb }))
|
|
return null;
|
|
}
|
|
|
|
// Unknown slash verb: retail treats / and @ as equivalent command
|
|
// prefixes, but ACE's GameActionTalk only intercepts the @ form on
|
|
// the wire (a /-prefixed Talk is plain speech server-side). Rewrite
|
|
// to @ and emit as Say — same reasoning as the unknown-@ branch at
|
|
// the top: only the Talk action parses commands. (Deliberate
|
|
// divergence from holtburger's literal fall-through, which would
|
|
// SAY "/ci 629" out loud.)
|
|
if (trimmed.Length > 1 && trimmed[0] == '/' && char.IsLetter(trimmed[1]))
|
|
return new ParsedInput(ChatChannelKind.Say, null, "@" + trimmed.Substring(1));
|
|
|
|
// Plain speech (no recognized verb): emit on the default channel
|
|
// so the user's text round-trips instead of being silently dropped.
|
|
return new ParsedInput(defaultChannel, null, trimmed);
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Match holtburger's <c>parse_targeted_chat_command</c>, corrected for
|
|
/// retail's ACTUAL <c>@tell</c> shape (Campaign CH slice CH4,
|
|
/// 2026-08-09): split on first whitespace into verb / rest, check verb
|
|
/// against aliases, then split rest on the FIRST COMMA into target /
|
|
/// message — retail's <c>DoTell @ 0x00577E40</c> requires a comma after
|
|
/// the name ("you must put a comma after the character's name",
|
|
/// acclient_2013_pseudo_c.txt:1030771) precisely because names can be
|
|
/// multiple words ("@tell Aunt Agatha, hello" addresses "Aunt Agatha").
|
|
/// Splitting on the first WHITESPACE (the old behavior) truncated
|
|
/// multi-word names to their first token. Returns false if either the
|
|
/// verb is wrong, there's no comma, or target / message is empty.
|
|
/// </summary>
|
|
private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message)
|
|
{
|
|
target = string.Empty;
|
|
message = string.Empty;
|
|
|
|
int firstWs = IndexOfWhitespace(command);
|
|
if (firstWs < 0) return false;
|
|
|
|
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
|
if (!ContainsExact(aliases, verb)) return false;
|
|
|
|
var rest = command.Substring(firstWs + 1).TrimStart();
|
|
if (rest.Length == 0) return false;
|
|
|
|
int commaIndex = rest.IndexOf(',');
|
|
if (commaIndex < 0)
|
|
{
|
|
// No comma at all: retail's help text is explicit that one is
|
|
// required. Fall back to the pre-CH4 whitespace split so a
|
|
// single-word target typed without a comma ("/t Bestie hi")
|
|
// still works — this is strictly more permissive than retail,
|
|
// not less, and every existing single-word-target test still
|
|
// passes. Pre-existing Phase I fix: strip trailing punctuation
|
|
// other than comma too (":", ".", "!", "?", ";") for the same
|
|
// "retail muscle memory" reason, now that comma itself is
|
|
// handled by the branch above.
|
|
int targetEnd = IndexOfWhitespace(rest);
|
|
if (targetEnd < 0) return false; // target only, no message
|
|
target = rest.Substring(0, targetEnd).TrimEnd(',', ';', ':', '.', '!', '?');
|
|
message = rest.Substring(targetEnd + 1).TrimStart();
|
|
}
|
|
else
|
|
{
|
|
target = rest.Substring(0, commaIndex).TrimEnd();
|
|
message = rest.Substring(commaIndex + 1).TrimStart();
|
|
}
|
|
|
|
if (target.Length == 0 || message.Length == 0) return false;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Match holtburger's <c>parse_message_only_command</c>: split on
|
|
/// first whitespace, alias-match the verb, return the trimmed rest
|
|
/// as the message. Empty-message → false.
|
|
/// </summary>
|
|
private static bool TryParseMessageOnly(string command, string[] aliases, out string message)
|
|
{
|
|
message = string.Empty;
|
|
int firstWs = IndexOfWhitespace(command);
|
|
if (firstWs < 0) return false;
|
|
|
|
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
|
if (!ContainsExact(aliases, verb)) return false;
|
|
|
|
message = command.Substring(firstWs + 1).TrimStart();
|
|
return message.Length > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="command"/> is exactly one of the
|
|
/// alias verbs (no message, no extra whitespace beyond trimming).
|
|
/// </summary>
|
|
private static bool IsBareVerb(string command, string[] aliases)
|
|
{
|
|
string trimmedVerb = TrimVerbComma(command);
|
|
foreach (var alias in aliases)
|
|
if (trimmedVerb == alias) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="command"/> is "verb arg" with no
|
|
/// further whitespace — e.g. <c>/t Bestie</c> (target but no
|
|
/// message). Used to short-circuit the "fall through to default
|
|
/// channel" path so a half-typed tell doesn't get sent as Say.
|
|
/// </summary>
|
|
private static bool IsVerbWithSingleToken(string command, string[] aliases)
|
|
{
|
|
int firstWs = IndexOfWhitespace(command);
|
|
if (firstWs < 0) return false;
|
|
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
|
if (!ContainsExact(aliases, verb)) return false;
|
|
|
|
var rest = command.Substring(firstWs + 1).TrimStart();
|
|
if (rest.Length == 0) return true;
|
|
return IndexOfWhitespace(rest) < 0;
|
|
}
|
|
|
|
private static int IndexOfWhitespace(string s)
|
|
{
|
|
for (int i = 0; i < s.Length; i++)
|
|
if (char.IsWhiteSpace(s[i])) return i;
|
|
return -1;
|
|
}
|
|
|
|
private static bool ContainsExact(string[] aliases, string verb)
|
|
{
|
|
for (int i = 0; i < aliases.Length; i++)
|
|
if (aliases[i] == verb) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pull the first whitespace-separated token from <paramref name="command"/>.
|
|
/// Used by the @-prefix normalization to know whether the verb is
|
|
/// one we route locally (Tell / Channel / etc.) or an unknown
|
|
/// command that should pass through to the server intact.
|
|
/// </summary>
|
|
private static string ExtractVerb(string command)
|
|
{
|
|
int ws = IndexOfWhitespace(command);
|
|
return ws < 0 ? command : command.Substring(0, ws);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Union of every alias verb (with leading <c>/</c>) the parser
|
|
/// recognises. Used by the <c>@</c>-prefix passthrough decision:
|
|
/// if the substituted verb is in this set, we normalize to the
|
|
/// <c>/</c> path; otherwise the original <c>@</c>-prefixed text
|
|
/// is preserved so ACE's command handler sees it.
|
|
/// </summary>
|
|
private static readonly HashSet<string> AllKnownVerbs = BuildKnownVerbs();
|
|
|
|
private static HashSet<string> BuildKnownVerbs()
|
|
{
|
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var v in SayAliases) set.Add(v);
|
|
foreach (var v in TellAliases) set.Add(v);
|
|
foreach (var v in ReplyAliases) set.Add(v);
|
|
foreach (var v in RetellAliases) set.Add(v);
|
|
foreach (var (v, _) in ChannelVerbs) set.Add(v);
|
|
return set;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if <paramref name="verb"/> (with leading
|
|
/// <c>/</c>) is one this parser routes — used by callers that
|
|
/// need to distinguish "unknown slash command" from "known
|
|
/// verb with bad arguments" without reproducing the alias
|
|
/// tables. <c>@</c>-prefixed verbs need to be normalized to
|
|
/// <c>/</c> before passing. Trims a trailing comma first — retail's
|
|
/// <c>DoCommand @ 0x0057E2E0</c> right-trims <c>','</c> off the verb
|
|
/// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so
|
|
/// <c>"/f,"</c> is recognized exactly like <c>"/f"</c>.
|
|
/// </summary>
|
|
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb));
|
|
|
|
/// <summary>
|
|
/// Every chat-alias / channel verb this parser recognizes (with
|
|
/// leading <c>/</c>). Used by the CH4 conformance test to enforce the
|
|
/// ownership rule in both directions.
|
|
/// </summary>
|
|
public static IReadOnlyCollection<string> KnownVerbs => AllKnownVerbs;
|
|
|
|
/// <summary>
|
|
/// Right-trim a trailing <c>','</c> from a verb token — retail's
|
|
/// <c>DoCommand</c> trim-char set (<c>0x0079452C</c>, right-trim only)
|
|
/// applied before every verb-hash-table lookup. <c>"@f, hi"</c> ≡
|
|
/// <c>"@f hi"</c>.
|
|
/// </summary>
|
|
private static string TrimVerbComma(string verb) => verb.TrimEnd(',');
|
|
|
|
/// <summary>
|
|
/// Pull the first whitespace-separated token (the command verb)
|
|
/// from <paramref name="command"/>. Returns the entire string if
|
|
/// there is no whitespace.
|
|
/// </summary>
|
|
public static string GetVerbToken(string command) => ExtractVerb(command);
|
|
}
|