diff --git a/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj b/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj index f2c8cbc5a6..0af63b48b5 100644 --- a/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj +++ b/Dalamud.CorePlugin/Dalamud.CorePlugin.csproj @@ -6,6 +6,7 @@ false false IDE0003 + enable diff --git a/Dalamud.CorePlugin/PluginImpl.cs b/Dalamud.CorePlugin/PluginImpl.cs index 951050b336..fc5e96301b 100644 --- a/Dalamud.CorePlugin/PluginImpl.cs +++ b/Dalamud.CorePlugin/PluginImpl.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using Dalamud.Configuration.Internal; @@ -7,6 +8,7 @@ using Dalamud.Plugin; using Dalamud.Plugin.Services; using Dalamud.Utility; + using Serilog; namespace Dalamud.CorePlugin @@ -28,6 +30,7 @@ namespace Dalamud.CorePlugin /// public sealed class PluginImpl : IDalamudPlugin { + private readonly IChatGui chatGui; #if !DEBUG /// @@ -46,7 +49,6 @@ public void Dispose() #else private readonly WindowSystem windowSystem = new("Dalamud.CorePlugin"); - private Localization localization; private IPluginLog pluginLog; @@ -55,14 +57,17 @@ public void Dispose() /// /// Dalamud plugin interface. /// Logging service. - public PluginImpl(IDalamudPluginInterface pluginInterface, IPluginLog log) + /// Command manager. + /// Chat GUI. + [Experimental("Dalamud001")] + public PluginImpl(IDalamudPluginInterface pluginInterface, IPluginLog log, ICommandManager commandManager, IChatGui chatGui) { + this.chatGui = chatGui; + this.Interface = pluginInterface; + this.pluginLog = log; + try { - // this.InitLoc(); - this.Interface = pluginInterface; - this.pluginLog = log; - this.windowSystem.AddWindow(new PluginWindow()); this.Interface.UiBuilder.Draw += this.OnDraw; @@ -73,7 +78,8 @@ public PluginImpl(IDalamudPluginInterface pluginInterface, IPluginLog log) Log.Information($"CorePlugin : DefaultFontHandle.ImFontChanged called {fc}"); }; - Service.Get().AddHandler("/coreplug", new CommandInfo(this.OnCommand) { HelpMessage = "Access the plugin." }); + commandManager.AddHandler("/coreplug", new CommandInfo(this.OnCommand) { HelpMessage = "Access the plugin." }); + commandManager.AddCommand("/coreplugnew", "Access the plugin.", this.OnCommandNew); log.Information("CorePlugin ctor!"); } @@ -98,25 +104,6 @@ public void Dispose() this.windowSystem.RemoveAllWindows(); } - /// - /// CheapLoc needs to be reinitialized here because it tracks the setup by assembly name. New assembly, new setup. - /// - public void InitLoc() - { - var dalamud = Service.Get(); - var dalamudConfig = Service.Get(); - - this.localization = new Localization(Path.Combine(dalamud.AssetDirectory.FullName, "UIRes", "loc", "dalamud"), "dalamud_"); - if (!dalamudConfig.LanguageOverride.IsNullOrEmpty()) - { - this.localization.SetupWithLangCode(dalamudConfig.LanguageOverride); - } - else - { - this.localization.SetupWithUiCulture(); - } - } - /// /// Draw the window system. /// @@ -134,11 +121,17 @@ private void OnDraw() private void OnCommand(string command, string args) { - this.pluginLog.Information("Command called!"); + this.chatGui.Print("Command called!"); // this.window.IsOpen = true; } + private bool OnCommandNew(bool var1, int var2, string? var3) + { + this.chatGui.Print($"CorePlugin: Command called! var1: {var1}, var2: {var2}, var3: {var3}"); + return true; + } + private void OnOpenConfigUi() { // this.window.IsOpen = true; diff --git a/Dalamud/Console/ConsoleEntry.cs b/Dalamud/Console/ConsoleEntry.cs index 93f250228f..23a00eb82e 100644 --- a/Dalamud/Console/ConsoleEntry.cs +++ b/Dalamud/Console/ConsoleEntry.cs @@ -11,13 +11,19 @@ public interface IConsoleEntry /// Gets the name of the entry. /// public string Name { get; } - + /// /// Gets the description of the entry. /// public string Description { get; } + + /// + /// Get a string representation of the usage of this entry, detailing the expected arguments. + /// + /// The usage string. + public string GetUsageString(); } - + /// /// Interface representing a command in the console. /// diff --git a/Dalamud/Console/ConsoleManager.cs b/Dalamud/Console/ConsoleManager.cs index 4112cde2a8..a961971a0c 100644 --- a/Dalamud/Console/ConsoleManager.cs +++ b/Dalamud/Console/ConsoleManager.cs @@ -18,9 +18,9 @@ namespace Dalamud.Console; internal partial class ConsoleManager : IServiceType { private static readonly ModuleLog Log = new("CON"); - + private Dictionary entries = new(); - + /// /// Initializes a new instance of the class. /// @@ -29,17 +29,53 @@ public ConsoleManager() { this.AddCommand("toggle", "Toggle a boolean variable.", this.OnToggleVariable); } - + /// - /// Event that is triggered when a command is processed. Return true to stop the command from being processed any further. + /// Possible results when processing a console command. /// - public event Func? Invoke; - + public enum CommandProcessResult + { + /// + /// The command was processed and executed correctly. + /// + Success, + + /// + /// The input failed to parse. onDiagnosticEmitted will be called with further information. + /// + ParseFailure, + + /// + /// The command did not indicate success. + /// + ExecutionFailure, + + /// + /// The command was not found. + /// + NotFound, + } + /// /// Gets a read-only dictionary of console entries. /// public IReadOnlyDictionary Entries => this.entries; - + + /// + /// Add an entry to the console. + /// + /// The entry to add. + /// Thrown if the entry already exists. + public void AddEntry(IConsoleEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + if (this.FindEntry(entry.Name) != null) + throw new InvalidOperationException($"Entry '{entry.Name}' already exists."); + + this.entries.Add(entry.Name, entry); + } + /// /// Add a command to the console. /// @@ -53,13 +89,10 @@ public IConsoleCommand AddCommand(string name, string description, Delegate func ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(description); ArgumentNullException.ThrowIfNull(func); - - if (this.FindEntry(name) != null) - throw new InvalidOperationException($"Entry '{name}' already exists."); var command = new ConsoleCommand(name, description, func); - this.entries.Add(name, command); - + this.AddEntry(command); + return command; } @@ -77,14 +110,13 @@ public IConsoleVariable AddVariable(string name, string description, T def ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(description); Traits.ThrowIfTIsNullableAndNull(defaultValue); - - if (this.FindEntry(name) != null) - throw new InvalidOperationException($"Entry '{name}' already exists."); - - var variable = new ConsoleVariable(name, description); - variable.Value = defaultValue; - this.entries.Add(name, variable); - + + var variable = new ConsoleVariable(name, description) + { + Value = defaultValue, + }; + this.AddEntry(variable); + return variable; } @@ -98,11 +130,11 @@ public IConsoleEntry AddAlias(string name, string alias) { ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(alias); - + var target = this.FindEntry(name); if (target == null) throw new EntryNotFoundException(name); - + if (this.FindEntry(alias) != null) throw new InvalidOperationException($"Entry '{alias}' already exists."); @@ -135,21 +167,21 @@ public void RemoveEntry(IConsoleEntry entry) public T GetVariable(string name) { ArgumentNullException.ThrowIfNull(name); - + var entry = this.FindEntry(name); - + if (entry is ConsoleVariable variable) return variable.Value; - + if (entry is ConsoleVariable) throw new InvalidOperationException($"Variable '{name}' is not of type {typeof(T).Name}."); - + if (entry is null) throw new EntryNotFoundException(name); - + throw new InvalidOperationException($"Command '{name}' is not a variable."); } - + /// /// Set the value of a variable. /// @@ -162,18 +194,18 @@ public void SetVariable(string name, T value) { ArgumentNullException.ThrowIfNull(name); Traits.ThrowIfTIsNullableAndNull(value); - + var entry = this.FindEntry(name); - + if (entry is ConsoleVariable variable) variable.Value = value; - + if (entry is ConsoleVariable) throw new InvalidOperationException($"Variable '{name}' is not of type {typeof(T).Name}."); if (entry is null) - throw new EntryNotFoundException(name); - + throw new EntryNotFoundException(name); + throw new InvalidOperationException($"Command '{name}' is not a variable."); } @@ -181,30 +213,29 @@ public void SetVariable(string name, T value) /// Process a console command. /// /// The command to process. - /// Whether or not the command was successfully processed. - public bool ProcessCommand(string command) + /// Action to be invoked when an error during command parsing occurred. + /// Whether the command was successfully processed. + public CommandProcessResult ProcessCommand(string command, Action onDiagnosticEmitted) { - if (this.Invoke?.Invoke(command) == true) - return true; - + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(onDiagnosticEmitted); + var matches = GetCommandParsingRegex().Matches(command); if (matches.Count == 0) - return false; - + return CommandProcessResult.NotFound; + var entryName = matches[0].Value; if (string.IsNullOrEmpty(entryName) || entryName.Any(char.IsWhiteSpace)) { - Log.Error("No valid command specified"); - return false; + return CommandProcessResult.NotFound; } var entry = this.FindEntry(entryName); if (entry == null) { - Log.Error("Command {CommandName} not found", entryName); - return false; + return CommandProcessResult.NotFound; } - + var parsedArguments = new List(); if (entry.ValidArguments != null) @@ -213,17 +244,16 @@ public bool ProcessCommand(string command) { if (i - 1 >= entry.ValidArguments.Count) { - Log.Error("Too many arguments for command {CommandName}", entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new TooManyArgumentsDiagnostic(entry)); + return CommandProcessResult.ParseFailure; } - + var argumentToMatch = entry.ValidArguments[i - 1]; - + var group = matches[i]; if (!group.Success) continue; - + var value = group.Value; if (string.IsNullOrEmpty(value)) continue; @@ -238,55 +268,49 @@ public bool ProcessCommand(string command) parsedArguments.Add(intValue); break; case ConsoleArgumentType.Integer: - Log.Error("Argument {Argument} for command {CommandName} is not an integer", value, entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new WrongArgumentTypeDiagnostic(i, value, "integer", entry)); + return CommandProcessResult.ParseFailure; case ConsoleArgumentType.Float when float.TryParse(value, out var floatValue): parsedArguments.Add(floatValue); break; case ConsoleArgumentType.Float: - Log.Error("Argument {Argument} for command {CommandName} is not a float", value, entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new WrongArgumentTypeDiagnostic(i, value, "float", entry)); + return CommandProcessResult.ParseFailure; case ConsoleArgumentType.Bool when bool.TryParse(value, out var boolValue): parsedArguments.Add(boolValue); break; case ConsoleArgumentType.Bool: - Log.Error("Argument {Argument} for command {CommandName} is not a boolean", value, entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new WrongArgumentTypeDiagnostic(i, value, "boolean", entry)); + return CommandProcessResult.ParseFailure; default: throw new Exception("Unhandled argument type."); } } - + if (parsedArguments.Count != entry.ValidArguments.Count) { // Either fill in the default values or error out - for (var i = parsedArguments.Count; i < entry.ValidArguments.Count; i++) { var argument = entry.ValidArguments[i]; - + // If the default value is DBNull, we need to error out as that means it was not specified if (argument.DefaultValue == DBNull.Value) { - Log.Error("Not enough arguments for command {CommandName}", entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new NotEnoughArgumentsDiagnostic(entry)); + return CommandProcessResult.ParseFailure; } parsedArguments.Add(argument.DefaultValue); } - + if (parsedArguments.Count != entry.ValidArguments.Count) { - Log.Error("Too many arguments for command {CommandName}", entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new TooManyArgumentsDiagnostic(entry)); + return CommandProcessResult.ParseFailure; } } } @@ -294,28 +318,17 @@ public bool ProcessCommand(string command) { if (matches.Count > 1) { - Log.Error("Command {CommandName} does not take any arguments", entryName); - PrintUsage(entry); - return false; + onDiagnosticEmitted.Invoke(new DoesNotTakeArgumentsDiagnostic(entry)); + return CommandProcessResult.ParseFailure; } } - return entry.Invoke(parsedArguments); + return entry.Invoke(parsedArguments) ? CommandProcessResult.Success : CommandProcessResult.ExecutionFailure; } - + [GeneratedRegex("""("[^"]+"|[^\s"]+)""", RegexOptions.Compiled)] private static partial Regex GetCommandParsingRegex(); - - private static void PrintUsage(ConsoleEntry entry, bool error = true) - { - Log.WriteLog( - error ? LogEventLevel.Error : LogEventLevel.Information, - "Usage: {CommandName} {Arguments}", - null, - entry.Name, - string.Join(" ", entry.ValidArguments?.Select(x => $"<{x.Type.ToString().ToLowerInvariant()}>") ?? Enumerable.Empty())); - } - + private ConsoleEntry? FindEntry(string name) { return this.entries.TryGetValue(name, out var entry) ? entry as ConsoleEntry : null; @@ -333,7 +346,78 @@ private bool OnToggleVariable(string name) return true; } - + + /// + /// A diagnostic message emitted when a console command fails to parse. + /// + /// The relevant console entry. + internal abstract record ConsoleDiagnostic(IConsoleEntry Entry) + { + /// + /// Write a standard message detailing the diagnostic to the log. + /// + public abstract void WriteToLog(); + } + + /// + /// A diagnostic message emitted when an argument of a command is of the wrong type. + /// + /// The index of the argument. + /// The value we tried to parse. + /// The type we failed to match. + /// The relevant console entry. + internal record WrongArgumentTypeDiagnostic(int ArgumentIndex, string Value, string TypeToMatch, IConsoleEntry Entry) + : ConsoleDiagnostic(Entry) + { + /// + public override void WriteToLog() + { + Log.Error("Wrong type for argument at index {ArgumentIndex}. Expected {TypeToMatch}", this.ArgumentIndex, this.TypeToMatch); + } + } + + /// + /// A diagnostic message emitted when too many arguments were provided. + /// + /// The relevant console entry. + internal record TooManyArgumentsDiagnostic(IConsoleEntry Entry) + : ConsoleDiagnostic(Entry) + { + /// + public override void WriteToLog() + { + Log.Error("Too many arguments for command {CommandName}", this.Entry.Name); + } + } + + /// + /// A diagnostic message emitted when not enough arguments were provided. + /// + /// The relevant console entry. + internal record NotEnoughArgumentsDiagnostic(IConsoleEntry Entry) + : ConsoleDiagnostic(Entry) + { + /// + public override void WriteToLog() + { + Log.Error("Not enough arguments for command {CommandName}", this.Entry.Name); + } + } + + /// + /// A diagnostic message emitted when a command does not take any arguments. + /// + /// The relevant console entry. + internal record DoesNotTakeArgumentsDiagnostic(IConsoleEntry Entry) + : ConsoleDiagnostic(Entry) + { + /// + public override void WriteToLog() + { + Log.Error("Command {CommandName} does not take any arguments", this.Entry.Name); + } + } + private static class Traits { public static void ThrowIfTIsNullableAndNull(T? argument, [CallerArgumentExpression("argument")] string? paramName = null) @@ -353,7 +437,7 @@ private abstract class ConsoleEntry : IConsoleEntry /// /// The name of the entry. /// A description of the entry. - public ConsoleEntry(string name, string description) + protected ConsoleEntry(string name, string description) { this.Name = name; this.Description = description; @@ -364,19 +448,28 @@ public ConsoleEntry(string name, string description) /// public string Description { get; } - + /// /// Gets or sets a list of valid argument types for this console entry. /// public IReadOnlyList? ValidArguments { get; protected set; } - + /// /// Execute this command. /// /// Arguments to invoke the entry with. - /// Whether or not execution succeeded. + /// Whether execution succeeded. public abstract bool Invoke(IEnumerable arguments); + /// + public string GetUsageString() + { + var argumentStr = string.Join( + " ", + this.ValidArguments?.Select(x => $"<{x.Type.ToString().ToLowerInvariant()}>") ?? []); + return $"{this.Name} {argumentStr}"; + } + /// /// Get an instance of for a given type. /// @@ -388,46 +481,25 @@ protected static ArgumentInfo TypeToArgument(Type type, object? defaultValue) { if (type == typeof(string)) return new ArgumentInfo(ConsoleArgumentType.String, defaultValue); - + if (type == typeof(int)) return new ArgumentInfo(ConsoleArgumentType.Integer, defaultValue); - + if (type == typeof(float)) return new ArgumentInfo(ConsoleArgumentType.Float, defaultValue); - + if (type == typeof(bool)) return new ArgumentInfo(ConsoleArgumentType.Bool, defaultValue); - + throw new ArgumentException($"Invalid argument type: {type.Name}"); } - - public record ArgumentInfo(ConsoleArgumentType Type, object? DefaultValue); - } - - /// - /// Class representing an alias to another console entry. - /// - private class ConsoleAlias : ConsoleEntry - { - private readonly ConsoleEntry target; /// - /// Initializes a new instance of the class. + /// Information about a console argument. /// - /// The name of the alias. - /// The target entry to alias to. - public ConsoleAlias(string name, ConsoleEntry target) - : base(name, target.Description) - { - this.target = target; - this.ValidArguments = target.ValidArguments; - } - - /// - public override bool Invoke(IEnumerable arguments) - { - return this.target.Invoke(arguments); - } + /// The type of the argument. + /// The default value of the argument, if one is set, otherwise null. + public record ArgumentInfo(ConsoleArgumentType Type, object? DefaultValue); } /// @@ -436,7 +508,7 @@ public override bool Invoke(IEnumerable arguments) private class ConsoleCommand : ConsoleEntry, IConsoleCommand { private readonly Delegate func; - + /// /// Initializes a new instance of the class. /// @@ -447,17 +519,17 @@ public ConsoleCommand(string name, string description, Delegate func) : base(name, description) { this.func = func; - + if (func.Method.ReturnType != typeof(bool)) throw new ArgumentException("Console command functions must return a boolean indicating success."); - + var validArguments = new List(); foreach (var parameterInfo in func.Method.GetParameters()) { var paraT = parameterInfo.ParameterType; validArguments.Add(TypeToArgument(paraT, parameterInfo.DefaultValue)); } - + this.ValidArguments = validArguments; } @@ -468,6 +540,32 @@ public override bool Invoke(IEnumerable arguments) } } + /// + /// Class representing an alias to another console entry. + /// + private class ConsoleAlias : ConsoleEntry + { + private readonly ConsoleEntry target; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the alias. + /// The target entry to alias to. + public ConsoleAlias(string name, ConsoleEntry target) + : base(name, target.Description) + { + this.target = target; + this.ValidArguments = target.ValidArguments; + } + + /// + public override bool Invoke(IEnumerable arguments) + { + return this.target.Invoke(arguments); + } + } + /// /// Class representing a basic console variable. /// @@ -479,7 +577,7 @@ private abstract class ConsoleVariable(string name, string description) : Consol /// Class representing a generic console variable. /// /// The type of the variable. - private class ConsoleVariable : ConsoleVariable, IConsoleVariable + private class ConsoleVariable : ConsoleEntry, IConsoleVariable { /// /// Initializes a new instance of the class. @@ -491,7 +589,7 @@ public ConsoleVariable(string name, string description) { this.ValidArguments = new List { TypeToArgument(typeof(T), null) }; } - + /// public T Value { get; set; } @@ -507,16 +605,16 @@ public override bool Invoke(IEnumerable arguments) { this.Value = (T)(object)!boolValue; } - + Log.WriteLog(LogEventLevel.Information, "{VariableName} = {VariableValue}", null, this.Name, this.Value); return true; } - + if (first.GetType() != typeof(T)) throw new ArgumentException($"Console variable must be set with an argument of type {typeof(T).Name}."); this.Value = (T)first; - + return true; } } diff --git a/Dalamud/Game/Command/BaseChatCommand.cs b/Dalamud/Game/Command/BaseChatCommand.cs new file mode 100644 index 0000000000..7a1a482d3c --- /dev/null +++ b/Dalamud/Game/Command/BaseChatCommand.cs @@ -0,0 +1,34 @@ +using Dalamud.Console; + +namespace Dalamud.Game.Command; + +/// +/// Interface representing a command. +/// +internal abstract class BaseChatCommand(IConsoleCommand command) +{ + /// + /// Gets or sets the help message for this command. + /// + public string? HelpMessage { get; set; } + + /// + /// Gets or sets a value indicating whether if this command should be shown in the help output. + /// + public bool ShowInHelp { get; set; } + + /// + /// Gets or sets the display order of this command. Defaults to alphabetical ordering. + /// + public int DisplayOrder { get; set; } + + /// + /// Gets or sets the console entry associated with this command. + /// + public IConsoleCommand ConsoleEntry { get; set; } = command; + + /// + /// Gets or sets the WorkingPluginId of the plugin that registered this command. + /// + public Guid? OwnerPluginGuid { get; set; } +} diff --git a/Dalamud/Game/Command/CommandManager.cs b/Dalamud/Game/Command/CommandManager.cs index fdaa5833ba..c8a056f751 100644 --- a/Dalamud/Game/Command/CommandManager.cs +++ b/Dalamud/Game/Command/CommandManager.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Dalamud.Console; @@ -26,8 +27,7 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma { private static readonly ModuleLog Log = new("Command"); - private readonly ConcurrentDictionary commandMap = new(); - private readonly ConcurrentDictionary<(string, IReadOnlyCommandInfo), string> commandAssemblyNameMap = new(); + private readonly ConcurrentDictionary commandMap = new(); private readonly Hook? tryInvokeDebugCommandHook; @@ -41,12 +41,31 @@ private CommandManager(Dalamud dalamud) (nint)ShellCommands.MemberFunctionPointers.TryInvokeDebugCommand, this.OnTryInvokeDebugCommand); this.tryInvokeDebugCommandHook.Enable(); - - this.console.Invoke += this.ConsoleOnInvoke; } /// - public ReadOnlyDictionary Commands => new(this.commandMap); + [Api13ToDo("Make this sensible. Don't use exposed API for internal structures.")] + public ReadOnlyDictionary Commands => this.commandMap.ToDictionary(x => x.Key, + x => + { + return x.Value switch + { + IReadOnlyCommandInfo commandInfo => commandInfo, + ConsoleBackedChatCommand consoleEntry => new CommandInfo(null!) + { + HelpMessage = consoleEntry.HelpMessage ?? string.Empty, + ShowInHelp = consoleEntry.ShowInHelp, + DisplayOrder = consoleEntry.DisplayOrder, + }, + _ => throw new Exception("Unknown command type"), + }; + }).AsReadOnly(); + + /// + /// Gets a read-only dictionary of all registered commands. + /// + [Api13ToDo("Make this sensible. Don't use exposed API for internal structures.")] + public ReadOnlyDictionary CommandsNew => new(this.commandMap); /// public bool ProcessCommand(string content) @@ -58,15 +77,10 @@ public bool ProcessCommand(string content) if (separatorPosition == -1 || separatorPosition + 1 >= content.Length) { // If no space was found or ends with the space. Process them as a no argument - if (separatorPosition + 1 >= content.Length) - { - // Remove the trailing space - command = content.Substring(0, separatorPosition); - } - else - { - command = content; - } + // Remove the trailing space + command = separatorPosition + 1 >= content.Length ? + content[..separatorPosition] : + content; argument = string.Empty; } @@ -87,7 +101,30 @@ public bool ProcessCommand(string content) if (!this.commandMap.TryGetValue(command, out var handler)) // Command was not found. return false; - this.DispatchCommand(command, argument, handler); + switch (handler) + { + case ConsoleBackedChatCommand: + try + { + // TODO: Localize, print errors to chat + this.console.ProcessCommand(content, diagnostic => diagnostic.WriteToLog()); + } + catch (Exception ex) + { + Log.Error(ex, "Error while dispatching command {CommandName} (Argument: {Argument})", command, argument); + } + + break; + + case LegacyHandlerChatCommand legacyHandler: + this.DispatchCommand(command, argument, legacyHandler); + + break; + default: + Log.Error("Unknown command type for {CommandName}", command); + return false; + } + return true; } @@ -109,23 +146,35 @@ public void DispatchCommand(string command, string argument, IReadOnlyCommandInf /// /// The command to register. /// A object describing the command. - /// Assembly name of the plugin that added this command. + /// WorkingPluginId of the plugin that added this command. /// If adding was successful. - public bool AddHandler(string command, CommandInfo info, string loaderAssemblyName) + public bool AddHandler(string command, CommandInfo info, Guid? ownerPluginGuid) { if (info == null) throw new ArgumentNullException(nameof(info), "Command handler is null."); - if (!this.commandMap.TryAdd(command, info)) + IConsoleCommand debugCommand; + try { - Log.Error("Command {CommandName} is already registered", command); + debugCommand = this.console.AddCommand( + command, + info.HelpMessage, + (string args) => { this.DispatchCommand(command, args, info); }); + } + catch (InvalidOperationException) + { + Log.Error("Could not register debug command for legacy command {CommandName}", command); return false; } - if (!this.commandAssemblyNameMap.TryAdd((command, info), loaderAssemblyName)) + var legacyCommandInfo = new LegacyHandlerChatCommand(info, debugCommand) + { + OwnerPluginGuid = ownerPluginGuid, + }; + + if (!this.commandMap.TryAdd(command, legacyCommandInfo)) { - this.commandMap.Remove(command, out _); - Log.Error("Command {CommandName} is already registered in the assembly name map", command); + Log.Error("Command {CommandName} is already registered", command); return false; } @@ -135,66 +184,88 @@ public bool AddHandler(string command, CommandInfo info, string loaderAssemblyNa /// public bool AddHandler(string command, CommandInfo info) { - if (info == null) - throw new ArgumentNullException(nameof(info), "Command handler is null."); - - if (!this.commandMap.TryAdd(command, info)) - { - Log.Error("Command {CommandName} is already registered.", command); - return false; - } - - return true; + return this.AddHandler(command, info, null); } /// - public bool RemoveHandler(string command) + [Experimental("Dalamud001")] + public bool AddCommand(string commandName, string helpMessage, Delegate func, bool showInHelp = true, int displayOrder = -1) { - if (this.commandAssemblyNameMap.FindFirst(c => c.Key.Item1 == command, out var assemblyKeyValuePair)) - { - this.commandAssemblyNameMap.TryRemove(assemblyKeyValuePair.Key, out _); - } - - return this.commandMap.Remove(command, out _); + return this.AddCommandInternal(commandName, helpMessage, func, showInHelp, displayOrder, null); } - /// - /// Returns the assembly name from which the command was added or blank if added internally. - /// - /// The command. - /// A ICommandInfo object. - /// The name of the assembly. - public string GetHandlerAssemblyName(string command, IReadOnlyCommandInfo commandInfo) + /// + [Api13ToDo("Rename to Remove().")] + public bool RemoveHandler(string command) { - if (this.commandAssemblyNameMap.TryGetValue((command, commandInfo), out var assemblyName)) + var removed = this.commandMap.Remove(command, out var commandInfo); + if (removed) { - return assemblyName; + this.console.RemoveEntry(commandInfo.ConsoleEntry); } - return string.Empty; + return removed; } /// - /// Returns a list of commands given a specified assembly name. + /// Returns a list of commands given a specified WorkingPluginId. /// - /// The name of the assembly. + /// The WorkingPluginId of the plugin. /// A list of commands and their associated activation string. - public List> GetHandlersByAssemblyName( - string assemblyName) + public List<(string Command, BaseChatCommand CommandInfo)> GetHandlersByWorkingPluginId( + Guid ownerPluginGuid) { - return this.commandAssemblyNameMap.Where(c => c.Value == assemblyName).ToList(); + return this.commandMap.Where(c => c.Value.OwnerPluginGuid == ownerPluginGuid) + .Select(x => (x.Key, x.Value)) + .ToList(); } /// void IInternalDisposableService.DisposeService() { - this.console.Invoke -= this.ConsoleOnInvoke; this.tryInvokeDebugCommandHook?.Dispose(); } - private bool ConsoleOnInvoke(string arg) + /// + /// Register a chat command. Arguments to the command are parsed for you, based on the arguments to the function + /// passed into the parameter. + /// + /// The name of the command. + /// The help message shown to users in chat or the installer. + /// The function to be called when the chat command is executed. Arguments to the command are derived from the parameters of this function. + /// Whether this command should be shown to users. + /// The display order of this command. Defaults to alphabetical ordering. + /// WorkingPluginId of the plugin that added this command. + /// If adding was successful. + internal bool AddCommandInternal(string commandName, string helpMessage, Delegate func, bool showInHelp, int displayOrder, Guid? ownerPluginGuid) { - return arg.StartsWith('/') && this.ProcessCommand(arg); + var command = this.console.AddCommand(commandName, helpMessage, func); + var commandInfo = new ConsoleBackedChatCommand(command) + { + HelpMessage = helpMessage, + ShowInHelp = showInHelp, + DisplayOrder = displayOrder, + OwnerPluginGuid = ownerPluginGuid, + }; + + try + { + this.console.AddEntry(command); + } + catch (InvalidOperationException) + { + Log.Error("Command {CommandName} is already registered.", commandName); + return false; + } + + if (!this.commandMap.TryAdd(commandName, commandInfo)) + { + this.console.RemoveEntry(command); + Log.Error("Command {CommandName} is already registered.", commandName); + return false; + } + + return true; } private int OnTryInvokeDebugCommand(ShellCommands* self, Utf8String* command, UIModule* uiModule) @@ -204,6 +275,25 @@ private int OnTryInvokeDebugCommand(ShellCommands* self, Utf8String* command, UI return this.ProcessCommand(command->ToString()) ? 0 : result; } + + private class ConsoleBackedChatCommand(IConsoleCommand consoleCommand) : BaseChatCommand(consoleCommand) + { + } + + private class LegacyHandlerChatCommand(IReadOnlyCommandInfo.HandlerDelegate handler, IConsoleCommand command) + : BaseChatCommand(command), IReadOnlyCommandInfo + { + public LegacyHandlerChatCommand(CommandInfo commandInfo, IConsoleCommand command) + : this(commandInfo.Handler, command) + { + this.HelpMessage = commandInfo.HelpMessage; + this.ShowInHelp = commandInfo.ShowInHelp; + this.DisplayOrder = commandInfo.DisplayOrder; + } + + /// + public IReadOnlyCommandInfo.HandlerDelegate Handler { get; set; } = handler; + } } /// @@ -260,7 +350,7 @@ public bool AddHandler(string command, CommandInfo info) { if (!this.pluginRegisteredCommands.Contains(command)) { - if (this.commandManagerService.AddHandler(command, info, this.pluginInfo.InternalName)) + if (this.commandManagerService.AddHandler(command, info, this.pluginInfo.EffectiveWorkingPluginId)) { this.pluginRegisteredCommands.Add(command); return true; @@ -274,6 +364,26 @@ public bool AddHandler(string command, CommandInfo info) return false; } + /// + [Experimental("Dalamud001")] + public bool AddCommand(string commandName, string helpMessage, Delegate func, bool showInHelp = true, int displayOrder = -1) + { + if (!this.pluginRegisteredCommands.Contains(commandName)) + { + if (this.commandManagerService.AddCommandInternal(commandName, helpMessage, func, showInHelp, displayOrder, this.pluginInfo.EffectiveWorkingPluginId)) + { + this.pluginRegisteredCommands.Add(commandName); + return true; + } + } + else + { + Log.Error($"Command {commandName} is already registered."); + } + + return false; + } + /// public bool RemoveHandler(string command) { diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index f7ce5d1454..22dd9309a1 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -41,9 +41,9 @@ internal class ConsoleWindow : Window, IDisposable // Fields below should be touched only from the main thread. private readonly RollingList logText; private readonly RollingList filteredLogEntries; - + private readonly List pluginFilters = new(); - + private readonly DalamudConfiguration configuration; private int newRolledLines; @@ -87,14 +87,14 @@ public ConsoleWindow(DalamudConfiguration configuration) : base("Dalamud Console", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) { this.configuration = configuration; - + this.autoScroll = configuration.LogAutoScroll; this.autoOpen = configuration.LogOpenAtStartup; Service.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate); - + var cm = Service.Get(); - cm.AddCommand("clear", "Clear the console log", () => + cm.AddCommand("clear", "Clear the console log", () => { this.QueueClear(); return true; @@ -578,7 +578,7 @@ private void DrawOptionsToolbar() inputWidth = ImGui.GetWindowWidth() - (ImGui.GetStyle().WindowPadding.X * 2); if (!breakInputLines) - inputWidth = (inputWidth - ImGui.GetStyle().ItemSpacing.X) / 2; + inputWidth = (inputWidth - ImGui.GetStyle().ItemSpacing.X) / 2; } else { @@ -799,18 +799,25 @@ private void ProcessCommand() { if (string.IsNullOrEmpty(this.commandText)) return; - + this.historyPos = -1; - + if (this.commandText != this.configuration.LogCommandHistory.LastOrDefault()) this.configuration.LogCommandHistory.Add(this.commandText); - + if (this.configuration.LogCommandHistory.Count > HistorySize) this.configuration.LogCommandHistory.RemoveAt(0); - + this.configuration.QueueSave(); - this.lastCmdSuccess = Service.Get().ProcessCommand(this.commandText); + this.lastCmdSuccess = Service.Get().ProcessCommand( + this.commandText, + diagnostic => + { + diagnostic.WriteToLog(); + Log.Error("Usage: {Usage}", diagnostic.Entry.GetUsageString()); + }) == ConsoleManager.CommandProcessResult.Success; + this.commandText = string.Empty; // TODO: Force scroll to bottom @@ -832,7 +839,7 @@ private unsafe int CommandInputCallback(ImGuiInputTextCallbackData* data) this.completionZipText = null; this.completionTabIdx = 0; break; - + case ImGuiInputTextFlags.CallbackCompletion: var textBytes = new byte[data->BufTextLen]; Marshal.Copy((IntPtr)data->Buf, textBytes, 0, data->BufTextLen); @@ -843,11 +850,11 @@ private unsafe int CommandInputCallback(ImGuiInputTextCallbackData* data) // We can't do any completion for parameters at the moment since it just calls into CommandHandler if (words.Length > 1) return 0; - + var wordToComplete = words[0]; if (wordToComplete.IsNullOrWhitespace()) return 0; - + if (this.completionZipText is not null) wordToComplete = this.completionZipText; @@ -878,7 +885,7 @@ private unsafe int CommandInputCallback(ImGuiInputTextCallbackData* data) toComplete = candidates.ElementAt(this.completionTabIdx); this.completionTabIdx = (this.completionTabIdx + 1) % candidates.Count(); } - + if (toComplete != null) { ptr.DeleteChars(0, ptr.BufTextLen); diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs index 07695c02ab..73909fafb6 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/CommandWidget.cs @@ -2,6 +2,7 @@ using Dalamud.Game.Command; using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Internal; using ImGuiNET; @@ -14,9 +15,9 @@ internal class CommandWidget : IDataWindowWidget { /// public string[]? CommandShortcuts { get; init; } = { "command" }; - + /// - public string DisplayName { get; init; } = "Command"; + public string DisplayName { get; init; } = "Command"; /// public bool Ready { get; set; } @@ -31,6 +32,7 @@ public void Load() public void Draw() { var commandManager = Service.Get(); + var pluginManager = Service.Get(); var tableFlags = ImGuiTableFlags.ScrollY | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.Sortable | ImGuiTableFlags.SortTristate; @@ -44,9 +46,9 @@ public void Draw() ImGui.TableSetupColumn("HelpMessage", ImGuiTableColumnFlags.NoSort); ImGui.TableSetupColumn("In Help?", ImGuiTableColumnFlags.NoSort); ImGui.TableHeadersRow(); - + var sortSpecs = ImGui.TableGetSortSpecs(); - var commands = commandManager.Commands.ToArray(); + var commands = commandManager.CommandsNew.ToArray(); if (sortSpecs.SpecsCount != 0) { @@ -56,8 +58,8 @@ public void Draw() ? commands.OrderBy(kv => kv.Key).ToArray() : commands.OrderByDescending(kv => kv.Key).ToArray(), 1 => sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending - ? commands.OrderBy(kv => commandManager.GetHandlerAssemblyName(kv.Key, kv.Value)).ToArray() - : commands.OrderByDescending(kv => commandManager.GetHandlerAssemblyName(kv.Key, kv.Value)).ToArray(), + ? commands.OrderBy(kv => GetPluginDebugName(kv.Value.OwnerPluginGuid)).ToArray() + : commands.OrderByDescending(kv => GetPluginDebugName(kv.Value.OwnerPluginGuid)).ToArray(), _ => commands, }; } @@ -65,19 +67,31 @@ public void Draw() foreach (var command in commands) { ImGui.TableNextRow(); - + ImGui.TableSetColumnIndex(0); ImGui.Text(command.Key); - + ImGui.TableNextColumn(); - ImGui.Text(commandManager.GetHandlerAssemblyName(command.Key, command.Value)); - + ImGui.Text(GetPluginDebugName(command.Value.OwnerPluginGuid)); + ImGui.TableNextColumn(); ImGui.TextWrapped(command.Value.HelpMessage); - + ImGui.TableNextColumn(); ImGui.Text(command.Value.ShowInHelp ? "Yes" : "No"); } } + + return; + + string GetPluginDebugName(Guid? workingPluginId) + { + if (workingPluginId == null) + return "Unknown"; + + var plugin = pluginManager.InstalledPlugins + .FirstOrDefault(x => x.EffectiveWorkingPluginId == workingPluginId); + return plugin?.InternalName ?? "Unknown"; + } } } diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index c2efd2d680..1e998c5631 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -2814,19 +2814,18 @@ private void DrawInstalledPlugin(LocalPlugin plugin, int index, RemotePluginMani // Available commands (if loaded) if (plugin.IsLoaded) { - var commands = commandManager.Commands - .Where(cInfo => - cInfo.Value is { ShowInHelp: true } && - commandManager.GetHandlerAssemblyName(cInfo.Key, cInfo.Value) == plugin.Manifest.InternalName); + var commands = commandManager.GetHandlersByWorkingPluginId(plugin.EffectiveWorkingPluginId) + .Where(x => x.CommandInfo.ShowInHelp) + .ToList(); - if (commands.Any()) + if (commands.Count != 0) { ImGui.Dummy(ImGuiHelpers.ScaledVector2(10f, 10f)); foreach (var command in commands - .OrderBy(cInfo => cInfo.Value.DisplayOrder) - .ThenBy(cInfo => cInfo.Key)) + .OrderBy(cInfo => cInfo.CommandInfo.DisplayOrder) + .ThenBy(cInfo => cInfo.Command)) { - ImGuiHelpers.SafeTextWrapped($"{command.Key} → {command.Value.HelpMessage}"); + ImGuiHelpers.SafeTextWrapped($"{command.Command} → {command.CommandInfo.HelpMessage}"); } ImGuiHelpers.ScaledDummy(3); diff --git a/Dalamud/Plugin/Internal/PluginValidator.cs b/Dalamud/Plugin/Internal/PluginValidator.cs index b2cbe55202..e3553a4802 100644 --- a/Dalamud/Plugin/Internal/PluginValidator.cs +++ b/Dalamud/Plugin/Internal/PluginValidator.cs @@ -12,7 +12,7 @@ namespace Dalamud.Plugin.Internal; internal static class PluginValidator { private static readonly char[] LineSeparator = new[] { ' ', '\n', '\r' }; - + /// /// Represents the severity of a validation problem. /// @@ -22,18 +22,18 @@ public enum ValidationSeverity /// The problem is informational. /// Information, - + /// /// The problem is a warning. /// Warning, - + /// /// The problem is fatal. /// Fatal, } - + /// /// Represents a validation problem. /// @@ -60,42 +60,43 @@ public interface IValidationProblem public static IReadOnlyList CheckForProblems(LocalDevPlugin plugin) { var problems = new List(); - + if (!plugin.IsLoaded) throw new InvalidOperationException("Plugin must be loaded to validate."); - + if (!plugin.DalamudInterface!.LocalUiBuilder.HasConfigUi) problems.Add(new NoConfigUiProblem()); - + if (!plugin.DalamudInterface.LocalUiBuilder.HasMainUi) problems.Add(new NoMainUiProblem()); var cmdManager = Service.Get(); - - foreach (var cmd in cmdManager.GetHandlersByAssemblyName(plugin.InternalName).Where(c => c.Key.CommandInfo.ShowInHelp)) + + foreach (var cmd in cmdManager.GetHandlersByWorkingPluginId(plugin.EffectiveWorkingPluginId) + .Where(c => c.CommandInfo.ShowInHelp)) { - if (string.IsNullOrEmpty(cmd.Key.CommandInfo.HelpMessage)) - problems.Add(new CommandWithoutHelpTextProblem(cmd.Value)); + if (string.IsNullOrEmpty(cmd.CommandInfo.HelpMessage)) + problems.Add(new CommandWithoutHelpTextProblem(cmd.Command)); } - + if (plugin.Manifest.Tags == null || plugin.Manifest.Tags.Count == 0) problems.Add(new NoTagsProblem()); - + if (string.IsNullOrEmpty(plugin.Manifest.Description) || plugin.Manifest.Description.Split(LineSeparator, StringSplitOptions.RemoveEmptyEntries).Length <= 1) problems.Add(new NoDescriptionProblem()); - + if (string.IsNullOrEmpty(plugin.Manifest.Punchline)) problems.Add(new NoPunchlineProblem()); - + if (string.IsNullOrEmpty(plugin.Manifest.Name)) problems.Add(new NoNameProblem()); - + if (string.IsNullOrEmpty(plugin.Manifest.Author)) problems.Add(new NoAuthorProblem()); - + if (plugin.IsOutdated) problems.Add(new WrongApiLevelProblem()); - + return problems; } @@ -106,11 +107,11 @@ public class NoConfigUiProblem : IValidationProblem { /// public ValidationSeverity Severity => ValidationSeverity.Warning; - + /// public string GetLocalizedDescription() => "The plugin does not register a config UI callback. If you have a settings window or section, please consider registering UiBuilder.OpenConfigUi to open it."; } - + /// /// Representing a problem where the plugin does not have a main UI callback. /// @@ -122,7 +123,7 @@ public class NoMainUiProblem : IValidationProblem /// public string GetLocalizedDescription() => "The plugin does not register a main UI callback. If your plugin has a window that could be considered the main entrypoint to its features, please consider registering UiBuilder.OpenMainUi to open the plugin's main window."; } - + /// /// Representing a problem where a command does not have a help text. /// @@ -147,7 +148,7 @@ public class NoTagsProblem : IValidationProblem /// public string GetLocalizedDescription() => "Your plugin does not have any tags in its manifest. Please consider adding some to make it easier for users to find your plugin in the installer."; } - + /// /// Representing a problem where a plugin does not have a description in its manifest. /// @@ -159,7 +160,7 @@ public class NoDescriptionProblem : IValidationProblem /// public string GetLocalizedDescription() => "Your plugin does not have a description in its manifest, or it is very terse. Please consider adding one to give users more information about your plugin."; } - + /// /// Representing a problem where a plugin has no punchline in its manifest. /// @@ -171,7 +172,7 @@ public class NoPunchlineProblem : IValidationProblem /// public string GetLocalizedDescription() => "Your plugin does not have a punchline in its manifest. Please consider adding one to give users a quick overview of what your plugin does."; } - + /// /// Representing a problem where a plugin has no name in its manifest. /// @@ -183,7 +184,7 @@ public class NoNameProblem : IValidationProblem /// public string GetLocalizedDescription() => "Your plugin does not have a name in its manifest."; } - + /// /// Representing a problem where a plugin has no author in its manifest. /// diff --git a/Dalamud/Plugin/Services/ICommandManager.cs b/Dalamud/Plugin/Services/ICommandManager.cs index a6bc4763f8..a2cf09ca82 100644 --- a/Dalamud/Plugin/Services/ICommandManager.cs +++ b/Dalamud/Plugin/Services/ICommandManager.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using Dalamud.Game.Command; @@ -37,6 +38,19 @@ public interface ICommandManager /// If adding was successful. public bool AddHandler(string command, CommandInfo info); + /// + /// Register a chat command. Arguments to the command are parsed for you, based on the arguments to the function + /// passed into the parameter. + /// + /// The name of the command. + /// The help message shown to users in chat or the installer. + /// The function to be called when the chat command is executed. Arguments to the command are derived from the parameters of this function. + /// Whether or not this command should be shown to users. + /// The display order of this command. Defaults to alphabetical ordering. + /// If adding was successful. + [Experimental("Dalamud001")] + public bool AddCommand(string commandName, string helpMessage, Delegate func, bool showInHelp = true, int displayOrder = -1); + /// /// Remove a command from the command handlers. ///