using System; using System.Collections.Generic; using System.Linq; using System.Text; using StardewModdingAPI.Framework.Commands; namespace StardewModdingAPI.Framework { /// <summary>Manages console commands.</summary> internal class CommandManager { /********* ** Fields *********/ /// <summary>The commands registered with SMAPI.</summary> private readonly IDictionary<string, Command> Commands = new Dictionary<string, Command>(StringComparer.OrdinalIgnoreCase); /// <summary>Writes messages to the console.</summary> private readonly IMonitor Monitor; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="monitor">Writes messages to the console.</param> public CommandManager(IMonitor monitor) { this.Monitor = monitor; } /// <summary>Add a console command.</summary> /// <param name="mod">The mod adding the command (or <c>null</c> for a SMAPI command).</param> /// <param name="name">The command name, which the user must type to trigger it.</param> /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> /// <param name="allowNullCallback">Whether to allow a null <paramref name="callback"/> argument; this should only used for backwards compatibility.</param> /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> /// <exception cref="ArgumentException">There's already a command with that name.</exception> public CommandManager Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) { name = this.GetNormalizedName(name); // validate format if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name), "Can't register a command with no name."); if (name.Any(char.IsWhiteSpace)) throw new FormatException($"Can't register the '{name}' command because the name can't contain whitespace."); if (callback == null && !allowNullCallback) throw new ArgumentNullException(nameof(callback), $"Can't register the '{name}' command because without a callback."); // ensure uniqueness if (this.Commands.ContainsKey(name)) throw new ArgumentException(nameof(callback), $"Can't register the '{name}' command because there's already a command with that name."); // add command this.Commands.Add(name, new Command(mod, name, documentation, callback)); return this; } /// <summary>Add a console command.</summary> /// <param name="command">the SMAPI console command to add.</param> /// <param name="monitor">Writes messages to the console.</param> /// <exception cref="ArgumentException">There's already a command with that name.</exception> public CommandManager Add(IInternalCommand command, IMonitor monitor) { return this.Add(null, command.Name, command.Description, (name, args) => command.HandleCommand(args, monitor)); } /// <summary>Get a command by its unique name.</summary> /// <param name="name">The command name.</param> /// <returns>Returns the matching command, or <c>null</c> if not found.</returns> public Command Get(string name) { name = this.GetNormalizedName(name); this.Commands.TryGetValue(name, out Command command); return command; } /// <summary>Get all registered commands.</summary> public IEnumerable<Command> GetAll() { return this.Commands .Values .OrderBy(p => p.Name); } /// <summary>Try to parse a raw line of user input into an executable command.</summary> /// <param name="input">The raw user input.</param> /// <param name="name">The parsed command name.</param> /// <param name="args">The parsed command arguments.</param> /// <param name="command">The command which can handle the input.</param> /// <param name="screenId">The screen ID on which to run the command.</param> /// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns> public bool TryParse(string input, out string name, out string[] args, out Command command, out int screenId) { // ignore if blank if (string.IsNullOrWhiteSpace(input)) { name = null; args = null; command = null; screenId = 0; return false; } // parse input args = this.ParseArgs(input); name = this.GetNormalizedName(args[0]); args = args.Skip(1).ToArray(); // get screen ID argument screenId = 0; for (int i = 0; i < args.Length; i++) { // consume arg & set screen ID if (this.TryParseScreenId(args[i], out int rawScreenId, out string error)) { args = args.Take(i).Concat(args.Skip(i + 1)).ToArray(); screenId = rawScreenId; continue; } // invalid screen arg if (error != null) { this.Monitor.Log(error, LogLevel.Error); command = null; return false; } } // get command return this.Commands.TryGetValue(name, out command); } /// <summary>Trigger a command.</summary> /// <param name="name">The command name.</param> /// <param name="arguments">The command arguments.</param> /// <returns>Returns whether a matching command was triggered.</returns> public bool Trigger(string name, string[] arguments) { // get normalized name name = this.GetNormalizedName(name); if (name == null) return false; // get command if (this.Commands.TryGetValue(name, out Command command)) { command.Callback.Invoke(name, arguments); return true; } return false; } /********* ** Private methods *********/ /// <summary>Parse a string into command arguments.</summary> /// <param name="input">The string to parse.</param> private string[] ParseArgs(string input) { bool inQuotes = false; IList<string> args = new List<string>(); StringBuilder currentArg = new StringBuilder(); foreach (char ch in input) { if (ch == '"') inQuotes = !inQuotes; else if (!inQuotes && char.IsWhiteSpace(ch)) { args.Add(currentArg.ToString()); currentArg.Clear(); } else currentArg.Append(ch); } args.Add(currentArg.ToString()); return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray(); } /// <summary>Try to parse a 'screen=X' command argument, which specifies the screen that should receive the command.</summary> /// <param name="arg">The raw argument to parse.</param> /// <param name="screen">The parsed screen ID, if any.</param> /// <param name="error">The error which indicates an invalid screen ID, if applicable.</param> /// <returns>Returns whether the screen ID was parsed successfully.</returns> private bool TryParseScreenId(string arg, out int screen, out string error) { screen = -1; error = null; // skip non-screen arg if (!arg.StartsWith("screen=")) return false; // get screen ID string rawScreen = arg.Substring("screen=".Length); if (!int.TryParse(rawScreen, out screen)) { error = $"invalid screen ID format: {rawScreen}"; return false; } // validate ID if (!Context.HasScreenId(screen)) { error = $"there's no active screen with ID {screen}. Active screen IDs: {string.Join(", ", Context.ActiveScreenIds)}."; return false; } return true; } /// <summary>Get a normalized command name.</summary> /// <param name="name">The command name.</param> private string GetNormalizedName(string name) { name = name?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(name) ? name : null; } } }