From 5ec96f0444275049e167b1832e108793410cdb7b Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 17:58:41 -0700 Subject: [PATCH 001/119] Start command service implementation. --- src/TShock/Commands/TShockCommandService.cs | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/TShock/Commands/TShockCommandService.cs diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs new file mode 100644 index 000000000..41a70c20c --- /dev/null +++ b/src/TShock/Commands/TShockCommandService.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Reflection; +using Orion; +using Orion.Events; +using TShock.Events.Commands; + +namespace TShock.Commands { + internal sealed class TShockCommandService : OrionService, ICommandService { + private readonly ISet _commands = new HashSet(); + + public EventHandlerCollection? CommandRegister { get; set; } + public EventHandlerCollection? CommandExecute { get; set; } + public EventHandlerCollection? CommandUnregister { get; set; } + + public IReadOnlyCollection RegisterCommands(object obj) { + throw new NotImplementedException(); + } + + public IReadOnlyCollection RegisterCommands(Type type) { + throw new NotImplementedException(); + } + + public IReadOnlyCollection FindCommands(string commandName, params string[] commandSubNames) { + throw new NotImplementedException(); + } + + public bool UnregisterCommand(ICommand command) { + throw new NotImplementedException(); + } + + private ICommand RegisterCommand(object commandHandlerObject, MethodBase commandHandler) { + throw new NotImplementedException(); + } + } +} From 29265ef65acd111c71b678c05effb6224e3c23e5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 18:00:02 -0700 Subject: [PATCH 002/119] Add Pure contract to FindCommands. --- src/TShock/Commands/ICommandService.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 4d0d1e53a..1522f8f2e 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.Contracts; using Orion; using Orion.Events; using TShock.Events.Commands; @@ -69,6 +70,7 @@ public interface ICommandService : IService { /// /// or are null. /// + [Pure] IReadOnlyCollection FindCommands(string commandName, params string[] commandSubNames); /// From 14e4b6eed86b6c57017fd9079c96ac3d0ed9334a Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 18:56:16 -0700 Subject: [PATCH 003/119] Add JetBrains.Annotations. --- src/TShock/TShock.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/TShock/TShock.csproj b/src/TShock/TShock.csproj index 2441febd0..f8da9467c 100644 --- a/src/TShock/TShock.csproj +++ b/src/TShock/TShock.csproj @@ -32,6 +32,7 @@ + From 5e0e69233f90cb4059d9fc72e8585cdac10b9c73 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 18:56:32 -0700 Subject: [PATCH 004/119] Add [MeansImplicitUse] to CommandHandlerAttribute. --- src/TShock/Commands/CommandHandlerAttribute.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 4e5f39a21..433988513 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using JetBrains.Annotations; namespace TShock.Commands { /// @@ -24,6 +25,7 @@ namespace TShock.Commands { /// a method mutiple times to provide aliasing. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + [MeansImplicitUse] public sealed class CommandHandlerAttribute : Attribute { /// /// Gets the command's name. This includes the command's namespace: e.g., "tshock:kick". From e2af163ab49f430f3310b8e1b9bb53614e0ef1b5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 19:20:40 -0700 Subject: [PATCH 005/119] Implement TShockCommandService and tests. --- src/TShock/Commands/ICommand.cs | 2 +- src/TShock/Commands/ICommandService.cs | 33 +--- src/TShock/Commands/TShockCommand.cs | 49 ++++++ src/TShock/Commands/TShockCommandService.cs | 41 +++-- .../Commands/TShockCommandServiceTests.cs | 150 ++++++++++++++++++ 5 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 src/TShock/Commands/TShockCommand.cs create mode 100644 tests/TShock.Tests/Commands/TShockCommandServiceTests.cs diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index ed63a1324..6fad66303 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -35,7 +35,7 @@ public interface ICommand { IEnumerable SubNames { get; } /// - /// Gets the object associated with the command's handler. If null, then the command handler is static. + /// Gets the object associated with the command's handler. /// object? HandlerObject { get; } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 1522f8f2e..68a1d5ef9 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -17,7 +17,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.Contracts; using Orion; using Orion.Events; using TShock.Events.Commands; @@ -27,10 +26,15 @@ namespace TShock.Commands { /// Represents a service that manages commands. Provides command-related hooks and methods. /// public interface ICommandService : IService { + /// + /// Gets the registered commands. + /// + IEnumerable RegisteredCommands { get; } + /// /// Gets or sets the event handlers that occur when registering a command. /// - EventHandlerCollection? CommandRegister { get; set; } + EventHandlerCollection? CommandRegister { get; set; } /// /// Gets or sets the event handlers that occur when executing a command. @@ -40,7 +44,7 @@ public interface ICommandService : IService { /// /// Gets or sets the event handlers that occur when unregistering a command. /// - EventHandlerCollection? CommandUnregister { get; set; } + EventHandlerCollection? CommandUnregister { get; set; } /// /// Registers and returns the commands defined with the given object's command handlers. @@ -50,29 +54,6 @@ public interface ICommandService : IService { /// is null. IReadOnlyCollection RegisterCommands(object obj); - /// - /// Registers and returns the commands defined with the given type's static command handlers. - /// - /// The type. - /// The resulting commands. - /// is null. - IReadOnlyCollection RegisterCommands(Type type); - - /// - /// Returns all commands with the given command name and sub-names. - /// - /// The command name. - /// The command sub-names. - /// The commands with the given command name. - /// - /// An element of is null. - /// - /// - /// or are null. - /// - [Pure] - IReadOnlyCollection FindCommands(string commandName, params string[] commandSubNames); - /// /// Unregisters the given command and returns a value indicating success. /// diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs new file mode 100644 index 000000000..f2f5b7ec1 --- /dev/null +++ b/src/TShock/Commands/TShockCommand.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; + +namespace TShock.Commands { + internal class TShockCommand : ICommand { + private readonly ICommandService _commandService; + private readonly CommandHandlerAttribute _attribute; + + public string Name => _attribute.CommandName; + public IEnumerable SubNames => _attribute.CommandSubNames; + public object? HandlerObject { get; } + public MethodBase Handler { get; } + + public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object? handlerObject, + MethodBase handler) { + Debug.Assert(commandService != null, "commandService != null"); + Debug.Assert(attribute != null, "attribute != null"); + Debug.Assert(handler != null, "handler != null"); + + _commandService = commandService; + _attribute = attribute; + HandlerObject = handlerObject; + Handler = handler; + } + + public void Invoke(ICommandSender sender, string input) { + throw new NotImplementedException(); + } + } +} diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 41a70c20c..8fee05f52 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -17,37 +17,52 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using Orion; using Orion.Events; +using Orion.Events.Extensions; using TShock.Events.Commands; namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { private readonly ISet _commands = new HashSet(); - public EventHandlerCollection? CommandRegister { get; set; } + public IEnumerable RegisteredCommands => new HashSet(_commands); + public EventHandlerCollection? CommandRegister { get; set; } public EventHandlerCollection? CommandExecute { get; set; } - public EventHandlerCollection? CommandUnregister { get; set; } + public EventHandlerCollection? CommandUnregister { get; set; } public IReadOnlyCollection RegisterCommands(object obj) { - throw new NotImplementedException(); - } + if (obj is null) throw new ArgumentNullException(nameof(obj)); - public IReadOnlyCollection RegisterCommands(Type type) { - throw new NotImplementedException(); - } + var registeredCommands = new List(); + + void RegisterCommand(ICommand command) { + var args = new CommandRegisterEventArgs(command); + CommandRegister?.Invoke(this, args); + if (args.IsCanceled()) return; - public IReadOnlyCollection FindCommands(string commandName, params string[] commandSubNames) { - throw new NotImplementedException(); + _commands.Add(command); + registeredCommands.Add(command); + } + + foreach (var command in obj.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SelectMany(m => m.GetCustomAttributes(), + (handler, attribute) => (handler, attribute)) + .Select(t => new TShockCommand(this, t.attribute, obj, t.handler))) { + RegisterCommand(command); + } + + return registeredCommands; } public bool UnregisterCommand(ICommand command) { - throw new NotImplementedException(); - } + if (command is null) throw new ArgumentNullException(nameof(command)); - private ICommand RegisterCommand(object commandHandlerObject, MethodBase commandHandler) { - throw new NotImplementedException(); + var args = new CommandUnregisterEventArgs(command); + CommandUnregister?.Invoke(this, args); + return !args.IsCanceled() && _commands.Remove(command); } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs new file mode 100644 index 000000000..8d35b9494 --- /dev/null +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using Orion.Events.Extensions; +using Xunit; + +namespace TShock.Commands { + public class TShockCommandServiceTests : IDisposable { + private readonly ICommandService _commandService; + + public TShockCommandServiceTests() { + _commandService = new TShockCommandService(); + } + + public void Dispose() { + _commandService.Dispose(); + } + + [Fact] + public void RegisterCommands_IsCorrect() { + var testClass = new TestClass(); + + var commands = _commandService.RegisterCommands(testClass).ToList(); + + _commandService.RegisteredCommands.Should().BeEquivalentTo(commands); + commands.Should().HaveCount(3); + foreach (var command in commands) { + command.HandlerObject.Should().BeSameAs(testClass); + command.Name.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); + } + } + + [Fact] + public void RegisterCommands_NullObj_ThrowsArgumentNullException() { + Func> func = () => _commandService.RegisterCommands(null); + + func.Should().Throw(); + } + + [Fact] + public void UnregisterCommand_IsCorrect() { + var testClass = new TestClass(); + var commands = _commandService.RegisterCommands(testClass).ToList(); + var command = commands[0]; + + _commandService.UnregisterCommand(command).Should().BeTrue(); + + _commandService.RegisteredCommands.Should().NotContain(command); + } + + [Fact] + public void UnregisterCommand_NonexistentCommand_ReturnsFalse() { + var command = new Mock().Object; + + _commandService.UnregisterCommand(command).Should().BeFalse(); + } + + [Fact] + public void UnregisterCommand_NullCommand_ThrowsArgumentNullException() { + Func func = () => _commandService.UnregisterCommand(null); + + func.Should().Throw(); + } + + [Fact] + public void CommandRegister_IsTriggered() { + var isRun = false; + var testClass = new TestClass(); + _commandService.CommandRegister += (sender, args) => { + isRun = true; + args.Command.HandlerObject.Should().BeSameAs(testClass); + args.Command.Name.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); + }; + + _commandService.RegisterCommands(testClass); + + isRun.Should().BeTrue(); + } + + [Fact] + public void CommandRegister_Canceled_IsCorrect() { + var testClass = new TestClass(); + _commandService.CommandRegister += (sender, args) => { + args.Cancel(); + }; + + _commandService.RegisterCommands(testClass).Should().BeEmpty(); + } + + [Fact] + public void CommandUnregister_IsTriggered() { + var isRun = false; + var testClass = new TestClass(); + var commands = _commandService.RegisterCommands(testClass).ToList(); + var command = commands[0]; + _commandService.CommandUnregister += (sender, args) => { + isRun = true; + args.Command.Should().BeSameAs(command); + }; + + _commandService.UnregisterCommand(command); + + isRun.Should().BeTrue(); + } + + [Fact] + public void CommandUnregister_Canceled_IsCorrect() { + var testClass = new TestClass(); + var commands = _commandService.RegisterCommands(testClass).ToList(); + var command = commands[0]; + _commandService.CommandUnregister += (sender, args) => { + args.Cancel(); + }; + + _commandService.UnregisterCommand(command).Should().BeFalse(); + + _commandService.RegisteredCommands.Should().Contain(command); + } + + private class TestClass { + [CommandHandler("tshock_tests:test")] + public void TestCommand() { } + + [CommandHandler("tshock_tests:test2", "sub1")] + public void TestCommand2_Sub1() { } + + [CommandHandler("tshock_tests:test2", "sub2")] + public void TestCommand2_Sub2() { } + } + } +} From 9bf28b47d4fc0af78e3227edfa7ed102a745e13c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:00:50 -0700 Subject: [PATCH 006/119] Add IArgumentParser interface and Int32Parser implementation. --- .../Commands/Parsing/IArgumentParser.cs | 36 +++++++++++ src/TShock/Commands/Parsing/Int32Parser.cs | 53 ++++++++++++++++ src/TShock/Commands/Parsing/ParseException.cs | 46 ++++++++++++++ src/TShock/Commands/TShockCommand.cs | 9 +++ src/TShock/Properties/Resources.Designer.cs | 18 ++++++ src/TShock/Properties/Resources.resx | 6 ++ .../Commands/Parsing/Int32ParserTests.cs | 60 +++++++++++++++++++ 7 files changed, 228 insertions(+) create mode 100644 src/TShock/Commands/Parsing/IArgumentParser.cs create mode 100644 src/TShock/Commands/Parsing/Int32Parser.cs create mode 100644 src/TShock/Commands/Parsing/ParseException.cs create mode 100644 tests/TShock.Tests/Commands/Parsing/Int32ParserTests.cs diff --git a/src/TShock/Commands/Parsing/IArgumentParser.cs b/src/TShock/Commands/Parsing/IArgumentParser.cs new file mode 100644 index 000000000..c8420c9c5 --- /dev/null +++ b/src/TShock/Commands/Parsing/IArgumentParser.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Commands.Parsing { + /// + /// Provides parsing support for a type. + /// + /// The parse type. + public interface IArgumentParser { + /// + /// Parses the given input and returns a corresponding instance of the parse type along with what the next + /// input should be. + /// + /// The input. + /// The next input. + /// A corresponding instance of the parse type. + /// The input could not be parsed properly. + TParse Parse(ReadOnlySpan input, out ReadOnlySpan nextInput); + } +} diff --git a/src/TShock/Commands/Parsing/Int32Parser.cs b/src/TShock/Commands/Parsing/Int32Parser.cs new file mode 100644 index 000000000..5a0d88ae5 --- /dev/null +++ b/src/TShock/Commands/Parsing/Int32Parser.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using TShock.Properties; + +namespace TShock.Commands.Parsing { + internal sealed class Int32Parser : IArgumentParser { + public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { + // Scan until we find some non-whitespace character. + var start = 0; + while (start < input.Length) { + if (!char.IsWhiteSpace(input[start])) break; + + ++start; + } + + // Now scan until we find some whitespace character. + var end = start; + while (end < input.Length) { + if (char.IsWhiteSpace(input[end])) break; + + ++end; + } + + var parse = input[start..end]; + nextInput = input[end..]; + + // Calling Parse here instead of TryParse allows us to give better error messages. + try { + return int.Parse(parse); + } catch (FormatException ex) { + throw new ParseException(string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); + } catch (OverflowException ex) { + throw new ParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); + } + } + } +} diff --git a/src/TShock/Commands/Parsing/ParseException.cs b/src/TShock/Commands/Parsing/ParseException.cs new file mode 100644 index 000000000..fbe552f4a --- /dev/null +++ b/src/TShock/Commands/Parsing/ParseException.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Commands.Parsing { + /// + /// The exception thrown when a command input cannot be parsed. + /// + [Serializable, ExcludeFromCodeCoverage] + public class ParseException : Exception { + /// + /// Initializes a new instance of the class. + /// + public ParseException() { } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message. + public ParseException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message + /// and inner exception. + /// + /// The message. + /// The inner exception. + public ParseException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index f2f5b7ec1..d1b899eaa 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -19,6 +19,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.Reflection; +using Orion.Events.Extensions; +using TShock.Events.Commands; namespace TShock.Commands { internal class TShockCommand : ICommand { @@ -43,6 +45,13 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att } public void Invoke(ICommandSender sender, string input) { + if (sender is null) throw new ArgumentNullException(nameof(sender)); + if (input is null) throw new ArgumentNullException(nameof(input)); + + var args = new CommandExecuteEventArgs(this, sender, input); + _commandService.CommandExecute?.Invoke(this, args); + if (args.IsCanceled()) return; + throw new NotImplementedException(); } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index fb0ec952f..2775802e2 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -59,5 +59,23 @@ internal Resources() { resourceCulture = value; } } + + /// + /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. + /// + internal static string Int32Parser_IntegerOutOfRange { + get { + return ResourceManager.GetString("Int32Parser_IntegerOutOfRange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "{0}" is not a valid integer.. + /// + internal static string Int32Parser_InvalidInteger { + get { + return ResourceManager.GetString("Int32Parser_InvalidInteger", resourceCulture); + } + } } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 1af7de150..4f3d9d04c 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,4 +117,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + "{0}" is a number that is out of range of an integer. + + + "{0}" is not a valid integer. + \ No newline at end of file diff --git a/tests/TShock.Tests/Commands/Parsing/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsing/Int32ParserTests.cs new file mode 100644 index 000000000..1e973de08 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsing/Int32ParserTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsing { + public class Int32ParserTests { + [Theory] + [InlineData("1234", 1234, "")] + [InlineData("+1234", 1234, "")] + [InlineData("000", 0, "")] + [InlineData("-1234", -1234, "")] + [InlineData("123 test", 123, " test")] + [InlineData(" 123", 123, "")] + public void Parse_IsCorrect(string input, int expected, string expectedNextInput) { + var parser = new Int32Parser(); + + parser.Parse(input, out var nextInput).Should().Be(expected); + + nextInput.ToString().Should().Be(expectedNextInput); + } + + [Theory] + [InlineData("2147483648")] + [InlineData("-2147483649")] + public void Parse_IntegerOutOfRange_ThrowsParseException(string input) { + var parser = new Int32Parser(); + Func func = () => parser.Parse(input, out _); + + func.Should().Throw().WithInnerException(); + } + + [Theory] + [InlineData("aaa")] + [InlineData("123a")] + [InlineData("1.0")] + public void Parse_InvalidInteger_ThrowsParseException(string input) { + var parser = new Int32Parser(); + Func func = () => parser.Parse(input, out _); + + func.Should().Throw().WithInnerException(); + } + } +} From 07ca5fe0075ca79f495de7b356362b43d4d00670 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:01:16 -0700 Subject: [PATCH 007/119] Rename Parsing to Parsers. --- src/TShock/Commands/{Parsing => Parsers}/IArgumentParser.cs | 0 src/TShock/Commands/{Parsing => Parsers}/Int32Parser.cs | 0 src/TShock/Commands/{Parsing => Parsers}/ParseException.cs | 0 .../Commands/{Parsing => Parsers}/Int32ParserTests.cs | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/TShock/Commands/{Parsing => Parsers}/IArgumentParser.cs (100%) rename src/TShock/Commands/{Parsing => Parsers}/Int32Parser.cs (100%) rename src/TShock/Commands/{Parsing => Parsers}/ParseException.cs (100%) rename tests/TShock.Tests/Commands/{Parsing => Parsers}/Int32ParserTests.cs (100%) diff --git a/src/TShock/Commands/Parsing/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs similarity index 100% rename from src/TShock/Commands/Parsing/IArgumentParser.cs rename to src/TShock/Commands/Parsers/IArgumentParser.cs diff --git a/src/TShock/Commands/Parsing/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs similarity index 100% rename from src/TShock/Commands/Parsing/Int32Parser.cs rename to src/TShock/Commands/Parsers/Int32Parser.cs diff --git a/src/TShock/Commands/Parsing/ParseException.cs b/src/TShock/Commands/Parsers/ParseException.cs similarity index 100% rename from src/TShock/Commands/Parsing/ParseException.cs rename to src/TShock/Commands/Parsers/ParseException.cs diff --git a/tests/TShock.Tests/Commands/Parsing/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs similarity index 100% rename from tests/TShock.Tests/Commands/Parsing/Int32ParserTests.cs rename to tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs From 31ad6feb99e70d70ae35565ca582ca0336370e56 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:01:34 -0700 Subject: [PATCH 008/119] Adjust Parsing to Parsers namespace. --- src/TShock/Commands/Parsers/IArgumentParser.cs | 2 +- src/TShock/Commands/Parsers/Int32Parser.cs | 2 +- src/TShock/Commands/Parsers/ParseException.cs | 2 +- tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index c8420c9c5..2beac6c79 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -17,7 +17,7 @@ using System; -namespace TShock.Commands.Parsing { +namespace TShock.Commands.Parsers { /// /// Provides parsing support for a type. /// diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 5a0d88ae5..eb13ad194 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -18,7 +18,7 @@ using System; using TShock.Properties; -namespace TShock.Commands.Parsing { +namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { // Scan until we find some non-whitespace character. diff --git a/src/TShock/Commands/Parsers/ParseException.cs b/src/TShock/Commands/Parsers/ParseException.cs index fbe552f4a..47467637b 100644 --- a/src/TShock/Commands/Parsers/ParseException.cs +++ b/src/TShock/Commands/Parsers/ParseException.cs @@ -18,7 +18,7 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace TShock.Commands.Parsing { +namespace TShock.Commands.Parsers { /// /// The exception thrown when a command input cannot be parsed. /// diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index 1e973de08..bcd2dbb64 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -19,7 +19,7 @@ using FluentAssertions; using Xunit; -namespace TShock.Commands.Parsing { +namespace TShock.Commands.Parsers { public class Int32ParserTests { [Theory] [InlineData("1234", 1234, "")] From ebc683e4013626e3805a90ff206346aaa8e0d930 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:32:09 -0700 Subject: [PATCH 009/119] Add StringParser. --- src/TShock/Commands/Parsers/StringParser.cs | 82 +++++++++++++++++++ src/TShock/Properties/Resources.Designer.cs | 18 ++++ src/TShock/Properties/Resources.resx | 6 ++ .../Commands/Parsers/StringParserTests.cs | 56 +++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 src/TShock/Commands/Parsers/StringParser.cs create mode 100644 tests/TShock.Tests/Commands/Parsers/StringParserTests.cs diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs new file mode 100644 index 000000000..31bcec96b --- /dev/null +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Text; +using TShock.Properties; + +namespace TShock.Commands.Parsers { + // It'd be nice to return ReadOnlySpan, but because of escape characters, we have to return copies. + internal sealed class StringParser : IArgumentParser { + public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { + // Scan until we find some non-whitespace character. + var start = 0; + while (start < input.Length) { + if (!char.IsWhiteSpace(input[start])) break; + + ++start; + } + + // Begin building our string character-by-character. + var builder = new StringBuilder(); + var end = start; + var isInQuotes = false; + while (end < input.Length) { + var c = input[end]; + + // Handle quotes. + if (c == '"') { + ++end; + if (isInQuotes) break; + + isInQuotes = true; + continue; + } + + // Handle escape characters. + if (c == '\\') { + if (++end >= input.Length) throw new ParseException(Resources.StringParser_EndOfInput); + + var nextC = input[end]; + if (nextC == '"' || nextC == '\\' || char.IsWhiteSpace(nextC)) { + builder.Append(nextC); + } else if (nextC == 't') { + builder.Append('\t'); + } else if (nextC == 'n') { + builder.Append('\n'); + } else { + throw new ParseException(string.Format(Resources.StringParser_UnexpectedEscape, nextC)); + } + + ++end; + continue; + } + + if (char.IsWhiteSpace(c)) { + if (!isInQuotes) break; + } + + builder.Append(c); + ++end; + } + + nextInput = input[end..]; + + return builder.ToString(); + } + } +} diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 2775802e2..20b17026d 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -77,5 +77,23 @@ internal static string Int32Parser_InvalidInteger { return ResourceManager.GetString("Int32Parser_InvalidInteger", resourceCulture); } } + + /// + /// Looks up a localized string similar to Reached end of input.. + /// + internal static string StringParser_EndOfInput { + get { + return ResourceManager.GetString("StringParser_EndOfInput", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unexpected escape character "\{0}".. + /// + internal static string StringParser_UnexpectedEscape { + get { + return ResourceManager.GetString("StringParser_UnexpectedEscape", resourceCulture); + } + } } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 4f3d9d04c..dab56c439 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -123,4 +123,10 @@ "{0}" is not a valid integer. + + Reached end of input. + + + Unexpected escape character "\{0}". + \ No newline at end of file diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs new file mode 100644 index 000000000..deba40630 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class StringParserTests { + [Theory] + [InlineData("", "", "")] + [InlineData("test", "test", "")] + [InlineData("test abc", "test", " abc")] + [InlineData(" test", "test", "")] + [InlineData(@"""test""", "test", "")] + [InlineData(@"""test abc def""", "test abc def", "")] + [InlineData(@"""test abc"" def ghi", "test abc", " def ghi")] + [InlineData(@"test\ abc", "test abc", "")] + [InlineData(@"test\ abc def", "test abc", " def")] + [InlineData(@"\\", @"\", "")] + [InlineData(@"\""", @"""", "")] + [InlineData(@"\t", "\t", "")] + [InlineData(@"\n", "\n", "")] + public void Parse_IsCorrect(string input, string expected, string expectedNextInput) { + var parser = new StringParser(); + + parser.Parse(input, out var nextInput).Should().Be(expected); + + nextInput.ToString().Should().Be(expectedNextInput); + } + + [Theory] + [InlineData(@"\")] + [InlineData(@"\a")] + public void Parse_EscapeReachesEnd_ThrowsParseException(string input) { + var parser = new StringParser(); + Func func = () => parser.Parse(input, out _); + + func.Should().Throw(); + } + } +} From bc99d3395ab1afd19dd91aef02a97669295e50e2 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:36:33 -0700 Subject: [PATCH 010/119] Add non-generic IArgumentParser. --- .../Commands/Parsers/IArgumentParser.cs | 12 +++---- .../Commands/Parsers/IArgumentParser`1.cs | 36 +++++++++++++++++++ src/TShock/Commands/Parsers/Int32Parser.cs | 3 ++ src/TShock/Commands/Parsers/StringParser.cs | 3 ++ 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/TShock/Commands/Parsers/IArgumentParser`1.cs diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 2beac6c79..19f8727db 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -19,18 +19,16 @@ namespace TShock.Commands.Parsers { /// - /// Provides parsing support for a type. + /// Provides parsing support. /// - /// The parse type. - public interface IArgumentParser { + public interface IArgumentParser { /// - /// Parses the given input and returns a corresponding instance of the parse type along with what the next - /// input should be. + /// Parses the given input and returns a corresponding object along with what the next input should be. /// /// The input. /// The next input. - /// A corresponding instance of the parse type. + /// A corresponding object. /// The input could not be parsed properly. - TParse Parse(ReadOnlySpan input, out ReadOnlySpan nextInput); + object Parse(ReadOnlySpan input, out ReadOnlySpan nextInput); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs new file mode 100644 index 000000000..8b92797e5 --- /dev/null +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Commands.Parsers { + /// + /// Provides parsing support for a type. + /// + /// The parse type. + public interface IArgumentParser : IArgumentParser { + /// + /// Parses the given input and returns a corresponding instance of the parse type along with what the next + /// input should be. + /// + /// The input. + /// The next input. + /// A corresponding instance of the parse type. + /// The input could not be parsed properly. + new TParse Parse(ReadOnlySpan input, out ReadOnlySpan nextInput); + } +} diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index eb13ad194..0f0edb7e9 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -49,5 +49,8 @@ public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { throw new ParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } + + object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) => + Parse(input, out nextInput); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 31bcec96b..bfc6ef2d5 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -78,5 +78,8 @@ public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) return builder.ToString(); } + + object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) => + Parse(input, out nextInput); } } From 3ea5469b071b7de51eabf451d103b15c2e1c58ef Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:43:57 -0700 Subject: [PATCH 011/119] Add RegisterParser to ICommandService interface. --- src/TShock/Commands/ICommandService.cs | 16 +++++++++ src/TShock/Commands/TShockCommandService.cs | 10 ++++++ .../Commands/TShockCommandServiceTests.cs | 35 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 68a1d5ef9..317621fe7 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using Orion; using Orion.Events; +using TShock.Commands.Parsers; using TShock.Events.Commands; namespace TShock.Commands { @@ -31,6 +32,11 @@ public interface ICommandService : IService { /// IEnumerable RegisteredCommands { get; } + /// + /// Gets the registered parsers. + /// + IDictionary RegisteredParsers { get; } + /// /// Gets or sets the event handlers that occur when registering a command. /// @@ -54,6 +60,16 @@ public interface ICommandService : IService { /// is null. IReadOnlyCollection RegisterCommands(object obj); + /// + /// Registers the given parser as the definitive parser for the parse type. + /// + /// The parse type. + /// The parser. + /// + /// or are null. + /// + void RegisterParser(Type parseType, IArgumentParser parser); + /// /// Unregisters the given command and returns a value indicating success. /// diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 8fee05f52..8760d48ce 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -19,16 +19,20 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using JetBrains.Annotations; using Orion; using Orion.Events; using Orion.Events.Extensions; +using TShock.Commands.Parsers; using TShock.Events.Commands; namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { private readonly ISet _commands = new HashSet(); + private readonly IDictionary _parsers = new Dictionary(); public IEnumerable RegisteredCommands => new HashSet(_commands); + public IDictionary RegisteredParsers => new Dictionary(_parsers); public EventHandlerCollection? CommandRegister { get; set; } public EventHandlerCollection? CommandExecute { get; set; } public EventHandlerCollection? CommandUnregister { get; set; } @@ -57,6 +61,12 @@ void RegisterCommand(ICommand command) { return registeredCommands; } + public void RegisterParser([NotNull] Type parseType, [NotNull] IArgumentParser parser) { + if (parseType is null) throw new ArgumentNullException(nameof(parseType)); + + _parsers[parseType] = parser ?? throw new ArgumentNullException(nameof(parser)); + } + public bool UnregisterCommand(ICommand command) { if (command is null) throw new ArgumentNullException(nameof(command)); diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 8d35b9494..401b8b4f0 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -21,6 +21,7 @@ using FluentAssertions; using Moq; using Orion.Events.Extensions; +using TShock.Commands.Parsers; using Xunit; namespace TShock.Commands { @@ -36,12 +37,29 @@ public void Dispose() { } [Fact] - public void RegisterCommands_IsCorrect() { + public void RegisteredCommands_Get_IsCorrect() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); _commandService.RegisteredCommands.Should().BeEquivalentTo(commands); + } + + [Fact] + public void RegisteredParsers_Get_IsCorrect() { + var parser = new Mock().Object; + _commandService.RegisterParser(typeof(object), parser); + + _commandService.RegisteredParsers.Should().ContainKey(typeof(object)); + _commandService.RegisteredParsers.Should().ContainValue(parser); + } + + [Fact] + public void RegisterCommands_IsCorrect() { + var testClass = new TestClass(); + + var commands = _commandService.RegisterCommands(testClass).ToList(); + commands.Should().HaveCount(3); foreach (var command in commands) { command.HandlerObject.Should().BeSameAs(testClass); @@ -56,6 +74,21 @@ public void RegisterCommands_NullObj_ThrowsArgumentNullException() { func.Should().Throw(); } + [Fact] + public void RegisterParser_NullType_ThrowsArgumentNullException() { + var parser = new Mock().Object; + Action action = () => _commandService.RegisterParser(null, parser); + + action.Should().Throw(); + } + + [Fact] + public void RegisterParser_NullParser_ThrowsArgumentNullException() { + Action action = () => _commandService.RegisterParser(typeof(object), null); + + action.Should().Throw(); + } + [Fact] public void UnregisterCommand_IsCorrect() { var testClass = new TestClass(); From 043befc1e1ac28ac1324d39805bf016375aa8ee2 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 20:57:33 -0700 Subject: [PATCH 012/119] Add ParseOptionsAttribute. --- src/TShock/Commands/Parsers/Int32Parser.cs | 4 +- .../Commands/Parsers/ParseOptionsAttribute.cs | 46 +++++++++++++++++++ src/TShock/Commands/Parsers/StringParser.cs | 2 + .../Parsers/ParseOptionsAttributeTests.cs | 38 +++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/TShock/Commands/Parsers/ParseOptionsAttribute.cs create mode 100644 tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 0f0edb7e9..e01ce8b66 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Diagnostics.CodeAnalysis; using TShock.Properties; namespace TShock.Commands.Parsers { @@ -49,7 +50,8 @@ public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { throw new ParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } - + + [ExcludeFromCodeCoverage] object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) => Parse(input, out nextInput); } diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs new file mode 100644 index 000000000..5cac0d250 --- /dev/null +++ b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace TShock.Commands.Parsers { + /// + /// An attribute that can be applied to a parameter to specify options for parsing that specific parameter. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class ParseOptionsAttribute : Attribute { + /// + /// Gets the options. + /// + public ISet Options { get; } + + /// + /// Initializes a new instance of the class with the specified options. + /// + /// The options. + /// is null. + public ParseOptionsAttribute(params string[] options) { + if (options is null) throw new ArgumentNullException(nameof(options)); + + var optionsSet = new HashSet(); + optionsSet.UnionWith(options); + Options = optionsSet; + } + } +} diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index bfc6ef2d5..7490bbcf0 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Diagnostics.CodeAnalysis; using System.Text; using TShock.Properties; @@ -79,6 +80,7 @@ public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) return builder.ToString(); } + [ExcludeFromCodeCoverage] object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) => Parse(input, out nextInput); } diff --git a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs new file mode 100644 index 000000000..5a9907ea9 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class ParseOptionsAttributeTests { + [Fact] + public void Ctor_NullOptions_ThrowsArgumentNullException() { + Func func = () => new ParseOptionsAttribute(null); + + func.Should().Throw(); + } + + [Fact] + public void Options_Get_IsCorrect() { + var attribute = new ParseOptionsAttribute("test", "test2"); + + attribute.Options.Should().BeEquivalentTo("test", "test2"); + } + } +} From fd36f1142101e726614d271ee64699be49dbc4c2 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 21:07:34 -0700 Subject: [PATCH 013/119] Add ParseOptions to parse a string to the end of the input. --- .../Commands/Parsers/IArgumentParser.cs | 4 ++- .../Commands/Parsers/IArgumentParser`1.cs | 4 ++- src/TShock/Commands/Parsers/Int32Parser.cs | 9 +++--- src/TShock/Commands/Parsers/ParseOptions.cs | 28 +++++++++++++++++++ .../Commands/Parsers/ParseOptionsAttribute.cs | 5 ++-- src/TShock/Commands/Parsers/StringParser.cs | 12 ++++++-- .../Commands/Parsers/StringParserTests.cs | 12 ++++++++ 7 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 src/TShock/Commands/Parsers/ParseOptions.cs diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 19f8727db..53ef81d8f 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; namespace TShock.Commands.Parsers { /// @@ -27,8 +28,9 @@ public interface IArgumentParser { /// /// The input. /// The next input. + /// The parse options. /// A corresponding object. /// The input could not be parsed properly. - object Parse(ReadOnlySpan input, out ReadOnlySpan nextInput); + object Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options = null); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 8b92797e5..6f994c193 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; namespace TShock.Commands.Parsers { /// @@ -29,8 +30,9 @@ public interface IArgumentParser : IArgumentParser { /// /// The input. /// The next input. + /// The parse options. /// A corresponding instance of the parse type. /// The input could not be parsed properly. - new TParse Parse(ReadOnlySpan input, out ReadOnlySpan nextInput); + new TParse Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options = null); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index e01ce8b66..a7089bfa8 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -16,12 +16,13 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using TShock.Properties; namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { - public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { + public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options= null) { // Scan until we find some non-whitespace character. var start = 0; while (start < input.Length) { @@ -50,9 +51,9 @@ public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { throw new ParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } - + [ExcludeFromCodeCoverage] - object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) => - Parse(input, out nextInput); + object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, + ISet? options) => Parse(input, out nextInput, options); } } diff --git a/src/TShock/Commands/Parsers/ParseOptions.cs b/src/TShock/Commands/Parsers/ParseOptions.cs new file mode 100644 index 000000000..78c95efc8 --- /dev/null +++ b/src/TShock/Commands/Parsers/ParseOptions.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +namespace TShock.Commands.Parsers { + /// + /// Provides parse options. These are strings so that parse options can easily be added by consumers. + /// + public static class ParseOptions { + /// + /// An option which forces a string to be parsed to the end of the input. + /// + public const string ToEndOfInput = nameof(ToEndOfInput); + } +} diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs index 5cac0d250..5a0fea0c4 100644 --- a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs +++ b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs @@ -17,7 +17,7 @@ using System; using System.Collections.Generic; -using System.Linq; +using JetBrains.Annotations; namespace TShock.Commands.Parsers { /// @@ -35,7 +35,8 @@ public sealed class ParseOptionsAttribute : Attribute { /// /// The options. /// is null. - public ParseOptionsAttribute(params string[] options) { + public ParseOptionsAttribute([ValueProvider("TShock.Commands.Parsers.ParseOptions")] + params string[] options) { if (options is null) throw new ArgumentNullException(nameof(options)); var optionsSet = new HashSet(); diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 7490bbcf0..86f81d3dd 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; using TShock.Properties; @@ -23,7 +24,12 @@ namespace TShock.Commands.Parsers { // It'd be nice to return ReadOnlySpan, but because of escape characters, we have to return copies. internal sealed class StringParser : IArgumentParser { - public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) { + public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options = null) { + if (options?.Contains(ParseOptions.ToEndOfInput) == true) { + nextInput = default; + return input.ToString(); + } + // Scan until we find some non-whitespace character. var start = 0; while (start < input.Length) { @@ -81,7 +87,7 @@ public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) } [ExcludeFromCodeCoverage] - object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput) => - Parse(input, out nextInput); + object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, + ISet? options) => Parse(input, out nextInput, options); } } diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index deba40630..7af6e3d28 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using FluentAssertions; using Xunit; @@ -52,5 +53,16 @@ public void Parse_EscapeReachesEnd_ThrowsParseException(string input) { func.Should().Throw(); } + + [Fact] + public void Parse_ToEndOfInput_IsCorrect() { + var parser = new StringParser(); + + parser.Parse(@"blah blah ""test"" blah blah", out var nextInput, + new HashSet {ParseOptions.ToEndOfInput}) + .Should().Be(@"blah blah ""test"" blah blah"); + + nextInput.ToString().Should().BeEmpty(); + } } } From 8fe4653eb3ecd4ad01f009f88e02e82ef2c8025b Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 21:16:09 -0700 Subject: [PATCH 014/119] Add strongly-typed RegisterParser extension method. --- .../Extensions/CommandServiceExtensions.cs | 44 +++++++++++++++ .../CommandServiceExtensionsTests.cs | 54 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/TShock/Commands/Extensions/CommandServiceExtensions.cs create mode 100644 tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs diff --git a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs new file mode 100644 index 000000000..0867c54f2 --- /dev/null +++ b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using JetBrains.Annotations; +using TShock.Commands.Parsers; + +namespace TShock.Commands.Extensions { + /// + /// Provides extensions to the interface. + /// + public static class CommandServiceExtensions { + /// + /// Registers the given strongly-typed parser as the definitive parser for the parse type. + /// + /// The parse type. + /// The command service. + /// The parser. + /// + /// or are null. + /// + public static void RegisterParser([NotNull] this ICommandService commandService, + [NotNull] IArgumentParser parser) { + if (commandService is null) throw new ArgumentNullException(nameof(commandService)); + if (parser is null) throw new ArgumentNullException(nameof(parser)); + + commandService.RegisterParser(typeof(TParse), parser); + } + } +} diff --git a/tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs new file mode 100644 index 000000000..2bb0a6910 --- /dev/null +++ b/tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using TShock.Commands.Parsers; +using Xunit; + +namespace TShock.Commands.Extensions { + public class CommandServiceExtensionsTests { + [Fact] + public void RegisterParser1_IsCorrect() { + var parser = new Mock>().Object; + var mockCommandService = new Mock(); + mockCommandService.Setup(cs => cs.RegisterParser(typeof(byte), parser)); + + mockCommandService.Object.RegisterParser(parser); + + mockCommandService.Verify(cs => cs.RegisterParser(typeof(byte), parser)); + mockCommandService.VerifyNoOtherCalls(); + } + + [Fact] + public void RegisterParser1_NullCommandService_ThrowsArgumentNullException() { + var parser = new Mock>().Object; + Action action = () => CommandServiceExtensions.RegisterParser(null, parser); + + action.Should().Throw(); + } + + [Fact] + public void RegisterParser1_NullParser_ThrowsArgumentNullException() { + var commandService = new Mock().Object; + Action action = () => commandService.RegisterParser(null); + + action.Should().Throw(); + } + } +} From f7ea6c30a085ee637fe2142e5487d2f5d353fe28 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 21:17:33 -0700 Subject: [PATCH 015/119] Add parsers in TShockCommandService constructor. --- src/TShock/Commands/TShockCommandService.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 8760d48ce..fb8b8dd6c 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -23,6 +23,7 @@ using Orion; using Orion.Events; using Orion.Events.Extensions; +using TShock.Commands.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; @@ -37,6 +38,11 @@ internal sealed class TShockCommandService : OrionService, ICommandService { public EventHandlerCollection? CommandExecute { get; set; } public EventHandlerCollection? CommandUnregister { get; set; } + public TShockCommandService() { + this.RegisterParser(new Int32Parser()); + this.RegisterParser(new StringParser()); + } + public IReadOnlyCollection RegisterCommands(object obj) { if (obj is null) throw new ArgumentNullException(nameof(obj)); From 4b99642a559e0eb6cec44e964b7d219b6084c8e3 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 21:18:19 -0700 Subject: [PATCH 016/119] Remove [NotNull]s. --- src/TShock/Commands/TShockCommandService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index fb8b8dd6c..abd1c51ad 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -19,7 +19,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JetBrains.Annotations; using Orion; using Orion.Events; using Orion.Events.Extensions; @@ -67,7 +66,7 @@ void RegisterCommand(ICommand command) { return registeredCommands; } - public void RegisterParser([NotNull] Type parseType, [NotNull] IArgumentParser parser) { + public void RegisterParser(Type parseType, IArgumentParser parser) { if (parseType is null) throw new ArgumentNullException(nameof(parseType)); _parsers[parseType] = parser ?? throw new ArgumentNullException(nameof(parser)); From ca8fbc2ae4d70e63aa83cbe064e483e88c3279d3 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 21:40:27 -0700 Subject: [PATCH 017/119] Begin implementation of ICommand.Invoke. --- src/TShock/Commands/TShockCommand.cs | 37 +++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index d1b899eaa..5e51e09f3 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -20,6 +20,7 @@ using System.Diagnostics; using System.Reflection; using Orion.Events.Extensions; +using TShock.Commands.Parsers; using TShock.Events.Commands; namespace TShock.Commands { @@ -44,15 +45,43 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att Handler = handler; } - public void Invoke(ICommandSender sender, string input) { + public void Invoke(ICommandSender sender, string inputString) { if (sender is null) throw new ArgumentNullException(nameof(sender)); - if (input is null) throw new ArgumentNullException(nameof(input)); + if (inputString is null) throw new ArgumentNullException(nameof(inputString)); - var args = new CommandExecuteEventArgs(this, sender, input); + var args = new CommandExecuteEventArgs(this, sender, inputString); _commandService.CommandExecute?.Invoke(this, args); if (args.IsCanceled()) return; - throw new NotImplementedException(); + var parsers = _commandService.RegisteredParsers; + var handlerArgs = new List(); + + void CoerceParameter(ParameterInfo parameter, ReadOnlySpan input, out ReadOnlySpan nextInput) { + var parameterType = parameter.ParameterType; + + // Special case: parameter is an ICommandSender, in which case we inject sender. + if (parameterType == typeof(ICommandSender)) { + handlerArgs.Add(sender); + nextInput = input; + return; + } + + // If we can directly parse the parameter type, then do so. + if (parsers.TryGetValue(parameterType, out var parser)) { + var options = parameter.GetCustomAttribute()?.Options; + handlerArgs.Add(parser.Parse(input, out nextInput, options)); + return; + } + + nextInput = input; + } + + var input = inputString.AsSpan(); + foreach (var parameter in Handler.GetParameters()) { + CoerceParameter(parameter, input, out input); + } + + Handler.Invoke(HandlerObject, handlerArgs.ToArray()); } } } From c04918ebcbfa0eb10b725888171ba5cd477f19e5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 21:43:31 -0700 Subject: [PATCH 018/119] Remove another instance of NotNull. --- src/TShock/Commands/Extensions/CommandServiceExtensions.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs index 0867c54f2..d12bc3990 100644 --- a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs +++ b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs @@ -16,7 +16,6 @@ // along with TShock. If not, see . using System; -using JetBrains.Annotations; using TShock.Commands.Parsers; namespace TShock.Commands.Extensions { @@ -33,8 +32,7 @@ public static class CommandServiceExtensions { /// /// or are null. /// - public static void RegisterParser([NotNull] this ICommandService commandService, - [NotNull] IArgumentParser parser) { + public static void RegisterParser(this ICommandService commandService, IArgumentParser parser) { if (commandService is null) throw new ArgumentNullException(nameof(commandService)); if (parser is null) throw new ArgumentNullException(nameof(parser)); From aedc4a6f7a6401e7112e91b8585d9f20db2b1da6 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 22:57:19 -0700 Subject: [PATCH 019/119] Bind ICommandService in TShockPlugin constructor. --- src/TShock/TShockPlugin.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index a3ee02ea8..a7afc010e 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -21,6 +21,7 @@ using Orion.Events; using Orion.Events.Packets; using Orion.Players; +using TShock.Commands; namespace TShock { /// @@ -45,6 +46,8 @@ public sealed class TShockPlugin : OrionPlugin { /// The player service. /// Any of the services are null. public TShockPlugin(OrionKernel kernel, Lazy playerService) : base(kernel) { + kernel.Bind().To(); + _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); } From d49dffdfa2232fbb3b6f41b2f97c9e5ef5517842 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 23:21:27 -0700 Subject: [PATCH 020/119] Add tests for TShockCommand. --- .../Commands/TShockCommandTests.cs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/TShock.Tests/Commands/TShockCommandTests.cs diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs new file mode 100644 index 000000000..214792497 --- /dev/null +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using FluentAssertions; +using Moq; +using TShock.Commands.Parsers; +using Xunit; + +namespace TShock.Commands { + public class TShockCommandTests { + private readonly Mock _mockCommandService = new Mock(); + + [Fact] + public void Invoke_Sender_IsCorrect() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, ""); + + testClass.Sender.Should().BeSameAs(commandSender); + } + + [Theory] + [InlineData("1 test", 1, "test")] + [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] + public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, string expectedString) { + // This test isn't super isolated, but it should be fine. Mocking IArgumentParser is kind of sketchy because + // of ReadOnlySpan. + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser(), + [typeof(string)] = new StringParser() + }); + + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Int.Should().Be(expectedInt); + testClass.String.Should().Be(expectedString); + } + + [Fact] + public void Invoke_InParam_ThrowsParseException() { + + } + + [Fact] + public void Invoke_NullSender_ThrowsArgumentNullException() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + Action action = () => command.Invoke(null, ""); + + action.Should().Throw(); + } + + [Fact] + public void Invoke_NullInput_ThrowsArgumentNullException() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, null); + + action.Should().Throw(); + } + + private ICommand GetCommand(TestClass testClass, string methodName) { + var handler = typeof(TestClass).GetMethod(methodName); + var attribute = handler.GetCustomAttribute(); + return new TShockCommand(_mockCommandService.Object, attribute, testClass, handler); + } + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] + private class TestClass { + public ICommandSender Sender { get; private set; } + public int Int { get; private set; } + public string String { get; private set; } + + [CommandHandler("tshock_tests:test")] + public void TestCommand(ICommandSender sender) { + Sender = sender; + } + + [CommandHandler("tshock_tests:test_int_string")] + public void TestCommand_Int_String(ICommandSender sender, int @int, string @string) { + Sender = sender; + Int = @int; + String = @string; + } + } + } +} From 0d1da264eddad8f6c5c044760eb73bec358a106d Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 23:39:30 -0700 Subject: [PATCH 021/119] Add CommandException. --- src/TShock/Commands/CommandException.cs | 46 +++++++++++++++++++ src/TShock/Commands/ICommand.cs | 3 ++ src/TShock/Commands/TShockCommand.cs | 10 +++- src/TShock/Properties/Resources.Designer.cs | 18 ++++++++ src/TShock/Properties/Resources.resx | 6 +++ .../Commands/TShockCommandTests.cs | 36 +++++++++++++++ 6 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/TShock/Commands/CommandException.cs diff --git a/src/TShock/Commands/CommandException.cs b/src/TShock/Commands/CommandException.cs new file mode 100644 index 000000000..4528f8650 --- /dev/null +++ b/src/TShock/Commands/CommandException.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Commands { + /// + /// The exception thrown when a command could not be executed. + /// + [Serializable, ExcludeFromCodeCoverage] + public class CommandException : Exception { + /// + /// Initializes a new instance of the class. + /// + public CommandException() { } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message. + public CommandException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message + /// and inner exception. + /// + /// The message. + /// The inner exception. + public CommandException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 6fad66303..e966c6ed1 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using TShock.Commands.Parsers; namespace TShock.Commands { /// @@ -52,6 +53,8 @@ public interface ICommand { /// /// or are null. /// + /// The command could not be executed. + /// The command input could not be parsed. void Invoke(ICommandSender sender, string input); } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 5e51e09f3..fcb3675bb 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -22,6 +22,7 @@ using Orion.Events.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; +using TShock.Properties; namespace TShock.Commands { internal class TShockCommand : ICommand { @@ -57,6 +58,8 @@ public void Invoke(ICommandSender sender, string inputString) { var handlerArgs = new List(); void CoerceParameter(ParameterInfo parameter, ReadOnlySpan input, out ReadOnlySpan nextInput) { + if (parameter.ParameterType.IsByRef) throw new ParseException(Resources.CommandParse_ArgIsByReference); + var parameterType = parameter.ParameterType; // Special case: parameter is an ICommandSender, in which case we inject sender. @@ -81,7 +84,12 @@ void CoerceParameter(ParameterInfo parameter, ReadOnlySpan input, out Read CoerceParameter(parameter, input, out input); } - Handler.Invoke(HandlerObject, handlerArgs.ToArray()); + try { + Handler.Invoke(HandlerObject, handlerArgs.ToArray()); + } catch (Exception ex) { + sender.Log.Error(ex, Resources.CommandInvoke_Exception); + throw new CommandException(Resources.CommandInvoke_Exception, ex); + } } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 20b17026d..f30e88813 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -60,6 +60,24 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to Exception occurred while executing command.. + /// + internal static string CommandInvoke_Exception { + get { + return ResourceManager.GetString("CommandInvoke_Exception", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to By-reference command arguments are unsupported.. + /// + internal static string CommandParse_ArgIsByReference { + get { + return ResourceManager.GetString("CommandParse_ArgIsByReference", resourceCulture); + } + } + /// /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index dab56c439..938d667ef 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,6 +117,12 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Exception occurred while executing command. + + + By-reference command arguments are unsupported. + "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 214792497..0b980d3ba 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -63,7 +63,32 @@ public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, stri [Fact] public void Invoke_InParam_ThrowsParseException() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoIn)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); + + action.Should().Throw(); + } + + [Fact] + public void Invoke_OutParam_ThrowsParseException() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoOut)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); + action.Should().Throw(); + } + + [Fact] + public void Invoke_RefParam_ThrowsParseException() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoRef)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); + + action.Should().Throw(); } [Fact] @@ -108,6 +133,17 @@ public void TestCommand_Int_String(ICommandSender sender, int @int, string @stri Int = @int; String = @string; } + + [CommandHandler("tshock_tests:test_no_in")] + public void TestCommand_NoIn(ICommandSender sender, in int x) { } + + [CommandHandler("tshock_tests:test_no_out")] + public void TestCommand_NoOut(ICommandSender sender, out int x) { + x = 0; + } + + [CommandHandler("tshock_tests:test_no_out")] + public void TestCommand_NoRef(ICommandSender sender, ref int x) { } } } } From da8b2835716b275c2a4f1d0f5f5431d184efaedc Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 23:45:40 -0700 Subject: [PATCH 022/119] Add FlagAttribute. --- src/TShock/Commands/Parsers/FlagAttribute.cs | 47 +++++++++++++++++++ .../Commands/Parsers/FlagAttributeTests.cs | 45 ++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/TShock/Commands/Parsers/FlagAttribute.cs create mode 100644 tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs new file mode 100644 index 000000000..056c5d6a9 --- /dev/null +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Commands.Parsers { + /// + /// An attribute that can be applied to a bool parameter to specify flag parsing for that specific parameter. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class FlagAttribute : Attribute { + /// + /// Gets the short form of the flag. + /// + public char ShortFlag { get; } + + /// + /// Gets the long form of the flag. + /// + public string LongFlag { get; } + + /// + /// Initializes a new instance of the class with the specified short and long flags. + /// + /// The short flag. + /// The long flag. + /// is null. + public FlagAttribute(char shortFlag, string longFlag) { + ShortFlag = shortFlag; + LongFlag = longFlag ?? throw new ArgumentNullException(nameof(longFlag)); + } + } +} diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs new file mode 100644 index 000000000..93157ff96 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class FlagAttributeTests { + [Fact] + public void Ctor_NullLongFlag_ThrowsArgumentNullException() { + Func func = () => new FlagAttribute('f', null); + + func.Should().Throw(); + } + + [Fact] + public void ShortFlag_Get_IsCorrect() { + var attribute = new FlagAttribute('f', "force"); + + attribute.ShortFlag.Should().Be('f'); + } + + [Fact] + public void LongFlag_Get_IsCorrect() { + var attribute = new FlagAttribute('f', "force"); + + attribute.LongFlag.Should().Be("force"); + } + } +} From b9e7d9f9813249b51f4fa4e8104d50e7231d66e7 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 29 Sep 2019 23:55:13 -0700 Subject: [PATCH 023/119] Add ScanFor extension method. --- .../Extensions/ReadOnlySpanExtensions.cs | 35 +++++++++++++++++++ src/TShock/Commands/Parsers/Int32Parser.cs | 19 ++-------- src/TShock/Commands/Parsers/StringParser.cs | 9 ++--- .../Extensions/ReadOnlySpanExtensionsTests.cs | 34 ++++++++++++++++++ 4 files changed, 74 insertions(+), 23 deletions(-) create mode 100644 src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs create mode 100644 tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs diff --git a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs new file mode 100644 index 000000000..9e81d6fa0 --- /dev/null +++ b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; + +namespace TShock.Commands.Extensions { + internal static class ReadOnlySpanExtensions { + public static int ScanFor(this ReadOnlySpan input, Func predicate, int start = 0) { + Debug.Assert(predicate != null, "predicate != null"); + + while (start < input.Length) { + if (predicate(input[start])) break; + + ++start; + } + + return start; + } + } +} diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index a7089bfa8..c0c0ae5cd 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -18,27 +18,14 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using TShock.Commands.Extensions; using TShock.Properties; namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options= null) { - // Scan until we find some non-whitespace character. - var start = 0; - while (start < input.Length) { - if (!char.IsWhiteSpace(input[start])) break; - - ++start; - } - - // Now scan until we find some whitespace character. - var end = start; - while (end < input.Length) { - if (char.IsWhiteSpace(input[end])) break; - - ++end; - } - + var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + var end = input.ScanFor(char.IsWhiteSpace, start); var parse = input[start..end]; nextInput = input[end..]; diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 86f81d3dd..ff42b7484 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; +using TShock.Commands.Extensions; using TShock.Properties; namespace TShock.Commands.Parsers { @@ -30,13 +31,7 @@ public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, return input.ToString(); } - // Scan until we find some non-whitespace character. - var start = 0; - while (start < input.Length) { - if (!char.IsWhiteSpace(input[start])) break; - - ++start; - } + var start = input.ScanFor(c => !char.IsWhiteSpace(c)); // Begin building our string character-by-character. var builder = new StringBuilder(); diff --git a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs new file mode 100644 index 000000000..ed432cb9a --- /dev/null +++ b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Extensions { + public class ReadOnlySpanExtensionsTests { + [Fact] + public void ScanFor_IsCorrect() { + "abcdeA".AsSpan().ScanFor(char.IsUpper).Should().Be(5); + } + + [Fact] + public void ScanFor_ReachesEnd_IsCorrect() { + "abcdefghijklmnopqrstuvwxyz".AsSpan().ScanFor(char.IsUpper).Should().Be(26); + } + } +} From be4566b2081ef9efc6ef08e7537bac8a727d0a24 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 00:48:44 -0700 Subject: [PATCH 024/119] Add short flag parsing. --- src/TShock/Commands/TShockCommand.cs | 90 ++++++++++++++++--- src/TShock/Properties/Resources.Designer.cs | 18 ++++ src/TShock/Properties/Resources.resx | 6 ++ .../Commands/TShockCommandTests.cs | 32 ++++++- 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index fcb3675bb..2e138ee5b 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -20,12 +20,15 @@ using System.Diagnostics; using System.Reflection; using Orion.Events.Extensions; +using TShock.Commands.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; using TShock.Properties; namespace TShock.Commands { internal class TShockCommand : ICommand { + private static readonly ISet EmptyShortFlags = new HashSet(); + private readonly ICommandService _commandService; private readonly CommandHandlerAttribute _attribute; @@ -54,34 +57,74 @@ public void Invoke(ICommandSender sender, string inputString) { _commandService.CommandExecute?.Invoke(this, args); if (args.IsCanceled()) return; - var parsers = _commandService.RegisteredParsers; - var handlerArgs = new List(); + // Pass 1: Scan through the parameters and learn flags and optionals. + var parameters = Handler.GetParameters(); + var validShortFlags = new HashSet(); + var validLongFlags = new HashSet(); + var validOptionals = new HashSet(); - void CoerceParameter(ParameterInfo parameter, ReadOnlySpan input, out ReadOnlySpan nextInput) { + void PreprocessParameter(ParameterInfo parameter) { + // Check for things we don't support: by-reference types. if (parameter.ParameterType.IsByRef) throw new ParseException(Resources.CommandParse_ArgIsByReference); var parameterType = parameter.ParameterType; + // If the parameter is a bool and is marked with a [Flag], we'll take note of that. + if (parameterType == typeof(bool)) { + var attribute = parameter.GetCustomAttribute(); + if (attribute != null) { + validShortFlags.Add(attribute.ShortFlag); + validLongFlags.Add(attribute.LongFlag); + } + } + + // If the parameter is optional, we'll take note of that. + if (parameter.IsOptional) { + validOptionals.Add(parameter.Name); + } + } + + foreach (var parameter in parameters) { + PreprocessParameter(parameter); + } + + + // Pass 2, part 1: Parse flags and optionals. + var input = inputString.AsSpan(); + var shortFlags = ParseShortFlags(ref input, validShortFlags); + + // Pass 2, part 2: Parse parameters. + var parsers = _commandService.RegisteredParsers; + var handlerArgs = new List(); + + void CoerceParameter(ParameterInfo parameter, ref ReadOnlySpan input) { + var parameterType = parameter.ParameterType; + // Special case: parameter is an ICommandSender, in which case we inject sender. if (parameterType == typeof(ICommandSender)) { handlerArgs.Add(sender); - nextInput = input; return; } + // Special case: parameter is a bool and is marked with a [Flag], in which case we look up shortFlags + // and longFlags and inject that. + if (parameterType == typeof(bool)) { + var attribute = parameter.GetCustomAttribute(); + if (attribute != null) { + handlerArgs.Add(shortFlags.Contains(attribute.ShortFlag)); + return; + } + } + // If we can directly parse the parameter type, then do so. if (parsers.TryGetValue(parameterType, out var parser)) { var options = parameter.GetCustomAttribute()?.Options; - handlerArgs.Add(parser.Parse(input, out nextInput, options)); - return; + handlerArgs.Add(parser.Parse(input, out input, options)); } - - nextInput = input; } - var input = inputString.AsSpan(); foreach (var parameter in Handler.GetParameters()) { - CoerceParameter(parameter, input, out input); + CoerceParameter(parameter, ref input); } try { @@ -91,5 +134,32 @@ void CoerceParameter(ParameterInfo parameter, ReadOnlySpan input, out Read throw new CommandException(Resources.CommandInvoke_Exception, ex); } } + + private static ISet ParseShortFlags(ref ReadOnlySpan input, ISet validShortFlags) { + // Quick return if there are no valid short flags. + if (validShortFlags.Count == 0) return EmptyShortFlags; + + var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + var end = input.ScanFor(char.IsWhiteSpace, start); + if (start == end || input[start] != '-') + return EmptyShortFlags; + + ++start; + + // Make sure we're not treating a long flag or an optional as a set of short flags. + if (start == end || input[start] == '-') return EmptyShortFlags; + + var shortFlags = new HashSet(); + foreach (var c in input[start..end]) { + if (!validShortFlags.Contains(c)) { + throw new ParseException(string.Format(Resources.CommandParse_BadShortFlag, c)); + } + + shortFlags.Add(c); + } + + input = input[end..]; + return shortFlags; + } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index f30e88813..2f482e734 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -78,6 +78,24 @@ internal static string CommandParse_ArgIsByReference { } } + /// + /// Looks up a localized string similar to Unexpected long flag "{0}".. + /// + internal static string CommandParse_BadLongFlag { + get { + return ResourceManager.GetString("CommandParse_BadLongFlag", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unexpected short flag "{0}".. + /// + internal static string CommandParse_BadShortFlag { + get { + return ResourceManager.GetString("CommandParse_BadShortFlag", resourceCulture); + } + } + /// /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 938d667ef..2d6b554f8 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -123,6 +123,12 @@ By-reference command arguments are unsupported. + + Unexpected long flag "{0}". + + + Unexpected short flag "{0}". + "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 0b980d3ba..306eafc47 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -61,6 +61,23 @@ public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, stri testClass.String.Should().Be(expectedString); } + [Theory] + [InlineData("", false, false)] + [InlineData("-x", true, false)] + [InlineData("-y", false, true)] + [InlineData("-xy", true, true)] + public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.X.Should().Be(expectedX); + testClass.Y.Should().Be(expectedY); + } + [Fact] public void Invoke_InParam_ThrowsParseException() { var testClass = new TestClass(); @@ -115,12 +132,14 @@ private ICommand GetCommand(TestClass testClass, string methodName) { var attribute = handler.GetCustomAttribute(); return new TShockCommand(_mockCommandService.Object, attribute, testClass, handler); } - + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] private class TestClass { public ICommandSender Sender { get; private set; } public int Int { get; private set; } public string String { get; private set; } + public bool X { get; private set; } + public bool Y { get; private set; } [CommandHandler("tshock_tests:test")] public void TestCommand(ICommandSender sender) { @@ -133,10 +152,10 @@ public void TestCommand_Int_String(ICommandSender sender, int @int, string @stri Int = @int; String = @string; } - + [CommandHandler("tshock_tests:test_no_in")] public void TestCommand_NoIn(ICommandSender sender, in int x) { } - + [CommandHandler("tshock_tests:test_no_out")] public void TestCommand_NoOut(ICommandSender sender, out int x) { x = 0; @@ -144,6 +163,13 @@ public void TestCommand_NoOut(ICommandSender sender, out int x) { [CommandHandler("tshock_tests:test_no_out")] public void TestCommand_NoRef(ICommandSender sender, ref int x) { } + + [CommandHandler("tshock_tests:test_flags")] + public void TestCommand_Flags(ICommandSender sender, [Flag('x', "xxx")] bool x, [Flag('y', "yyy")] bool y) { + Sender = sender; + X = x; + Y = y; + } } } } From 6a4c3b79b7df02cfe24e6c29d995ac6e1f1fa712 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 00:53:33 -0700 Subject: [PATCH 025/119] Switch out nextInput in IArgumentParser for ref input. --- .../Commands/Parsers/IArgumentParser.cs | 5 ++--- .../Commands/Parsers/IArgumentParser`1.cs | 6 ++---- src/TShock/Commands/Parsers/Int32Parser.cs | 7 +++---- src/TShock/Commands/Parsers/StringParser.cs | 13 ++++++------ src/TShock/Commands/TShockCommand.cs | 2 +- .../Commands/Parsers/Int32ParserTests.cs | 21 ++++++++++++------- .../Commands/Parsers/StringParserTests.cs | 20 +++++++++++------- 7 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 53ef81d8f..2d2de9f58 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -24,13 +24,12 @@ namespace TShock.Commands.Parsers { /// public interface IArgumentParser { /// - /// Parses the given input and returns a corresponding object along with what the next input should be. + /// Parses the given input and returns a corresponding object. /// /// The input. - /// The next input. /// The parse options. /// A corresponding object. /// The input could not be parsed properly. - object Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options = null); + object Parse(ref ReadOnlySpan input, ISet? options = null); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 6f994c193..229f9272f 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -25,14 +25,12 @@ namespace TShock.Commands.Parsers { /// The parse type. public interface IArgumentParser : IArgumentParser { /// - /// Parses the given input and returns a corresponding instance of the parse type along with what the next - /// input should be. + /// Parses the given input and returns a corresponding instance of the parse type. /// /// The input. - /// The next input. /// The parse options. /// A corresponding instance of the parse type. /// The input could not be parsed properly. - new TParse Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options = null); + new TParse Parse(ref ReadOnlySpan input, ISet? options = null); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index c0c0ae5cd..bd6256f5b 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -23,11 +23,11 @@ namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { - public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options= null) { + public int Parse(ref ReadOnlySpan input, ISet? options= null) { var start = input.ScanFor(c => !char.IsWhiteSpace(c)); var end = input.ScanFor(char.IsWhiteSpace, start); var parse = input[start..end]; - nextInput = input[end..]; + input = input[end..]; // Calling Parse here instead of TryParse allows us to give better error messages. try { @@ -40,7 +40,6 @@ public int Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISe } [ExcludeFromCodeCoverage] - object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, - ISet? options) => Parse(input, out nextInput, options); + object IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index ff42b7484..2ecdddb72 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -25,10 +25,11 @@ namespace TShock.Commands.Parsers { // It'd be nice to return ReadOnlySpan, but because of escape characters, we have to return copies. internal sealed class StringParser : IArgumentParser { - public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ISet? options = null) { + public string Parse(ref ReadOnlySpan input, ISet? options = null) { if (options?.Contains(ParseOptions.ToEndOfInput) == true) { - nextInput = default; - return input.ToString(); + var result = input.ToString(); + input = default; + return result; } var start = input.ScanFor(c => !char.IsWhiteSpace(c)); @@ -76,13 +77,11 @@ public string Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, ++end; } - nextInput = input[end..]; - + input = input[end..]; return builder.ToString(); } [ExcludeFromCodeCoverage] - object IArgumentParser.Parse(ReadOnlySpan input, out ReadOnlySpan nextInput, - ISet? options) => Parse(input, out nextInput, options); + object IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 2e138ee5b..93ba4d895 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -119,7 +119,7 @@ void CoerceParameter(ParameterInfo parameter, ref ReadOnlySpan input) { // If we can directly parse the parameter type, then do so. if (parsers.TryGetValue(parameterType, out var parser)) { var options = parameter.GetCustomAttribute()?.Options; - handlerArgs.Add(parser.Parse(input, out input, options)); + handlerArgs.Add(parser.Parse(ref input, options)); } } diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index bcd2dbb64..9fe4a6360 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -28,20 +28,24 @@ public class Int32ParserTests { [InlineData("-1234", -1234, "")] [InlineData("123 test", 123, " test")] [InlineData(" 123", 123, "")] - public void Parse_IsCorrect(string input, int expected, string expectedNextInput) { + public void Parse_IsCorrect(string inputString, int expected, string expectedNextInput) { var parser = new Int32Parser(); + var input = inputString.AsSpan(); - parser.Parse(input, out var nextInput).Should().Be(expected); + parser.Parse(ref input).Should().Be(expected); - nextInput.ToString().Should().Be(expectedNextInput); + input.ToString().Should().Be(expectedNextInput); } [Theory] [InlineData("2147483648")] [InlineData("-2147483649")] - public void Parse_IntegerOutOfRange_ThrowsParseException(string input) { + public void Parse_IntegerOutOfRange_ThrowsParseException(string inputString) { var parser = new Int32Parser(); - Func func = () => parser.Parse(input, out _); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input); + }; func.Should().Throw().WithInnerException(); } @@ -50,9 +54,12 @@ public void Parse_IntegerOutOfRange_ThrowsParseException(string input) { [InlineData("aaa")] [InlineData("123a")] [InlineData("1.0")] - public void Parse_InvalidInteger_ThrowsParseException(string input) { + public void Parse_InvalidInteger_ThrowsParseException(string inputString) { var parser = new Int32Parser(); - Func func = () => parser.Parse(input, out _); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input); + }; func.Should().Throw().WithInnerException(); } diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index 7af6e3d28..d4968a417 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -36,20 +36,24 @@ public class StringParserTests { [InlineData(@"\""", @"""", "")] [InlineData(@"\t", "\t", "")] [InlineData(@"\n", "\n", "")] - public void Parse_IsCorrect(string input, string expected, string expectedNextInput) { + public void Parse_IsCorrect(string inputString, string expected, string expectedNextInput) { var parser = new StringParser(); + var input = inputString.AsSpan(); - parser.Parse(input, out var nextInput).Should().Be(expected); + parser.Parse(ref input).Should().Be(expected); - nextInput.ToString().Should().Be(expectedNextInput); + input.ToString().Should().Be(expectedNextInput); } [Theory] [InlineData(@"\")] [InlineData(@"\a")] - public void Parse_EscapeReachesEnd_ThrowsParseException(string input) { + public void Parse_EscapeReachesEnd_ThrowsParseException(string inputString) { var parser = new StringParser(); - Func func = () => parser.Parse(input, out _); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input); + }; func.Should().Throw(); } @@ -57,12 +61,12 @@ public void Parse_EscapeReachesEnd_ThrowsParseException(string input) { [Fact] public void Parse_ToEndOfInput_IsCorrect() { var parser = new StringParser(); + var input = @"blah blah ""test"" blah blah".AsSpan(); - parser.Parse(@"blah blah ""test"" blah blah", out var nextInput, - new HashSet {ParseOptions.ToEndOfInput}) + parser.Parse(ref input, new HashSet {ParseOptions.ToEndOfInput}) .Should().Be(@"blah blah ""test"" blah blah"); - nextInput.ToString().Should().BeEmpty(); + input.ToString().Should().BeEmpty(); } } } From 865e59e237a53ef1ebc96506a6d47d45830fdb3e Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 21:30:20 -0700 Subject: [PATCH 026/119] Implement and test flags and optionals parsing. --- src/TShock/Commands/ICommand.cs | 2 +- src/TShock/Commands/ICommandService.cs | 6 +- src/TShock/Commands/Parsers/FlagAttribute.cs | 7 +- src/TShock/Commands/TShockCommand.cs | 212 +++++++++++------- src/TShock/Commands/TShockCommandService.cs | 8 +- src/TShock/Properties/Resources.Designer.cs | 45 +++- src/TShock/Properties/Resources.resx | 23 +- .../Commands/Parsers/FlagAttributeTests.cs | 7 - .../Commands/TShockCommandTests.cs | 171 ++++++++++++-- tests/TShock.Tests/TShock.Tests.csproj | 3 +- 10 files changed, 353 insertions(+), 131 deletions(-) diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index e966c6ed1..102d163ff 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -38,7 +38,7 @@ public interface ICommand { /// /// Gets the object associated with the command's handler. /// - object? HandlerObject { get; } + object HandlerObject { get; } /// /// Gets the command's handler. diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 317621fe7..433a93178 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -55,10 +55,10 @@ public interface ICommandService : IService { /// /// Registers and returns the commands defined with the given object's command handlers. /// - /// The object. + /// The object. /// The resulting commands. - /// is null. - IReadOnlyCollection RegisterCommands(object obj); + /// is null. + IReadOnlyCollection RegisterCommands(object handlerObject); /// /// Registers the given parser as the definitive parser for the parse type. diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index 056c5d6a9..2978ee706 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -31,17 +31,16 @@ public sealed class FlagAttribute : Attribute { /// /// Gets the long form of the flag. /// - public string LongFlag { get; } + public string? LongFlag { get; } /// /// Initializes a new instance of the class with the specified short and long flags. /// /// The short flag. /// The long flag. - /// is null. - public FlagAttribute(char shortFlag, string longFlag) { + public FlagAttribute(char shortFlag, string? longFlag = null) { ShortFlag = shortFlag; - LongFlag = longFlag ?? throw new ArgumentNullException(nameof(longFlag)); + LongFlag = longFlag; } } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 93ba4d895..d0dc4a990 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -28,16 +28,21 @@ namespace TShock.Commands { internal class TShockCommand : ICommand { private static readonly ISet EmptyShortFlags = new HashSet(); + private static readonly ISet EmptyLongFlags = new HashSet(); + private static readonly IDictionary EmptyOptionals = new Dictionary(); private readonly ICommandService _commandService; private readonly CommandHandlerAttribute _attribute; + private readonly ISet validShortFlags = new HashSet(); + private readonly ISet validLongFlags = new HashSet(); + private readonly IDictionary validOptionals = new Dictionary(); public string Name => _attribute.CommandName; public IEnumerable SubNames => _attribute.CommandSubNames; - public object? HandlerObject { get; } + public object HandlerObject { get; } public MethodBase Handler { get; } - public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object? handlerObject, + public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object handlerObject, MethodBase handler) { Debug.Assert(commandService != null, "commandService != null"); Debug.Assert(attribute != null, "attribute != null"); @@ -47,119 +52,170 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att _attribute = attribute; HandlerObject = handlerObject; Handler = handler; - } - - public void Invoke(ICommandSender sender, string inputString) { - if (sender is null) throw new ArgumentNullException(nameof(sender)); - if (inputString is null) throw new ArgumentNullException(nameof(inputString)); - - var args = new CommandExecuteEventArgs(this, sender, inputString); - _commandService.CommandExecute?.Invoke(this, args); - if (args.IsCanceled()) return; - // Pass 1: Scan through the parameters and learn flags and optionals. - var parameters = Handler.GetParameters(); - var validShortFlags = new HashSet(); - var validLongFlags = new HashSet(); - var validOptionals = new HashSet(); + void PreprocessParameter(ParameterInfo parameterInfo) { + var parameterType = parameterInfo.ParameterType; - void PreprocessParameter(ParameterInfo parameter) { - // Check for things we don't support: by-reference types. - if (parameter.ParameterType.IsByRef) throw new ParseException(Resources.CommandParse_ArgIsByReference); + // Check for types we don't support, such as by-reference and pointers. + if (parameterType.IsByRef) { + throw new NotSupportedException( + string.Format(Resources.CommandCtor_ArgIsByReference, parameterInfo)); + } - var parameterType = parameter.ParameterType; + if (parameterType.IsPointer) { + throw new NotSupportedException(string.Format(Resources.CommandCtor_ArgIsPointer, parameterInfo)); + } - // If the parameter is a bool and is marked with a [Flag], we'll take note of that. + // If the parameter is a bool, then it should be marked with FlagAttribute and we'll note it. if (parameterType == typeof(bool)) { - var attribute = parameter.GetCustomAttribute(); + var attribute = parameterInfo.GetCustomAttribute(); if (attribute != null) { validShortFlags.Add(attribute.ShortFlag); - validLongFlags.Add(attribute.LongFlag); + if (attribute.LongFlag != null) { + validLongFlags.Add(attribute.LongFlag); + } } } - // If the parameter is optional, we'll take note of that. - if (parameter.IsOptional) { - validOptionals.Add(parameter.Name); + // If the parameter is optional, we'll take note of that. We replace underscores with hyphens here + // because hyphens are not valid in C# identifiers. + if (parameterInfo.IsOptional) { + validOptionals.Add(parameterInfo.Name.Replace('_', '-'), parameterInfo); } } - foreach (var parameter in parameters) { + // Scan through the parameters and learn flags and optionals. + foreach (var parameter in Handler.GetParameters()) { PreprocessParameter(parameter); } + } + public void Invoke(ICommandSender sender, string inputString) { + if (sender is null) throw new ArgumentNullException(nameof(sender)); + if (inputString is null) throw new ArgumentNullException(nameof(inputString)); - // Pass 2, part 1: Parse flags and optionals. - var input = inputString.AsSpan(); - var shortFlags = ParseShortFlags(ref input, validShortFlags); + var args = new CommandExecuteEventArgs(this, sender, inputString); + _commandService.CommandExecute?.Invoke(this, args); + if (args.IsCanceled()) return; - // Pass 2, part 2: Parse parameters. var parsers = _commandService.RegisteredParsers; - var handlerArgs = new List(); - void CoerceParameter(ParameterInfo parameter, ref ReadOnlySpan input) { - var parameterType = parameter.ParameterType; + ISet ParseShortFlags(ref ReadOnlySpan input) { + // Quick return if there are no valid short flags. + if (validShortFlags.Count == 0) return EmptyShortFlags; + + var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + if (start >= input.Length || input[start++] != '-') return EmptyShortFlags; + if (start >= input.Length || input[start] == '-') return EmptyShortFlags; + + var end = input.ScanFor(char.IsWhiteSpace, start); + var shortFlags = new HashSet(); + for (var i = start; i < end; ++i) { + var c = input[i]; + if (!validShortFlags.Contains(c)) { + throw new ParseException(string.Format(Resources.CommandParse_BadShortFlag, c)); + } - // Special case: parameter is an ICommandSender, in which case we inject sender. - if (parameterType == typeof(ICommandSender)) { - handlerArgs.Add(sender); - return; + shortFlags.Add(c); } - // Special case: parameter is a bool and is marked with a [Flag], in which case we look up shortFlags - // and longFlags and inject that. - if (parameterType == typeof(bool)) { - var attribute = parameter.GetCustomAttribute(); - if (attribute != null) { - handlerArgs.Add(shortFlags.Contains(attribute.ShortFlag)); - return; - } - } + input = input[end..]; + return shortFlags; + } - // If we can directly parse the parameter type, then do so. - if (parsers.TryGetValue(parameterType, out var parser)) { - var options = parameter.GetCustomAttribute()?.Options; - handlerArgs.Add(parser.Parse(ref input, options)); + (ISet longFlags, IDictionary optionals) ParseLongFlagsAndOptionals( + ref ReadOnlySpan input) { + // Quick return if there are no valid long flags and optionals. + if (validLongFlags.Count == 0 && validOptionals.Count == 0) return (EmptyLongFlags, EmptyOptionals); + + var longFlags = new HashSet(); + var optionals = new Dictionary(); + while (true) { + var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + if (start >= input.Length || input[start++] != '-') break; + if (start >= input.Length || input[start++] != '-') break; + + var end = input.ScanFor(c => char.IsWhiteSpace(c) || c == '=', start); + var isLongFlag = end >= input.Length || input[end] != '='; + if (isLongFlag) { + var longFlag = input[start..end].ToString(); + if (!validLongFlags.Contains(longFlag)) { + throw new ParseException(string.Format(Resources.CommandParse_BadLongFlag, longFlag)); + } + + longFlags.Add(longFlag); + input = input[end..]; + } else { + var optional = input[start..end].ToString(); + if (!validOptionals.TryGetValue(optional, out var parameter)) { + throw new ParseException(string.Format(Resources.CommandParse_BadOptional, optional)); + } + + var parameterType = parameter.ParameterType; + if (!parsers.TryGetValue(parameterType, out var parser)) { + throw new ParseException( + string.Format(Resources.CommandParse_NoParserFound, parameterType)); + } + + ++end; + input = input[end..]; + var options = parameter.GetCustomAttribute()?.Options; + optionals[optional] = parser.Parse(ref input, options); + } } - } - foreach (var parameter in Handler.GetParameters()) { - CoerceParameter(parameter, ref input); + return (longFlags, optionals); } - try { - Handler.Invoke(HandlerObject, handlerArgs.ToArray()); - } catch (Exception ex) { - sender.Log.Error(ex, Resources.CommandInvoke_Exception); - throw new CommandException(Resources.CommandInvoke_Exception, ex); - } - } + var input = inputString.AsSpan(); + var shortFlags = ParseShortFlags(ref input); + var (longFlags, optionals) = ParseLongFlagsAndOptionals(ref input); - private static ISet ParseShortFlags(ref ReadOnlySpan input, ISet validShortFlags) { - // Quick return if there are no valid short flags. - if (validShortFlags.Count == 0) return EmptyShortFlags; + object? ProcessParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { + var parameterType = parameterInfo.ParameterType; - var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - var end = input.ScanFor(char.IsWhiteSpace, start); - if (start == end || input[start] != '-') - return EmptyShortFlags; + // Special case: parameter is an ICommandSender, in which case we inject sender. + if (parameterType == typeof(ICommandSender)) return sender; - ++start; + // Special case: parameter is a bool and is marked with FlagAttribute, in which case we look up + // shortFlags and longFlags and inject that. + if (parameterType == typeof(bool)) { + var attribute = parameterInfo.GetCustomAttribute(); + if (attribute != null) { + return shortFlags.Contains(attribute.ShortFlag) || + attribute.LongFlag != null && longFlags.Contains(attribute.LongFlag); + } + } - // Make sure we're not treating a long flag or an optional as a set of short flags. - if (start == end || input[start] == '-') return EmptyShortFlags; + // Special case: parameter is optional, in which case we look up optionals and try injecting that. If + // that fails, then we just inject the default value. + if (parameterInfo.IsOptional) { + var optional = parameterInfo.Name.Replace('_', '-'); + return optionals.TryGetValue(optional, out var value) ? value : parameterInfo.DefaultValue; + } - var shortFlags = new HashSet(); - foreach (var c in input[start..end]) { - if (!validShortFlags.Contains(c)) { - throw new ParseException(string.Format(Resources.CommandParse_BadShortFlag, c)); + // If we can directly parse the parameter type, then do so. + if (parsers.TryGetValue(parameterType, out var parser)) { + var options = parameterInfo.GetCustomAttribute()?.Options; + return parser.Parse(ref input, options); } - shortFlags.Add(c); + // Otherwise, it's impossible to parse. + throw new ParseException(string.Format(Resources.CommandParse_NoParserFound, parameterType)); + } + + var parameterInfos = Handler.GetParameters(); + var parameters = new object?[parameterInfos.Length]; + for (var i = 0; i < parameters.Length; ++i) { + parameters[i] = ProcessParameter(parameterInfos[i], ref input); } - input = input[end..]; - return shortFlags; + try { + Handler.Invoke(HandlerObject, parameters); + } catch (TargetInvocationException ex) { + sender.Log.Error(ex.InnerException, Resources.CommandInvoke_Exception); + throw new CommandException(Resources.CommandInvoke_Exception, ex.InnerException); + } } } } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index abd1c51ad..87c50acfc 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -42,8 +42,8 @@ public TShockCommandService() { this.RegisterParser(new StringParser()); } - public IReadOnlyCollection RegisterCommands(object obj) { - if (obj is null) throw new ArgumentNullException(nameof(obj)); + public IReadOnlyCollection RegisterCommands(object handlerObject) { + if (handlerObject is null) throw new ArgumentNullException(nameof(handlerObject)); var registeredCommands = new List(); @@ -56,10 +56,10 @@ void RegisterCommand(ICommand command) { registeredCommands.Add(command); } - foreach (var command in obj.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + foreach (var command in handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) .SelectMany(m => m.GetCustomAttributes(), (handler, attribute) => (handler, attribute)) - .Select(t => new TShockCommand(this, t.attribute, obj, t.handler))) { + .Select(t => new TShockCommand(this, t.attribute, handlerObject, t.handler))) { RegisterCommand(command); } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 2f482e734..b893f39c5 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -60,6 +60,24 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to By-reference argument "{0}" is not supported.. + /// + internal static string CommandCtor_ArgIsByReference { + get { + return ResourceManager.GetString("CommandCtor_ArgIsByReference", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Pointer argument "{0}" is not supported.. + /// + internal static string CommandCtor_ArgIsPointer { + get { + return ResourceManager.GetString("CommandCtor_ArgIsPointer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exception occurred while executing command.. /// @@ -70,25 +88,25 @@ internal static string CommandInvoke_Exception { } /// - /// Looks up a localized string similar to By-reference command arguments are unsupported.. + /// Looks up a localized string similar to Long flag "--{0}" was unexpected.. /// - internal static string CommandParse_ArgIsByReference { + internal static string CommandParse_BadLongFlag { get { - return ResourceManager.GetString("CommandParse_ArgIsByReference", resourceCulture); + return ResourceManager.GetString("CommandParse_BadLongFlag", resourceCulture); } } /// - /// Looks up a localized string similar to Unexpected long flag "{0}".. + /// Looks up a localized string similar to Optional argument "{0}" was unexpected.. /// - internal static string CommandParse_BadLongFlag { + internal static string CommandParse_BadOptional { get { - return ResourceManager.GetString("CommandParse_BadLongFlag", resourceCulture); + return ResourceManager.GetString("CommandParse_BadOptional", resourceCulture); } } /// - /// Looks up a localized string similar to Unexpected short flag "{0}".. + /// Looks up a localized string similar to Short flag "-{0}" was unexpected.. /// internal static string CommandParse_BadShortFlag { get { @@ -96,6 +114,15 @@ internal static string CommandParse_BadShortFlag { } } + /// + /// Looks up a localized string similar to No parser found for argument type "{0}".. + /// + internal static string CommandParse_NoParserFound { + get { + return ResourceManager.GetString("CommandParse_NoParserFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. /// @@ -115,7 +142,7 @@ internal static string Int32Parser_InvalidInteger { } /// - /// Looks up a localized string similar to Reached end of input.. + /// Looks up a localized string similar to Unexpected end of input.. /// internal static string StringParser_EndOfInput { get { @@ -124,7 +151,7 @@ internal static string StringParser_EndOfInput { } /// - /// Looks up a localized string similar to Unexpected escape character "\{0}".. + /// Looks up a localized string similar to Escape character "\{0}" was unexpected.. /// internal static string StringParser_UnexpectedEscape { get { diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 2d6b554f8..5f8575126 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,17 +117,26 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + By-reference argument "{0}" is not supported. + + + Pointer argument "{0}" is not supported. + Exception occurred while executing command. - - By-reference command arguments are unsupported. - - Unexpected long flag "{0}". + Long flag "--{0}" was unexpected. + + + Optional argument "{0}" was unexpected. - Unexpected short flag "{0}". + Short flag "-{0}" was unexpected. + + + No parser found for argument type "{0}". "{0}" is a number that is out of range of an integer. @@ -136,9 +145,9 @@ "{0}" is not a valid integer. - Reached end of input. + Unexpected end of input. - Unexpected escape character "\{0}". + Escape character "\{0}" was unexpected. \ No newline at end of file diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs index 93157ff96..906ea4404 100644 --- a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs @@ -21,13 +21,6 @@ namespace TShock.Commands.Parsers { public class FlagAttributeTests { - [Fact] - public void Ctor_NullLongFlag_ThrowsArgumentNullException() { - Func func = () => new FlagAttribute('f', null); - - func.Should().Throw(); - } - [Fact] public void ShortFlag_Get_IsCorrect() { var attribute = new FlagAttribute('f', "force"); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 306eafc47..b0639250e 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -28,6 +28,38 @@ namespace TShock.Commands { public class TShockCommandTests { private readonly Mock _mockCommandService = new Mock(); + [Fact] + public void Ctor_InParam_ThrowsNotSupportedException() { + var testClass = new TestClass(); + Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoIn)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_OutParam_ThrowsNotSupportedException() { + var testClass = new TestClass(); + Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoOut)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_RefParam_ThrowsNotSupportedException() { + var testClass = new TestClass(); + Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoRef)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_PointerParam_ThrowsNotSupportedException() { + var testClass = new TestClass(); + Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoPointer)); + + func.Should().Throw(); + } + [Fact] public void Invoke_Sender_IsCorrect() { var testClass = new TestClass(); @@ -43,8 +75,6 @@ public void Invoke_Sender_IsCorrect() { [InlineData("1 test", 1, "test")] [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, string expectedString) { - // This test isn't super isolated, but it should be fine. Mocking IArgumentParser is kind of sketchy because - // of ReadOnlySpan. _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser(), [typeof(string)] = new StringParser() @@ -63,9 +93,13 @@ public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, stri [Theory] [InlineData("", false, false)] - [InlineData("-x", true, false)] - [InlineData("-y", false, true)] - [InlineData("-xy", true, true)] + [InlineData(" -x", true, false)] + [InlineData("--xxx ", true, false)] + [InlineData("-y ", false, true)] + [InlineData(" --yyy", false, true)] + [InlineData(" -xy ", true, true)] + [InlineData(" -x --yyy", true, true)] + [InlineData("--xxx --yyy", true, true)] public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); @@ -78,32 +112,104 @@ public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) testClass.Y.Should().Be(expectedY); } + [Theory] + [InlineData("1", 1, 1234, 5678)] + [InlineData(" 1 ", 1, 1234, 5678)] + [InlineData("--val=9001 1", 1, 9001, 5678)] + [InlineData(" --val=9001 1", 1, 9001, 5678)] + public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int expectedVal, int expectedVal2) { + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser() + }); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Optionals)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Required.Should().Be(expectedRequired); + testClass.Val.Should().Be(expectedVal); + testClass.Val2.Should().Be(expectedVal2); + } + + [Theory] + [InlineData("", false, false, 10)] + [InlineData("-f", true, false, 10)] + [InlineData("--force", true, false, 10)] + [InlineData("-r", false, true, 10)] + [InlineData("--recursive", false, true, 10)] + [InlineData("-fr", true, true, 10)] + [InlineData("-rf", true, true, 10)] + [InlineData("-f --recursive", true, true, 10)] + [InlineData("-r --force", true, true, 10)] + [InlineData("--recursive --force", true, true, 10)] + [InlineData("--depth=1 --recursive --force", true, true, 1)] + [InlineData("-r --force --depth=100 ", true, true, 100)] + public void Invoke_FlagsAndOptionals_IsCorrect(string input, bool expectedForce, bool expectedRecursive, + int expectedDepth) { + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser() + }); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Force.Should().Be(expectedForce); + testClass.Recursive.Should().Be(expectedRecursive); + testClass.Depth.Should().Be(expectedDepth); + } + [Fact] - public void Invoke_InParam_ThrowsParseException() { + public void Invoke_OptionalGetsRenamed() { + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser() + }); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_OptionalRename)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, "--hyphenated-optional-is-long=60"); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.HyphenatedOptionalIsLong.Should().Be(60); + } + + [Theory] + [InlineData("-xyz")] + [InlineData("-z")] + public void Invoke_UnexpectedShortFlag_ThrowsParseException(string input) { var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoIn)); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; - Action action = () => command.Invoke(commandSender, ""); + Action action = () => command.Invoke(commandSender, input); action.Should().Throw(); } - [Fact] - public void Invoke_OutParam_ThrowsParseException() { + [Theory] + [InlineData("--this-is-not-ok")] + [InlineData("--neither-is-this")] + public void Invoke_UnexpectedLongFlag_ThrowsParseException(string input) { var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoOut)); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; - Action action = () => command.Invoke(commandSender, ""); + Action action = () => command.Invoke(commandSender, input); action.Should().Throw(); } - [Fact] - public void Invoke_RefParam_ThrowsParseException() { + [Theory] + [InlineData("--required=123")] + [InlineData("--not-ok=test")] + public void Invoke_UnexpectedOptional_ThrowsParseException(string input) { var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoRef)); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Optionals)); var commandSender = new Mock().Object; - Action action = () => command.Invoke(commandSender, ""); + Action action = () => command.Invoke(commandSender, input); action.Should().Throw(); } @@ -140,6 +246,13 @@ private class TestClass { public string String { get; private set; } public bool X { get; private set; } public bool Y { get; private set; } + public int Required { get; private set; } + public int Val { get; private set; } + public int Val2 { get; private set; } + public bool Force { get; private set; } + public bool Recursive { get; private set; } + public int Depth { get; private set; } + public int HyphenatedOptionalIsLong { get; private set; } [CommandHandler("tshock_tests:test")] public void TestCommand(ICommandSender sender) { @@ -163,6 +276,9 @@ public void TestCommand_NoOut(ICommandSender sender, out int x) { [CommandHandler("tshock_tests:test_no_out")] public void TestCommand_NoRef(ICommandSender sender, ref int x) { } + + [CommandHandler("tshock_tests:test_no_ptr")] + public unsafe void TestCommand_NoPointer(ICommandSender sender, int* x) { } [CommandHandler("tshock_tests:test_flags")] public void TestCommand_Flags(ICommandSender sender, [Flag('x', "xxx")] bool x, [Flag('y', "yyy")] bool y) { @@ -170,6 +286,29 @@ public void TestCommand_Flags(ICommandSender sender, [Flag('x', "xxx")] bool x, X = x; Y = y; } + + [CommandHandler("tshock_tests:test_optionals")] + public void TestCommand_Optionals(ICommandSender sender, int required, int val = 1234, int val2 = 5678) { + Sender = sender; + Required = required; + Val = val; + Val2 = val2; + } + + [CommandHandler("tshock_tests:test_flags_and_optionals")] + public void TestCommand_FlagsAndOptionals(ICommandSender sender, [Flag('f', "force")] bool force, + [Flag('r', "recursive")] bool recursive, int depth = 10) { + Sender = sender; + Force = force; + Recursive = recursive; + Depth = depth; + } + + [CommandHandler("tshock_tests:test_optional_rename")] + public void TestCommand_OptionalRename(ICommandSender sender, int hyphenated_optional_is_long = 100) { + Sender = sender; + HyphenatedOptionalIsLong = hyphenated_optional_is_long; + } } } } diff --git a/tests/TShock.Tests/TShock.Tests.csproj b/tests/TShock.Tests/TShock.Tests.csproj index 1648d3fe4..1a3c554a1 100644 --- a/tests/TShock.Tests/TShock.Tests.csproj +++ b/tests/TShock.Tests/TShock.Tests.csproj @@ -2,10 +2,9 @@ netcoreapp3.0 - false - TShock + true From 3f1f738a9e4378e83310dfc49888978430555436 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 21:32:03 -0700 Subject: [PATCH 027/119] Rename ParseException to CommandParseException and inherit from CommandException. --- ...arseException.cs => CommandParseException.cs} | 16 ++++++++-------- src/TShock/Commands/ICommand.cs | 2 +- src/TShock/Commands/Parsers/IArgumentParser.cs | 2 +- src/TShock/Commands/Parsers/IArgumentParser`1.cs | 4 ++-- src/TShock/Commands/Parsers/Int32Parser.cs | 4 ++-- src/TShock/Commands/Parsers/StringParser.cs | 4 ++-- src/TShock/Commands/TShockCommand.cs | 10 +++++----- .../Commands/Parsers/Int32ParserTests.cs | 4 ++-- .../Commands/Parsers/StringParserTests.cs | 2 +- .../TShock.Tests/Commands/TShockCommandTests.cs | 6 +++--- 10 files changed, 27 insertions(+), 27 deletions(-) rename src/TShock/Commands/{Parsers/ParseException.cs => CommandParseException.cs} (66%) diff --git a/src/TShock/Commands/Parsers/ParseException.cs b/src/TShock/Commands/CommandParseException.cs similarity index 66% rename from src/TShock/Commands/Parsers/ParseException.cs rename to src/TShock/Commands/CommandParseException.cs index 47467637b..e0a545b2a 100644 --- a/src/TShock/Commands/Parsers/ParseException.cs +++ b/src/TShock/Commands/CommandParseException.cs @@ -18,29 +18,29 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace TShock.Commands.Parsers { +namespace TShock.Commands { /// /// The exception thrown when a command input cannot be parsed. /// [Serializable, ExcludeFromCodeCoverage] - public class ParseException : Exception { + public class CommandParseException : CommandException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public ParseException() { } + public CommandParseException() { } /// - /// Initializes a new instance of the class with the specified message. + /// Initializes a new instance of the class with the specified message. /// /// The message. - public ParseException(string message) : base(message) { } + public CommandParseException(string message) : base(message) { } /// - /// Initializes a new instance of the class with the specified message + /// Initializes a new instance of the class with the specified message /// and inner exception. /// /// The message. /// The inner exception. - public ParseException(string message, Exception innerException) : base(message, innerException) { } + public CommandParseException(string message, Exception innerException) : base(message, innerException) { } } } diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 102d163ff..8950724c4 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -54,7 +54,7 @@ public interface ICommand { /// or are null. /// /// The command could not be executed. - /// The command input could not be parsed. + /// The command input could not be parsed. void Invoke(ICommandSender sender, string input); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 2d2de9f58..108d08f21 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -29,7 +29,7 @@ public interface IArgumentParser { /// The input. /// The parse options. /// A corresponding object. - /// The input could not be parsed properly. + /// The input could not be parsed properly. object Parse(ref ReadOnlySpan input, ISet? options = null); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 229f9272f..9719eb17d 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -20,7 +20,7 @@ namespace TShock.Commands.Parsers { /// - /// Provides parsing support for a type. + /// Provides type-safe parsing support. /// /// The parse type. public interface IArgumentParser : IArgumentParser { @@ -30,7 +30,7 @@ public interface IArgumentParser : IArgumentParser { /// The input. /// The parse options. /// A corresponding instance of the parse type. - /// The input could not be parsed properly. + /// The input could not be parsed properly. new TParse Parse(ref ReadOnlySpan input, ISet? options = null); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index bd6256f5b..a87d77465 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -33,9 +33,9 @@ public int Parse(ref ReadOnlySpan input, ISet? options= null) { try { return int.Parse(parse); } catch (FormatException ex) { - throw new ParseException(string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); + throw new CommandParseException(string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); } catch (OverflowException ex) { - throw new ParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); + throw new CommandParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 2ecdddb72..b5ee44ea6 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -52,7 +52,7 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) // Handle escape characters. if (c == '\\') { - if (++end >= input.Length) throw new ParseException(Resources.StringParser_EndOfInput); + if (++end >= input.Length) throw new CommandParseException(Resources.StringParser_EndOfInput); var nextC = input[end]; if (nextC == '"' || nextC == '\\' || char.IsWhiteSpace(nextC)) { @@ -62,7 +62,7 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) } else if (nextC == 'n') { builder.Append('\n'); } else { - throw new ParseException(string.Format(Resources.StringParser_UnexpectedEscape, nextC)); + throw new CommandParseException(string.Format(Resources.StringParser_UnexpectedEscape, nextC)); } ++end; diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index d0dc4a990..0a204ab6c 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -113,7 +113,7 @@ ISet ParseShortFlags(ref ReadOnlySpan input) { for (var i = start; i < end; ++i) { var c = input[i]; if (!validShortFlags.Contains(c)) { - throw new ParseException(string.Format(Resources.CommandParse_BadShortFlag, c)); + throw new CommandParseException(string.Format(Resources.CommandParse_BadShortFlag, c)); } shortFlags.Add(c); @@ -140,7 +140,7 @@ ISet ParseShortFlags(ref ReadOnlySpan input) { if (isLongFlag) { var longFlag = input[start..end].ToString(); if (!validLongFlags.Contains(longFlag)) { - throw new ParseException(string.Format(Resources.CommandParse_BadLongFlag, longFlag)); + throw new CommandParseException(string.Format(Resources.CommandParse_BadLongFlag, longFlag)); } longFlags.Add(longFlag); @@ -148,12 +148,12 @@ ISet ParseShortFlags(ref ReadOnlySpan input) { } else { var optional = input[start..end].ToString(); if (!validOptionals.TryGetValue(optional, out var parameter)) { - throw new ParseException(string.Format(Resources.CommandParse_BadOptional, optional)); + throw new CommandParseException(string.Format(Resources.CommandParse_BadOptional, optional)); } var parameterType = parameter.ParameterType; if (!parsers.TryGetValue(parameterType, out var parser)) { - throw new ParseException( + throw new CommandParseException( string.Format(Resources.CommandParse_NoParserFound, parameterType)); } @@ -201,7 +201,7 @@ ISet ParseShortFlags(ref ReadOnlySpan input) { } // Otherwise, it's impossible to parse. - throw new ParseException(string.Format(Resources.CommandParse_NoParserFound, parameterType)); + throw new CommandParseException(string.Format(Resources.CommandParse_NoParserFound, parameterType)); } var parameterInfos = Handler.GetParameters(); diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index 9fe4a6360..b41304632 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -47,7 +47,7 @@ public void Parse_IntegerOutOfRange_ThrowsParseException(string inputString) { return parser.Parse(ref input); }; - func.Should().Throw().WithInnerException(); + func.Should().Throw().WithInnerException(); } [Theory] @@ -61,7 +61,7 @@ public void Parse_InvalidInteger_ThrowsParseException(string inputString) { return parser.Parse(ref input); }; - func.Should().Throw().WithInnerException(); + func.Should().Throw().WithInnerException(); } } } diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index d4968a417..1ae878e92 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -55,7 +55,7 @@ public void Parse_EscapeReachesEnd_ThrowsParseException(string inputString) { return parser.Parse(ref input); }; - func.Should().Throw(); + func.Should().Throw(); } [Fact] diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index b0639250e..12a37b13c 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -187,7 +187,7 @@ public void Invoke_UnexpectedShortFlag_ThrowsParseException(string input) { var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); - action.Should().Throw(); + action.Should().Throw(); } [Theory] @@ -199,7 +199,7 @@ public void Invoke_UnexpectedLongFlag_ThrowsParseException(string input) { var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); - action.Should().Throw(); + action.Should().Throw(); } [Theory] @@ -211,7 +211,7 @@ public void Invoke_UnexpectedOptional_ThrowsParseException(string input) { var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); - action.Should().Throw(); + action.Should().Throw(); } [Fact] From ca06750542e6bc19e0c85bca8f5c766538be7353 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 21:39:20 -0700 Subject: [PATCH 028/119] Add AllowEmpty option for StringParser. --- src/TShock/Commands/Parsers/ParseOptions.cs | 5 ++++ src/TShock/Commands/Parsers/StringParser.cs | 8 +++++++ .../Commands/Parsers/StringParserTests.cs | 24 ++++++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/TShock/Commands/Parsers/ParseOptions.cs b/src/TShock/Commands/Parsers/ParseOptions.cs index 78c95efc8..562b3b3dd 100644 --- a/src/TShock/Commands/Parsers/ParseOptions.cs +++ b/src/TShock/Commands/Parsers/ParseOptions.cs @@ -24,5 +24,10 @@ public static class ParseOptions { /// An option which forces a string to be parsed to the end of the input. /// public const string ToEndOfInput = nameof(ToEndOfInput); + + /// + /// An option which allows a parser to parse empty input. + /// + public const string AllowEmpty = nameof(AllowEmpty); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index b5ee44ea6..194a5225a 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -33,6 +33,14 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) } var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + if (start >= input.Length) { + if (options?.Contains(ParseOptions.AllowEmpty) != true) { + throw new CommandParseException(Resources.StringParser_EndOfInput); + } + + input = default; + return string.Empty; + } // Begin building our string character-by-character. var builder = new StringBuilder(); diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index 1ae878e92..2e05228c9 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -23,7 +23,6 @@ namespace TShock.Commands.Parsers { public class StringParserTests { [Theory] - [InlineData("", "", "")] [InlineData("test", "test", "")] [InlineData("test abc", "test", " abc")] [InlineData(" test", "test", "")] @@ -45,6 +44,19 @@ public void Parse_IsCorrect(string inputString, string expected, string expected input.ToString().Should().Be(expectedNextInput); } + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_Empty_ThrowsParseException(string inputString) { + var parser = new StringParser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input); + }; + + func.Should().Throw(); + } + [Theory] [InlineData(@"\")] [InlineData(@"\a")] @@ -68,5 +80,15 @@ public void Parse_ToEndOfInput_IsCorrect() { input.ToString().Should().BeEmpty(); } + + [Fact] + public void Parse_AllowEmpty_IsCorrect() { + var parser = new StringParser(); + var input = string.Empty.AsSpan(); + + parser.Parse(ref input, new HashSet {ParseOptions.AllowEmpty}).Should().BeEmpty(); + + input.ToString().Should().BeEmpty(); + } } } From 521032975d093abb6d5cc0916e64086389a5963f Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 21:49:53 -0700 Subject: [PATCH 029/119] Add check for too many command arguments. --- src/TShock/Commands/Parsers/FlagAttribute.cs | 2 +- src/TShock/Commands/TShockCommand.cs | 3 +++ src/TShock/Properties/Resources.Designer.cs | 9 +++++++++ src/TShock/Properties/Resources.resx | 3 +++ tests/TShock.Tests/Commands/TShockCommandTests.cs | 12 ++++++++++++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index 2978ee706..f3f37c679 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -29,7 +29,7 @@ public sealed class FlagAttribute : Attribute { public char ShortFlag { get; } /// - /// Gets the long form of the flag. + /// Gets the long form of the flag. If null, then there is no long flag. /// public string? LongFlag { get; } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 0a204ab6c..cc38abc68 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -210,6 +210,9 @@ ISet ParseShortFlags(ref ReadOnlySpan input) { parameters[i] = ProcessParameter(parameterInfos[i], ref input); } + var end = input.ScanFor(c => !char.IsWhiteSpace(c)); + if (end < input.Length) throw new CommandParseException(Resources.CommandParse_TooManyArgs); + try { Handler.Invoke(HandlerObject, parameters); } catch (TargetInvocationException ex) { diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index b893f39c5..61ee8025b 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -123,6 +123,15 @@ internal static string CommandParse_NoParserFound { } } + /// + /// Looks up a localized string similar to Too many arguments were provided.. + /// + internal static string CommandParse_TooManyArgs { + get { + return ResourceManager.GetString("CommandParse_TooManyArgs", resourceCulture); + } + } + /// /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 5f8575126..61f7471f8 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -138,6 +138,9 @@ No parser found for argument type "{0}". + + Too many arguments were provided. + "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 12a37b13c..e6e9c59e3 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -214,6 +214,18 @@ public void Invoke_UnexpectedOptional_ThrowsParseException(string input) { action.Should().Throw(); } + [Theory] + [InlineData("a")] + [InlineData("bcd")] + public void Invoke_TooManyArguments_ThrowsParseException(string input) { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + [Fact] public void Invoke_NullSender_ThrowsArgumentNullException() { var testClass = new TestClass(); From 2653690d2469d8d2cc37539c7d52f3873e93b222 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 21:55:51 -0700 Subject: [PATCH 030/119] Add Int32Parser restrictions on empty input. --- src/TShock/Commands/Parsers/Int32Parser.cs | 9 +++++++ src/TShock/Properties/Resources.Designer.cs | 9 +++++++ src/TShock/Properties/Resources.resx | 3 +++ .../Commands/Parsers/Int32ParserTests.cs | 24 +++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index a87d77465..c29ea9570 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -25,6 +25,15 @@ namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { public int Parse(ref ReadOnlySpan input, ISet? options= null) { var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + if (start >= input.Length) { + if (options?.Contains(ParseOptions.AllowEmpty) != true) { + throw new CommandParseException(Resources.Int32Parser_MissingInteger); + } + + input = default; + return 0; + } + var end = input.ScanFor(char.IsWhiteSpace, start); var parse = input[start..end]; input = input[end..]; diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 61ee8025b..edf10d09e 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -150,6 +150,15 @@ internal static string Int32Parser_InvalidInteger { } } + /// + /// Looks up a localized string similar to No integer was provided.. + /// + internal static string Int32Parser_MissingInteger { + get { + return ResourceManager.GetString("Int32Parser_MissingInteger", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unexpected end of input.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 61f7471f8..a3fa96bb2 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -147,6 +147,9 @@ "{0}" is not a valid integer. + + No integer was provided. + Unexpected end of input. diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index b41304632..479af3366 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using FluentAssertions; using Xunit; @@ -36,6 +37,29 @@ public void Parse_IsCorrect(string inputString, int expected, string expectedNex input.ToString().Should().Be(expectedNextInput); } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_AllowEmpty_IsCorrect(string inputString) { + var parser = new Int32Parser(); + var input = inputString.AsSpan(); + + parser.Parse(ref input, new HashSet {ParseOptions.AllowEmpty}).Should().Be(0); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_MissingInteger_ThrowsParseException(string inputString) { + var parser = new Int32Parser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input); + }; + + func.Should().Throw(); + } [Theory] [InlineData("2147483648")] From bca8dab55364c2137a99cc1718cf7fa3fd56d2bb Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 21:58:08 -0700 Subject: [PATCH 031/119] Change some Int32 exception messages. --- src/TShock/Commands/Parsers/Int32Parser.cs | 8 +++++--- src/TShock/Properties/Resources.Designer.cs | 2 +- src/TShock/Properties/Resources.resx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index c29ea9570..2bf623b48 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -23,7 +23,7 @@ namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { - public int Parse(ref ReadOnlySpan input, ISet? options= null) { + public int Parse(ref ReadOnlySpan input, ISet? options = null) { var start = input.ScanFor(c => !char.IsWhiteSpace(c)); if (start >= input.Length) { if (options?.Contains(ParseOptions.AllowEmpty) != true) { @@ -42,9 +42,11 @@ public int Parse(ref ReadOnlySpan input, ISet? options= null) { try { return int.Parse(parse); } catch (FormatException ex) { - throw new CommandParseException(string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); + throw new CommandParseException( + string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); } catch (OverflowException ex) { - throw new CommandParseException(string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); + throw new CommandParseException( + string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index edf10d09e..d12e9a05e 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -142,7 +142,7 @@ internal static string Int32Parser_IntegerOutOfRange { } /// - /// Looks up a localized string similar to "{0}" is not a valid integer.. + /// Looks up a localized string similar to "{0}" is an invalid integer.. /// internal static string Int32Parser_InvalidInteger { get { diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index a3fa96bb2..a169a92f6 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -145,7 +145,7 @@ "{0}" is a number that is out of range of an integer. - "{0}" is not a valid integer. + "{0}" is an invalid integer. No integer was provided. From ab531e80bd24c6b231471129cc7ee41e2ae6ba59 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 22:04:31 -0700 Subject: [PATCH 032/119] Change StringParser exception messages. --- src/TShock/Commands/Parsers/StringParser.cs | 10 +++++++--- src/TShock/Properties/Resources.Designer.cs | 21 +++++++++++++++------ src/TShock/Properties/Resources.resx | 11 +++++++---- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 194a5225a..8ff9373ac 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -26,6 +26,7 @@ namespace TShock.Commands.Parsers { // It'd be nice to return ReadOnlySpan, but because of escape characters, we have to return copies. internal sealed class StringParser : IArgumentParser { public string Parse(ref ReadOnlySpan input, ISet? options = null) { + // ToEndOfInput should have higher priority than AllowEmpty since empty strings should be permitted. if (options?.Contains(ParseOptions.ToEndOfInput) == true) { var result = input.ToString(); input = default; @@ -35,7 +36,7 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) var start = input.ScanFor(c => !char.IsWhiteSpace(c)); if (start >= input.Length) { if (options?.Contains(ParseOptions.AllowEmpty) != true) { - throw new CommandParseException(Resources.StringParser_EndOfInput); + throw new CommandParseException(Resources.StringParser_MissingString); } input = default; @@ -60,7 +61,9 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) // Handle escape characters. if (c == '\\') { - if (++end >= input.Length) throw new CommandParseException(Resources.StringParser_EndOfInput); + if (++end >= input.Length) { + throw new CommandParseException(Resources.StringParser_InvalidBackslash); + } var nextC = input[end]; if (nextC == '"' || nextC == '\\' || char.IsWhiteSpace(nextC)) { @@ -70,7 +73,8 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) } else if (nextC == 'n') { builder.Append('\n'); } else { - throw new CommandParseException(string.Format(Resources.StringParser_UnexpectedEscape, nextC)); + throw new CommandParseException( + string.Format(Resources.StringParser_UnrecognizedEscape, nextC)); } ++end; diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index d12e9a05e..43125b4f8 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -160,20 +160,29 @@ internal static string Int32Parser_MissingInteger { } /// - /// Looks up a localized string similar to Unexpected end of input.. + /// Looks up a localized string similar to Invalid backslash.. /// - internal static string StringParser_EndOfInput { + internal static string StringParser_InvalidBackslash { get { - return ResourceManager.GetString("StringParser_EndOfInput", resourceCulture); + return ResourceManager.GetString("StringParser_InvalidBackslash", resourceCulture); } } /// - /// Looks up a localized string similar to Escape character "\{0}" was unexpected.. + /// Looks up a localized string similar to No string was provided.. /// - internal static string StringParser_UnexpectedEscape { + internal static string StringParser_MissingString { get { - return ResourceManager.GetString("StringParser_UnexpectedEscape", resourceCulture); + return ResourceManager.GetString("StringParser_MissingString", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Escape character "\{0}" not recognized.. + /// + internal static string StringParser_UnrecognizedEscape { + get { + return ResourceManager.GetString("StringParser_UnrecognizedEscape", resourceCulture); } } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index a169a92f6..b0be55ac4 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -150,10 +150,13 @@ No integer was provided. - - Unexpected end of input. + + Invalid backslash. - - Escape character "\{0}" was unexpected. + + No string was provided. + + + Escape character "\{0}" not recognized. \ No newline at end of file From 8659dca2654f9f31e4ec007a3b7e139acfe5b593 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 22:49:41 -0700 Subject: [PATCH 033/119] Restructure TShockCommand implementation. --- src/TShock/Commands/TShockCommand.cs | 188 ++++++++++-------- src/TShock/Properties/Resources.Designer.cs | 51 +++-- src/TShock/Properties/Resources.resx | 31 +-- .../Commands/TShockCommandTests.cs | 4 +- 4 files changed, 152 insertions(+), 122 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index cc38abc68..4281ea774 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -32,13 +32,14 @@ internal class TShockCommand : ICommand { private static readonly IDictionary EmptyOptionals = new Dictionary(); private readonly ICommandService _commandService; - private readonly CommandHandlerAttribute _attribute; - private readonly ISet validShortFlags = new HashSet(); - private readonly ISet validLongFlags = new HashSet(); - private readonly IDictionary validOptionals = new Dictionary(); - - public string Name => _attribute.CommandName; - public IEnumerable SubNames => _attribute.CommandSubNames; + private readonly ISet _validShortFlags = new HashSet(); + private readonly ISet _validLongFlags = new HashSet(); + private readonly IDictionary _validOptionals = new Dictionary(); + private readonly ParameterInfo[] _parameterInfos; + private readonly object?[] _parameters; + + public string Name { get; } + public IEnumerable SubNames { get; } public object HandlerObject { get; } public MethodBase Handler { get; } @@ -49,43 +50,47 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att Debug.Assert(handler != null, "handler != null"); _commandService = commandService; - _attribute = attribute; + Name = attribute.CommandName; + SubNames = attribute.CommandSubNames; HandlerObject = handlerObject; Handler = handler; + // Preprocessing parameters in the constructor allows us to learn the command's flags and optionals. void PreprocessParameter(ParameterInfo parameterInfo) { var parameterType = parameterInfo.ParameterType; // Check for types we don't support, such as by-reference and pointers. if (parameterType.IsByRef) { throw new NotSupportedException( - string.Format(Resources.CommandCtor_ArgIsByReference, parameterInfo)); + string.Format(Resources.CommandCtor_ByRefArgType, parameterType)); } if (parameterType.IsPointer) { - throw new NotSupportedException(string.Format(Resources.CommandCtor_ArgIsPointer, parameterInfo)); + throw new NotSupportedException( + string.Format(Resources.CommandCtor_PointerArgType, parameterType)); } - // If the parameter is a bool, then it should be marked with FlagAttribute and we'll note it. + // If the parameter is a bool and it is marked with FlagAttribute, we'll note it. if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); if (attribute != null) { - validShortFlags.Add(attribute.ShortFlag); + _validShortFlags.Add(attribute.ShortFlag); if (attribute.LongFlag != null) { - validLongFlags.Add(attribute.LongFlag); + _validLongFlags.Add(attribute.LongFlag); } } } - // If the parameter is optional, we'll take note of that. We replace underscores with hyphens here - // because hyphens are not valid in C# identifiers. + // If the parameter is optional, we'll note it. We replace underscores with hyphens here since hyphens + // aren't valid in C# identifiers. if (parameterInfo.IsOptional) { - validOptionals.Add(parameterInfo.Name.Replace('_', '-'), parameterInfo); + _validOptionals.Add(parameterInfo.Name.Replace('_', '-'), parameterInfo); } } - // Scan through the parameters and learn flags and optionals. - foreach (var parameter in Handler.GetParameters()) { + _parameterInfos = Handler.GetParameters(); + _parameters = new object?[_parameterInfos.Length]; + foreach (var parameter in _parameterInfos) { PreprocessParameter(parameter); } } @@ -98,87 +103,106 @@ public void Invoke(ICommandSender sender, string inputString) { _commandService.CommandExecute?.Invoke(this, args); if (args.IsCanceled()) return; - var parsers = _commandService.RegisteredParsers; + var shortFlags = new HashSet(); + var longFlags = new HashSet(); + var optionals = new Dictionary(); - ISet ParseShortFlags(ref ReadOnlySpan input) { - // Quick return if there are no valid short flags. - if (validShortFlags.Count == 0) return EmptyShortFlags; + object ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo) { + var parameterType = parameterInfo.ParameterType; + if (!_commandService.RegisteredParsers.TryGetValue(parameterType, out var parser)) { + throw new CommandParseException( + string.Format(Resources.CommandParse_UnrecognizedArgType, parameterType)); + } - var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (start >= input.Length || input[start++] != '-') return EmptyShortFlags; - if (start >= input.Length || input[start] == '-') return EmptyShortFlags; - + var options = parameterInfo.GetCustomAttribute()?.Options; + return parser.Parse(ref input, options); + } + + void ParseShortFlags(ref ReadOnlySpan input, int start) { var end = input.ScanFor(char.IsWhiteSpace, start); - var shortFlags = new HashSet(); for (var i = start; i < end; ++i) { var c = input[i]; - if (!validShortFlags.Contains(c)) { - throw new CommandParseException(string.Format(Resources.CommandParse_BadShortFlag, c)); + if (!_validShortFlags.Contains(c)) { + throw new CommandParseException( + string.Format(Resources.CommandParse_UnrecognizedShortFlag, c)); } shortFlags.Add(c); } input = input[end..]; - return shortFlags; } - (ISet longFlags, IDictionary optionals) ParseLongFlagsAndOptionals( - ref ReadOnlySpan input) { - // Quick return if there are no valid long flags and optionals. - if (validLongFlags.Count == 0 && validOptionals.Count == 0) return (EmptyLongFlags, EmptyOptionals); + void ParseLongFlag(ref ReadOnlySpan input, int start, int end) { + var longFlag = input[start..end].ToString(); + if (!_validLongFlags.Contains(longFlag)) { + throw new CommandParseException( + string.Format(Resources.CommandParse_UnrecognizedLongFlag, longFlag)); + } + + input = input[end..]; + longFlags.Add(longFlag); + } + + void ParseOptional(ref ReadOnlySpan input, int start, int end) { + var optional = input[start..end].ToString(); + if (!_validOptionals.TryGetValue(optional, out var parameterInfo)) { + throw new CommandParseException( + string.Format(Resources.CommandParse_UnrecognizedOptional, optional)); + } + + // Skip over the '='. + input = input[(end + 1)..]; + optionals[optional] = ParseArgument(ref input, parameterInfo); + } - var longFlags = new HashSet(); - var optionals = new Dictionary(); + /* + * Parse all hyphenated arguments: + * - Short flags are single-character flags and use one hyphen: "-f". + * - Long flags are string flags and use two hyphens: "--force". + * - Optionals specify values with two hyphens: "--depth=10". + */ + void ParseHyphenatedArgs(ref ReadOnlySpan input) { while (true) { var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (start >= input.Length || input[start++] != '-') break; - if (start >= input.Length || input[start++] != '-') break; - - var end = input.ScanFor(c => char.IsWhiteSpace(c) || c == '=', start); - var isLongFlag = end >= input.Length || input[end] != '='; - if (isLongFlag) { - var longFlag = input[start..end].ToString(); - if (!validLongFlags.Contains(longFlag)) { - throw new CommandParseException(string.Format(Resources.CommandParse_BadLongFlag, longFlag)); - } + if (start >= input.Length || input[start] != '-') { + input = input[start..]; + break; + } - longFlags.Add(longFlag); - input = input[end..]; - } else { - var optional = input[start..end].ToString(); - if (!validOptionals.TryGetValue(optional, out var parameter)) { - throw new CommandParseException(string.Format(Resources.CommandParse_BadOptional, optional)); + if (++start >= input.Length) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } + + if (input[start] == '-') { + if (++start >= input.Length) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); } - var parameterType = parameter.ParameterType; - if (!parsers.TryGetValue(parameterType, out var parser)) { - throw new CommandParseException( - string.Format(Resources.CommandParse_NoParserFound, parameterType)); + var end = input.ScanFor(c => char.IsWhiteSpace(c) || c == '=', start); + if (end >= input.Length || input[end] != '=') { + ParseLongFlag(ref input, start, end); + } else { + ParseOptional(ref input, start, end); } - - ++end; - input = input[end..]; - var options = parameter.GetCustomAttribute()?.Options; - optionals[optional] = parser.Parse(ref input, options); + } else { + ParseShortFlags(ref input, start); } } - - return (longFlags, optionals); } - var input = inputString.AsSpan(); - var shortFlags = ParseShortFlags(ref input); - var (longFlags, optionals) = ParseLongFlagsAndOptionals(ref input); - - object? ProcessParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { + /* + * Parse a parameter: + * - If the parameter is an ICommandSender, then inject sender. + * - If the parameter is a bool and is marked with FlagAttribute, then inject the flag. + * - If the parameter is optional, then inject the optional or else the default value. + * - Otherwise, we parse the argument directly. + */ + object? ParseParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { var parameterType = parameterInfo.ParameterType; - // Special case: parameter is an ICommandSender, in which case we inject sender. if (parameterType == typeof(ICommandSender)) return sender; - // Special case: parameter is a bool and is marked with FlagAttribute, in which case we look up - // shortFlags and longFlags and inject that. if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); if (attribute != null) { @@ -187,34 +211,28 @@ ISet ParseShortFlags(ref ReadOnlySpan input) { } } - // Special case: parameter is optional, in which case we look up optionals and try injecting that. If - // that fails, then we just inject the default value. if (parameterInfo.IsOptional) { var optional = parameterInfo.Name.Replace('_', '-'); return optionals.TryGetValue(optional, out var value) ? value : parameterInfo.DefaultValue; } - // If we can directly parse the parameter type, then do so. - if (parsers.TryGetValue(parameterType, out var parser)) { - var options = parameterInfo.GetCustomAttribute()?.Options; - return parser.Parse(ref input, options); - } + return ParseArgument(ref input, parameterInfo); + } - // Otherwise, it's impossible to parse. - throw new CommandParseException(string.Format(Resources.CommandParse_NoParserFound, parameterType)); + var input = inputString.AsSpan(); + if (_validShortFlags.Count > 0 || _validLongFlags.Count > 0 || _validOptionals.Count > 0) { + ParseHyphenatedArgs(ref input); } - var parameterInfos = Handler.GetParameters(); - var parameters = new object?[parameterInfos.Length]; - for (var i = 0; i < parameters.Length; ++i) { - parameters[i] = ProcessParameter(parameterInfos[i], ref input); + for (var i = 0; i < _parameters.Length; ++i) { + _parameters[i] = ParseParameter(_parameterInfos[i], ref input); } var end = input.ScanFor(c => !char.IsWhiteSpace(c)); if (end < input.Length) throw new CommandParseException(Resources.CommandParse_TooManyArgs); try { - Handler.Invoke(HandlerObject, parameters); + Handler.Invoke(HandlerObject, _parameters); } catch (TargetInvocationException ex) { sender.Log.Error(ex.InnerException, Resources.CommandInvoke_Exception); throw new CommandException(Resources.CommandInvoke_Exception, ex.InnerException); diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 43125b4f8..c1276e296 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -61,20 +61,20 @@ internal Resources() { } /// - /// Looks up a localized string similar to By-reference argument "{0}" is not supported.. + /// Looks up a localized string similar to By-reference argument type "{0}" not supported.. /// - internal static string CommandCtor_ArgIsByReference { + internal static string CommandCtor_ByRefArgType { get { - return ResourceManager.GetString("CommandCtor_ArgIsByReference", resourceCulture); + return ResourceManager.GetString("CommandCtor_ByRefArgType", resourceCulture); } } /// - /// Looks up a localized string similar to Pointer argument "{0}" is not supported.. + /// Looks up a localized string similar to Pointer argument type "{0}" not supported.. /// - internal static string CommandCtor_ArgIsPointer { + internal static string CommandCtor_PointerArgType { get { - return ResourceManager.GetString("CommandCtor_ArgIsPointer", resourceCulture); + return ResourceManager.GetString("CommandCtor_PointerArgType", resourceCulture); } } @@ -88,47 +88,56 @@ internal static string CommandInvoke_Exception { } /// - /// Looks up a localized string similar to Long flag "--{0}" was unexpected.. + /// Looks up a localized string similar to Invalid hyphenated argument.. /// - internal static string CommandParse_BadLongFlag { + internal static string CommandParse_InvalidHyphenatedArg { get { - return ResourceManager.GetString("CommandParse_BadLongFlag", resourceCulture); + return ResourceManager.GetString("CommandParse_InvalidHyphenatedArg", resourceCulture); } } /// - /// Looks up a localized string similar to Optional argument "{0}" was unexpected.. + /// Looks up a localized string similar to Too many arguments were provided.. /// - internal static string CommandParse_BadOptional { + internal static string CommandParse_TooManyArgs { get { - return ResourceManager.GetString("CommandParse_BadOptional", resourceCulture); + return ResourceManager.GetString("CommandParse_TooManyArgs", resourceCulture); } } /// - /// Looks up a localized string similar to Short flag "-{0}" was unexpected.. + /// Looks up a localized string similar to Argument type "{0}" not recognized.. /// - internal static string CommandParse_BadShortFlag { + internal static string CommandParse_UnrecognizedArgType { get { - return ResourceManager.GetString("CommandParse_BadShortFlag", resourceCulture); + return ResourceManager.GetString("CommandParse_UnrecognizedArgType", resourceCulture); } } /// - /// Looks up a localized string similar to No parser found for argument type "{0}".. + /// Looks up a localized string similar to Long flag "--{0}" not recognized.. /// - internal static string CommandParse_NoParserFound { + internal static string CommandParse_UnrecognizedLongFlag { get { - return ResourceManager.GetString("CommandParse_NoParserFound", resourceCulture); + return ResourceManager.GetString("CommandParse_UnrecognizedLongFlag", resourceCulture); } } /// - /// Looks up a localized string similar to Too many arguments were provided.. + /// Looks up a localized string similar to Optional argument "{0}" not recognized.. /// - internal static string CommandParse_TooManyArgs { + internal static string CommandParse_UnrecognizedOptional { get { - return ResourceManager.GetString("CommandParse_TooManyArgs", resourceCulture); + return ResourceManager.GetString("CommandParse_UnrecognizedOptional", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Short flag "-{0}" not recognized.. + /// + internal static string CommandParse_UnrecognizedShortFlag { + get { + return ResourceManager.GetString("CommandParse_UnrecognizedShortFlag", resourceCulture); } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index b0be55ac4..85d1ec05b 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,29 +117,32 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - By-reference argument "{0}" is not supported. + + By-reference argument type "{0}" not supported. - - Pointer argument "{0}" is not supported. + + Pointer argument type "{0}" not supported. Exception occurred while executing command. - - Long flag "--{0}" was unexpected. + + Invalid hyphenated argument. - - Optional argument "{0}" was unexpected. + + Too many arguments were provided. - - Short flag "-{0}" was unexpected. + + Argument type "{0}" not recognized. - - No parser found for argument type "{0}". + + Long flag "--{0}" not recognized. - - Too many arguments were provided. + + Optional argument "{0}" not recognized. + + + Short flag "-{0}" not recognized. "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index e6e9c59e3..e25dccf40 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -144,8 +144,8 @@ public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int e [InlineData("-f --recursive", true, true, 10)] [InlineData("-r --force", true, true, 10)] [InlineData("--recursive --force", true, true, 10)] - [InlineData("--depth=1 --recursive --force", true, true, 1)] - [InlineData("-r --force --depth=100 ", true, true, 100)] + [InlineData("--depth=1 --recursive -f", true, true, 1)] + [InlineData("--force -r --depth=100 ", true, true, 100)] public void Invoke_FlagsAndOptionals_IsCorrect(string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { From 0b5206f359c6a53da2ee6ad85bf35358e2891976 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 22:49:57 -0700 Subject: [PATCH 034/119] Remove unused static readonly fields. --- src/TShock/Commands/TShockCommand.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 4281ea774..c1f7ff020 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -27,10 +27,6 @@ namespace TShock.Commands { internal class TShockCommand : ICommand { - private static readonly ISet EmptyShortFlags = new HashSet(); - private static readonly ISet EmptyLongFlags = new HashSet(); - private static readonly IDictionary EmptyOptionals = new Dictionary(); - private readonly ICommandService _commandService; private readonly ISet _validShortFlags = new HashSet(); private readonly ISet _validLongFlags = new HashSet(); From 91fc5f7ba86cec6e02d89062fc9f7dade117a06d Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 23:26:29 -0700 Subject: [PATCH 035/119] Improve command parsing some more. --- .../Commands/Parsers/IArgumentParser.cs | 8 +- .../Commands/Parsers/IArgumentParser`1.cs | 8 +- src/TShock/Commands/Parsers/Int32Parser.cs | 19 ++-- src/TShock/Commands/Parsers/StringParser.cs | 19 ++-- src/TShock/Commands/TShockCommand.cs | 40 ++++++-- src/TShock/Properties/Resources.Designer.cs | 9 ++ src/TShock/Properties/Resources.resx | 3 + .../Commands/Parsers/Int32ParserTests.cs | 11 --- .../Commands/Parsers/StringParserTests.cs | 14 --- .../Commands/TShockCommandTests.cs | 97 ++++++++++++++++--- 10 files changed, 152 insertions(+), 76 deletions(-) diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 108d08f21..102b05851 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -26,10 +26,16 @@ public interface IArgumentParser { /// /// Parses the given input and returns a corresponding object. /// - /// The input. + /// The input. This is guaranteed to start with a non-whitespace character. /// The parse options. /// A corresponding object. /// The input could not be parsed properly. object Parse(ref ReadOnlySpan input, ISet? options = null); + + /// + /// Gets a default object. + /// + /// The default object. + object GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 9719eb17d..17a87a789 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -27,10 +27,16 @@ public interface IArgumentParser : IArgumentParser { /// /// Parses the given input and returns a corresponding instance of the parse type. /// - /// The input. + /// The input. This is guaranteed to start with a non-whitespace character. /// The parse options. /// A corresponding instance of the parse type. /// The input could not be parsed properly. new TParse Parse(ref ReadOnlySpan input, ISet? options = null); + + /// + /// Gets a default instance of the parse type. + /// + /// A default instance of the parse type. + new TParse GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 2bf623b48..06d88fba2 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -24,18 +24,8 @@ namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { public int Parse(ref ReadOnlySpan input, ISet? options = null) { - var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (start >= input.Length) { - if (options?.Contains(ParseOptions.AllowEmpty) != true) { - throw new CommandParseException(Resources.Int32Parser_MissingInteger); - } - - input = default; - return 0; - } - - var end = input.ScanFor(char.IsWhiteSpace, start); - var parse = input[start..end]; + var end = input.ScanFor(char.IsWhiteSpace); + var parse = input[..end]; input = input[end..]; // Calling Parse here instead of TryParse allows us to give better error messages. @@ -50,7 +40,12 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { } } + public int GetDefault() => 0; + [ExcludeFromCodeCoverage] object IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); + + [ExcludeFromCodeCoverage] + object IArgumentParser.GetDefault() => GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 8ff9373ac..7cb6c024c 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -19,33 +19,21 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; -using TShock.Commands.Extensions; using TShock.Properties; namespace TShock.Commands.Parsers { // It'd be nice to return ReadOnlySpan, but because of escape characters, we have to return copies. internal sealed class StringParser : IArgumentParser { public string Parse(ref ReadOnlySpan input, ISet? options = null) { - // ToEndOfInput should have higher priority than AllowEmpty since empty strings should be permitted. if (options?.Contains(ParseOptions.ToEndOfInput) == true) { var result = input.ToString(); input = default; return result; } - var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (start >= input.Length) { - if (options?.Contains(ParseOptions.AllowEmpty) != true) { - throw new CommandParseException(Resources.StringParser_MissingString); - } - - input = default; - return string.Empty; - } - // Begin building our string character-by-character. var builder = new StringBuilder(); - var end = start; + var end = 0; var isInQuotes = false; while (end < input.Length) { var c = input[end]; @@ -93,7 +81,12 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) return builder.ToString(); } + public string GetDefault() => string.Empty; + [ExcludeFromCodeCoverage] object IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); + + [ExcludeFromCodeCoverage] + object IArgumentParser.GetDefault() => GetDefault(); } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index c1f7ff020..dcc46d6e1 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -109,13 +109,24 @@ object ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo) throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedArgType, parameterType)); } - + var options = parameterInfo.GetCustomAttribute()?.Options; + var start = input.ScanFor(c => !char.IsWhiteSpace(c)); + if (start >= input.Length) { + if (options?.Contains(ParseOptions.AllowEmpty) != true) { + throw new CommandParseException( + string.Format(Resources.CommandParse_MissingArg, parameterInfo)); + } + + input = default; + return parser.GetDefault(); + } + + input = input[start..]; return parser.Parse(ref input, options); } - void ParseShortFlags(ref ReadOnlySpan input, int start) { - var end = input.ScanFor(char.IsWhiteSpace, start); + void ParseShortFlags(ref ReadOnlySpan input, int start, int end) { for (var i = start; i < end; ++i) { var c = input[i]; if (!_validShortFlags.Contains(c)) { @@ -148,7 +159,8 @@ void ParseOptional(ref ReadOnlySpan input, int start, int end) { } // Skip over the '='. - input = input[(end + 1)..]; + start = input.ScanFor(c => !char.IsWhiteSpace(c), end + 1); + input = input[start..]; optionals[optional] = ParseArgument(ref input, parameterInfo); } @@ -158,7 +170,7 @@ void ParseOptional(ref ReadOnlySpan input, int start, int end) { * - Long flags are string flags and use two hyphens: "--force". * - Optionals specify values with two hyphens: "--depth=10". */ - void ParseHyphenatedArgs(ref ReadOnlySpan input) { + void ParseHyphenatedArguments(ref ReadOnlySpan input) { while (true) { var start = input.ScanFor(c => !char.IsWhiteSpace(c)); if (start >= input.Length || input[start] != '-') { @@ -176,13 +188,22 @@ void ParseHyphenatedArgs(ref ReadOnlySpan input) { } var end = input.ScanFor(c => char.IsWhiteSpace(c) || c == '=', start); + if (start >= end) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } + if (end >= input.Length || input[end] != '=') { ParseLongFlag(ref input, start, end); } else { ParseOptional(ref input, start, end); } } else { - ParseShortFlags(ref input, start); + var end = input.ScanFor(char.IsWhiteSpace, start); + if (start >= end) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } + + ParseShortFlags(ref input, start, end); } } } @@ -217,15 +238,18 @@ void ParseHyphenatedArgs(ref ReadOnlySpan input) { var input = inputString.AsSpan(); if (_validShortFlags.Count > 0 || _validLongFlags.Count > 0 || _validOptionals.Count > 0) { - ParseHyphenatedArgs(ref input); + ParseHyphenatedArguments(ref input); } for (var i = 0; i < _parameters.Length; ++i) { _parameters[i] = ParseParameter(_parameterInfos[i], ref input); } + // Ensure that we've consumed all of the useful parts of the input. var end = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (end < input.Length) throw new CommandParseException(Resources.CommandParse_TooManyArgs); + if (end < input.Length) { + throw new CommandParseException(Resources.CommandParse_TooManyArgs); + } try { Handler.Invoke(HandlerObject, _parameters); diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index c1276e296..e39bace6b 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -96,6 +96,15 @@ internal static string CommandParse_InvalidHyphenatedArg { } } + /// + /// Looks up a localized string similar to Missing argument "{0}".. + /// + internal static string CommandParse_MissingArg { + get { + return ResourceManager.GetString("CommandParse_MissingArg", resourceCulture); + } + } + /// /// Looks up a localized string similar to Too many arguments were provided.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 85d1ec05b..8918db46c 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -129,6 +129,9 @@ Invalid hyphenated argument. + + Missing argument "{0}". + Too many arguments were provided. diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index 479af3366..a8fe1ad43 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -28,7 +28,6 @@ public class Int32ParserTests { [InlineData("000", 0, "")] [InlineData("-1234", -1234, "")] [InlineData("123 test", 123, " test")] - [InlineData(" 123", 123, "")] public void Parse_IsCorrect(string inputString, int expected, string expectedNextInput) { var parser = new Int32Parser(); var input = inputString.AsSpan(); @@ -38,16 +37,6 @@ public void Parse_IsCorrect(string inputString, int expected, string expectedNex input.ToString().Should().Be(expectedNextInput); } - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Parse_AllowEmpty_IsCorrect(string inputString) { - var parser = new Int32Parser(); - var input = inputString.AsSpan(); - - parser.Parse(ref input, new HashSet {ParseOptions.AllowEmpty}).Should().Be(0); - } - [Theory] [InlineData("")] [InlineData(" ")] diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index 2e05228c9..befc5accf 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -25,7 +25,6 @@ public class StringParserTests { [Theory] [InlineData("test", "test", "")] [InlineData("test abc", "test", " abc")] - [InlineData(" test", "test", "")] [InlineData(@"""test""", "test", "")] [InlineData(@"""test abc def""", "test abc def", "")] [InlineData(@"""test abc"" def ghi", "test abc", " def ghi")] @@ -44,19 +43,6 @@ public void Parse_IsCorrect(string inputString, string expected, string expected input.ToString().Should().Be(expectedNextInput); } - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Parse_Empty_ThrowsParseException(string inputString) { - var parser = new StringParser(); - Func func = () => { - var input = inputString.AsSpan(); - return parser.Parse(ref input); - }; - - func.Should().Throw(); - } - [Theory] [InlineData(@"\")] [InlineData(@"\a")] diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index e25dccf40..4b0bfb3a5 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -79,7 +79,6 @@ public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, stri [typeof(int)] = new Int32Parser(), [typeof(string)] = new StringParser() }); - var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); var commandSender = new Mock().Object; @@ -178,10 +177,41 @@ public void Invoke_OptionalGetsRenamed() { testClass.HyphenatedOptionalIsLong.Should().Be(60); } + [Fact] + public void Invoke_AllowEmpty() { + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + [typeof(string)] = new StringParser() + }); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_AllowEmpty)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, ""); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.String.Should().BeEmpty(); + } + + [Theory] + [InlineData("1 ")] + [InlineData("-7345734 ")] + public void Invoke_MissingArg_ThrowsCommandParseException(string input) { + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser(), + [typeof(string)] = new StringParser() + }); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + [Theory] [InlineData("-xyz")] [InlineData("-z")] - public void Invoke_UnexpectedShortFlag_ThrowsParseException(string input) { + public void Invoke_UnexpectedShortFlag_ThrowsCommandParseException(string input) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; @@ -193,7 +223,7 @@ public void Invoke_UnexpectedShortFlag_ThrowsParseException(string input) { [Theory] [InlineData("--this-is-not-ok")] [InlineData("--neither-is-this")] - public void Invoke_UnexpectedLongFlag_ThrowsParseException(string input) { + public void Invoke_UnexpectedLongFlag_ThrowsCommandParseException(string input) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; @@ -205,7 +235,7 @@ public void Invoke_UnexpectedLongFlag_ThrowsParseException(string input) { [Theory] [InlineData("--required=123")] [InlineData("--not-ok=test")] - public void Invoke_UnexpectedOptional_ThrowsParseException(string input) { + public void Invoke_UnexpectedOptional_ThrowsCommandParseException(string input) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Optionals)); var commandSender = new Mock().Object; @@ -214,10 +244,35 @@ public void Invoke_UnexpectedOptional_ThrowsParseException(string input) { action.Should().Throw(); } + [Theory] + [InlineData("-")] + [InlineData("- ")] + [InlineData("--")] + [InlineData("-- ")] + public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string input) { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Fact] + public void Invoke_UnexpectedArgType_ThrowsCommandParseException() { + _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary()); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoByte)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); + + action.Should().Throw(); + } + [Theory] [InlineData("a")] [InlineData("bcd")] - public void Invoke_TooManyArguments_ThrowsParseException(string input) { + public void Invoke_TooManyArguments_ThrowsCommandParseException(string input) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; @@ -278,17 +333,6 @@ public void TestCommand_Int_String(ICommandSender sender, int @int, string @stri String = @string; } - [CommandHandler("tshock_tests:test_no_in")] - public void TestCommand_NoIn(ICommandSender sender, in int x) { } - - [CommandHandler("tshock_tests:test_no_out")] - public void TestCommand_NoOut(ICommandSender sender, out int x) { - x = 0; - } - - [CommandHandler("tshock_tests:test_no_out")] - public void TestCommand_NoRef(ICommandSender sender, ref int x) { } - [CommandHandler("tshock_tests:test_no_ptr")] public unsafe void TestCommand_NoPointer(ICommandSender sender, int* x) { } @@ -321,6 +365,27 @@ public void TestCommand_OptionalRename(ICommandSender sender, int hyphenated_opt Sender = sender; HyphenatedOptionalIsLong = hyphenated_optional_is_long; } + + [CommandHandler("tshock_tests:allow_empty")] + public void TestCommand_AllowEmpty(ICommandSender sender, + [ParseOptions(ParseOptions.AllowEmpty)] string @string) { + Sender = sender; + String = @string; + } + + [CommandHandler("tshock_tests:test_no_in")] + public void TestCommand_NoIn(ICommandSender sender, in int x) { } + + [CommandHandler("tshock_tests:test_no_out")] + public void TestCommand_NoOut(ICommandSender sender, out int x) { + x = 0; + } + + [CommandHandler("tshock_tests:test_no_out")] + public void TestCommand_NoRef(ICommandSender sender, ref int x) { } + + [CommandHandler("tshock_tests:test_no_byte")] + public void TestCommand_NoByte(ICommandSender sender, byte b) { } } } } From 3860b1253d1bbad31869cb1ff915da5c52ffe92c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 23:27:18 -0700 Subject: [PATCH 036/119] Remove unused resource strings. --- src/TShock/Properties/Resources.Designer.cs | 18 ------------------ src/TShock/Properties/Resources.resx | 6 ------ 2 files changed, 24 deletions(-) diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index e39bace6b..8d45a5ebe 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -168,15 +168,6 @@ internal static string Int32Parser_InvalidInteger { } } - /// - /// Looks up a localized string similar to No integer was provided.. - /// - internal static string Int32Parser_MissingInteger { - get { - return ResourceManager.GetString("Int32Parser_MissingInteger", resourceCulture); - } - } - /// /// Looks up a localized string similar to Invalid backslash.. /// @@ -186,15 +177,6 @@ internal static string StringParser_InvalidBackslash { } } - /// - /// Looks up a localized string similar to No string was provided.. - /// - internal static string StringParser_MissingString { - get { - return ResourceManager.GetString("StringParser_MissingString", resourceCulture); - } - } - /// /// Looks up a localized string similar to Escape character "\{0}" not recognized.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 8918db46c..1a3af3455 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -153,15 +153,9 @@ "{0}" is an invalid integer. - - No integer was provided. - Invalid backslash. - - No string was provided. - Escape character "\{0}" not recognized. From 69edfb850d5c60d168a4f5fd6a25706fb828c08c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 23:43:00 -0700 Subject: [PATCH 037/119] Add more command tests. --- .../Commands/TShockCommandTests.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 4b0bfb3a5..b47005805 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -21,7 +21,11 @@ using System.Reflection; using FluentAssertions; using Moq; +using Orion.Events; +using Orion.Events.Extensions; +using Serilog; using TShock.Commands.Parsers; +using TShock.Events.Commands; using Xunit; namespace TShock.Commands { @@ -191,6 +195,40 @@ public void Invoke_AllowEmpty() { testClass.Sender.Should().BeSameAs(commandSender); testClass.String.Should().BeEmpty(); } + + [Fact] + public void Invoke_TriggersCommandExecute() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + var isRun = false; + EventHandlerCollection commandExecute = null; + commandExecute += (sender, args) => { + isRun = true; + args.Command.Should().Be(command); + args.Input.Should().BeEmpty(); + }; + _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); + + command.Invoke(commandSender, ""); + + testClass.Sender.Should().BeSameAs(commandSender); + isRun.Should().BeTrue(); + } + + [Fact] + public void Invoke_CommandExecuteCanceled_IsCanceled() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + EventHandlerCollection commandExecute = null; + commandExecute += (sender, args) => { + args.Cancel(); + }; + _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); + + command.Invoke(commandSender, "failing input"); + } [Theory] [InlineData("1 ")] @@ -281,6 +319,23 @@ public void Invoke_TooManyArguments_ThrowsCommandParseException(string input) { action.Should().Throw(); } + [Fact] + public void Invoke_ThrowsException_ThrowsCommandException() { + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Exception)); + var mockLog = new Mock(); + var mockCommandSender = new Mock(); + mockCommandSender.SetupGet(cs => cs.Log).Returns(mockLog.Object); + Action action = () => command.Invoke(mockCommandSender.Object, ""); + + action.Should().Throw().WithInnerException(); + + mockLog.Verify(l => l.Error(It.IsAny(), It.IsAny())); + mockLog.VerifyNoOtherCalls(); + mockCommandSender.VerifyGet(cs => cs.Log); + mockCommandSender.VerifyNoOtherCalls(); + } + [Fact] public void Invoke_NullSender_ThrowsArgumentNullException() { var testClass = new TestClass(); @@ -372,6 +427,11 @@ public void TestCommand_AllowEmpty(ICommandSender sender, Sender = sender; String = @string; } + + [CommandHandler("tshock_tests:exception")] + public void TestCommand_Exception(ICommandSender sender) { + throw new NotImplementedException(); + } [CommandHandler("tshock_tests:test_no_in")] public void TestCommand_NoIn(ICommandSender sender, in int x) { } From 15febbd90c3e60d5ecc579d25e4a57165a9ca698 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 23:45:19 -0700 Subject: [PATCH 038/119] Remove logging in Invoke method. --- src/TShock/Commands/TShockCommand.cs | 1 - tests/TShock.Tests/Commands/TShockCommandTests.cs | 12 ++---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index dcc46d6e1..d167e8d36 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -254,7 +254,6 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { try { Handler.Invoke(HandlerObject, _parameters); } catch (TargetInvocationException ex) { - sender.Log.Error(ex.InnerException, Resources.CommandInvoke_Exception); throw new CommandException(Resources.CommandInvoke_Exception, ex.InnerException); } } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index b47005805..d92d7b4e8 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -23,7 +23,6 @@ using Moq; using Orion.Events; using Orion.Events.Extensions; -using Serilog; using TShock.Commands.Parsers; using TShock.Events.Commands; using Xunit; @@ -323,17 +322,10 @@ public void Invoke_TooManyArguments_ThrowsCommandParseException(string input) { public void Invoke_ThrowsException_ThrowsCommandException() { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Exception)); - var mockLog = new Mock(); - var mockCommandSender = new Mock(); - mockCommandSender.SetupGet(cs => cs.Log).Returns(mockLog.Object); - Action action = () => command.Invoke(mockCommandSender.Object, ""); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); action.Should().Throw().WithInnerException(); - - mockLog.Verify(l => l.Error(It.IsAny(), It.IsAny())); - mockLog.VerifyNoOtherCalls(); - mockCommandSender.VerifyGet(cs => cs.Log); - mockCommandSender.VerifyNoOtherCalls(); } [Fact] From 89eb94cc585dfb6e32cca2d39407ace2c4631100 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 23:51:18 -0700 Subject: [PATCH 039/119] Remove unused usings. --- src/TShock/Commands/ICommand.cs | 1 - tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs | 1 - tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs | 1 - 3 files changed, 3 deletions(-) diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 8950724c4..b2946b5dd 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -18,7 +18,6 @@ using System; using System.Collections.Generic; using System.Reflection; -using TShock.Commands.Parsers; namespace TShock.Commands { /// diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs index 906ea4404..e326a7245 100644 --- a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs @@ -15,7 +15,6 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using FluentAssertions; using Xunit; diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index a8fe1ad43..85ad3fa7e 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -16,7 +16,6 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; using FluentAssertions; using Xunit; From f75ddff1156864581e77913d81119daa717508b4 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 30 Sep 2019 23:57:15 -0700 Subject: [PATCH 040/119] Migrate to using . Add Player property to ICommandSender. --- src/TShock/Commands/CommandHandlerAttribute.cs | 4 ++-- .../Commands/Extensions/CommandServiceExtensions.cs | 2 +- src/TShock/Commands/ICommand.cs | 2 +- src/TShock/Commands/ICommandSender.cs | 8 +++++++- src/TShock/Commands/ICommandService.cs | 8 +++++--- src/TShock/Commands/Parsers/FlagAttribute.cs | 4 ++-- src/TShock/Commands/Parsers/ParseOptionsAttribute.cs | 4 ++-- src/TShock/Events/Commands/CommandRegisterEventArgs.cs | 2 +- src/TShock/Events/Commands/CommandUnregisterEventArgs.cs | 2 +- src/TShock/TShockPlugin.cs | 2 +- 10 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 433988513..c99e1d91a 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -28,7 +28,7 @@ namespace TShock.Commands { [MeansImplicitUse] public sealed class CommandHandlerAttribute : Attribute { /// - /// Gets the command's name. This includes the command's namespace: e.g., "tshock:kick". + /// Gets the command's name. This includes the command's namespace: e.g., "tshock:kick". /// public string CommandName { get; } @@ -44,7 +44,7 @@ public sealed class CommandHandlerAttribute : Attribute { /// The command name. /// The command sub-names. /// - /// or are null. + /// or are . /// public CommandHandlerAttribute(string commandName, params string[] commandSubNames) { CommandName = commandName ?? throw new ArgumentNullException(nameof(commandName)); diff --git a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs index d12bc3990..b712c2f6a 100644 --- a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs +++ b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs @@ -30,7 +30,7 @@ public static class CommandServiceExtensions { /// The command service. /// The parser. /// - /// or are null. + /// or are . /// public static void RegisterParser(this ICommandService commandService, IArgumentParser parser) { if (commandService is null) throw new ArgumentNullException(nameof(commandService)); diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index b2946b5dd..93126e2e8 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -50,7 +50,7 @@ public interface ICommand { /// The sender. /// The input. This does not include the command's name or sub-names. /// - /// or are null. + /// or are . /// /// The command could not be executed. /// The command input could not be parsed. diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 67cf0293e..892e4c31d 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -17,6 +17,7 @@ using System; using Microsoft.Xna.Framework; +using Orion.Players; using Serilog; namespace TShock.Commands { @@ -34,12 +35,17 @@ public interface ICommandSender { /// ILogger Log { get; } + /// + /// Gets the sender's player. If , then there is no associated player. + /// + IPlayer? Player { get; } + /// /// Sends a message to the sender with the given color. /// /// The message. /// The color. - /// is null. + /// is . void SendMessage(string message, Color color); } } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 433a93178..f03214bfc 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -57,7 +57,9 @@ public interface ICommandService : IService { /// /// The object. /// The resulting commands. - /// is null. + /// + /// is . + /// IReadOnlyCollection RegisterCommands(object handlerObject); /// @@ -66,7 +68,7 @@ public interface ICommandService : IService { /// The parse type. /// The parser. /// - /// or are null. + /// or are . /// void RegisterParser(Type parseType, IArgumentParser parser); @@ -75,7 +77,7 @@ public interface ICommandService : IService { /// /// The command. /// A value indicating whether the command was successfully unregistered. - /// is null. + /// is . bool UnregisterCommand(ICommand command); } } diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index f3f37c679..b9ecb8cdc 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -19,7 +19,7 @@ namespace TShock.Commands.Parsers { /// - /// An attribute that can be applied to a bool parameter to specify flag parsing for that specific parameter. + /// Specifies that a parameter should have flag parsing. /// [AttributeUsage(AttributeTargets.Parameter)] public sealed class FlagAttribute : Attribute { @@ -29,7 +29,7 @@ public sealed class FlagAttribute : Attribute { public char ShortFlag { get; } /// - /// Gets the long form of the flag. If null, then there is no long flag. + /// Gets the long form of the flag. If , then there is no long flag. /// public string? LongFlag { get; } diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs index 5a0fea0c4..e140ddd42 100644 --- a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs +++ b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs @@ -21,7 +21,7 @@ namespace TShock.Commands.Parsers { /// - /// An attribute that can be applied to a parameter to specify options for parsing that specific parameter. + /// Specifies the options with which to parse a parameter. /// [AttributeUsage(AttributeTargets.Parameter)] public sealed class ParseOptionsAttribute : Attribute { @@ -34,7 +34,7 @@ public sealed class ParseOptionsAttribute : Attribute { /// Initializes a new instance of the class with the specified options. /// /// The options. - /// is null. + /// is . public ParseOptionsAttribute([ValueProvider("TShock.Commands.Parsers.ParseOptions")] params string[] options) { if (options is null) throw new ArgumentNullException(nameof(options)); diff --git a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs index 766bbd6b1..8fc5b64cb 100644 --- a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs @@ -27,7 +27,7 @@ public sealed class CommandRegisterEventArgs : CommandEventArgs { /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is null. + /// is . public CommandRegisterEventArgs(ICommand command) : base(command) { } } } diff --git a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs index 02f54b9c9..bd9bc6ef2 100644 --- a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs @@ -27,7 +27,7 @@ public sealed class CommandUnregisterEventArgs : CommandEventArgs { /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is null. + /// is . public CommandUnregisterEventArgs(ICommand command) : base(command) { } } } diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index a7afc010e..a7d591287 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -44,7 +44,7 @@ public sealed class TShockPlugin : OrionPlugin { /// /// The Orion kernel. /// The player service. - /// Any of the services are null. + /// Any of the services are . public TShockPlugin(OrionKernel kernel, Lazy playerService) : base(kernel) { kernel.Bind().To(); From c923e497db89325cbdfffb5f5769e3fd7142b2b5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 00:16:19 -0700 Subject: [PATCH 041/119] Start PlayerCommandSender implementation. --- src/TShock/Commands/PlayerCommandSender.cs | 45 ++++++++++++ .../Commands/PlayerCommandSenderTests.cs | 70 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/TShock/Commands/PlayerCommandSender.cs create mode 100644 tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs new file mode 100644 index 000000000..703bc50a5 --- /dev/null +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using Microsoft.Xna.Framework; +using Orion.Packets.World; +using Orion.Players; +using Serilog; + +namespace TShock.Commands { + internal sealed class PlayerCommandSender : ICommandSender { + public string Name => Player.Name; + public ILogger Log => throw new NotImplementedException(); + public IPlayer Player { get; } + + public PlayerCommandSender(IPlayer player) { + Debug.Assert(player != null, "player != null"); + + Player = player; + } + + public void SendMessage(string message, Color color) { + Player.SendPacket(new ChatPacket { + ChatColor = color, + ChatLineWidth = -1, + ChatText = message ?? throw new ArgumentNullException(nameof(message)) + }); + } + } +} diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs new file mode 100644 index 000000000..cf578ec84 --- /dev/null +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Packets.World; +using Orion.Players; +using Xunit; + +namespace TShock.Commands { + public class PlayerCommandSenderTests { + [Fact] + public void Name_Get_IsCorrect() { + var mockPlayer = new Mock(); + mockPlayer.SetupGet(p => p.Name).Returns("test"); + ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); + + sender.Name.Should().Be("test"); + + mockPlayer.VerifyGet(p => p.Name); + mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Player_Get_IsCorrect() { + var player = new Mock().Object; + ICommandSender sender = new PlayerCommandSender(player); + + sender.Player.Should().NotBeNull(); + sender.Player.Should().Be(player); + } + + [Fact] + public void SendMessage_IsCorrect() { + var mockPlayer = new Mock(); + ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); + + sender.SendMessage("test", Color.White); + + mockPlayer.Verify( + p => p.SendPacket(It.Is(cp => cp.ChatText == "test" && cp.ChatColor == Color.White))); + mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void SendMessage_NullMessage_ThrowsArgumentNullException() { + var player = new Mock().Object; + ICommandSender sender = new PlayerCommandSender(player); + Action action = () => sender.SendMessage(null, Color.White); + + action.Should().Throw(); + } + } +} From da9acd60bfd257b3ed6eea09095ad84898559fac Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 09:08:34 -0700 Subject: [PATCH 042/119] Update attribute XML docs. --- src/TShock/Commands/CommandHandlerAttribute.cs | 4 ++-- src/TShock/Commands/Parsers/FlagAttribute.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index c99e1d91a..84757b2cc 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -21,8 +21,8 @@ namespace TShock.Commands { /// - /// An attribute that can be applied to a method to indicate that it is a command handler. This can be applied to - /// a method mutiple times to provide aliasing. + /// Specifies that a method is a command handler. This can be applied multiple times to a method to provide + /// aliasing. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] [MeansImplicitUse] diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index b9ecb8cdc..d937831b6 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -19,7 +19,7 @@ namespace TShock.Commands.Parsers { /// - /// Specifies that a parameter should have flag parsing. + /// Specifies that a parameter should have flag-based parsing. /// [AttributeUsage(AttributeTargets.Parameter)] public sealed class FlagAttribute : Attribute { From db8766865c797880b73c961eb23ae2315816e953 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 09:12:39 -0700 Subject: [PATCH 043/119] Make RegisterParser type-safe. --- .../Extensions/CommandServiceExtensions.cs | 42 --------------- src/TShock/Commands/ICommandService.cs | 18 +++---- src/TShock/Commands/TShockCommand.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 15 +++--- .../CommandServiceExtensionsTests.cs | 54 ------------------- .../Commands/TShockCommandServiceTests.cs | 24 +++------ .../Commands/TShockCommandTests.cs | 14 ++--- 7 files changed, 30 insertions(+), 139 deletions(-) delete mode 100644 src/TShock/Commands/Extensions/CommandServiceExtensions.cs delete mode 100644 tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs diff --git a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs b/src/TShock/Commands/Extensions/CommandServiceExtensions.cs deleted file mode 100644 index b712c2f6a..000000000 --- a/src/TShock/Commands/Extensions/CommandServiceExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using TShock.Commands.Parsers; - -namespace TShock.Commands.Extensions { - /// - /// Provides extensions to the interface. - /// - public static class CommandServiceExtensions { - /// - /// Registers the given strongly-typed parser as the definitive parser for the parse type. - /// - /// The parse type. - /// The command service. - /// The parser. - /// - /// or are . - /// - public static void RegisterParser(this ICommandService commandService, IArgumentParser parser) { - if (commandService is null) throw new ArgumentNullException(nameof(commandService)); - if (parser is null) throw new ArgumentNullException(nameof(parser)); - - commandService.RegisterParser(typeof(TParse), parser); - } - } -} diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index f03214bfc..72c04e0f0 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -28,14 +28,14 @@ namespace TShock.Commands { /// public interface ICommandService : IService { /// - /// Gets the registered commands. + /// Gets the commands. /// - IEnumerable RegisteredCommands { get; } + IEnumerable Commands { get; } /// /// Gets the registered parsers. /// - IDictionary RegisteredParsers { get; } + IDictionary Parsers { get; } /// /// Gets or sets the event handlers that occur when registering a command. @@ -61,16 +61,14 @@ public interface ICommandService : IService { /// is . /// IReadOnlyCollection RegisterCommands(object handlerObject); - + /// - /// Registers the given parser as the definitive parser for the parse type. + /// Registers the given strongly-typed parser as the definitive parser for the parse type. /// - /// The parse type. + /// The parse type. /// The parser. - /// - /// or are . - /// - void RegisterParser(Type parseType, IArgumentParser parser); + /// is . + void RegisterParser(IArgumentParser parser); /// /// Unregisters the given command and returns a value indicating success. diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index d167e8d36..6dda8b43a 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -105,7 +105,7 @@ public void Invoke(ICommandSender sender, string inputString) { object ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo) { var parameterType = parameterInfo.ParameterType; - if (!_commandService.RegisteredParsers.TryGetValue(parameterType, out var parser)) { + if (!_commandService.Parsers.TryGetValue(parameterType, out var parser)) { throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedArgType, parameterType)); } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 87c50acfc..34004f673 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -22,7 +22,6 @@ using Orion; using Orion.Events; using Orion.Events.Extensions; -using TShock.Commands.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; @@ -31,15 +30,15 @@ internal sealed class TShockCommandService : OrionService, ICommandService { private readonly ISet _commands = new HashSet(); private readonly IDictionary _parsers = new Dictionary(); - public IEnumerable RegisteredCommands => new HashSet(_commands); - public IDictionary RegisteredParsers => new Dictionary(_parsers); + public IEnumerable Commands => new HashSet(_commands); + public IDictionary Parsers => new Dictionary(_parsers); public EventHandlerCollection? CommandRegister { get; set; } public EventHandlerCollection? CommandExecute { get; set; } public EventHandlerCollection? CommandUnregister { get; set; } public TShockCommandService() { - this.RegisterParser(new Int32Parser()); - this.RegisterParser(new StringParser()); + RegisterParser(new Int32Parser()); + RegisterParser(new StringParser()); } public IReadOnlyCollection RegisterCommands(object handlerObject) { @@ -66,10 +65,8 @@ void RegisterCommand(ICommand command) { return registeredCommands; } - public void RegisterParser(Type parseType, IArgumentParser parser) { - if (parseType is null) throw new ArgumentNullException(nameof(parseType)); - - _parsers[parseType] = parser ?? throw new ArgumentNullException(nameof(parser)); + public void RegisterParser(IArgumentParser parser) { + _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); } public bool UnregisterCommand(ICommand command) { diff --git a/tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs deleted file mode 100644 index 2bb0a6910..000000000 --- a/tests/TShock.Tests/Commands/Extensions/CommandServiceExtensionsTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using FluentAssertions; -using Moq; -using TShock.Commands.Parsers; -using Xunit; - -namespace TShock.Commands.Extensions { - public class CommandServiceExtensionsTests { - [Fact] - public void RegisterParser1_IsCorrect() { - var parser = new Mock>().Object; - var mockCommandService = new Mock(); - mockCommandService.Setup(cs => cs.RegisterParser(typeof(byte), parser)); - - mockCommandService.Object.RegisterParser(parser); - - mockCommandService.Verify(cs => cs.RegisterParser(typeof(byte), parser)); - mockCommandService.VerifyNoOtherCalls(); - } - - [Fact] - public void RegisterParser1_NullCommandService_ThrowsArgumentNullException() { - var parser = new Mock>().Object; - Action action = () => CommandServiceExtensions.RegisterParser(null, parser); - - action.Should().Throw(); - } - - [Fact] - public void RegisterParser1_NullParser_ThrowsArgumentNullException() { - var commandService = new Mock().Object; - Action action = () => commandService.RegisterParser(null); - - action.Should().Throw(); - } - } -} diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 401b8b4f0..fe0c37640 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -42,16 +42,16 @@ public void RegisteredCommands_Get_IsCorrect() { var commands = _commandService.RegisterCommands(testClass).ToList(); - _commandService.RegisteredCommands.Should().BeEquivalentTo(commands); + _commandService.Commands.Should().BeEquivalentTo(commands); } [Fact] public void RegisteredParsers_Get_IsCorrect() { - var parser = new Mock().Object; - _commandService.RegisterParser(typeof(object), parser); + var parser = new Mock>().Object; + _commandService.RegisterParser(parser); - _commandService.RegisteredParsers.Should().ContainKey(typeof(object)); - _commandService.RegisteredParsers.Should().ContainValue(parser); + _commandService.Parsers.Should().ContainKey(typeof(object)); + _commandService.Parsers.Should().ContainValue(parser); } [Fact] @@ -74,17 +74,9 @@ public void RegisterCommands_NullObj_ThrowsArgumentNullException() { func.Should().Throw(); } - [Fact] - public void RegisterParser_NullType_ThrowsArgumentNullException() { - var parser = new Mock().Object; - Action action = () => _commandService.RegisterParser(null, parser); - - action.Should().Throw(); - } - [Fact] public void RegisterParser_NullParser_ThrowsArgumentNullException() { - Action action = () => _commandService.RegisterParser(typeof(object), null); + Action action = () => _commandService.RegisterParser(null); action.Should().Throw(); } @@ -97,7 +89,7 @@ public void UnregisterCommand_IsCorrect() { _commandService.UnregisterCommand(command).Should().BeTrue(); - _commandService.RegisteredCommands.Should().NotContain(command); + _commandService.Commands.Should().NotContain(command); } [Fact] @@ -166,7 +158,7 @@ public void CommandUnregister_Canceled_IsCorrect() { _commandService.UnregisterCommand(command).Should().BeFalse(); - _commandService.RegisteredCommands.Should().Contain(command); + _commandService.Commands.Should().Contain(command); } private class TestClass { diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index d92d7b4e8..2c3e41729 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -78,7 +78,7 @@ public void Invoke_Sender_IsCorrect() { [InlineData("1 test", 1, "test")] [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, string expectedString) { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser(), [typeof(string)] = new StringParser() }); @@ -120,7 +120,7 @@ public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) [InlineData("--val=9001 1", 1, 9001, 5678)] [InlineData(" --val=9001 1", 1, 9001, 5678)] public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int expectedVal, int expectedVal2) { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() }); var testClass = new TestClass(); @@ -150,7 +150,7 @@ public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int e [InlineData("--force -r --depth=100 ", true, true, 100)] public void Invoke_FlagsAndOptionals_IsCorrect(string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() }); var testClass = new TestClass(); @@ -167,7 +167,7 @@ public void Invoke_FlagsAndOptionals_IsCorrect(string input, bool expectedForce, [Fact] public void Invoke_OptionalGetsRenamed() { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() }); var testClass = new TestClass(); @@ -182,7 +182,7 @@ public void Invoke_OptionalGetsRenamed() { [Fact] public void Invoke_AllowEmpty() { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(string)] = new StringParser() }); var testClass = new TestClass(); @@ -233,7 +233,7 @@ public void Invoke_CommandExecuteCanceled_IsCanceled() { [InlineData("1 ")] [InlineData("-7345734 ")] public void Invoke_MissingArg_ThrowsCommandParseException(string input) { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser(), [typeof(string)] = new StringParser() }); @@ -297,7 +297,7 @@ public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string inpu [Fact] public void Invoke_UnexpectedArgType_ThrowsCommandParseException() { - _mockCommandService.Setup(cs => cs.RegisteredParsers).Returns(new Dictionary()); + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary()); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoByte)); var commandSender = new Mock().Object; From fbca462d70e52be03103b6deda8ea147ae094fb7 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 09:16:48 -0700 Subject: [PATCH 044/119] Make ScanFor generic. --- src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs index 9e81d6fa0..978bfcdcf 100644 --- a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs +++ b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs @@ -20,7 +20,7 @@ namespace TShock.Commands.Extensions { internal static class ReadOnlySpanExtensions { - public static int ScanFor(this ReadOnlySpan input, Func predicate, int start = 0) { + public static int ScanFor(this ReadOnlySpan input, Func predicate, int start = 0) { Debug.Assert(predicate != null, "predicate != null"); while (start < input.Length) { From f6b7cda2334a423ae4cb26a6e9cf179eb3930a33 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 19:54:01 -0700 Subject: [PATCH 045/119] Make FlagAttribute take any number of arguments. --- src/TShock/Commands/Parsers/FlagAttribute.cs | 26 ++++++++++--------- src/TShock/Commands/TShockCommand.cs | 14 +++++----- .../Commands/Parsers/FlagAttributeTests.cs | 20 +++++++++----- .../Commands/TShockCommandTests.cs | 6 ++--- 4 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index d937831b6..ca058a7fe 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -16,6 +16,8 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; +using System.Linq; namespace TShock.Commands.Parsers { /// @@ -24,23 +26,23 @@ namespace TShock.Commands.Parsers { [AttributeUsage(AttributeTargets.Parameter)] public sealed class FlagAttribute : Attribute { /// - /// Gets the short form of the flag. + /// Gets the flags. /// - public char ShortFlag { get; } + public IReadOnlyCollection Flags { get; } /// - /// Gets the long form of the flag. If , then there is no long flag. + /// Initializes a new instance of the class with the specified flags. /// - public string? LongFlag { get; } + /// The flag. + /// The alternate flags. + /// + /// or are . + /// + public FlagAttribute(string flag, params string[] alternateFlags) { + if (flag is null) throw new ArgumentNullException(nameof(flag)); - /// - /// Initializes a new instance of the class with the specified short and long flags. - /// - /// The short flag. - /// The long flag. - public FlagAttribute(char shortFlag, string? longFlag = null) { - ShortFlag = shortFlag; - LongFlag = longFlag; + Flags = new[] {flag}.Concat(alternateFlags ?? throw new ArgumentNullException(nameof(alternateFlags))) + .ToList(); } } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 6dda8b43a..6f3f72b1a 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Reflection; using Orion.Events.Extensions; using TShock.Commands.Extensions; @@ -69,10 +70,11 @@ void PreprocessParameter(ParameterInfo parameterInfo) { // If the parameter is a bool and it is marked with FlagAttribute, we'll note it. if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); - if (attribute != null) { - _validShortFlags.Add(attribute.ShortFlag); - if (attribute.LongFlag != null) { - _validLongFlags.Add(attribute.LongFlag); + foreach (var flag in attribute?.Flags ?? Enumerable.Empty()) { + if (flag.Length == 1) { + _validShortFlags.Add(flag[0]); + } else { + _validLongFlags.Add(flag); } } } @@ -223,8 +225,8 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); if (attribute != null) { - return shortFlags.Contains(attribute.ShortFlag) || - attribute.LongFlag != null && longFlags.Contains(attribute.LongFlag); + return attribute.Flags.Any(f => f.Length == 1 && shortFlags.Contains(f[0]) || + longFlags.Contains(f)); } } diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs index e326a7245..056f1f1cc 100644 --- a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs @@ -15,23 +15,31 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . +using System; using FluentAssertions; using Xunit; namespace TShock.Commands.Parsers { public class FlagAttributeTests { [Fact] - public void ShortFlag_Get_IsCorrect() { - var attribute = new FlagAttribute('f', "force"); + public void Ctor_NullFlag_ThrowsArgumentNullException() { + Func func = () => new FlagAttribute(null); - attribute.ShortFlag.Should().Be('f'); + func.Should().Throw(); } [Fact] - public void LongFlag_Get_IsCorrect() { - var attribute = new FlagAttribute('f', "force"); + public void Ctor_NullAlternateFlags_ThrowsArgumentNullException() { + Func func = () => new FlagAttribute("", null); - attribute.LongFlag.Should().Be("force"); + func.Should().Throw(); + } + + [Fact] + public void Flags_Get_IsCorrect() { + var attribute = new FlagAttribute("test1", "test2", "test3"); + + attribute.Flags.Should().BeEquivalentTo("test1", "test2", "test3"); } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 2c3e41729..5b50926ba 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -384,7 +384,7 @@ public void TestCommand_Int_String(ICommandSender sender, int @int, string @stri public unsafe void TestCommand_NoPointer(ICommandSender sender, int* x) { } [CommandHandler("tshock_tests:test_flags")] - public void TestCommand_Flags(ICommandSender sender, [Flag('x', "xxx")] bool x, [Flag('y', "yyy")] bool y) { + public void TestCommand_Flags(ICommandSender sender, [Flag("x", "xxx")] bool x, [Flag("y", "yyy")] bool y) { Sender = sender; X = x; Y = y; @@ -399,8 +399,8 @@ public void TestCommand_Optionals(ICommandSender sender, int required, int val = } [CommandHandler("tshock_tests:test_flags_and_optionals")] - public void TestCommand_FlagsAndOptionals(ICommandSender sender, [Flag('f', "force")] bool force, - [Flag('r', "recursive")] bool recursive, int depth = 10) { + public void TestCommand_FlagsAndOptionals(ICommandSender sender, [Flag("f", "force")] bool force, + [Flag("r", "recursive")] bool recursive, int depth = 10) { Sender = sender; Force = force; Recursive = recursive; From 69c1ad28135dbcb05ea361fc788e05436f64b2ed Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 19:54:50 -0700 Subject: [PATCH 046/119] Make Commands an IReadOnlyCollection. --- src/TShock/Commands/ICommandService.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 72c04e0f0..64afa0784 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -30,7 +30,7 @@ public interface ICommandService : IService { /// /// Gets the commands. /// - IEnumerable Commands { get; } + IReadOnlyCollection Commands { get; } /// /// Gets the registered parsers. diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 34004f673..f455c9e5a 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -30,7 +30,7 @@ internal sealed class TShockCommandService : OrionService, ICommandService { private readonly ISet _commands = new HashSet(); private readonly IDictionary _parsers = new Dictionary(); - public IEnumerable Commands => new HashSet(_commands); + public IReadOnlyCollection Commands => new HashSet(_commands); public IDictionary Parsers => new Dictionary(_parsers); public EventHandlerCollection? CommandRegister { get; set; } public EventHandlerCollection? CommandExecute { get; set; } From 240f5c83f707198d90f407840f98749e63700861 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 22:13:04 -0700 Subject: [PATCH 047/119] Add ConsoleCommandSender. --- src/TShock/Commands/ConsoleCommandSender.cs | 76 +++++++++++++++++++ src/TShock/TShock.csproj | 3 +- src/TShock/TShockPlugin.cs | 4 + .../Commands/ConsoleCommandSenderTests.cs | 47 ++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/TShock/Commands/ConsoleCommandSender.cs create mode 100644 tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs new file mode 100644 index 000000000..1ee7baed5 --- /dev/null +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using Microsoft.Xna.Framework; +using Orion.Players; +using Serilog; + +namespace TShock.Commands { + internal sealed class ConsoleCommandSender : ICommandSender { + private const string ResetColorString = "\x1b[0m"; + private const string ColorTagPrefix = "c/"; + + public string Name => "Console"; + public ILogger Log => Serilog.Log.Logger; + public IPlayer? Player => null; + + private static string GetColorString(Color color) => $"\x1b[38;2;{color.R};{color.G};{color.B}m"; + + // This method will be **very** thoroughly tested outside of unit tests. + [ExcludeFromCodeCoverage] + public void SendMessage(string message, Color color) { + if (message is null) throw new ArgumentNullException(nameof(message)); + + var colorString = GetColorString(color); + var output = new StringBuilder(colorString); + var input = message.AsSpan(); + + while (true) { + var leftBracket = input.IndexOf('['); + var rightBracket = leftBracket + 1 + input[(leftBracket + 1)..].IndexOf(']'); + if (leftBracket < 0 || rightBracket < 0) break; + + output.Append(input[..leftBracket]); + var inside = input[(leftBracket + 1)..rightBracket]; + input = input[(rightBracket + 1)..]; + var colon = inside.IndexOf(':'); + var isValidColorTag = inside.StartsWith(ColorTagPrefix, StringComparison.OrdinalIgnoreCase) && + colon > ColorTagPrefix.Length; + if (!isValidColorTag) { + output.Append('[').Append(inside).Append(']'); + continue; + } + + if (int.TryParse(inside[ColorTagPrefix.Length..colon], NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, out var numberColor)) { + var tagColor = new Color((numberColor >> 16) & 255, (numberColor >> 8) & 255, numberColor & 255); + output.Append(GetColorString(tagColor)); + } + + output.Append(inside[(colon + 1)..]); + output.Append(colorString); + } + + output.Append(input).Append(ResetColorString); + Console.WriteLine(output); + } + } +} diff --git a/src/TShock/TShock.csproj b/src/TShock/TShock.csproj index f8da9467c..d1e751872 100644 --- a/src/TShock/TShock.csproj +++ b/src/TShock/TShock.csproj @@ -14,6 +14,7 @@ git en-US COPYING + OnBuildSuccess @@ -53,7 +54,7 @@ - + diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index a7d591287..84117bf44 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -17,6 +17,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; using Orion; using Orion.Events; using Orion.Events.Packets; @@ -49,6 +50,9 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService) : ba kernel.Bind().To(); _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); + + var ccs = new ConsoleCommandSender(); + ccs.SendMessage("test[c/abcdef:12345]", Color.OrangeRed); } /// diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs new file mode 100644 index 000000000..ff5e542e6 --- /dev/null +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Xunit; + +namespace TShock.Commands { + public class ConsoleCommandSenderTests { + [Fact] + public void Name_Get_IsCorrect() { + ICommandSender sender = new ConsoleCommandSender(); + + sender.Name.Should().Be("Console"); + } + + [Fact] + public void Player_Get_IsCorrect() { + ICommandSender sender = new ConsoleCommandSender(); + + sender.Player.Should().BeNull(); + } + + [Fact] + public void SendMessage_NullMessage_ThrowsArgumentNullException() { + ICommandSender sender = new ConsoleCommandSender(); + Action action = () => sender.SendMessage(null, Color.White); + + action.Should().Throw(); + } + } +} From ee089133643a8518a1cd1c5d559b1255659fc86a Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 22:17:32 -0700 Subject: [PATCH 048/119] Make ICommandSender take ReadOnlySpan. --- src/TShock/Commands/ConsoleCommandSender.cs | 22 ++++++++----------- src/TShock/Commands/ICommandSender.cs | 3 +-- src/TShock/Commands/PlayerCommandSender.cs | 4 ++-- .../Commands/ConsoleCommandSenderTests.cs | 12 +++++----- .../Commands/PlayerCommandSenderTests.cs | 9 -------- 5 files changed, 17 insertions(+), 33 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 1ee7baed5..51d6ebf51 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -16,9 +16,9 @@ // along with TShock. If not, see . using System; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; +using JetBrains.Annotations; using Microsoft.Xna.Framework; using Orion.Players; using Serilog; @@ -32,25 +32,21 @@ internal sealed class ConsoleCommandSender : ICommandSender { public ILogger Log => Serilog.Log.Logger; public IPlayer? Player => null; + [Pure] private static string GetColorString(Color color) => $"\x1b[38;2;{color.R};{color.G};{color.B}m"; - // This method will be **very** thoroughly tested outside of unit tests. - [ExcludeFromCodeCoverage] - public void SendMessage(string message, Color color) { - if (message is null) throw new ArgumentNullException(nameof(message)); - + public void SendMessage(ReadOnlySpan message, Color color) { var colorString = GetColorString(color); var output = new StringBuilder(colorString); - var input = message.AsSpan(); while (true) { - var leftBracket = input.IndexOf('['); - var rightBracket = leftBracket + 1 + input[(leftBracket + 1)..].IndexOf(']'); + var leftBracket = message.IndexOf('['); + var rightBracket = leftBracket + 1 + message[(leftBracket + 1)..].IndexOf(']'); if (leftBracket < 0 || rightBracket < 0) break; - output.Append(input[..leftBracket]); - var inside = input[(leftBracket + 1)..rightBracket]; - input = input[(rightBracket + 1)..]; + output.Append(message[..leftBracket]); + var inside = message[(leftBracket + 1)..rightBracket]; + message = message[(rightBracket + 1)..]; var colon = inside.IndexOf(':'); var isValidColorTag = inside.StartsWith(ColorTagPrefix, StringComparison.OrdinalIgnoreCase) && colon > ColorTagPrefix.Length; @@ -69,7 +65,7 @@ public void SendMessage(string message, Color color) { output.Append(colorString); } - output.Append(input).Append(ResetColorString); + output.Append(message).Append(ResetColorString); Console.WriteLine(output); } } diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 892e4c31d..f6cc4a1bb 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -45,7 +45,6 @@ public interface ICommandSender { /// /// The message. /// The color. - /// is . - void SendMessage(string message, Color color); + void SendMessage(ReadOnlySpan message, Color color); } } diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 703bc50a5..6a5b601c1 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -34,11 +34,11 @@ public PlayerCommandSender(IPlayer player) { Player = player; } - public void SendMessage(string message, Color color) { + public void SendMessage(ReadOnlySpan message, Color color) { Player.SendPacket(new ChatPacket { ChatColor = color, ChatLineWidth = -1, - ChatText = message ?? throw new ArgumentNullException(nameof(message)) + ChatText = message.ToString() }); } } diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs index ff5e542e6..01b6cc0ba 100644 --- a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -15,9 +15,8 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using FluentAssertions; -using Microsoft.Xna.Framework; +using Serilog; using Xunit; namespace TShock.Commands { @@ -30,18 +29,17 @@ public void Name_Get_IsCorrect() { } [Fact] - public void Player_Get_IsCorrect() { + public void Log_Get_IsCorrect() { ICommandSender sender = new ConsoleCommandSender(); - sender.Player.Should().BeNull(); + sender.Log.Should().BeSameAs(Log.Logger); } [Fact] - public void SendMessage_NullMessage_ThrowsArgumentNullException() { + public void Player_Get_IsCorrect() { ICommandSender sender = new ConsoleCommandSender(); - Action action = () => sender.SendMessage(null, Color.White); - action.Should().Throw(); + sender.Player.Should().BeNull(); } } } diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index cf578ec84..98e8a3247 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -57,14 +57,5 @@ public void SendMessage_IsCorrect() { p => p.SendPacket(It.Is(cp => cp.ChatText == "test" && cp.ChatColor == Color.White))); mockPlayer.VerifyNoOtherCalls(); } - - [Fact] - public void SendMessage_NullMessage_ThrowsArgumentNullException() { - var player = new Mock().Object; - ICommandSender sender = new PlayerCommandSender(player); - Action action = () => sender.SendMessage(null, Color.White); - - action.Should().Throw(); - } } } From a67c5b8d3ada761591958d671660b66c0a42a6a1 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 22:20:01 -0700 Subject: [PATCH 049/119] Add SendMessage overload to ICommandSender with no color argument. --- src/TShock/Commands/ConsoleCommandSender.cs | 6 ++++-- src/TShock/Commands/ICommandSender.cs | 6 ++++++ src/TShock/Commands/PlayerCommandSender.cs | 2 ++ .../Commands/PlayerCommandSenderTests.cs | 14 +++++++++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 51d6ebf51..d02343677 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -35,8 +35,10 @@ internal sealed class ConsoleCommandSender : ICommandSender { [Pure] private static string GetColorString(Color color) => $"\x1b[38;2;{color.R};{color.G};{color.B}m"; - public void SendMessage(ReadOnlySpan message, Color color) { - var colorString = GetColorString(color); + public void SendMessage(ReadOnlySpan message) => SendMessage(message, string.Empty); + public void SendMessage(ReadOnlySpan message, Color color) => SendMessage(message, GetColorString(color)); + + private static void SendMessage(ReadOnlySpan message, string colorString) { var output = new StringBuilder(colorString); while (true) { diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index f6cc4a1bb..8144e326c 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -40,6 +40,12 @@ public interface ICommandSender { /// IPlayer? Player { get; } + /// + /// Sends a message to the sender. + /// + /// The message. + void SendMessage(ReadOnlySpan message); + /// /// Sends a message to the sender with the given color. /// diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 6a5b601c1..8f09b8697 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -34,6 +34,8 @@ public PlayerCommandSender(IPlayer player) { Player = player; } + public void SendMessage(ReadOnlySpan message) => SendMessage(message, Color.White); + public void SendMessage(ReadOnlySpan message, Color color) { Player.SendPacket(new ChatPacket { ChatColor = color, diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 98e8a3247..362f0f41f 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -51,11 +51,23 @@ public void SendMessage_IsCorrect() { var mockPlayer = new Mock(); ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); - sender.SendMessage("test", Color.White); + sender.SendMessage("test"); mockPlayer.Verify( p => p.SendPacket(It.Is(cp => cp.ChatText == "test" && cp.ChatColor == Color.White))); mockPlayer.VerifyNoOtherCalls(); } + + [Fact] + public void SendMessage_WithColor_IsCorrect() { + var mockPlayer = new Mock(); + ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); + + sender.SendMessage("test", Color.Orange); + + mockPlayer.Verify( + p => p.SendPacket(It.Is(cp => cp.ChatText == "test" && cp.ChatColor == Color.Orange))); + mockPlayer.VerifyNoOtherCalls(); + } } } From 24930842827ba95eee99197998af67f61962c3ef Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 22:22:41 -0700 Subject: [PATCH 050/119] Add static Console property to ICommandSender. --- src/TShock/Commands/ConsoleCommandSender.cs | 2 +- src/TShock/Commands/ICommandSender.cs | 7 +++++++ tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index d02343677..823e7b620 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -40,7 +40,7 @@ internal sealed class ConsoleCommandSender : ICommandSender { private static void SendMessage(ReadOnlySpan message, string colorString) { var output = new StringBuilder(colorString); - + while (true) { var leftBracket = message.IndexOf('['); var rightBracket = leftBracket + 1 + message[(leftBracket + 1)..].IndexOf(']'); diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 8144e326c..e5d252e08 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -25,6 +25,13 @@ namespace TShock.Commands { /// Represents a command sender. Provides the ability to communicate with the sender. /// public interface ICommandSender { + private static readonly ICommandSender _console = new ConsoleCommandSender(); + + /// + /// Gets a console-based command sender. + /// + static ICommandSender Console => _console; + /// /// Gets the sender's name. /// diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs index 01b6cc0ba..90624974f 100644 --- a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -23,21 +23,21 @@ namespace TShock.Commands { public class ConsoleCommandSenderTests { [Fact] public void Name_Get_IsCorrect() { - ICommandSender sender = new ConsoleCommandSender(); + var sender = ICommandSender.Console; sender.Name.Should().Be("Console"); } [Fact] public void Log_Get_IsCorrect() { - ICommandSender sender = new ConsoleCommandSender(); + var sender = ICommandSender.Console; sender.Log.Should().BeSameAs(Log.Logger); } [Fact] public void Player_Get_IsCorrect() { - ICommandSender sender = new ConsoleCommandSender(); + var sender = ICommandSender.Console; sender.Player.Should().BeNull(); } From 8b3ee39d3931f595836a2e149679f8efe7243746 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 22:25:11 -0700 Subject: [PATCH 051/119] Make ICommand take ReadOnlySpan. --- src/TShock/Commands/ICommand.cs | 6 ++---- src/TShock/Commands/TShockCommand.cs | 6 ++---- tests/TShock.Tests/Commands/TShockCommandTests.cs | 10 ---------- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 93126e2e8..00c10f7c4 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -49,11 +49,9 @@ public interface ICommand { /// /// The sender. /// The input. This does not include the command's name or sub-names. - /// - /// or are . - /// + /// is . /// The command could not be executed. /// The command input could not be parsed. - void Invoke(ICommandSender sender, string input); + void Invoke(ICommandSender sender, ReadOnlySpan input); } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 6f3f72b1a..1e959d1be 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -93,11 +93,10 @@ void PreprocessParameter(ParameterInfo parameterInfo) { } } - public void Invoke(ICommandSender sender, string inputString) { + public void Invoke(ICommandSender sender, ReadOnlySpan input) { if (sender is null) throw new ArgumentNullException(nameof(sender)); - if (inputString is null) throw new ArgumentNullException(nameof(inputString)); - var args = new CommandExecuteEventArgs(this, sender, inputString); + var args = new CommandExecuteEventArgs(this, sender, input.ToString()); _commandService.CommandExecute?.Invoke(this, args); if (args.IsCanceled()) return; @@ -238,7 +237,6 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { return ParseArgument(ref input, parameterInfo); } - var input = inputString.AsSpan(); if (_validShortFlags.Count > 0 || _validLongFlags.Count > 0 || _validOptionals.Count > 0) { ParseHyphenatedArguments(ref input); } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 5b50926ba..d74c9c6d6 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -337,16 +337,6 @@ public void Invoke_NullSender_ThrowsArgumentNullException() { action.Should().Throw(); } - [Fact] - public void Invoke_NullInput_ThrowsArgumentNullException() { - var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand)); - var commandSender = new Mock().Object; - Action action = () => command.Invoke(commandSender, null); - - action.Should().Throw(); - } - private ICommand GetCommand(TestClass testClass, string methodName) { var handler = typeof(TestClass).GetMethod(methodName); var attribute = handler.GetCustomAttribute(); From f491b41ad2ce41c960dec1cfcc2cbbc12eeaa54c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 22:37:39 -0700 Subject: [PATCH 052/119] Make ICommandService use IReadOnlyDictionary, and return views. --- src/TShock/Commands/ICommandService.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 8 ++++---- tests/TShock.Tests/Commands/TShockCommandServiceTests.cs | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 64afa0784..64fd9f6cb 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -35,7 +35,7 @@ public interface ICommandService : IService { /// /// Gets the registered parsers. /// - IDictionary Parsers { get; } + IReadOnlyDictionary Parsers { get; } /// /// Gets or sets the event handlers that occur when registering a command. diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index f455c9e5a..d10263439 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -27,11 +27,11 @@ namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { - private readonly ISet _commands = new HashSet(); - private readonly IDictionary _parsers = new Dictionary(); + private readonly HashSet _commands = new HashSet(); + private readonly Dictionary _parsers = new Dictionary(); - public IReadOnlyCollection Commands => new HashSet(_commands); - public IDictionary Parsers => new Dictionary(_parsers); + public IReadOnlyCollection Commands => _commands; + public IReadOnlyDictionary Parsers => _parsers; public EventHandlerCollection? CommandRegister { get; set; } public EventHandlerCollection? CommandExecute { get; set; } public EventHandlerCollection? CommandUnregister { get; set; } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index fe0c37640..e0c6d46ca 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -49,9 +49,8 @@ public void RegisteredCommands_Get_IsCorrect() { public void RegisteredParsers_Get_IsCorrect() { var parser = new Mock>().Object; _commandService.RegisterParser(parser); - - _commandService.Parsers.Should().ContainKey(typeof(object)); - _commandService.Parsers.Should().ContainValue(parser); + + _commandService.Parsers.Should().Contain(new KeyValuePair(typeof(object), parser)); } [Fact] From cd1ae3f79a1c09db06a7c11ee83e96ca93c7ff01 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 23:00:58 -0700 Subject: [PATCH 053/119] Use IPlayer.SendMessage in PlayerCommandSender. --- src/TShock/Commands/PlayerCommandSender.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 8f09b8697..fdfa78d99 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -18,7 +18,6 @@ using System; using System.Diagnostics; using Microsoft.Xna.Framework; -using Orion.Packets.World; using Orion.Players; using Serilog; @@ -36,12 +35,7 @@ public PlayerCommandSender(IPlayer player) { public void SendMessage(ReadOnlySpan message) => SendMessage(message, Color.White); - public void SendMessage(ReadOnlySpan message, Color color) { - Player.SendPacket(new ChatPacket { - ChatColor = color, - ChatLineWidth = -1, - ChatText = message.ToString() - }); - } + public void SendMessage(ReadOnlySpan message, Color color) => + Player.SendMessage(message.ToString(), color); } } From a55a16728871f049533bb503f0385a67ac7ba455 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 1 Oct 2019 23:05:17 -0700 Subject: [PATCH 054/119] Fix failing tests and add ICommandSender.FromPlayer. --- src/TShock/Commands/ICommandSender.cs | 8 +++ .../Commands/CommandSenderTests.cs | 55 +++++++++++++++++++ .../Commands/PlayerCommandSenderTests.cs | 10 +--- 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 tests/TShock.Tests/Commands/CommandSenderTests.cs diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index e5d252e08..8c15a0fae 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -47,6 +47,14 @@ public interface ICommandSender { /// IPlayer? Player { get; } + /// + /// Returns a command sender based on the given player. + /// + /// The player. + /// A command sender based on the player. + static ICommandSender FromPlayer(IPlayer player) => + new PlayerCommandSender(player ?? throw new ArgumentNullException(nameof(player))); + /// /// Sends a message to the sender. /// diff --git a/tests/TShock.Tests/Commands/CommandSenderTests.cs b/tests/TShock.Tests/Commands/CommandSenderTests.cs new file mode 100644 index 000000000..2c5ad6fab --- /dev/null +++ b/tests/TShock.Tests/Commands/CommandSenderTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using Orion.Players; +using Xunit; + +namespace TShock.Commands { + public class CommandSenderTests { + [Fact] + public void Console_Get_IsCorrect() { + ICommandSender.Console.Should().BeOfType(); + } + + [Fact] + public void Console_GetMultipleTimes_ReturnsSameInstance() { + var sender1 = ICommandSender.Console; + var sender2 = ICommandSender.Console; + + sender1.Should().BeSameAs(sender2); + } + + [Fact] + public void FromPlayer_IsCorrect() { + var player = new Mock().Object; + var sender = ICommandSender.FromPlayer(player); + + sender.Should().BeOfType(); + sender.Player.Should().BeSameAs(player); + } + + [Fact] + public void FromPlayer_NullPlayer_ThrowsArgumentNullException() { + Func func = () => ICommandSender.FromPlayer(null); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 362f0f41f..9fa90c1e9 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -15,11 +15,9 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using FluentAssertions; using Microsoft.Xna.Framework; using Moq; -using Orion.Packets.World; using Orion.Players; using Xunit; @@ -52,9 +50,8 @@ public void SendMessage_IsCorrect() { ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); sender.SendMessage("test"); - - mockPlayer.Verify( - p => p.SendPacket(It.Is(cp => cp.ChatText == "test" && cp.ChatColor == Color.White))); + + mockPlayer.Verify(p => p.SendMessage("test", Color.White)); mockPlayer.VerifyNoOtherCalls(); } @@ -65,8 +62,7 @@ public void SendMessage_WithColor_IsCorrect() { sender.SendMessage("test", Color.Orange); - mockPlayer.Verify( - p => p.SendPacket(It.Is(cp => cp.ChatText == "test" && cp.ChatColor == Color.Orange))); + mockPlayer.Verify(p => p.SendMessage("test", Color.Orange)); mockPlayer.VerifyNoOtherCalls(); } } From e6e1e8b7c5d18e1aa7a1a1ef57b25c8345792ee7 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 2 Oct 2019 09:10:50 -0700 Subject: [PATCH 055/119] Use InvariantCulture in Int32Parser. --- src/TShock/Commands/Parsers/Int32Parser.cs | 3 ++- tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 06d88fba2..93e3a65e0 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using TShock.Commands.Extensions; using TShock.Properties; @@ -30,7 +31,7 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { // Calling Parse here instead of TryParse allows us to give better error messages. try { - return int.Parse(parse); + return int.Parse(parse, NumberStyles.Integer, CultureInfo.InvariantCulture); } catch (FormatException ex) { throw new CommandParseException( string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index 85ad3fa7e..b71baaf1c 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -65,7 +65,7 @@ public void Parse_IntegerOutOfRange_ThrowsParseException(string inputString) { [Theory] [InlineData("aaa")] [InlineData("123a")] - [InlineData("1.0")] + [InlineData("123.0")] public void Parse_InvalidInteger_ThrowsParseException(string inputString) { var parser = new Int32Parser(); Func func = () => { From e1b67e84720b797b6b92fd7d3121f7994d8a358d Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 2 Oct 2019 09:15:44 -0700 Subject: [PATCH 056/119] Use FormattableString.Invariant in ConsoleCommandSender. --- src/TShock/Commands/ConsoleCommandSender.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 823e7b620..7c4ac4247 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -33,14 +33,14 @@ internal sealed class ConsoleCommandSender : ICommandSender { public IPlayer? Player => null; [Pure] - private static string GetColorString(Color color) => $"\x1b[38;2;{color.R};{color.G};{color.B}m"; + private static string GetColorString(Color color) => + FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); public void SendMessage(ReadOnlySpan message) => SendMessage(message, string.Empty); public void SendMessage(ReadOnlySpan message, Color color) => SendMessage(message, GetColorString(color)); private static void SendMessage(ReadOnlySpan message, string colorString) { var output = new StringBuilder(colorString); - while (true) { var leftBracket = message.IndexOf('['); var rightBracket = leftBracket + 1 + message[(leftBracket + 1)..].IndexOf(']'); From cbf67660aa9920bbd07a66e5ebed27d25f38dbea Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 2 Oct 2019 20:34:55 -0700 Subject: [PATCH 057/119] Add ReadOnlySpan to sender constructors for input logging. --- src/TShock/Commands/ConsoleCommandSender.cs | 9 ++- src/TShock/Commands/ICommandSender.cs | 15 ----- src/TShock/Commands/PlayerCommandSender.cs | 2 +- .../Commands/CommandSenderTests.cs | 55 ------------------- .../Commands/ConsoleCommandSenderTests.cs | 12 +--- .../Commands/PlayerCommandSenderTests.cs | 42 +++++++------- 6 files changed, 30 insertions(+), 105 deletions(-) delete mode 100644 tests/TShock.Tests/Commands/CommandSenderTests.cs diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 7c4ac4247..9a7805469 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -29,13 +29,20 @@ internal sealed class ConsoleCommandSender : ICommandSender { private const string ColorTagPrefix = "c/"; public string Name => "Console"; - public ILogger Log => Serilog.Log.Logger; + public ILogger Log { get; } public IPlayer? Player => null; [Pure] private static string GetColorString(Color color) => FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); + public ConsoleCommandSender(ReadOnlySpan input) { + Log = new LoggerConfiguration().MinimumLevel.Verbose() + .WriteTo.Logger(Serilog.Log.Logger) + .Enrich.WithProperty("Cmd", input.ToString()) + .CreateLogger(); + } + public void SendMessage(ReadOnlySpan message) => SendMessage(message, string.Empty); public void SendMessage(ReadOnlySpan message, Color color) => SendMessage(message, GetColorString(color)); diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 8c15a0fae..8144e326c 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -25,13 +25,6 @@ namespace TShock.Commands { /// Represents a command sender. Provides the ability to communicate with the sender. /// public interface ICommandSender { - private static readonly ICommandSender _console = new ConsoleCommandSender(); - - /// - /// Gets a console-based command sender. - /// - static ICommandSender Console => _console; - /// /// Gets the sender's name. /// @@ -47,14 +40,6 @@ public interface ICommandSender { /// IPlayer? Player { get; } - /// - /// Returns a command sender based on the given player. - /// - /// The player. - /// A command sender based on the player. - static ICommandSender FromPlayer(IPlayer player) => - new PlayerCommandSender(player ?? throw new ArgumentNullException(nameof(player))); - /// /// Sends a message to the sender. /// diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index fdfa78d99..2c897a70d 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -27,7 +27,7 @@ internal sealed class PlayerCommandSender : ICommandSender { public ILogger Log => throw new NotImplementedException(); public IPlayer Player { get; } - public PlayerCommandSender(IPlayer player) { + public PlayerCommandSender(IPlayer player, ReadOnlySpan input) { Debug.Assert(player != null, "player != null"); Player = player; diff --git a/tests/TShock.Tests/Commands/CommandSenderTests.cs b/tests/TShock.Tests/Commands/CommandSenderTests.cs deleted file mode 100644 index 2c5ad6fab..000000000 --- a/tests/TShock.Tests/Commands/CommandSenderTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using FluentAssertions; -using Moq; -using Orion.Players; -using Xunit; - -namespace TShock.Commands { - public class CommandSenderTests { - [Fact] - public void Console_Get_IsCorrect() { - ICommandSender.Console.Should().BeOfType(); - } - - [Fact] - public void Console_GetMultipleTimes_ReturnsSameInstance() { - var sender1 = ICommandSender.Console; - var sender2 = ICommandSender.Console; - - sender1.Should().BeSameAs(sender2); - } - - [Fact] - public void FromPlayer_IsCorrect() { - var player = new Mock().Object; - var sender = ICommandSender.FromPlayer(player); - - sender.Should().BeOfType(); - sender.Player.Should().BeSameAs(player); - } - - [Fact] - public void FromPlayer_NullPlayer_ThrowsArgumentNullException() { - Func func = () => ICommandSender.FromPlayer(null); - - func.Should().Throw(); - } - } -} diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs index 90624974f..86f7495ce 100644 --- a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -16,28 +16,20 @@ // along with TShock. If not, see . using FluentAssertions; -using Serilog; using Xunit; namespace TShock.Commands { public class ConsoleCommandSenderTests { [Fact] public void Name_Get_IsCorrect() { - var sender = ICommandSender.Console; + var sender = new ConsoleCommandSender("test"); sender.Name.Should().Be("Console"); } - [Fact] - public void Log_Get_IsCorrect() { - var sender = ICommandSender.Console; - - sender.Log.Should().BeSameAs(Log.Logger); - } - [Fact] public void Player_Get_IsCorrect() { - var sender = ICommandSender.Console; + var sender = new ConsoleCommandSender("test"); sender.Player.Should().BeNull(); } diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 9fa90c1e9..84be7047d 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -23,47 +23,43 @@ namespace TShock.Commands { public class PlayerCommandSenderTests { + private readonly Mock _mockPlayer = new Mock(); + private readonly ICommandSender _sender; + + public PlayerCommandSenderTests() { + _sender = new PlayerCommandSender(_mockPlayer.Object, "test"); + } + [Fact] public void Name_Get_IsCorrect() { - var mockPlayer = new Mock(); - mockPlayer.SetupGet(p => p.Name).Returns("test"); - ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); + _mockPlayer.SetupGet(p => p.Name).Returns("test"); - sender.Name.Should().Be("test"); + _sender.Name.Should().Be("test"); - mockPlayer.VerifyGet(p => p.Name); - mockPlayer.VerifyNoOtherCalls(); + _mockPlayer.VerifyGet(p => p.Name); + _mockPlayer.VerifyNoOtherCalls(); } [Fact] public void Player_Get_IsCorrect() { - var player = new Mock().Object; - ICommandSender sender = new PlayerCommandSender(player); - - sender.Player.Should().NotBeNull(); - sender.Player.Should().Be(player); + _sender.Player.Should().NotBeNull(); + _sender.Player.Should().Be(_mockPlayer.Object); } [Fact] public void SendMessage_IsCorrect() { - var mockPlayer = new Mock(); - ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); - - sender.SendMessage("test"); + _sender.SendMessage("test"); - mockPlayer.Verify(p => p.SendMessage("test", Color.White)); - mockPlayer.VerifyNoOtherCalls(); + _mockPlayer.Verify(p => p.SendMessage("test", Color.White)); + _mockPlayer.VerifyNoOtherCalls(); } [Fact] public void SendMessage_WithColor_IsCorrect() { - var mockPlayer = new Mock(); - ICommandSender sender = new PlayerCommandSender(mockPlayer.Object); - - sender.SendMessage("test", Color.Orange); + _sender.SendMessage("test", Color.Orange); - mockPlayer.Verify(p => p.SendMessage("test", Color.Orange)); - mockPlayer.VerifyNoOtherCalls(); + _mockPlayer.Verify(p => p.SendMessage("test", Color.Orange)); + _mockPlayer.VerifyNoOtherCalls(); } } } From 3223a51efb92b883e11c720836f7840b46b66aef Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 2 Oct 2019 20:46:08 -0700 Subject: [PATCH 058/119] Implement player logging. --- src/TShock/Commands/ConsoleCommandSender.cs | 10 +- src/TShock/Commands/Logging/PlayerLogSink.cs | 87 ++++++++++++ .../Logging/PlayerLogValueFormatter.cs | 56 ++++++++ src/TShock/Commands/PlayerCommandSender.cs | 16 ++- src/TShock/TShockPlugin.cs | 9 +- .../Commands/Logging/PlayerLogSinkTests.cs | 132 ++++++++++++++++++ .../Logging/PlayerLogValueFormatterTests.cs | 93 ++++++++++++ .../Commands/PlayerCommandSenderTests.cs | 1 + 8 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 src/TShock/Commands/Logging/PlayerLogSink.cs create mode 100644 src/TShock/Commands/Logging/PlayerLogValueFormatter.cs create mode 100644 tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs create mode 100644 tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 9a7805469..14997ad92 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -22,9 +22,15 @@ using Microsoft.Xna.Framework; using Orion.Players; using Serilog; +using Serilog.Events; namespace TShock.Commands { internal sealed class ConsoleCommandSender : ICommandSender { +#if DEBUG + private const LogEventLevel LogLevel = LogEventLevel.Verbose; +#else + private const LogEventLevel LogLevel = LogEventLevel.Error; +#endif private const string ResetColorString = "\x1b[0m"; private const string ColorTagPrefix = "c/"; @@ -37,9 +43,9 @@ private static string GetColorString(Color color) => FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); public ConsoleCommandSender(ReadOnlySpan input) { - Log = new LoggerConfiguration().MinimumLevel.Verbose() - .WriteTo.Logger(Serilog.Log.Logger) + Log = new LoggerConfiguration().MinimumLevel.Is(LogLevel) .Enrich.WithProperty("Cmd", input.ToString()) + .WriteTo.Logger(Serilog.Log.Logger) .CreateLogger(); } diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs new file mode 100644 index 000000000..842e2b56a --- /dev/null +++ b/src/TShock/Commands/Logging/PlayerLogSink.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using System.Text; +using Microsoft.Xna.Framework; +using Orion.Players; +using Serilog.Core; +using Serilog.Events; +using Serilog.Parsing; + +namespace TShock.Commands.Logging { + internal sealed class PlayerLogSink : ILogEventSink { + private const string LevelVerbose = "[c/c0c0c0:VRB]"; + private const string LevelDebug = "[c/c0c0c0:DBG]"; + private const string LevelInformation = "[c/ffffff:INF]"; + private const string LevelWarning = "[c/ffffaf:WRN]"; + private const string LevelError = "[c/ff005f:ERR]"; + private const string LevelFatal = "[c/ff005f:FTL]"; + private const string LevelUnknown = "[c/ff0000:UNK]"; + + private static readonly Color _textColor = new Color(0xda, 0xda, 0xda); + private static readonly Color _exceptionColor = new Color(0xda, 0xda, 0xda); + private static readonly PlayerLogValueFormatter _formatter = new PlayerLogValueFormatter(); + + private readonly IPlayer _player; + + public PlayerLogSink(IPlayer player) { + Debug.Assert(player != null, "player != null"); + + _player = player; + } + + public void Emit(LogEvent logEvent) { + var logLevel = logEvent.Level switch { + LogEventLevel.Verbose => LevelVerbose, + LogEventLevel.Debug => LevelDebug, + LogEventLevel.Information => LevelInformation, + LogEventLevel.Warning => LevelWarning, + LogEventLevel.Error => LevelError, + LogEventLevel.Fatal => LevelFatal, + _ => LevelUnknown + }; + + var output = new StringBuilder($"[c/6c6c6c:{logEvent.Timestamp:HH:mm:ss zz}] [{logLevel}] "); + foreach (var token in logEvent.MessageTemplate.Tokens) { + if (token is TextToken textToken) { + output.Append(textToken.Text); + continue; + } + + var propertyToken = (PropertyToken)token; + if (!logEvent.Properties.TryGetValue(propertyToken.PropertyName, out var propertyValue)) { + output.Append($"[c/ff0000:{propertyToken}]"); + continue; + } + + output.Append(_formatter.Format(propertyValue)); + } + + _player.SendMessage(output.ToString(), _textColor); + + if (logEvent.Exception != null) { + using var reader = new StringReader(logEvent.Exception.ToString()); + string line; + while ((line = reader.ReadLine()) != null) { + _player.SendMessage(line, _exceptionColor); + } + } + } + } +} diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs new file mode 100644 index 000000000..c3fe2535e --- /dev/null +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Globalization; +using System.Linq; +using Serilog.Data; +using Serilog.Events; +using Unit = System.ValueTuple; + +namespace TShock.Commands.Logging { + internal sealed class PlayerLogValueFormatter : LogEventPropertyValueVisitor { + private const string NullFormat = "[c/33cc99:null]"; + private const string StringFormat = "[c/d69d85:\"{0}\"]"; + private const string BooleanFormat = "[c/33cc99:{0}]"; + private const string CharFormat = "[c/d69d85:'{0}']"; + private const string NumberFormat = "[c/b5cea8:{0}]"; + private const string ScalarFormat = "[c/86c691:{0}]"; + private const string TypeTagFormat = "[c/4ec9b0:{0}] "; + + public string Format(LogEventPropertyValue value) => Visit(default, value); + + protected override string VisitScalarValue(Unit _, ScalarValue scalar) => + string.Format(CultureInfo.InvariantCulture, scalar.Value switch { + null => NullFormat, + string _ => StringFormat, + bool _ => BooleanFormat, + char _ => CharFormat, + var n when n.GetType().IsPrimitive || n is decimal => NumberFormat, + _ => ScalarFormat + }, scalar.Value); + + protected override string VisitSequenceValue(Unit _, SequenceValue sequence) => + $"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"; + + protected override string VisitStructureValue(Unit _, StructureValue structure) => + $"{(structure.TypeTag != null ? string.Format(TypeTagFormat, structure.TypeTag) : "")}" + + $"{{{string.Join(", ", structure.Properties.Select(p => $"{p.Name}={Visit(_, p.Value)}"))}}}"; + + protected override string VisitDictionaryValue(Unit _, DictionaryValue dictionary) => + $"{{{string.Join(", ", dictionary.Elements.Select(e => $"[{Visit(_, e.Key)}]={Visit(_, e.Value)}"))}}}"; + } +} diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 2c897a70d..472a4bfb9 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -20,17 +20,31 @@ using Microsoft.Xna.Framework; using Orion.Players; using Serilog; +using Serilog.Events; +using TShock.Commands.Logging; namespace TShock.Commands { internal sealed class PlayerCommandSender : ICommandSender { +#if DEBUG + private const LogEventLevel LogLevel = LogEventLevel.Verbose; +#else + private const LogEventLevel LogLevel = LogEventLevel.Error; +#endif + public string Name => Player.Name; - public ILogger Log => throw new NotImplementedException(); + public ILogger Log { get; } public IPlayer Player { get; } public PlayerCommandSender(IPlayer player, ReadOnlySpan input) { Debug.Assert(player != null, "player != null"); Player = player; + Log = new LoggerConfiguration().MinimumLevel.Is(LogLevel) + .WriteTo.Sink(new PlayerLogSink(player)) + .Enrich.WithProperty("Player", player.Name) + .Enrich.WithProperty("Cmd", input.ToString()) + .WriteTo.Logger(Serilog.Log.Logger) + .CreateLogger(); } public void SendMessage(ReadOnlySpan message) => SendMessage(message, Color.White); diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 84117bf44..f75f3c41c 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -21,6 +21,7 @@ using Orion; using Orion.Events; using Orion.Events.Packets; +using Orion.Packets.World; using Orion.Players; using TShock.Commands; @@ -50,9 +51,6 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService) : ba kernel.Bind().To(); _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); - - var ccs = new ConsoleCommandSender(); - ccs.SendMessage("test[c/abcdef:12345]", Color.OrangeRed); } /// @@ -68,6 +66,9 @@ protected override void Dispose(bool disposeManaged) { } [EventHandler(EventPriority.Lowest)] - private void PacketReceiveHandler(object sender, PacketReceiveEventArgs args) { } + private void PacketReceiveHandler(object sender, PacketReceiveEventArgs args) { + var s = new PlayerCommandSender(args.Sender, "test"); + s.SendMessage("TEST!!!!", Color.Orange); + } } } diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs new file mode 100644 index 000000000..bace97fa3 --- /dev/null +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Players; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Xunit; + +namespace TShock.Commands.Logging { + public class PlayerLogSinkTests { + private readonly Mock _mockPlayer; + private readonly ILogger _logger; + + public PlayerLogSinkTests() { + _mockPlayer = new Mock(); + ILogEventSink sink = new PlayerLogSink(_mockPlayer.Object); + _logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); + } + + private void VerifyMessage(string regex) { + _mockPlayer.Verify(p => p.SendMessage(It.IsRegex(regex), It.IsAny())); + } + + [Fact] + public void Emit_Verbose() { + _logger.Verbose("test"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:VRB\]\] test"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_Debug() { + _logger.Debug("test"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:DBG\]\] test"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_Information() { + _logger.Information("test"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:INF\]\] test"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_Warning() { + _logger.Warning("test"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:WRN\]\] test"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_Error() { + _logger.Error("test"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:ERR\]\] test"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_Fatal() { + _logger.Fatal("test"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:FTL\]\] test"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_WithProperties() { + _logger.Verbose("{Bool} {Int}", true, 42); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:VRB\]\] \[c/[a-fA-F0-9]{6}:True\] \[c/[a-fA-F0-9]{6}:42\]"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_PropertyMissing() { + _logger.Verbose("{Bool}"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:VRB\]\] \[c/[a-fA-F0-9]{6}:{Bool}\]"); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Emit_WithException() { + static void Exception1() => Exception2(); + static void Exception2() => Exception3(); + static void Exception3() => throw new NotImplementedException(); + + Exception exception = null; + try { + Exception1(); + } catch (NotImplementedException ex) { + exception = ex; + } + + _logger.Error(exception, "Exception"); + + VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:ERR\]\] Exception"); + VerifyMessage(@"System\.NotImplementedException: The method or operation is not implemented."); + VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); + VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); + VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); + VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); + _mockPlayer.VerifyNoOtherCalls(); + } + } +} diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs new file mode 100644 index 000000000..3d75f1c49 --- /dev/null +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using FluentAssertions; +using Serilog.Events; +using Xunit; + +namespace TShock.Commands.Logging { + public class PlayerLogValueFormatterTests { + [Fact] + public void Format_Null() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new ScalarValue(null)).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:null\]"); + } + + [Fact] + public void Format_String() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new ScalarValue("test")).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:""test""\]"); + } + + [Fact] + public void Format_Bool() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new ScalarValue(true)).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:True\]"); + } + + [Fact] + public void Format_Char() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new ScalarValue('t')).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:'t'\]"); + } + + [Fact] + public void Format_Number() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new ScalarValue(-12345)).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:-12345\]"); + } + + [Fact] + public void Format_Sequence() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new SequenceValue(new[] {new ScalarValue(1), new ScalarValue(2)})) + .Should().MatchRegex(@"\[\[c/[a-fA-F0-9]{6}:1\], \[c/[a-fA-F0-9]{6}:2\]\]"); + } + + [Fact] + public void Format_Structure() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new StructureValue(new[] {new LogEventProperty("Test", new ScalarValue(1))})) + .Should().MatchRegex(@"{Test=\[c/[a-fA-F0-9]{6}:1\]}"); + } + + [Fact] + public void Format_Structure_WithTypeTag() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new StructureValue(new[] {new LogEventProperty("Test", new ScalarValue(1))}, "Type")) + .Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:Type\] {Test=\[c/[a-fA-F0-9]{6}:1\]}"); + } + + [Fact] + public void Format_Dictionary() { + var formatter = new PlayerLogValueFormatter(); + + formatter.Format(new DictionaryValue(new[] { + new KeyValuePair(new ScalarValue(1), new ScalarValue("test")) + })).Should().MatchRegex(@"{\[\[c/[a-fA-F0-9]{6}:1\]\]=\[c/[a-fA-F0-9]{6}:""test""\]}"); + } + } +} diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 84be7047d..afb9d151a 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -28,6 +28,7 @@ public class PlayerCommandSenderTests { public PlayerCommandSenderTests() { _sender = new PlayerCommandSender(_mockPlayer.Object, "test"); + _mockPlayer.VerifyGet(p => p.Name); } [Fact] From d813ebbe6e0672095d82c87bbbb8f6f2179211e7 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 2 Oct 2019 20:47:15 -0700 Subject: [PATCH 059/119] Remove redundant _IsCorrect() in test names. --- .../Commands/CommandHandlerAttributeTests.cs | 4 ++-- .../Commands/ConsoleCommandSenderTests.cs | 4 ++-- .../Extensions/ReadOnlySpanExtensionsTests.cs | 4 ++-- .../Commands/Parsers/FlagAttributeTests.cs | 2 +- .../Commands/Parsers/ParseOptionsAttributeTests.cs | 2 +- .../Commands/Parsers/StringParserTests.cs | 4 ++-- .../Commands/PlayerCommandSenderTests.cs | 8 ++++---- .../Commands/TShockCommandServiceTests.cs | 12 ++++++------ tests/TShock.Tests/Commands/TShockCommandTests.cs | 2 +- .../Events/Commands/CommandExecuteEventArgsTests.cs | 4 ++-- 10 files changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs index 11c080428..f2af13f5d 100644 --- a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs +++ b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs @@ -36,14 +36,14 @@ public void Ctor_NullCommandSubNames_ThrowsArgumentNullException() { } [Fact] - public void CommandName_Get_IsCorrect() { + public void CommandName_Get() { var attribute = new CommandHandlerAttribute("test"); attribute.CommandName.Should().Be("test"); } [Fact] - public void CommandSubNames_Get_IsCorrect() { + public void CommandSubNames_Get() { var attribute = new CommandHandlerAttribute("", "test1", "test2"); attribute.CommandSubNames.Should().BeEquivalentTo("test1", "test2"); diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs index 86f7495ce..72a4b3fe5 100644 --- a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -21,14 +21,14 @@ namespace TShock.Commands { public class ConsoleCommandSenderTests { [Fact] - public void Name_Get_IsCorrect() { + public void Name_Get() { var sender = new ConsoleCommandSender("test"); sender.Name.Should().Be("Console"); } [Fact] - public void Player_Get_IsCorrect() { + public void Player_Get() { var sender = new ConsoleCommandSender("test"); sender.Player.Should().BeNull(); diff --git a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs index ed432cb9a..9b499bc13 100644 --- a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs +++ b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs @@ -22,12 +22,12 @@ namespace TShock.Commands.Extensions { public class ReadOnlySpanExtensionsTests { [Fact] - public void ScanFor_IsCorrect() { + public void ScanFor() { "abcdeA".AsSpan().ScanFor(char.IsUpper).Should().Be(5); } [Fact] - public void ScanFor_ReachesEnd_IsCorrect() { + public void ScanFor_ReachesEnd() { "abcdefghijklmnopqrstuvwxyz".AsSpan().ScanFor(char.IsUpper).Should().Be(26); } } diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs index 056f1f1cc..e373ba2c0 100644 --- a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs @@ -36,7 +36,7 @@ public void Ctor_NullAlternateFlags_ThrowsArgumentNullException() { } [Fact] - public void Flags_Get_IsCorrect() { + public void Flags_Get() { var attribute = new FlagAttribute("test1", "test2", "test3"); attribute.Flags.Should().BeEquivalentTo("test1", "test2", "test3"); diff --git a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs index 5a9907ea9..e5c4e1fc8 100644 --- a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs @@ -29,7 +29,7 @@ public void Ctor_NullOptions_ThrowsArgumentNullException() { } [Fact] - public void Options_Get_IsCorrect() { + public void Options_Get() { var attribute = new ParseOptionsAttribute("test", "test2"); attribute.Options.Should().BeEquivalentTo("test", "test2"); diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index befc5accf..ad762c2ff 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -57,7 +57,7 @@ public void Parse_EscapeReachesEnd_ThrowsParseException(string inputString) { } [Fact] - public void Parse_ToEndOfInput_IsCorrect() { + public void Parse_ToEndOfInput() { var parser = new StringParser(); var input = @"blah blah ""test"" blah blah".AsSpan(); @@ -68,7 +68,7 @@ public void Parse_ToEndOfInput_IsCorrect() { } [Fact] - public void Parse_AllowEmpty_IsCorrect() { + public void Parse_AllowEmpty() { var parser = new StringParser(); var input = string.Empty.AsSpan(); diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index afb9d151a..47c66c7a7 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -32,7 +32,7 @@ public PlayerCommandSenderTests() { } [Fact] - public void Name_Get_IsCorrect() { + public void Name_Get() { _mockPlayer.SetupGet(p => p.Name).Returns("test"); _sender.Name.Should().Be("test"); @@ -42,13 +42,13 @@ public void Name_Get_IsCorrect() { } [Fact] - public void Player_Get_IsCorrect() { + public void Player_Get() { _sender.Player.Should().NotBeNull(); _sender.Player.Should().Be(_mockPlayer.Object); } [Fact] - public void SendMessage_IsCorrect() { + public void SendMessage() { _sender.SendMessage("test"); _mockPlayer.Verify(p => p.SendMessage("test", Color.White)); @@ -56,7 +56,7 @@ public void SendMessage_IsCorrect() { } [Fact] - public void SendMessage_WithColor_IsCorrect() { + public void SendMessage_WithColor() { _sender.SendMessage("test", Color.Orange); _mockPlayer.Verify(p => p.SendMessage("test", Color.Orange)); diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index e0c6d46ca..b0bbb6e86 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -37,7 +37,7 @@ public void Dispose() { } [Fact] - public void RegisteredCommands_Get_IsCorrect() { + public void RegisteredCommands_Get() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); @@ -46,7 +46,7 @@ public void RegisteredCommands_Get_IsCorrect() { } [Fact] - public void RegisteredParsers_Get_IsCorrect() { + public void RegisteredParsers_Get() { var parser = new Mock>().Object; _commandService.RegisterParser(parser); @@ -54,7 +54,7 @@ public void RegisteredParsers_Get_IsCorrect() { } [Fact] - public void RegisterCommands_IsCorrect() { + public void RegisterCommands() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); @@ -81,7 +81,7 @@ public void RegisterParser_NullParser_ThrowsArgumentNullException() { } [Fact] - public void UnregisterCommand_IsCorrect() { + public void UnregisterCommand() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; @@ -121,7 +121,7 @@ public void CommandRegister_IsTriggered() { } [Fact] - public void CommandRegister_Canceled_IsCorrect() { + public void CommandRegister_Canceled() { var testClass = new TestClass(); _commandService.CommandRegister += (sender, args) => { args.Cancel(); @@ -147,7 +147,7 @@ public void CommandUnregister_IsTriggered() { } [Fact] - public void CommandUnregister_Canceled_IsCorrect() { + public void CommandUnregister_Canceled() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index d74c9c6d6..d7806df7b 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -64,7 +64,7 @@ public void Ctor_PointerParam_ThrowsNotSupportedException() { } [Fact] - public void Invoke_Sender_IsCorrect() { + public void Invoke_Sender() { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; diff --git a/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs b/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs index 8f67dfc1e..acbed37cb 100644 --- a/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs +++ b/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs @@ -41,7 +41,7 @@ public void Ctor_NullInput_ThrowsArgumentNullException() { } [Fact] - public void Sender_Get_IsCorrect() { + public void Sender_Get() { var command = new Mock().Object; var sender = new Mock().Object; var args = new CommandExecuteEventArgs(command, sender, ""); @@ -50,7 +50,7 @@ public void Sender_Get_IsCorrect() { } [Fact] - public void Input_Get_IsCorrect() { + public void Input_Get() { var command = new Mock().Object; var sender = new Mock().Object; var args = new CommandExecuteEventArgs(command, sender, "test"); From a3f0a2ceadc3d5eca8efe921f141c27310fe0a13 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Thu, 3 Oct 2019 09:23:09 -0700 Subject: [PATCH 060/119] General cleanup. --- .../Extensions/ReadOnlySpanExtensions.cs | 5 + .../Commands/Parsers/IArgumentParser.cs | 4 +- .../Commands/Parsers/IArgumentParser`1.cs | 8 ++ src/TShock/Commands/Parsers/Int32Parser.cs | 9 +- src/TShock/Commands/Parsers/StringParser.cs | 9 +- src/TShock/Commands/TShockCommand.cs | 105 +++++++----------- .../Extensions/ReadOnlySpanExtensionsTests.cs | 10 ++ .../Commands/TShockCommandTests.cs | 2 + 8 files changed, 73 insertions(+), 79 deletions(-) diff --git a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs index 978bfcdcf..fc3a25b74 100644 --- a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs +++ b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs @@ -20,6 +20,11 @@ namespace TShock.Commands.Extensions { internal static class ReadOnlySpanExtensions { + public static int IndexOfOrEnd(this ReadOnlySpan span, T value) where T : IEquatable { + var index = span.IndexOf(value); + return index >= 0 ? index : span.Length; + } + public static int ScanFor(this ReadOnlySpan input, Func predicate, int start = 0) { Debug.Assert(predicate != null, "predicate != null"); diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 102b05851..da5b1313b 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -30,12 +30,12 @@ public interface IArgumentParser { /// The parse options. /// A corresponding object. /// The input could not be parsed properly. - object Parse(ref ReadOnlySpan input, ISet? options = null); + object? Parse(ref ReadOnlySpan input, ISet? options = null); /// /// Gets a default object. /// /// The default object. - object GetDefault(); + object? GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 17a87a789..03d62d359 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace TShock.Commands.Parsers { /// @@ -33,10 +34,17 @@ public interface IArgumentParser : IArgumentParser { /// The input could not be parsed properly. new TParse Parse(ref ReadOnlySpan input, ISet? options = null); + [ExcludeFromCodeCoverage] + object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => + Parse(ref input, options); + /// /// Gets a default instance of the parse type. /// /// A default instance of the parse type. new TParse GetDefault(); + + [ExcludeFromCodeCoverage] + object? IArgumentParser.GetDefault() => GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 93e3a65e0..a05b2323c 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -25,7 +25,7 @@ namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { public int Parse(ref ReadOnlySpan input, ISet? options = null) { - var end = input.ScanFor(char.IsWhiteSpace); + var end = input.IndexOfOrEnd(' '); var parse = input[..end]; input = input[end..]; @@ -40,13 +40,8 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } - - public int GetDefault() => 0; - - [ExcludeFromCodeCoverage] - object IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); [ExcludeFromCodeCoverage] - object IArgumentParser.GetDefault() => GetDefault(); + public int GetDefault() => 0; } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 7cb6c024c..d3f55133f 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -80,13 +80,8 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) input = input[end..]; return builder.ToString(); } - - public string GetDefault() => string.Empty; - + [ExcludeFromCodeCoverage] - object IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); - - [ExcludeFromCodeCoverage] - object IArgumentParser.GetDefault() => GetDefault(); + public string GetDefault() => string.Empty; } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 1e959d1be..0c5d36756 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -102,34 +102,31 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { var shortFlags = new HashSet(); var longFlags = new HashSet(); - var optionals = new Dictionary(); + var optionals = new Dictionary(); - object ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo) { + object? ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo) { var parameterType = parameterInfo.ParameterType; if (!_commandService.Parsers.TryGetValue(parameterType, out var parser)) { throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedArgType, parameterType)); } + input = input.TrimStart(); var options = parameterInfo.GetCustomAttribute()?.Options; - var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (start >= input.Length) { - if (options?.Contains(ParseOptions.AllowEmpty) != true) { - throw new CommandParseException( - string.Format(Resources.CommandParse_MissingArg, parameterInfo)); - } + if (!input.IsEmpty) return parser.Parse(ref input, options); - input = default; - return parser.GetDefault(); + if (options?.Contains(ParseOptions.AllowEmpty) != true) { + throw new CommandParseException( + string.Format(Resources.CommandParse_MissingArg, parameterInfo)); } - input = input[start..]; - return parser.Parse(ref input, options); + return parser.GetDefault(); } - void ParseShortFlags(ref ReadOnlySpan input, int start, int end) { - for (var i = start; i < end; ++i) { - var c = input[i]; + void ParseShortFlags(ref ReadOnlySpan input, int space) { + if (space <= 1) throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + + foreach (var c in input[1..space]) { if (!_validShortFlags.Contains(c)) { throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedShortFlag, c)); @@ -138,83 +135,66 @@ void ParseShortFlags(ref ReadOnlySpan input, int start, int end) { shortFlags.Add(c); } - input = input[end..]; + input = input[space..]; } - void ParseLongFlag(ref ReadOnlySpan input, int start, int end) { - var longFlag = input[start..end].ToString(); + void ParseLongFlag(ref ReadOnlySpan input, int space) { + if (space <= 2) throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + + var longFlag = input[2..space].ToString(); if (!_validLongFlags.Contains(longFlag)) { throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedLongFlag, longFlag)); } - input = input[end..]; longFlags.Add(longFlag); + input = input[space..]; } - void ParseOptional(ref ReadOnlySpan input, int start, int end) { - var optional = input[start..end].ToString(); + void ParseOptional(ref ReadOnlySpan input, int equals) { + if (equals <= 2) throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + + var optional = input[2..equals].ToString(); if (!_validOptionals.TryGetValue(optional, out var parameterInfo)) { throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedOptional, optional)); } - // Skip over the '='. - start = input.ScanFor(c => !char.IsWhiteSpace(c), end + 1); - input = input[start..]; + input = input[(equals + 1)..]; optionals[optional] = ParseArgument(ref input, parameterInfo); } /* * Parse all hyphenated arguments: - * - Short flags are single-character flags and use one hyphen: "-f". - * - Long flags are string flags and use two hyphens: "--force". - * - Optionals specify values with two hyphens: "--depth=10". + * 1) Short flags are single-character flags and use one hyphen: "-f". + * 2) Long flags are string flags and use two hyphens: "--force". + * 3) Optionals specify values with two hyphens: "--depth=10". */ void ParseHyphenatedArguments(ref ReadOnlySpan input) { - while (true) { - var start = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (start >= input.Length || input[start] != '-') { - input = input[start..]; - break; - } - - if (++start >= input.Length) { - throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); - } - - if (input[start] == '-') { - if (++start >= input.Length) { - throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); - } - - var end = input.ScanFor(c => char.IsWhiteSpace(c) || c == '=', start); - if (start >= end) { - throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); - } - - if (end >= input.Length || input[end] != '=') { - ParseLongFlag(ref input, start, end); + input = input.TrimStart(); + while (input.StartsWith("-")) { + var space = input.IndexOfOrEnd(' '); + if (input.StartsWith("--")) { + var equals = input.IndexOfOrEnd('='); + if (equals < space) { + ParseOptional(ref input, equals); } else { - ParseOptional(ref input, start, end); + ParseLongFlag(ref input, space); } } else { - var end = input.ScanFor(char.IsWhiteSpace, start); - if (start >= end) { - throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); - } - - ParseShortFlags(ref input, start, end); + ParseShortFlags(ref input, space); } + + input = input.TrimStart(); } } /* * Parse a parameter: - * - If the parameter is an ICommandSender, then inject sender. - * - If the parameter is a bool and is marked with FlagAttribute, then inject the flag. - * - If the parameter is optional, then inject the optional or else the default value. - * - Otherwise, we parse the argument directly. + * 1) If the parameter is an ICommandSender, then inject sender. + * 2) If the parameter is a bool and is marked with FlagAttribute, then inject the flag. + * 3) If the parameter is optional, then inject the optional or else the default value. + * 4) Otherwise, we parse the argument directly. */ object? ParseParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { var parameterType = parameterInfo.ParameterType; @@ -246,8 +226,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } // Ensure that we've consumed all of the useful parts of the input. - var end = input.ScanFor(c => !char.IsWhiteSpace(c)); - if (end < input.Length) { + if (!input.IsWhiteSpace()) { throw new CommandParseException(Resources.CommandParse_TooManyArgs); } diff --git a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs index 9b499bc13..3b003d5be 100644 --- a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs +++ b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs @@ -21,6 +21,16 @@ namespace TShock.Commands.Extensions { public class ReadOnlySpanExtensionsTests { + [Fact] + public void IndexOfOrEnd() { + "abcde".AsSpan().IndexOfOrEnd('b').Should().Be(1); + } + + [Fact] + public void IndexOfOrEnd_AtEnd() { + "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); + } + [Fact] public void ScanFor() { "abcdeA".AsSpan().ScanFor(char.IsUpper).Should().Be(5); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index d7806df7b..fcda6953f 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -148,6 +148,7 @@ public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int e [InlineData("--recursive --force", true, true, 10)] [InlineData("--depth=1 --recursive -f", true, true, 1)] [InlineData("--force -r --depth=100 ", true, true, 100)] + [InlineData("--force -r --depth= 100 ", true, true, 100)] public void Invoke_FlagsAndOptionals_IsCorrect(string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { @@ -286,6 +287,7 @@ public void Invoke_UnexpectedOptional_ThrowsCommandParseException(string input) [InlineData("- ")] [InlineData("--")] [InlineData("-- ")] + [InlineData("--= ")] public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string input) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); From 1545f0366ac61a352bdacf387f0daa6b198b51f0 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Thu, 3 Oct 2019 09:28:33 -0700 Subject: [PATCH 061/119] Remove localization for programmer-oriented output. --- src/TShock/Commands/TShockCommand.cs | 12 +++--------- src/TShock/Properties/Resources.Designer.cs | 18 ------------------ src/TShock/Properties/Resources.resx | 6 ------ 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 0c5d36756..7632f83db 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -55,16 +55,10 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att // Preprocessing parameters in the constructor allows us to learn the command's flags and optionals. void PreprocessParameter(ParameterInfo parameterInfo) { var parameterType = parameterInfo.ParameterType; - - // Check for types we don't support, such as by-reference and pointers. if (parameterType.IsByRef) { - throw new NotSupportedException( - string.Format(Resources.CommandCtor_ByRefArgType, parameterType)); - } - - if (parameterType.IsPointer) { - throw new NotSupportedException( - string.Format(Resources.CommandCtor_PointerArgType, parameterType)); + throw new NotSupportedException($"By-reference argument type {parameterType} not supported."); + } else if (parameterType.IsPointer) { + throw new NotSupportedException($"Pointer argument type {parameterType} not supported."); } // If the parameter is a bool and it is marked with FlagAttribute, we'll note it. diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 8d45a5ebe..2bce36260 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -60,24 +60,6 @@ internal Resources() { } } - /// - /// Looks up a localized string similar to By-reference argument type "{0}" not supported.. - /// - internal static string CommandCtor_ByRefArgType { - get { - return ResourceManager.GetString("CommandCtor_ByRefArgType", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Pointer argument type "{0}" not supported.. - /// - internal static string CommandCtor_PointerArgType { - get { - return ResourceManager.GetString("CommandCtor_PointerArgType", resourceCulture); - } - } - /// /// Looks up a localized string similar to Exception occurred while executing command.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 1a3af3455..92a7f458c 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,12 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - By-reference argument type "{0}" not supported. - - - Pointer argument type "{0}" not supported. - Exception occurred while executing command. From 0a0665b8ad9b40d9ac84ad97d7e9febf5819dcb5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Thu, 3 Oct 2019 20:02:27 -0700 Subject: [PATCH 062/119] Cleanup code some more. --- .../Commands/Extensions/ReadOnlySpanExtensions.cs | 12 ------------ src/TShock/Commands/TShockCommand.cs | 9 +++------ .../Extensions/ReadOnlySpanExtensionsTests.cs | 10 ---------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs index fc3a25b74..fa20bdca2 100644 --- a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs +++ b/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs @@ -24,17 +24,5 @@ public static int IndexOfOrEnd(this ReadOnlySpan span, T value) where T : var index = span.IndexOf(value); return index >= 0 ? index : span.Length; } - - public static int ScanFor(this ReadOnlySpan input, Func predicate, int start = 0) { - Debug.Assert(predicate != null, "predicate != null"); - - while (start < input.Length) { - if (predicate(input[start])) break; - - ++start; - } - - return start; - } } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 7632f83db..10f26a571 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -110,8 +110,7 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { if (!input.IsEmpty) return parser.Parse(ref input, options); if (options?.Contains(ParseOptions.AllowEmpty) != true) { - throw new CommandParseException( - string.Format(Resources.CommandParse_MissingArg, parameterInfo)); + throw new CommandParseException(string.Format(Resources.CommandParse_MissingArg, parameterInfo)); } return parser.GetDefault(); @@ -122,8 +121,7 @@ void ParseShortFlags(ref ReadOnlySpan input, int space) { foreach (var c in input[1..space]) { if (!_validShortFlags.Contains(c)) { - throw new CommandParseException( - string.Format(Resources.CommandParse_UnrecognizedShortFlag, c)); + throw new CommandParseException(string.Format(Resources.CommandParse_UnrecognizedShortFlag, c)); } shortFlags.Add(c); @@ -192,14 +190,13 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { */ object? ParseParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { var parameterType = parameterInfo.ParameterType; - if (parameterType == typeof(ICommandSender)) return sender; if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); if (attribute != null) { return attribute.Flags.Any(f => f.Length == 1 && shortFlags.Contains(f[0]) || - longFlags.Contains(f)); + longFlags.Contains(f)); } } diff --git a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs index 3b003d5be..06bc82806 100644 --- a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs +++ b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs @@ -30,15 +30,5 @@ public void IndexOfOrEnd() { public void IndexOfOrEnd_AtEnd() { "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); } - - [Fact] - public void ScanFor() { - "abcdeA".AsSpan().ScanFor(char.IsUpper).Should().Be(5); - } - - [Fact] - public void ScanFor_ReachesEnd() { - "abcdefghijklmnopqrstuvwxyz".AsSpan().ScanFor(char.IsUpper).Should().Be(26); - } } } From f0a686119bcb93547195fb6445019f6f68ae4a15 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Fri, 4 Oct 2019 20:12:57 -0700 Subject: [PATCH 063/119] Make IndexOfOrEnd public and move to Utils/Extensions. --- src/TShock/Commands/Parsers/Int32Parser.cs | 2 +- .../Extensions/ReadOnlySpanExtensions.cs | 38 +++++++++++++++++++ .../Extensions/ReadOnlySpanExtensionsTests.cs | 18 ++++++--- 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs rename src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs => tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs (67%) diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index a05b2323c..58240fde2 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -19,8 +19,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using TShock.Commands.Extensions; using TShock.Properties; +using TShock.Utils.Extensions; namespace TShock.Commands.Parsers { internal sealed class Int32Parser : IArgumentParser { diff --git a/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs new file mode 100644 index 000000000..af745b175 --- /dev/null +++ b/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the structure. + /// + public static class ReadOnlySpanExtensions { + /// + /// Searches for the specified value and returns the first index of its occurrence, or the length of the span + /// if it is not found. + /// + /// The type of span. + /// The span. + /// The value. + /// The index of the first occurrence, or the length of the span if it is not found. + public static int IndexOfOrEnd(this ReadOnlySpan span, T value) where T : IEquatable { + var index = span.IndexOf(value); + return index >= 0 ? index : span.Length; + } + } +} diff --git a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs b/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs similarity index 67% rename from src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs rename to tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs index fa20bdca2..2d43e7c01 100644 --- a/src/TShock/Commands/Extensions/ReadOnlySpanExtensions.cs +++ b/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs @@ -16,13 +16,19 @@ // along with TShock. If not, see . using System; -using System.Diagnostics; +using FluentAssertions; +using Xunit; -namespace TShock.Commands.Extensions { - internal static class ReadOnlySpanExtensions { - public static int IndexOfOrEnd(this ReadOnlySpan span, T value) where T : IEquatable { - var index = span.IndexOf(value); - return index >= 0 ? index : span.Length; +namespace TShock.Utils.Extensions { + public class ReadOnlySpanExtensionsTests { + [Fact] + public void IndexOfOrEnd() { + "abcde".AsSpan().IndexOfOrEnd('b').Should().Be(1); + } + + [Fact] + public void IndexOfOrEnd_AtEnd() { + "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); } } } From bf2ecd88c45454ef0e85ddf3c383891471a0699c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Fri, 4 Oct 2019 20:13:45 -0700 Subject: [PATCH 064/119] Include test deletion. --- .../Extensions/ReadOnlySpanExtensionsTests.cs | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs diff --git a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs deleted file mode 100644 index 06bc82806..000000000 --- a/tests/TShock.Tests/Commands/Extensions/ReadOnlySpanExtensionsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using FluentAssertions; -using Xunit; - -namespace TShock.Commands.Extensions { - public class ReadOnlySpanExtensionsTests { - [Fact] - public void IndexOfOrEnd() { - "abcde".AsSpan().IndexOfOrEnd('b').Should().Be(1); - } - - [Fact] - public void IndexOfOrEnd_AtEnd() { - "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); - } - } -} From 77d2b90c1eccfc79899bb0f3f4fd4f5f1c2ea15d Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Fri, 4 Oct 2019 22:43:29 -0700 Subject: [PATCH 065/119] Rename command Name to QualifiedName. --- .../Commands/CommandHandlerAttribute.cs | 47 +++++++++++++++---- src/TShock/Commands/ICommand.cs | 4 +- src/TShock/Commands/TShockCommand.cs | 6 +-- .../Commands/CommandHandlerAttributeTests.cs | 21 +++++++-- .../Commands/TShockCommandServiceTests.cs | 4 +- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 84757b2cc..afca806d4 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -28,9 +28,9 @@ namespace TShock.Commands { [MeansImplicitUse] public sealed class CommandHandlerAttribute : Attribute { /// - /// Gets the command's name. This includes the command's namespace: e.g., "tshock:kick". + /// Gets the command's qualified name. This includes the command's namespace: e.g., "tshock:kick". /// - public string CommandName { get; } + public string QualifiedCommandName { get; } /// /// Gets the command's sub-names. @@ -38,16 +38,45 @@ public sealed class CommandHandlerAttribute : Attribute { public IEnumerable CommandSubNames { get; } /// - /// Initializes a new instance of the class with the given command name - /// and sub-names. + /// Initializes a new instance of the class with the given qualified + /// command name and sub-names. /// - /// The command name. - /// The command sub-names. + /// + /// The qualified command name. This includes the namespace: e.g., "tshock:kick". + /// + /// The command sub-names. This can be empty. + /// + /// is missing the namespace or name, or contains a space. + /// /// - /// or are . + /// or are . /// - public CommandHandlerAttribute(string commandName, params string[] commandSubNames) { - CommandName = commandName ?? throw new ArgumentNullException(nameof(commandName)); + public CommandHandlerAttribute(string qualifiedCommandName, params string[] commandSubNames) { + if (qualifiedCommandName is null) { + throw new ArgumentNullException(nameof(qualifiedCommandName)); + } + + if (commandSubNames is null) { + throw new ArgumentNullException(nameof(commandSubNames)); + } + + var colon = qualifiedCommandName.IndexOf(':'); + if (colon <= 0) { + throw new ArgumentException("Qualified command name is missing the namespace.", + nameof(qualifiedCommandName)); + } + + if (colon >= qualifiedCommandName.Length - 1) { + throw new ArgumentException("Qualified command name is missing the name.", + nameof(qualifiedCommandName)); + } + + if (qualifiedCommandName.IndexOf(' ') >= 0) { + throw new ArgumentException("Qualified command name contains a space.", + nameof(qualifiedCommandName)); + } + + QualifiedCommandName = qualifiedCommandName; CommandSubNames = commandSubNames ?? throw new ArgumentNullException(nameof(commandSubNames)); } } diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 00c10f7c4..5a5760960 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -25,9 +25,9 @@ namespace TShock.Commands { /// public interface ICommand { /// - /// Gets the command's name. + /// Gets the command's qualified name. This includes the namespace: e.g., "tshock:kick". /// - string Name { get; } + string QualifiedName { get; } /// /// Gets the command's sub-names. diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 10f26a571..044d9d9ea 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -21,10 +21,10 @@ using System.Linq; using System.Reflection; using Orion.Events.Extensions; -using TShock.Commands.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; using TShock.Properties; +using TShock.Utils.Extensions; namespace TShock.Commands { internal class TShockCommand : ICommand { @@ -35,7 +35,7 @@ internal class TShockCommand : ICommand { private readonly ParameterInfo[] _parameterInfos; private readonly object?[] _parameters; - public string Name { get; } + public string QualifiedName { get; } public IEnumerable SubNames { get; } public object HandlerObject { get; } public MethodBase Handler { get; } @@ -47,7 +47,7 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att Debug.Assert(handler != null, "handler != null"); _commandService = commandService; - Name = attribute.CommandName; + QualifiedName = attribute.QualifiedCommandName; SubNames = attribute.CommandSubNames; HandlerObject = handlerObject; Handler = handler; diff --git a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs index f2af13f5d..d1364edf5 100644 --- a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs +++ b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs @@ -21,6 +21,17 @@ namespace TShock.Commands { public class CommandHandlerAttributeTests { + [Theory] + [InlineData("test")] + [InlineData(":test")] + [InlineData("test:")] + [InlineData(":")] + public void Ctor_InvalidQualifiedCommandName_ThrowsArgumentException(string qualifiedCommandName) { + Func func = () => new CommandHandlerAttribute(qualifiedCommandName); + + func.Should().Throw(); + } + [Fact] public void Ctor_NullCommandName_ThrowsArgumentNullException() { Func func = () => new CommandHandlerAttribute(null); @@ -30,21 +41,21 @@ public void Ctor_NullCommandName_ThrowsArgumentNullException() { [Fact] public void Ctor_NullCommandSubNames_ThrowsArgumentNullException() { - Func func = () => new CommandHandlerAttribute("", null); + Func func = () => new CommandHandlerAttribute("tshock_test:test", null); func.Should().Throw(); } [Fact] - public void CommandName_Get() { - var attribute = new CommandHandlerAttribute("test"); + public void QualifiedCommandName_Get() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); - attribute.CommandName.Should().Be("test"); + attribute.QualifiedCommandName.Should().Be("tshock_test:test"); } [Fact] public void CommandSubNames_Get() { - var attribute = new CommandHandlerAttribute("", "test1", "test2"); + var attribute = new CommandHandlerAttribute("tshock_test:test", "test1", "test2"); attribute.CommandSubNames.Should().BeEquivalentTo("test1", "test2"); } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index b0bbb6e86..d5f40e89d 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -62,7 +62,7 @@ public void RegisterCommands() { commands.Should().HaveCount(3); foreach (var command in commands) { command.HandlerObject.Should().BeSameAs(testClass); - command.Name.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); + command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); } } @@ -112,7 +112,7 @@ public void CommandRegister_IsTriggered() { _commandService.CommandRegister += (sender, args) => { isRun = true; args.Command.HandlerObject.Should().BeSameAs(testClass); - args.Command.Name.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); + args.Command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); }; _commandService.RegisterCommands(testClass); From 0a531c15dde35bad144cf34b9c9d1562da19ecc2 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Fri, 4 Oct 2019 23:07:38 -0700 Subject: [PATCH 066/119] Add DictionaryExtensions. --- src/TShock/Commands/CommandProcessor.cs | 65 +++++++++++++++++++ .../Utils/Extensions/DictionaryExtensions.cs | 55 ++++++++++++++++ .../Extensions/DictionaryExtensionsTests.cs | 56 ++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/TShock/Commands/CommandProcessor.cs create mode 100644 src/TShock/Utils/Extensions/DictionaryExtensions.cs create mode 100644 tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs diff --git a/src/TShock/Commands/CommandProcessor.cs b/src/TShock/Commands/CommandProcessor.cs new file mode 100644 index 000000000..cee0cc8da --- /dev/null +++ b/src/TShock/Commands/CommandProcessor.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Text; +using TShock.Events.Commands; +using TShock.Utils.Extensions; + +namespace TShock.Commands { + internal sealed class CommandProcessor : IDisposable { + private readonly ICommandService _commandService; + + // Lookup table that maps command name -> set of qualified command names. + private readonly Dictionary> _qualifiedCommandNameLookup = + new Dictionary>(); + + public CommandProcessor(ICommandService commandService) { + _commandService = commandService; + + _commandService.CommandRegister += CommandRegisterHandler; + _commandService.CommandUnregister += CommandUnregisterHandler; + } + + public void Dispose() { + _commandService.CommandRegister -= CommandRegisterHandler; + _commandService.CommandUnregister -= CommandUnregisterHandler; + } + + private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args) { + var command = args.Command; + + // First, fill in our qualified command name lookup table. + var colon = command.QualifiedName.IndexOf(':'); + var name = command.QualifiedName.Substring(colon + 1); + var qualifiedCommandNames = _qualifiedCommandNameLookup.GetValueOrDefault( + name, () => new HashSet()); + + if (!_qualifiedCommandNameLookup.TryGetValue(name, out var commands)) { + _qualifiedCommandNameLookup[name] = commands = new HashSet(); + } + + commands.Add(command.QualifiedName); + + } + + private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs args) { + + } + } +} diff --git a/src/TShock/Utils/Extensions/DictionaryExtensions.cs b/src/TShock/Utils/Extensions/DictionaryExtensions.cs new file mode 100644 index 000000000..4f8d8d0c7 --- /dev/null +++ b/src/TShock/Utils/Extensions/DictionaryExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the interface. + /// + public static class DictionaryExtensions { + /// + /// Gets the value corresponding to the given , using the default value provider if + /// does not exist. + /// + /// The type of key. + /// The type of value. + /// The dictionary. + /// The key. + /// The value provider. + /// + /// The value, or a default instance provided by if + /// does not exist in the dictionary. + /// + /// + /// or are . + /// + public static TValue GetValueOrDefault(this IDictionary dictionary, TKey key, + Func defaultValueProvider) { + if (dictionary is null) { + throw new ArgumentNullException(nameof(dictionary)); + } + + if (defaultValueProvider is null) { + throw new ArgumentNullException(nameof(defaultValueProvider)); + } + + return dictionary.TryGetValue(key, out var value) ? value : defaultValueProvider(); + } + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs new file mode 100644 index 000000000..0ebd7bcf6 --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace TShock.Utils.Extensions { + public class DictionaryExtensionsTests { + [Fact] + public void GetValueOrDefault_KeyExists() { + var dictionary = new Dictionary { + ["test"] = 10 + }; + + dictionary.GetValueOrDefault("test", () => 50).Should().Be(10); + } + + [Fact] + public void GetValueOrDefault_KeyDoesntExist() { + var dictionary = new Dictionary(); + + dictionary.GetValueOrDefault("test", () => 50).Should().Be(50); + } + + [Fact] + public void GetValueOrDefault_NullDictionary_ThrowsArgumentNullException() { + Func func = () => DictionaryExtensions.GetValueOrDefault(null, "", () => 0); + + func.Should().Throw(); + } + + [Fact] + public void GetValueOrDefault_NullValueProvider_ThrowsArgumentNullException() { + var dictionary = new Dictionary(); + Func func = () => DictionaryExtensions.GetValueOrDefault(dictionary, "", null); + + func.Should().Throw(); + } + } +} From 4650173733cfea74a1e6c2c4e5a33fc4f1a3c3f5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 00:05:06 -0700 Subject: [PATCH 067/119] DESIGN DECISION: do not support command subnames natively. This is simply far too annoying to implement cleanly, and the auto-generated error messages would not work well anyways. Anyone requiring subcommands should use a string parameter. --- .../Commands/CommandHandlerAttribute.cs | 20 ++++--------------- src/TShock/Commands/ICommand.cs | 5 ----- src/TShock/Commands/TShockCommand.cs | 2 -- .../Commands/CommandHandlerAttributeTests.cs | 16 +-------------- .../Commands/TShockCommandServiceTests.cs | 9 +++------ 5 files changed, 8 insertions(+), 44 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index afca806d4..eaffb8611 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -16,7 +16,6 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; using JetBrains.Annotations; namespace TShock.Commands { @@ -33,33 +32,23 @@ public sealed class CommandHandlerAttribute : Attribute { public string QualifiedCommandName { get; } /// - /// Gets the command's sub-names. - /// - public IEnumerable CommandSubNames { get; } - - /// - /// Initializes a new instance of the class with the given qualified - /// command name and sub-names. + /// Initializes a new instance of the class with the specified qualified + /// command name. /// /// /// The qualified command name. This includes the namespace: e.g., "tshock:kick". /// - /// The command sub-names. This can be empty. /// /// is missing the namespace or name, or contains a space. /// /// - /// or are . + /// is . /// - public CommandHandlerAttribute(string qualifiedCommandName, params string[] commandSubNames) { + public CommandHandlerAttribute(string qualifiedCommandName) { if (qualifiedCommandName is null) { throw new ArgumentNullException(nameof(qualifiedCommandName)); } - if (commandSubNames is null) { - throw new ArgumentNullException(nameof(commandSubNames)); - } - var colon = qualifiedCommandName.IndexOf(':'); if (colon <= 0) { throw new ArgumentException("Qualified command name is missing the namespace.", @@ -77,7 +66,6 @@ public CommandHandlerAttribute(string qualifiedCommandName, params string[] comm } QualifiedCommandName = qualifiedCommandName; - CommandSubNames = commandSubNames ?? throw new ArgumentNullException(nameof(commandSubNames)); } } } diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 5a5760960..fa12a43aa 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -29,11 +29,6 @@ public interface ICommand { /// string QualifiedName { get; } - /// - /// Gets the command's sub-names. - /// - IEnumerable SubNames { get; } - /// /// Gets the object associated with the command's handler. /// diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 044d9d9ea..12f547dc6 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -36,7 +36,6 @@ internal class TShockCommand : ICommand { private readonly object?[] _parameters; public string QualifiedName { get; } - public IEnumerable SubNames { get; } public object HandlerObject { get; } public MethodBase Handler { get; } @@ -48,7 +47,6 @@ public TShockCommand(ICommandService commandService, CommandHandlerAttribute att _commandService = commandService; QualifiedName = attribute.QualifiedCommandName; - SubNames = attribute.CommandSubNames; HandlerObject = handlerObject; Handler = handler; diff --git a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs index d1364edf5..5a21c6deb 100644 --- a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs +++ b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs @@ -33,31 +33,17 @@ public void Ctor_InvalidQualifiedCommandName_ThrowsArgumentException(string qual } [Fact] - public void Ctor_NullCommandName_ThrowsArgumentNullException() { + public void Ctor_NullQualifiedCommandName_ThrowsArgumentNullException() { Func func = () => new CommandHandlerAttribute(null); func.Should().Throw(); } - [Fact] - public void Ctor_NullCommandSubNames_ThrowsArgumentNullException() { - Func func = () => new CommandHandlerAttribute("tshock_test:test", null); - - func.Should().Throw(); - } - [Fact] public void QualifiedCommandName_Get() { var attribute = new CommandHandlerAttribute("tshock_test:test"); attribute.QualifiedCommandName.Should().Be("tshock_test:test"); } - - [Fact] - public void CommandSubNames_Get() { - var attribute = new CommandHandlerAttribute("tshock_test:test", "test1", "test2"); - - attribute.CommandSubNames.Should().BeEquivalentTo("test1", "test2"); - } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index d5f40e89d..c94d35345 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -59,7 +59,7 @@ public void RegisterCommands() { var commands = _commandService.RegisterCommands(testClass).ToList(); - commands.Should().HaveCount(3); + commands.Should().HaveCount(2); foreach (var command in commands) { command.HandlerObject.Should().BeSameAs(testClass); command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); @@ -164,11 +164,8 @@ private class TestClass { [CommandHandler("tshock_tests:test")] public void TestCommand() { } - [CommandHandler("tshock_tests:test2", "sub1")] - public void TestCommand2_Sub1() { } - - [CommandHandler("tshock_tests:test2", "sub2")] - public void TestCommand2_Sub2() { } + [CommandHandler("tshock_tests:test2")] + public void TestCommand2() { } } } } From b6bc926999703d2854498bf0b774704a2dccf7af Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 01:19:29 -0700 Subject: [PATCH 068/119] Implement command finding. Clean up code again. --- ...xception.cs => CommandExecuteException.cs} | 14 ++-- .../Commands/CommandHandlerAttribute.cs | 4 +- src/TShock/Commands/CommandParseException.cs | 6 +- src/TShock/Commands/CommandProcessor.cs | 65 -------------- src/TShock/Commands/ConsoleCommandSender.cs | 9 +- src/TShock/Commands/ICommand.cs | 9 +- src/TShock/Commands/ICommandSender.cs | 2 +- src/TShock/Commands/ICommandService.cs | 41 +++++---- src/TShock/Commands/Logging/PlayerLogSink.cs | 4 +- src/TShock/Commands/Parsers/FlagAttribute.cs | 20 +++-- .../Commands/Parsers/IArgumentParser.cs | 3 +- .../Commands/Parsers/IArgumentParser`1.cs | 8 +- src/TShock/Commands/Parsers/Int32Parser.cs | 2 +- .../Commands/Parsers/ParseOptionsAttribute.cs | 9 +- src/TShock/Commands/Parsers/StringParser.cs | 7 +- src/TShock/Commands/PlayerCommandSender.cs | 15 ++-- src/TShock/Commands/TShockCommand.cs | 49 +++++++---- src/TShock/Commands/TShockCommandService.cs | 84 ++++++++++++++++--- .../Events/Commands/CommandEventArgs.cs | 10 ++- .../Commands/CommandExecuteEventArgs.cs | 5 +- .../Commands/CommandRegisterEventArgs.cs | 2 +- .../Commands/CommandUnregisterEventArgs.cs | 2 +- src/TShock/Properties/Resources.Designer.cs | 27 ++++++ src/TShock/Properties/Resources.resx | 9 ++ src/TShock/TShockPlugin.cs | 7 +- .../Extensions/ReadOnlySpanExtensions.cs | 4 +- .../Commands/TShockCommandServiceTests.cs | 70 +++++++++++++--- .../Commands/TShockCommandTests.cs | 4 +- .../Events/Commands/CommandEventArgsTests.cs | 18 ++++ 29 files changed, 326 insertions(+), 183 deletions(-) rename src/TShock/Commands/{CommandException.cs => CommandExecuteException.cs} (77%) delete mode 100644 src/TShock/Commands/CommandProcessor.cs diff --git a/src/TShock/Commands/CommandException.cs b/src/TShock/Commands/CommandExecuteException.cs similarity index 77% rename from src/TShock/Commands/CommandException.cs rename to src/TShock/Commands/CommandExecuteException.cs index 4528f8650..86a711730 100644 --- a/src/TShock/Commands/CommandException.cs +++ b/src/TShock/Commands/CommandExecuteException.cs @@ -23,24 +23,24 @@ namespace TShock.Commands { /// The exception thrown when a command could not be executed. /// [Serializable, ExcludeFromCodeCoverage] - public class CommandException : Exception { + public class CommandExecuteException : Exception { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public CommandException() { } + public CommandExecuteException() { } /// - /// Initializes a new instance of the class with the specified message. + /// Initializes a new instance of the class with the specified message. /// /// The message. - public CommandException(string message) : base(message) { } + public CommandExecuteException(string message) : base(message) { } /// - /// Initializes a new instance of the class with the specified message + /// Initializes a new instance of the class with the specified message /// and inner exception. /// /// The message. /// The inner exception. - public CommandException(string message, Exception innerException) : base(message, innerException) { } + public CommandExecuteException(string message, Exception innerException) : base(message, innerException) { } } } diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index eaffb8611..793833d11 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -42,7 +42,7 @@ public sealed class CommandHandlerAttribute : Attribute { /// is missing the namespace or name, or contains a space. /// /// - /// is . + /// is (). /// public CommandHandlerAttribute(string qualifiedCommandName) { if (qualifiedCommandName is null) { @@ -54,7 +54,7 @@ public CommandHandlerAttribute(string qualifiedCommandName) { throw new ArgumentException("Qualified command name is missing the namespace.", nameof(qualifiedCommandName)); } - + if (colon >= qualifiedCommandName.Length - 1) { throw new ArgumentException("Qualified command name is missing the name.", nameof(qualifiedCommandName)); diff --git a/src/TShock/Commands/CommandParseException.cs b/src/TShock/Commands/CommandParseException.cs index e0a545b2a..8cf376996 100644 --- a/src/TShock/Commands/CommandParseException.cs +++ b/src/TShock/Commands/CommandParseException.cs @@ -23,7 +23,7 @@ namespace TShock.Commands { /// The exception thrown when a command input cannot be parsed. /// [Serializable, ExcludeFromCodeCoverage] - public class CommandParseException : CommandException { + public class CommandParseException : Exception { /// /// Initializes a new instance of the class. /// @@ -36,8 +36,8 @@ public CommandParseException() { } public CommandParseException(string message) : base(message) { } /// - /// Initializes a new instance of the class with the specified message - /// and inner exception. + /// Initializes a new instance of the class with the specified message and + /// inner exception. /// /// The message. /// The inner exception. diff --git a/src/TShock/Commands/CommandProcessor.cs b/src/TShock/Commands/CommandProcessor.cs deleted file mode 100644 index cee0cc8da..000000000 --- a/src/TShock/Commands/CommandProcessor.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using System.Collections.Generic; -using System.Text; -using TShock.Events.Commands; -using TShock.Utils.Extensions; - -namespace TShock.Commands { - internal sealed class CommandProcessor : IDisposable { - private readonly ICommandService _commandService; - - // Lookup table that maps command name -> set of qualified command names. - private readonly Dictionary> _qualifiedCommandNameLookup = - new Dictionary>(); - - public CommandProcessor(ICommandService commandService) { - _commandService = commandService; - - _commandService.CommandRegister += CommandRegisterHandler; - _commandService.CommandUnregister += CommandUnregisterHandler; - } - - public void Dispose() { - _commandService.CommandRegister -= CommandRegisterHandler; - _commandService.CommandUnregister -= CommandUnregisterHandler; - } - - private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args) { - var command = args.Command; - - // First, fill in our qualified command name lookup table. - var colon = command.QualifiedName.IndexOf(':'); - var name = command.QualifiedName.Substring(colon + 1); - var qualifiedCommandNames = _qualifiedCommandNameLookup.GetValueOrDefault( - name, () => new HashSet()); - - if (!_qualifiedCommandNameLookup.TryGetValue(name, out var commands)) { - _qualifiedCommandNameLookup[name] = commands = new HashSet(); - } - - commands.Add(command.QualifiedName); - - } - - private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs args) { - - } - } -} diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 14997ad92..13acf2f54 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -43,10 +43,11 @@ private static string GetColorString(Color color) => FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); public ConsoleCommandSender(ReadOnlySpan input) { - Log = new LoggerConfiguration().MinimumLevel.Is(LogLevel) - .Enrich.WithProperty("Cmd", input.ToString()) - .WriteTo.Logger(Serilog.Log.Logger) - .CreateLogger(); + Log = new LoggerConfiguration() + .MinimumLevel.Is(LogLevel) + .Enrich.WithProperty("Cmd", input.ToString()) + .WriteTo.Logger(Serilog.Log.Logger) + .CreateLogger(); } public void SendMessage(ReadOnlySpan message) => SendMessage(message, string.Empty); diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index fa12a43aa..98e4109b1 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -16,12 +16,11 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; using System.Reflection; namespace TShock.Commands { /// - /// Represents a command. + /// Represents a command. Commands can be executed by command senders, and provide bits of functionality. /// public interface ICommand { /// @@ -43,9 +42,9 @@ public interface ICommand { /// Invokes the command as the given sender with the specified input. /// /// The sender. - /// The input. This does not include the command's name or sub-names. - /// is . - /// The command could not be executed. + /// The input. This does not include the command's name. + /// is (). + /// The command could not be executed. /// The command input could not be parsed. void Invoke(ICommandSender sender, ReadOnlySpan input); } diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 8144e326c..bc8c5ca80 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -36,7 +36,7 @@ public interface ICommandSender { ILogger Log { get; } /// - /// Gets the sender's player. If , then there is no associated player. + /// Gets the sender's player. If (), then there is no associated player. /// IPlayer? Player { get; } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 64fd9f6cb..c5dfd7a82 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -28,54 +28,67 @@ namespace TShock.Commands { /// public interface ICommandService : IService { /// - /// Gets the commands. + /// Gets a read-only mapping from qualified command names to commands. /// - IReadOnlyCollection Commands { get; } + IReadOnlyDictionary Commands { get; } /// - /// Gets the registered parsers. + /// Gets a read-only mapping from types to parsers. /// IReadOnlyDictionary Parsers { get; } /// - /// Gets or sets the event handlers that occur when registering a command. + /// Gets or sets the event handlers that occur when registering a command. This event can be canceled. /// EventHandlerCollection? CommandRegister { get; set; } /// - /// Gets or sets the event handlers that occur when executing a command. + /// Gets or sets the event handlers that occur when executing a command. This event can be canceled. /// EventHandlerCollection? CommandExecute { get; set; } /// - /// Gets or sets the event handlers that occur when unregistering a command. + /// Gets or sets the event handlers that occur when unregistering a command. This event can be canceled. /// EventHandlerCollection? CommandUnregister { get; set; } /// - /// Registers and returns the commands defined with the given object's command handlers. + /// Registers and returns the commands defined with 's command handlers. Command + /// handlers are specified using the attribute. /// /// The object. /// The resulting commands. /// - /// is . + /// is (). /// IReadOnlyCollection RegisterCommands(object handlerObject); - + /// - /// Registers the given strongly-typed parser as the definitive parser for the parse type. + /// Registers as the parser for . This will allow + /// to be parsed in command handlers. /// /// The parse type. /// The parser. - /// is . + /// is (). void RegisterParser(IArgumentParser parser); /// - /// Unregisters the given command and returns a value indicating success. + /// Finds and returns a command with . A command name (possibly qualified) will be + /// extracted and tested from . + /// + /// The input. + /// The command. + /// The command does not exist or is ambiguous. + ICommand FindCommand(ref ReadOnlySpan input); + + /// + /// Unregisters and returns a value indicating success. /// /// The command. - /// A value indicating whether the command was successfully unregistered. - /// is . + /// + /// if was unregistered; otherwise, . + /// + /// is (). bool UnregisterCommand(ICommand command); } } diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs index 842e2b56a..4b2bb6ba9 100644 --- a/src/TShock/Commands/Logging/PlayerLogSink.cs +++ b/src/TShock/Commands/Logging/PlayerLogSink.cs @@ -33,7 +33,7 @@ internal sealed class PlayerLogSink : ILogEventSink { private const string LevelError = "[c/ff005f:ERR]"; private const string LevelFatal = "[c/ff005f:FTL]"; private const string LevelUnknown = "[c/ff0000:UNK]"; - + private static readonly Color _textColor = new Color(0xda, 0xda, 0xda); private static readonly Color _exceptionColor = new Color(0xda, 0xda, 0xda); private static readonly PlayerLogValueFormatter _formatter = new PlayerLogValueFormatter(); @@ -41,7 +41,7 @@ internal sealed class PlayerLogSink : ILogEventSink { private readonly IPlayer _player; public PlayerLogSink(IPlayer player) { - Debug.Assert(player != null, "player != null"); + Debug.Assert(player != null, "player should not be null"); _player = player; } diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index ca058a7fe..8683d905f 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -26,23 +26,29 @@ namespace TShock.Commands.Parsers { [AttributeUsage(AttributeTargets.Parameter)] public sealed class FlagAttribute : Attribute { /// - /// Gets the flags. + /// Gets a read-only view of the flags. Flags with length 1 are treated as short flags. /// public IReadOnlyCollection Flags { get; } /// - /// Initializes a new instance of the class with the specified flags. + /// Initializes a new instance of the class with the specified flags. Flags with + /// length 1 are treated as short flags. /// /// The flag. - /// The alternate flags. + /// The alternate flags. This may be empty. /// - /// or are . + /// or are (). /// public FlagAttribute(string flag, params string[] alternateFlags) { - if (flag is null) throw new ArgumentNullException(nameof(flag)); + if (flag is null) { + throw new ArgumentNullException(nameof(flag)); + } - Flags = new[] {flag}.Concat(alternateFlags ?? throw new ArgumentNullException(nameof(alternateFlags))) - .ToList(); + if (alternateFlags is null) { + throw new ArgumentNullException(nameof(alternateFlags)); + } + + Flags = new[] { flag }.Concat(alternateFlags).ToList(); } } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index da5b1313b..3a777c725 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -24,7 +24,8 @@ namespace TShock.Commands.Parsers { /// public interface IArgumentParser { /// - /// Parses the given input and returns a corresponding object. + /// Parses and returns a corresponding object. will be + /// consumed as necessary. /// /// The input. This is guaranteed to start with a non-whitespace character. /// The parse options. diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 03d62d359..5690dcfaa 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -26,17 +26,17 @@ namespace TShock.Commands.Parsers { /// The parse type. public interface IArgumentParser : IArgumentParser { /// - /// Parses the given input and returns a corresponding instance of the parse type. + /// Parses and returns a corresponding instance. + /// will be consumed as necessary. /// /// The input. This is guaranteed to start with a non-whitespace character. /// The parse options. - /// A corresponding instance of the parse type. + /// A corresponding instance. /// The input could not be parsed properly. new TParse Parse(ref ReadOnlySpan input, ISet? options = null); [ExcludeFromCodeCoverage] - object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => - Parse(ref input, options); + object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); /// /// Gets a default instance of the parse type. diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 58240fde2..43aa34862 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -40,7 +40,7 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); } } - + [ExcludeFromCodeCoverage] public int GetDefault() => 0; } diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs index e140ddd42..d68fb3804 100644 --- a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs +++ b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs @@ -34,10 +34,11 @@ public sealed class ParseOptionsAttribute : Attribute { /// Initializes a new instance of the class with the specified options. /// /// The options. - /// is . - public ParseOptionsAttribute([ValueProvider("TShock.Commands.Parsers.ParseOptions")] - params string[] options) { - if (options is null) throw new ArgumentNullException(nameof(options)); + /// is (). + public ParseOptionsAttribute([ValueProvider("TShock.Commands.Parsers.ParseOptions")] params string[] options) { + if (options is null) { + throw new ArgumentNullException(nameof(options)); + } var optionsSet = new HashSet(); optionsSet.UnionWith(options); diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index d3f55133f..3162c94b9 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -22,7 +22,6 @@ using TShock.Properties; namespace TShock.Commands.Parsers { - // It'd be nice to return ReadOnlySpan, but because of escape characters, we have to return copies. internal sealed class StringParser : IArgumentParser { public string Parse(ref ReadOnlySpan input, ISet? options = null) { if (options?.Contains(ParseOptions.ToEndOfInput) == true) { @@ -69,8 +68,8 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) continue; } - if (char.IsWhiteSpace(c)) { - if (!isInQuotes) break; + if (char.IsWhiteSpace(c) && !isInQuotes) { + break; } builder.Append(c); @@ -80,7 +79,7 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) input = input[end..]; return builder.ToString(); } - + [ExcludeFromCodeCoverage] public string GetDefault() => string.Empty; } diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 472a4bfb9..7e9fc5665 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -36,15 +36,16 @@ internal sealed class PlayerCommandSender : ICommandSender { public IPlayer Player { get; } public PlayerCommandSender(IPlayer player, ReadOnlySpan input) { - Debug.Assert(player != null, "player != null"); + Debug.Assert(player != null, "player should not be null"); Player = player; - Log = new LoggerConfiguration().MinimumLevel.Is(LogLevel) - .WriteTo.Sink(new PlayerLogSink(player)) - .Enrich.WithProperty("Player", player.Name) - .Enrich.WithProperty("Cmd", input.ToString()) - .WriteTo.Logger(Serilog.Log.Logger) - .CreateLogger(); + Log = new LoggerConfiguration() + .MinimumLevel.Is(LogLevel) + .WriteTo.Sink(new PlayerLogSink(player)) + .Enrich.WithProperty("Player", player.Name) + .Enrich.WithProperty("Cmd", input.ToString()) + .WriteTo.Logger(Serilog.Log.Logger) + .CreateLogger(); } public void SendMessage(ReadOnlySpan message) => SendMessage(message, Color.White); diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 12f547dc6..32b987ad8 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -39,14 +39,16 @@ internal class TShockCommand : ICommand { public object HandlerObject { get; } public MethodBase Handler { get; } - public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object handlerObject, - MethodBase handler) { - Debug.Assert(commandService != null, "commandService != null"); - Debug.Assert(attribute != null, "attribute != null"); - Debug.Assert(handler != null, "handler != null"); + // We need to inject ICommandService so that we can trigger its CommandExecute event. + public TShockCommand(ICommandService commandService, string qualifiedName, object handlerObject, + MethodBase handler) { + Debug.Assert(commandService != null, "command service should not be null"); + Debug.Assert(qualifiedName != null, "qualified name should not be null"); + Debug.Assert(handlerObject != null, "handler object should not be null"); + Debug.Assert(handler != null, "handler should not be null"); _commandService = commandService; - QualifiedName = attribute.QualifiedCommandName; + QualifiedName = qualifiedName; HandlerObject = handlerObject; Handler = handler; @@ -55,7 +57,9 @@ void PreprocessParameter(ParameterInfo parameterInfo) { var parameterType = parameterInfo.ParameterType; if (parameterType.IsByRef) { throw new NotSupportedException($"By-reference argument type {parameterType} not supported."); - } else if (parameterType.IsPointer) { + } + + if (parameterType.IsPointer) { throw new NotSupportedException($"Pointer argument type {parameterType} not supported."); } @@ -86,11 +90,15 @@ void PreprocessParameter(ParameterInfo parameterInfo) { } public void Invoke(ICommandSender sender, ReadOnlySpan input) { - if (sender is null) throw new ArgumentNullException(nameof(sender)); + if (sender is null) { + throw new ArgumentNullException(nameof(sender)); + } var args = new CommandExecuteEventArgs(this, sender, input.ToString()); _commandService.CommandExecute?.Invoke(this, args); - if (args.IsCanceled()) return; + if (args.IsCanceled()) { + return; + } var shortFlags = new HashSet(); var longFlags = new HashSet(); @@ -102,20 +110,23 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { throw new CommandParseException( string.Format(Resources.CommandParse_UnrecognizedArgType, parameterType)); } - + input = input.TrimStart(); var options = parameterInfo.GetCustomAttribute()?.Options; if (!input.IsEmpty) return parser.Parse(ref input, options); if (options?.Contains(ParseOptions.AllowEmpty) != true) { - throw new CommandParseException(string.Format(Resources.CommandParse_MissingArg, parameterInfo)); + throw new CommandParseException( + string.Format(Resources.CommandParse_MissingArg, parameterInfo)); } return parser.GetDefault(); } void ParseShortFlags(ref ReadOnlySpan input, int space) { - if (space <= 1) throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + if (space <= 1) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } foreach (var c in input[1..space]) { if (!_validShortFlags.Contains(c)) { @@ -129,7 +140,9 @@ void ParseShortFlags(ref ReadOnlySpan input, int space) { } void ParseLongFlag(ref ReadOnlySpan input, int space) { - if (space <= 2) throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + if (space <= 2) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } var longFlag = input[2..space].ToString(); if (!_validLongFlags.Contains(longFlag)) { @@ -142,7 +155,9 @@ void ParseLongFlag(ref ReadOnlySpan input, int space) { } void ParseOptional(ref ReadOnlySpan input, int equals) { - if (equals <= 2) throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + if (equals <= 2) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } var optional = input[2..equals].ToString(); if (!_validOptionals.TryGetValue(optional, out var parameterInfo)) { @@ -188,7 +203,9 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { */ object? ParseParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { var parameterType = parameterInfo.ParameterType; - if (parameterType == typeof(ICommandSender)) return sender; + if (parameterType == typeof(ICommandSender)) { + return sender; + } if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); @@ -222,7 +239,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { try { Handler.Invoke(HandlerObject, _parameters); } catch (TargetInvocationException ex) { - throw new CommandException(Resources.CommandInvoke_Exception, ex.InnerException); + throw new CommandExecuteException(Resources.CommandInvoke_Exception, ex.InnerException); } } } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index d10263439..6f24b8c20 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -24,13 +24,18 @@ using Orion.Events.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; +using TShock.Properties; +using TShock.Utils.Extensions; namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { - private readonly HashSet _commands = new HashSet(); + private readonly Dictionary _commands = new Dictionary(); private readonly Dictionary _parsers = new Dictionary(); - public IReadOnlyCollection Commands => _commands; + // Map from command name -> set of qualified command names. + private readonly IDictionary> _qualifiedNames = new Dictionary>(); + + public IReadOnlyDictionary Commands => _commands; public IReadOnlyDictionary Parsers => _parsers; public EventHandlerCollection? CommandRegister { get; set; } public EventHandlerCollection? CommandExecute { get; set; } @@ -42,23 +47,32 @@ public TShockCommandService() { } public IReadOnlyCollection RegisterCommands(object handlerObject) { - if (handlerObject is null) throw new ArgumentNullException(nameof(handlerObject)); + if (handlerObject is null) { + throw new ArgumentNullException(nameof(handlerObject)); + } var registeredCommands = new List(); void RegisterCommand(ICommand command) { var args = new CommandRegisterEventArgs(command); CommandRegister?.Invoke(this, args); - if (args.IsCanceled()) return; + if (args.IsCanceled()) { + return; + } + + var qualifiedName = command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':') + 1); + _qualifiedNames[name] = _qualifiedNames.GetValueOrDefault(name, () => new HashSet()); + _qualifiedNames[name].Add(qualifiedName); - _commands.Add(command); + _commands.Add(qualifiedName, command); registeredCommands.Add(command); } foreach (var command in handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) - .SelectMany(m => m.GetCustomAttributes(), - (handler, attribute) => (handler, attribute)) - .Select(t => new TShockCommand(this, t.attribute, handlerObject, t.handler))) { + .SelectMany(m => m.GetCustomAttributes(), + (handler, attribute) => (handler, attribute)) + .Select(t => new TShockCommand(this, t.attribute.QualifiedCommandName, handlerObject, t.handler))) { RegisterCommand(command); } @@ -69,12 +83,62 @@ public void RegisterParser(IArgumentParser parser) { _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); } + public ICommand FindCommand(ref ReadOnlySpan input) { + string ProcessQualifiedName(ref ReadOnlySpan input) { + input = input.TrimStart(); + if (input.IsEmpty) { + throw new CommandParseException(Resources.CommandParse_MissingCommand); + } + + var space = input.IndexOfOrEnd(' '); + var maybeQualifiedName = input[..space].ToString(); + var isQualifiedName = maybeQualifiedName.IndexOf(':') >= 0; + input = input[space..]; + if (isQualifiedName) { + return maybeQualifiedName; + } + + var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); + if (qualifiedNames.Count == 0) { + throw new CommandParseException( + string.Format(Resources.CommandParse_UnrecognizedCommand, maybeQualifiedName)); + } + + if (qualifiedNames.Count > 1) { + throw new CommandParseException( + string.Format(Resources.CommandParse_AmbiguousName, maybeQualifiedName, + string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); + } + + return qualifiedNames.Single(); + } + + var qualifiedName = ProcessQualifiedName(ref input); + if (!_commands.TryGetValue(qualifiedName, out var command)) { + throw new CommandParseException( + string.Format(Resources.CommandParse_UnrecognizedCommand, qualifiedName)); + } + + return command; + } + public bool UnregisterCommand(ICommand command) { - if (command is null) throw new ArgumentNullException(nameof(command)); + if (command is null) { + throw new ArgumentNullException(nameof(command)); + } var args = new CommandUnregisterEventArgs(command); CommandUnregister?.Invoke(this, args); - return !args.IsCanceled() && _commands.Remove(command); + if (args.IsCanceled()) { + return false; + } + + var qualifiedName = command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':') + 1); + _qualifiedNames[name] = _qualifiedNames.GetValueOrDefault(name, () => new HashSet()); + _qualifiedNames[name].Remove(qualifiedName); + + return _commands.Remove(qualifiedName); } } } diff --git a/src/TShock/Events/Commands/CommandEventArgs.cs b/src/TShock/Events/Commands/CommandEventArgs.cs index a95c8ab35..cc25a01e5 100644 --- a/src/TShock/Events/Commands/CommandEventArgs.cs +++ b/src/TShock/Events/Commands/CommandEventArgs.cs @@ -24,16 +24,22 @@ namespace TShock.Events.Commands { /// Provides data for command-related events. /// public abstract class CommandEventArgs : EventArgs, ICancelable { + private ICommand _command; + /// public string? CancellationReason { get; set; } /// /// Gets or sets the command. /// - public ICommand Command { get; set; } + /// is . + public ICommand Command { + get => _command; + set => _command = value ?? throw new ArgumentNullException(nameof(value)); + } private protected CommandEventArgs(ICommand command) { - Command = command ?? throw new ArgumentNullException(nameof(command)); + _command = command ?? throw new ArgumentNullException(nameof(command)); } } } diff --git a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs b/src/TShock/Events/Commands/CommandExecuteEventArgs.cs index 1d531befd..3910b4011 100644 --- a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs +++ b/src/TShock/Events/Commands/CommandExecuteEventArgs.cs @@ -31,8 +31,9 @@ public sealed class CommandExecuteEventArgs : CommandEventArgs { public ICommandSender Sender { get; } /// - /// Gets or sets the input. + /// Gets or sets the input. This does not include the command's name. /// + /// is . public string Input { get => _input; set => _input = value ?? throw new ArgumentNullException(nameof(value)); @@ -44,7 +45,7 @@ public string Input { /// /// The command. /// The command sender. - /// The input. This does not include the command's name or sub-names. + /// The input. This does not include the command's name. public CommandExecuteEventArgs(ICommand command, ICommandSender sender, string input) : base(command) { Sender = sender ?? throw new ArgumentNullException(nameof(sender)); _input = input ?? throw new ArgumentNullException(nameof(input)); diff --git a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs index 8fc5b64cb..a77e8de11 100644 --- a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs @@ -27,7 +27,7 @@ public sealed class CommandRegisterEventArgs : CommandEventArgs { /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is . + /// is (). public CommandRegisterEventArgs(ICommand command) : base(command) { } } } diff --git a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs index bd9bc6ef2..9130202f1 100644 --- a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs @@ -27,7 +27,7 @@ public sealed class CommandUnregisterEventArgs : CommandEventArgs { /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is . + /// is (). public CommandUnregisterEventArgs(ICommand command) : base(command) { } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 2bce36260..0d610b3c8 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -69,6 +69,15 @@ internal static string CommandInvoke_Exception { } } + /// + /// Looks up a localized string similar to "/{0}" is ambiguous and can refer to {1}.. + /// + internal static string CommandParse_AmbiguousName { + get { + return ResourceManager.GetString("CommandParse_AmbiguousName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid hyphenated argument.. /// @@ -87,6 +96,15 @@ internal static string CommandParse_MissingArg { } } + /// + /// Looks up a localized string similar to Missing command name.. + /// + internal static string CommandParse_MissingCommand { + get { + return ResourceManager.GetString("CommandParse_MissingCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Too many arguments were provided.. /// @@ -105,6 +123,15 @@ internal static string CommandParse_UnrecognizedArgType { } } + /// + /// Looks up a localized string similar to "/{0}" is an unrecognized command.. + /// + internal static string CommandParse_UnrecognizedCommand { + get { + return ResourceManager.GetString("CommandParse_UnrecognizedCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Long flag "--{0}" not recognized.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 92a7f458c..79c7fecec 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -120,18 +120,27 @@ Exception occurred while executing command. + + "/{0}" is ambiguous and can refer to {1}. + Invalid hyphenated argument. Missing argument "{0}". + + Missing command name. + Too many arguments were provided. Argument type "{0}" not recognized. + + "/{0}" is an unrecognized command. + Long flag "--{0}" not recognized. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index f75f3c41c..1df6d8a9c 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -21,7 +21,6 @@ using Orion; using Orion.Events; using Orion.Events.Packets; -using Orion.Packets.World; using Orion.Players; using TShock.Commands; @@ -46,7 +45,7 @@ public sealed class TShockPlugin : OrionPlugin { /// /// The Orion kernel. /// The player service. - /// Any of the services are . + /// Any of the services are . public TShockPlugin(OrionKernel kernel, Lazy playerService) : base(kernel) { kernel.Bind().To(); @@ -60,7 +59,9 @@ protected override void Initialize() { /// protected override void Dispose(bool disposeManaged) { - if (!disposeManaged) return; + if (!disposeManaged) { + return; + } _playerService.Value.PacketReceive -= PacketReceiveHandler; } diff --git a/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs index af745b175..e99e311ba 100644 --- a/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs +++ b/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs @@ -23,8 +23,8 @@ namespace TShock.Utils.Extensions { /// public static class ReadOnlySpanExtensions { /// - /// Searches for the specified value and returns the first index of its occurrence, or the length of the span - /// if it is not found. + /// Searches for and returns the first index of its occurrence, or the length of the + /// span if it is not found. /// /// The type of span. /// The span. diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index c94d35345..121a911bb 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -37,16 +37,18 @@ public void Dispose() { } [Fact] - public void RegisteredCommands_Get() { + public void Commands_Get() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); - - _commandService.Commands.Should().BeEquivalentTo(commands); + + _commandService.Commands.Keys.Should().BeEquivalentTo( + "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); + _commandService.Commands.Values.Should().BeEquivalentTo(commands); } [Fact] - public void RegisteredParsers_Get() { + public void Parsers_Get() { var parser = new Mock>().Object; _commandService.RegisterParser(parser); @@ -58,11 +60,11 @@ public void RegisterCommands() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); - - commands.Should().HaveCount(2); + + commands.Should().HaveCount(3); foreach (var command in commands) { command.HandlerObject.Should().BeSameAs(testClass); - command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); + command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); } } @@ -80,6 +82,42 @@ public void RegisterParser_NullParser_ThrowsArgumentNullException() { action.Should().Throw(); } + [Fact] + public void FindCommand_WithoutNamespace() { + var testClass = new TestClass(); + _commandService.RegisterCommands(testClass).ToList(); + var command = _commandService.Commands["tshock_tests:test2"]; + + var input = "test2".AsSpan(); + _commandService.FindCommand(ref input).Should().BeSameAs(command); + } + + [Fact] + public void FindCommand_WithNamespace() { + var testClass = new TestClass(); + _commandService.RegisterCommands(testClass).ToList(); + var command = _commandService.Commands["tshock_tests:test"]; + + var input = "tshock_tests:test".AsSpan(); + _commandService.FindCommand(ref input).Should().BeSameAs(command); + } + + [Theory] + [InlineData("")] + [InlineData("test")] + [InlineData("test3")] + [InlineData("tshock_tests:test3")] + public void FindCommand_InvalidCommand_ThrowsCommandParseException(string inputString) { + var testClass = new TestClass(); + _commandService.RegisterCommands(testClass).ToList(); + Action action = () => { + var input = inputString.AsSpan(); + _commandService.FindCommand(ref input); + }; + + action.Should().Throw(); + } + [Fact] public void UnregisterCommand() { var testClass = new TestClass(); @@ -88,14 +126,16 @@ public void UnregisterCommand() { _commandService.UnregisterCommand(command).Should().BeTrue(); - _commandService.Commands.Should().NotContain(command); + _commandService.Commands.Keys.Should().NotContain(command.QualifiedName); + _commandService.Commands.Values.Should().NotContain(command); } [Fact] - public void UnregisterCommand_NonexistentCommand_ReturnsFalse() { - var command = new Mock().Object; + public void UnregisterCommand_CommandDoesntExist_ReturnsFalse() { + var mockCommand = new Mock(); + mockCommand.SetupGet(c => c.QualifiedName).Returns("test"); - _commandService.UnregisterCommand(command).Should().BeFalse(); + _commandService.UnregisterCommand(mockCommand.Object).Should().BeFalse(); } [Fact] @@ -112,7 +152,8 @@ public void CommandRegister_IsTriggered() { _commandService.CommandRegister += (sender, args) => { isRun = true; args.Command.HandlerObject.Should().BeSameAs(testClass); - args.Command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2"); + args.Command.QualifiedName.Should().BeOneOf( + "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); }; _commandService.RegisterCommands(testClass); @@ -157,7 +198,7 @@ public void CommandUnregister_Canceled() { _commandService.UnregisterCommand(command).Should().BeFalse(); - _commandService.Commands.Should().Contain(command); + _commandService.Commands.Values.Should().Contain(command); } private class TestClass { @@ -166,6 +207,9 @@ public void TestCommand() { } [CommandHandler("tshock_tests:test2")] public void TestCommand2() { } + + [CommandHandler("tshock_tests2:test")] + public void TestCommandTest() { } } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index fcda6953f..bc054defa 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -327,7 +327,7 @@ public void Invoke_ThrowsException_ThrowsCommandException() { var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, ""); - action.Should().Throw().WithInnerException(); + action.Should().Throw().WithInnerException(); } [Fact] @@ -342,7 +342,7 @@ public void Invoke_NullSender_ThrowsArgumentNullException() { private ICommand GetCommand(TestClass testClass, string methodName) { var handler = typeof(TestClass).GetMethod(methodName); var attribute = handler.GetCustomAttribute(); - return new TShockCommand(_mockCommandService.Object, attribute, testClass, handler); + return new TShockCommand(_mockCommandService.Object, attribute.QualifiedCommandName, testClass, handler); } [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] diff --git a/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs b/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs index fb4d6b856..f4fccc146 100644 --- a/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs +++ b/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs @@ -17,6 +17,7 @@ using System; using FluentAssertions; +using Moq; using TShock.Commands; using Xunit; @@ -29,6 +30,23 @@ public void Ctor_NullCommand_ThrowsArgumentNullException() { func.Should().Throw(); } + [Fact] + public void Command_Get() { + var command = new Mock().Object; + var args = new TestCommandEventArgs(command); + + args.Command.Should().BeSameAs(command); + } + + [Fact] + public void Command_SetNullValue_ThrowsArgumentNullException() { + var command = new Mock().Object; + var args = new TestCommandEventArgs(command); + Action action = () => args.Command = null; + + action.Should().Throw(); + } + private class TestCommandEventArgs : CommandEventArgs { public TestCommandEventArgs(ICommand command) : base(command) { } } From ee363837bf469062c6576941b8a0040774247026 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 01:30:12 -0700 Subject: [PATCH 069/119] Add FxCop analyzers, and treat warnings as errors. --- src/TShock/Commands/CommandExecuteException.cs | 2 +- src/TShock/Commands/CommandHandlerAttribute.cs | 7 +++++-- src/TShock/Commands/CommandParseException.cs | 2 +- .../Commands/Logging/PlayerLogValueFormatter.cs | 2 +- src/TShock/Commands/Parsers/Int32Parser.cs | 6 ++++-- src/TShock/Commands/Parsers/StringParser.cs | 4 +++- src/TShock/Commands/TShockCommand.cs | 17 ++++++++++++----- src/TShock/Commands/TShockCommandService.cs | 17 ++++++++++------- src/TShock/TShock.csproj | 6 ++++++ src/TShock/TShockPlugin.cs | 2 +- 10 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/TShock/Commands/CommandExecuteException.cs b/src/TShock/Commands/CommandExecuteException.cs index 86a711730..10d3913f2 100644 --- a/src/TShock/Commands/CommandExecuteException.cs +++ b/src/TShock/Commands/CommandExecuteException.cs @@ -22,7 +22,7 @@ namespace TShock.Commands { /// /// The exception thrown when a command could not be executed. /// - [Serializable, ExcludeFromCodeCoverage] + [ExcludeFromCodeCoverage] public class CommandExecuteException : Exception { /// /// Initializes a new instance of the class. diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 793833d11..24131faab 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; namespace TShock.Commands { @@ -44,12 +45,14 @@ public sealed class CommandHandlerAttribute : Attribute { /// /// is (). /// + [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "strings are not user-facing")] public CommandHandlerAttribute(string qualifiedCommandName) { if (qualifiedCommandName is null) { throw new ArgumentNullException(nameof(qualifiedCommandName)); } - var colon = qualifiedCommandName.IndexOf(':'); + var colon = qualifiedCommandName.IndexOf(':', StringComparison.Ordinal); if (colon <= 0) { throw new ArgumentException("Qualified command name is missing the namespace.", nameof(qualifiedCommandName)); @@ -60,7 +63,7 @@ public CommandHandlerAttribute(string qualifiedCommandName) { nameof(qualifiedCommandName)); } - if (qualifiedCommandName.IndexOf(' ') >= 0) { + if (qualifiedCommandName.IndexOf(' ', StringComparison.Ordinal) >= 0) { throw new ArgumentException("Qualified command name contains a space.", nameof(qualifiedCommandName)); } diff --git a/src/TShock/Commands/CommandParseException.cs b/src/TShock/Commands/CommandParseException.cs index 8cf376996..e75ea1e3a 100644 --- a/src/TShock/Commands/CommandParseException.cs +++ b/src/TShock/Commands/CommandParseException.cs @@ -22,7 +22,7 @@ namespace TShock.Commands { /// /// The exception thrown when a command input cannot be parsed. /// - [Serializable, ExcludeFromCodeCoverage] + [ExcludeFromCodeCoverage] public class CommandParseException : Exception { /// /// Initializes a new instance of the class. diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs index c3fe2535e..b837a13aa 100644 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -47,7 +47,7 @@ protected override string VisitSequenceValue(Unit _, SequenceValue sequence) => $"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"; protected override string VisitStructureValue(Unit _, StructureValue structure) => - $"{(structure.TypeTag != null ? string.Format(TypeTagFormat, structure.TypeTag) : "")}" + + $"{(structure.TypeTag != null ? string.Format(CultureInfo.InvariantCulture, TypeTagFormat, structure.TypeTag) : "")}" + $"{{{string.Join(", ", structure.Properties.Select(p => $"{p.Name}={Visit(_, p.Value)}"))}}}"; protected override string VisitDictionaryValue(Unit _, DictionaryValue dictionary) => diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 43aa34862..0750fdb0b 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -34,10 +34,12 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { return int.Parse(parse, NumberStyles.Integer, CultureInfo.InvariantCulture); } catch (FormatException ex) { throw new CommandParseException( - string.Format(Resources.Int32Parser_InvalidInteger, parse.ToString()), ex); + string.Format(CultureInfo.InvariantCulture, Resources.Int32Parser_InvalidInteger, + parse.ToString()), ex); } catch (OverflowException ex) { throw new CommandParseException( - string.Format(Resources.Int32Parser_IntegerOutOfRange, parse.ToString()), ex); + string.Format(CultureInfo.InvariantCulture, Resources.Int32Parser_IntegerOutOfRange, + parse.ToString()), ex); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 3162c94b9..106a6e15e 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; using TShock.Properties; @@ -61,7 +62,8 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) builder.Append('\n'); } else { throw new CommandParseException( - string.Format(Resources.StringParser_UnrecognizedEscape, nextC)); + string.Format(CultureInfo.InvariantCulture, Resources.StringParser_UnrecognizedEscape, + nextC)); } ++end; diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 32b987ad8..5db43b937 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Reflection; using Orion.Events.Extensions; @@ -108,7 +109,8 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { var parameterType = parameterInfo.ParameterType; if (!_commandService.Parsers.TryGetValue(parameterType, out var parser)) { throw new CommandParseException( - string.Format(Resources.CommandParse_UnrecognizedArgType, parameterType)); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedArgType, + parameterType)); } input = input.TrimStart(); @@ -117,7 +119,8 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { if (options?.Contains(ParseOptions.AllowEmpty) != true) { throw new CommandParseException( - string.Format(Resources.CommandParse_MissingArg, parameterInfo)); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_MissingArg, + parameterInfo)); } return parser.GetDefault(); @@ -130,7 +133,9 @@ void ParseShortFlags(ref ReadOnlySpan input, int space) { foreach (var c in input[1..space]) { if (!_validShortFlags.Contains(c)) { - throw new CommandParseException(string.Format(Resources.CommandParse_UnrecognizedShortFlag, c)); + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedShortFlag, + c)); } shortFlags.Add(c); @@ -147,7 +152,8 @@ void ParseLongFlag(ref ReadOnlySpan input, int space) { var longFlag = input[2..space].ToString(); if (!_validLongFlags.Contains(longFlag)) { throw new CommandParseException( - string.Format(Resources.CommandParse_UnrecognizedLongFlag, longFlag)); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedLongFlag, + longFlag)); } longFlags.Add(longFlag); @@ -162,7 +168,8 @@ void ParseOptional(ref ReadOnlySpan input, int equals) { var optional = input[2..equals].ToString(); if (!_validOptionals.TryGetValue(optional, out var parameterInfo)) { throw new CommandParseException( - string.Format(Resources.CommandParse_UnrecognizedOptional, optional)); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedOptional, + optional)); } input = input[(equals + 1)..]; diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 6f24b8c20..6e628d314 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using Orion; @@ -61,7 +62,7 @@ void RegisterCommand(ICommand command) { } var qualifiedName = command.QualifiedName; - var name = qualifiedName.Substring(qualifiedName.IndexOf(':') + 1); + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); _qualifiedNames[name] = _qualifiedNames.GetValueOrDefault(name, () => new HashSet()); _qualifiedNames[name].Add(qualifiedName); @@ -92,7 +93,7 @@ string ProcessQualifiedName(ref ReadOnlySpan input) { var space = input.IndexOfOrEnd(' '); var maybeQualifiedName = input[..space].ToString(); - var isQualifiedName = maybeQualifiedName.IndexOf(':') >= 0; + var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; input = input[space..]; if (isQualifiedName) { return maybeQualifiedName; @@ -101,13 +102,14 @@ string ProcessQualifiedName(ref ReadOnlySpan input) { var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); if (qualifiedNames.Count == 0) { throw new CommandParseException( - string.Format(Resources.CommandParse_UnrecognizedCommand, maybeQualifiedName)); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, + maybeQualifiedName)); } if (qualifiedNames.Count > 1) { throw new CommandParseException( - string.Format(Resources.CommandParse_AmbiguousName, maybeQualifiedName, - string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_AmbiguousName, + maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); } return qualifiedNames.Single(); @@ -116,7 +118,8 @@ string ProcessQualifiedName(ref ReadOnlySpan input) { var qualifiedName = ProcessQualifiedName(ref input); if (!_commands.TryGetValue(qualifiedName, out var command)) { throw new CommandParseException( - string.Format(Resources.CommandParse_UnrecognizedCommand, qualifiedName)); + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, + qualifiedName)); } return command; @@ -134,7 +137,7 @@ public bool UnregisterCommand(ICommand command) { } var qualifiedName = command.QualifiedName; - var name = qualifiedName.Substring(qualifiedName.IndexOf(':') + 1); + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); _qualifiedNames[name] = _qualifiedNames.GetValueOrDefault(name, () => new HashSet()); _qualifiedNames[name].Remove(qualifiedName); diff --git a/src/TShock/TShock.csproj b/src/TShock/TShock.csproj index d1e751872..1ea640dd9 100644 --- a/src/TShock/TShock.csproj +++ b/src/TShock/TShock.csproj @@ -19,6 +19,8 @@ bin\$(Configuration)\TShock.xml + true + @@ -34,6 +36,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 1df6d8a9c..41a09538a 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -47,7 +47,7 @@ public sealed class TShockPlugin : OrionPlugin { /// The player service. /// Any of the services are . public TShockPlugin(OrionKernel kernel, Lazy playerService) : base(kernel) { - kernel.Bind().To(); + Kernel.Bind().To(); _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); } From 19b2274466e8d4fa4098891396f848d2f0593451 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 02:00:08 -0700 Subject: [PATCH 070/119] Implement PlayerChat handler. --- .editorconfig | 4 +- .../Commands/CommandHandlerAttribute.cs | 2 +- src/TShock/Commands/ICommand.cs | 4 +- src/TShock/Commands/ICommandSender.cs | 14 +++- src/TShock/Commands/ICommandService.cs | 4 +- src/TShock/Commands/Logging/PlayerLogSink.cs | 3 +- .../Logging/PlayerLogValueFormatter.cs | 3 +- src/TShock/Commands/Parsers/FlagAttribute.cs | 2 +- .../Commands/Parsers/ParseOptionsAttribute.cs | 2 +- src/TShock/Commands/PlayerCommandSender.cs | 4 +- .../Commands/CommandRegisterEventArgs.cs | 2 +- .../Commands/CommandUnregisterEventArgs.cs | 2 +- src/TShock/Properties/Resources.Designer.cs | 9 +++ src/TShock/Properties/Resources.resx | 3 + src/TShock/TShockPlugin.cs | 66 ++++++++++++++++--- .../Commands/Logging/PlayerLogSinkTests.cs | 3 - .../Logging/PlayerLogValueFormatterTests.cs | 12 ++-- .../Commands/Parsers/Int32ParserTests.cs | 5 +- .../Commands/Parsers/StringParserTests.cs | 6 +- .../Commands/PlayerCommandSenderTests.cs | 2 +- .../Commands/TShockCommandTests.cs | 14 ++-- 21 files changed, 117 insertions(+), 49 deletions(-) diff --git a/.editorconfig b/.editorconfig index 1b5926353..3cb991565 100644 --- a/.editorconfig +++ b/.editorconfig @@ -66,9 +66,9 @@ csharp_style_var_for_built_in_types=true:silent csharp_style_var_when_type_is_apparent=true:silent csharp_style_var_elsewhere=true:silent # Expression-bodied members -csharp_style_expression_bodied_methods=false:silent +csharp_style_expression_bodied_methods=true:silent csharp_style_expression_bodied_constructors=false:silent -csharp_style_expression_bodied_operators=false:silent +csharp_style_expression_bodied_operators=true:silent csharp_style_expression_bodied_properties=true:silent csharp_style_expression_bodied_indexers=true:silent csharp_style_expression_bodied_accessors=true:silent diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 24131faab..62914614c 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -43,7 +43,7 @@ public sealed class CommandHandlerAttribute : Attribute { /// is missing the namespace or name, or contains a space. /// /// - /// is (). + /// is . /// [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "strings are not user-facing")] diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 98e4109b1..b5232f164 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -39,11 +39,11 @@ public interface ICommand { MethodBase Handler { get; } /// - /// Invokes the command as the given sender with the specified input. + /// Invokes the command as with . /// /// The sender. /// The input. This does not include the command's name. - /// is (). + /// is . /// The command could not be executed. /// The command input could not be parsed. void Invoke(ICommandSender sender, ReadOnlySpan input); diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index bc8c5ca80..778ab7f1f 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -25,6 +25,8 @@ namespace TShock.Commands { /// Represents a command sender. Provides the ability to communicate with the sender. /// public interface ICommandSender { + private static readonly Color _errorColor = new Color(0xcc, 0x00, 0x00); + /// /// Gets the sender's name. /// @@ -36,21 +38,27 @@ public interface ICommandSender { ILogger Log { get; } /// - /// Gets the sender's player. If (), then there is no associated player. + /// Gets the sender's player. If , then there is no associated player. /// IPlayer? Player { get; } /// - /// Sends a message to the sender. + /// Sends a to the sender. /// /// The message. void SendMessage(ReadOnlySpan message); /// - /// Sends a message to the sender with the given color. + /// Sends a to the sender with the given . /// /// The message. /// The color. void SendMessage(ReadOnlySpan message, Color color); + + /// + /// Sends an error to the sender. + /// + /// The error message. + void SendErrorMessage(ReadOnlySpan message) => SendMessage(message, _errorColor); } } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index c5dfd7a82..6746c0c66 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -69,7 +69,7 @@ public interface ICommandService : IService { /// /// The parse type. /// The parser. - /// is (). + /// is . void RegisterParser(IArgumentParser parser); /// @@ -88,7 +88,7 @@ public interface ICommandService : IService { /// /// if was unregistered; otherwise, . /// - /// is (). + /// is . bool UnregisterCommand(ICommand command); } } diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs index 4b2bb6ba9..bd18afd7b 100644 --- a/src/TShock/Commands/Logging/PlayerLogSink.cs +++ b/src/TShock/Commands/Logging/PlayerLogSink.cs @@ -47,7 +47,8 @@ public PlayerLogSink(IPlayer player) { } public void Emit(LogEvent logEvent) { - var logLevel = logEvent.Level switch { + var logLevel = logEvent.Level switch + { LogEventLevel.Verbose => LevelVerbose, LogEventLevel.Debug => LevelDebug, LogEventLevel.Information => LevelInformation, diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs index b837a13aa..235aa9089 100644 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -34,7 +34,8 @@ internal sealed class PlayerLogValueFormatter : LogEventPropertyValueVisitor Visit(default, value); protected override string VisitScalarValue(Unit _, ScalarValue scalar) => - string.Format(CultureInfo.InvariantCulture, scalar.Value switch { + string.Format(CultureInfo.InvariantCulture, scalar.Value switch + { null => NullFormat, string _ => StringFormat, bool _ => BooleanFormat, diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index 8683d905f..f0d8cad4d 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -37,7 +37,7 @@ public sealed class FlagAttribute : Attribute { /// The flag. /// The alternate flags. This may be empty. /// - /// or are (). + /// or are . /// public FlagAttribute(string flag, params string[] alternateFlags) { if (flag is null) { diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs index d68fb3804..79d82d9fb 100644 --- a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs +++ b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs @@ -34,7 +34,7 @@ public sealed class ParseOptionsAttribute : Attribute { /// Initializes a new instance of the class with the specified options. /// /// The options. - /// is (). + /// is . public ParseOptionsAttribute([ValueProvider("TShock.Commands.Parsers.ParseOptions")] params string[] options) { if (options is null) { throw new ArgumentNullException(nameof(options)); diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 7e9fc5665..0abde0251 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -35,7 +35,7 @@ internal sealed class PlayerCommandSender : ICommandSender { public ILogger Log { get; } public IPlayer Player { get; } - public PlayerCommandSender(IPlayer player, ReadOnlySpan input) { + public PlayerCommandSender(IPlayer player, string input) { Debug.Assert(player != null, "player should not be null"); Player = player; @@ -43,7 +43,7 @@ public PlayerCommandSender(IPlayer player, ReadOnlySpan input) { .MinimumLevel.Is(LogLevel) .WriteTo.Sink(new PlayerLogSink(player)) .Enrich.WithProperty("Player", player.Name) - .Enrich.WithProperty("Cmd", input.ToString()) + .Enrich.WithProperty("Cmd", input) .WriteTo.Logger(Serilog.Log.Logger) .CreateLogger(); } diff --git a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs index a77e8de11..5230e5f70 100644 --- a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs @@ -27,7 +27,7 @@ public sealed class CommandRegisterEventArgs : CommandEventArgs { /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is (). + /// is . public CommandRegisterEventArgs(ICommand command) : base(command) { } } } diff --git a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs index 9130202f1..f9faa77b2 100644 --- a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs @@ -27,7 +27,7 @@ public sealed class CommandUnregisterEventArgs : CommandEventArgs { /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is (). + /// is . public CommandUnregisterEventArgs(ICommand command) : base(command) { } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 0d610b3c8..c9af30e15 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -60,6 +60,15 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to {0}. Type /help for a list of commands.. + /// + internal static string Chat_BadCommand { + get { + return ResourceManager.GetString("Chat_BadCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exception occurred while executing command.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 79c7fecec..01ca74a19 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,6 +117,9 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {0}. Type /help for a list of commands. + Exception occurred while executing command. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 41a09538a..0722b6a45 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -16,20 +16,33 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using Microsoft.Xna.Framework; +using System.Globalization; using Orion; using Orion.Events; -using Orion.Events.Packets; +using Orion.Events.Extensions; +using Orion.Events.Players; using Orion.Players; using TShock.Commands; +using TShock.Properties; namespace TShock { /// /// Represents the TShock plugin. /// public sealed class TShockPlugin : OrionPlugin { + // Map from Terraria command -> canonical command. This is used to unify Terraria and TShock commands. + private static readonly IDictionary _canonicalCommands = new Dictionary { + ["Say"] = "", + ["Emote"] = "/me ", + ["Party"] = "/p ", + ["Playing"] = "/playing ", + ["Roll"] = "/roll " + }; + private readonly Lazy _playerService; + private readonly Lazy _commandService; /// [ExcludeFromCodeCoverage] @@ -45,16 +58,19 @@ public sealed class TShockPlugin : OrionPlugin { /// /// The Orion kernel. /// The player service. + /// The command service. /// Any of the services are . - public TShockPlugin(OrionKernel kernel, Lazy playerService) : base(kernel) { + public TShockPlugin(OrionKernel kernel, Lazy playerService, + Lazy commandService) : base(kernel) { Kernel.Bind().To(); _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); + _commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); } /// protected override void Initialize() { - _playerService.Value.PacketReceive += PacketReceiveHandler; + _playerService.Value.PlayerChat += PlayerChatHandler; } /// @@ -63,13 +79,47 @@ protected override void Dispose(bool disposeManaged) { return; } - _playerService.Value.PacketReceive -= PacketReceiveHandler; + _playerService.Value.PlayerChat -= PlayerChatHandler; } [EventHandler(EventPriority.Lowest)] - private void PacketReceiveHandler(object sender, PacketReceiveEventArgs args) { - var s = new PlayerCommandSender(args.Sender, "test"); - s.SendMessage("TEST!!!!", Color.Orange); + private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { + if (args.IsCanceled()) { + return; + } + + var chatCommand = args.ChatCommand; + if (!_canonicalCommands.TryGetValue(chatCommand, out var canonicalCommand)) { + args.Cancel("Terraria command is invalid"); + return; + } + + var chat = canonicalCommand + args.ChatText; + if (chat.StartsWith('/')) { + args.Cancel("TShock command executing"); + + ICommandSender commandSender = new PlayerCommandSender(args.Player, chat); + var input = chat.AsSpan(); + ICommand command; + + try { + command = _commandService.Value.FindCommand(ref input); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Chat_BadCommand, ex.Message)); + return; + } + + try { + command.Invoke(commandSender, input); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage(ex.Message); + return; + } catch (CommandExecuteException ex) { + commandSender.SendErrorMessage(ex.Message); + return; + } + } } } } diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs index bace97fa3..2388d5847 100644 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs @@ -16,14 +16,11 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; -using System.Text; using Microsoft.Xna.Framework; using Moq; using Orion.Players; using Serilog; using Serilog.Core; -using Serilog.Events; using Xunit; namespace TShock.Commands.Logging { diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs index 3d75f1c49..fcad3cfc8 100644 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs @@ -61,24 +61,24 @@ public void Format_Number() { public void Format_Sequence() { var formatter = new PlayerLogValueFormatter(); - formatter.Format(new SequenceValue(new[] {new ScalarValue(1), new ScalarValue(2)})) - .Should().MatchRegex(@"\[\[c/[a-fA-F0-9]{6}:1\], \[c/[a-fA-F0-9]{6}:2\]\]"); + formatter.Format(new SequenceValue(new[] { new ScalarValue(1), new ScalarValue(2) })) + .Should().MatchRegex(@"\[\[c/[a-fA-F0-9]{6}:1\], \[c/[a-fA-F0-9]{6}:2\]\]"); } [Fact] public void Format_Structure() { var formatter = new PlayerLogValueFormatter(); - formatter.Format(new StructureValue(new[] {new LogEventProperty("Test", new ScalarValue(1))})) - .Should().MatchRegex(@"{Test=\[c/[a-fA-F0-9]{6}:1\]}"); + formatter.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) })) + .Should().MatchRegex(@"{Test=\[c/[a-fA-F0-9]{6}:1\]}"); } [Fact] public void Format_Structure_WithTypeTag() { var formatter = new PlayerLogValueFormatter(); - formatter.Format(new StructureValue(new[] {new LogEventProperty("Test", new ScalarValue(1))}, "Type")) - .Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:Type\] {Test=\[c/[a-fA-F0-9]{6}:1\]}"); + formatter.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }, "Type")) + .Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:Type\] {Test=\[c/[a-fA-F0-9]{6}:1\]}"); } [Fact] diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index b71baaf1c..e8fcc7976 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -35,7 +35,7 @@ public void Parse_IsCorrect(string inputString, int expected, string expectedNex input.ToString().Should().Be(expectedNextInput); } - + [Theory] [InlineData("")] [InlineData(" ")] @@ -61,7 +61,7 @@ public void Parse_IntegerOutOfRange_ThrowsParseException(string inputString) { func.Should().Throw().WithInnerException(); } - + [Theory] [InlineData("aaa")] [InlineData("123a")] @@ -72,7 +72,6 @@ public void Parse_InvalidInteger_ThrowsParseException(string inputString) { var input = inputString.AsSpan(); return parser.Parse(ref input); }; - func.Should().Throw().WithInnerException(); } } diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index ad762c2ff..d9b918f66 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -61,8 +61,8 @@ public void Parse_ToEndOfInput() { var parser = new StringParser(); var input = @"blah blah ""test"" blah blah".AsSpan(); - parser.Parse(ref input, new HashSet {ParseOptions.ToEndOfInput}) - .Should().Be(@"blah blah ""test"" blah blah"); + parser.Parse(ref input, new HashSet { ParseOptions.ToEndOfInput }) + .Should().Be(@"blah blah ""test"" blah blah"); input.ToString().Should().BeEmpty(); } @@ -72,7 +72,7 @@ public void Parse_AllowEmpty() { var parser = new StringParser(); var input = string.Empty.AsSpan(); - parser.Parse(ref input, new HashSet {ParseOptions.AllowEmpty}).Should().BeEmpty(); + parser.Parse(ref input, new HashSet { ParseOptions.AllowEmpty }).Should().BeEmpty(); input.ToString().Should().BeEmpty(); } diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 47c66c7a7..872ca0370 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -50,7 +50,7 @@ public void Player_Get() { [Fact] public void SendMessage() { _sender.SendMessage("test"); - + _mockPlayer.Verify(p => p.SendMessage("test", Color.White)); _mockPlayer.VerifyNoOtherCalls(); } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index bc054defa..70bd06400 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -209,7 +209,7 @@ public void Invoke_TriggersCommandExecute() { args.Input.Should().BeEmpty(); }; _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); - + command.Invoke(commandSender, ""); testClass.Sender.Should().BeSameAs(commandSender); @@ -229,7 +229,7 @@ public void Invoke_CommandExecuteCanceled_IsCanceled() { command.Invoke(commandSender, "failing input"); } - + [Theory] [InlineData("1 ")] [InlineData("-7345734 ")] @@ -374,14 +374,14 @@ public void TestCommand_Int_String(ICommandSender sender, int @int, string @stri [CommandHandler("tshock_tests:test_no_ptr")] public unsafe void TestCommand_NoPointer(ICommandSender sender, int* x) { } - + [CommandHandler("tshock_tests:test_flags")] public void TestCommand_Flags(ICommandSender sender, [Flag("x", "xxx")] bool x, [Flag("y", "yyy")] bool y) { Sender = sender; X = x; Y = y; } - + [CommandHandler("tshock_tests:test_optionals")] public void TestCommand_Optionals(ICommandSender sender, int required, int val = 1234, int val2 = 5678) { Sender = sender; @@ -404,14 +404,14 @@ public void TestCommand_OptionalRename(ICommandSender sender, int hyphenated_opt Sender = sender; HyphenatedOptionalIsLong = hyphenated_optional_is_long; } - + [CommandHandler("tshock_tests:allow_empty")] public void TestCommand_AllowEmpty(ICommandSender sender, [ParseOptions(ParseOptions.AllowEmpty)] string @string) { Sender = sender; String = @string; } - + [CommandHandler("tshock_tests:exception")] public void TestCommand_Exception(ICommandSender sender) { throw new NotImplementedException(); @@ -427,7 +427,7 @@ public void TestCommand_NoOut(ICommandSender sender, out int x) { [CommandHandler("tshock_tests:test_no_out")] public void TestCommand_NoRef(ICommandSender sender, ref int x) { } - + [CommandHandler("tshock_tests:test_no_byte")] public void TestCommand_NoByte(ICommandSender sender, byte b) { } } From 148ffef136e10bd45ecfec446eda8f22e42eb379 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 02:01:43 -0700 Subject: [PATCH 071/119] Apply code cleanup. --- src/TShock/Commands/ConsoleCommandSender.cs | 4 +++- src/TShock/Commands/Parsers/StringParser.cs | 4 +++- src/TShock/Commands/TShockCommand.cs | 4 +++- src/TShock/Commands/TShockCommandService.cs | 3 +-- .../Commands/Logging/PlayerLogSinkTests.cs | 17 ++++++++++++----- .../Commands/TShockCommandServiceTests.cs | 4 +--- .../TShock.Tests/Commands/TShockCommandTests.cs | 12 +++--------- .../Extensions/ReadOnlySpanExtensionsTests.cs | 8 ++------ 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 13acf2f54..7008b42e5 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -58,7 +58,9 @@ private static void SendMessage(ReadOnlySpan message, string colorString) while (true) { var leftBracket = message.IndexOf('['); var rightBracket = leftBracket + 1 + message[(leftBracket + 1)..].IndexOf(']'); - if (leftBracket < 0 || rightBracket < 0) break; + if (leftBracket < 0 || rightBracket < 0) { + break; + } output.Append(message[..leftBracket]); var inside = message[(leftBracket + 1)..rightBracket]; diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 106a6e15e..aafd644a0 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -41,7 +41,9 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) // Handle quotes. if (c == '"') { ++end; - if (isInQuotes) break; + if (isInQuotes) { + break; + } isInQuotes = true; continue; diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 5db43b937..23b5c6657 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -115,7 +115,9 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { input = input.TrimStart(); var options = parameterInfo.GetCustomAttribute()?.Options; - if (!input.IsEmpty) return parser.Parse(ref input, options); + if (!input.IsEmpty) { + return parser.Parse(ref input, options); + } if (options?.Contains(ParseOptions.AllowEmpty) != true) { throw new CommandParseException( diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 6e628d314..4b6f9f652 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -80,9 +80,8 @@ void RegisterCommand(ICommand command) { return registeredCommands; } - public void RegisterParser(IArgumentParser parser) { + public void RegisterParser(IArgumentParser parser) => _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); - } public ICommand FindCommand(ref ReadOnlySpan input) { string ProcessQualifiedName(ref ReadOnlySpan input) { diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs index 2388d5847..363909c96 100644 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs @@ -34,9 +34,8 @@ public PlayerLogSinkTests() { _logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); } - private void VerifyMessage(string regex) { + private void VerifyMessage(string regex) => _mockPlayer.Verify(p => p.SendMessage(It.IsRegex(regex), It.IsAny())); - } [Fact] public void Emit_Verbose() { @@ -104,9 +103,17 @@ public void Emit_PropertyMissing() { [Fact] public void Emit_WithException() { - static void Exception1() => Exception2(); - static void Exception2() => Exception3(); - static void Exception3() => throw new NotImplementedException(); + static void Exception1() { + Exception2(); + } + + static void Exception2() { + Exception3(); + } + + static void Exception3() { + throw new NotImplementedException(); + } Exception exception = null; try { diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 121a911bb..900c5c7a0 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -32,9 +32,7 @@ public TShockCommandServiceTests() { _commandService = new TShockCommandService(); } - public void Dispose() { - _commandService.Dispose(); - } + public void Dispose() => _commandService.Dispose(); [Fact] public void Commands_Get() { diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 70bd06400..a006adbf2 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -361,9 +361,7 @@ private class TestClass { public int HyphenatedOptionalIsLong { get; private set; } [CommandHandler("tshock_tests:test")] - public void TestCommand(ICommandSender sender) { - Sender = sender; - } + public void TestCommand(ICommandSender sender) => Sender = sender; [CommandHandler("tshock_tests:test_int_string")] public void TestCommand_Int_String(ICommandSender sender, int @int, string @string) { @@ -413,17 +411,13 @@ public void TestCommand_AllowEmpty(ICommandSender sender, } [CommandHandler("tshock_tests:exception")] - public void TestCommand_Exception(ICommandSender sender) { - throw new NotImplementedException(); - } + public void TestCommand_Exception(ICommandSender sender) => throw new NotImplementedException(); [CommandHandler("tshock_tests:test_no_in")] public void TestCommand_NoIn(ICommandSender sender, in int x) { } [CommandHandler("tshock_tests:test_no_out")] - public void TestCommand_NoOut(ICommandSender sender, out int x) { - x = 0; - } + public void TestCommand_NoOut(ICommandSender sender, out int x) => x = 0; [CommandHandler("tshock_tests:test_no_out")] public void TestCommand_NoRef(ICommandSender sender, ref int x) { } diff --git a/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs index 2d43e7c01..3061a9422 100644 --- a/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs +++ b/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs @@ -22,13 +22,9 @@ namespace TShock.Utils.Extensions { public class ReadOnlySpanExtensionsTests { [Fact] - public void IndexOfOrEnd() { - "abcde".AsSpan().IndexOfOrEnd('b').Should().Be(1); - } + public void IndexOfOrEnd() => "abcde".AsSpan().IndexOfOrEnd('b').Should().Be(1); [Fact] - public void IndexOfOrEnd_AtEnd() { - "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); - } + public void IndexOfOrEnd_AtEnd() => "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); } } From 1ca15f5233167438a95b3c0741f16d7af079eb60 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 11:44:06 -0700 Subject: [PATCH 072/119] Update .editorconfig. --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index 3cb991565..6a078547d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -72,6 +72,7 @@ csharp_style_expression_bodied_operators=true:silent csharp_style_expression_bodied_properties=true:silent csharp_style_expression_bodied_indexers=true:silent csharp_style_expression_bodied_accessors=true:silent +csharp_style_expression_bodied_local_functions = true:silent # Pattern matching preferences csharp_style_pattern_matching_over_is_with_cast_check=true:suggestion csharp_style_pattern_matching_over_as_with_null_check=true:suggestion From 29477c65fcd3098deb9e3183f5e30a8929b3c5cc Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 16:12:39 -0700 Subject: [PATCH 073/119] Implement Help command. --- src/TShock/Commands/ICommandSender.cs | 9 ++- src/TShock/Commands/TShockCommandService.cs | 7 +++ src/TShock/Properties/Resources.Designer.cs | 2 +- src/TShock/Properties/Resources.resx | 2 +- src/TShock/TShockPlugin.cs | 61 +++++++++++-------- .../Commands/TShockCommandServiceTests.cs | 6 +- 6 files changed, 54 insertions(+), 33 deletions(-) diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 778ab7f1f..88febd36b 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -25,7 +25,8 @@ namespace TShock.Commands { /// Represents a command sender. Provides the ability to communicate with the sender. /// public interface ICommandSender { - private static readonly Color _errorColor = new Color(0xcc, 0x00, 0x00); + private static readonly Color _errorColor = new Color(0xcc, 0x44, 0x44); + private static readonly Color _infoColor = new Color(0xff, 0xff, 0x44); /// /// Gets the sender's name. @@ -60,5 +61,11 @@ public interface ICommandSender { /// /// The error message. void SendErrorMessage(ReadOnlySpan message) => SendMessage(message, _errorColor); + + /// + /// Sends an informational to the sender. + /// + /// The informational message. + void SendInfoMessage(ReadOnlySpan message) => SendMessage(message, _infoColor); } } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 4b6f9f652..9588857e7 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -45,6 +45,7 @@ internal sealed class TShockCommandService : OrionService, ICommandService { public TShockCommandService() { RegisterParser(new Int32Parser()); RegisterParser(new StringParser()); + RegisterCommands(this); } public IReadOnlyCollection RegisterCommands(object handlerObject) { @@ -142,5 +143,11 @@ public bool UnregisterCommand(ICommand command) { return _commands.Remove(qualifiedName); } + + [CommandHandler("tshock:help")] + public void Help(ICommandSender sender) { + sender.SendInfoMessage("Commands:"); + sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{c.QualifiedName}"))); + } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index c9af30e15..4c92985ac 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -61,7 +61,7 @@ internal Resources() { } /// - /// Looks up a localized string similar to {0}. Type /help for a list of commands.. + /// Looks up a localized string similar to {0} Type /help for a list of commands.. /// internal static string Chat_BadCommand { get { diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 01ca74a19..e1d64c67f 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - {0}. Type /help for a list of commands. + {0} Type /help for a list of commands. Exception occurred while executing command. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 0722b6a45..c568ac6fe 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -24,6 +24,7 @@ using Orion.Events.Extensions; using Orion.Events.Players; using Orion.Players; +using Serilog; using TShock.Commands; using TShock.Properties; @@ -52,6 +53,9 @@ public sealed class TShockPlugin : OrionPlugin { [ExcludeFromCodeCoverage] public override string Name => "TShock"; + private IPlayerService PlayerService => _playerService.Value; + private ICommandService CommandService => _commandService.Value; + /// /// Initializes a new instance of the class with the specified Orion kernel and /// services. @@ -70,7 +74,7 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService, /// protected override void Initialize() { - _playerService.Value.PlayerChat += PlayerChatHandler; + PlayerService.PlayerChat += PlayerChatHandler; } /// @@ -78,8 +82,8 @@ protected override void Dispose(bool disposeManaged) { if (!disposeManaged) { return; } - - _playerService.Value.PlayerChat -= PlayerChatHandler; + + PlayerService.PlayerChat -= PlayerChatHandler; } [EventHandler(EventPriority.Lowest)] @@ -90,35 +94,38 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { var chatCommand = args.ChatCommand; if (!_canonicalCommands.TryGetValue(chatCommand, out var canonicalCommand)) { - args.Cancel("Terraria command is invalid"); + args.Cancel("tshock: Terraria command is invalid"); return; } var chat = canonicalCommand + args.ChatText; if (chat.StartsWith('/')) { - args.Cancel("TShock command executing"); - - ICommandSender commandSender = new PlayerCommandSender(args.Player, chat); - var input = chat.AsSpan(); - ICommand command; - - try { - command = _commandService.Value.FindCommand(ref input); - } catch (CommandParseException ex) { - commandSender.SendErrorMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Chat_BadCommand, ex.Message)); - return; - } - - try { - command.Invoke(commandSender, input); - } catch (CommandParseException ex) { - commandSender.SendErrorMessage(ex.Message); - return; - } catch (CommandExecuteException ex) { - commandSender.SendErrorMessage(ex.Message); - return; - } + args.Cancel("tshock: command executing"); + + var input = chat.Substring(1); + ExecuteCommand(new PlayerCommandSender(args.Player, input), input); + } + } + + // Executes a command. input should not have the leading /. + private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan input) { + Log.Information("{Sender} is executing /{Command}", commandSender.Name, input.ToString()); + + ICommand command; + try { + command = CommandService.FindCommand(ref input); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Chat_BadCommand, ex.Message)); + return; + } + + try { + command.Invoke(commandSender, input); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage(ex.Message); + } catch (CommandExecuteException ex) { + commandSender.SendErrorMessage(ex.Message); } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 900c5c7a0..7222e5bf6 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -40,9 +40,9 @@ public void Commands_Get() { var commands = _commandService.RegisterCommands(testClass).ToList(); - _commandService.Commands.Keys.Should().BeEquivalentTo( - "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); - _commandService.Commands.Values.Should().BeEquivalentTo(commands); + _commandService.Commands.Keys.Should().Contain( + new[] { "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test" }); + _commandService.Commands.Values.Should().Contain(commands); } [Fact] From 281ecec45264ad8103098729210e026fb55f6cfc Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sat, 5 Oct 2019 16:30:08 -0700 Subject: [PATCH 074/119] Implement console commands. --- src/TShock/Commands/ConsoleCommandSender.cs | 4 ++-- src/TShock/TShockPlugin.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 7008b42e5..c76fbc9f4 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -42,10 +42,10 @@ internal sealed class ConsoleCommandSender : ICommandSender { private static string GetColorString(Color color) => FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); - public ConsoleCommandSender(ReadOnlySpan input) { + public ConsoleCommandSender(string input) { Log = new LoggerConfiguration() .MinimumLevel.Is(LogLevel) - .Enrich.WithProperty("Cmd", input.ToString()) + .Enrich.WithProperty("Cmd", input) .WriteTo.Logger(Serilog.Log.Logger) .CreateLogger(); } diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index c568ac6fe..0f8a864bb 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -23,6 +23,7 @@ using Orion.Events; using Orion.Events.Extensions; using Orion.Events.Players; +using Orion.Events.Server; using Orion.Players; using Serilog; using TShock.Commands; @@ -74,6 +75,8 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService, /// protected override void Initialize() { + Kernel.ServerCommand += ServerCommandHandler; + PlayerService.PlayerChat += PlayerChatHandler; } @@ -82,10 +85,24 @@ protected override void Dispose(bool disposeManaged) { if (!disposeManaged) { return; } + + Kernel.ServerCommand -= ServerCommandHandler; PlayerService.PlayerChat -= PlayerChatHandler; } + [EventHandler(EventPriority.Lowest)] + private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { + args.Cancel("tshock: command executing"); + + var input = args.Input; + if (input.StartsWith('/')) { + input = input.Substring(1); + } + + ExecuteCommand(new ConsoleCommandSender(input), input); + } + [EventHandler(EventPriority.Lowest)] private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { if (args.IsCanceled()) { From 12f805fdf5975b21bbf0bf556b27373a5ea47f89 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:16:34 -0700 Subject: [PATCH 075/119] Make ICommandSender use string instead of ReadOnlySpan. --- src/TShock/Commands/ConsoleCommandSender.cs | 12 ++++++++---- src/TShock/Commands/ICommandSender.cs | 8 ++++---- src/TShock/Commands/PlayerCommandSender.cs | 7 ++----- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index c76fbc9f4..386b86387 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -23,6 +23,7 @@ using Orion.Players; using Serilog; using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; namespace TShock.Commands { internal sealed class ConsoleCommandSender : ICommandSender { @@ -44,16 +45,19 @@ private static string GetColorString(Color color) => public ConsoleCommandSender(string input) { Log = new LoggerConfiguration() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Properties} {Message:lj}{NewLine}{Exception}", + theme: AnsiConsoleTheme.Code) .MinimumLevel.Is(LogLevel) .Enrich.WithProperty("Cmd", input) - .WriteTo.Logger(Serilog.Log.Logger) .CreateLogger(); } - public void SendMessage(ReadOnlySpan message) => SendMessage(message, string.Empty); - public void SendMessage(ReadOnlySpan message, Color color) => SendMessage(message, GetColorString(color)); + public void SendMessage(string message) => SendMessage(message, string.Empty); + public void SendMessage(string message, Color color) => SendMessage(message, GetColorString(color)); - private static void SendMessage(ReadOnlySpan message, string colorString) { + private static void SendMessage(string messageString, string colorString) { + var message = messageString.AsSpan(); var output = new StringBuilder(colorString); while (true) { var leftBracket = message.IndexOf('['); diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 88febd36b..db48c9d32 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -47,25 +47,25 @@ public interface ICommandSender { /// Sends a to the sender. /// /// The message. - void SendMessage(ReadOnlySpan message); + void SendMessage(string message); /// /// Sends a to the sender with the given . /// /// The message. /// The color. - void SendMessage(ReadOnlySpan message, Color color); + void SendMessage(string message, Color color); /// /// Sends an error to the sender. /// /// The error message. - void SendErrorMessage(ReadOnlySpan message) => SendMessage(message, _errorColor); + void SendErrorMessage(string message) => SendMessage(message, _errorColor); /// /// Sends an informational to the sender. /// /// The informational message. - void SendInfoMessage(ReadOnlySpan message) => SendMessage(message, _infoColor); + void SendInfoMessage(string message) => SendMessage(message, _infoColor); } } diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 0abde0251..3e965df86 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -15,7 +15,6 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using System.Diagnostics; using Microsoft.Xna.Framework; using Orion.Players; @@ -48,9 +47,7 @@ public PlayerCommandSender(IPlayer player, string input) { .CreateLogger(); } - public void SendMessage(ReadOnlySpan message) => SendMessage(message, Color.White); - - public void SendMessage(ReadOnlySpan message, Color color) => - Player.SendMessage(message.ToString(), color); + public void SendMessage(string message) => SendMessage(message, Color.White); + public void SendMessage(string message, Color color) => Player.SendMessage(message, color); } } From 619089b02bbc843c5b48a36db0b1296a9a3ad946 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:30:58 -0700 Subject: [PATCH 076/119] Implement /playing. --- src/TShock/Commands/TShockCommandService.cs | 39 ++++++++++++++++--- src/TShock/Properties/Resources.Designer.cs | 27 +++++++++++++ src/TShock/Properties/Resources.resx | 9 +++++ .../Commands/TShockCommandServiceTests.cs | 4 +- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 9588857e7..fcd616220 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -17,12 +17,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Reflection; using Orion; using Orion.Events; using Orion.Events.Extensions; +using Orion.Players; using TShock.Commands.Parsers; using TShock.Events.Commands; using TShock.Properties; @@ -30,6 +32,8 @@ namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { + private readonly Lazy _playerService; + private readonly Dictionary _commands = new Dictionary(); private readonly Dictionary _parsers = new Dictionary(); @@ -42,7 +46,11 @@ internal sealed class TShockCommandService : OrionService, ICommandService { public EventHandlerCollection? CommandExecute { get; set; } public EventHandlerCollection? CommandUnregister { get; set; } - public TShockCommandService() { + private IPlayerService PlayerService => _playerService.Value; + + public TShockCommandService(Lazy playerService) { + _playerService = playerService; + RegisterParser(new Int32Parser()); RegisterParser(new StringParser()); RegisterCommands(this); @@ -135,19 +143,38 @@ public bool UnregisterCommand(ICommand command) { if (args.IsCanceled()) { return false; } - + var qualifiedName = command.QualifiedName; + if (!_commands.Remove(qualifiedName)) { + return false; + } + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - _qualifiedNames[name] = _qualifiedNames.GetValueOrDefault(name, () => new HashSet()); + Debug.Assert(_qualifiedNames.ContainsKey(name)); _qualifiedNames[name].Remove(qualifiedName); - - return _commands.Remove(qualifiedName); + return true; } [CommandHandler("tshock:help")] public void Help(ICommandSender sender) { - sender.SendInfoMessage("Commands:"); + sender.SendInfoMessage(Resources.Command_Help_Header); sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{c.QualifiedName}"))); } + + [CommandHandler("tshock:playing")] + public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { + var onlinePlayers = PlayerService.Players.Where(p => p.IsActive).ToList(); + if (onlinePlayers.Count == 0) { + sender.SendInfoMessage(Resources.Command_Playing_NoPlayers); + return; + } + + sender.SendInfoMessage(Resources.Command_Playing_Header); + if (showIds) { + sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => $"[{p.Index}] {p.Name}"))); + } else { + sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => p.Name))); + } + } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 4c92985ac..d5d1b90cc 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -69,6 +69,33 @@ internal static string Chat_BadCommand { } } + /// + /// Looks up a localized string similar to Commands:. + /// + internal static string Command_Help_Header { + get { + return ResourceManager.GetString("Command_Help_Header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Players online:. + /// + internal static string Command_Playing_Header { + get { + return ResourceManager.GetString("Command_Playing_Header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No players online.. + /// + internal static string Command_Playing_NoPlayers { + get { + return ResourceManager.GetString("Command_Playing_NoPlayers", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exception occurred while executing command.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index e1d64c67f..5e1ac8c48 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -153,6 +153,15 @@ Short flag "-{0}" not recognized. + + Commands: + + + Players online: + + + No players online. + "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 7222e5bf6..18228aec2 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -21,15 +21,17 @@ using FluentAssertions; using Moq; using Orion.Events.Extensions; +using Orion.Players; using TShock.Commands.Parsers; using Xunit; namespace TShock.Commands { public class TShockCommandServiceTests : IDisposable { + private readonly Mock _mockPlayerService = new Mock(); private readonly ICommandService _commandService; public TShockCommandServiceTests() { - _commandService = new TShockCommandService(); + _commandService = new TShockCommandService(new Lazy(() => _mockPlayerService.Object)); } public void Dispose() => _commandService.Dispose(); From bcd77e577633480632cfb8459033e8be45bffce2 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:33:19 -0700 Subject: [PATCH 077/119] Rename Chat_BadCommand to Command_BadCommand. --- src/TShock/Properties/Resources.Designer.cs | 4 ++-- src/TShock/Properties/Resources.resx | 6 +++--- src/TShock/TShockPlugin.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index d5d1b90cc..886697692 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -63,9 +63,9 @@ internal Resources() { /// /// Looks up a localized string similar to {0} Type /help for a list of commands.. /// - internal static string Chat_BadCommand { + internal static string Command_BadCommand { get { - return ResourceManager.GetString("Chat_BadCommand", resourceCulture); + return ResourceManager.GetString("Command_BadCommand", resourceCulture); } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 5e1ac8c48..c3dd61be4 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,9 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - {0} Type /help for a list of commands. - Exception occurred while executing command. @@ -153,6 +150,9 @@ Short flag "-{0}" not recognized. + + {0} Type /help for a list of commands. + Commands: diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 0f8a864bb..4f6410cca 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -133,7 +133,7 @@ private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan inp command = CommandService.FindCommand(ref input); } catch (CommandParseException ex) { commandSender.SendErrorMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Chat_BadCommand, ex.Message)); + string.Format(CultureInfo.InvariantCulture, Resources.Command_BadCommand, ex.Message)); return; } From 5d36b6a1a774ae899ef1e02bb8a090d06a792862 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:42:46 -0700 Subject: [PATCH 078/119] Implement /me. --- src/TShock/Commands/TShockCommandService.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index fcd616220..c8cc40275 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -21,10 +21,12 @@ using System.Globalization; using System.Linq; using System.Reflection; +using Microsoft.Xna.Framework; using Orion; using Orion.Events; using Orion.Events.Extensions; using Orion.Players; +using Serilog; using TShock.Commands.Parsers; using TShock.Events.Commands; using TShock.Properties; @@ -32,6 +34,8 @@ namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { + private static readonly Color _meColor = new Color(0xcd, 0x85, 0x3f); + private readonly Lazy _playerService; private readonly Dictionary _commands = new Dictionary(); @@ -176,5 +180,11 @@ public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => p.Name))); } } + + [CommandHandler("tshock:me")] + public void Me(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] string text) { + PlayerService.BroadcastMessage($"*{sender.Name} {text}", _meColor); + Log.Information("*{Name} {Text}", sender.Name, text); + } } } From 4f8313cc14d293689deb119a6028cdc4a628493a Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:44:16 -0700 Subject: [PATCH 079/119] Update /me color. --- src/TShock/Commands/TShockCommandService.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index c8cc40275..773a6c13d 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -34,8 +34,6 @@ namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { - private static readonly Color _meColor = new Color(0xcd, 0x85, 0x3f); - private readonly Lazy _playerService; private readonly Dictionary _commands = new Dictionary(); @@ -183,7 +181,7 @@ public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { [CommandHandler("tshock:me")] public void Me(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] string text) { - PlayerService.BroadcastMessage($"*{sender.Name} {text}", _meColor); + PlayerService.BroadcastMessage($"*{sender.Name} {text}", new Color(0xc8, 0x64, 0x00)); Log.Information("*{Name} {Text}", sender.Name, text); } } From 2da15dca0e3e85dba87694a865ffd65cafc34c72 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:44:44 -0700 Subject: [PATCH 080/119] Inline colors. --- src/TShock/Commands/ICommandSender.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index db48c9d32..d6383f222 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -15,7 +15,6 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using Microsoft.Xna.Framework; using Orion.Players; using Serilog; @@ -25,9 +24,6 @@ namespace TShock.Commands { /// Represents a command sender. Provides the ability to communicate with the sender. /// public interface ICommandSender { - private static readonly Color _errorColor = new Color(0xcc, 0x44, 0x44); - private static readonly Color _infoColor = new Color(0xff, 0xff, 0x44); - /// /// Gets the sender's name. /// @@ -60,12 +56,12 @@ public interface ICommandSender { /// Sends an error to the sender. /// /// The error message. - void SendErrorMessage(string message) => SendMessage(message, _errorColor); + void SendErrorMessage(string message) => SendMessage(message, new Color(0xcc, 0x44, 0x44)); /// /// Sends an informational to the sender. /// /// The informational message. - void SendInfoMessage(string message) => SendMessage(message, _infoColor); + void SendInfoMessage(string message) => SendMessage(message, new Color(0xff, 0xff, 0x44)); } } From a485cb76d693f4316fbb45cf4640b0582f1f9775 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 00:53:52 -0700 Subject: [PATCH 081/119] Implement /roll. --- src/TShock/Commands/ICommandSender.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 17 +++++++++++++---- src/TShock/Properties/Resources.Designer.cs | 9 +++++++++ src/TShock/Properties/Resources.resx | 3 +++ src/TShock/TShockPlugin.cs | 2 +- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index d6383f222..be8e756bb 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -62,6 +62,6 @@ public interface ICommandSender { /// Sends an informational to the sender. /// /// The informational message. - void SendInfoMessage(string message) => SendMessage(message, new Color(0xff, 0xff, 0x44)); + void SendInfoMessage(string message) => SendMessage(message, new Color(0xff, 0xf0, 0x14)); } } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 773a6c13d..59f72a0bd 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -38,9 +38,8 @@ internal sealed class TShockCommandService : OrionService, ICommandService { private readonly Dictionary _commands = new Dictionary(); private readonly Dictionary _parsers = new Dictionary(); - - // Map from command name -> set of qualified command names. private readonly IDictionary> _qualifiedNames = new Dictionary>(); + private readonly Random _rand = new Random(); public IReadOnlyDictionary Commands => _commands; public IReadOnlyDictionary Parsers => _parsers; @@ -145,12 +144,12 @@ public bool UnregisterCommand(ICommand command) { if (args.IsCanceled()) { return false; } - + var qualifiedName = command.QualifiedName; if (!_commands.Remove(qualifiedName)) { return false; } - + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); Debug.Assert(_qualifiedNames.ContainsKey(name)); _qualifiedNames[name].Remove(qualifiedName); @@ -184,5 +183,15 @@ public void Me(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] PlayerService.BroadcastMessage($"*{sender.Name} {text}", new Color(0xc8, 0x64, 0x00)); Log.Information("*{Name} {Text}", sender.Name, text); } + + [CommandHandler("tshock:roll")] + public void Roll(ICommandSender sender) { + var num = _rand.Next(1, 101); + // TODO: fix colors here + PlayerService.BroadcastMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll, sender.Name, num), + new Color(0xff, 0xf0, 0x14)); + Log.Information("*{Name} rolls a {Num}", sender.Name, num); + } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 886697692..ebff16e81 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -96,6 +96,15 @@ internal static string Command_Playing_NoPlayers { } } + /// + /// Looks up a localized string similar to *{0} rolls a {1}. + /// + internal static string Command_Roll { + get { + return ResourceManager.GetString("Command_Roll", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exception occurred while executing command.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index c3dd61be4..c3629eab5 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -162,6 +162,9 @@ No players online. + + *{0} rolls a {1} + "{0}" is a number that is out of range of an integer. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 4f6410cca..dd634c14e 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -87,7 +87,7 @@ protected override void Dispose(bool disposeManaged) { } Kernel.ServerCommand -= ServerCommandHandler; - + PlayerService.PlayerChat -= PlayerChatHandler; } From 5ed5e5510f8a5d5fd3e3f1df1b2b676122dd1eff Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 20:39:24 -0700 Subject: [PATCH 082/119] Implement /p. --- src/TShock/Commands/TShockCommand.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 23 ++++++++++++++++++- src/TShock/Properties/Resources.Designer.cs | 18 +++++++++++++++ src/TShock/Properties/Resources.resx | 6 +++++ src/TShock/TShockPlugin.cs | 1 - .../Commands/TShockCommandServiceTests.cs | 2 +- .../Commands/TShockCommandTests.cs | 1 - 7 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 23b5c6657..965e11624 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -21,7 +21,7 @@ using System.Globalization; using System.Linq; using System.Reflection; -using Orion.Events.Extensions; +using Orion.Events; using TShock.Commands.Parsers; using TShock.Events.Commands; using TShock.Properties; diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 59f72a0bd..43f4e85b0 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -24,7 +24,6 @@ using Microsoft.Xna.Framework; using Orion; using Orion.Events; -using Orion.Events.Extensions; using Orion.Players; using Serilog; using TShock.Commands.Parsers; @@ -193,5 +192,27 @@ public void Roll(ICommandSender sender) { new Color(0xff, 0xf0, 0x14)); Log.Information("*{Name} rolls a {Num}", sender.Name, num); } + + [CommandHandler("tshock:p")] + public void Party(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] string text) { + var player = sender.Player; + if (player is null) { + sender.SendErrorMessage(Resources.Command_Party_NotAPlayer); + return; + } + + var team = player.Team; + if (team == PlayerTeam.None) { + sender.SendErrorMessage(Resources.Command_Party_NotInTeam); + return; + } + + var teamColor = team.Color(); + var teamPlayers = PlayerService.Players.Where(p => p.IsActive && p.Team == team); + foreach (var teamPlayer in teamPlayers) { + teamPlayer.SendMessageFrom(player, text, teamColor); + } + Log.Information("<{Player} (to {Team} team)> {Text}", player.Name, team, text); + } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index ebff16e81..bc1c155d2 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -78,6 +78,24 @@ internal static string Command_Help_Header { } } + /// + /// Looks up a localized string similar to You must be a player to party chat.. + /// + internal static string Command_Party_NotAPlayer { + get { + return ResourceManager.GetString("Command_Party_NotAPlayer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not in a team!. + /// + internal static string Command_Party_NotInTeam { + get { + return ResourceManager.GetString("Command_Party_NotInTeam", resourceCulture); + } + } + /// /// Looks up a localized string similar to Players online:. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index c3629eab5..41dd1d337 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -156,6 +156,12 @@ Commands: + + You must be a player to party chat. + + + You are not in a team! + Players online: diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index dd634c14e..31b910d95 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -21,7 +21,6 @@ using System.Globalization; using Orion; using Orion.Events; -using Orion.Events.Extensions; using Orion.Events.Players; using Orion.Events.Server; using Orion.Players; diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 18228aec2..95134648d 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -20,7 +20,7 @@ using System.Linq; using FluentAssertions; using Moq; -using Orion.Events.Extensions; +using Orion.Events; using Orion.Players; using TShock.Commands.Parsers; using Xunit; diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index a006adbf2..c9a0a465f 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -22,7 +22,6 @@ using FluentAssertions; using Moq; using Orion.Events; -using Orion.Events.Extensions; using TShock.Commands.Parsers; using TShock.Events.Commands; using Xunit; From 4a43cbb0ef9a4cd8dc309deea229d3ef7e67a740 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 20:54:59 -0700 Subject: [PATCH 083/119] Allow optionals to be parsed with extra arguments, and fix failing tests. --- src/TShock/Commands/TShockCommand.cs | 22 ++++++++++++------- src/TShock/Commands/TShockCommandService.cs | 1 - .../Commands/Logging/PlayerLogSinkTests.cs | 9 +++++--- .../Commands/PlayerCommandSenderTests.cs | 9 +++++--- .../Commands/TShockCommandTests.cs | 4 ++++ 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 965e11624..9aa18c840 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -105,7 +105,7 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { var longFlags = new HashSet(); var optionals = new Dictionary(); - object? ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo) { + object? ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo, object? defaultValue) { var parameterType = parameterInfo.ParameterType; if (!_commandService.Parsers.TryGetValue(parameterType, out var parser)) { throw new CommandParseException( @@ -119,13 +119,14 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { return parser.Parse(ref input, options); } - if (options?.Contains(ParseOptions.AllowEmpty) != true) { + var allowsEmpty = defaultValue != null || options?.Contains(ParseOptions.AllowEmpty) == true; + if (!allowsEmpty) { throw new CommandParseException( string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_MissingArg, parameterInfo)); } - return parser.GetDefault(); + return defaultValue ?? parser.GetDefault(); } void ParseShortFlags(ref ReadOnlySpan input, int space) { @@ -175,7 +176,7 @@ void ParseOptional(ref ReadOnlySpan input, int equals) { } input = input[(equals + 1)..]; - optionals[optional] = ParseArgument(ref input, parameterInfo); + optionals[optional] = ParseArgument(ref input, parameterInfo, null); } /* @@ -219,17 +220,22 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { if (parameterType == typeof(bool)) { var attribute = parameterInfo.GetCustomAttribute(); if (attribute != null) { - return attribute.Flags.Any(f => f.Length == 1 && shortFlags.Contains(f[0]) || - longFlags.Contains(f)); + return attribute.Flags.Any( + f => f.Length == 1 && shortFlags.Contains(f[0]) || longFlags.Contains(f)); } } + object? defaultValue = null; if (parameterInfo.IsOptional) { var optional = parameterInfo.Name.Replace('_', '-'); - return optionals.TryGetValue(optional, out var value) ? value : parameterInfo.DefaultValue; + if (optionals.TryGetValue(optional, out var value)) { + return value; + } + + defaultValue = parameterInfo.DefaultValue; } - return ParseArgument(ref input, parameterInfo); + return ParseArgument(ref input, parameterInfo, defaultValue); } if (_validShortFlags.Count > 0 || _validLongFlags.Count > 0 || _validOptionals.Count > 0) { diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 43f4e85b0..76c4b43fa 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -186,7 +186,6 @@ public void Me(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] [CommandHandler("tshock:roll")] public void Roll(ICommandSender sender) { var num = _rand.Next(1, 101); - // TODO: fix colors here PlayerService.BroadcastMessage( string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll, sender.Name, num), new Color(0xff, 0xf0, 0x14)); diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs index 363909c96..38128b61c 100644 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs @@ -16,8 +16,9 @@ // along with TShock. If not, see . using System; -using Microsoft.Xna.Framework; +using System.Text.RegularExpressions; using Moq; +using Orion.Packets.World; using Orion.Players; using Serilog; using Serilog.Core; @@ -34,8 +35,10 @@ public PlayerLogSinkTests() { _logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); } - private void VerifyMessage(string regex) => - _mockPlayer.Verify(p => p.SendMessage(It.IsRegex(regex), It.IsAny())); + private void VerifyMessage(string regex) { + _mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => Regex.IsMatch(cp.ChatText, regex)))); + } [Fact] public void Emit_Verbose() { diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 872ca0370..76eff0f45 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -18,6 +18,7 @@ using FluentAssertions; using Microsoft.Xna.Framework; using Moq; +using Orion.Packets.World; using Orion.Players; using Xunit; @@ -51,15 +52,17 @@ public void Player_Get() { public void SendMessage() { _sender.SendMessage("test"); - _mockPlayer.Verify(p => p.SendMessage("test", Color.White)); + _mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.ChatColor == Color.White && cp.ChatText == "test"))); _mockPlayer.VerifyNoOtherCalls(); } [Fact] public void SendMessage_WithColor() { _sender.SendMessage("test", Color.Orange); - - _mockPlayer.Verify(p => p.SendMessage("test", Color.Orange)); + + _mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.ChatColor == Color.Orange && cp.ChatText == "test"))); _mockPlayer.VerifyNoOtherCalls(); } } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index c9a0a465f..16cba095c 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -116,8 +116,12 @@ public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) [Theory] [InlineData("1", 1, 1234, 5678)] [InlineData(" 1 ", 1, 1234, 5678)] + [InlineData("1 2", 1, 2, 5678)] + [InlineData("1 2 3", 1, 2, 3)] [InlineData("--val=9001 1", 1, 9001, 5678)] [InlineData(" --val=9001 1", 1, 9001, 5678)] + [InlineData("--val2=5678 1", 1, 1234, 5678)] + [InlineData(" --val2=5678 1", 1, 1234, 5678)] public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int expectedVal, int expectedVal2) { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() From 9d60bcb88acd55b3985aa815bb6abdeeddaeb71e Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 21:00:22 -0700 Subject: [PATCH 084/119] Improve /help to only include command namespaces if necessary. --- src/TShock/Commands/TShockCommandService.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 76c4b43fa..b5308b1f8 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -150,7 +150,7 @@ public bool UnregisterCommand(ICommand command) { } var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - Debug.Assert(_qualifiedNames.ContainsKey(name)); + Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); _qualifiedNames[name].Remove(qualifiedName); return true; } @@ -158,7 +158,16 @@ public bool UnregisterCommand(ICommand command) { [CommandHandler("tshock:help")] public void Help(ICommandSender sender) { sender.SendInfoMessage(Resources.Command_Help_Header); - sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{c.QualifiedName}"))); + + string FormatCommandName(ICommand command) { + var qualifiedName = command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); + + return "/" + (_qualifiedNames[name].Count > 1 ? qualifiedName : name); + } + + sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(FormatCommandName))); } [CommandHandler("tshock:playing")] From 196558efcac247e06fff683270406dad8a1fcd69 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 22:21:51 -0700 Subject: [PATCH 085/119] Make parsers public. --- src/TShock/Commands/Parsers/Int32Parser.cs | 14 ++++++++++---- src/TShock/Commands/Parsers/StringParser.cs | 9 +++++++-- src/TShock/Properties/Resources.Designer.cs | 18 ++++++++++++++++++ src/TShock/Properties/Resources.resx | 6 ++++++ .../Commands/Parsers/Int32ParserTests.cs | 2 +- .../Commands/Parsers/StringParserTests.cs | 2 +- .../Commands/TShockCommandTests.cs | 8 ++++---- 7 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 0750fdb0b..170d7d5fe 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -23,15 +23,20 @@ using TShock.Utils.Extensions; namespace TShock.Commands.Parsers { - internal sealed class Int32Parser : IArgumentParser { + /// + /// Parses an Int32. + /// + public sealed class Int32Parser : IArgumentParser { + /// public int Parse(ref ReadOnlySpan input, ISet? options = null) { var end = input.IndexOfOrEnd(' '); var parse = input[..end]; - input = input[end..]; // Calling Parse here instead of TryParse allows us to give better error messages. try { - return int.Parse(parse, NumberStyles.Integer, CultureInfo.InvariantCulture); + var value = int.Parse(parse, NumberStyles.Integer, CultureInfo.InvariantCulture); + input = input[end..]; + return value; } catch (FormatException ex) { throw new CommandParseException( string.Format(CultureInfo.InvariantCulture, Resources.Int32Parser_InvalidInteger, @@ -42,7 +47,8 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { parse.ToString()), ex); } } - + + /// [ExcludeFromCodeCoverage] public int GetDefault() => 0; } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index aafd644a0..d4e24ce9e 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -23,7 +23,11 @@ using TShock.Properties; namespace TShock.Commands.Parsers { - internal sealed class StringParser : IArgumentParser { + /// + /// Parses a string. + /// + public sealed class StringParser : IArgumentParser { + /// public string Parse(ref ReadOnlySpan input, ISet? options = null) { if (options?.Contains(ParseOptions.ToEndOfInput) == true) { var result = input.ToString(); @@ -83,7 +87,8 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) input = input[end..]; return builder.ToString(); } - + + /// [ExcludeFromCodeCoverage] public string GetDefault() => string.Empty; } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index bc1c155d2..40e7c59f1 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -222,6 +222,24 @@ internal static string CommandParse_UnrecognizedShortFlag { } } + /// + /// Looks up a localized string similar to "{0}" is not a valid value.. + /// + internal static string EnumParser_InvalidInteger { + get { + return ResourceManager.GetString("EnumParser_InvalidInteger", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "{0}" is not a valid value.. + /// + internal static string EnumParser_InvalidString { + get { + return ResourceManager.GetString("EnumParser_InvalidString", resourceCulture); + } + } + /// /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 41dd1d337..b39c5a698 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -171,6 +171,12 @@ *{0} rolls a {1} + + "{0}" is not a valid value. + + + "{0}" is not a valid value. + "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index e8fcc7976..c58705cfb 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -27,7 +27,7 @@ public class Int32ParserTests { [InlineData("000", 0, "")] [InlineData("-1234", -1234, "")] [InlineData("123 test", 123, " test")] - public void Parse_IsCorrect(string inputString, int expected, string expectedNextInput) { + public void Parse(string inputString, int expected, string expectedNextInput) { var parser = new Int32Parser(); var input = inputString.AsSpan(); diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index d9b918f66..20fbada9a 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -34,7 +34,7 @@ public class StringParserTests { [InlineData(@"\""", @"""", "")] [InlineData(@"\t", "\t", "")] [InlineData(@"\n", "\n", "")] - public void Parse_IsCorrect(string inputString, string expected, string expectedNextInput) { + public void Parse(string inputString, string expected, string expectedNextInput) { var parser = new StringParser(); var input = inputString.AsSpan(); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 16cba095c..711a34004 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -76,7 +76,7 @@ public void Invoke_Sender() { [Theory] [InlineData("1 test", 1, "test")] [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] - public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, string expectedString) { + public void Invoke_SenderIntString(string input, int expectedInt, string expectedString) { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser(), [typeof(string)] = new StringParser() @@ -101,7 +101,7 @@ public void Invoke_SenderIntString_IsCorrect(string input, int expectedInt, stri [InlineData(" -xy ", true, true)] [InlineData(" -x --yyy", true, true)] [InlineData("--xxx --yyy", true, true)] - public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) { + public void Invoke_Flags(string input, bool expectedX, bool expectedY) { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; @@ -122,7 +122,7 @@ public void Invoke_Flags_IsCorrect(string input, bool expectedX, bool expectedY) [InlineData(" --val=9001 1", 1, 9001, 5678)] [InlineData("--val2=5678 1", 1, 1234, 5678)] [InlineData(" --val2=5678 1", 1, 1234, 5678)] - public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int expectedVal, int expectedVal2) { + public void Invoke_Optionals(string input, int expectedRequired, int expectedVal, int expectedVal2) { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() }); @@ -152,7 +152,7 @@ public void Invoke_Optionals_IsCorrect(string input, int expectedRequired, int e [InlineData("--depth=1 --recursive -f", true, true, 1)] [InlineData("--force -r --depth=100 ", true, true, 100)] [InlineData("--force -r --depth= 100 ", true, true, 100)] - public void Invoke_FlagsAndOptionals_IsCorrect(string input, bool expectedForce, bool expectedRecursive, + public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() From 84f483e397200a40f3b7b7afc5da64c8885dfb2e Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 22:45:48 -0700 Subject: [PATCH 086/119] Implement params parsing. --- src/TShock/Commands/TShockCommand.cs | 39 ++++++++++++++----- .../Commands/TShockCommandTests.cs | 32 +++++++++++++-- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 9aa18c840..d806e2fea 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -105,28 +105,26 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { var longFlags = new HashSet(); var optionals = new Dictionary(); - object? ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo, object? defaultValue) { - var parameterType = parameterInfo.ParameterType; + object? ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo, Type? typeHint = null) { + var parameterType = typeHint ?? parameterInfo.ParameterType; if (!_commandService.Parsers.TryGetValue(parameterType, out var parser)) { throw new CommandParseException( string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedArgType, parameterType)); } - input = input.TrimStart(); var options = parameterInfo.GetCustomAttribute()?.Options; if (!input.IsEmpty) { return parser.Parse(ref input, options); } - var allowsEmpty = defaultValue != null || options?.Contains(ParseOptions.AllowEmpty) == true; - if (!allowsEmpty) { + if (options?.Contains(ParseOptions.AllowEmpty) != true) { throw new CommandParseException( string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_MissingArg, parameterInfo)); } - return defaultValue ?? parser.GetDefault(); + return parser.GetDefault(); } void ParseShortFlags(ref ReadOnlySpan input, int space) { @@ -176,7 +174,8 @@ void ParseOptional(ref ReadOnlySpan input, int equals) { } input = input[(equals + 1)..]; - optionals[optional] = ParseArgument(ref input, parameterInfo, null); + input = input.TrimStart(); + optionals[optional] = ParseArgument(ref input, parameterInfo); } /* @@ -225,17 +224,37 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } } - object? defaultValue = null; + if (parameterInfo.GetCustomAttribute() != null) { + var elementType = parameterType.GetElementType(); + + var list = new List(); + input = input.TrimStart(); + while (!input.IsEmpty) { + list.Add(ParseArgument(ref input, parameterInfo, elementType)); + input = input.TrimStart(); + } + + var array = Array.CreateInstance(elementType, list.Count); + for (var i = 0; i < list.Count; ++i) { + array.SetValue(list[i], i); + } + + return array; + } + + input = input.TrimStart(); if (parameterInfo.IsOptional) { var optional = parameterInfo.Name.Replace('_', '-'); if (optionals.TryGetValue(optional, out var value)) { return value; } - defaultValue = parameterInfo.DefaultValue; + if (input.IsEmpty) { + return parameterInfo.DefaultValue; + } } - return ParseArgument(ref input, parameterInfo, defaultValue); + return ParseArgument(ref input, parameterInfo); } if (_validShortFlags.Count > 0 || _validLongFlags.Count > 0 || _validOptionals.Count > 0) { diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 711a34004..e4a8f5408 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -153,7 +153,7 @@ public void Invoke_Optionals(string input, int expectedRequired, int expectedVal [InlineData("--force -r --depth=100 ", true, true, 100)] [InlineData("--force -r --depth= 100 ", true, true, 100)] public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expectedRecursive, - int expectedDepth) { + int expectedDepth) { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { [typeof(int)] = new Int32Parser() }); @@ -169,6 +169,25 @@ public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expe testClass.Depth.Should().Be(expectedDepth); } + [Theory] + [InlineData("")] + [InlineData("1", 1)] + [InlineData("1 2", 1, 2)] + [InlineData(" -1 2 -5", -1, 2, -5)] + public void Invoke_Params(string input, params int[] expectedInts) { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser() + }); + var testClass = new TestClass(); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_Params)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Ints.Should().BeEquivalentTo(expectedInts); + } + [Fact] public void Invoke_OptionalGetsRenamed() { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { @@ -362,6 +381,7 @@ private class TestClass { public bool Recursive { get; private set; } public int Depth { get; private set; } public int HyphenatedOptionalIsLong { get; private set; } + public int[] Ints { get; private set; } [CommandHandler("tshock_tests:test")] public void TestCommand(ICommandSender sender) => Sender = sender; @@ -393,7 +413,7 @@ public void TestCommand_Optionals(ICommandSender sender, int required, int val = [CommandHandler("tshock_tests:test_flags_and_optionals")] public void TestCommand_FlagsAndOptionals(ICommandSender sender, [Flag("f", "force")] bool force, - [Flag("r", "recursive")] bool recursive, int depth = 10) { + [Flag("r", "recursive")] bool recursive, int depth = 10) { Sender = sender; Force = force; Recursive = recursive; @@ -405,10 +425,16 @@ public void TestCommand_OptionalRename(ICommandSender sender, int hyphenated_opt Sender = sender; HyphenatedOptionalIsLong = hyphenated_optional_is_long; } + + [CommandHandler("tshock_tests:test_params")] + public void TestCommand_Params(ICommandSender sender, params int[] ints) { + Sender = sender; + Ints = ints; + } [CommandHandler("tshock_tests:allow_empty")] public void TestCommand_AllowEmpty(ICommandSender sender, - [ParseOptions(ParseOptions.AllowEmpty)] string @string) { + [ParseOptions(ParseOptions.AllowEmpty)] string @string) { Sender = sender; String = @string; } From 0c13777e816e44e2fb4f2b3f1bd8bbebc58a4821 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 23:18:20 -0700 Subject: [PATCH 087/119] Make ICommandSender implementations public, and remove input from ctors. --- src/TShock/Commands/ConsoleCommandSender.cs | 27 +++++++++++++---- src/TShock/Commands/Parsers/Int32Parser.cs | 2 +- src/TShock/Commands/Parsers/StringParser.cs | 2 +- src/TShock/Commands/PlayerCommandSender.cs | 29 +++++++++++++------ src/TShock/TShockPlugin.cs | 4 +-- .../Commands/ConsoleCommandSenderTests.cs | 4 +-- .../Commands/Logging/PlayerLogSinkTests.cs | 18 ++++-------- .../Commands/PlayerCommandSenderTests.cs | 13 +++++++-- .../Commands/TShockCommandTests.cs | 2 +- 9 files changed, 63 insertions(+), 38 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 386b86387..2b7dbb078 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -26,7 +26,10 @@ using Serilog.Sinks.SystemConsole.Themes; namespace TShock.Commands { - internal sealed class ConsoleCommandSender : ICommandSender { + /// + /// Represents a console-based command sender. + /// + public sealed class ConsoleCommandSender : ICommandSender { #if DEBUG private const LogEventLevel LogLevel = LogEventLevel.Verbose; #else @@ -35,28 +38,40 @@ internal sealed class ConsoleCommandSender : ICommandSender { private const string ResetColorString = "\x1b[0m"; private const string ColorTagPrefix = "c/"; + /// + /// Gets the console-based command sender. + /// + public static ConsoleCommandSender Instance { get; } = new ConsoleCommandSender(); + + /// public string Name => "Console"; + + /// public ILogger Log { get; } + + /// public IPlayer? Player => null; [Pure] private static string GetColorString(Color color) => FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); - public ConsoleCommandSender(string input) { + private ConsoleCommandSender() { Log = new LoggerConfiguration() .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Properties} {Message:lj}{NewLine}{Exception}", theme: AnsiConsoleTheme.Code) .MinimumLevel.Is(LogLevel) - .Enrich.WithProperty("Cmd", input) .CreateLogger(); } - public void SendMessage(string message) => SendMessage(message, string.Empty); - public void SendMessage(string message, Color color) => SendMessage(message, GetColorString(color)); + /// + public void SendMessage(string message) => SendMessageImpl(message, string.Empty); + + /// + public void SendMessage(string message, Color color) => SendMessageImpl(message, GetColorString(color)); - private static void SendMessage(string messageString, string colorString) { + private static void SendMessageImpl(string messageString, string colorString) { var message = messageString.AsSpan(); var output = new StringBuilder(colorString); while (true) { diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 170d7d5fe..1f5f54a91 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -47,7 +47,7 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { parse.ToString()), ex); } } - + /// [ExcludeFromCodeCoverage] public int GetDefault() => 0; diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index d4e24ce9e..a60b91e18 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -87,7 +87,7 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) input = input[end..]; return builder.ToString(); } - + /// [ExcludeFromCodeCoverage] public string GetDefault() => string.Empty; diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 3e965df86..31a15d1f8 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -15,7 +15,7 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System.Diagnostics; +using System; using Microsoft.Xna.Framework; using Orion.Players; using Serilog; @@ -23,31 +23,42 @@ using TShock.Commands.Logging; namespace TShock.Commands { - internal sealed class PlayerCommandSender : ICommandSender { + /// + /// Represents a player-based command sender. + /// + public sealed class PlayerCommandSender : ICommandSender { #if DEBUG private const LogEventLevel LogLevel = LogEventLevel.Verbose; #else private const LogEventLevel LogLevel = LogEventLevel.Error; #endif + /// public string Name => Player.Name; + + /// public ILogger Log { get; } - public IPlayer Player { get; } - public PlayerCommandSender(IPlayer player, string input) { - Debug.Assert(player != null, "player should not be null"); + /// + public IPlayer Player { get; } - Player = player; + /// + /// Initializes a new instance of the class with the specified player. + /// + /// The player. + /// is . + public PlayerCommandSender(IPlayer player) { + Player = player ?? throw new ArgumentNullException(nameof(player)); Log = new LoggerConfiguration() .MinimumLevel.Is(LogLevel) .WriteTo.Sink(new PlayerLogSink(player)) - .Enrich.WithProperty("Player", player.Name) - .Enrich.WithProperty("Cmd", input) - .WriteTo.Logger(Serilog.Log.Logger) .CreateLogger(); } + /// public void SendMessage(string message) => SendMessage(message, Color.White); + + /// public void SendMessage(string message, Color color) => Player.SendMessage(message, color); } } diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 31b910d95..e99d45eb8 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -99,7 +99,7 @@ private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { input = input.Substring(1); } - ExecuteCommand(new ConsoleCommandSender(input), input); + ExecuteCommand(ConsoleCommandSender.Instance, input); } [EventHandler(EventPriority.Lowest)] @@ -119,7 +119,7 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { args.Cancel("tshock: command executing"); var input = chat.Substring(1); - ExecuteCommand(new PlayerCommandSender(args.Player, input), input); + ExecuteCommand(new PlayerCommandSender(args.Player), input); } } diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs index 72a4b3fe5..d980977c5 100644 --- a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -22,14 +22,14 @@ namespace TShock.Commands { public class ConsoleCommandSenderTests { [Fact] public void Name_Get() { - var sender = new ConsoleCommandSender("test"); + ICommandSender sender = ConsoleCommandSender.Instance; sender.Name.Should().Be("Console"); } [Fact] public void Player_Get() { - var sender = new ConsoleCommandSender("test"); + ICommandSender sender = ConsoleCommandSender.Instance; sender.Player.Should().BeNull(); } diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs index 38128b61c..546ff08c7 100644 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs @@ -35,10 +35,8 @@ public PlayerLogSinkTests() { _logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); } - private void VerifyMessage(string regex) { - _mockPlayer.Verify(p => p.SendPacket( - It.Is(cp => Regex.IsMatch(cp.ChatText, regex)))); - } + private void VerifyMessage(string regex) => _mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => Regex.IsMatch(cp.ChatText, regex)))); [Fact] public void Emit_Verbose() { @@ -106,17 +104,11 @@ public void Emit_PropertyMissing() { [Fact] public void Emit_WithException() { - static void Exception1() { - Exception2(); - } + static void Exception1() => Exception2(); - static void Exception2() { - Exception3(); - } + static void Exception2() => Exception3(); - static void Exception3() { - throw new NotImplementedException(); - } + static void Exception3() => throw new NotImplementedException(); Exception exception = null; try { diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 76eff0f45..8fe121fc9 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -15,6 +15,7 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . +using System; using FluentAssertions; using Microsoft.Xna.Framework; using Moq; @@ -28,8 +29,14 @@ public class PlayerCommandSenderTests { private readonly ICommandSender _sender; public PlayerCommandSenderTests() { - _sender = new PlayerCommandSender(_mockPlayer.Object, "test"); - _mockPlayer.VerifyGet(p => p.Name); + _sender = new PlayerCommandSender(_mockPlayer.Object); + } + + [Fact] + public void Ctor_NullPlayer_ThrowsArgumentNullException() { + Func func = () => new PlayerCommandSender(null); + + func.Should().Throw(); } [Fact] @@ -60,7 +67,7 @@ public void SendMessage() { [Fact] public void SendMessage_WithColor() { _sender.SendMessage("test", Color.Orange); - + _mockPlayer.Verify(p => p.SendPacket( It.Is(cp => cp.ChatColor == Color.Orange && cp.ChatText == "test"))); _mockPlayer.VerifyNoOtherCalls(); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index e4a8f5408..95f36d8df 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -425,7 +425,7 @@ public void TestCommand_OptionalRename(ICommandSender sender, int hyphenated_opt Sender = sender; HyphenatedOptionalIsLong = hyphenated_optional_is_long; } - + [CommandHandler("tshock_tests:test_params")] public void TestCommand_Params(ICommandSender sender, params int[] ints) { Sender = sender; From dccf2deb263203a06b10f15c73f873f392824809 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Mon, 7 Oct 2019 23:38:21 -0700 Subject: [PATCH 088/119] Switch to more invariant culture strings. --- src/TShock/Commands/Logging/PlayerLogSink.cs | 6 ++- .../Logging/PlayerLogValueFormatter.cs | 21 +++++++-- src/TShock/Commands/TShockCommand.cs | 7 --- src/TShock/Commands/TShockCommandService.cs | 4 +- src/TShock/Properties/Resources.Designer.cs | 9 ++++ src/TShock/Properties/Resources.resx | 3 ++ .../Commands/TShockCommandTests.cs | 47 ++----------------- 7 files changed, 39 insertions(+), 58 deletions(-) diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs index bd18afd7b..18c3c341c 100644 --- a/src/TShock/Commands/Logging/PlayerLogSink.cs +++ b/src/TShock/Commands/Logging/PlayerLogSink.cs @@ -15,6 +15,7 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . +using System; using System.Diagnostics; using System.IO; using System.Text; @@ -58,7 +59,8 @@ public void Emit(LogEvent logEvent) { _ => LevelUnknown }; - var output = new StringBuilder($"[c/6c6c6c:{logEvent.Timestamp:HH:mm:ss zz}] [{logLevel}] "); + var output = new StringBuilder( + FormattableString.Invariant($"[c/6c6c6c:{logEvent.Timestamp:HH:mm:ss zz}] [{logLevel}] ")); foreach (var token in logEvent.MessageTemplate.Tokens) { if (token is TextToken textToken) { output.Append(textToken.Text); @@ -67,7 +69,7 @@ public void Emit(LogEvent logEvent) { var propertyToken = (PropertyToken)token; if (!logEvent.Properties.TryGetValue(propertyToken.PropertyName, out var propertyValue)) { - output.Append($"[c/ff0000:{propertyToken}]"); + output.Append(FormattableString.Invariant($"[c/ff0000:{propertyToken}]")); continue; } diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs index 235aa9089..e705f0d9f 100644 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -15,6 +15,8 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . +using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using Serilog.Data; @@ -45,13 +47,24 @@ var n when n.GetType().IsPrimitive || n is decimal => NumberFormat, }, scalar.Value); protected override string VisitSequenceValue(Unit _, SequenceValue sequence) => - $"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"; + FormattableString.Invariant($"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"); protected override string VisitStructureValue(Unit _, StructureValue structure) => - $"{(structure.TypeTag != null ? string.Format(CultureInfo.InvariantCulture, TypeTagFormat, structure.TypeTag) : "")}" + - $"{{{string.Join(", ", structure.Properties.Select(p => $"{p.Name}={Visit(_, p.Value)}"))}}}"; + FormatTypeTag(structure.TypeTag) + + FormattableString.Invariant( + $"{{{string.Join(", ", structure.Properties.Select(FormatStructureElement))}}}"); protected override string VisitDictionaryValue(Unit _, DictionaryValue dictionary) => - $"{{{string.Join(", ", dictionary.Elements.Select(e => $"[{Visit(_, e.Key)}]={Visit(_, e.Value)}"))}}}"; + FormattableString.Invariant( + $"{{{string.Join(", ", dictionary.Elements.Select(FormatDictionaryElement))}}}"); + + private string FormatTypeTag(string? typeTag) => + typeTag is null ? string.Empty : string.Format(CultureInfo.InvariantCulture, TypeTagFormat, typeTag); + + private string FormatStructureElement(LogEventProperty p) => + FormattableString.Invariant($"{p.Name}={Visit(default, p.Value)}"); + + private string FormatDictionaryElement(KeyValuePair kvp) => + FormattableString.Invariant($"[{Visit(default, kvp.Key)}]={Visit(default, kvp.Value)}"); } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index d806e2fea..41e0311fb 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -56,13 +56,6 @@ public TShockCommand(ICommandService commandService, string qualifiedName, objec // Preprocessing parameters in the constructor allows us to learn the command's flags and optionals. void PreprocessParameter(ParameterInfo parameterInfo) { var parameterType = parameterInfo.ParameterType; - if (parameterType.IsByRef) { - throw new NotSupportedException($"By-reference argument type {parameterType} not supported."); - } - - if (parameterType.IsPointer) { - throw new NotSupportedException($"Pointer argument type {parameterType} not supported."); - } // If the parameter is a bool and it is marked with FlagAttribute, we'll note it. if (parameterType == typeof(bool)) { diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index b5308b1f8..1b667463b 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -188,7 +188,9 @@ public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { [CommandHandler("tshock:me")] public void Me(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] string text) { - PlayerService.BroadcastMessage($"*{sender.Name} {text}", new Color(0xc8, 0x64, 0x00)); + PlayerService.BroadcastMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Command_Me, sender.Name, text), + new Color(0xc8, 0x64, 0x00)); Log.Information("*{Name} {Text}", sender.Name, text); } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 40e7c59f1..298c80f62 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -78,6 +78,15 @@ internal static string Command_Help_Header { } } + /// + /// Looks up a localized string similar to *{0} {1}. + /// + internal static string Command_Me { + get { + return ResourceManager.GetString("Command_Me", resourceCulture); + } + } + /// /// Looks up a localized string similar to You must be a player to party chat.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index b39c5a698..76b752f2a 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -156,6 +156,9 @@ Commands: + + *{0} {1} + You must be a player to party chat. diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 95f36d8df..095c1b30b 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -30,38 +30,6 @@ namespace TShock.Commands { public class TShockCommandTests { private readonly Mock _mockCommandService = new Mock(); - [Fact] - public void Ctor_InParam_ThrowsNotSupportedException() { - var testClass = new TestClass(); - Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoIn)); - - func.Should().Throw(); - } - - [Fact] - public void Ctor_OutParam_ThrowsNotSupportedException() { - var testClass = new TestClass(); - Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoOut)); - - func.Should().Throw(); - } - - [Fact] - public void Ctor_RefParam_ThrowsNotSupportedException() { - var testClass = new TestClass(); - Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoRef)); - - func.Should().Throw(); - } - - [Fact] - public void Ctor_PointerParam_ThrowsNotSupportedException() { - var testClass = new TestClass(); - Func func = () => GetCommand(testClass, nameof(TestClass.TestCommand_NoPointer)); - - func.Should().Throw(); - } - [Fact] public void Invoke_Sender() { var testClass = new TestClass(); @@ -323,7 +291,7 @@ public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string inpu public void Invoke_UnexpectedArgType_ThrowsCommandParseException() { _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary()); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoByte)); + var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoTestClass)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, ""); @@ -442,17 +410,8 @@ public void TestCommand_AllowEmpty(ICommandSender sender, [CommandHandler("tshock_tests:exception")] public void TestCommand_Exception(ICommandSender sender) => throw new NotImplementedException(); - [CommandHandler("tshock_tests:test_no_in")] - public void TestCommand_NoIn(ICommandSender sender, in int x) { } - - [CommandHandler("tshock_tests:test_no_out")] - public void TestCommand_NoOut(ICommandSender sender, out int x) => x = 0; - - [CommandHandler("tshock_tests:test_no_out")] - public void TestCommand_NoRef(ICommandSender sender, ref int x) { } - - [CommandHandler("tshock_tests:test_no_byte")] - public void TestCommand_NoByte(ICommandSender sender, byte b) { } + [CommandHandler("tshock_tests:test_no_testclass")] + public void TestCommand_NoTestClass(ICommandSender sender, TestClass testClass) { } } } } From 8a249ab0e67e2c5d7d82b73fddb7236cf952f745 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 19:38:06 -0700 Subject: [PATCH 089/119] Remove ParseOptions.AllowEmpty, as that is no longer necessary. --- .../Commands/CommandHandlerAttribute.cs | 4 ++-- src/TShock/Commands/Logging/PlayerLogSink.cs | 3 +-- .../Logging/PlayerLogValueFormatter.cs | 3 +-- .../Commands/Parsers/IArgumentParser.cs | 6 ----- .../Commands/Parsers/IArgumentParser`1.cs | 9 -------- src/TShock/Commands/Parsers/Int32Parser.cs | 5 ----- src/TShock/Commands/Parsers/ParseOptions.cs | 5 ----- src/TShock/Commands/Parsers/StringParser.cs | 5 ----- src/TShock/Commands/TShockCommand.cs | 8 ++----- .../Commands/Parsers/StringParserTests.cs | 10 --------- .../Commands/TShockCommandTests.cs | 22 ------------------- 11 files changed, 6 insertions(+), 74 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 62914614c..9308eb64d 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -21,8 +21,8 @@ namespace TShock.Commands { /// - /// Specifies that a method is a command handler. This can be applied multiple times to a method to provide - /// aliasing. + /// Specifies that a method is a command handler. This controls many aspects of the command, and can be applied + /// multiple times to provide aliasing. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] [MeansImplicitUse] diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs index 18c3c341c..8516906f2 100644 --- a/src/TShock/Commands/Logging/PlayerLogSink.cs +++ b/src/TShock/Commands/Logging/PlayerLogSink.cs @@ -48,8 +48,7 @@ public PlayerLogSink(IPlayer player) { } public void Emit(LogEvent logEvent) { - var logLevel = logEvent.Level switch - { + var logLevel = logEvent.Level switch { LogEventLevel.Verbose => LevelVerbose, LogEventLevel.Debug => LevelDebug, LogEventLevel.Information => LevelInformation, diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs index e705f0d9f..c5a0d43d3 100644 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -36,8 +36,7 @@ internal sealed class PlayerLogValueFormatter : LogEventPropertyValueVisitor Visit(default, value); protected override string VisitScalarValue(Unit _, ScalarValue scalar) => - string.Format(CultureInfo.InvariantCulture, scalar.Value switch - { + string.Format(CultureInfo.InvariantCulture, scalar.Value switch { null => NullFormat, string _ => StringFormat, bool _ => BooleanFormat, diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 3a777c725..3295117f1 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -32,11 +32,5 @@ public interface IArgumentParser { /// A corresponding object. /// The input could not be parsed properly. object? Parse(ref ReadOnlySpan input, ISet? options = null); - - /// - /// Gets a default object. - /// - /// The default object. - object? GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs index 5690dcfaa..293ad5b91 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser`1.cs @@ -37,14 +37,5 @@ public interface IArgumentParser : IArgumentParser { [ExcludeFromCodeCoverage] object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); - - /// - /// Gets a default instance of the parse type. - /// - /// A default instance of the parse type. - new TParse GetDefault(); - - [ExcludeFromCodeCoverage] - object? IArgumentParser.GetDefault() => GetDefault(); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 1f5f54a91..1f79eb1f6 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -17,7 +17,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using TShock.Properties; using TShock.Utils.Extensions; @@ -47,9 +46,5 @@ public int Parse(ref ReadOnlySpan input, ISet? options = null) { parse.ToString()), ex); } } - - /// - [ExcludeFromCodeCoverage] - public int GetDefault() => 0; } } diff --git a/src/TShock/Commands/Parsers/ParseOptions.cs b/src/TShock/Commands/Parsers/ParseOptions.cs index 562b3b3dd..78c95efc8 100644 --- a/src/TShock/Commands/Parsers/ParseOptions.cs +++ b/src/TShock/Commands/Parsers/ParseOptions.cs @@ -24,10 +24,5 @@ public static class ParseOptions { /// An option which forces a string to be parsed to the end of the input. /// public const string ToEndOfInput = nameof(ToEndOfInput); - - /// - /// An option which allows a parser to parse empty input. - /// - public const string AllowEmpty = nameof(AllowEmpty); } } diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index a60b91e18..8d89449c1 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -17,7 +17,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using TShock.Properties; @@ -87,9 +86,5 @@ public string Parse(ref ReadOnlySpan input, ISet? options = null) input = input[end..]; return builder.ToString(); } - - /// - [ExcludeFromCodeCoverage] - public string GetDefault() => string.Empty; } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 41e0311fb..5e53fa424 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -107,17 +107,13 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { } var options = parameterInfo.GetCustomAttribute()?.Options; - if (!input.IsEmpty) { - return parser.Parse(ref input, options); - } - - if (options?.Contains(ParseOptions.AllowEmpty) != true) { + if (input.IsEmpty) { throw new CommandParseException( string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_MissingArg, parameterInfo)); } - return parser.GetDefault(); + return parser.Parse(ref input, options); } void ParseShortFlags(ref ReadOnlySpan input, int space) { diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index 20fbada9a..635c9045d 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -66,15 +66,5 @@ public void Parse_ToEndOfInput() { input.ToString().Should().BeEmpty(); } - - [Fact] - public void Parse_AllowEmpty() { - var parser = new StringParser(); - var input = string.Empty.AsSpan(); - - parser.Parse(ref input, new HashSet { ParseOptions.AllowEmpty }).Should().BeEmpty(); - - input.ToString().Should().BeEmpty(); - } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 095c1b30b..8516de605 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -171,21 +171,6 @@ public void Invoke_OptionalGetsRenamed() { testClass.HyphenatedOptionalIsLong.Should().Be(60); } - [Fact] - public void Invoke_AllowEmpty() { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(string)] = new StringParser() - }); - var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_AllowEmpty)); - var commandSender = new Mock().Object; - - command.Invoke(commandSender, ""); - - testClass.Sender.Should().BeSameAs(commandSender); - testClass.String.Should().BeEmpty(); - } - [Fact] public void Invoke_TriggersCommandExecute() { var testClass = new TestClass(); @@ -400,13 +385,6 @@ public void TestCommand_Params(ICommandSender sender, params int[] ints) { Ints = ints; } - [CommandHandler("tshock_tests:allow_empty")] - public void TestCommand_AllowEmpty(ICommandSender sender, - [ParseOptions(ParseOptions.AllowEmpty)] string @string) { - Sender = sender; - String = @string; - } - [CommandHandler("tshock_tests:exception")] public void TestCommand_Exception(ICommandSender sender) => throw new NotImplementedException(); From 1b6eb46feeefe074b2d08d061702aab60129b64c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 19:41:00 -0700 Subject: [PATCH 090/119] Remove un-necessary spaces from XMLdoc tags. --- src/TShock/Commands/Parsers/FlagAttribute.cs | 2 +- src/TShock/Events/Commands/CommandEventArgs.cs | 2 +- src/TShock/TShockPlugin.cs | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index f0d8cad4d..0c0db1466 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -21,7 +21,7 @@ namespace TShock.Commands.Parsers { /// - /// Specifies that a parameter should have flag-based parsing. + /// Specifies that a parameter should have flag-based parsing. /// [AttributeUsage(AttributeTargets.Parameter)] public sealed class FlagAttribute : Attribute { diff --git a/src/TShock/Events/Commands/CommandEventArgs.cs b/src/TShock/Events/Commands/CommandEventArgs.cs index cc25a01e5..c1c66ccb0 100644 --- a/src/TShock/Events/Commands/CommandEventArgs.cs +++ b/src/TShock/Events/Commands/CommandEventArgs.cs @@ -26,7 +26,7 @@ namespace TShock.Events.Commands { public abstract class CommandEventArgs : EventArgs, ICancelable { private ICommand _command; - /// + /// public string? CancellationReason { get; set; } /// diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index e99d45eb8..4753199cb 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -45,11 +45,11 @@ public sealed class TShockPlugin : OrionPlugin { private readonly Lazy _playerService; private readonly Lazy _commandService; - /// + /// [ExcludeFromCodeCoverage] public override string Author => "Pryaxis"; - /// + /// [ExcludeFromCodeCoverage] public override string Name => "TShock"; @@ -72,14 +72,14 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService, _commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); } - /// + /// protected override void Initialize() { Kernel.ServerCommand += ServerCommandHandler; PlayerService.PlayerChat += PlayerChatHandler; } - /// + /// protected override void Dispose(bool disposeManaged) { if (!disposeManaged) { return; From 16e3eb0da80b76dce55e830e31c120398922843f Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 19:43:55 -0700 Subject: [PATCH 091/119] Add test for default interface implementation. --- .../Commands/Parsers/IArgumentParser.cs | 20 +++++++++ .../Commands/Parsers/IArgumentParser`1.cs | 41 ------------------- .../Commands/Parsers/ArgumentParserTests.cs | 39 ++++++++++++++++++ 3 files changed, 59 insertions(+), 41 deletions(-) delete mode 100644 src/TShock/Commands/Parsers/IArgumentParser`1.cs create mode 100644 tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 3295117f1..ea9a8f9b3 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace TShock.Commands.Parsers { /// @@ -33,4 +34,23 @@ public interface IArgumentParser { /// The input could not be parsed properly. object? Parse(ref ReadOnlySpan input, ISet? options = null); } + + /// + /// Provides type-safe parsing support. + /// + /// The parse type. + public interface IArgumentParser : IArgumentParser { + /// + /// Parses and returns a corresponding instance. + /// will be consumed as necessary. + /// + /// The input. This is guaranteed to start with a non-whitespace character. + /// The parse options. + /// A corresponding instance. + /// The input could not be parsed properly. + new TParse Parse(ref ReadOnlySpan input, ISet? options = null); + + [ExcludeFromCodeCoverage] + object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); + } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser`1.cs b/src/TShock/Commands/Parsers/IArgumentParser`1.cs deleted file mode 100644 index 293ad5b91..000000000 --- a/src/TShock/Commands/Parsers/IArgumentParser`1.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace TShock.Commands.Parsers { - /// - /// Provides type-safe parsing support. - /// - /// The parse type. - public interface IArgumentParser : IArgumentParser { - /// - /// Parses and returns a corresponding instance. - /// will be consumed as necessary. - /// - /// The input. This is guaranteed to start with a non-whitespace character. - /// The parse options. - /// A corresponding instance. - /// The input could not be parsed properly. - new TParse Parse(ref ReadOnlySpan input, ISet? options = null); - - [ExcludeFromCodeCoverage] - object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); - } -} diff --git a/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs b/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs new file mode 100644 index 000000000..b599d5864 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class ArgumentParserTests { + [Fact] + public void Parse_NonGeneric() { + IArgumentParser parser = new TestParser(); + var input = "".AsSpan(); + + parser.Parse(ref input).Should().NotBeNull().And.BeOfType(); + } + + private class TestParser : IArgumentParser { + public TestClass Parse(ref ReadOnlySpan input, ISet options = null) => new TestClass(); + } + + private class TestClass { } + } +} From faa6378620a26137f713fa5620566b36b3f7d047 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 19:55:13 -0700 Subject: [PATCH 092/119] Move default interface methods in ICommandSender out. --- src/TShock/Commands/ICommand.cs | 2 +- src/TShock/Commands/ICommandSender.cs | 44 ++++++++++- src/TShock/Commands/ICommandService.cs | 12 +-- .../Commands/CommandSenderTests.cs | 76 +++++++++++++++++++ 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 tests/TShock.Tests/Commands/CommandSenderTests.cs diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index b5232f164..e0d88092d 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -39,7 +39,7 @@ public interface ICommand { MethodBase Handler { get; } /// - /// Invokes the command as with . + /// Invokes the command as a with the . /// /// The sender. /// The input. This does not include the command's name. diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index be8e756bb..c7a3c7734 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -15,6 +15,7 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . +using System; using Microsoft.Xna.Framework; using Orion.Players; using Serilog; @@ -43,6 +44,7 @@ public interface ICommandSender { /// Sends a to the sender. /// /// The message. + /// is . void SendMessage(string message); /// @@ -50,18 +52,52 @@ public interface ICommandSender { /// /// The message. /// The color. + /// is . void SendMessage(string message, Color color); + } + /// + /// Provides extensions for the interface. + /// + public static class CommandSenderExtensions { /// - /// Sends an error to the sender. + /// Sends an error to the . /// + /// The sender. /// The error message. - void SendErrorMessage(string message) => SendMessage(message, new Color(0xcc, 0x44, 0x44)); + /// + /// or are . + /// + public static void SendErrorMessage(this ICommandSender sender, string message) { + if (sender is null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (message is null) { + throw new ArgumentNullException(nameof(message)); + } + + sender.SendMessage(message, new Color(0xcc, 0x44, 0x44)); + } /// - /// Sends an informational to the sender. + /// Sends an informational to the . /// + /// The sender. /// The informational message. - void SendInfoMessage(string message) => SendMessage(message, new Color(0xff, 0xf0, 0x14)); + /// + /// or are . + /// + public static void SendInfoMessage(this ICommandSender sender, string message) { + if (sender is null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (message is null) { + throw new ArgumentNullException(nameof(message)); + } + + sender.SendMessage(message, new Color(0xff, 0xf0, 0x14)); + } } } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 6746c0c66..2b243d39d 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -53,13 +53,13 @@ public interface ICommandService : IService { EventHandlerCollection? CommandUnregister { get; set; } /// - /// Registers and returns the commands defined with 's command handlers. Command - /// handlers are specified using the attribute. + /// Registers and returns the commands defined with the 's command handlers. + /// Command handlers are specified using the attribute. /// /// The object. /// The resulting commands. /// - /// is (). + /// is . /// IReadOnlyCollection RegisterCommands(object handlerObject); @@ -73,8 +73,8 @@ public interface ICommandService : IService { void RegisterParser(IArgumentParser parser); /// - /// Finds and returns a command with . A command name (possibly qualified) will be - /// extracted and tested from . + /// Finds and returns a command with the . A command name (possibly qualified) will be + /// extracted and tested from the . /// /// The input. /// The command. @@ -82,7 +82,7 @@ public interface ICommandService : IService { ICommand FindCommand(ref ReadOnlySpan input); /// - /// Unregisters and returns a value indicating success. + /// Unregisters the and returns a value indicating success. /// /// The command. /// diff --git a/tests/TShock.Tests/Commands/CommandSenderTests.cs b/tests/TShock.Tests/Commands/CommandSenderTests.cs new file mode 100644 index 000000000..e596d5268 --- /dev/null +++ b/tests/TShock.Tests/Commands/CommandSenderTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Moq; +using Xunit; + +namespace TShock.Commands { + public class CommandSenderTests { + [Fact] + public void SendErrorMessage() { + var mockSender = new Mock(); + + mockSender.Object.SendErrorMessage("test"); + + mockSender.Verify(s => s.SendMessage("test", It.IsAny())); + mockSender.VerifyNoOtherCalls(); + } + + [Fact] + public void SendErrorMessage_NullSender_ThrowsArgumentNullException() { + Action action = () => CommandSenderExtensions.SendErrorMessage(null, ""); + + action.Should().Throw(); + } + + [Fact] + public void SendErrorMessage_NullMessage_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Action action = () => sender.SendErrorMessage(null); + + action.Should().Throw(); + } + + [Fact] + public void SendInfoMessage() { + var mockSender = new Mock(); + + mockSender.Object.SendInfoMessage("test"); + + mockSender.Verify(s => s.SendMessage("test", It.IsAny())); + mockSender.VerifyNoOtherCalls(); + } + + [Fact] + public void SendInfoMessage_NullSender_ThrowsArgumentNullException() { + Action action = () => CommandSenderExtensions.SendInfoMessage(null, ""); + + action.Should().Throw(); + } + + [Fact] + public void SendInfoMessage_NullMessage_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Action action = () => sender.SendInfoMessage(null); + + action.Should().Throw(); + } + } +} From 3f370bb3088e9d8d3a2bfc25016de0dd7325e10b Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 20:06:39 -0700 Subject: [PATCH 093/119] Add more validation. --- .../Commands/CommandHandlerAttribute.cs | 9 ++--- .../Logging/PlayerLogValueFormatter.cs | 23 +++++++++---- src/TShock/Commands/Parsers/FlagAttribute.cs | 10 ++++++ .../Commands/Parsers/ParseOptionsAttribute.cs | 33 ++++++++++++++----- .../Commands/Parsers/FlagAttributeTests.cs | 7 ++++ .../Parsers/ParseOptionsAttributeTests.cs | 16 ++++++++- 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 9308eb64d..e0d3f9a1b 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -54,18 +54,15 @@ public CommandHandlerAttribute(string qualifiedCommandName) { var colon = qualifiedCommandName.IndexOf(':', StringComparison.Ordinal); if (colon <= 0) { - throw new ArgumentException("Qualified command name is missing the namespace.", - nameof(qualifiedCommandName)); + throw new ArgumentException("Parameter is missing namespace.", nameof(qualifiedCommandName)); } if (colon >= qualifiedCommandName.Length - 1) { - throw new ArgumentException("Qualified command name is missing the name.", - nameof(qualifiedCommandName)); + throw new ArgumentException("Parameter is missing name.", nameof(qualifiedCommandName)); } if (qualifiedCommandName.IndexOf(' ', StringComparison.Ordinal) >= 0) { - throw new ArgumentException("Qualified command name contains a space.", - nameof(qualifiedCommandName)); + throw new ArgumentException("Parameter contains a space.", nameof(qualifiedCommandName)); } QualifiedCommandName = qualifiedCommandName; diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs index c5a0d43d3..6c514a5aa 100644 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.Contracts; using System.Globalization; using System.Linq; using Serilog.Data; @@ -32,9 +33,11 @@ internal sealed class PlayerLogValueFormatter : LogEventPropertyValueVisitor Visit(default, value); + [Pure] protected override string VisitScalarValue(Unit _, ScalarValue scalar) => string.Format(CultureInfo.InvariantCulture, scalar.Value switch { null => NullFormat, @@ -44,25 +47,31 @@ protected override string VisitScalarValue(Unit _, ScalarValue scalar) => var n when n.GetType().IsPrimitive || n is decimal => NumberFormat, _ => ScalarFormat }, scalar.Value); - + + [Pure] protected override string VisitSequenceValue(Unit _, SequenceValue sequence) => FormattableString.Invariant($"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"); - + + [Pure] protected override string VisitStructureValue(Unit _, StructureValue structure) => FormatTypeTag(structure.TypeTag) + FormattableString.Invariant( $"{{{string.Join(", ", structure.Properties.Select(FormatStructureElement))}}}"); - + + [Pure] protected override string VisitDictionaryValue(Unit _, DictionaryValue dictionary) => FormattableString.Invariant( $"{{{string.Join(", ", dictionary.Elements.Select(FormatDictionaryElement))}}}"); - + + [Pure] private string FormatTypeTag(string? typeTag) => typeTag is null ? string.Empty : string.Format(CultureInfo.InvariantCulture, TypeTagFormat, typeTag); - + + [Pure] private string FormatStructureElement(LogEventProperty p) => FormattableString.Invariant($"{p.Name}={Visit(default, p.Value)}"); - + + [Pure] private string FormatDictionaryElement(KeyValuePair kvp) => FormattableString.Invariant($"[{Visit(default, kvp.Key)}]={Visit(default, kvp.Value)}"); } diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/FlagAttribute.cs index 0c0db1466..68c229b09 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/FlagAttribute.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace TShock.Commands.Parsers { @@ -36,9 +37,14 @@ public sealed class FlagAttribute : Attribute { /// /// The flag. /// The alternate flags. This may be empty. + /// + /// contains a element. + /// /// /// or are . /// + [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "strings are not user-facing")] public FlagAttribute(string flag, params string[] alternateFlags) { if (flag is null) { throw new ArgumentNullException(nameof(flag)); @@ -48,6 +54,10 @@ public FlagAttribute(string flag, params string[] alternateFlags) { throw new ArgumentNullException(nameof(alternateFlags)); } + if (alternateFlags.Any(f => f == null)) { + throw new ArgumentException("Array contains null element.", nameof(alternateFlags)); + } + Flags = new[] { flag }.Concat(alternateFlags).ToList(); } } diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs index 79d82d9fb..34dbc0a44 100644 --- a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs +++ b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs @@ -17,7 +17,8 @@ using System; using System.Collections.Generic; -using JetBrains.Annotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace TShock.Commands.Parsers { /// @@ -33,15 +34,31 @@ public sealed class ParseOptionsAttribute : Attribute { /// /// Initializes a new instance of the class with the specified options. /// - /// The options. - /// is . - public ParseOptionsAttribute([ValueProvider("TShock.Commands.Parsers.ParseOptions")] params string[] options) { - if (options is null) { - throw new ArgumentNullException(nameof(options)); + /// The option. + /// The other options. + /// + /// contains a element. + /// + /// + /// or are . + /// + [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "strings are not user-facing")] + public ParseOptionsAttribute(string option, params string[] otherOptions) { + if (option is null) { + throw new ArgumentNullException(nameof(option)); } - var optionsSet = new HashSet(); - optionsSet.UnionWith(options); + if (otherOptions is null) { + throw new ArgumentNullException(nameof(otherOptions)); + } + + if (otherOptions.Any(o => o == null)) { + throw new ArgumentException("Array contains null element.", nameof(otherOptions)); + } + + var optionsSet = new HashSet { option }; + optionsSet.UnionWith(otherOptions); Options = optionsSet; } } diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs index e373ba2c0..7095fe83d 100644 --- a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs @@ -35,6 +35,13 @@ public void Ctor_NullAlternateFlags_ThrowsArgumentNullException() { func.Should().Throw(); } + [Fact] + public void Ctor_AlternateFlagsNullElement_ThrowsArgumentException() { + Func func = () => new FlagAttribute("", "test", null); + + func.Should().Throw(); + } + [Fact] public void Flags_Get() { var attribute = new FlagAttribute("test1", "test2", "test3"); diff --git a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs index e5c4e1fc8..ab5836751 100644 --- a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs @@ -22,12 +22,26 @@ namespace TShock.Commands.Parsers { public class ParseOptionsAttributeTests { [Fact] - public void Ctor_NullOptions_ThrowsArgumentNullException() { + public void Ctor_NullOption_ThrowsArgumentNullException() { Func func = () => new ParseOptionsAttribute(null); func.Should().Throw(); } + [Fact] + public void Ctor_NullOptions_ThrowsArgumentNullException() { + Func func = () => new ParseOptionsAttribute("", null); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_OptionsNullElement_ThrowsArgumentException() { + Func func = () => new ParseOptionsAttribute("", "test", null); + + func.Should().Throw(); + } + [Fact] public void Options_Get() { var attribute = new ParseOptionsAttribute("test", "test2"); From bcbe2ad06e4e25ae62018a2fba478dec4ac9e63b Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 20:19:33 -0700 Subject: [PATCH 094/119] Switch to attribute-based parsing. --- .../Parsers/{ => Attributes}/FlagAttribute.cs | 2 +- .../RestOfInputAttribute.cs} | 14 ++-- .../Commands/Parsers/IArgumentParser.cs | 19 +++--- src/TShock/Commands/Parsers/Int32Parser.cs | 2 +- .../Commands/Parsers/ParseOptionsAttribute.cs | 65 ------------------- src/TShock/Commands/Parsers/StringParser.cs | 6 +- src/TShock/Commands/TShockCommand.cs | 6 +- src/TShock/Commands/TShockCommandService.cs | 5 +- .../Commands/Parsers/ArgumentParserTests.cs | 4 +- .../{ => Attributes}/FlagAttributeTests.cs | 2 +- .../Commands/Parsers/Int32ParserTests.cs | 9 +-- .../Parsers/ParseOptionsAttributeTests.cs | 52 --------------- .../Commands/Parsers/StringParserTests.cs | 9 +-- .../Commands/TShockCommandTests.cs | 1 + 14 files changed, 42 insertions(+), 154 deletions(-) rename src/TShock/Commands/Parsers/{ => Attributes}/FlagAttribute.cs (98%) rename src/TShock/Commands/Parsers/{ParseOptions.cs => Attributes/RestOfInputAttribute.cs} (66%) delete mode 100644 src/TShock/Commands/Parsers/ParseOptionsAttribute.cs rename tests/TShock.Tests/Commands/Parsers/{ => Attributes}/FlagAttributeTests.cs (97%) delete mode 100644 tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs diff --git a/src/TShock/Commands/Parsers/FlagAttribute.cs b/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs similarity index 98% rename from src/TShock/Commands/Parsers/FlagAttribute.cs rename to src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs index 68c229b09..bc7ef7095 100644 --- a/src/TShock/Commands/Parsers/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs @@ -20,7 +20,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; -namespace TShock.Commands.Parsers { +namespace TShock.Commands.Parsers.Attributes { /// /// Specifies that a parameter should have flag-based parsing. /// diff --git a/src/TShock/Commands/Parsers/ParseOptions.cs b/src/TShock/Commands/Parsers/Attributes/RestOfInputAttribute.cs similarity index 66% rename from src/TShock/Commands/Parsers/ParseOptions.cs rename to src/TShock/Commands/Parsers/Attributes/RestOfInputAttribute.cs index 78c95efc8..f0433aee2 100644 --- a/src/TShock/Commands/Parsers/ParseOptions.cs +++ b/src/TShock/Commands/Parsers/Attributes/RestOfInputAttribute.cs @@ -15,14 +15,12 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -namespace TShock.Commands.Parsers { +using System; + +namespace TShock.Commands.Parsers.Attributes { /// - /// Provides parse options. These are strings so that parse options can easily be added by consumers. + /// Specifies that a parameter should parse to the rest of the input. /// - public static class ParseOptions { - /// - /// An option which forces a string to be parsed to the end of the input. - /// - public const string ToEndOfInput = nameof(ToEndOfInput); - } + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class RestOfInputAttribute : Attribute { } } diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index ea9a8f9b3..5e935fbb6 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -25,14 +25,14 @@ namespace TShock.Commands.Parsers { /// public interface IArgumentParser { /// - /// Parses and returns a corresponding object. will be - /// consumed as necessary. + /// Parses and returns a corresponding object using the . + /// will be consumed as necessary. /// /// The input. This is guaranteed to start with a non-whitespace character. - /// The parse options. + /// The attributes. /// A corresponding object. /// The input could not be parsed properly. - object? Parse(ref ReadOnlySpan input, ISet? options = null); + object? Parse(ref ReadOnlySpan input, ISet attributes); } /// @@ -41,16 +41,17 @@ public interface IArgumentParser { /// The parse type. public interface IArgumentParser : IArgumentParser { /// - /// Parses and returns a corresponding instance. - /// will be consumed as necessary. + /// Parses and returns a corresponding instance using the + /// . will be consumed as necessary. /// /// The input. This is guaranteed to start with a non-whitespace character. - /// The parse options. + /// The attributes. /// A corresponding instance. /// The input could not be parsed properly. - new TParse Parse(ref ReadOnlySpan input, ISet? options = null); + new TParse Parse(ref ReadOnlySpan input, ISet attributes); [ExcludeFromCodeCoverage] - object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet? options) => Parse(ref input, options); + object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet attributes) => + Parse(ref input, attributes); } } diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 1f79eb1f6..44600091c 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -27,7 +27,7 @@ namespace TShock.Commands.Parsers { /// public sealed class Int32Parser : IArgumentParser { /// - public int Parse(ref ReadOnlySpan input, ISet? options = null) { + public int Parse(ref ReadOnlySpan input, ISet attributes) { var end = input.IndexOfOrEnd(' '); var parse = input[..end]; diff --git a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs b/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs deleted file mode 100644 index 34dbc0a44..000000000 --- a/src/TShock/Commands/Parsers/ParseOptionsAttribute.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace TShock.Commands.Parsers { - /// - /// Specifies the options with which to parse a parameter. - /// - [AttributeUsage(AttributeTargets.Parameter)] - public sealed class ParseOptionsAttribute : Attribute { - /// - /// Gets the options. - /// - public ISet Options { get; } - - /// - /// Initializes a new instance of the class with the specified options. - /// - /// The option. - /// The other options. - /// - /// contains a element. - /// - /// - /// or are . - /// - [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", - Justification = "strings are not user-facing")] - public ParseOptionsAttribute(string option, params string[] otherOptions) { - if (option is null) { - throw new ArgumentNullException(nameof(option)); - } - - if (otherOptions is null) { - throw new ArgumentNullException(nameof(otherOptions)); - } - - if (otherOptions.Any(o => o == null)) { - throw new ArgumentException("Array contains null element.", nameof(otherOptions)); - } - - var optionsSet = new HashSet { option }; - optionsSet.UnionWith(otherOptions); - Options = optionsSet; - } - } -} diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 8d89449c1..8662e24ea 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -18,7 +18,9 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text; +using TShock.Commands.Parsers.Attributes; using TShock.Properties; namespace TShock.Commands.Parsers { @@ -27,8 +29,8 @@ namespace TShock.Commands.Parsers { /// public sealed class StringParser : IArgumentParser { /// - public string Parse(ref ReadOnlySpan input, ISet? options = null) { - if (options?.Contains(ParseOptions.ToEndOfInput) == true) { + public string Parse(ref ReadOnlySpan input, ISet attributes) { + if (attributes.Any(a => a is RestOfInputAttribute)) { var result = input.ToString(); input = default; return result; diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 5e53fa424..adea505c7 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -22,7 +22,7 @@ using System.Linq; using System.Reflection; using Orion.Events; -using TShock.Commands.Parsers; +using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; using TShock.Properties; using TShock.Utils.Extensions; @@ -106,14 +106,14 @@ public void Invoke(ICommandSender sender, ReadOnlySpan input) { parameterType)); } - var options = parameterInfo.GetCustomAttribute()?.Options; + var attributes = parameterInfo.GetCustomAttributes().ToHashSet(); if (input.IsEmpty) { throw new CommandParseException( string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_MissingArg, parameterInfo)); } - return parser.Parse(ref input, options); + return parser.Parse(ref input, attributes); } void ParseShortFlags(ref ReadOnlySpan input, int space) { diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 1b667463b..02b2ac85b 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -27,6 +27,7 @@ using Orion.Players; using Serilog; using TShock.Commands.Parsers; +using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; using TShock.Properties; using TShock.Utils.Extensions; @@ -187,7 +188,7 @@ public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { } [CommandHandler("tshock:me")] - public void Me(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] string text) { + public void Me(ICommandSender sender, [RestOfInput] string text) { PlayerService.BroadcastMessage( string.Format(CultureInfo.InvariantCulture, Resources.Command_Me, sender.Name, text), new Color(0xc8, 0x64, 0x00)); @@ -204,7 +205,7 @@ public void Roll(ICommandSender sender) { } [CommandHandler("tshock:p")] - public void Party(ICommandSender sender, [ParseOptions(ParseOptions.ToEndOfInput)] string text) { + public void Party(ICommandSender sender, [RestOfInput] string text) { var player = sender.Player; if (player is null) { sender.SendErrorMessage(Resources.Command_Party_NotAPlayer); diff --git a/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs b/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs index b599d5864..16b0eaf45 100644 --- a/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs @@ -27,11 +27,11 @@ public void Parse_NonGeneric() { IArgumentParser parser = new TestParser(); var input = "".AsSpan(); - parser.Parse(ref input).Should().NotBeNull().And.BeOfType(); + parser.Parse(ref input, new HashSet()).Should().NotBeNull().And.BeOfType(); } private class TestParser : IArgumentParser { - public TestClass Parse(ref ReadOnlySpan input, ISet options = null) => new TestClass(); + public TestClass Parse(ref ReadOnlySpan input, ISet attributes) => new TestClass(); } private class TestClass { } diff --git a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/Attributes/FlagAttributeTests.cs similarity index 97% rename from tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs rename to tests/TShock.Tests/Commands/Parsers/Attributes/FlagAttributeTests.cs index 7095fe83d..707f560f0 100644 --- a/tests/TShock.Tests/Commands/Parsers/FlagAttributeTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Attributes/FlagAttributeTests.cs @@ -19,7 +19,7 @@ using FluentAssertions; using Xunit; -namespace TShock.Commands.Parsers { +namespace TShock.Commands.Parsers.Attributes { public class FlagAttributeTests { [Fact] public void Ctor_NullFlag_ThrowsArgumentNullException() { diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index c58705cfb..f9d138fa0 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using FluentAssertions; using Xunit; @@ -31,7 +32,7 @@ public void Parse(string inputString, int expected, string expectedNextInput) { var parser = new Int32Parser(); var input = inputString.AsSpan(); - parser.Parse(ref input).Should().Be(expected); + parser.Parse(ref input, new HashSet()).Should().Be(expected); input.ToString().Should().Be(expectedNextInput); } @@ -43,7 +44,7 @@ public void Parse_MissingInteger_ThrowsParseException(string inputString) { var parser = new Int32Parser(); Func func = () => { var input = inputString.AsSpan(); - return parser.Parse(ref input); + return parser.Parse(ref input, new HashSet()); }; func.Should().Throw(); @@ -56,7 +57,7 @@ public void Parse_IntegerOutOfRange_ThrowsParseException(string inputString) { var parser = new Int32Parser(); Func func = () => { var input = inputString.AsSpan(); - return parser.Parse(ref input); + return parser.Parse(ref input, new HashSet()); }; func.Should().Throw().WithInnerException(); @@ -70,7 +71,7 @@ public void Parse_InvalidInteger_ThrowsParseException(string inputString) { var parser = new Int32Parser(); Func func = () => { var input = inputString.AsSpan(); - return parser.Parse(ref input); + return parser.Parse(ref input, new HashSet()); }; func.Should().Throw().WithInnerException(); } diff --git a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs deleted file mode 100644 index ab5836751..000000000 --- a/tests/TShock.Tests/Commands/Parsers/ParseOptionsAttributeTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using FluentAssertions; -using Xunit; - -namespace TShock.Commands.Parsers { - public class ParseOptionsAttributeTests { - [Fact] - public void Ctor_NullOption_ThrowsArgumentNullException() { - Func func = () => new ParseOptionsAttribute(null); - - func.Should().Throw(); - } - - [Fact] - public void Ctor_NullOptions_ThrowsArgumentNullException() { - Func func = () => new ParseOptionsAttribute("", null); - - func.Should().Throw(); - } - - [Fact] - public void Ctor_OptionsNullElement_ThrowsArgumentException() { - Func func = () => new ParseOptionsAttribute("", "test", null); - - func.Should().Throw(); - } - - [Fact] - public void Options_Get() { - var attribute = new ParseOptionsAttribute("test", "test2"); - - attribute.Options.Should().BeEquivalentTo("test", "test2"); - } - } -} diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index 635c9045d..0e22328cb 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using FluentAssertions; +using TShock.Commands.Parsers.Attributes; using Xunit; namespace TShock.Commands.Parsers { @@ -38,7 +39,7 @@ public void Parse(string inputString, string expected, string expectedNextInput) var parser = new StringParser(); var input = inputString.AsSpan(); - parser.Parse(ref input).Should().Be(expected); + parser.Parse(ref input, new HashSet()).Should().Be(expected); input.ToString().Should().Be(expectedNextInput); } @@ -50,18 +51,18 @@ public void Parse_EscapeReachesEnd_ThrowsParseException(string inputString) { var parser = new StringParser(); Func func = () => { var input = inputString.AsSpan(); - return parser.Parse(ref input); + return parser.Parse(ref input, new HashSet()); }; func.Should().Throw(); } [Fact] - public void Parse_ToEndOfInput() { + public void Parse_RestOfInput() { var parser = new StringParser(); var input = @"blah blah ""test"" blah blah".AsSpan(); - parser.Parse(ref input, new HashSet { ParseOptions.ToEndOfInput }) + parser.Parse(ref input, new HashSet { new RestOfInputAttribute() }) .Should().Be(@"blah blah ""test"" blah blah"); input.ToString().Should().BeEmpty(); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 8516de605..d0449bf1d 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -23,6 +23,7 @@ using Moq; using Orion.Events; using TShock.Commands.Parsers; +using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; using Xunit; From 6dbc9456d177aa2ba92553441ce6c413847f8f17 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 21:21:18 -0700 Subject: [PATCH 095/119] Implement localizable CommandHandlerAttribute. --- .../Commands/CommandHandlerAttribute.cs | 82 +++++++---- src/TShock/Commands/ICommand.cs | 12 ++ src/TShock/Commands/TShockCommand.cs | 10 +- src/TShock/Commands/TShockCommandService.cs | 61 ++++---- src/TShock/Properties/Resources.Designer.cs | 139 +++++++++++++++++- src/TShock/Properties/Resources.resx | 45 ++++++ src/TShock/Utils/ResourceHelper.cs | 60 ++++++++ .../Commands/CommandHandlerAttributeTests.cs | 67 +++++++-- .../Commands/TShockCommandTests.cs | 2 +- .../TShock.Tests/Utils/ResourceHelperTests.cs | 60 ++++++++ 10 files changed, 466 insertions(+), 72 deletions(-) create mode 100644 src/TShock/Utils/ResourceHelper.cs create mode 100644 tests/TShock.Tests/Utils/ResourceHelperTests.cs diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index e0d3f9a1b..c3bb26966 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -18,6 +18,7 @@ using System; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; +using TShock.Utils; namespace TShock.Commands { /// @@ -27,45 +28,74 @@ namespace TShock.Commands { [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] [MeansImplicitUse] public sealed class CommandHandlerAttribute : Attribute { + private readonly string _qualifiedName; + private string? _helpText; + private string? _usageText; + private Type? _resourceType; + + /// + /// Gets the qualified name. This includes the namespace: e.g., "tshock:kick". + /// + public string QualifiedName => GetResourceStringMaybe(_qualifiedName); + + /// + /// Gets or sets the help text. If , then no help text exists. This will show up in the + /// /help command. + /// + /// is . + [DisallowNull] + public string? HelpText { + get => GetResourceStringMaybe(_helpText); + set => _helpText = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets the usage text. If , then no usage text exists. This will show up in the + /// /help command and when invalid syntax is used. + /// + /// is . + [DisallowNull] + public string? UsageText { + get => GetResourceStringMaybe(_usageText); + set => _usageText = value ?? throw new ArgumentNullException(nameof(value)); + } + /// - /// Gets the command's qualified name. This includes the command's namespace: e.g., "tshock:kick". + /// Gets or sets the resource type to load localizable strings from. If , then no + /// localization will occur. /// - public string QualifiedCommandName { get; } + public Type? ResourceType { + get => _resourceType; + set => _resourceType = value ?? throw new ArgumentNullException(nameof(value)); + } + + [return: NotNullIfNotNull("str")] + private string? GetResourceStringMaybe(string? str) { + if (str is null) { + return null; + } + + return _resourceType != null ? ResourceHelper.LoadResource(_resourceType, str) : str; + } /// /// Initializes a new instance of the class with the specified qualified - /// command name. + /// name. /// - /// - /// The qualified command name. This includes the namespace: e.g., "tshock:kick". + /// + /// The qualified name. This must include the namespace: e.g., "tshock:kick". /// - /// - /// is missing the namespace or name, or contains a space. - /// /// - /// is . + /// is . /// [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "strings are not user-facing")] - public CommandHandlerAttribute(string qualifiedCommandName) { - if (qualifiedCommandName is null) { - throw new ArgumentNullException(nameof(qualifiedCommandName)); - } - - var colon = qualifiedCommandName.IndexOf(':', StringComparison.Ordinal); - if (colon <= 0) { - throw new ArgumentException("Parameter is missing namespace.", nameof(qualifiedCommandName)); - } - - if (colon >= qualifiedCommandName.Length - 1) { - throw new ArgumentException("Parameter is missing name.", nameof(qualifiedCommandName)); - } - - if (qualifiedCommandName.IndexOf(' ', StringComparison.Ordinal) >= 0) { - throw new ArgumentException("Parameter contains a space.", nameof(qualifiedCommandName)); + public CommandHandlerAttribute(string qualifiedName) { + if (qualifiedName is null) { + throw new ArgumentNullException(nameof(qualifiedName)); } - QualifiedCommandName = qualifiedCommandName; + _qualifiedName = qualifiedName; } } } diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index e0d88092d..077ac691d 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -28,6 +28,18 @@ public interface ICommand { /// string QualifiedName { get; } + /// + /// Gets the command's help text. If , then no help text exists. This will show up in the + /// /help command. + /// + string? HelpText { get; } + + /// + /// Gets the command's usage text. If , then no usage text exists. This will show up in the + /// /help command and when invalid syntax is used. + /// + string? UsageText { get; } + /// /// Gets the object associated with the command's handler. /// diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index adea505c7..4a4b681ba 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -37,19 +37,23 @@ internal class TShockCommand : ICommand { private readonly object?[] _parameters; public string QualifiedName { get; } + public string? HelpText { get; } + public string? UsageText { get; } public object HandlerObject { get; } public MethodBase Handler { get; } // We need to inject ICommandService so that we can trigger its CommandExecute event. - public TShockCommand(ICommandService commandService, string qualifiedName, object handlerObject, + public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object handlerObject, MethodBase handler) { Debug.Assert(commandService != null, "command service should not be null"); - Debug.Assert(qualifiedName != null, "qualified name should not be null"); + Debug.Assert(attribute != null, "attribute should not be null"); Debug.Assert(handlerObject != null, "handler object should not be null"); Debug.Assert(handler != null, "handler should not be null"); _commandService = commandService; - QualifiedName = qualifiedName; + QualifiedName = attribute.QualifiedName; + HelpText = attribute.HelpText; + UsageText = attribute.UsageText; HandlerObject = handlerObject; Handler = handler; diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 02b2ac85b..fc8a24d92 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -62,13 +62,11 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { throw new ArgumentNullException(nameof(handlerObject)); } - var registeredCommands = new List(); - - void RegisterCommand(ICommand command) { + ICommand? RegisterCommand(ICommand command) { var args = new CommandRegisterEventArgs(command); CommandRegister?.Invoke(this, args); if (args.IsCanceled()) { - return; + return null; } var qualifiedName = command.QualifiedName; @@ -77,17 +75,13 @@ void RegisterCommand(ICommand command) { _qualifiedNames[name].Add(qualifiedName); _commands.Add(qualifiedName, command); - registeredCommands.Add(command); - } - - foreach (var command in handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) - .SelectMany(m => m.GetCustomAttributes(), - (handler, attribute) => (handler, attribute)) - .Select(t => new TShockCommand(this, t.attribute.QualifiedCommandName, handlerObject, t.handler))) { - RegisterCommand(command); + return command; } - return registeredCommands; + return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SelectMany(m => m.GetCustomAttributes(), + (m, a) => RegisterCommand(new TShockCommand(this, a, handlerObject, m))) + .Where(c => c != null).ToList()!; } public void RegisterParser(IArgumentParser parser) => @@ -155,11 +149,12 @@ public bool UnregisterCommand(ICommand command) { _qualifiedNames[name].Remove(qualifiedName); return true; } - - [CommandHandler("tshock:help")] + + [CommandHandler(nameof(Resources.Command_Help), + HelpText = nameof(Resources.Command_Help_HelpText), + UsageText = nameof(Resources.Command_Help_UsageText), + ResourceType = typeof(Resources))] public void Help(ICommandSender sender) { - sender.SendInfoMessage(Resources.Command_Help_Header); - string FormatCommandName(ICommand command) { var qualifiedName = command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); @@ -170,8 +165,11 @@ string FormatCommandName(ICommand command) { sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(FormatCommandName))); } - - [CommandHandler("tshock:playing")] + + [CommandHandler(nameof(Resources.Command_Playing), + HelpText = nameof(Resources.Command_Playing_HelpText), + UsageText = nameof(Resources.Command_Playing_UsageText), + ResourceType = typeof(Resources))] public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { var onlinePlayers = PlayerService.Players.Where(p => p.IsActive).ToList(); if (onlinePlayers.Count == 0) { @@ -187,25 +185,34 @@ public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { } } - [CommandHandler("tshock:me")] + [CommandHandler(nameof(Resources.Command_Me), + HelpText = nameof(Resources.Command_Me_HelpText), + UsageText = nameof(Resources.Command_Me_UsageText), + ResourceType = typeof(Resources))] public void Me(ICommandSender sender, [RestOfInput] string text) { PlayerService.BroadcastMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Command_Me, sender.Name, text), + string.Format(CultureInfo.InvariantCulture, Resources.Command_Me_Message, sender.Name, text), new Color(0xc8, 0x64, 0x00)); Log.Information("*{Name} {Text}", sender.Name, text); } - - [CommandHandler("tshock:roll")] + + [CommandHandler(nameof(Resources.Command_Roll), + HelpText = nameof(Resources.Command_Roll_HelpText), + UsageText = nameof(Resources.Command_Roll_UsageText), + ResourceType = typeof(Resources))] public void Roll(ICommandSender sender) { var num = _rand.Next(1, 101); PlayerService.BroadcastMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll, sender.Name, num), + string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll_Message, sender.Name, num), new Color(0xff, 0xf0, 0x14)); Log.Information("*{Name} rolls a {Num}", sender.Name, num); } - - [CommandHandler("tshock:p")] - public void Party(ICommandSender sender, [RestOfInput] string text) { + + [CommandHandler(nameof(Resources.Command_P), + HelpText = nameof(Resources.Command_P_HelpText), + UsageText = nameof(Resources.Command_P_UsageText), + ResourceType = typeof(Resources))] + public void P(ICommandSender sender, [RestOfInput] string text) { var player = sender.Player; if (player is null) { sender.SendErrorMessage(Resources.Command_Party_NotAPlayer); diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 298c80f62..68f6761fb 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -69,6 +69,15 @@ internal static string Command_BadCommand { } } + /// + /// Looks up a localized string similar to tshock:help. + /// + internal static string Command_Help { + get { + return ResourceManager.GetString("Command_Help", resourceCulture); + } + } + /// /// Looks up a localized string similar to Commands:. /// @@ -79,7 +88,25 @@ internal static string Command_Help_Header { } /// - /// Looks up a localized string similar to *{0} {1}. + /// Looks up a localized string similar to Shows available commands and provides information about specific commands.. + /// + internal static string Command_Help_HelpText { + get { + return ResourceManager.GetString("Command_Help_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /help [command-name]. + /// + internal static string Command_Help_UsageText { + get { + return ResourceManager.GetString("Command_Help_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:me. /// internal static string Command_Me { get { @@ -87,6 +114,60 @@ internal static string Command_Me { } } + /// + /// Looks up a localized string similar to Sends a message in the third person.. + /// + internal static string Command_Me_HelpText { + get { + return ResourceManager.GetString("Command_Me_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{0} {1}. + /// + internal static string Command_Me_Message { + get { + return ResourceManager.GetString("Command_Me_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /me <text>. + /// + internal static string Command_Me_UsageText { + get { + return ResourceManager.GetString("Command_Me_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:p. + /// + internal static string Command_P { + get { + return ResourceManager.GetString("Command_P", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends a message to your party members.. + /// + internal static string Command_P_HelpText { + get { + return ResourceManager.GetString("Command_P_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /p <text>. + /// + internal static string Command_P_UsageText { + get { + return ResourceManager.GetString("Command_P_UsageText", resourceCulture); + } + } + /// /// Looks up a localized string similar to You must be a player to party chat.. /// @@ -105,6 +186,15 @@ internal static string Command_Party_NotInTeam { } } + /// + /// Looks up a localized string similar to tshock:playing. + /// + internal static string Command_Playing { + get { + return ResourceManager.GetString("Command_Playing", resourceCulture); + } + } + /// /// Looks up a localized string similar to Players online:. /// @@ -114,6 +204,15 @@ internal static string Command_Playing_Header { } } + /// + /// Looks up a localized string similar to Shows the current players.. + /// + internal static string Command_Playing_HelpText { + get { + return ResourceManager.GetString("Command_Playing_HelpText", resourceCulture); + } + } + /// /// Looks up a localized string similar to No players online.. /// @@ -124,7 +223,16 @@ internal static string Command_Playing_NoPlayers { } /// - /// Looks up a localized string similar to *{0} rolls a {1}. + /// Looks up a localized string similar to Usage: /playing [-i]. + /// + internal static string Command_Playing_UsageText { + get { + return ResourceManager.GetString("Command_Playing_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:roll. /// internal static string Command_Roll { get { @@ -132,6 +240,33 @@ internal static string Command_Roll { } } + /// + /// Looks up a localized string similar to Rolls a random number from 1 to 100.. + /// + internal static string Command_Roll_HelpText { + get { + return ResourceManager.GetString("Command_Roll_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{0} rolls a {1}. + /// + internal static string Command_Roll_Message { + get { + return ResourceManager.GetString("Command_Roll_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /roll. + /// + internal static string Command_Roll_UsageText { + get { + return ResourceManager.GetString("Command_Roll_UsageText", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exception occurred while executing command.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 76b752f2a..9cbf56436 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -153,27 +153,72 @@ {0} Type /help for a list of commands. + + tshock:help + Commands: + + Shows available commands and provides information about specific commands. + + + Usage: /help [command-name] + + tshock:me + + + Sends a message in the third person. + + *{0} {1} + + Usage: /me <text> + + + tshock:p + You must be a player to party chat. You are not in a team! + + tshock:playing + Players online: + + Shows the current players. + No players online. + + Usage: /playing [-i] + + + Sends a message to your party members. + + + Usage: /p <text> + + tshock:roll + + + Rolls a random number from 1 to 100. + + *{0} rolls a {1} + + Usage: /roll + "{0}" is not a valid value. diff --git a/src/TShock/Utils/ResourceHelper.cs b/src/TShock/Utils/ResourceHelper.cs new file mode 100644 index 000000000..2eb052d9a --- /dev/null +++ b/src/TShock/Utils/ResourceHelper.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace TShock.Utils { + /// + /// Provides helper methods for loading resources. + /// + public static class ResourceHelper { + /// + /// Loads a resource from the with the given . + /// + /// The type of resource. + /// The resource type. + /// The name. + /// The resource. + /// + /// is not a property of . + /// + /// + /// or are . + /// + [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "strings are not user-facing")] + public static T LoadResource(Type resourceType, string name) { + if (resourceType is null) { + throw new ArgumentNullException(nameof(resourceType)); + } + + if (name is null) { + throw new ArgumentNullException(nameof(name)); + } + + var property = resourceType.GetProperty(name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (property is null) { + throw new ArgumentException("Property does not exist.", nameof(name)); + } + + return (T)property.GetValue(null); + } + } +} diff --git a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs index 5a21c6deb..810a55dcf 100644 --- a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs +++ b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs @@ -21,29 +21,70 @@ namespace TShock.Commands { public class CommandHandlerAttributeTests { - [Theory] - [InlineData("test")] - [InlineData(":test")] - [InlineData("test:")] - [InlineData(":")] - public void Ctor_InvalidQualifiedCommandName_ThrowsArgumentException(string qualifiedCommandName) { - Func func = () => new CommandHandlerAttribute(qualifiedCommandName); + [Fact] + public void HelpText_GetWithResourceType() { + var attribute = new CommandHandlerAttribute("tshock_test:test") { + HelpText = nameof(TestClass.HelpText), + ResourceType = typeof(TestClass) + }; + + attribute.HelpText.Should().Be(TestClass.HelpText); + } + + [Fact] + public void QualifiedName_Get() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + + attribute.QualifiedName.Should().Be("tshock_test:test"); + } + + [Fact] + public void QualifiedName_GetWithResourceType() { + var attribute = new CommandHandlerAttribute(nameof(TestClass.QualifiedName)) { + ResourceType = typeof(TestClass) + }; + + attribute.QualifiedName.Should().Be(TestClass.QualifiedName); + } + + [Fact] + public void HelpText_SetNullValue_ThrowsArgumentNullException() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + Action action = () => attribute.HelpText = null; - func.Should().Throw(); + action.Should().Throw(); } [Fact] - public void Ctor_NullQualifiedCommandName_ThrowsArgumentNullException() { - Func func = () => new CommandHandlerAttribute(null); + public void UsageText_GetWithResourceType() { + var attribute = new CommandHandlerAttribute("tshock_test:test") { + UsageText = nameof(TestClass.UsageText), + ResourceType = typeof(TestClass) + }; - func.Should().Throw(); + attribute.UsageText.Should().Be(TestClass.UsageText); } [Fact] - public void QualifiedCommandName_Get() { + public void UsageText_SetNullValue_ThrowsArgumentNullException() { var attribute = new CommandHandlerAttribute("tshock_test:test"); + Action action = () => attribute.UsageText = null; + + action.Should().Throw(); + } + + [Fact] + public void ResourceType_SetNullValue_ThrowsArgumentNullException() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + Action action = () => attribute.ResourceType = null; + + action.Should().Throw(); + } - attribute.QualifiedCommandName.Should().Be("tshock_test:test"); + private class TestClass { + public static string QualifiedName => "tshock:qualified_name_test"; + public static string HelpText => "HelpText test"; + public static string UsageText => "UsageText test"; } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index d0449bf1d..74f12d5ba 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -318,7 +318,7 @@ public void Invoke_NullSender_ThrowsArgumentNullException() { private ICommand GetCommand(TestClass testClass, string methodName) { var handler = typeof(TestClass).GetMethod(methodName); var attribute = handler.GetCustomAttribute(); - return new TShockCommand(_mockCommandService.Object, attribute.QualifiedCommandName, testClass, handler); + return new TShockCommand(_mockCommandService.Object, attribute, testClass, handler); } [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] diff --git a/tests/TShock.Tests/Utils/ResourceHelperTests.cs b/tests/TShock.Tests/Utils/ResourceHelperTests.cs new file mode 100644 index 000000000..b1858c1fd --- /dev/null +++ b/tests/TShock.Tests/Utils/ResourceHelperTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Utils { + public class ResourceHelperTests { + [Fact] + public void LoadResource_PublicProperty() => + ResourceHelper.LoadResource(typeof(TestClass), nameof(TestClass.PublicProperty)) + .Should().Be(TestClass.PublicProperty); + + [Fact] + public void LoadResource_InternalProperty() => + ResourceHelper.LoadResource(typeof(TestClass), nameof(TestClass.InternalProperty)) + .Should().Be(TestClass.InternalProperty); + + [Fact] + public void LoadResource_NullResourceType_ThrowsArgumentNullException() { + Func func = () => ResourceHelper.LoadResource(null, ""); + + func.Should().Throw(); + } + + [Fact] + public void LoadResource_NullName_ThrowsArgumentNullException() { + Func func = () => ResourceHelper.LoadResource(typeof(TestClass), null); + + func.Should().Throw(); + } + + [Fact] + public void LoadResource_InvalidProperty_ThrowsArgumentException() { + Func func = () => ResourceHelper.LoadResource(typeof(TestClass), "DoesNotExist"); + + func.Should().Throw(); + } + + private class TestClass { + public static int PublicProperty => 123; + internal static int InternalProperty => 456; + } + } +} From 60c13e5267dc646ad66526c5c4d8ac619e571f37 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 21:22:05 -0700 Subject: [PATCH 096/119] Add ArgumentNullException xmldoc to ResourceType. --- .../Commands/CommandHandlerAttribute.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index c3bb26966..945cd5d1e 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -64,20 +64,12 @@ public string? UsageText { /// Gets or sets the resource type to load localizable strings from. If , then no /// localization will occur. /// + /// is . public Type? ResourceType { get => _resourceType; set => _resourceType = value ?? throw new ArgumentNullException(nameof(value)); } - [return: NotNullIfNotNull("str")] - private string? GetResourceStringMaybe(string? str) { - if (str is null) { - return null; - } - - return _resourceType != null ? ResourceHelper.LoadResource(_resourceType, str) : str; - } - /// /// Initializes a new instance of the class with the specified qualified /// name. @@ -97,5 +89,14 @@ public CommandHandlerAttribute(string qualifiedName) { _qualifiedName = qualifiedName; } + + [return: NotNullIfNotNull("str")] + private string? GetResourceStringMaybe(string? str) { + if (str is null) { + return null; + } + + return _resourceType != null ? ResourceHelper.LoadResource(_resourceType, str) : str; + } } } From f32af9dd78024c41e1516be87d01a40d07b7a89c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 21:44:46 -0700 Subject: [PATCH 097/119] Implement extra functionality for help command. --- src/TShock/Commands/TShockCommandService.cs | 105 ++++++++++++-------- src/TShock/Properties/Resources.Designer.cs | 16 +-- src/TShock/Properties/Resources.resx | 16 +-- src/TShock/TShockPlugin.cs | 10 +- 4 files changed, 79 insertions(+), 68 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index fc8a24d92..a9de950c6 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -88,44 +88,17 @@ public void RegisterParser(IArgumentParser parser) => _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); public ICommand FindCommand(ref ReadOnlySpan input) { - string ProcessQualifiedName(ref ReadOnlySpan input) { - input = input.TrimStart(); - if (input.IsEmpty) { - throw new CommandParseException(Resources.CommandParse_MissingCommand); - } - - var space = input.IndexOfOrEnd(' '); - var maybeQualifiedName = input[..space].ToString(); - var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; - input = input[space..]; - if (isQualifiedName) { - return maybeQualifiedName; - } - - var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); - if (qualifiedNames.Count == 0) { - throw new CommandParseException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, - maybeQualifiedName)); - } - - if (qualifiedNames.Count > 1) { - throw new CommandParseException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_AmbiguousName, - maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); - } - - return qualifiedNames.Single(); - } - - var qualifiedName = ProcessQualifiedName(ref input); - if (!_commands.TryGetValue(qualifiedName, out var command)) { - throw new CommandParseException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, - qualifiedName)); + input = input.TrimStart(); + if (input.IsEmpty) { + throw new CommandParseException(Resources.CommandParse_MissingCommand); } - return command; + var space = input.IndexOfOrEnd(' '); + var maybeQualifiedName = input[..space].ToString(); + var qualifiedName = GetQualifiedName(maybeQualifiedName); + input = input[space..]; + Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); + return _commands[qualifiedName]; } public bool UnregisterCommand(ICommand command) { @@ -149,23 +122,40 @@ public bool UnregisterCommand(ICommand command) { _qualifiedNames[name].Remove(qualifiedName); return true; } - + [CommandHandler(nameof(Resources.Command_Help), HelpText = nameof(Resources.Command_Help_HelpText), UsageText = nameof(Resources.Command_Help_UsageText), ResourceType = typeof(Resources))] - public void Help(ICommandSender sender) { + public void Help(ICommandSender sender, string? command_name = null) { string FormatCommandName(ICommand command) { var qualifiedName = command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); + return _qualifiedNames[name].Count > 1 ? qualifiedName : name; + } - return "/" + (_qualifiedNames[name].Count > 1 ? qualifiedName : name); + if (command_name is null) { + sender.SendInfoMessage(Resources.Command_Help_Header); + sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{FormatCommandName(c)}"))); + return; } - sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(FormatCommandName))); + string qualifiedName; + try { + qualifiedName = GetQualifiedName(command_name); + } catch (CommandParseException ex) { + sender.SendErrorMessage(ex.Message); + return; + } + + Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); + var command = _commands[qualifiedName]; + var commandName = FormatCommandName(command); + var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName); + sender.SendInfoMessage($"/{commandName}:\n{command.HelpText}\n{usageText}"); } - + [CommandHandler(nameof(Resources.Command_Playing), HelpText = nameof(Resources.Command_Playing_HelpText), UsageText = nameof(Resources.Command_Playing_UsageText), @@ -195,7 +185,7 @@ public void Me(ICommandSender sender, [RestOfInput] string text) { new Color(0xc8, 0x64, 0x00)); Log.Information("*{Name} {Text}", sender.Name, text); } - + [CommandHandler(nameof(Resources.Command_Roll), HelpText = nameof(Resources.Command_Roll_HelpText), UsageText = nameof(Resources.Command_Roll_UsageText), @@ -207,7 +197,7 @@ public void Roll(ICommandSender sender) { new Color(0xff, 0xf0, 0x14)); Log.Information("*{Name} rolls a {Num}", sender.Name, num); } - + [CommandHandler(nameof(Resources.Command_P), HelpText = nameof(Resources.Command_P_HelpText), UsageText = nameof(Resources.Command_P_UsageText), @@ -232,5 +222,34 @@ public void P(ICommandSender sender, [RestOfInput] string text) { } Log.Information("<{Player} (to {Team} team)> {Text}", player.Name, team, text); } + + // Gets the qualified command name for a possibly-qualified command name. + private string GetQualifiedName(string maybeQualifiedName) { + var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; + if (isQualifiedName) { + if (!_commands.ContainsKey(maybeQualifiedName)) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, + maybeQualifiedName)); + } + + return maybeQualifiedName; + } + + var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); + if (qualifiedNames.Count == 0) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, + maybeQualifiedName)); + } + + if (qualifiedNames.Count > 1) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_AmbiguousName, + maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); + } + + return qualifiedNames.Single(); + } } } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 68f6761fb..4a78fb4eb 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -97,7 +97,7 @@ internal static string Command_Help_HelpText { } /// - /// Looks up a localized string similar to Usage: /help [command-name]. + /// Looks up a localized string similar to Usage: /{0} [command-name]. /// internal static string Command_Help_UsageText { get { @@ -106,7 +106,7 @@ internal static string Command_Help_UsageText { } /// - /// Looks up a localized string similar to tshock:me. + /// Looks up a localized string similar to me:help. /// internal static string Command_Me { get { @@ -133,7 +133,7 @@ internal static string Command_Me_Message { } /// - /// Looks up a localized string similar to Usage: /me <text>. + /// Looks up a localized string similar to Usage: /{0} <text>. /// internal static string Command_Me_UsageText { get { @@ -160,7 +160,7 @@ internal static string Command_P_HelpText { } /// - /// Looks up a localized string similar to Usage: /p <text>. + /// Looks up a localized string similar to Usage: /{0} <text>. /// internal static string Command_P_UsageText { get { @@ -223,7 +223,7 @@ internal static string Command_Playing_NoPlayers { } /// - /// Looks up a localized string similar to Usage: /playing [-i]. + /// Looks up a localized string similar to Usage: /{0} [-i]. /// internal static string Command_Playing_UsageText { get { @@ -259,7 +259,7 @@ internal static string Command_Roll_Message { } /// - /// Looks up a localized string similar to Usage: /roll. + /// Looks up a localized string similar to Usage: /{0}. /// internal static string Command_Roll_UsageText { get { @@ -304,7 +304,7 @@ internal static string CommandParse_MissingArg { } /// - /// Looks up a localized string similar to Missing command name.. + /// Looks up a localized string similar to Missing command name. Type /help for a list of commands.. /// internal static string CommandParse_MissingCommand { get { @@ -331,7 +331,7 @@ internal static string CommandParse_UnrecognizedArgType { } /// - /// Looks up a localized string similar to "/{0}" is an unrecognized command.. + /// Looks up a localized string similar to Command "/{0}" is not recognized. Type /help for a list of commands.. /// internal static string CommandParse_UnrecognizedCommand { get { diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 9cbf56436..12c8594ca 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -130,7 +130,7 @@ Missing argument "{0}". - Missing command name. + Missing command name. Type /help for a list of commands. Too many arguments were provided. @@ -139,7 +139,7 @@ Argument type "{0}" not recognized. - "/{0}" is an unrecognized command. + Command "/{0}" is not recognized. Type /help for a list of commands. Long flag "--{0}" not recognized. @@ -163,10 +163,10 @@ Shows available commands and provides information about specific commands. - Usage: /help [command-name] + Usage: /{0} [command-name] - tshock:me + me:help Sends a message in the third person. @@ -175,7 +175,7 @@ *{0} {1} - Usage: /me <text> + Usage: /{0} <text> tshock:p @@ -199,13 +199,13 @@ No players online. - Usage: /playing [-i] + Usage: /{0} [-i] Sends a message to your party members. - Usage: /p <text> + Usage: /{0} <text> tshock:roll @@ -217,7 +217,7 @@ *{0} rolls a {1} - Usage: /roll + Usage: /{0} "{0}" is not a valid value. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 4753199cb..57f286c8c 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -127,16 +127,8 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan input) { Log.Information("{Sender} is executing /{Command}", commandSender.Name, input.ToString()); - ICommand command; - try { - command = CommandService.FindCommand(ref input); - } catch (CommandParseException ex) { - commandSender.SendErrorMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Command_BadCommand, ex.Message)); - return; - } - try { + var command = CommandService.FindCommand(ref input); command.Invoke(commandSender, input); } catch (CommandParseException ex) { commandSender.SendErrorMessage(ex.Message); From 5851cf1c67a85bdb4b31643bd95666872d13bc69 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 21:48:14 -0700 Subject: [PATCH 098/119] Localize log messages. --- src/TShock/Commands/TShockCommandService.cs | 6 +-- src/TShock/Properties/Resources.Designer.cs | 45 ++++++++++++++++----- src/TShock/Properties/Resources.resx | 15 +++++-- src/TShock/TShockPlugin.cs | 2 +- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index a9de950c6..283882d85 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -183,7 +183,7 @@ public void Me(ICommandSender sender, [RestOfInput] string text) { PlayerService.BroadcastMessage( string.Format(CultureInfo.InvariantCulture, Resources.Command_Me_Message, sender.Name, text), new Color(0xc8, 0x64, 0x00)); - Log.Information("*{Name} {Text}", sender.Name, text); + Log.Information(Resources.Log_Command_Me_Message, sender.Name, text); } [CommandHandler(nameof(Resources.Command_Roll), @@ -195,7 +195,7 @@ public void Roll(ICommandSender sender) { PlayerService.BroadcastMessage( string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll_Message, sender.Name, num), new Color(0xff, 0xf0, 0x14)); - Log.Information("*{Name} rolls a {Num}", sender.Name, num); + Log.Information(Resources.Log_Command_Roll_Message, sender.Name, num); } [CommandHandler(nameof(Resources.Command_P), @@ -220,7 +220,7 @@ public void P(ICommandSender sender, [RestOfInput] string text) { foreach (var teamPlayer in teamPlayers) { teamPlayer.SendMessageFrom(player, text, teamColor); } - Log.Information("<{Player} (to {Team} team)> {Text}", player.Name, team, text); + Log.Information(Resources.Log_Command_P_Message, player.Name, team, text); } // Gets the qualified command name for a possibly-qualified command name. diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 4a78fb4eb..25a5e70b4 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -60,15 +60,6 @@ internal Resources() { } } - /// - /// Looks up a localized string similar to {0} Type /help for a list of commands.. - /// - internal static string Command_BadCommand { - get { - return ResourceManager.GetString("Command_BadCommand", resourceCulture); - } - } - /// /// Looks up a localized string similar to tshock:help. /// @@ -402,6 +393,42 @@ internal static string Int32Parser_InvalidInteger { } } + /// + /// Looks up a localized string similar to *{Name} {Text}. + /// + internal static string Log_Command_Me_Message { + get { + return ResourceManager.GetString("Log_Command_Me_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to <{Player} (to {Team} team)> {Text}. + /// + internal static string Log_Command_P_Message { + get { + return ResourceManager.GetString("Log_Command_P_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{Name} rolls a {Num}. + /// + internal static string Log_Command_Roll_Message { + get { + return ResourceManager.GetString("Log_Command_Roll_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {Sender} is executing command /{Command}. + /// + internal static string Log_ExecutingCommand { + get { + return ResourceManager.GetString("Log_ExecutingCommand", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid backslash.. /// diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 12c8594ca..f1a35ea50 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -150,9 +150,6 @@ Short flag "-{0}" not recognized. - - {0} Type /help for a list of commands. - tshock:help @@ -231,6 +228,18 @@ "{0}" is an invalid integer. + + *{Name} {Text} + + + <{Player} (to {Team} team)> {Text} + + + *{Name} rolls a {Num} + + + {Sender} is executing command /{Command} + Invalid backslash. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 57f286c8c..76164dfc3 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -125,7 +125,7 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { // Executes a command. input should not have the leading /. private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan input) { - Log.Information("{Sender} is executing /{Command}", commandSender.Name, input.ToString()); + Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input.ToString()); try { var command = CommandService.FindCommand(ref input); From 976777e43a074bdfb03a2b3559de5a3fd6b96b49 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 21:48:59 -0700 Subject: [PATCH 099/119] Undo test change. --- src/TShock/Properties/Resources.Designer.cs | 2 +- src/TShock/Properties/Resources.resx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 25a5e70b4..cfdbc4aec 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -97,7 +97,7 @@ internal static string Command_Help_UsageText { } /// - /// Looks up a localized string similar to me:help. + /// Looks up a localized string similar to tshock:me. /// internal static string Command_Me { get { diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index f1a35ea50..aabf262a1 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -163,7 +163,7 @@ Usage: /{0} [command-name] - me:help + tshock:me Sends a message in the third person. From e9756d70d311bf9225734af9b0204288b8cecdf6 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 23:03:39 -0700 Subject: [PATCH 100/119] Show usage text on command parsing errors. --- .../Commands/CommandNotFoundException.cs | 46 ++++++++++++++++++ src/TShock/Commands/ICommandService.cs | 11 +++-- src/TShock/Commands/TShockCommandService.cs | 45 ++++++++--------- src/TShock/Properties/Resources.Designer.cs | 48 +++++++++---------- src/TShock/Properties/Resources.resx | 18 +++---- src/TShock/TShockPlugin.cs | 15 +++++- .../Commands/TShockCommandServiceTests.cs | 15 ++---- 7 files changed, 124 insertions(+), 74 deletions(-) create mode 100644 src/TShock/Commands/CommandNotFoundException.cs diff --git a/src/TShock/Commands/CommandNotFoundException.cs b/src/TShock/Commands/CommandNotFoundException.cs new file mode 100644 index 000000000..05bfea31b --- /dev/null +++ b/src/TShock/Commands/CommandNotFoundException.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Commands { + /// + /// The exception thrown when a command could not be found. + /// + [ExcludeFromCodeCoverage] + public class CommandNotFoundException : Exception { + /// + /// Initializes a new instance of the class. + /// + public CommandNotFoundException() { } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message. + public CommandNotFoundException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message + /// and inner exception. + /// + /// The message. + /// The inner exception. + public CommandNotFoundException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 2b243d39d..4bf1207dc 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -73,13 +73,14 @@ public interface ICommandService : IService { void RegisterParser(IArgumentParser parser); /// - /// Finds and returns a command with the . A command name (possibly qualified) will be - /// extracted and tested from the . + /// Finds and returns the command with . The qualified name will be determined and + /// used, if necessary. /// - /// The input. + /// The command name. /// The command. - /// The command does not exist or is ambiguous. - ICommand FindCommand(ref ReadOnlySpan input); + /// is . + /// The command does not exist or is ambiguous. + ICommand FindCommand(string commandName); /// /// Unregisters the and returns a value indicating success. diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 283882d85..4336f39c2 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -87,16 +87,12 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { public void RegisterParser(IArgumentParser parser) => _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); - public ICommand FindCommand(ref ReadOnlySpan input) { - input = input.TrimStart(); - if (input.IsEmpty) { - throw new CommandParseException(Resources.CommandParse_MissingCommand); + public ICommand FindCommand(string commandName) { + if (string.IsNullOrEmpty(commandName)) { + throw new CommandNotFoundException(Resources.CommandFind_MissingCommand); } - var space = input.IndexOfOrEnd(' '); - var maybeQualifiedName = input[..space].ToString(); - var qualifiedName = GetQualifiedName(maybeQualifiedName); - input = input[space..]; + var qualifiedName = GetQualifiedName(commandName); Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); return _commands[qualifiedName]; } @@ -128,14 +124,14 @@ public bool UnregisterCommand(ICommand command) { UsageText = nameof(Resources.Command_Help_UsageText), ResourceType = typeof(Resources))] public void Help(ICommandSender sender, string? command_name = null) { - string FormatCommandName(ICommand command) { - var qualifiedName = command.QualifiedName; - var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); - return _qualifiedNames[name].Count > 1 ? qualifiedName : name; - } - if (command_name is null) { + string FormatCommandName(ICommand command) { + var qualifiedName = command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); + return _qualifiedNames[name].Count > 1 ? qualifiedName : name; + } + sender.SendInfoMessage(Resources.Command_Help_Header); sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{FormatCommandName(c)}"))); return; @@ -144,16 +140,15 @@ string FormatCommandName(ICommand command) { string qualifiedName; try { qualifiedName = GetQualifiedName(command_name); - } catch (CommandParseException ex) { + } catch (CommandNotFoundException ex) { sender.SendErrorMessage(ex.Message); return; } Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); var command = _commands[qualifiedName]; - var commandName = FormatCommandName(command); - var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName); - sender.SendInfoMessage($"/{commandName}:\n{command.HelpText}\n{usageText}"); + var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, command_name); + sender.SendInfoMessage($"/{command_name}:\n{command.HelpText}\n{usageText}"); } [CommandHandler(nameof(Resources.Command_Playing), @@ -228,8 +223,8 @@ private string GetQualifiedName(string maybeQualifiedName) { var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; if (isQualifiedName) { if (!_commands.ContainsKey(maybeQualifiedName)) { - throw new CommandParseException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandFind_UnrecognizedCommand, maybeQualifiedName)); } @@ -238,14 +233,14 @@ private string GetQualifiedName(string maybeQualifiedName) { var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); if (qualifiedNames.Count == 0) { - throw new CommandParseException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedCommand, + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandFind_UnrecognizedCommand, maybeQualifiedName)); } if (qualifiedNames.Count > 1) { - throw new CommandParseException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_AmbiguousName, + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandFind_AmbiguousName, maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index cfdbc4aec..542f11038 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -259,74 +259,74 @@ internal static string Command_Roll_UsageText { } /// - /// Looks up a localized string similar to Exception occurred while executing command.. + /// Looks up a localized string similar to "/{0}" is ambiguous and can refer to {1}.. /// - internal static string CommandInvoke_Exception { + internal static string CommandFind_AmbiguousName { get { - return ResourceManager.GetString("CommandInvoke_Exception", resourceCulture); + return ResourceManager.GetString("CommandFind_AmbiguousName", resourceCulture); } } /// - /// Looks up a localized string similar to "/{0}" is ambiguous and can refer to {1}.. + /// Looks up a localized string similar to Missing command name. Type /help for a list of commands.. /// - internal static string CommandParse_AmbiguousName { + internal static string CommandFind_MissingCommand { get { - return ResourceManager.GetString("CommandParse_AmbiguousName", resourceCulture); + return ResourceManager.GetString("CommandFind_MissingCommand", resourceCulture); } } /// - /// Looks up a localized string similar to Invalid hyphenated argument.. + /// Looks up a localized string similar to Command "/{0}" is not recognized. Type /help for a list of commands.. /// - internal static string CommandParse_InvalidHyphenatedArg { + internal static string CommandFind_UnrecognizedCommand { get { - return ResourceManager.GetString("CommandParse_InvalidHyphenatedArg", resourceCulture); + return ResourceManager.GetString("CommandFind_UnrecognizedCommand", resourceCulture); } } /// - /// Looks up a localized string similar to Missing argument "{0}".. + /// Looks up a localized string similar to Exception occurred while executing command.. /// - internal static string CommandParse_MissingArg { + internal static string CommandInvoke_Exception { get { - return ResourceManager.GetString("CommandParse_MissingArg", resourceCulture); + return ResourceManager.GetString("CommandInvoke_Exception", resourceCulture); } } /// - /// Looks up a localized string similar to Missing command name. Type /help for a list of commands.. + /// Looks up a localized string similar to Invalid hyphenated argument.. /// - internal static string CommandParse_MissingCommand { + internal static string CommandParse_InvalidHyphenatedArg { get { - return ResourceManager.GetString("CommandParse_MissingCommand", resourceCulture); + return ResourceManager.GetString("CommandParse_InvalidHyphenatedArg", resourceCulture); } } /// - /// Looks up a localized string similar to Too many arguments were provided.. + /// Looks up a localized string similar to Missing argument "{0}".. /// - internal static string CommandParse_TooManyArgs { + internal static string CommandParse_MissingArg { get { - return ResourceManager.GetString("CommandParse_TooManyArgs", resourceCulture); + return ResourceManager.GetString("CommandParse_MissingArg", resourceCulture); } } /// - /// Looks up a localized string similar to Argument type "{0}" not recognized.. + /// Looks up a localized string similar to Too many arguments were provided.. /// - internal static string CommandParse_UnrecognizedArgType { + internal static string CommandParse_TooManyArgs { get { - return ResourceManager.GetString("CommandParse_UnrecognizedArgType", resourceCulture); + return ResourceManager.GetString("CommandParse_TooManyArgs", resourceCulture); } } /// - /// Looks up a localized string similar to Command "/{0}" is not recognized. Type /help for a list of commands.. + /// Looks up a localized string similar to Argument type "{0}" not recognized.. /// - internal static string CommandParse_UnrecognizedCommand { + internal static string CommandParse_UnrecognizedArgType { get { - return ResourceManager.GetString("CommandParse_UnrecognizedCommand", resourceCulture); + return ResourceManager.GetString("CommandParse_UnrecognizedArgType", resourceCulture); } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index aabf262a1..dc6c9d8cb 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,30 +117,30 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + "/{0}" is ambiguous and can refer to {1}. + + + Missing command name. Type /help for a list of commands. + + + Command "/{0}" is not recognized. Type /help for a list of commands. + Exception occurred while executing command. - - "/{0}" is ambiguous and can refer to {1}. - Invalid hyphenated argument. Missing argument "{0}". - - Missing command name. Type /help for a list of commands. - Too many arguments were provided. Argument type "{0}" not recognized. - - Command "/{0}" is not recognized. Type /help for a list of commands. - Long flag "--{0}" not recognized. diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 76164dfc3..8abffefe0 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -27,6 +27,7 @@ using Serilog; using TShock.Commands; using TShock.Properties; +using TShock.Utils.Extensions; namespace TShock { /// @@ -127,11 +128,23 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan input) { Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input.ToString()); + var space = input.IndexOfOrEnd(' '); + var commandName = input[..space].ToString(); + ICommand command; + try { + command = CommandService.FindCommand(commandName); + } catch (CommandNotFoundException ex) { + commandSender.SendErrorMessage(ex.Message); + return; + } + + input = input[space..]; try { - var command = CommandService.FindCommand(ref input); command.Invoke(commandSender, input); } catch (CommandParseException ex) { commandSender.SendErrorMessage(ex.Message); + commandSender.SendInfoMessage( + string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName)); } catch (CommandExecuteException ex) { commandSender.SendErrorMessage(ex.Message); } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 95134648d..2e1287d7f 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -88,8 +88,7 @@ public void FindCommand_WithoutNamespace() { _commandService.RegisterCommands(testClass).ToList(); var command = _commandService.Commands["tshock_tests:test2"]; - var input = "test2".AsSpan(); - _commandService.FindCommand(ref input).Should().BeSameAs(command); + _commandService.FindCommand("test2").Should().BeSameAs(command); } [Fact] @@ -98,8 +97,7 @@ public void FindCommand_WithNamespace() { _commandService.RegisterCommands(testClass).ToList(); var command = _commandService.Commands["tshock_tests:test"]; - var input = "tshock_tests:test".AsSpan(); - _commandService.FindCommand(ref input).Should().BeSameAs(command); + _commandService.FindCommand("tshock_tests:test").Should().BeSameAs(command); } [Theory] @@ -107,15 +105,12 @@ public void FindCommand_WithNamespace() { [InlineData("test")] [InlineData("test3")] [InlineData("tshock_tests:test3")] - public void FindCommand_InvalidCommand_ThrowsCommandParseException(string inputString) { + public void FindCommand_InvalidCommand_ThrowsCommandNotFoundException(string inputString) { var testClass = new TestClass(); _commandService.RegisterCommands(testClass).ToList(); - Action action = () => { - var input = inputString.AsSpan(); - _commandService.FindCommand(ref input); - }; + Action action = () => _commandService.FindCommand(inputString); - action.Should().Throw(); + action.Should().Throw(); } [Fact] From d9b549d07219066afc8b822244a372a9e62c96d4 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 23:04:27 -0700 Subject: [PATCH 101/119] Move exceptions into the Exceptions namespace. --- src/TShock/Commands/{ => Exceptions}/CommandExecuteException.cs | 2 +- .../Commands/{ => Exceptions}/CommandNotFoundException.cs | 2 +- src/TShock/Commands/{ => Exceptions}/CommandParseException.cs | 2 +- src/TShock/Commands/ICommand.cs | 1 + src/TShock/Commands/ICommandService.cs | 1 + src/TShock/Commands/Parsers/IArgumentParser.cs | 1 + src/TShock/Commands/Parsers/Int32Parser.cs | 1 + src/TShock/Commands/Parsers/StringParser.cs | 1 + src/TShock/Commands/TShockCommand.cs | 1 + src/TShock/Commands/TShockCommandService.cs | 1 + src/TShock/TShockPlugin.cs | 1 + tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs | 1 + tests/TShock.Tests/Commands/Parsers/StringParserTests.cs | 1 + tests/TShock.Tests/Commands/TShockCommandServiceTests.cs | 1 + tests/TShock.Tests/Commands/TShockCommandTests.cs | 1 + 15 files changed, 15 insertions(+), 3 deletions(-) rename src/TShock/Commands/{ => Exceptions}/CommandExecuteException.cs (97%) rename src/TShock/Commands/{ => Exceptions}/CommandNotFoundException.cs (97%) rename src/TShock/Commands/{ => Exceptions}/CommandParseException.cs (97%) diff --git a/src/TShock/Commands/CommandExecuteException.cs b/src/TShock/Commands/Exceptions/CommandExecuteException.cs similarity index 97% rename from src/TShock/Commands/CommandExecuteException.cs rename to src/TShock/Commands/Exceptions/CommandExecuteException.cs index 10d3913f2..2d2b4c2cc 100644 --- a/src/TShock/Commands/CommandExecuteException.cs +++ b/src/TShock/Commands/Exceptions/CommandExecuteException.cs @@ -18,7 +18,7 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace TShock.Commands { +namespace TShock.Commands.Exceptions { /// /// The exception thrown when a command could not be executed. /// diff --git a/src/TShock/Commands/CommandNotFoundException.cs b/src/TShock/Commands/Exceptions/CommandNotFoundException.cs similarity index 97% rename from src/TShock/Commands/CommandNotFoundException.cs rename to src/TShock/Commands/Exceptions/CommandNotFoundException.cs index 05bfea31b..5d58b2f25 100644 --- a/src/TShock/Commands/CommandNotFoundException.cs +++ b/src/TShock/Commands/Exceptions/CommandNotFoundException.cs @@ -18,7 +18,7 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace TShock.Commands { +namespace TShock.Commands.Exceptions { /// /// The exception thrown when a command could not be found. /// diff --git a/src/TShock/Commands/CommandParseException.cs b/src/TShock/Commands/Exceptions/CommandParseException.cs similarity index 97% rename from src/TShock/Commands/CommandParseException.cs rename to src/TShock/Commands/Exceptions/CommandParseException.cs index e75ea1e3a..31d9be897 100644 --- a/src/TShock/Commands/CommandParseException.cs +++ b/src/TShock/Commands/Exceptions/CommandParseException.cs @@ -18,7 +18,7 @@ using System; using System.Diagnostics.CodeAnalysis; -namespace TShock.Commands { +namespace TShock.Commands.Exceptions { /// /// The exception thrown when a command input cannot be parsed. /// diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index 077ac691d..e1df2ebf0 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -17,6 +17,7 @@ using System; using System.Reflection; +using TShock.Commands.Exceptions; namespace TShock.Commands { /// diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 4bf1207dc..2eb3c1fa4 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using Orion; using Orion.Events; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using TShock.Events.Commands; diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index 5e935fbb6..fe8d40cd1 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using TShock.Commands.Exceptions; namespace TShock.Commands.Parsers { /// diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs index 44600091c..4e5755f98 100644 --- a/src/TShock/Commands/Parsers/Int32Parser.cs +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using TShock.Commands.Exceptions; using TShock.Properties; using TShock.Utils.Extensions; diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs index 8662e24ea..cb48057fb 100644 --- a/src/TShock/Commands/Parsers/StringParser.cs +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -20,6 +20,7 @@ using System.Globalization; using System.Linq; using System.Text; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers.Attributes; using TShock.Properties; diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 4a4b681ba..d0dc3ad40 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -22,6 +22,7 @@ using System.Linq; using System.Reflection; using Orion.Events; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; using TShock.Properties; diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 4336f39c2..48c5e32c8 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -26,6 +26,7 @@ using Orion.Events; using Orion.Players; using Serilog; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 8abffefe0..9fbe09e6a 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -26,6 +26,7 @@ using Orion.Players; using Serilog; using TShock.Commands; +using TShock.Commands.Exceptions; using TShock.Properties; using TShock.Utils.Extensions; diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs index f9d138fa0..ff9751c2c 100644 --- a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using FluentAssertions; +using TShock.Commands.Exceptions; using Xunit; namespace TShock.Commands.Parsers { diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs index 0e22328cb..bad2e4ac6 100644 --- a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using FluentAssertions; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers.Attributes; using Xunit; diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 2e1287d7f..c4b8809e4 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -22,6 +22,7 @@ using Moq; using Orion.Events; using Orion.Players; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using Xunit; diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 74f12d5ba..b881fe61d 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -22,6 +22,7 @@ using FluentAssertions; using Moq; using Orion.Events; +using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; From c70dd79855f871c68149e7bc581f3c91a63534ef Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Tue, 8 Oct 2019 23:38:38 -0700 Subject: [PATCH 102/119] Implement DoubleParser. --- src/TShock/Commands/Parsers/DoubleParser.cs | 47 +++++++++++++ src/TShock/Commands/TShockCommand.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 11 +-- src/TShock/Properties/Resources.Designer.cs | 39 +++++------ src/TShock/Properties/Resources.resx | 19 +++--- .../Commands/Parsers/DoubleParserTests.cs | 67 +++++++++++++++++++ .../Commands/TShockCommandServiceTests.cs | 2 + 7 files changed, 147 insertions(+), 40 deletions(-) create mode 100644 src/TShock/Commands/Parsers/DoubleParser.cs create mode 100644 tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs diff --git a/src/TShock/Commands/Parsers/DoubleParser.cs b/src/TShock/Commands/Parsers/DoubleParser.cs new file mode 100644 index 000000000..4e1abf715 --- /dev/null +++ b/src/TShock/Commands/Parsers/DoubleParser.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using TShock.Commands.Exceptions; +using TShock.Properties; +using TShock.Utils.Extensions; + +namespace TShock.Commands.Parsers { + /// + /// Parses a Double. + /// + public sealed class DoubleParser : IArgumentParser { + /// + public double Parse(ref ReadOnlySpan input, ISet attributes) { + var end = input.IndexOfOrEnd(' '); + var parse = input[..end]; + + // Calling Parse here instead of TryParse allows us to give better error messages. + try { + var value = double.Parse(parse, NumberStyles.Float, CultureInfo.InvariantCulture); + input = input[end..]; + return value; + } catch (FormatException ex) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.DoubleParser_InvalidDouble, + parse.ToString()), ex); + } + } + } +} diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index d0dc3ad40..47cfcc0ff 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -267,7 +267,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { try { Handler.Invoke(HandlerObject, _parameters); } catch (TargetInvocationException ex) { - throw new CommandExecuteException(Resources.CommandInvoke_Exception, ex.InnerException); + throw new CommandExecuteException(Resources.CommandExecute_Exception, ex.InnerException); } } } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 48c5e32c8..df7b77393 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -54,6 +54,7 @@ public TShockCommandService(Lazy playerService) { _playerService = playerService; RegisterParser(new Int32Parser()); + RegisterParser(new DoubleParser()); RegisterParser(new StringParser()); RegisterCommands(this); } @@ -90,7 +91,7 @@ public void RegisterParser(IArgumentParser parser) => public ICommand FindCommand(string commandName) { if (string.IsNullOrEmpty(commandName)) { - throw new CommandNotFoundException(Resources.CommandFind_MissingCommand); + throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); } var qualifiedName = GetQualifiedName(commandName); @@ -120,6 +121,8 @@ public bool UnregisterCommand(ICommand command) { return true; } + // TODO: write tests for /help, /playing, /me, /roll, /p + [CommandHandler(nameof(Resources.Command_Help), HelpText = nameof(Resources.Command_Help_HelpText), UsageText = nameof(Resources.Command_Help_UsageText), @@ -225,7 +228,7 @@ private string GetQualifiedName(string maybeQualifiedName) { if (isQualifiedName) { if (!_commands.ContainsKey(maybeQualifiedName)) { throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandFind_UnrecognizedCommand, + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, maybeQualifiedName)); } @@ -235,13 +238,13 @@ private string GetQualifiedName(string maybeQualifiedName) { var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); if (qualifiedNames.Count == 0) { throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandFind_UnrecognizedCommand, + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, maybeQualifiedName)); } if (qualifiedNames.Count > 1) { throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandFind_AmbiguousName, + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_AmbiguousName, maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 542f11038..54364e605 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -259,38 +259,38 @@ internal static string Command_Roll_UsageText { } /// - /// Looks up a localized string similar to "/{0}" is ambiguous and can refer to {1}.. + /// Looks up a localized string similar to Exception occurred while executing command.. /// - internal static string CommandFind_AmbiguousName { + internal static string CommandExecute_Exception { get { - return ResourceManager.GetString("CommandFind_AmbiguousName", resourceCulture); + return ResourceManager.GetString("CommandExecute_Exception", resourceCulture); } } /// - /// Looks up a localized string similar to Missing command name. Type /help for a list of commands.. + /// Looks up a localized string similar to "/{0}" is ambiguous and can refer to {1}.. /// - internal static string CommandFind_MissingCommand { + internal static string CommandNotFound_AmbiguousName { get { - return ResourceManager.GetString("CommandFind_MissingCommand", resourceCulture); + return ResourceManager.GetString("CommandNotFound_AmbiguousName", resourceCulture); } } /// - /// Looks up a localized string similar to Command "/{0}" is not recognized. Type /help for a list of commands.. + /// Looks up a localized string similar to Missing command name. Type /help for a list of commands.. /// - internal static string CommandFind_UnrecognizedCommand { + internal static string CommandNotFound_MissingCommand { get { - return ResourceManager.GetString("CommandFind_UnrecognizedCommand", resourceCulture); + return ResourceManager.GetString("CommandNotFound_MissingCommand", resourceCulture); } } /// - /// Looks up a localized string similar to Exception occurred while executing command.. + /// Looks up a localized string similar to Command "/{0}" is not recognized. Type /help for a list of commands.. /// - internal static string CommandInvoke_Exception { + internal static string CommandNotFound_UnrecognizedCommand { get { - return ResourceManager.GetString("CommandInvoke_Exception", resourceCulture); + return ResourceManager.GetString("CommandNotFound_UnrecognizedCommand", resourceCulture); } } @@ -358,20 +358,11 @@ internal static string CommandParse_UnrecognizedShortFlag { } /// - /// Looks up a localized string similar to "{0}" is not a valid value.. - /// - internal static string EnumParser_InvalidInteger { - get { - return ResourceManager.GetString("EnumParser_InvalidInteger", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to "{0}" is not a valid value.. + /// Looks up a localized string similar to "{0}" is an invalid number.. /// - internal static string EnumParser_InvalidString { + internal static string DoubleParser_InvalidDouble { get { - return ResourceManager.GetString("EnumParser_InvalidString", resourceCulture); + return ResourceManager.GetString("DoubleParser_InvalidDouble", resourceCulture); } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index dc6c9d8cb..0f2bfea0d 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,18 +117,18 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + + Exception occurred while executing command. + + "/{0}" is ambiguous and can refer to {1}. - + Missing command name. Type /help for a list of commands. - + Command "/{0}" is not recognized. Type /help for a list of commands. - - Exception occurred while executing command. - Invalid hyphenated argument. @@ -216,11 +216,8 @@ Usage: /{0} - - "{0}" is not a valid value. - - - "{0}" is not a valid value. + + "{0}" is an invalid number. "{0}" is a number that is out of range of an integer. diff --git a/tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs b/tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs new file mode 100644 index 000000000..f05b5d701 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using TShock.Commands.Exceptions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class DoubleParserTests { + [Theory] + [InlineData("1.234", 1.234, "")] + [InlineData("+1.234", 1.234, "")] + [InlineData("000", 0, "")] + [InlineData("-1.234", -1.234, "")] + [InlineData("1.23 test", 1.23, " test")] + [InlineData("1E6", 1E6, "")] + public void Parse(string inputString, double expected, string expectedNextInput) { + var parser = new DoubleParser(); + var input = inputString.AsSpan(); + + parser.Parse(ref input, new HashSet()).Should().Be(expected); + + input.ToString().Should().Be(expectedNextInput); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_MissingDouble_ThrowsParseException(string inputString) { + var parser = new DoubleParser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + + func.Should().Throw(); + } + + [Theory] + [InlineData("aaa")] + [InlineData("123a")] + public void Parse_InvalidDouble_ThrowsParseException(string inputString) { + var parser = new DoubleParser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + func.Should().Throw().WithInnerException(); + } + } +} diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index c4b8809e4..adb440942 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -21,7 +21,9 @@ using FluentAssertions; using Moq; using Orion.Events; +using Orion.Packets.World; using Orion.Players; +using Orion.Utils; using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using Xunit; From 04f761f30fe783b4a664bfecd48f0f53ecf3b2d5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 00:22:55 -0700 Subject: [PATCH 103/119] Make TShockCommandService thread-safe. --- src/TShock/Commands/ICommand.cs | 3 +- src/TShock/Commands/ICommandService.cs | 15 ++-- src/TShock/Commands/TShockCommandService.cs | 83 ++++++++++++------- src/TShock/TShockPlugin.cs | 8 +- .../Commands/TShockCommandServiceTests.cs | 16 ++-- .../Commands/TShockCommandTests.cs | 12 +-- 6 files changed, 80 insertions(+), 57 deletions(-) diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index e1df2ebf0..e5a6b7e79 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -21,7 +21,8 @@ namespace TShock.Commands { /// - /// Represents a command. Commands can be executed by command senders, and provide bits of functionality. + /// Represents a command. Commands can be executed by command senders, and provide bits of functionality. This class + /// is not thread-safe. /// public interface ICommand { /// diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 2eb3c1fa4..7fa3bdeb0 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -25,7 +25,8 @@ namespace TShock.Commands { /// - /// Represents a service that manages commands. Provides command-related hooks and methods. + /// Represents a service that manages commands. Provides command-related hooks and methods, and in a thread-safe + /// manner unless specified otherwise. /// public interface ICommandService : IService { /// @@ -39,19 +40,19 @@ public interface ICommandService : IService { IReadOnlyDictionary Parsers { get; } /// - /// Gets or sets the event handlers that occur when registering a command. This event can be canceled. + /// Gets the event handlers that occur when registering a command. This event can be canceled. /// - EventHandlerCollection? CommandRegister { get; set; } + EventHandlerCollection CommandRegister { get; } /// - /// Gets or sets the event handlers that occur when executing a command. This event can be canceled. + /// Gets the event handlers that occur when executing a command. This event can be canceled. /// - EventHandlerCollection? CommandExecute { get; set; } + EventHandlerCollection CommandExecute { get; } /// - /// Gets or sets the event handlers that occur when unregistering a command. This event can be canceled. + /// Gets the event handlers that occur when unregistering a command. This event can be canceled. /// - EventHandlerCollection? CommandUnregister { get; set; } + EventHandlerCollection CommandUnregister { get; } /// /// Registers and returns the commands defined with the 's command handlers. diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index df7b77393..4c21738b7 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -35,6 +35,7 @@ namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { + private readonly object _lock = new object(); private readonly Lazy _playerService; private readonly Dictionary _commands = new Dictionary(); @@ -44,9 +45,15 @@ internal sealed class TShockCommandService : OrionService, ICommandService { public IReadOnlyDictionary Commands => _commands; public IReadOnlyDictionary Parsers => _parsers; - public EventHandlerCollection? CommandRegister { get; set; } - public EventHandlerCollection? CommandExecute { get; set; } - public EventHandlerCollection? CommandUnregister { get; set; } + + public EventHandlerCollection CommandRegister { get; } + = new EventHandlerCollection(); + + public EventHandlerCollection CommandExecute { get; } + = new EventHandlerCollection(); + + public EventHandlerCollection CommandUnregister { get; } + = new EventHandlerCollection(); private IPlayerService PlayerService => _playerService.Value; @@ -80,23 +87,30 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { return command; } - return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) - .SelectMany(m => m.GetCustomAttributes(), - (m, a) => RegisterCommand(new TShockCommand(this, a, handlerObject, m))) - .Where(c => c != null).ToList()!; + lock (_lock) { + return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SelectMany(m => m.GetCustomAttributes(), + (m, a) => RegisterCommand(new TShockCommand(this, a, handlerObject, m))) + .Where(c => c != null).ToList()!; + } } - public void RegisterParser(IArgumentParser parser) => - _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); + public void RegisterParser(IArgumentParser parser) { + lock (_lock) { + _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); + } + } public ICommand FindCommand(string commandName) { if (string.IsNullOrEmpty(commandName)) { throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); } - var qualifiedName = GetQualifiedName(commandName); - Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); - return _commands[qualifiedName]; + lock (_lock) { + var qualifiedName = GetQualifiedName(commandName); + Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); + return _commands[qualifiedName]; + } } public bool UnregisterCommand(ICommand command) { @@ -110,15 +124,17 @@ public bool UnregisterCommand(ICommand command) { return false; } - var qualifiedName = command.QualifiedName; - if (!_commands.Remove(qualifiedName)) { - return false; - } + lock (_lock) { + var qualifiedName = command.QualifiedName; + if (!_commands.Remove(qualifiedName)) { + return false; + } - var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); - _qualifiedNames[name].Remove(qualifiedName); - return true; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); + _qualifiedNames[name].Remove(qualifiedName); + return true; + } } // TODO: write tests for /help, /playing, /me, /roll, /p @@ -135,22 +151,27 @@ string FormatCommandName(ICommand command) { Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); return _qualifiedNames[name].Count > 1 ? qualifiedName : name; } - + sender.SendInfoMessage(Resources.Command_Help_Header); - sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{FormatCommandName(c)}"))); + lock (_lock) { + sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{FormatCommandName(c)}"))); + } return; } - string qualifiedName; - try { - qualifiedName = GetQualifiedName(command_name); - } catch (CommandNotFoundException ex) { - sender.SendErrorMessage(ex.Message); - return; - } + ICommand command; + lock (_lock) { + string qualifiedName; + try { + qualifiedName = GetQualifiedName(command_name); + } catch (CommandNotFoundException ex) { + sender.SendErrorMessage(ex.Message); + return; + } - Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); - var command = _commands[qualifiedName]; + Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); + command = _commands[qualifiedName]; + } var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, command_name); sender.SendInfoMessage($"/{command_name}:\n{command.HelpText}\n{usageText}"); } diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 9fbe09e6a..537902cef 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -76,9 +76,9 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService, /// protected override void Initialize() { - Kernel.ServerCommand += ServerCommandHandler; + Kernel.ServerCommand.RegisterHandler(ServerCommandHandler); - PlayerService.PlayerChat += PlayerChatHandler; + PlayerService.PlayerChat.RegisterHandler(PlayerChatHandler); } /// @@ -87,9 +87,9 @@ protected override void Dispose(bool disposeManaged) { return; } - Kernel.ServerCommand -= ServerCommandHandler; + Kernel.ServerCommand.UnregisterHandler(ServerCommandHandler); - PlayerService.PlayerChat -= PlayerChatHandler; + PlayerService.PlayerChat.UnregisterHandler(PlayerChatHandler); } [EventHandler(EventPriority.Lowest)] diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index adb440942..d8b92f5d3 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -147,12 +147,12 @@ public void UnregisterCommand_NullCommand_ThrowsArgumentNullException() { public void CommandRegister_IsTriggered() { var isRun = false; var testClass = new TestClass(); - _commandService.CommandRegister += (sender, args) => { + _commandService.CommandRegister.RegisterHandler((sender, args) => { isRun = true; args.Command.HandlerObject.Should().BeSameAs(testClass); args.Command.QualifiedName.Should().BeOneOf( "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); - }; + }); _commandService.RegisterCommands(testClass); @@ -162,9 +162,9 @@ public void CommandRegister_IsTriggered() { [Fact] public void CommandRegister_Canceled() { var testClass = new TestClass(); - _commandService.CommandRegister += (sender, args) => { + _commandService.CommandRegister.RegisterHandler((sender, args) => { args.Cancel(); - }; + }); _commandService.RegisterCommands(testClass).Should().BeEmpty(); } @@ -175,10 +175,10 @@ public void CommandUnregister_IsTriggered() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; - _commandService.CommandUnregister += (sender, args) => { + _commandService.CommandUnregister.RegisterHandler((sender, args) => { isRun = true; args.Command.Should().BeSameAs(command); - }; + }); _commandService.UnregisterCommand(command); @@ -190,9 +190,9 @@ public void CommandUnregister_Canceled() { var testClass = new TestClass(); var commands = _commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; - _commandService.CommandUnregister += (sender, args) => { + _commandService.CommandUnregister.RegisterHandler((sender, args) => { args.Cancel(); - }; + }); _commandService.UnregisterCommand(command).Should().BeFalse(); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index b881fe61d..240d4618a 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -179,12 +179,12 @@ public void Invoke_TriggersCommandExecute() { var command = GetCommand(testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; var isRun = false; - EventHandlerCollection commandExecute = null; - commandExecute += (sender, args) => { + var commandExecute = new EventHandlerCollection(); + commandExecute.RegisterHandler((sender, args) => { isRun = true; args.Command.Should().Be(command); args.Input.Should().BeEmpty(); - }; + }); _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); command.Invoke(commandSender, ""); @@ -198,10 +198,10 @@ public void Invoke_CommandExecuteCanceled_IsCanceled() { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; - EventHandlerCollection commandExecute = null; - commandExecute += (sender, args) => { + var commandExecute = new EventHandlerCollection(); + commandExecute.RegisterHandler((sender, args) => { args.Cancel(); - }; + }); _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); command.Invoke(commandSender, "failing input"); From 930446016d3e427bcd8cbd405eb479426a76787a Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 00:48:08 -0700 Subject: [PATCH 104/119] Improve GetValueOrDefault. --- src/TShock/Commands/TShockCommandService.cs | 4 ++-- .../Utils/Extensions/DictionaryExtensions.cs | 21 ++++++++++++------- .../Extensions/DictionaryExtensionsTests.cs | 20 ++++++++++++------ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 4c21738b7..65e9559de 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -80,8 +80,8 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { var qualifiedName = command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - _qualifiedNames[name] = _qualifiedNames.GetValueOrDefault(name, () => new HashSet()); - _qualifiedNames[name].Add(qualifiedName); + var qualifiedNames = _qualifiedNames.GetValueOrDefault(name, () => new HashSet(), true); + qualifiedNames.Add(qualifiedName); _commands.Add(qualifiedName, command); return command; diff --git a/src/TShock/Utils/Extensions/DictionaryExtensions.cs b/src/TShock/Utils/Extensions/DictionaryExtensions.cs index 4f8d8d0c7..07b2d645c 100644 --- a/src/TShock/Utils/Extensions/DictionaryExtensions.cs +++ b/src/TShock/Utils/Extensions/DictionaryExtensions.cs @@ -24,14 +24,20 @@ namespace TShock.Utils.Extensions { /// public static class DictionaryExtensions { /// - /// Gets the value corresponding to the given , using the default value provider if - /// does not exist. + /// Gets the value corresponding to the given , using the + /// if does not exist. /// /// The type of key. /// The type of value. /// The dictionary. /// The key. - /// The value provider. + /// + /// The default value provider. If , then the provider will return a default instance of + /// . + /// + /// + /// to create the annotation if it does not exist; otherwise, . + /// /// /// The value, or a default instance provided by if /// does not exist in the dictionary. @@ -40,16 +46,17 @@ public static class DictionaryExtensions { /// or are . /// public static TValue GetValueOrDefault(this IDictionary dictionary, TKey key, - Func defaultValueProvider) { + Func? defaultValueProvider = null, bool createIfNotExists = false) { if (dictionary is null) { throw new ArgumentNullException(nameof(dictionary)); } - if (defaultValueProvider is null) { - throw new ArgumentNullException(nameof(defaultValueProvider)); + if (dictionary.TryGetValue(key, out var value)) { + return value; } - return dictionary.TryGetValue(key, out var value) ? value : defaultValueProvider(); + var provider = defaultValueProvider ?? (() => default!); + return createIfNotExists ? (TValue)(dictionary[key] = provider())! : provider(); } } } diff --git a/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs index 0ebd7bcf6..cf920ffdb 100644 --- a/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs +++ b/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs @@ -22,6 +22,13 @@ namespace TShock.Utils.Extensions { public class DictionaryExtensionsTests { + [Fact] + public void GetValueOrDefault() { + var dictionary = new Dictionary(); + + dictionary.GetValueOrDefault("test").Should().Be(0); + } + [Fact] public void GetValueOrDefault_KeyExists() { var dictionary = new Dictionary { @@ -39,16 +46,17 @@ public void GetValueOrDefault_KeyDoesntExist() { } [Fact] - public void GetValueOrDefault_NullDictionary_ThrowsArgumentNullException() { - Func func = () => DictionaryExtensions.GetValueOrDefault(null, "", () => 0); + public void GetValueOrDefault_Create() { + var dictionary = new Dictionary(); - func.Should().Throw(); + dictionary.GetValueOrDefault("test", () => 50, true).Should().Be(50); + + dictionary["test"].Should().Be(50); } [Fact] - public void GetValueOrDefault_NullValueProvider_ThrowsArgumentNullException() { - var dictionary = new Dictionary(); - Func func = () => DictionaryExtensions.GetValueOrDefault(dictionary, "", null); + public void GetValueOrDefault_NullDictionary_ThrowsArgumentNullException() { + Func func = () => DictionaryExtensions.GetValueOrDefault(null, "", () => 0); func.Should().Throw(); } From 13bb612683d0002f1a53227be92fd5d5e709b3db Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 00:53:13 -0700 Subject: [PATCH 105/119] Persist the PlayerCommandSender. --- src/TShock/TShockPlugin.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 537902cef..345c76326 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -121,7 +121,9 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { args.Cancel("tshock: command executing"); var input = chat.Substring(1); - ExecuteCommand(new PlayerCommandSender(args.Player), input); + ICommandSender commandSender = args.Player.GetAnnotationOrDefault("tshock:CommandSender", + () => new PlayerCommandSender(args.Player), true); + ExecuteCommand(commandSender, input); } } @@ -139,9 +141,8 @@ private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan inp return; } - input = input[space..]; try { - command.Invoke(commandSender, input); + command.Invoke(commandSender, input[space..]); } catch (CommandParseException ex) { commandSender.SendErrorMessage(ex.Message); commandSender.SendInfoMessage( From a6a6a2f3d1331907326214797b81ebf94545c816 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 00:58:06 -0700 Subject: [PATCH 106/119] Strip leading / from /help argument. --- src/TShock/Commands/TShockCommandService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 65e9559de..13eecaace 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -159,6 +159,10 @@ string FormatCommandName(ICommand command) { return; } + if (command_name.StartsWith('/')) { + command_name = command_name.Substring(1); + } + ICommand command; lock (_lock) { string qualifiedName; From f1737f11f8e99877c9696f883c5144213ba56be4 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 19:08:20 -0700 Subject: [PATCH 107/119] Switch to string and ReadOnlySpan when necessary. --- src/TShock/Commands/ConsoleCommandSender.cs | 17 ++++++++--------- src/TShock/Commands/ICommand.cs | 2 +- src/TShock/Commands/TShockCommand.cs | 16 ++++++++++------ src/TShock/TShockPlugin.cs | 13 ++++++++----- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 2b7dbb078..5622c4a26 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -36,7 +36,7 @@ public sealed class ConsoleCommandSender : ICommandSender { private const LogEventLevel LogLevel = LogEventLevel.Error; #endif private const string ResetColorString = "\x1b[0m"; - private const string ColorTagPrefix = "c/"; + private const string ColorTag = "c/"; /// /// Gets the console-based command sender. @@ -71,10 +71,10 @@ private ConsoleCommandSender() { /// public void SendMessage(string message, Color color) => SendMessageImpl(message, GetColorString(color)); - private static void SendMessageImpl(string messageString, string colorString) { - var message = messageString.AsSpan(); - var output = new StringBuilder(colorString); + private static void SendMessageImpl(ReadOnlySpan message, string colorString) { + var output = new StringBuilder(message.Length); while (true) { + output.Append(colorString); var leftBracket = message.IndexOf('['); var rightBracket = leftBracket + 1 + message[(leftBracket + 1)..].IndexOf(']'); if (leftBracket < 0 || rightBracket < 0) { @@ -85,21 +85,20 @@ private static void SendMessageImpl(string messageString, string colorString) { var inside = message[(leftBracket + 1)..rightBracket]; message = message[(rightBracket + 1)..]; var colon = inside.IndexOf(':'); - var isValidColorTag = inside.StartsWith(ColorTagPrefix, StringComparison.OrdinalIgnoreCase) && - colon > ColorTagPrefix.Length; + var isValidColorTag = + inside.StartsWith(ColorTag, StringComparison.OrdinalIgnoreCase) && colon > ColorTag.Length; if (!isValidColorTag) { output.Append('[').Append(inside).Append(']'); continue; } - if (int.TryParse(inside[ColorTagPrefix.Length..colon], NumberStyles.AllowHexSpecifier, - CultureInfo.InvariantCulture, out var numberColor)) { + if (int.TryParse(inside[ColorTag.Length..colon], NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, out var numberColor)) { var tagColor = new Color((numberColor >> 16) & 255, (numberColor >> 8) & 255, numberColor & 255); output.Append(GetColorString(tagColor)); } output.Append(inside[(colon + 1)..]); - output.Append(colorString); } output.Append(message).Append(ResetColorString); diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index e5a6b7e79..b364eac94 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -60,6 +60,6 @@ public interface ICommand { /// is . /// The command could not be executed. /// The command input could not be parsed. - void Invoke(ICommandSender sender, ReadOnlySpan input); + void Invoke(ICommandSender sender, string input); } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 47cfcc0ff..3da4ecf93 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -34,6 +34,7 @@ internal class TShockCommand : ICommand { private readonly ISet _validShortFlags = new HashSet(); private readonly ISet _validLongFlags = new HashSet(); private readonly IDictionary _validOptionals = new Dictionary(); + private readonly bool _shouldParseHyphenatedArguments; private readonly ParameterInfo[] _parameterInfos; private readonly object?[] _parameters; @@ -81,6 +82,8 @@ void PreprocessParameter(ParameterInfo parameterInfo) { } } + _shouldParseHyphenatedArguments = + _validShortFlags.Count + _validLongFlags.Count + _validOptionals.Count > 0; _parameterInfos = Handler.GetParameters(); _parameters = new object?[_parameterInfos.Length]; foreach (var parameter in _parameterInfos) { @@ -88,12 +91,12 @@ void PreprocessParameter(ParameterInfo parameterInfo) { } } - public void Invoke(ICommandSender sender, ReadOnlySpan input) { + public void Invoke(ICommandSender sender, string input) { if (sender is null) { throw new ArgumentNullException(nameof(sender)); } - var args = new CommandExecuteEventArgs(this, sender, input.ToString()); + var args = new CommandExecuteEventArgs(this, sender, input); _commandService.CommandExecute?.Invoke(this, args); if (args.IsCanceled()) { return; @@ -251,16 +254,17 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { return ParseArgument(ref input, parameterInfo); } - if (_validShortFlags.Count > 0 || _validLongFlags.Count > 0 || _validOptionals.Count > 0) { - ParseHyphenatedArguments(ref input); + var inputSpan = args.Input.AsSpan(); + if (_shouldParseHyphenatedArguments) { + ParseHyphenatedArguments(ref inputSpan); } for (var i = 0; i < _parameters.Length; ++i) { - _parameters[i] = ParseParameter(_parameterInfos[i], ref input); + _parameters[i] = ParseParameter(_parameterInfos[i], ref inputSpan); } // Ensure that we've consumed all of the useful parts of the input. - if (!input.IsWhiteSpace()) { + if (!inputSpan.IsWhiteSpace()) { throw new CommandParseException(Resources.CommandParse_TooManyArgs); } diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 345c76326..3133536c3 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -128,11 +128,13 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { } // Executes a command. input should not have the leading /. - private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan input) { - Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input.ToString()); + private void ExecuteCommand(ICommandSender commandSender, string input) { + var space = input.IndexOf(' ', StringComparison.Ordinal); + if (space < 0) { + space = input.Length; + } - var space = input.IndexOfOrEnd(' '); - var commandName = input[..space].ToString(); + var commandName = input.Substring(0, space); ICommand command; try { command = CommandService.FindCommand(commandName); @@ -142,7 +144,8 @@ private void ExecuteCommand(ICommandSender commandSender, ReadOnlySpan inp } try { - command.Invoke(commandSender, input[space..]); + Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); + command.Invoke(commandSender, input.Substring(space)); } catch (CommandParseException ex) { commandSender.SendErrorMessage(ex.Message); commandSender.SendInfoMessage( From ed7c569c9c343f87c518ed6a193240c1fea498e8 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 21:07:29 -0700 Subject: [PATCH 108/119] Remove HandlerObject and Handler from ICommand as those are implementation details. --- src/TShock/Commands/ICommand.cs | 11 ----------- src/TShock/Commands/TShockCommand.cs | 15 ++++++++------- src/TShock/TShock.csproj | 2 +- .../Commands/TShockCommandServiceTests.cs | 2 -- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index b364eac94..ba5e202ab 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -16,7 +16,6 @@ // along with TShock. If not, see . using System; -using System.Reflection; using TShock.Commands.Exceptions; namespace TShock.Commands { @@ -42,16 +41,6 @@ public interface ICommand { /// string? UsageText { get; } - /// - /// Gets the object associated with the command's handler. - /// - object HandlerObject { get; } - - /// - /// Gets the command's handler. - /// - MethodBase Handler { get; } - /// /// Invokes the command as a with the . /// diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 3da4ecf93..280815e28 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -31,6 +31,9 @@ namespace TShock.Commands { internal class TShockCommand : ICommand { private readonly ICommandService _commandService; + private readonly object _handlerObject; + private readonly MethodInfo _handler; + private readonly ISet _validShortFlags = new HashSet(); private readonly ISet _validLongFlags = new HashSet(); private readonly IDictionary _validOptionals = new Dictionary(); @@ -41,23 +44,21 @@ internal class TShockCommand : ICommand { public string QualifiedName { get; } public string? HelpText { get; } public string? UsageText { get; } - public object HandlerObject { get; } - public MethodBase Handler { get; } // We need to inject ICommandService so that we can trigger its CommandExecute event. public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object handlerObject, - MethodBase handler) { + MethodInfo handler) { Debug.Assert(commandService != null, "command service should not be null"); Debug.Assert(attribute != null, "attribute should not be null"); Debug.Assert(handlerObject != null, "handler object should not be null"); Debug.Assert(handler != null, "handler should not be null"); _commandService = commandService; + _handlerObject = handlerObject; + _handler = handler; QualifiedName = attribute.QualifiedName; HelpText = attribute.HelpText; UsageText = attribute.UsageText; - HandlerObject = handlerObject; - Handler = handler; // Preprocessing parameters in the constructor allows us to learn the command's flags and optionals. void PreprocessParameter(ParameterInfo parameterInfo) { @@ -84,7 +85,7 @@ void PreprocessParameter(ParameterInfo parameterInfo) { _shouldParseHyphenatedArguments = _validShortFlags.Count + _validLongFlags.Count + _validOptionals.Count > 0; - _parameterInfos = Handler.GetParameters(); + _parameterInfos = _handler.GetParameters(); _parameters = new object?[_parameterInfos.Length]; foreach (var parameter in _parameterInfos) { PreprocessParameter(parameter); @@ -269,7 +270,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } try { - Handler.Invoke(HandlerObject, _parameters); + _handler.Invoke(_handlerObject, _parameters); } catch (TargetInvocationException ex) { throw new CommandExecuteException(Resources.CommandExecute_Exception, ex.InnerException); } diff --git a/src/TShock/TShock.csproj b/src/TShock/TShock.csproj index 1ea640dd9..302291a91 100644 --- a/src/TShock/TShock.csproj +++ b/src/TShock/TShock.csproj @@ -60,7 +60,7 @@ - + diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index d8b92f5d3..a50d88ad9 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -66,7 +66,6 @@ public void RegisterCommands() { commands.Should().HaveCount(3); foreach (var command in commands) { - command.HandlerObject.Should().BeSameAs(testClass); command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); } } @@ -149,7 +148,6 @@ public void CommandRegister_IsTriggered() { var testClass = new TestClass(); _commandService.CommandRegister.RegisterHandler((sender, args) => { isRun = true; - args.Command.HandlerObject.Should().BeSameAs(testClass); args.Command.QualifiedName.Should().BeOneOf( "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); }); From b6cf6f59e74c4a469af250d7cd68167a55e3b3c9 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 21:16:54 -0700 Subject: [PATCH 109/119] Clean up implementation and fix failing tests. --- src/TShock/Commands/TShockCommand.cs | 23 ++++++--------- src/TShock/Commands/TShockCommandService.cs | 4 +-- .../Commands/TShockCommandTests.cs | 29 ++++++------------- 3 files changed, 20 insertions(+), 36 deletions(-) diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 280815e28..e25cc5f71 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -37,9 +37,6 @@ internal class TShockCommand : ICommand { private readonly ISet _validShortFlags = new HashSet(); private readonly ISet _validLongFlags = new HashSet(); private readonly IDictionary _validOptionals = new Dictionary(); - private readonly bool _shouldParseHyphenatedArguments; - private readonly ParameterInfo[] _parameterInfos; - private readonly object?[] _parameters; public string QualifiedName { get; } public string? HelpText { get; } @@ -83,12 +80,8 @@ void PreprocessParameter(ParameterInfo parameterInfo) { } } - _shouldParseHyphenatedArguments = - _validShortFlags.Count + _validLongFlags.Count + _validOptionals.Count > 0; - _parameterInfos = _handler.GetParameters(); - _parameters = new object?[_parameterInfos.Length]; - foreach (var parameter in _parameterInfos) { - PreprocessParameter(parameter); + foreach (var parameterInfo in _handler.GetParameters()) { + PreprocessParameter(parameterInfo); } } @@ -98,7 +91,7 @@ public void Invoke(ICommandSender sender, string input) { } var args = new CommandExecuteEventArgs(this, sender, input); - _commandService.CommandExecute?.Invoke(this, args); + _commandService.CommandExecute.Invoke(this, args); if (args.IsCanceled()) { return; } @@ -256,12 +249,14 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } var inputSpan = args.Input.AsSpan(); - if (_shouldParseHyphenatedArguments) { + if (_validShortFlags.Count + _validLongFlags.Count + _validOptionals.Count > 0) { ParseHyphenatedArguments(ref inputSpan); } - for (var i = 0; i < _parameters.Length; ++i) { - _parameters[i] = ParseParameter(_parameterInfos[i], ref inputSpan); + var parameterInfos = _handler.GetParameters(); + object?[] parameters = new object?[parameterInfos.Length]; + for (var i = 0; i < parameters.Length; ++i) { + parameters[i] = ParseParameter(parameterInfos[i], ref inputSpan); } // Ensure that we've consumed all of the useful parts of the input. @@ -270,7 +265,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } try { - _handler.Invoke(_handlerObject, _parameters); + _handler.Invoke(_handlerObject, parameters); } catch (TargetInvocationException ex) { throw new CommandExecuteException(Resources.CommandExecute_Exception, ex.InnerException); } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 13eecaace..77e579143 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -73,7 +73,7 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { ICommand? RegisterCommand(ICommand command) { var args = new CommandRegisterEventArgs(command); - CommandRegister?.Invoke(this, args); + CommandRegister.Invoke(this, args); if (args.IsCanceled()) { return null; } @@ -119,7 +119,7 @@ public bool UnregisterCommand(ICommand command) { } var args = new CommandUnregisterEventArgs(command); - CommandUnregister?.Invoke(this, args); + CommandUnregister.Invoke(this, args); if (args.IsCanceled()) { return false; } diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 240d4618a..c8d181535 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -32,6 +32,15 @@ namespace TShock.Commands { public class TShockCommandTests { private readonly Mock _mockCommandService = new Mock(); + public TShockCommandTests() { + _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { + [typeof(int)] = new Int32Parser(), + [typeof(string)] = new StringParser() + }); + _mockCommandService.Setup(cs => cs.CommandExecute).Returns( + new EventHandlerCollection()); + } + [Fact] public void Invoke_Sender() { var testClass = new TestClass(); @@ -47,10 +56,6 @@ public void Invoke_Sender() { [InlineData("1 test", 1, "test")] [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] public void Invoke_SenderIntString(string input, int expectedInt, string expectedString) { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser(), - [typeof(string)] = new StringParser() - }); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); var commandSender = new Mock().Object; @@ -93,9 +98,6 @@ public void Invoke_Flags(string input, bool expectedX, bool expectedY) { [InlineData("--val2=5678 1", 1, 1234, 5678)] [InlineData(" --val2=5678 1", 1, 1234, 5678)] public void Invoke_Optionals(string input, int expectedRequired, int expectedVal, int expectedVal2) { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser() - }); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Optionals)); var commandSender = new Mock().Object; @@ -124,9 +126,6 @@ public void Invoke_Optionals(string input, int expectedRequired, int expectedVal [InlineData("--force -r --depth= 100 ", true, true, 100)] public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser() - }); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); var commandSender = new Mock().Object; @@ -145,9 +144,6 @@ public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expe [InlineData("1 2", 1, 2)] [InlineData(" -1 2 -5", -1, 2, -5)] public void Invoke_Params(string input, params int[] expectedInts) { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser() - }); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Params)); var commandSender = new Mock().Object; @@ -160,9 +156,6 @@ public void Invoke_Params(string input, params int[] expectedInts) { [Fact] public void Invoke_OptionalGetsRenamed() { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser() - }); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_OptionalRename)); var commandSender = new Mock().Object; @@ -211,10 +204,6 @@ public void Invoke_CommandExecuteCanceled_IsCanceled() { [InlineData("1 ")] [InlineData("-7345734 ")] public void Invoke_MissingArg_ThrowsCommandParseException(string input) { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser(), - [typeof(string)] = new StringParser() - }); var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); var commandSender = new Mock().Object; From 45ac98168e92f23fe23f0152238047349ac9487c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 21:43:45 -0700 Subject: [PATCH 110/119] Implement ShouldBeLogged for sensitive commands. --- .../Commands/CommandHandlerAttribute.cs | 6 ++++ src/TShock/Commands/ICommand.cs | 20 ++++++----- src/TShock/Commands/TShockCommand.cs | 18 +++++----- src/TShock/Commands/TShockCommandService.cs | 8 ++--- src/TShock/Properties/Resources.Designer.cs | 36 ++++++++++++++----- src/TShock/Properties/Resources.resx | 18 ++++++---- src/TShock/TShockPlugin.cs | 8 +++-- .../Commands/TShockCommandTests.cs | 2 +- 8 files changed, 76 insertions(+), 40 deletions(-) diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 945cd5d1e..b035412a5 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -70,6 +70,12 @@ public Type? ResourceType { set => _resourceType = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Gets or sets a value indicating whether the command should be logged. For example, authentication commands + /// should not be logged. + /// + public bool ShouldBeLogged { get; set; } = true; + /// /// Initializes a new instance of the class with the specified qualified /// name. diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index ba5e202ab..fd2c71054 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -20,8 +20,8 @@ namespace TShock.Commands { /// - /// Represents a command. Commands can be executed by command senders, and provide bits of functionality. This class - /// is not thread-safe. + /// Represents a command. Commands can be executed by command senders, and provide bits of functionality. + /// Implementations are thread-safe. /// public interface ICommand { /// @@ -30,16 +30,20 @@ public interface ICommand { string QualifiedName { get; } /// - /// Gets the command's help text. If , then no help text exists. This will show up in the - /// /help command. + /// Gets the command's help text. This will show up in the /help command. /// - string? HelpText { get; } + string HelpText { get; } /// - /// Gets the command's usage text. If , then no usage text exists. This will show up in the - /// /help command and when invalid syntax is used. + /// Gets the command's usage text. This will show up in the /help command and when invalid syntax is used. /// - string? UsageText { get; } + string UsageText { get; } + + /// + /// Gets a value indicating whether the command should be logged. For example, authentication commands should + /// not be logged. + /// + bool ShouldBeLogged { get; } /// /// Invokes the command as a with the . diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index e25cc5f71..8f7f26f36 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -33,29 +33,29 @@ internal class TShockCommand : ICommand { private readonly ICommandService _commandService; private readonly object _handlerObject; private readonly MethodInfo _handler; + private readonly CommandHandlerAttribute _attribute; private readonly ISet _validShortFlags = new HashSet(); private readonly ISet _validLongFlags = new HashSet(); private readonly IDictionary _validOptionals = new Dictionary(); - public string QualifiedName { get; } - public string? HelpText { get; } - public string? UsageText { get; } + public string QualifiedName => _attribute.QualifiedName; + public string HelpText => _attribute.HelpText ?? Resources.Command_MissingHelpText; + public string UsageText => _attribute.UsageText ?? Resources.Command_MissingUsageText; + public bool ShouldBeLogged => _attribute.ShouldBeLogged; // We need to inject ICommandService so that we can trigger its CommandExecute event. - public TShockCommand(ICommandService commandService, CommandHandlerAttribute attribute, object handlerObject, - MethodInfo handler) { + public TShockCommand(ICommandService commandService, object handlerObject, MethodInfo handler, + CommandHandlerAttribute attribute) { Debug.Assert(commandService != null, "command service should not be null"); - Debug.Assert(attribute != null, "attribute should not be null"); Debug.Assert(handlerObject != null, "handler object should not be null"); Debug.Assert(handler != null, "handler should not be null"); + Debug.Assert(attribute != null, "attribute should not be null"); _commandService = commandService; _handlerObject = handlerObject; _handler = handler; - QualifiedName = attribute.QualifiedName; - HelpText = attribute.HelpText; - UsageText = attribute.UsageText; + _attribute = attribute; // Preprocessing parameters in the constructor allows us to learn the command's flags and optionals. void PreprocessParameter(ParameterInfo parameterInfo) { diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 77e579143..3f807d76c 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -90,7 +90,7 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { lock (_lock) { return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) .SelectMany(m => m.GetCustomAttributes(), - (m, a) => RegisterCommand(new TShockCommand(this, a, handlerObject, m))) + (m, a) => RegisterCommand(new TShockCommand(this, handlerObject, m, a))) .Where(c => c != null).ToList()!; } } @@ -151,7 +151,7 @@ string FormatCommandName(ICommand command) { Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); return _qualifiedNames[name].Count > 1 ? qualifiedName : name; } - + sender.SendInfoMessage(Resources.Command_Help_Header); lock (_lock) { sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{FormatCommandName(c)}"))); @@ -229,13 +229,13 @@ public void Roll(ICommandSender sender) { public void P(ICommandSender sender, [RestOfInput] string text) { var player = sender.Player; if (player is null) { - sender.SendErrorMessage(Resources.Command_Party_NotAPlayer); + sender.SendErrorMessage(Resources.Command_P_NotAPlayer); return; } var team = player.Team; if (team == PlayerTeam.None) { - sender.SendErrorMessage(Resources.Command_Party_NotInTeam); + sender.SendErrorMessage(Resources.Command_P_NotInTeam); return; } diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 54364e605..68954d06a 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -132,6 +132,24 @@ internal static string Command_Me_UsageText { } } + /// + /// Looks up a localized string similar to Missing command help text.. + /// + internal static string Command_MissingHelpText { + get { + return ResourceManager.GetString("Command_MissingHelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing command usage text.. + /// + internal static string Command_MissingUsageText { + get { + return ResourceManager.GetString("Command_MissingUsageText", resourceCulture); + } + } + /// /// Looks up a localized string similar to tshock:p. /// @@ -151,29 +169,29 @@ internal static string Command_P_HelpText { } /// - /// Looks up a localized string similar to Usage: /{0} <text>. + /// Looks up a localized string similar to You must be a player to party chat.. /// - internal static string Command_P_UsageText { + internal static string Command_P_NotAPlayer { get { - return ResourceManager.GetString("Command_P_UsageText", resourceCulture); + return ResourceManager.GetString("Command_P_NotAPlayer", resourceCulture); } } /// - /// Looks up a localized string similar to You must be a player to party chat.. + /// Looks up a localized string similar to You are not in a team!. /// - internal static string Command_Party_NotAPlayer { + internal static string Command_P_NotInTeam { get { - return ResourceManager.GetString("Command_Party_NotAPlayer", resourceCulture); + return ResourceManager.GetString("Command_P_NotInTeam", resourceCulture); } } /// - /// Looks up a localized string similar to You are not in a team!. + /// Looks up a localized string similar to Usage: /{0} <text>. /// - internal static string Command_Party_NotInTeam { + internal static string Command_P_UsageText { get { - return ResourceManager.GetString("Command_Party_NotInTeam", resourceCulture); + return ResourceManager.GetString("Command_P_UsageText", resourceCulture); } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 0f2bfea0d..f4bc86ebb 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -174,14 +174,14 @@ Usage: /{0} <text> - - tshock:p + + Missing command help text. - - You must be a player to party chat. + + Missing command usage text. - - You are not in a team! + + tshock:p tshock:playing @@ -201,6 +201,12 @@ Sends a message to your party members. + + You must be a player to party chat. + + + You are not in a team! + Usage: /{0} <text> diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 3133536c3..6040b05ef 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -28,7 +28,6 @@ using TShock.Commands; using TShock.Commands.Exceptions; using TShock.Properties; -using TShock.Utils.Extensions; namespace TShock { /// @@ -142,9 +141,12 @@ private void ExecuteCommand(ICommandSender commandSender, string input) { commandSender.SendErrorMessage(ex.Message); return; } - - try { + + if (command.ShouldBeLogged) { Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); + } + + try { command.Invoke(commandSender, input.Substring(space)); } catch (CommandParseException ex) { commandSender.SendErrorMessage(ex.Message); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index c8d181535..4d151e84f 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -308,7 +308,7 @@ public void Invoke_NullSender_ThrowsArgumentNullException() { private ICommand GetCommand(TestClass testClass, string methodName) { var handler = typeof(TestClass).GetMethod(methodName); var attribute = handler.GetCustomAttribute(); - return new TShockCommand(_mockCommandService.Object, attribute, testClass, handler); + return new TShockCommand(_mockCommandService.Object, testClass, handler, attribute); } [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] From c2e5bbe61393213aba772423f98c2f4fd63917ab Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 9 Oct 2019 23:04:24 -0700 Subject: [PATCH 111/119] Refactor out commands into a separate module of functionality. --- .../Commands/CommandHandlerAttribute.cs | 9 +- src/TShock/Commands/ConsoleCommandSender.cs | 12 +- src/TShock/Commands/ICommandService.cs | 15 +- src/TShock/Commands/Logging/PlayerLogSink.cs | 28 +- .../Logging/PlayerLogValueFormatter.cs | 47 +-- src/TShock/Commands/TShockCommand.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 216 +----------- src/TShock/Modules/CommandModule.cs | 311 ++++++++++++++++++ src/TShock/Modules/TShockModule.cs | 25 ++ src/TShock/TShockPlugin.cs | 99 +----- .../Utils/Extensions/DictionaryExtensions.cs | 2 +- .../Logging/PlayerLogValueFormatterTests.cs | 4 +- .../Commands/TShockCommandServiceTests.cs | 42 +-- 13 files changed, 399 insertions(+), 413 deletions(-) create mode 100644 src/TShock/Modules/CommandModule.cs create mode 100644 src/TShock/Modules/TShockModule.cs diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index b035412a5..79b4708ae 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -39,8 +39,8 @@ public sealed class CommandHandlerAttribute : Attribute { public string QualifiedName => GetResourceStringMaybe(_qualifiedName); /// - /// Gets or sets the help text. If , then no help text exists. This will show up in the - /// /help command. + /// Gets or sets the help text. This will show up in the /help command. If , then no help + /// text exists. /// /// is . [DisallowNull] @@ -50,8 +50,8 @@ public string? HelpText { } /// - /// Gets or sets the usage text. If , then no usage text exists. This will show up in the - /// /help command and when invalid syntax is used. + /// Gets or sets the usage text. This will show up in the /help command and when invalid syntax is used. If + /// , then no usage text exists. /// /// is . [DisallowNull] @@ -65,6 +65,7 @@ public string? UsageText { /// localization will occur. /// /// is . + [DisallowNull] public Type? ResourceType { get => _resourceType; set => _resourceType = value ?? throw new ArgumentNullException(nameof(value)); diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 5622c4a26..4dfd17c09 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -35,8 +35,6 @@ public sealed class ConsoleCommandSender : ICommandSender { #else private const LogEventLevel LogLevel = LogEventLevel.Error; #endif - private const string ResetColorString = "\x1b[0m"; - private const string ColorTag = "c/"; /// /// Gets the console-based command sender. @@ -53,8 +51,7 @@ public sealed class ConsoleCommandSender : ICommandSender { public IPlayer? Player => null; [Pure] - private static string GetColorString(Color color) => - FormattableString.Invariant($"\x1b[38;2;{color.R};{color.G};{color.B}m"); + private static string GetColorString(Color color) => $"\x1b[38;2;{color.R};{color.G};{color.B}m"; private ConsoleCommandSender() { Log = new LoggerConfiguration() @@ -85,14 +82,13 @@ private static void SendMessageImpl(ReadOnlySpan message, string colorStri var inside = message[(leftBracket + 1)..rightBracket]; message = message[(rightBracket + 1)..]; var colon = inside.IndexOf(':'); - var isValidColorTag = - inside.StartsWith(ColorTag, StringComparison.OrdinalIgnoreCase) && colon > ColorTag.Length; + var isValidColorTag = inside.StartsWith("c/", StringComparison.OrdinalIgnoreCase) && colon > 2; if (!isValidColorTag) { output.Append('[').Append(inside).Append(']'); continue; } - if (int.TryParse(inside[ColorTag.Length..colon], NumberStyles.AllowHexSpecifier, + if (int.TryParse(inside[2..colon], NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var numberColor)) { var tagColor = new Color((numberColor >> 16) & 255, (numberColor >> 8) & 255, numberColor & 255); output.Append(GetColorString(tagColor)); @@ -101,7 +97,7 @@ private static void SendMessageImpl(ReadOnlySpan message, string colorStri output.Append(inside[(colon + 1)..]); } - output.Append(message).Append(ResetColorString); + output.Append(message).Append("\x1b[0m"); Console.WriteLine(output); } } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 7fa3bdeb0..1cfc5cfe5 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -19,14 +19,13 @@ using System.Collections.Generic; using Orion; using Orion.Events; -using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using TShock.Events.Commands; namespace TShock.Commands { /// - /// Represents a service that manages commands. Provides command-related hooks and methods, and in a thread-safe - /// manner unless specified otherwise. + /// Represents a service that manages commands. Provides command-related hooks and methods. Implementations are not + /// required to be thread-safe. /// public interface ICommandService : IService { /// @@ -74,16 +73,6 @@ public interface ICommandService : IService { /// is . void RegisterParser(IArgumentParser parser); - /// - /// Finds and returns the command with . The qualified name will be determined and - /// used, if necessary. - /// - /// The command name. - /// The command. - /// is . - /// The command does not exist or is ambiguous. - ICommand FindCommand(string commandName); - /// /// Unregisters the and returns a value indicating success. /// diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs index 8516906f2..547c6baba 100644 --- a/src/TShock/Commands/Logging/PlayerLogSink.cs +++ b/src/TShock/Commands/Logging/PlayerLogSink.cs @@ -27,16 +27,6 @@ namespace TShock.Commands.Logging { internal sealed class PlayerLogSink : ILogEventSink { - private const string LevelVerbose = "[c/c0c0c0:VRB]"; - private const string LevelDebug = "[c/c0c0c0:DBG]"; - private const string LevelInformation = "[c/ffffff:INF]"; - private const string LevelWarning = "[c/ffffaf:WRN]"; - private const string LevelError = "[c/ff005f:ERR]"; - private const string LevelFatal = "[c/ff005f:FTL]"; - private const string LevelUnknown = "[c/ff0000:UNK]"; - - private static readonly Color _textColor = new Color(0xda, 0xda, 0xda); - private static readonly Color _exceptionColor = new Color(0xda, 0xda, 0xda); private static readonly PlayerLogValueFormatter _formatter = new PlayerLogValueFormatter(); private readonly IPlayer _player; @@ -49,13 +39,13 @@ public PlayerLogSink(IPlayer player) { public void Emit(LogEvent logEvent) { var logLevel = logEvent.Level switch { - LogEventLevel.Verbose => LevelVerbose, - LogEventLevel.Debug => LevelDebug, - LogEventLevel.Information => LevelInformation, - LogEventLevel.Warning => LevelWarning, - LogEventLevel.Error => LevelError, - LogEventLevel.Fatal => LevelFatal, - _ => LevelUnknown + LogEventLevel.Verbose => "[c/c0c0c0:VRB]", + LogEventLevel.Debug => "[c/c0c0c0:DBG]", + LogEventLevel.Information => "[c/ffffff:INF]", + LogEventLevel.Warning => "[c/ffffaf:WRN]", + LogEventLevel.Error => "[c/ff005f:ERR]", + LogEventLevel.Fatal => "[c/ff005f:FTL]", + _ => "[c/ff0000:UNK]" }; var output = new StringBuilder( @@ -75,13 +65,13 @@ public void Emit(LogEvent logEvent) { output.Append(_formatter.Format(propertyValue)); } - _player.SendMessage(output.ToString(), _textColor); + _player.SendMessage(output.ToString(), new Color(0xda, 0xda, 0xda)); if (logEvent.Exception != null) { using var reader = new StringReader(logEvent.Exception.ToString()); string line; while ((line = reader.ReadLine()) != null) { - _player.SendMessage(line, _exceptionColor); + _player.SendMessage(line, new Color(0xda, 0xda, 0xda)); } } } diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs index 6c514a5aa..5162a7d8f 100644 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs @@ -15,7 +15,6 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Globalization; @@ -26,53 +25,43 @@ namespace TShock.Commands.Logging { internal sealed class PlayerLogValueFormatter : LogEventPropertyValueVisitor { - private const string NullFormat = "[c/33cc99:null]"; - private const string StringFormat = "[c/d69d85:\"{0}\"]"; - private const string BooleanFormat = "[c/33cc99:{0}]"; - private const string CharFormat = "[c/d69d85:'{0}']"; - private const string NumberFormat = "[c/b5cea8:{0}]"; - private const string ScalarFormat = "[c/86c691:{0}]"; - private const string TypeTagFormat = "[c/4ec9b0:{0}] "; - [Pure] public string Format(LogEventPropertyValue value) => Visit(default, value); [Pure] protected override string VisitScalarValue(Unit _, ScalarValue scalar) => string.Format(CultureInfo.InvariantCulture, scalar.Value switch { - null => NullFormat, - string _ => StringFormat, - bool _ => BooleanFormat, - char _ => CharFormat, - var n when n.GetType().IsPrimitive || n is decimal => NumberFormat, - _ => ScalarFormat + null => "[c/569cd6:null]", + string _ => "[c/d69d85:{0}]", + bool _ => "[c/569cd6:{0}]", + char _ => "[c/d69d85:'{0}']", + var n when n.GetType().IsPrimitive || n is decimal => "[c/b5cea8:{0}]", + _ => "[c/86c691:{0}]" }, scalar.Value); - + [Pure] protected override string VisitSequenceValue(Unit _, SequenceValue sequence) => - FormattableString.Invariant($"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"); - + $"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"; + [Pure] protected override string VisitStructureValue(Unit _, StructureValue structure) => FormatTypeTag(structure.TypeTag) + - FormattableString.Invariant( - $"{{{string.Join(", ", structure.Properties.Select(FormatStructureElement))}}}"); - + $"{{{string.Join(", ", structure.Properties.Select(FormatStructureElement))}}}"; + [Pure] protected override string VisitDictionaryValue(Unit _, DictionaryValue dictionary) => - FormattableString.Invariant( - $"{{{string.Join(", ", dictionary.Elements.Select(FormatDictionaryElement))}}}"); - + $"{{{string.Join(", ", dictionary.Elements.Select(FormatDictionaryElement))}}}"; + [Pure] private string FormatTypeTag(string? typeTag) => - typeTag is null ? string.Empty : string.Format(CultureInfo.InvariantCulture, TypeTagFormat, typeTag); - + typeTag is null ? string.Empty : $"[c/4ec9b0:{typeTag}] "; + [Pure] private string FormatStructureElement(LogEventProperty p) => - FormattableString.Invariant($"{p.Name}={Visit(default, p.Value)}"); - + $"{p.Name}={Visit(default, p.Value)}"; + [Pure] private string FormatDictionaryElement(KeyValuePair kvp) => - FormattableString.Invariant($"[{Visit(default, kvp.Key)}]={Visit(default, kvp.Value)}"); + $"[{Visit(default, kvp.Key)}]={Visit(default, kvp.Value)}"; } } diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 8f7f26f36..5cceb4e63 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -254,7 +254,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } var parameterInfos = _handler.GetParameters(); - object?[] parameters = new object?[parameterInfos.Length]; + var parameters = new object?[parameterInfos.Length]; for (var i = 0; i < parameters.Length; ++i) { parameters[i] = ParseParameter(parameterInfos[i], ref inputSpan); } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 3f807d76c..6451e1daa 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -17,31 +17,17 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; using System.Linq; using System.Reflection; -using Microsoft.Xna.Framework; using Orion; using Orion.Events; -using Orion.Players; -using Serilog; -using TShock.Commands.Exceptions; using TShock.Commands.Parsers; -using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; -using TShock.Properties; -using TShock.Utils.Extensions; namespace TShock.Commands { internal sealed class TShockCommandService : OrionService, ICommandService { - private readonly object _lock = new object(); - private readonly Lazy _playerService; - private readonly Dictionary _commands = new Dictionary(); private readonly Dictionary _parsers = new Dictionary(); - private readonly IDictionary> _qualifiedNames = new Dictionary>(); - private readonly Random _rand = new Random(); public IReadOnlyDictionary Commands => _commands; public IReadOnlyDictionary Parsers => _parsers; @@ -55,17 +41,6 @@ internal sealed class TShockCommandService : OrionService, ICommandService { public EventHandlerCollection CommandUnregister { get; } = new EventHandlerCollection(); - private IPlayerService PlayerService => _playerService.Value; - - public TShockCommandService(Lazy playerService) { - _playerService = playerService; - - RegisterParser(new Int32Parser()); - RegisterParser(new DoubleParser()); - RegisterParser(new StringParser()); - RegisterCommands(this); - } - public IReadOnlyCollection RegisterCommands(object handlerObject) { if (handlerObject is null) { throw new ArgumentNullException(nameof(handlerObject)); @@ -78,40 +53,18 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { return null; } - var qualifiedName = command.QualifiedName; - var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - var qualifiedNames = _qualifiedNames.GetValueOrDefault(name, () => new HashSet(), true); - qualifiedNames.Add(qualifiedName); - - _commands.Add(qualifiedName, command); + _commands.Add(command.QualifiedName, command); return command; } - lock (_lock) { - return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) - .SelectMany(m => m.GetCustomAttributes(), - (m, a) => RegisterCommand(new TShockCommand(this, handlerObject, m, a))) - .Where(c => c != null).ToList()!; - } + return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SelectMany(m => m.GetCustomAttributes(), + (m, a) => RegisterCommand(new TShockCommand(this, handlerObject, m, a))) + .Where(c => c != null).ToList()!; } - public void RegisterParser(IArgumentParser parser) { - lock (_lock) { - _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); - } - } - - public ICommand FindCommand(string commandName) { - if (string.IsNullOrEmpty(commandName)) { - throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); - } - - lock (_lock) { - var qualifiedName = GetQualifiedName(commandName); - Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); - return _commands[qualifiedName]; - } - } + public void RegisterParser(IArgumentParser parser) => + _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); public bool UnregisterCommand(ICommand command) { if (command is null) { @@ -120,160 +73,7 @@ public bool UnregisterCommand(ICommand command) { var args = new CommandUnregisterEventArgs(command); CommandUnregister.Invoke(this, args); - if (args.IsCanceled()) { - return false; - } - - lock (_lock) { - var qualifiedName = command.QualifiedName; - if (!_commands.Remove(qualifiedName)) { - return false; - } - - var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); - _qualifiedNames[name].Remove(qualifiedName); - return true; - } - } - - // TODO: write tests for /help, /playing, /me, /roll, /p - - [CommandHandler(nameof(Resources.Command_Help), - HelpText = nameof(Resources.Command_Help_HelpText), - UsageText = nameof(Resources.Command_Help_UsageText), - ResourceType = typeof(Resources))] - public void Help(ICommandSender sender, string? command_name = null) { - if (command_name is null) { - string FormatCommandName(ICommand command) { - var qualifiedName = command.QualifiedName; - var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); - Debug.Assert(_qualifiedNames.ContainsKey(name), "qualified names should contain command name"); - return _qualifiedNames[name].Count > 1 ? qualifiedName : name; - } - - sender.SendInfoMessage(Resources.Command_Help_Header); - lock (_lock) { - sender.SendInfoMessage(string.Join(", ", _commands.Values.Select(c => $"/{FormatCommandName(c)}"))); - } - return; - } - - if (command_name.StartsWith('/')) { - command_name = command_name.Substring(1); - } - - ICommand command; - lock (_lock) { - string qualifiedName; - try { - qualifiedName = GetQualifiedName(command_name); - } catch (CommandNotFoundException ex) { - sender.SendErrorMessage(ex.Message); - return; - } - - Debug.Assert(_commands.ContainsKey(qualifiedName), "commands should contain qualified name"); - command = _commands[qualifiedName]; - } - var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, command_name); - sender.SendInfoMessage($"/{command_name}:\n{command.HelpText}\n{usageText}"); - } - - [CommandHandler(nameof(Resources.Command_Playing), - HelpText = nameof(Resources.Command_Playing_HelpText), - UsageText = nameof(Resources.Command_Playing_UsageText), - ResourceType = typeof(Resources))] - public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { - var onlinePlayers = PlayerService.Players.Where(p => p.IsActive).ToList(); - if (onlinePlayers.Count == 0) { - sender.SendInfoMessage(Resources.Command_Playing_NoPlayers); - return; - } - - sender.SendInfoMessage(Resources.Command_Playing_Header); - if (showIds) { - sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => $"[{p.Index}] {p.Name}"))); - } else { - sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => p.Name))); - } - } - - [CommandHandler(nameof(Resources.Command_Me), - HelpText = nameof(Resources.Command_Me_HelpText), - UsageText = nameof(Resources.Command_Me_UsageText), - ResourceType = typeof(Resources))] - public void Me(ICommandSender sender, [RestOfInput] string text) { - PlayerService.BroadcastMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Command_Me_Message, sender.Name, text), - new Color(0xc8, 0x64, 0x00)); - Log.Information(Resources.Log_Command_Me_Message, sender.Name, text); - } - - [CommandHandler(nameof(Resources.Command_Roll), - HelpText = nameof(Resources.Command_Roll_HelpText), - UsageText = nameof(Resources.Command_Roll_UsageText), - ResourceType = typeof(Resources))] - public void Roll(ICommandSender sender) { - var num = _rand.Next(1, 101); - PlayerService.BroadcastMessage( - string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll_Message, sender.Name, num), - new Color(0xff, 0xf0, 0x14)); - Log.Information(Resources.Log_Command_Roll_Message, sender.Name, num); - } - - [CommandHandler(nameof(Resources.Command_P), - HelpText = nameof(Resources.Command_P_HelpText), - UsageText = nameof(Resources.Command_P_UsageText), - ResourceType = typeof(Resources))] - public void P(ICommandSender sender, [RestOfInput] string text) { - var player = sender.Player; - if (player is null) { - sender.SendErrorMessage(Resources.Command_P_NotAPlayer); - return; - } - - var team = player.Team; - if (team == PlayerTeam.None) { - sender.SendErrorMessage(Resources.Command_P_NotInTeam); - return; - } - - var teamColor = team.Color(); - var teamPlayers = PlayerService.Players.Where(p => p.IsActive && p.Team == team); - foreach (var teamPlayer in teamPlayers) { - teamPlayer.SendMessageFrom(player, text, teamColor); - } - Log.Information(Resources.Log_Command_P_Message, player.Name, team, text); - } - - // Gets the qualified command name for a possibly-qualified command name. - private string GetQualifiedName(string maybeQualifiedName) { - var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; - if (isQualifiedName) { - if (!_commands.ContainsKey(maybeQualifiedName)) { - throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, - maybeQualifiedName)); - } - - return maybeQualifiedName; - } - - var qualifiedNames = _qualifiedNames.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); - if (qualifiedNames.Count == 0) { - throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, - maybeQualifiedName)); - } - - if (qualifiedNames.Count > 1) { - throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_AmbiguousName, - maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); - } - - return qualifiedNames.Single(); + return !args.IsCanceled() && _commands.Remove(command.QualifiedName); } } } diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs new file mode 100644 index 000000000..d6c5695ce --- /dev/null +++ b/src/TShock/Modules/CommandModule.cs @@ -0,0 +1,311 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using Microsoft.Xna.Framework; +using Orion; +using Orion.Events; +using Orion.Events.Players; +using Orion.Events.Server; +using Orion.Players; +using Serilog; +using TShock.Commands; +using TShock.Commands.Exceptions; +using TShock.Commands.Parsers; +using TShock.Commands.Parsers.Attributes; +using TShock.Events.Commands; +using TShock.Properties; +using TShock.Utils.Extensions; + +namespace TShock.Modules { + internal sealed class CommandModule : TShockModule { + private readonly OrionKernel _kernel; + private readonly IPlayerService _playerService; + private readonly ICommandService _commandService; + + // Map from Terraria command -> canonical command. This is used to unify Terraria and TShock commands. + private readonly IDictionary _canonicalCommands = new Dictionary { + ["Say"] = string.Empty, + ["Emote"] = "/me ", + ["Party"] = "/p ", + ["Playing"] = "/playing ", + ["Roll"] = "/roll " + }; + + private readonly IDictionary> _nameToQualifiedName + = new Dictionary>(); + + private readonly Random _rand = new Random(); + + public CommandModule(OrionKernel kernel, IPlayerService playerService, ICommandService commandService) { + Debug.Assert(kernel != null, "kernel should not be null"); + Debug.Assert(playerService != null, "player service should not be null"); + Debug.Assert(commandService != null, "command service should not be null"); + + _kernel = kernel; + _playerService = playerService; + _commandService = commandService; + + _kernel.ServerCommand.RegisterHandler(ServerCommandHandler); + _playerService.PlayerChat.RegisterHandler(PlayerChatHandler); + _commandService.CommandRegister.RegisterHandler(CommandRegisterHandler); + _commandService.CommandUnregister.RegisterHandler(CommandUnregisterHandler); + } + + public override void Initialize() { + _commandService.RegisterParser(new Int32Parser()); + _commandService.RegisterParser(new DoubleParser()); + _commandService.RegisterParser(new StringParser()); + _commandService.RegisterCommands(this); + } + + public override void Dispose() { + _kernel.ServerCommand.UnregisterHandler(ServerCommandHandler); + _playerService.PlayerChat.UnregisterHandler(PlayerChatHandler); + _commandService.CommandRegister.UnregisterHandler(CommandRegisterHandler); + _commandService.CommandUnregister.UnregisterHandler(CommandUnregisterHandler); + } + + [EventHandler(EventPriority.Lowest)] + private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { + args.Cancel("tshock: command executing"); + + var input = args.Input; + if (input.StartsWith('/')) { + input = input.Substring(1); + } + + ExecuteCommand(ConsoleCommandSender.Instance, input); + } + + [EventHandler(EventPriority.Lowest)] + private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { + if (args.IsCanceled()) { + return; + } + + var chatCommand = args.ChatCommand; + if (!_canonicalCommands.TryGetValue(chatCommand, out var canonicalCommand)) { + args.Cancel("tshock: Terraria command is invalid"); + return; + } + + var chat = canonicalCommand + args.ChatText; + if (chat.StartsWith('/')) { + args.Cancel("tshock: command executing"); + + ICommandSender commandSender = args.Player.GetAnnotationOrDefault("tshock:CommandSender", + () => new PlayerCommandSender(args.Player), true); + var input = chat.Substring(1); + ExecuteCommand(commandSender, input); + } + } + + [EventHandler(EventPriority.Monitor)] + private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args) { + var qualifiedName = args.Command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + _nameToQualifiedName + .GetValueOrDefault(name, () => new HashSet(), true) + .Add(qualifiedName); + } + + [EventHandler(EventPriority.Monitor)] + private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs args) { + var qualifiedName = args.Command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + _nameToQualifiedName + .GetValueOrDefault(name, () => new HashSet(), true) + .Remove(qualifiedName); + } + + // Executes a command. input should not have the leading /. + private void ExecuteCommand(ICommandSender commandSender, string input) { + var space = input.IndexOf(' ', StringComparison.Ordinal); + if (space < 0) { + space = input.Length; + } + + var commandName = input.Substring(0, space); + ICommand command; + try { + var qualifiedName = GetQualifiedName(commandName); + command = _commandService.Commands[qualifiedName]; + } catch (CommandNotFoundException ex) { + commandSender.SendErrorMessage(ex.Message); + return; + } + + if (command.ShouldBeLogged) { + Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); + } + + try { + command.Invoke(commandSender, input.Substring(space)); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage(ex.Message); + commandSender.SendInfoMessage( + string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName)); + } catch (CommandExecuteException ex) { + commandSender.SendErrorMessage(ex.Message); + } + } + + // Gets the qualified command name for a possibly-qualified command name. + private string GetQualifiedName(string maybeQualifiedName) { + if (string.IsNullOrEmpty(maybeQualifiedName)) { + throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); + } + + var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; + if (isQualifiedName) { + if (!_commandService.Commands.ContainsKey(maybeQualifiedName)) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, + maybeQualifiedName)); + } + + return maybeQualifiedName; + } + + var qualifiedNames = _nameToQualifiedName.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); + if (qualifiedNames.Count == 0) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, + maybeQualifiedName)); + } + + if (qualifiedNames.Count > 1) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_AmbiguousName, + maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); + } + + return qualifiedNames.Single(); + } + + // ============================================================================================================= + // Core command implementations below: + // + + [CommandHandler(nameof(Resources.Command_Help), + HelpText = nameof(Resources.Command_Help_HelpText), + UsageText = nameof(Resources.Command_Help_UsageText), + ResourceType = typeof(Resources))] + public void Help(ICommandSender sender, string? command_name = null) { + if (command_name is null) { + string FormatCommandName(ICommand command) { + var qualifiedName = command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + return _nameToQualifiedName[name].Count > 1 ? qualifiedName : name; + } + + sender.SendInfoMessage(Resources.Command_Help_Header); + sender.SendInfoMessage( + string.Join(", ", _commandService.Commands.Values.Select(c => $"/{FormatCommandName(c)}"))); + return; + } + + if (command_name.StartsWith('/')) { + command_name = command_name.Substring(1); + } + + string qualifiedName; + try { + qualifiedName = GetQualifiedName(command_name); + } catch (CommandNotFoundException ex) { + sender.SendErrorMessage(ex.Message); + return; + } + + var command = _commandService.Commands[qualifiedName]; + var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, command_name); + sender.SendInfoMessage($"/{command_name}:\n{command.HelpText}\n{usageText}"); + } + + [CommandHandler(nameof(Resources.Command_Playing), + HelpText = nameof(Resources.Command_Playing_HelpText), + UsageText = nameof(Resources.Command_Playing_UsageText), + ResourceType = typeof(Resources))] + public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { + var onlinePlayers = _playerService.Players.Where(p => p.IsActive).ToList(); + if (onlinePlayers.Count == 0) { + sender.SendInfoMessage(Resources.Command_Playing_NoPlayers); + return; + } + + sender.SendInfoMessage(Resources.Command_Playing_Header); + if (showIds) { + sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => $"[{p.Index}] {p.Name}"))); + } else { + sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => p.Name))); + } + } + + [CommandHandler(nameof(Resources.Command_Me), + HelpText = nameof(Resources.Command_Me_HelpText), + UsageText = nameof(Resources.Command_Me_UsageText), + ResourceType = typeof(Resources))] + public void Me(ICommandSender sender, [RestOfInput] string text) { + _playerService.BroadcastMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Command_Me_Message, sender.Name, text), + new Color(0xc8, 0x64, 0x00)); + Log.Information(Resources.Log_Command_Me_Message, sender.Name, text); + } + + [CommandHandler(nameof(Resources.Command_Roll), + HelpText = nameof(Resources.Command_Roll_HelpText), + UsageText = nameof(Resources.Command_Roll_UsageText), + ResourceType = typeof(Resources))] + public void Roll(ICommandSender sender) { + var num = _rand.Next(1, 101); + _playerService.BroadcastMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll_Message, sender.Name, num), + new Color(0xff, 0xf0, 0x14)); + Log.Information(Resources.Log_Command_Roll_Message, sender.Name, num); + } + + [CommandHandler(nameof(Resources.Command_P), + HelpText = nameof(Resources.Command_P_HelpText), + UsageText = nameof(Resources.Command_P_UsageText), + ResourceType = typeof(Resources))] + public void P(ICommandSender sender, [RestOfInput] string text) { + var player = sender.Player; + if (player is null) { + sender.SendErrorMessage(Resources.Command_P_NotAPlayer); + return; + } + + var team = player.Team; + if (team == PlayerTeam.None) { + sender.SendErrorMessage(Resources.Command_P_NotInTeam); + return; + } + + var teamColor = team.Color(); + var teamPlayers = _playerService.Players.Where(p => p.IsActive && p.Team == team); + foreach (var teamPlayer in teamPlayers) { + teamPlayer.SendMessageFrom(player, text, teamColor); + } + Log.Information(Resources.Log_Command_P_Message, player.Name, team, text); + } + } +} diff --git a/src/TShock/Modules/TShockModule.cs b/src/TShock/Modules/TShockModule.cs new file mode 100644 index 000000000..4e448b9e7 --- /dev/null +++ b/src/TShock/Modules/TShockModule.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Modules { + internal abstract class TShockModule : IDisposable { + public abstract void Initialize(); + public abstract void Dispose(); + } +} diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 6040b05ef..f1ac04f97 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -18,34 +18,22 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using Orion; -using Orion.Events; -using Orion.Events.Players; using Orion.Events.Server; using Orion.Players; -using Serilog; using TShock.Commands; -using TShock.Commands.Exceptions; -using TShock.Properties; +using TShock.Modules; namespace TShock { /// /// Represents the TShock plugin. /// public sealed class TShockPlugin : OrionPlugin { - // Map from Terraria command -> canonical command. This is used to unify Terraria and TShock commands. - private static readonly IDictionary _canonicalCommands = new Dictionary { - ["Say"] = "", - ["Emote"] = "/me ", - ["Party"] = "/p ", - ["Playing"] = "/playing ", - ["Roll"] = "/roll " - }; - private readonly Lazy _playerService; private readonly Lazy _commandService; + private readonly ISet _modules = new HashSet(); + /// [ExcludeFromCodeCoverage] public override string Author => "Pryaxis"; @@ -54,9 +42,6 @@ public sealed class TShockPlugin : OrionPlugin { [ExcludeFromCodeCoverage] public override string Name => "TShock"; - private IPlayerService PlayerService => _playerService.Value; - private ICommandService CommandService => _commandService.Value; - /// /// Initializes a new instance of the class with the specified Orion kernel and /// services. @@ -75,9 +60,8 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService, /// protected override void Initialize() { - Kernel.ServerCommand.RegisterHandler(ServerCommandHandler); - - PlayerService.PlayerChat.RegisterHandler(PlayerChatHandler); + Kernel.ServerInitialize.RegisterHandler(ServerInitializeHandler); + _modules.Add(new CommandModule(Kernel, _playerService.Value, _commandService.Value)); } /// @@ -85,75 +69,16 @@ protected override void Dispose(bool disposeManaged) { if (!disposeManaged) { return; } - - Kernel.ServerCommand.UnregisterHandler(ServerCommandHandler); - - PlayerService.PlayerChat.UnregisterHandler(PlayerChatHandler); - } - - [EventHandler(EventPriority.Lowest)] - private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { - args.Cancel("tshock: command executing"); - - var input = args.Input; - if (input.StartsWith('/')) { - input = input.Substring(1); + + Kernel.ServerInitialize.UnregisterHandler(ServerInitializeHandler); + foreach (var module in _modules) { + module.Dispose(); } - - ExecuteCommand(ConsoleCommandSender.Instance, input); } - [EventHandler(EventPriority.Lowest)] - private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { - if (args.IsCanceled()) { - return; - } - - var chatCommand = args.ChatCommand; - if (!_canonicalCommands.TryGetValue(chatCommand, out var canonicalCommand)) { - args.Cancel("tshock: Terraria command is invalid"); - return; - } - - var chat = canonicalCommand + args.ChatText; - if (chat.StartsWith('/')) { - args.Cancel("tshock: command executing"); - - var input = chat.Substring(1); - ICommandSender commandSender = args.Player.GetAnnotationOrDefault("tshock:CommandSender", - () => new PlayerCommandSender(args.Player), true); - ExecuteCommand(commandSender, input); - } - } - - // Executes a command. input should not have the leading /. - private void ExecuteCommand(ICommandSender commandSender, string input) { - var space = input.IndexOf(' ', StringComparison.Ordinal); - if (space < 0) { - space = input.Length; - } - - var commandName = input.Substring(0, space); - ICommand command; - try { - command = CommandService.FindCommand(commandName); - } catch (CommandNotFoundException ex) { - commandSender.SendErrorMessage(ex.Message); - return; - } - - if (command.ShouldBeLogged) { - Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); - } - - try { - command.Invoke(commandSender, input.Substring(space)); - } catch (CommandParseException ex) { - commandSender.SendErrorMessage(ex.Message); - commandSender.SendInfoMessage( - string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName)); - } catch (CommandExecuteException ex) { - commandSender.SendErrorMessage(ex.Message); + private void ServerInitializeHandler(object sender, ServerInitializeEventArgs args) { + foreach (var module in _modules) { + module.Initialize(); } } } diff --git a/src/TShock/Utils/Extensions/DictionaryExtensions.cs b/src/TShock/Utils/Extensions/DictionaryExtensions.cs index 07b2d645c..bfb65ac75 100644 --- a/src/TShock/Utils/Extensions/DictionaryExtensions.cs +++ b/src/TShock/Utils/Extensions/DictionaryExtensions.cs @@ -56,7 +56,7 @@ public static TValue GetValueOrDefault(this IDictionary default!); - return createIfNotExists ? (TValue)(dictionary[key] = provider())! : provider(); + return createIfNotExists ? (dictionary[key] = provider())! : provider(); } } } diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs index fcad3cfc8..42c747747 100644 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs +++ b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs @@ -33,7 +33,7 @@ public void Format_Null() { public void Format_String() { var formatter = new PlayerLogValueFormatter(); - formatter.Format(new ScalarValue("test")).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:""test""\]"); + formatter.Format(new ScalarValue("test")).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:test\]"); } [Fact] @@ -87,7 +87,7 @@ public void Format_Dictionary() { formatter.Format(new DictionaryValue(new[] { new KeyValuePair(new ScalarValue(1), new ScalarValue("test")) - })).Should().MatchRegex(@"{\[\[c/[a-fA-F0-9]{6}:1\]\]=\[c/[a-fA-F0-9]{6}:""test""\]}"); + })).Should().MatchRegex(@"{\[\[c/[a-fA-F0-9]{6}:1\]\]=\[c/[a-fA-F0-9]{6}:test\]}"); } } } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index a50d88ad9..9867081c1 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -21,21 +21,12 @@ using FluentAssertions; using Moq; using Orion.Events; -using Orion.Packets.World; -using Orion.Players; -using Orion.Utils; -using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using Xunit; namespace TShock.Commands { public class TShockCommandServiceTests : IDisposable { - private readonly Mock _mockPlayerService = new Mock(); - private readonly ICommandService _commandService; - - public TShockCommandServiceTests() { - _commandService = new TShockCommandService(new Lazy(() => _mockPlayerService.Object)); - } + private readonly ICommandService _commandService = new TShockCommandService(); public void Dispose() => _commandService.Dispose(); @@ -84,37 +75,6 @@ public void RegisterParser_NullParser_ThrowsArgumentNullException() { action.Should().Throw(); } - [Fact] - public void FindCommand_WithoutNamespace() { - var testClass = new TestClass(); - _commandService.RegisterCommands(testClass).ToList(); - var command = _commandService.Commands["tshock_tests:test2"]; - - _commandService.FindCommand("test2").Should().BeSameAs(command); - } - - [Fact] - public void FindCommand_WithNamespace() { - var testClass = new TestClass(); - _commandService.RegisterCommands(testClass).ToList(); - var command = _commandService.Commands["tshock_tests:test"]; - - _commandService.FindCommand("tshock_tests:test").Should().BeSameAs(command); - } - - [Theory] - [InlineData("")] - [InlineData("test")] - [InlineData("test3")] - [InlineData("tshock_tests:test3")] - public void FindCommand_InvalidCommand_ThrowsCommandNotFoundException(string inputString) { - var testClass = new TestClass(); - _commandService.RegisterCommands(testClass).ToList(); - Action action = () => _commandService.FindCommand(inputString); - - action.Should().Throw(); - } - [Fact] public void UnregisterCommand() { var testClass = new TestClass(); From 3b40693437b69f357e273a89053025c858c87056 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Thu, 10 Oct 2019 00:42:30 -0700 Subject: [PATCH 112/119] Add tests for TShockPlugin. --- src/TShock/Modules/CommandModule.cs | 86 +++++++++--------- src/TShock/Modules/TShockModule.cs | 26 +++++- src/TShock/TShockPlugin.cs | 21 +++-- .../Commands/CommandHandlerAttributeTests.cs | 7 ++ .../Commands/TShockCommandTests.cs | 64 +++++++++++++- tests/TShock.Tests/TShockPluginTests.cs | 87 +++++++++++++++++++ 6 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 tests/TShock.Tests/TShockPluginTests.cs diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs index d6c5695ce..bd249ce01 100644 --- a/src/TShock/Modules/CommandModule.cs +++ b/src/TShock/Modules/CommandModule.cs @@ -36,11 +36,14 @@ using TShock.Utils.Extensions; namespace TShock.Modules { + /// + /// Represents TShock's command module. Provides command functionality and core commands. + /// internal sealed class CommandModule : TShockModule { private readonly OrionKernel _kernel; private readonly IPlayerService _playerService; private readonly ICommandService _commandService; - + // Map from Terraria command -> canonical command. This is used to unify Terraria and TShock commands. private readonly IDictionary _canonicalCommands = new Dictionary { ["Say"] = string.Empty, @@ -63,7 +66,7 @@ public CommandModule(OrionKernel kernel, IPlayerService playerService, ICommandS _kernel = kernel; _playerService = playerService; _commandService = commandService; - + _kernel.ServerCommand.RegisterHandler(ServerCommandHandler); _playerService.PlayerChat.RegisterHandler(PlayerChatHandler); _commandService.CommandRegister.RegisterHandler(CommandRegisterHandler); @@ -77,7 +80,39 @@ public override void Initialize() { _commandService.RegisterCommands(this); } - public override void Dispose() { + // Executes a command. input should not have the leading /. + public void ExecuteCommand(ICommandSender commandSender, string input) { + var space = input.IndexOf(' ', StringComparison.Ordinal); + if (space < 0) { + space = input.Length; + } + + var commandName = input.Substring(0, space); + ICommand command; + try { + var qualifiedName = GetQualifiedName(commandName); + command = _commandService.Commands[qualifiedName]; + } catch (CommandNotFoundException ex) { + commandSender.SendErrorMessage(ex.Message); + return; + } + + if (command.ShouldBeLogged) { + Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); + } + + try { + command.Invoke(commandSender, input.Substring(space)); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage(ex.Message); + commandSender.SendInfoMessage( + string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName)); + } catch (CommandExecuteException ex) { + commandSender.SendErrorMessage(ex.Message); + } + } + + protected override void Dispose(bool disposeManaged) { _kernel.ServerCommand.UnregisterHandler(ServerCommandHandler); _playerService.PlayerChat.UnregisterHandler(PlayerChatHandler); _commandService.CommandRegister.UnregisterHandler(CommandRegisterHandler); @@ -86,6 +121,10 @@ public override void Dispose() { [EventHandler(EventPriority.Lowest)] private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { + if (args.IsCanceled()) { + return; + } + args.Cancel("tshock: command executing"); var input = args.Input; @@ -112,13 +151,14 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { if (chat.StartsWith('/')) { args.Cancel("tshock: command executing"); - ICommandSender commandSender = args.Player.GetAnnotationOrDefault("tshock:CommandSender", + ICommandSender commandSender = args.Player.GetAnnotationOrDefault( + "tshock:CommandSender", () => new PlayerCommandSender(args.Player), true); var input = chat.Substring(1); ExecuteCommand(commandSender, input); } } - + [EventHandler(EventPriority.Monitor)] private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args) { var qualifiedName = args.Command.QualifiedName; @@ -127,7 +167,7 @@ private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args .GetValueOrDefault(name, () => new HashSet(), true) .Add(qualifiedName); } - + [EventHandler(EventPriority.Monitor)] private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs args) { var qualifiedName = args.Command.QualifiedName; @@ -136,39 +176,7 @@ private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs .GetValueOrDefault(name, () => new HashSet(), true) .Remove(qualifiedName); } - - // Executes a command. input should not have the leading /. - private void ExecuteCommand(ICommandSender commandSender, string input) { - var space = input.IndexOf(' ', StringComparison.Ordinal); - if (space < 0) { - space = input.Length; - } - - var commandName = input.Substring(0, space); - ICommand command; - try { - var qualifiedName = GetQualifiedName(commandName); - command = _commandService.Commands[qualifiedName]; - } catch (CommandNotFoundException ex) { - commandSender.SendErrorMessage(ex.Message); - return; - } - if (command.ShouldBeLogged) { - Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); - } - - try { - command.Invoke(commandSender, input.Substring(space)); - } catch (CommandParseException ex) { - commandSender.SendErrorMessage(ex.Message); - commandSender.SendInfoMessage( - string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName)); - } catch (CommandExecuteException ex) { - commandSender.SendErrorMessage(ex.Message); - } - } - // Gets the qualified command name for a possibly-qualified command name. private string GetQualifiedName(string maybeQualifiedName) { if (string.IsNullOrEmpty(maybeQualifiedName)) { @@ -201,7 +209,7 @@ private string GetQualifiedName(string maybeQualifiedName) { return qualifiedNames.Single(); } - + // ============================================================================================================= // Core command implementations below: // diff --git a/src/TShock/Modules/TShockModule.cs b/src/TShock/Modules/TShockModule.cs index 4e448b9e7..64d78b010 100644 --- a/src/TShock/Modules/TShockModule.cs +++ b/src/TShock/Modules/TShockModule.cs @@ -18,8 +18,30 @@ using System; namespace TShock.Modules { - internal abstract class TShockModule : IDisposable { + /// + /// Represents a module of TShock's functionality. + /// + public abstract class TShockModule : IDisposable { + /// + /// Disposes the module and any of its managed and unmanaged resources. + /// + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Initializes the module. Typically, commands should be registered here and event handlers should be + /// registered in the constructor. + /// public abstract void Initialize(); - public abstract void Dispose(); + + /// + /// Disposes the module and any of its unmanaged resources, optionally including its managed resources. + /// + /// + /// to dispose managed resources, otherwise, . + /// + protected abstract void Dispose(bool disposeManaged); } } diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index f1ac04f97..2ad0d2a24 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -58,18 +58,27 @@ public TShockPlugin(OrionKernel kernel, Lazy playerService, _commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); } + /// + /// Registers the given . + /// + /// The module. + /// is . + public void RegisterModule(TShockModule module) { + if (module is null) { + throw new ArgumentNullException(nameof(module)); + } + + _modules.Add(module); + } + /// - protected override void Initialize() { + public override void Initialize() { Kernel.ServerInitialize.RegisterHandler(ServerInitializeHandler); - _modules.Add(new CommandModule(Kernel, _playerService.Value, _commandService.Value)); + RegisterModule(new CommandModule(Kernel, _playerService.Value, _commandService.Value)); } /// protected override void Dispose(bool disposeManaged) { - if (!disposeManaged) { - return; - } - Kernel.ServerInitialize.UnregisterHandler(ServerInitializeHandler); foreach (var module in _modules) { module.Dispose(); diff --git a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs index 810a55dcf..87e08a2f4 100644 --- a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs +++ b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs @@ -21,6 +21,13 @@ namespace TShock.Commands { public class CommandHandlerAttributeTests { + [Fact] + public void Ctor_NullQualifiedName_ThrowsArgumentNullException() { + Func func = () => new CommandHandlerAttribute(null); + + func.Should().Throw(); + } + [Fact] public void HelpText_GetWithResourceType() { var attribute = new CommandHandlerAttribute("tshock_test:test") { diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 4d151e84f..4c7415e75 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -26,6 +26,7 @@ using TShock.Commands.Parsers; using TShock.Commands.Parsers.Attributes; using TShock.Events.Commands; +using TShock.Properties; using Xunit; namespace TShock.Commands { @@ -41,6 +42,66 @@ public TShockCommandTests() { new EventHandlerCollection()); } + [Fact] + public void QualifiedName_Get() { + var attribute = new CommandHandlerAttribute("test"); + var command = new TShockCommand( + _mockCommandService.Object, "", + typeof(TShockCommandTests).GetMethod(nameof(QualifiedName_Get)), attribute); + + command.QualifiedName.Should().Be("test"); + } + + [Fact] + public void HelpText_Get() { + var attribute = new CommandHandlerAttribute("test") { HelpText = "HelpTest" }; + var command = new TShockCommand( + _mockCommandService.Object, "", + typeof(TShockCommandTests).GetMethod(nameof(HelpText_Get)), attribute); + + command.HelpText.Should().Be("HelpTest"); + } + + [Fact] + public void HelpText_GetMissing() { + var attribute = new CommandHandlerAttribute("test"); + var command = new TShockCommand( + _mockCommandService.Object, "", + typeof(TShockCommandTests).GetMethod(nameof(HelpText_GetMissing)), attribute); + + command.HelpText.Should().Be(Resources.Command_MissingHelpText); + } + + [Fact] + public void UsageText_Get() { + var attribute = new CommandHandlerAttribute("test") { UsageText = "UsageTest" }; + var command = new TShockCommand( + _mockCommandService.Object, "", + typeof(TShockCommandTests).GetMethod(nameof(UsageText_Get)), attribute); + + command.UsageText.Should().Be("UsageTest"); + } + + [Fact] + public void UsageText_GetMissing() { + var attribute = new CommandHandlerAttribute("test"); + var command = new TShockCommand( + _mockCommandService.Object, "", + typeof(TShockCommandTests).GetMethod(nameof(UsageText_GetMissing)), attribute); + + command.UsageText.Should().Be(Resources.Command_MissingUsageText); + } + + [Fact] + public void ShouldBeLogged_Get() { + var attribute = new CommandHandlerAttribute("test") { ShouldBeLogged = false }; + var command = new TShockCommand( + _mockCommandService.Object, "", + typeof(TShockCommandTests).GetMethod(nameof(ShouldBeLogged_Get)), attribute); + + command.ShouldBeLogged.Should().Be(false); + } + [Fact] public void Invoke_Sender() { var testClass = new TestClass(); @@ -337,9 +398,6 @@ public void TestCommand_Int_String(ICommandSender sender, int @int, string @stri String = @string; } - [CommandHandler("tshock_tests:test_no_ptr")] - public unsafe void TestCommand_NoPointer(ICommandSender sender, int* x) { } - [CommandHandler("tshock_tests:test_flags")] public void TestCommand_Flags(ICommandSender sender, [Flag("x", "xxx")] bool x, [Flag("y", "yyy")] bool y) { Sender = sender; diff --git a/tests/TShock.Tests/TShockPluginTests.cs b/tests/TShock.Tests/TShockPluginTests.cs new file mode 100644 index 000000000..b72fc6384 --- /dev/null +++ b/tests/TShock.Tests/TShockPluginTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using Orion; +using Orion.Events; +using Orion.Events.Players; +using Orion.Events.Server; +using Orion.Players; +using TShock.Commands; +using TShock.Events.Commands; +using TShock.Modules; +using Xunit; + +namespace TShock { + public class TShockPluginTests { + private readonly OrionKernel _kernel = new OrionKernel(); + private readonly TShockPlugin _plugin; + private readonly Mock _mockPlayerService = new Mock(); + private readonly Mock _mockCommandService = new Mock(); + + public TShockPluginTests() { + _plugin = new TShockPlugin(_kernel, + new Lazy(() => _mockPlayerService.Object), + new Lazy(() => _mockCommandService.Object)); + } + + [Fact] + public void RegisterModule_NullModule_ThrowsArgumentNullException() { + Action action = () => _plugin.RegisterModule(null); + + action.Should().Throw(); + } + + [Fact] + public void Dispose_DisposesModule() { + var module = new TestModule(); + _plugin.RegisterModule(module); + + _plugin.Dispose(); + + module.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void ServerInitialize_InitializesModule() { + _mockPlayerService.Setup(ps => ps.PlayerChat).Returns(new EventHandlerCollection()); + _mockCommandService + .Setup(ps => ps.CommandRegister) + .Returns(new EventHandlerCollection()); + _mockCommandService + .Setup(ps => ps.CommandUnregister) + .Returns(new EventHandlerCollection()); + var module = new TestModule(); + _plugin.Initialize(); + _plugin.RegisterModule(module); + + _kernel.ServerInitialize.Invoke(this, new ServerInitializeEventArgs()); + + module.IsInitialized.Should().BeTrue(); + } + + private class TestModule : TShockModule { + public bool IsDisposed { get; private set; } + public bool IsInitialized { get; private set; } + + public override void Initialize() => IsInitialized = true; + protected override void Dispose(bool disposeManaged) => IsDisposed = true; + } + } +} From 2f9890335d62c07314dcf804b2dbf3b479e492a8 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Thu, 10 Oct 2019 00:54:47 -0700 Subject: [PATCH 113/119] Localize Terraria command -> command map. --- src/TShock/Modules/CommandModule.cs | 80 ++++++++++----------- src/TShock/Properties/Resources.Designer.cs | 36 ++++++++++ src/TShock/Properties/Resources.resx | 12 ++++ 3 files changed, 88 insertions(+), 40 deletions(-) diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs index bd249ce01..2dd0cc709 100644 --- a/src/TShock/Modules/CommandModule.cs +++ b/src/TShock/Modules/CommandModule.cs @@ -44,13 +44,13 @@ internal sealed class CommandModule : TShockModule { private readonly IPlayerService _playerService; private readonly ICommandService _commandService; - // Map from Terraria command -> canonical command. This is used to unify Terraria and TShock commands. - private readonly IDictionary _canonicalCommands = new Dictionary { + // Map from Terraria command -> command. This is used to unify Terraria and TShock commands. + private readonly IDictionary _terrariaCommandToCommand = new Dictionary { ["Say"] = string.Empty, - ["Emote"] = "/me ", - ["Party"] = "/p ", - ["Playing"] = "/playing ", - ["Roll"] = "/roll " + ["Emote"] = Resources.TerrariaCommand_Me, + ["Party"] = Resources.TerrariaCommand_P, + ["Playing"] = Resources.TerrariaCommand_Playing, + ["Roll"] = Resources.TerrariaCommand_Roll }; private readonly IDictionary> _nameToQualifiedName @@ -112,6 +112,39 @@ public void ExecuteCommand(ICommandSender commandSender, string input) { } } + // Gets the qualified command name for a possibly-qualified command name. + public string GetQualifiedName(string maybeQualifiedName) { + if (string.IsNullOrWhiteSpace(maybeQualifiedName)) { + throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); + } + + var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; + if (isQualifiedName) { + if (!_commandService.Commands.ContainsKey(maybeQualifiedName)) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, + maybeQualifiedName)); + } + + return maybeQualifiedName; + } + + var qualifiedNames = _nameToQualifiedName.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); + if (qualifiedNames.Count == 0) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, + maybeQualifiedName)); + } + + if (qualifiedNames.Count > 1) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_AmbiguousName, + maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); + } + + return qualifiedNames.Single(); + } + protected override void Dispose(bool disposeManaged) { _kernel.ServerCommand.UnregisterHandler(ServerCommandHandler); _playerService.PlayerChat.UnregisterHandler(PlayerChatHandler); @@ -142,7 +175,7 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { } var chatCommand = args.ChatCommand; - if (!_canonicalCommands.TryGetValue(chatCommand, out var canonicalCommand)) { + if (!_terrariaCommandToCommand.TryGetValue(chatCommand, out var canonicalCommand)) { args.Cancel("tshock: Terraria command is invalid"); return; } @@ -177,39 +210,6 @@ private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs .Remove(qualifiedName); } - // Gets the qualified command name for a possibly-qualified command name. - private string GetQualifiedName(string maybeQualifiedName) { - if (string.IsNullOrEmpty(maybeQualifiedName)) { - throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); - } - - var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; - if (isQualifiedName) { - if (!_commandService.Commands.ContainsKey(maybeQualifiedName)) { - throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, - maybeQualifiedName)); - } - - return maybeQualifiedName; - } - - var qualifiedNames = _nameToQualifiedName.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); - if (qualifiedNames.Count == 0) { - throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, - maybeQualifiedName)); - } - - if (qualifiedNames.Count > 1) { - throw new CommandNotFoundException( - string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_AmbiguousName, - maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); - } - - return qualifiedNames.Single(); - } - // ============================================================================================================= // Core command implementations below: // diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index 68954d06a..489da05a4 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -455,5 +455,41 @@ internal static string StringParser_UnrecognizedEscape { return ResourceManager.GetString("StringParser_UnrecognizedEscape", resourceCulture); } } + + /// + /// Looks up a localized string similar to /me . + /// + internal static string TerrariaCommand_Me { + get { + return ResourceManager.GetString("TerrariaCommand_Me", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /p . + /// + internal static string TerrariaCommand_P { + get { + return ResourceManager.GetString("TerrariaCommand_P", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /playing . + /// + internal static string TerrariaCommand_Playing { + get { + return ResourceManager.GetString("TerrariaCommand_Playing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /roll . + /// + internal static string TerrariaCommand_Roll { + get { + return ResourceManager.GetString("TerrariaCommand_Roll", resourceCulture); + } + } } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index f4bc86ebb..299a9715f 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -249,4 +249,16 @@ Escape character "\{0}" not recognized. + + /me + + + /p + + + /playing + + + /roll + \ No newline at end of file From a717c632adaa970690f3e37871f7f387cd75156c Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Fri, 11 Oct 2019 00:16:47 -0700 Subject: [PATCH 114/119] Create comprehensive logging sub-library. --- src/TShock/Commands/Logging/PlayerLogSink.cs | 79 -------- .../Logging/PlayerLogValueFormatter.cs | 67 ------- src/TShock/Commands/PlayerCommandSender.cs | 4 +- .../Logging/Formatting/ExceptionFormatter.cs | 46 +++++ .../Logging/Formatting/LevelFormatter.cs | 66 +++++++ .../Formatting/MessageTemplateFormatter.cs | 58 ++++++ .../Logging/Formatting/NewLineFormatter.cs | 32 +++ .../Logging/Formatting/PlayerLogFormatter.cs | 68 +++++++ .../Logging/Formatting/PropertiesFormatter.cs | 57 ++++++ .../Logging/Formatting/PropertyFormatter.cs | 52 +++++ .../Logging/Formatting/TextFormatter.cs | 49 +++++ .../Logging/Formatting/TimestampFormatter.cs | 48 +++++ src/TShock/Logging/PlayerLogSink.cs | 53 +++++ src/TShock/Logging/PlayerLogValueVisitor.cs | 125 ++++++++++++ .../PlayerLoggerConfigurationExtensions.cs | 74 +++++++ src/TShock/Logging/Themes/PlayerLogTheme.cs | 42 ++++ .../Logging/Themes/PlayerLogThemeStyle.cs | 121 ++++++++++++ src/TShock/Logging/Themes/PlayerLogThemes.cs | 51 +++++ .../Utils/Extensions/ColorExtensions.cs | 32 +++ .../Utils/Extensions/StringExtensions.cs | 41 ++++ .../Commands/Logging/PlayerLogSinkTests.cs | 131 ------------- .../Logging/PlayerLogValueFormatterTests.cs | 93 --------- .../Formatting/ExceptionFormatterTests.cs | 61 ++++++ .../Logging/Formatting/LevelFormatterTests.cs | 131 +++++++++++++ .../MessageTemplateFormatterTests.cs | 78 ++++++++ .../Formatting/NewLineFormatterTests.cs | 40 ++++ .../Formatting/PlayerLogFormatterTests.cs | 51 +++++ .../Formatting/PropertiesFormatterTests.cs | 85 ++++++++ .../Formatting/PropertyFormatterTests.cs | 64 ++++++ .../Logging/Formatting/TextFormatterTests.cs | 47 +++++ .../Formatting/TimestampFormatterTests.cs | 48 +++++ .../Logging/PlayerLogSinkTests.cs | 65 ++++++ .../Logging/PlayerLogValueVisitorTests.cs | 185 ++++++++++++++++++ ...layerLoggerConfigurationExtensionsTests.cs | 83 ++++++++ .../Logging/Themes/PlayerLogThemeTests.cs | 41 ++++ .../Utils/Extensions/ColorExtensionsTests.cs | 34 ++++ .../Utils/Extensions/StringExtensionsTests.cs | 35 ++++ 37 files changed, 2065 insertions(+), 372 deletions(-) delete mode 100644 src/TShock/Commands/Logging/PlayerLogSink.cs delete mode 100644 src/TShock/Commands/Logging/PlayerLogValueFormatter.cs create mode 100644 src/TShock/Logging/Formatting/ExceptionFormatter.cs create mode 100644 src/TShock/Logging/Formatting/LevelFormatter.cs create mode 100644 src/TShock/Logging/Formatting/MessageTemplateFormatter.cs create mode 100644 src/TShock/Logging/Formatting/NewLineFormatter.cs create mode 100644 src/TShock/Logging/Formatting/PlayerLogFormatter.cs create mode 100644 src/TShock/Logging/Formatting/PropertiesFormatter.cs create mode 100644 src/TShock/Logging/Formatting/PropertyFormatter.cs create mode 100644 src/TShock/Logging/Formatting/TextFormatter.cs create mode 100644 src/TShock/Logging/Formatting/TimestampFormatter.cs create mode 100644 src/TShock/Logging/PlayerLogSink.cs create mode 100644 src/TShock/Logging/PlayerLogValueVisitor.cs create mode 100644 src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs create mode 100644 src/TShock/Logging/Themes/PlayerLogTheme.cs create mode 100644 src/TShock/Logging/Themes/PlayerLogThemeStyle.cs create mode 100644 src/TShock/Logging/Themes/PlayerLogThemes.cs create mode 100644 src/TShock/Utils/Extensions/ColorExtensions.cs create mode 100644 src/TShock/Utils/Extensions/StringExtensions.cs delete mode 100644 tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs delete mode 100644 tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs create mode 100644 tests/TShock.Tests/Logging/PlayerLogSinkTests.cs create mode 100644 tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs create mode 100644 tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs create mode 100644 tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs create mode 100644 tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs create mode 100644 tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs diff --git a/src/TShock/Commands/Logging/PlayerLogSink.cs b/src/TShock/Commands/Logging/PlayerLogSink.cs deleted file mode 100644 index 547c6baba..000000000 --- a/src/TShock/Commands/Logging/PlayerLogSink.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using System.Diagnostics; -using System.IO; -using System.Text; -using Microsoft.Xna.Framework; -using Orion.Players; -using Serilog.Core; -using Serilog.Events; -using Serilog.Parsing; - -namespace TShock.Commands.Logging { - internal sealed class PlayerLogSink : ILogEventSink { - private static readonly PlayerLogValueFormatter _formatter = new PlayerLogValueFormatter(); - - private readonly IPlayer _player; - - public PlayerLogSink(IPlayer player) { - Debug.Assert(player != null, "player should not be null"); - - _player = player; - } - - public void Emit(LogEvent logEvent) { - var logLevel = logEvent.Level switch { - LogEventLevel.Verbose => "[c/c0c0c0:VRB]", - LogEventLevel.Debug => "[c/c0c0c0:DBG]", - LogEventLevel.Information => "[c/ffffff:INF]", - LogEventLevel.Warning => "[c/ffffaf:WRN]", - LogEventLevel.Error => "[c/ff005f:ERR]", - LogEventLevel.Fatal => "[c/ff005f:FTL]", - _ => "[c/ff0000:UNK]" - }; - - var output = new StringBuilder( - FormattableString.Invariant($"[c/6c6c6c:{logEvent.Timestamp:HH:mm:ss zz}] [{logLevel}] ")); - foreach (var token in logEvent.MessageTemplate.Tokens) { - if (token is TextToken textToken) { - output.Append(textToken.Text); - continue; - } - - var propertyToken = (PropertyToken)token; - if (!logEvent.Properties.TryGetValue(propertyToken.PropertyName, out var propertyValue)) { - output.Append(FormattableString.Invariant($"[c/ff0000:{propertyToken}]")); - continue; - } - - output.Append(_formatter.Format(propertyValue)); - } - - _player.SendMessage(output.ToString(), new Color(0xda, 0xda, 0xda)); - - if (logEvent.Exception != null) { - using var reader = new StringReader(logEvent.Exception.ToString()); - string line; - while ((line = reader.ReadLine()) != null) { - _player.SendMessage(line, new Color(0xda, 0xda, 0xda)); - } - } - } - } -} diff --git a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs b/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs deleted file mode 100644 index 5162a7d8f..000000000 --- a/src/TShock/Commands/Logging/PlayerLogValueFormatter.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System.Collections.Generic; -using System.Diagnostics.Contracts; -using System.Globalization; -using System.Linq; -using Serilog.Data; -using Serilog.Events; -using Unit = System.ValueTuple; - -namespace TShock.Commands.Logging { - internal sealed class PlayerLogValueFormatter : LogEventPropertyValueVisitor { - [Pure] - public string Format(LogEventPropertyValue value) => Visit(default, value); - - [Pure] - protected override string VisitScalarValue(Unit _, ScalarValue scalar) => - string.Format(CultureInfo.InvariantCulture, scalar.Value switch { - null => "[c/569cd6:null]", - string _ => "[c/d69d85:{0}]", - bool _ => "[c/569cd6:{0}]", - char _ => "[c/d69d85:'{0}']", - var n when n.GetType().IsPrimitive || n is decimal => "[c/b5cea8:{0}]", - _ => "[c/86c691:{0}]" - }, scalar.Value); - - [Pure] - protected override string VisitSequenceValue(Unit _, SequenceValue sequence) => - $"[{string.Join(", ", sequence.Elements.Select(e => Visit(_, e)))}]"; - - [Pure] - protected override string VisitStructureValue(Unit _, StructureValue structure) => - FormatTypeTag(structure.TypeTag) + - $"{{{string.Join(", ", structure.Properties.Select(FormatStructureElement))}}}"; - - [Pure] - protected override string VisitDictionaryValue(Unit _, DictionaryValue dictionary) => - $"{{{string.Join(", ", dictionary.Elements.Select(FormatDictionaryElement))}}}"; - - [Pure] - private string FormatTypeTag(string? typeTag) => - typeTag is null ? string.Empty : $"[c/4ec9b0:{typeTag}] "; - - [Pure] - private string FormatStructureElement(LogEventProperty p) => - $"{p.Name}={Visit(default, p.Value)}"; - - [Pure] - private string FormatDictionaryElement(KeyValuePair kvp) => - $"[{Visit(default, kvp.Key)}]={Visit(default, kvp.Value)}"; - } -} diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 31a15d1f8..81cabe176 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -20,7 +20,7 @@ using Orion.Players; using Serilog; using Serilog.Events; -using TShock.Commands.Logging; +using TShock.Logging; namespace TShock.Commands { /// @@ -51,7 +51,7 @@ public PlayerCommandSender(IPlayer player) { Player = player ?? throw new ArgumentNullException(nameof(player)); Log = new LoggerConfiguration() .MinimumLevel.Is(LogLevel) - .WriteTo.Sink(new PlayerLogSink(player)) + .WriteTo.Player(player) .CreateLogger(); } diff --git a/src/TShock/Logging/Formatting/ExceptionFormatter.cs b/src/TShock/Logging/Formatting/ExceptionFormatter.cs new file mode 100644 index 000000000..ee8c65c33 --- /dev/null +++ b/src/TShock/Logging/Formatting/ExceptionFormatter.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class ExceptionFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + + public ExceptionFormatter(PlayerLogTheme theme) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + var exception = logEvent.Exception; + if (exception is null) { + return; + } + + output.Write(_theme.Stylize(exception.ToString(), PlayerLogThemeStyle.Exception)); + } + } +} diff --git a/src/TShock/Logging/Formatting/LevelFormatter.cs b/src/TShock/Logging/Formatting/LevelFormatter.cs new file mode 100644 index 000000000..f62f22a3c --- /dev/null +++ b/src/TShock/Logging/Formatting/LevelFormatter.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; +using TShock.Utils.Extensions; + +namespace TShock.Logging.Formatting { + internal sealed class LevelFormatter : ITextFormatter { + private static readonly IDictionary _levelToText = + new Dictionary { + [LogEventLevel.Verbose] = "VRB", + [LogEventLevel.Debug] = "DBG", + [LogEventLevel.Information] = "INF", + [LogEventLevel.Warning] = "WRN", + [LogEventLevel.Error] = "ERR", + [LogEventLevel.Fatal] = "FTL" + }; + + private static readonly IDictionary _levelToStyle = + new Dictionary { + [LogEventLevel.Verbose] = PlayerLogThemeStyle.VerboseLevel, + [LogEventLevel.Debug] = PlayerLogThemeStyle.DebugLevel, + [LogEventLevel.Information] = PlayerLogThemeStyle.InformationLevel, + [LogEventLevel.Warning] = PlayerLogThemeStyle.WarningLevel, + [LogEventLevel.Error] = PlayerLogThemeStyle.ErrorLevel, + [LogEventLevel.Fatal] = PlayerLogThemeStyle.FatalLevel + }; + + private readonly PlayerLogTheme _theme; + + public LevelFormatter(PlayerLogTheme theme) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + var level = logEvent.Level; + var text = _levelToText.GetValueOrDefault(level, () => "???"); + var style = _levelToStyle.GetValueOrDefault(level, () => PlayerLogThemeStyle.Invalid); + output.Write(_theme.Stylize(text, style)); + } + } +} diff --git a/src/TShock/Logging/Formatting/MessageTemplateFormatter.cs b/src/TShock/Logging/Formatting/MessageTemplateFormatter.cs new file mode 100644 index 000000000..0bf34d986 --- /dev/null +++ b/src/TShock/Logging/Formatting/MessageTemplateFormatter.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class MessageTemplateFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly PlayerLogValueVisitor _visitor; + + public MessageTemplateFormatter(PlayerLogTheme theme, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme; + _visitor = new PlayerLogValueVisitor(theme, formatProvider); + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + foreach (var token in logEvent.MessageTemplate.Tokens) { + if (token is TextToken textToken) { + output.Write(_theme.Stylize(textToken.Text, PlayerLogThemeStyle.Text)); + continue; + } + + var propertyToken = (PropertyToken)token; + if (!logEvent.Properties.TryGetValue(propertyToken.PropertyName, out var propertyValue)) { + output.Write(_theme.Stylize(propertyToken.ToString(), PlayerLogThemeStyle.Invalid)); + continue; + } + + _visitor.Format(propertyValue, output); + } + } + } +} diff --git a/src/TShock/Logging/Formatting/NewLineFormatter.cs b/src/TShock/Logging/Formatting/NewLineFormatter.cs new file mode 100644 index 000000000..a79b9c7ea --- /dev/null +++ b/src/TShock/Logging/Formatting/NewLineFormatter.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; + +namespace TShock.Logging.Formatting { + internal sealed class NewLineFormatter : ITextFormatter { + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + output.Write('\n'); + } + } +} diff --git a/src/TShock/Logging/Formatting/PlayerLogFormatter.cs b/src/TShock/Logging/Formatting/PlayerLogFormatter.cs new file mode 100644 index 000000000..39c6a8e45 --- /dev/null +++ b/src/TShock/Logging/Formatting/PlayerLogFormatter.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Display; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class PlayerLogFormatter : ITextFormatter { + private static readonly MessageTemplateParser _templateParser = new MessageTemplateParser(); + + private readonly IList _formatters = new List(); + + public PlayerLogFormatter(PlayerLogTheme theme, string outputTemplate, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(outputTemplate != null, "output template should not be null"); + + var template = _templateParser.Parse(outputTemplate); + foreach (var token in template.Tokens) { + if (token is TextToken textToken) { + _formatters.Add(new TextFormatter(theme, textToken.Text)); + continue; + } + + var propertyToken = (PropertyToken)token; + _formatters.Add(propertyToken.PropertyName switch { + OutputProperties.MessagePropertyName => new MessageTemplateFormatter(theme, formatProvider), + OutputProperties.TimestampPropertyName => + new TimestampFormatter(theme, propertyToken.Format, formatProvider), + OutputProperties.LevelPropertyName => new LevelFormatter(theme), + OutputProperties.NewLinePropertyName => new NewLineFormatter(), + OutputProperties.ExceptionPropertyName => new ExceptionFormatter(theme), + OutputProperties.PropertiesPropertyName => new PropertiesFormatter(theme, template, formatProvider), + _ => new PropertyFormatter(theme, propertyToken, formatProvider) + }); + } + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + foreach (var formatter in _formatters) { + formatter.Format(logEvent, output); + } + } + } +} diff --git a/src/TShock/Logging/Formatting/PropertiesFormatter.cs b/src/TShock/Logging/Formatting/PropertiesFormatter.cs new file mode 100644 index 000000000..317330650 --- /dev/null +++ b/src/TShock/Logging/Formatting/PropertiesFormatter.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class PropertiesFormatter : ITextFormatter { + private readonly MessageTemplate _outputTemplate; + private readonly PlayerLogValueVisitor _visitor; + + public PropertiesFormatter(PlayerLogTheme theme, MessageTemplate outputTemplate, + IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(outputTemplate != null, "output template should not be null"); + + _outputTemplate = outputTemplate; + _visitor = new PlayerLogValueVisitor(theme, formatProvider); + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + bool IsExcluded(string propertyName) { + return _outputTemplate.Tokens + .Concat(logEvent.MessageTemplate.Tokens) + .All(t => !(t is PropertyToken p) || p.PropertyName != propertyName); + } + + var properties = logEvent.Properties + .Where(kvp => IsExcluded(kvp.Key)) + .Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)); + _visitor.Format(new StructureValue(properties), output); + } + } +} diff --git a/src/TShock/Logging/Formatting/PropertyFormatter.cs b/src/TShock/Logging/Formatting/PropertyFormatter.cs new file mode 100644 index 000000000..b8f19f9ba --- /dev/null +++ b/src/TShock/Logging/Formatting/PropertyFormatter.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class PropertyFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly PropertyToken _propertyToken; + private readonly PlayerLogValueVisitor _visitor; + + public PropertyFormatter(PlayerLogTheme theme, PropertyToken propertyToken, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(propertyToken != null, "property token should not be null"); + + _theme = theme; + _propertyToken = propertyToken; + _visitor = new PlayerLogValueVisitor(theme, formatProvider); + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + if (!logEvent.Properties.TryGetValue(_propertyToken.PropertyName, out var propertyValue)) { + output.Write(_theme.Stylize(_propertyToken.ToString(), PlayerLogThemeStyle.Invalid)); + } else { + _visitor.Format(propertyValue, output); + } + } + } +} diff --git a/src/TShock/Logging/Formatting/TextFormatter.cs b/src/TShock/Logging/Formatting/TextFormatter.cs new file mode 100644 index 000000000..08241d913 --- /dev/null +++ b/src/TShock/Logging/Formatting/TextFormatter.cs @@ -0,0 +1,49 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class TextFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly string _text; + + public TextFormatter(PlayerLogTheme theme, string text) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(text != null, "text should not be null"); + + _theme = theme; + _text = text; + } + + /// + /// + /// or are . + /// + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + output.Write(_theme.Stylize(_text, PlayerLogThemeStyle.Text)); + } + } +} diff --git a/src/TShock/Logging/Formatting/TimestampFormatter.cs b/src/TShock/Logging/Formatting/TimestampFormatter.cs new file mode 100644 index 000000000..5e05631a0 --- /dev/null +++ b/src/TShock/Logging/Formatting/TimestampFormatter.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class TimestampFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly string _format; + private readonly IFormatProvider? _formatProvider; + + public TimestampFormatter(PlayerLogTheme theme, string format, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(format != null, "format should not be null"); + + _theme = theme; + _format = format; + _formatProvider = formatProvider; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + var timestamp = logEvent.Timestamp.ToString(_format, _formatProvider); + output.Write(_theme.Stylize(timestamp, PlayerLogThemeStyle.Timestamp)); + } + } +} diff --git a/src/TShock/Logging/PlayerLogSink.cs b/src/TShock/Logging/PlayerLogSink.cs new file mode 100644 index 000000000..38cedf16d --- /dev/null +++ b/src/TShock/Logging/PlayerLogSink.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Microsoft.Xna.Framework; +using Orion.Players; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; + +namespace TShock.Logging { + internal sealed class PlayerLogSink : ILogEventSink { + private readonly IPlayer _player; + private readonly ITextFormatter _formatter; + + public PlayerLogSink(IPlayer player, ITextFormatter formatter) { + Debug.Assert(player != null, "player should not be null"); + Debug.Assert(formatter != null, "formatter should not be null"); + + _player = player; + _formatter = formatter; + } + + public void Emit(LogEvent logEvent) { + Debug.Assert(logEvent != null, "log event should not be null"); + + var output = new StringWriter(); + _formatter.Format(logEvent, output); + + var text = output.ToString(); + if (text.EndsWith('\n')) { + text = text[0..^1]; + } + + _player.SendMessage(text, Color.White); + } + } +} diff --git a/src/TShock/Logging/PlayerLogValueVisitor.cs b/src/TShock/Logging/PlayerLogValueVisitor.cs new file mode 100644 index 000000000..6f43cc3e0 --- /dev/null +++ b/src/TShock/Logging/PlayerLogValueVisitor.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Serilog.Data; +using Serilog.Events; +using TShock.Logging.Themes; +using Unit = System.ValueTuple; + +namespace TShock.Logging { + [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "symbols should not be localized")] + [SuppressMessage("Design", "CA1062:Validate arguments of public methods", + Justification = "validation has already occurred")] + internal sealed class PlayerLogValueVisitor : LogEventPropertyValueVisitor { + private readonly PlayerLogTheme _theme; + private readonly IFormatProvider? _formatProvider; + + public PlayerLogValueVisitor(PlayerLogTheme theme, IFormatProvider? formatProvider = null) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme ?? throw new ArgumentNullException(nameof(theme)); + _formatProvider = formatProvider; + } + + public Unit Format(LogEventPropertyValue value, TextWriter output) + { + Debug.Assert(value != null, "value should not be null"); + Debug.Assert(output != null, "output should not be null"); + + return Visit(output, value); + } + + protected override Unit VisitScalarValue(TextWriter output, ScalarValue scalar) { + var value = scalar.Value; + var text = value is IFormattable f ? f.ToString(null, _formatProvider) : value?.ToString() ?? "null"; + var style = scalar.Value switch { + null => PlayerLogThemeStyle.Null, + string _ => PlayerLogThemeStyle.String, + bool _ => PlayerLogThemeStyle.Boolean, + char _ => PlayerLogThemeStyle.Character, + var n when n.GetType().IsPrimitive || n is decimal => PlayerLogThemeStyle.Number, + _ => PlayerLogThemeStyle.Scalar + }; + output.Write(_theme.Stylize(text, style)); + return default; + } + + protected override Unit VisitSequenceValue(TextWriter output, SequenceValue sequence) { + var includeSeparator = false; + output.Write(_theme.Stylize("[", PlayerLogThemeStyle.Text)); + foreach (var element in sequence.Elements) { + if (includeSeparator) { + output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); + } + + Visit(output, element); + includeSeparator = true; + } + + output.Write(_theme.Stylize("]", PlayerLogThemeStyle.Text)); + return default; + } + + protected override Unit VisitStructureValue(TextWriter output, StructureValue structure) { + var typeTag = structure.TypeTag; + if (typeTag != null) { + output.Write(_theme.Stylize(typeTag, PlayerLogThemeStyle.Type)); + } + + var includeSeparator = false; + output.Write(_theme.Stylize("{", PlayerLogThemeStyle.Text)); + foreach (var property in structure.Properties) { + if (includeSeparator) { + output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); + } + + output.Write(_theme.Stylize(property.Name, PlayerLogThemeStyle.Identifier)); + output.Write(_theme.Stylize("=", PlayerLogThemeStyle.Separator)); + Visit(output, property.Value); + includeSeparator = true; + } + + output.Write(_theme.Stylize("}", PlayerLogThemeStyle.Text)); + return default; + } + + protected override Unit VisitDictionaryValue(TextWriter output, DictionaryValue dictionary) { + var includeSeparator = false; + output.Write(_theme.Stylize("{", PlayerLogThemeStyle.Text)); + foreach (var kvp in dictionary.Elements) { + if (includeSeparator) { + output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); + } + + output.Write(_theme.Stylize("[", PlayerLogThemeStyle.Text)); + Visit(output, kvp.Key); + output.Write(_theme.Stylize("]", PlayerLogThemeStyle.Text)); + output.Write(_theme.Stylize("=", PlayerLogThemeStyle.Separator)); + Visit(output, kvp.Value); + includeSeparator = true; + } + + output.Write(_theme.Stylize("}", PlayerLogThemeStyle.Text)); + return default; + } + } +} diff --git a/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs b/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs new file mode 100644 index 000000000..1035c3a95 --- /dev/null +++ b/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using Orion.Players; +using Serilog; +using Serilog.Configuration; +using Serilog.Core; +using Serilog.Events; +using TShock.Logging.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging { + /// + /// Extends with a WriteTo.Player() method. + /// + public static class PlayerLoggerConfigurationExtensions { + private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {Message}{NewLine}{Exception}"; + + /// + /// Writes log events to the given . + /// + /// The logger sink configuration. + /// The player. + /// The minimum level for events to be logged. + /// The output template. + /// The format provider, or for a default one. + /// The level switch, or for none. + /// The theme, or for a default one. + /// A logger configuration. + /// + /// , , or are + /// . + /// + public static LoggerConfiguration Player( + this LoggerSinkConfiguration configuration, IPlayer player, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + string outputTemplate = DefaultOutputTemplate, + IFormatProvider? formatProvider = null, + LoggingLevelSwitch? levelSwitch = null, + PlayerLogTheme? theme = null) { + if (configuration is null) { + throw new ArgumentNullException(nameof(configuration)); + } + + if (player is null) { + throw new ArgumentNullException(nameof(player)); + } + + if (outputTemplate is null) { + throw new ArgumentNullException(nameof(outputTemplate)); + } + + var formatter = new PlayerLogFormatter( + theme ?? PlayerLogThemes.VisualStudio, outputTemplate, formatProvider); + var sink = new PlayerLogSink(player, formatter); + return configuration.Sink(sink, restrictedToMinimumLevel, levelSwitch); + } + } +} diff --git a/src/TShock/Logging/Themes/PlayerLogTheme.cs b/src/TShock/Logging/Themes/PlayerLogTheme.cs new file mode 100644 index 000000000..a76889243 --- /dev/null +++ b/src/TShock/Logging/Themes/PlayerLogTheme.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Xna.Framework; +using TShock.Utils.Extensions; + +namespace TShock.Logging.Themes { + /// + /// Represents a player log theme. + /// + public sealed class PlayerLogTheme { + private readonly IDictionary _styleToColor; + + internal PlayerLogTheme(IDictionary styleToColor) { + Debug.Assert(styleToColor != null, "style to color map should not be null"); + + _styleToColor = styleToColor; + } + + internal string Stylize(string text, PlayerLogThemeStyle style) { + Debug.Assert(text != null, "Text should not be null"); + + return _styleToColor.TryGetValue(style, out var color) ? text.WithColor(color) : text; + } + } +} diff --git a/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs new file mode 100644 index 000000000..e322f10f3 --- /dev/null +++ b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Logging.Themes { + /// + /// Specifies an entity styled by a player log theme. + /// + [SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "ignored")] + public enum PlayerLogThemeStyle { + /// + /// Represents text. + /// + Text, + + /// + /// Represents a separator in, e.g., a sequence or structure. + /// + Separator, + + /// + /// Represents . + /// + Null, + + /// + /// Represents a . + /// + Boolean, + + /// + /// Represents a . + /// + String, + + /// + /// Represents a . + /// + Character, + + /// + /// Represents a number. + /// + Number, + + /// + /// Represents all other scalar values. + /// + Scalar, + + /// + /// Represents a property identifier. + /// + Identifier, + + /// + /// Represents a structure type. + /// + Type, + + /// + /// Represents a timestamp. + /// + Timestamp, + + /// + /// Represents an exception. + /// + Exception, + + /// + /// Represents the verbose level indicator. + /// + VerboseLevel, + + /// + /// Represents the debug level indicator. + /// + DebugLevel, + + /// + /// Represents the information level indicator. + /// + InformationLevel, + + /// + /// Represents the warning level indicator. + /// + WarningLevel, + + /// + /// Represents the error level indicator. + /// + ErrorLevel, + + /// + /// Represents the fatal level indicator. + /// + FatalLevel, + + /// + /// Represents something invalid: e.g., an unknown level indicator or a missing property. + /// + Invalid + } +} diff --git a/src/TShock/Logging/Themes/PlayerLogThemes.cs b/src/TShock/Logging/Themes/PlayerLogThemes.cs new file mode 100644 index 000000000..02165e47b --- /dev/null +++ b/src/TShock/Logging/Themes/PlayerLogThemes.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace TShock.Logging.Themes { + /// + /// Provides instances. + /// + public static class PlayerLogThemes { + /// + /// Gets a theme that uses Visual Studio's colors wherever possible. + /// + public static PlayerLogTheme VisualStudio { get; } = + new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Text] = new Color(0xdc, 0xdc, 0xdc), + [PlayerLogThemeStyle.Separator] = new Color(0x9b, 0x9b, 0x9b), + [PlayerLogThemeStyle.Null] = new Color(0x56, 0x9c, 0xd6), + [PlayerLogThemeStyle.Boolean] = new Color(0x56, 0x9c, 0xd6), + [PlayerLogThemeStyle.String] = new Color(0xd6, 0x9d, 0x85), + [PlayerLogThemeStyle.Character] = new Color(0xd6, 0x9d, 0x85), + [PlayerLogThemeStyle.Number] = new Color(0xb5, 0xce, 0xa8), + [PlayerLogThemeStyle.Scalar] = new Color(0x86, 0xc6, 0x91), + [PlayerLogThemeStyle.Identifier] = new Color(0x9c, 0xdc, 0xfe), + [PlayerLogThemeStyle.Type] = new Color(0x4e, 0xc9, 0xb0), + [PlayerLogThemeStyle.Timestamp] = new Color(0x86, 0xc6, 0x91), + [PlayerLogThemeStyle.Exception] = new Color(0xcc, 0x44, 0x44), + [PlayerLogThemeStyle.VerboseLevel] = new Color(0xcc, 0xcc, 0xcc), + [PlayerLogThemeStyle.DebugLevel] = new Color(0xff, 0xff, 0x44), + [PlayerLogThemeStyle.WarningLevel] = new Color(0xff, 0x88, 0x44), + [PlayerLogThemeStyle.ErrorLevel] = new Color(0xcc, 0x44, 0x44), + [PlayerLogThemeStyle.FatalLevel] = new Color(0xff, 0x00, 0x00), + [PlayerLogThemeStyle.Invalid] = new Color(0xcc, 0x44, 0x44), + }); + } +} diff --git a/src/TShock/Utils/Extensions/ColorExtensions.cs b/src/TShock/Utils/Extensions/ColorExtensions.cs new file mode 100644 index 000000000..1bc1b0158 --- /dev/null +++ b/src/TShock/Utils/Extensions/ColorExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using Microsoft.Xna.Framework; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the structure. + /// + public static class ColorExtensions { + /// + /// Converts the to a hex string representation. + /// + /// The color. + /// The hex string representation. + public static string ToHexString(this Color color) => $"{color.R:x2}{color.G:x2}{color.B:x2}"; + } +} diff --git a/src/TShock/Utils/Extensions/StringExtensions.cs b/src/TShock/Utils/Extensions/StringExtensions.cs new file mode 100644 index 000000000..d463db3e5 --- /dev/null +++ b/src/TShock/Utils/Extensions/StringExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using Microsoft.Xna.Framework; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the class. + /// + public static class StringExtensions { + /// + /// Returns a string with the given . + /// + /// The string. + /// The color. + /// A string with the given . + /// is . + public static string WithColor(this string str, Color color) { + if (str is null) { + throw new ArgumentNullException(nameof(str)); + } + + return $"[c/{color.ToHexString()}:{str}]"; + } + } +} diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs deleted file mode 100644 index 546ff08c7..000000000 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogSinkTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System; -using System.Text.RegularExpressions; -using Moq; -using Orion.Packets.World; -using Orion.Players; -using Serilog; -using Serilog.Core; -using Xunit; - -namespace TShock.Commands.Logging { - public class PlayerLogSinkTests { - private readonly Mock _mockPlayer; - private readonly ILogger _logger; - - public PlayerLogSinkTests() { - _mockPlayer = new Mock(); - ILogEventSink sink = new PlayerLogSink(_mockPlayer.Object); - _logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); - } - - private void VerifyMessage(string regex) => _mockPlayer.Verify(p => p.SendPacket( - It.Is(cp => Regex.IsMatch(cp.ChatText, regex)))); - - [Fact] - public void Emit_Verbose() { - _logger.Verbose("test"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:VRB\]\] test"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_Debug() { - _logger.Debug("test"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:DBG\]\] test"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_Information() { - _logger.Information("test"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:INF\]\] test"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_Warning() { - _logger.Warning("test"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:WRN\]\] test"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_Error() { - _logger.Error("test"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:ERR\]\] test"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_Fatal() { - _logger.Fatal("test"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:FTL\]\] test"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_WithProperties() { - _logger.Verbose("{Bool} {Int}", true, 42); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:VRB\]\] \[c/[a-fA-F0-9]{6}:True\] \[c/[a-fA-F0-9]{6}:42\]"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_PropertyMissing() { - _logger.Verbose("{Bool}"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:VRB\]\] \[c/[a-fA-F0-9]{6}:{Bool}\]"); - _mockPlayer.VerifyNoOtherCalls(); - } - - [Fact] - public void Emit_WithException() { - static void Exception1() => Exception2(); - - static void Exception2() => Exception3(); - - static void Exception3() => throw new NotImplementedException(); - - Exception exception = null; - try { - Exception1(); - } catch (NotImplementedException ex) { - exception = ex; - } - - _logger.Error(exception, "Exception"); - - VerifyMessage(@"\[.+\] \[\[c/[a-fA-F0-9]{6}:ERR\]\] Exception"); - VerifyMessage(@"System\.NotImplementedException: The method or operation is not implemented."); - VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); - VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); - VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); - VerifyMessage(@" at TShock\.Commands\.Logging\.PlayerLogSinkTests.+"); - _mockPlayer.VerifyNoOtherCalls(); - } - } -} diff --git a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs b/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs deleted file mode 100644 index 42c747747..000000000 --- a/tests/TShock.Tests/Commands/Logging/PlayerLogValueFormatterTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2019 Pryaxis & TShock Contributors -// -// This file is part of TShock. -// -// TShock is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TShock is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TShock. If not, see . - -using System.Collections.Generic; -using FluentAssertions; -using Serilog.Events; -using Xunit; - -namespace TShock.Commands.Logging { - public class PlayerLogValueFormatterTests { - [Fact] - public void Format_Null() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new ScalarValue(null)).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:null\]"); - } - - [Fact] - public void Format_String() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new ScalarValue("test")).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:test\]"); - } - - [Fact] - public void Format_Bool() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new ScalarValue(true)).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:True\]"); - } - - [Fact] - public void Format_Char() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new ScalarValue('t')).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:'t'\]"); - } - - [Fact] - public void Format_Number() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new ScalarValue(-12345)).Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:-12345\]"); - } - - [Fact] - public void Format_Sequence() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new SequenceValue(new[] { new ScalarValue(1), new ScalarValue(2) })) - .Should().MatchRegex(@"\[\[c/[a-fA-F0-9]{6}:1\], \[c/[a-fA-F0-9]{6}:2\]\]"); - } - - [Fact] - public void Format_Structure() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) })) - .Should().MatchRegex(@"{Test=\[c/[a-fA-F0-9]{6}:1\]}"); - } - - [Fact] - public void Format_Structure_WithTypeTag() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }, "Type")) - .Should().MatchRegex(@"\[c/[a-fA-F0-9]{6}:Type\] {Test=\[c/[a-fA-F0-9]{6}:1\]}"); - } - - [Fact] - public void Format_Dictionary() { - var formatter = new PlayerLogValueFormatter(); - - formatter.Format(new DictionaryValue(new[] { - new KeyValuePair(new ScalarValue(1), new ScalarValue("test")) - })).Should().MatchRegex(@"{\[\[c/[a-fA-F0-9]{6}:1\]\]=\[c/[a-fA-F0-9]{6}:test\]}"); - } - } -} diff --git a/tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs new file mode 100644 index 000000000..b36dee8ff --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class ExceptionFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Exception] = new Color(0xff, 0x00, 0x00) + }); + + [Fact] + public void Format() { + var formatter = new ExceptionFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, + new Exception("TEST"), + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:System.Exception: TEST]"); + } + + [Fact] + public void Format_NoException() { + var formatter = new ExceptionFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().BeEmpty(); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs new file mode 100644 index 000000000..a11b3355a --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class LevelFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.VerboseLevel] = new Color(0x00, 0x00, 0xff), + [PlayerLogThemeStyle.DebugLevel] = new Color(0x00, 0xff, 0x00), + [PlayerLogThemeStyle.InformationLevel] = new Color(0x00, 0xff, 0xff), + [PlayerLogThemeStyle.WarningLevel] = new Color(0xff, 0x00, 0x00), + [PlayerLogThemeStyle.ErrorLevel] = new Color(0xff, 0x00, 0xff), + [PlayerLogThemeStyle.FatalLevel] = new Color(0xff, 0xff, 0x00), + [PlayerLogThemeStyle.Invalid] = new Color(0x00, 0x00, 0x00) + }); + + [Fact] + public void Format_Verbose() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Verbose, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/0000ff:VRB]"); + } + + [Fact] + public void Format_Debug() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/00ff00:DBG]"); + } + + [Fact] + public void Format_Information() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Information, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/00ffff:INF]"); + } + + [Fact] + public void Format_Warning() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Warning, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:WRN]"); + } + + [Fact] + public void Format_Error() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Error, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff00ff:ERR]"); + } + + [Fact] + public void Format_Fatal() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Fatal, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ffff00:FTL]"); + } + + [Fact] + public void Format_Unknown() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, (LogEventLevel)(-1), null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/000000:???]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs new file mode 100644 index 000000000..1e2d5a0a0 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class MessageTemplateFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Invalid] = new Color(0xff, 0x00, 0x00) + }); + + [Fact] + public void Format_Text() { + var formatter = new MessageTemplateFormatter(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new TextToken("Test") }), + Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("Test"); + } + + [Fact] + public void Format_Property() { + var formatter = new MessageTemplateFormatter(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new PropertyToken("Test", "{Test}") }), + new[] { new LogEventProperty("Test", new ScalarValue(1)) }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("1"); + } + + [Fact] + public void Format_InvalidProperty() { + var formatter = new MessageTemplateFormatter(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new PropertyToken("Test", "{Test}") }), + Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:{Test}]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs new file mode 100644 index 000000000..05b1a1e3a --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using Serilog.Events; +using Xunit; + +namespace TShock.Logging.Formatting { + public class NewLineFormatterTests { + [Fact] + public void Format() { + var formatter = new NewLineFormatter(); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("\n"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs new file mode 100644 index 000000000..6dafb1e82 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class PlayerLogFormatterTests { + [Fact] + public void Format() { + var theme = new PlayerLogTheme(new Dictionary()); + var formatter = new PlayerLogFormatter(theme, + "[{Timestamp:HH:mm:ss} {Level}] {Property1}{Message}{NewLine}{Exception}", + CultureInfo.InvariantCulture); + var logEvent = new LogEvent( + new DateTimeOffset(new DateTime(2019, 10, 10, 9, 59, 59)), LogEventLevel.Debug, + new Exception("TEST"), + new MessageTemplate(new[] { new TextToken("my message") }), + new[] { + new LogEventProperty("Property1", new ScalarValue(1)) + }); + var writer = new StringWriter(); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[09:59:59 DBG] 1my message\nSystem.Exception: TEST"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs new file mode 100644 index 000000000..a49cc253c --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class PropertiesFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary()); + + [Fact] + public void Format() { + var formatter = new PropertiesFormatter(_theme, MessageTemplate.Empty, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, + new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)), + }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("{Test=1, Test2=2}"); + } + + [Fact] + public void Format_ExistsInOutputTemplate() { + var formatter = new PropertiesFormatter(_theme, + new MessageTemplate(new[] {new PropertyToken("Test2", "{Test2}")}), CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, + new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)), + }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("{Test=1}"); + } + + [Fact] + public void Format_ExistsInTemplate() { + var formatter = new PropertiesFormatter(_theme, MessageTemplate.Empty, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] {new PropertyToken("Test", "{Test}")}), + new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)), + }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("{Test2=2}"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs new file mode 100644 index 000000000..5c940b376 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class PropertyFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Invalid] = new Color(0xff, 0x00, 0x00) + }); + + [Fact] + public void Format() { + var formatter = new PropertyFormatter(_theme, new PropertyToken("Test", "{Test}"), + CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, MessageTemplate.Empty, + new[] { new LogEventProperty("Test", new ScalarValue(1)) }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("1"); + } + + [Fact] + public void Format_InvalidProperty() { + var formatter = new PropertyFormatter(_theme, new PropertyToken("Test", "{Test}"), + CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:{Test}]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs new file mode 100644 index 000000000..b23dad195 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class TextFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Text] = new Color(0xff, 0xff, 0x00) + }); + + [Fact] + public void Format_Text() { + var formatter = new TextFormatter(_theme, "test"); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ffff00:test]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs new file mode 100644 index 000000000..73d8435a1 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class TimestampFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Timestamp] = new Color(0xfa, 0xdf, 0xad) + }); + + [Fact] + public void Format() { + var formatter = new TimestampFormatter(_theme, "yyyyMMdd", CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + new DateTimeOffset(new DateTime(2019, 10, 10)), LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/fadfad:20191010]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs new file mode 100644 index 000000000..71c18bbfd --- /dev/null +++ b/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.IO; +using System.Linq; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Packets.World; +using Orion.Players; +using Serilog.Events; +using Serilog.Formatting; +using Xunit; + +namespace TShock.Logging { + public class PlayerLogSinkTests { + [Fact] + public void Emit() { + var mockPlayer = new Mock(); + var mockFormatter = new Mock(); + mockFormatter + .Setup(f => f.Format(It.IsAny(), It.IsAny())) + .Callback((LogEvent logEvent, TextWriter output) => output.Write("TEST")); + var sink = new PlayerLogSink(mockPlayer.Object, mockFormatter.Object); + + sink.Emit(new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty())); + + mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.ChatColor == Color.White && cp.ChatText == "TEST"))); + } + + [Fact] + public void Emit_EndsInNewLine() { + var mockPlayer = new Mock(); + var mockFormatter = new Mock(); + mockFormatter + .Setup(f => f.Format(It.IsAny(), It.IsAny())) + .Callback((LogEvent logEvent, TextWriter output) => output.Write("TEST\n")); + var sink = new PlayerLogSink(mockPlayer.Object, mockFormatter.Object); + + sink.Emit(new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty())); + + mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.ChatColor == Color.White && cp.ChatText == "TEST"))); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs b/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs new file mode 100644 index 000000000..f46c24ddc --- /dev/null +++ b/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging { + public class PlayerLogValueVisitorTests { + private readonly PlayerLogTheme _theme = + new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Null] = new Color(0x00, 0x00, 0x00), + [PlayerLogThemeStyle.String] = new Color(0x00, 0x80, 0x00), + [PlayerLogThemeStyle.Boolean] = new Color(0x00, 0x00, 0x80), + [PlayerLogThemeStyle.Character] = new Color(0x00, 0x00, 0xff), + [PlayerLogThemeStyle.Number] = new Color(0xff, 0xff, 0x00), + [PlayerLogThemeStyle.Scalar] = new Color(0x00, 0x00, 0x01), + [PlayerLogThemeStyle.Separator] = new Color(0xc0, 0xc0, 0xc0), + [PlayerLogThemeStyle.Identifier] = new Color(0xff, 0x00, 0x00), + [PlayerLogThemeStyle.Type] = new Color(0xff, 0x00, 0xff) + }); + + [Fact] + public void Format_Null() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(null), writer); + + writer.ToString().Should().Be("[c/000000:null]"); + } + + [Fact] + public void Format_String() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue("test"), writer); + + writer.ToString().Should().Be("[c/008000:test]"); + } + + [Fact] + public void Format_Bool() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(true), writer); + + writer.ToString().Should().Be("[c/000080:True]"); + } + + [Fact] + public void Format_Char() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue('t'), writer); + + writer.ToString().Should().Be("[c/0000ff:t]"); + } + + [Fact] + public void Format_Number() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(-12345), writer); + + writer.ToString().Should().Be("[c/ffff00:-12345]"); + } + + [Fact] + public void Format_Scalar() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(new StringBuilder("test")), writer); + + writer.ToString().Should().Be("[c/000001:test]"); + } + + [Fact] + public void Format_Sequence() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new SequenceValue(new[] { new ScalarValue(1), new ScalarValue(2) }), writer); + + writer.ToString().Should().Be("[[c/ffff00:1][c/c0c0c0:, ][c/ffff00:2]]"); + } + + [Fact] + public void Format_Sequence_NoSeparator() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new SequenceValue(new[] { new ScalarValue(1) }), writer); + + writer.ToString().Should().Be("[[c/ffff00:1]]"); + } + + [Fact] + public void Format_Structure() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }), writer); + + writer.ToString().Should().Be("{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1]}"); + } + + [Fact] + public void Format_Structure_WithSeparator() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new StructureValue(new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)) + }), writer); + + writer.ToString().Should().Be( + "{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1][c/c0c0c0:, ][c/ff0000:Test2][c/c0c0c0:=][c/ffff00:2]}"); + } + + [Fact] + public void Format_Structure_WithTypeTag() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format( + new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }, "Type"), + writer); + + writer.ToString().Should().Be("[c/ff00ff:Type]{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1]}"); + } + + [Fact] + public void Format_Dictionary() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new DictionaryValue(new[] { + new KeyValuePair(new ScalarValue(1), new ScalarValue("test")) + }), writer); + + writer.ToString().Should().Be("{[[c/ffff00:1]][c/c0c0c0:=][c/008000:test]}"); + } + + [Fact] + public void Format_Dictionary_WithSeparator() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new DictionaryValue(new[] { + new KeyValuePair(new ScalarValue(1), new ScalarValue("test")), + new KeyValuePair(new ScalarValue(2), new ScalarValue("test2")) + }), writer); + + writer.ToString().Should().Be( + "{[[c/ffff00:1]][c/c0c0c0:=][c/008000:test][c/c0c0c0:, ][[c/ffff00:2]][c/c0c0c0:=][c/008000:test2]}"); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs b/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs new file mode 100644 index 000000000..8fc0307a5 --- /dev/null +++ b/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Packets.World; +using Orion.Players; +using Serilog; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging { + public class PlayerLoggerConfigurationExtensionsTests { + [Fact] + public void Player() { + var theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Text] = new Color(0x12, 0x34, 0x56) + }); + var mockPlayer = new Mock(); + var logger = new LoggerConfiguration() + .WriteTo.Player(mockPlayer.Object, theme: theme) + .CreateLogger(); + + logger.Error("FAIL"); + + mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.ChatText.Contains("[c/123456:FAIL]")))); + } + + [Fact] + public void Player_NullTheme() { + var mockPlayer = new Mock(); + var logger = new LoggerConfiguration() + .WriteTo.Player(mockPlayer.Object) + .CreateLogger(); + + logger.Error("FAIL"); + + mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.ChatText.Contains("[c/dcdcdc:FAIL]")))); + } + + [Fact] + public void Player_NullConfiguration_ThrowsArgumentNullException() { + var player = new Mock().Object; + Func func = () => PlayerLoggerConfigurationExtensions.Player(null, player); + + func.Should().Throw(); + } + + [Fact] + public void Player_NullPlayer_ThrowsArgumentNullException() { + var configuration = new LoggerConfiguration(); + Func func = () => configuration.WriteTo.Player(null); + + func.Should().Throw(); + } + + [Fact] + public void Player_NullOutputTemplate_ThrowsArgumentNullException() { + var configuration = new LoggerConfiguration(); + var player = new Mock().Object; + Func func = () => configuration.WriteTo.Player(player, outputTemplate: null); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs b/tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs new file mode 100644 index 000000000..78b1e2fa7 --- /dev/null +++ b/tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Xunit; + +namespace TShock.Logging.Themes { + public class PlayerLogThemeTests { + [Fact] + public void Stylize() { + var theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Invalid] = new Color(0x65, 0x43, 0x21) + }); + + theme.Stylize("test", PlayerLogThemeStyle.Invalid).Should().Be("[c/654321:test]"); + } + + [Fact] + public void Stylize_MissingStyle() { + var theme = new PlayerLogTheme(new Dictionary()); + + theme.Stylize("test", PlayerLogThemeStyle.Text).Should().Be("test"); + } + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs new file mode 100644 index 000000000..9d7445d9a --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2019 Pryaxis & Orion Contributors +// +// This file is part of Orion. +// +// Orion is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Orion is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Orion. If not, see . + +using FluentAssertions; +using Microsoft.Xna.Framework; +using System.Collections.Generic; +using Xunit; + +namespace TShock.Utils.Extensions { + public class ColorExtensionsTests { + public static readonly IEnumerable ToHexStringData = new List { + new object[] { new Color(0xf3, 0x20, 0x00), "f32000" }, + new object[] { new Color(0x01, 0x02, 0x03), "010203" } + }; + + [Theory] + [MemberData(nameof(ToHexStringData))] + public void ToHexString(Color color, string expected) => color.ToHexString().Should().Be(expected); + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs new file mode 100644 index 000000000..f059dfed6 --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Xunit; + +namespace TShock.Utils.Extensions { + public class StringExtensionsTests { + [Fact] + public void WithColor() => "test".WithColor(new Color(0x12, 0x34, 0x56)).Should().Be("[c/123456:test]"); + + [Fact] + public void WithColor_NullStr_ThrowsArgumentNullException() { + Func func = () => StringExtensions.WithColor(null, Color.White); + + func.Should().Throw(); + } + } +} From 9c34a3f1550afb8e86011588309ee72f68b5650d Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Fri, 11 Oct 2019 00:21:17 -0700 Subject: [PATCH 115/119] Remove {Properties} from ConsoleCommandSender log. --- src/TShock/Commands/ConsoleCommandSender.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index 4dfd17c09..df8de7abc 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -56,7 +56,7 @@ public sealed class ConsoleCommandSender : ICommandSender { private ConsoleCommandSender() { Log = new LoggerConfiguration() .WriteTo.Console( - outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Properties} {Message:lj}{NewLine}{Exception}", + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}", theme: AnsiConsoleTheme.Code) .MinimumLevel.Is(LogLevel) .CreateLogger(); From 312c3942121f02b8e80d6ac08a9c95bf5c63e87a Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Sun, 13 Oct 2019 21:54:03 -0700 Subject: [PATCH 116/119] Update to new Orion features. --- src/TShock/Commands/ICommandService.cs | 2 +- src/TShock/Commands/TShockCommandService.cs | 17 +++++--- .../Commands/CommandExecuteEventArgs.cs | 2 + .../Commands/CommandRegisterEventArgs.cs | 2 + .../Commands/CommandUnregisterEventArgs.cs | 2 + .../Logging/Formatting/PlayerLogFormatter.cs | 3 ++ .../Logging/Formatting/PropertiesFormatter.cs | 8 ++-- .../Logging/Formatting/TextFormatter.cs | 5 --- src/TShock/Logging/PlayerLogSink.cs | 1 + src/TShock/Logging/PlayerLogValueVisitor.cs | 15 ++++--- .../Logging/Themes/PlayerLogThemeStyle.cs | 4 +- src/TShock/Logging/Themes/PlayerLogThemes.cs | 2 + src/TShock/Modules/CommandModule.cs | 8 ++-- src/TShock/Modules/TShockModule.cs | 3 +- src/TShock/TShockPlugin.cs | 39 +++++++++++------- .../Commands/TShockCommandServiceTests.cs | 3 +- .../Commands/TShockCommandTests.cs | 10 +++-- .../Formatting/PropertiesFormatterTests.cs | 4 +- .../Logging/PlayerLogValueVisitorTests.cs | 26 ++++++------ tests/TShock.Tests/TShockPluginTests.cs | 40 ++++++++++++++++--- .../Utils/Extensions/ColorExtensionsTests.cs | 2 +- 21 files changed, 124 insertions(+), 74 deletions(-) diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 1cfc5cfe5..a8844bd43 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -27,7 +27,7 @@ namespace TShock.Commands { /// Represents a service that manages commands. Provides command-related hooks and methods. Implementations are not /// required to be thread-safe. /// - public interface ICommandService : IService { + public interface ICommandService { /// /// Gets a read-only mapping from qualified command names to commands. /// diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 6451e1daa..262a29772 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -17,29 +17,34 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using Orion; using Orion.Events; +using Serilog; using TShock.Commands.Parsers; using TShock.Events.Commands; namespace TShock.Commands { + [Service("tshock-commands")] internal sealed class TShockCommandService : OrionService, ICommandService { private readonly Dictionary _commands = new Dictionary(); private readonly Dictionary _parsers = new Dictionary(); public IReadOnlyDictionary Commands => _commands; public IReadOnlyDictionary Parsers => _parsers; - public EventHandlerCollection CommandRegister { get; } - = new EventHandlerCollection(); - public EventHandlerCollection CommandExecute { get; } - = new EventHandlerCollection(); - public EventHandlerCollection CommandUnregister { get; } - = new EventHandlerCollection(); + + public TShockCommandService(ILogger log) : base(log) { + Debug.Assert(log != null, "log should not be null"); + + CommandRegister = new EventHandlerCollection(log); + CommandExecute = new EventHandlerCollection(log); + CommandUnregister = new EventHandlerCollection(log); + } public IReadOnlyCollection RegisterCommands(object handlerObject) { if (handlerObject is null) { diff --git a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs b/src/TShock/Events/Commands/CommandExecuteEventArgs.cs index 3910b4011..16a8cb79e 100644 --- a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs +++ b/src/TShock/Events/Commands/CommandExecuteEventArgs.cs @@ -16,12 +16,14 @@ // along with TShock. If not, see . using System; +using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// /// Provides data for the event. /// + [EventArgs("command-execute")] public sealed class CommandExecuteEventArgs : CommandEventArgs { private string _input; diff --git a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs index 5230e5f70..6bfc869c1 100644 --- a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandRegisterEventArgs.cs @@ -16,12 +16,14 @@ // along with TShock. If not, see . using System; +using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// /// Provides data for the event. /// + [EventArgs("command-register")] public sealed class CommandRegisterEventArgs : CommandEventArgs { /// /// Initializes a new instance of the class with the specified command. diff --git a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs index f9faa77b2..79b18e786 100644 --- a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs @@ -16,12 +16,14 @@ // along with TShock. If not, see . using System; +using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// /// Provides data for the event. /// + [EventArgs("command-unregister")] public sealed class CommandUnregisterEventArgs : CommandEventArgs { /// /// Initializes a new instance of the class with the specified command. diff --git a/src/TShock/Logging/Formatting/PlayerLogFormatter.cs b/src/TShock/Logging/Formatting/PlayerLogFormatter.cs index 39c6a8e45..075624619 100644 --- a/src/TShock/Logging/Formatting/PlayerLogFormatter.cs +++ b/src/TShock/Logging/Formatting/PlayerLogFormatter.cs @@ -27,6 +27,7 @@ namespace TShock.Logging.Formatting { internal sealed class PlayerLogFormatter : ITextFormatter { + // Cache the MessageTemplateParser because why not? private static readonly MessageTemplateParser _templateParser = new MessageTemplateParser(); private readonly IList _formatters = new List(); @@ -35,6 +36,8 @@ public PlayerLogFormatter(PlayerLogTheme theme, string outputTemplate, IFormatPr Debug.Assert(theme != null, "theme should not be null"); Debug.Assert(outputTemplate != null, "output template should not be null"); + // The general strategy is to parse the template, and create a list of formatters in the same order as the + // properties found within the template. We can then run the formatters in order on each log event. var template = _templateParser.Parse(outputTemplate); foreach (var token in template.Tokens) { if (token is TextToken textToken) { diff --git a/src/TShock/Logging/Formatting/PropertiesFormatter.cs b/src/TShock/Logging/Formatting/PropertiesFormatter.cs index 317330650..2ead9beef 100644 --- a/src/TShock/Logging/Formatting/PropertiesFormatter.cs +++ b/src/TShock/Logging/Formatting/PropertiesFormatter.cs @@ -42,11 +42,9 @@ public void Format(LogEvent logEvent, TextWriter output) { Debug.Assert(logEvent != null, "log event should not be null"); Debug.Assert(output != null, "output should not be null"); - bool IsExcluded(string propertyName) { - return _outputTemplate.Tokens - .Concat(logEvent.MessageTemplate.Tokens) - .All(t => !(t is PropertyToken p) || p.PropertyName != propertyName); - } + bool IsExcluded(string propertyName) => _outputTemplate.Tokens + .Concat(logEvent.MessageTemplate.Tokens) + .All(t => !(t is PropertyToken p) || p.PropertyName != propertyName); var properties = logEvent.Properties .Where(kvp => IsExcluded(kvp.Key)) diff --git a/src/TShock/Logging/Formatting/TextFormatter.cs b/src/TShock/Logging/Formatting/TextFormatter.cs index 08241d913..6122fa4be 100644 --- a/src/TShock/Logging/Formatting/TextFormatter.cs +++ b/src/TShock/Logging/Formatting/TextFormatter.cs @@ -15,7 +15,6 @@ // You should have received a copy of the GNU General Public License // along with TShock. If not, see . -using System; using System.Diagnostics; using System.IO; using Serilog.Events; @@ -35,10 +34,6 @@ public TextFormatter(PlayerLogTheme theme, string text) { _text = text; } - /// - /// - /// or are . - /// public void Format(LogEvent logEvent, TextWriter output) { Debug.Assert(logEvent != null, "log event should not be null"); Debug.Assert(output != null, "output should not be null"); diff --git a/src/TShock/Logging/PlayerLogSink.cs b/src/TShock/Logging/PlayerLogSink.cs index 38cedf16d..66a9666eb 100644 --- a/src/TShock/Logging/PlayerLogSink.cs +++ b/src/TShock/Logging/PlayerLogSink.cs @@ -42,6 +42,7 @@ public void Emit(LogEvent logEvent) { var output = new StringWriter(); _formatter.Format(logEvent, output); + // When sending the text, we don't want any newlines if possible. So we strip the ending newline. var text = output.ToString(); if (text.EndsWith('\n')) { text = text[0..^1]; diff --git a/src/TShock/Logging/PlayerLogValueVisitor.cs b/src/TShock/Logging/PlayerLogValueVisitor.cs index 6f43cc3e0..1f53530e4 100644 --- a/src/TShock/Logging/PlayerLogValueVisitor.cs +++ b/src/TShock/Logging/PlayerLogValueVisitor.cs @@ -40,8 +40,7 @@ public PlayerLogValueVisitor(PlayerLogTheme theme, IFormatProvider? formatProvid _formatProvider = formatProvider; } - public Unit Format(LogEventPropertyValue value, TextWriter output) - { + public Unit Format(LogEventPropertyValue value, TextWriter output) { Debug.Assert(value != null, "value should not be null"); Debug.Assert(output != null, "output should not be null"); @@ -74,11 +73,11 @@ protected override Unit VisitSequenceValue(TextWriter output, SequenceValue sequ Visit(output, element); includeSeparator = true; } - + output.Write(_theme.Stylize("]", PlayerLogThemeStyle.Text)); return default; } - + protected override Unit VisitStructureValue(TextWriter output, StructureValue structure) { var typeTag = structure.TypeTag; if (typeTag != null) { @@ -97,11 +96,11 @@ protected override Unit VisitStructureValue(TextWriter output, StructureValue st Visit(output, property.Value); includeSeparator = true; } - + output.Write(_theme.Stylize("}", PlayerLogThemeStyle.Text)); return default; } - + protected override Unit VisitDictionaryValue(TextWriter output, DictionaryValue dictionary) { var includeSeparator = false; output.Write(_theme.Stylize("{", PlayerLogThemeStyle.Text)); @@ -109,7 +108,7 @@ protected override Unit VisitDictionaryValue(TextWriter output, DictionaryValue if (includeSeparator) { output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); } - + output.Write(_theme.Stylize("[", PlayerLogThemeStyle.Text)); Visit(output, kvp.Key); output.Write(_theme.Stylize("]", PlayerLogThemeStyle.Text)); @@ -117,7 +116,7 @@ protected override Unit VisitDictionaryValue(TextWriter output, DictionaryValue Visit(output, kvp.Value); includeSeparator = true; } - + output.Write(_theme.Stylize("}", PlayerLogThemeStyle.Text)); return default; } diff --git a/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs index e322f10f3..31ef9fc8a 100644 --- a/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs +++ b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs @@ -32,7 +32,7 @@ public enum PlayerLogThemeStyle { /// Represents a separator in, e.g., a sequence or structure. /// Separator, - + /// /// Represents . /// @@ -72,7 +72,7 @@ public enum PlayerLogThemeStyle { /// Represents a structure type. /// Type, - + /// /// Represents a timestamp. /// diff --git a/src/TShock/Logging/Themes/PlayerLogThemes.cs b/src/TShock/Logging/Themes/PlayerLogThemes.cs index 02165e47b..d4d57bf79 100644 --- a/src/TShock/Logging/Themes/PlayerLogThemes.cs +++ b/src/TShock/Logging/Themes/PlayerLogThemes.cs @@ -38,6 +38,8 @@ public static class PlayerLogThemes { [PlayerLogThemeStyle.Scalar] = new Color(0x86, 0xc6, 0x91), [PlayerLogThemeStyle.Identifier] = new Color(0x9c, 0xdc, 0xfe), [PlayerLogThemeStyle.Type] = new Color(0x4e, 0xc9, 0xb0), + + // The below don't really have any analogs, so I made some colors up. [PlayerLogThemeStyle.Timestamp] = new Color(0x86, 0xc6, 0x91), [PlayerLogThemeStyle.Exception] = new Color(0xcc, 0x44, 0x44), [PlayerLogThemeStyle.VerboseLevel] = new Color(0xcc, 0xcc, 0xcc), diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs index 2dd0cc709..42bf2a234 100644 --- a/src/TShock/Modules/CommandModule.cs +++ b/src/TShock/Modules/CommandModule.cs @@ -152,7 +152,7 @@ protected override void Dispose(bool disposeManaged) { _commandService.CommandUnregister.UnregisterHandler(CommandUnregisterHandler); } - [EventHandler(EventPriority.Lowest)] + [EventHandler(EventPriority.Lowest, Name = "tshock")] private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { if (args.IsCanceled()) { return; @@ -168,7 +168,7 @@ private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { ExecuteCommand(ConsoleCommandSender.Instance, input); } - [EventHandler(EventPriority.Lowest)] + [EventHandler(EventPriority.Lowest, Name = "tshock")] private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { if (args.IsCanceled()) { return; @@ -192,7 +192,7 @@ private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { } } - [EventHandler(EventPriority.Monitor)] + [EventHandler(EventPriority.Monitor, Name = "tshock")] private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args) { var qualifiedName = args.Command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); @@ -201,7 +201,7 @@ private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args .Add(qualifiedName); } - [EventHandler(EventPriority.Monitor)] + [EventHandler(EventPriority.Monitor, Name = "tshock")] private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs args) { var qualifiedName = args.Command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); diff --git a/src/TShock/Modules/TShockModule.cs b/src/TShock/Modules/TShockModule.cs index 64d78b010..49357994a 100644 --- a/src/TShock/Modules/TShockModule.cs +++ b/src/TShock/Modules/TShockModule.cs @@ -31,8 +31,7 @@ public void Dispose() { } /// - /// Initializes the module. Typically, commands should be registered here and event handlers should be - /// registered in the constructor. + /// Initializes the module. Typically, commands should be registered here. /// public abstract void Initialize(); diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index 2ad0d2a24..f06e151a8 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -19,8 +19,10 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Orion; +using Orion.Events; using Orion.Events.Server; using Orion.Players; +using Serilog; using TShock.Commands; using TShock.Modules; @@ -28,30 +30,26 @@ namespace TShock { /// /// Represents the TShock plugin. /// + [Service("tshock")] public sealed class TShockPlugin : OrionPlugin { private readonly Lazy _playerService; private readonly Lazy _commandService; private readonly ISet _modules = new HashSet(); - /// - [ExcludeFromCodeCoverage] - public override string Author => "Pryaxis"; - - /// - [ExcludeFromCodeCoverage] - public override string Name => "TShock"; - /// - /// Initializes a new instance of the class with the specified Orion kernel and - /// services. + /// Initializes a new instance of the class with the specified Orion kernel, log, + /// and services. /// /// The Orion kernel. + /// The log. /// The player service. /// The command service. - /// Any of the services are . - public TShockPlugin(OrionKernel kernel, Lazy playerService, - Lazy commandService) : base(kernel) { + /// + /// , , or any of the services are . + /// + public TShockPlugin(OrionKernel kernel, ILogger log, Lazy playerService, + Lazy commandService) : base(kernel, log) { Kernel.Bind().To(); _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); @@ -74,18 +72,29 @@ public void RegisterModule(TShockModule module) { /// public override void Initialize() { Kernel.ServerInitialize.RegisterHandler(ServerInitializeHandler); + RegisterModule(new CommandModule(Kernel, _playerService.Value, _commandService.Value)); } /// - protected override void Dispose(bool disposeManaged) { - Kernel.ServerInitialize.UnregisterHandler(ServerInitializeHandler); + public override void Dispose() { foreach (var module in _modules) { module.Dispose(); } + + Kernel.ServerInitialize.UnregisterHandler(ServerInitializeHandler); } + [EventHandler(EventPriority.Normal, Name = "tshock")] private void ServerInitializeHandler(object sender, ServerInitializeEventArgs args) { + // The reason why we want to wait for ServerInitialize to initialize the modules is because we need three + // stages of initialization: + // + // 1) Plugin constructors, which use lazy services. + // 2) Plugin Initialize, which can set up service event handlers, + // 3) ServerInitialize, which can use any service. + // + // This way, we can have service rebinding and service event handlers that always run. foreach (var module in _modules) { module.Initialize(); } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 9867081c1..6b1dc253a 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -21,12 +21,13 @@ using FluentAssertions; using Moq; using Orion.Events; +using Serilog.Core; using TShock.Commands.Parsers; using Xunit; namespace TShock.Commands { public class TShockCommandServiceTests : IDisposable { - private readonly ICommandService _commandService = new TShockCommandService(); + private readonly TShockCommandService _commandService = new TShockCommandService(Logger.None); public void Dispose() => _commandService.Dispose(); diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 4c7415e75..a6d8cf2f0 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -22,6 +22,7 @@ using FluentAssertions; using Moq; using Orion.Events; +using Serilog.Core; using TShock.Commands.Exceptions; using TShock.Commands.Parsers; using TShock.Commands.Parsers.Attributes; @@ -38,8 +39,9 @@ public TShockCommandTests() { [typeof(int)] = new Int32Parser(), [typeof(string)] = new StringParser() }); - _mockCommandService.Setup(cs => cs.CommandExecute).Returns( - new EventHandlerCollection()); + _mockCommandService + .Setup(cs => cs.CommandExecute) + .Returns(new EventHandlerCollection(Logger.None)); } [Fact] @@ -233,7 +235,7 @@ public void Invoke_TriggersCommandExecute() { var command = GetCommand(testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; var isRun = false; - var commandExecute = new EventHandlerCollection(); + var commandExecute = new EventHandlerCollection(Logger.None); commandExecute.RegisterHandler((sender, args) => { isRun = true; args.Command.Should().Be(command); @@ -252,7 +254,7 @@ public void Invoke_CommandExecuteCanceled_IsCanceled() { var testClass = new TestClass(); var command = GetCommand(testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; - var commandExecute = new EventHandlerCollection(); + var commandExecute = new EventHandlerCollection(Logger.None); commandExecute.RegisterHandler((sender, args) => { args.Cancel(); }); diff --git a/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs index a49cc253c..dc864d6d0 100644 --- a/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs +++ b/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs @@ -50,7 +50,7 @@ public void Format() { [Fact] public void Format_ExistsInOutputTemplate() { var formatter = new PropertiesFormatter(_theme, - new MessageTemplate(new[] {new PropertyToken("Test2", "{Test2}")}), CultureInfo.InvariantCulture); + new MessageTemplate(new[] { new PropertyToken("Test2", "{Test2}") }), CultureInfo.InvariantCulture); var writer = new StringWriter(); var logEvent = new LogEvent( DateTimeOffset.Now, LogEventLevel.Debug, null, @@ -71,7 +71,7 @@ public void Format_ExistsInTemplate() { var writer = new StringWriter(); var logEvent = new LogEvent( DateTimeOffset.Now, LogEventLevel.Debug, null, - new MessageTemplate(new[] {new PropertyToken("Test", "{Test}")}), + new MessageTemplate(new[] { new PropertyToken("Test", "{Test}") }), new[] { new LogEventProperty("Test", new ScalarValue(1)), new LogEventProperty("Test2", new ScalarValue(2)), diff --git a/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs b/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs index f46c24ddc..a4fe994be 100644 --- a/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs +++ b/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs @@ -46,7 +46,7 @@ public void Format_Null() { var writer = new StringWriter(); visitor.Format(new ScalarValue(null), writer); - + writer.ToString().Should().Be("[c/000000:null]"); } @@ -56,7 +56,7 @@ public void Format_String() { var writer = new StringWriter(); visitor.Format(new ScalarValue("test"), writer); - + writer.ToString().Should().Be("[c/008000:test]"); } @@ -66,7 +66,7 @@ public void Format_Bool() { var writer = new StringWriter(); visitor.Format(new ScalarValue(true), writer); - + writer.ToString().Should().Be("[c/000080:True]"); } @@ -76,7 +76,7 @@ public void Format_Char() { var writer = new StringWriter(); visitor.Format(new ScalarValue('t'), writer); - + writer.ToString().Should().Be("[c/0000ff:t]"); } @@ -86,7 +86,7 @@ public void Format_Number() { var writer = new StringWriter(); visitor.Format(new ScalarValue(-12345), writer); - + writer.ToString().Should().Be("[c/ffff00:-12345]"); } @@ -96,7 +96,7 @@ public void Format_Scalar() { var writer = new StringWriter(); visitor.Format(new ScalarValue(new StringBuilder("test")), writer); - + writer.ToString().Should().Be("[c/000001:test]"); } @@ -106,7 +106,7 @@ public void Format_Sequence() { var writer = new StringWriter(); visitor.Format(new SequenceValue(new[] { new ScalarValue(1), new ScalarValue(2) }), writer); - + writer.ToString().Should().Be("[[c/ffff00:1][c/c0c0c0:, ][c/ffff00:2]]"); } @@ -116,7 +116,7 @@ public void Format_Sequence_NoSeparator() { var writer = new StringWriter(); visitor.Format(new SequenceValue(new[] { new ScalarValue(1) }), writer); - + writer.ToString().Should().Be("[[c/ffff00:1]]"); } @@ -126,7 +126,7 @@ public void Format_Structure() { var writer = new StringWriter(); visitor.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }), writer); - + writer.ToString().Should().Be("{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1]}"); } @@ -139,7 +139,7 @@ public void Format_Structure_WithSeparator() { new LogEventProperty("Test", new ScalarValue(1)), new LogEventProperty("Test2", new ScalarValue(2)) }), writer); - + writer.ToString().Should().Be( "{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1][c/c0c0c0:, ][c/ff0000:Test2][c/c0c0c0:=][c/ffff00:2]}"); } @@ -152,7 +152,7 @@ public void Format_Structure_WithTypeTag() { visitor.Format( new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }, "Type"), writer); - + writer.ToString().Should().Be("[c/ff00ff:Type]{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1]}"); } @@ -164,7 +164,7 @@ public void Format_Dictionary() { visitor.Format(new DictionaryValue(new[] { new KeyValuePair(new ScalarValue(1), new ScalarValue("test")) }), writer); - + writer.ToString().Should().Be("{[[c/ffff00:1]][c/c0c0c0:=][c/008000:test]}"); } @@ -177,7 +177,7 @@ public void Format_Dictionary_WithSeparator() { new KeyValuePair(new ScalarValue(1), new ScalarValue("test")), new KeyValuePair(new ScalarValue(2), new ScalarValue("test2")) }), writer); - + writer.ToString().Should().Be( "{[[c/ffff00:1]][c/c0c0c0:=][c/008000:test][c/c0c0c0:, ][[c/ffff00:2]][c/c0c0c0:=][c/008000:test2]}"); } diff --git a/tests/TShock.Tests/TShockPluginTests.cs b/tests/TShock.Tests/TShockPluginTests.cs index b72fc6384..12f4798b2 100644 --- a/tests/TShock.Tests/TShockPluginTests.cs +++ b/tests/TShock.Tests/TShockPluginTests.cs @@ -23,6 +23,7 @@ using Orion.Events.Players; using Orion.Events.Server; using Orion.Players; +using Serilog.Core; using TShock.Commands; using TShock.Events.Commands; using TShock.Modules; @@ -30,17 +31,44 @@ namespace TShock { public class TShockPluginTests { - private readonly OrionKernel _kernel = new OrionKernel(); + private readonly OrionKernel _kernel = new OrionKernel(Logger.None); private readonly TShockPlugin _plugin; private readonly Mock _mockPlayerService = new Mock(); private readonly Mock _mockCommandService = new Mock(); public TShockPluginTests() { - _plugin = new TShockPlugin(_kernel, + _plugin = new TShockPlugin(_kernel, Logger.None, new Lazy(() => _mockPlayerService.Object), new Lazy(() => _mockCommandService.Object)); } + [Fact] + public void Ctor_NullKernel_ThrowsArgumentNullException() { + Func func = () => new TShockPlugin(null, Logger.None, + new Lazy(() => _mockPlayerService.Object), + new Lazy(() => _mockCommandService.Object)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_NullPlayerService_ThrowsArgumentNullException() { + Func func = () => new TShockPlugin(_kernel, Logger.None, + null, + new Lazy(() => _mockCommandService.Object)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_NullCommandService_ThrowsArgumentNullException() { + Func func = () => new TShockPlugin(_kernel, Logger.None, + new Lazy(() => _mockPlayerService.Object), + null); + + func.Should().Throw(); + } + [Fact] public void RegisterModule_NullModule_ThrowsArgumentNullException() { Action action = () => _plugin.RegisterModule(null); @@ -60,13 +88,15 @@ public void Dispose_DisposesModule() { [Fact] public void ServerInitialize_InitializesModule() { - _mockPlayerService.Setup(ps => ps.PlayerChat).Returns(new EventHandlerCollection()); + _mockPlayerService + .Setup(ps => ps.PlayerChat) + .Returns(new EventHandlerCollection(Logger.None)); _mockCommandService .Setup(ps => ps.CommandRegister) - .Returns(new EventHandlerCollection()); + .Returns(new EventHandlerCollection(Logger.None)); _mockCommandService .Setup(ps => ps.CommandUnregister) - .Returns(new EventHandlerCollection()); + .Returns(new EventHandlerCollection(Logger.None)); var module = new TestModule(); _plugin.Initialize(); _plugin.RegisterModule(module); diff --git a/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs index 9d7445d9a..783dea45e 100644 --- a/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs +++ b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs @@ -15,9 +15,9 @@ // You should have received a copy of the GNU General Public License // along with Orion. If not, see . +using System.Collections.Generic; using FluentAssertions; using Microsoft.Xna.Framework; -using System.Collections.Generic; using Xunit; namespace TShock.Utils.Extensions { From d7723dbf056589d6727fb0a30be77f8848900654 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 23 Oct 2019 00:32:19 -0700 Subject: [PATCH 117/119] Update to new Orion v2 branch. --- .../Commands/CommandHandlerAttribute.cs | 16 +- src/TShock/Commands/ConsoleCommandSender.cs | 5 +- src/TShock/Commands/ICommand.cs | 17 +- src/TShock/Commands/ICommandSender.cs | 11 ++ src/TShock/Commands/ICommandService.cs | 23 +-- .../Parsers/Attributes/FlagAttribute.cs | 1 + .../Commands/Parsers/IArgumentParser.cs | 8 +- src/TShock/Commands/PlayerCommandSender.cs | 2 + src/TShock/Commands/TShockCommand.cs | 37 ++-- src/TShock/Commands/TShockCommandService.cs | 22 +-- .../{CommandEventArgs.cs => CommandEvent.cs} | 15 +- ...uteEventArgs.cs => CommandExecuteEvent.cs} | 23 ++- ...erEventArgs.cs => CommandRegisterEvent.cs} | 15 +- ...EventArgs.cs => CommandUnregisterEvent.cs} | 15 +- src/TShock/Logging/PlayerLogValueVisitor.cs | 6 +- .../PlayerLoggerConfigurationExtensions.cs | 2 +- .../Logging/Themes/PlayerLogThemeStyle.cs | 4 +- src/TShock/Logging/Themes/PlayerLogThemes.cs | 1 + src/TShock/Modules/CommandModule.cs | 60 +++---- src/TShock/TShock.csproj | 1 + src/TShock/TShockPlugin.cs | 26 +-- .../Utils/Extensions/DictionaryExtensions.cs | 5 +- src/TShock/Utils/ResourceHelper.cs | 3 +- .../Commands/PlayerCommandSenderTests.cs | 4 +- .../Commands/TShockCommandServiceTests.cs | 94 ++++++----- .../Commands/TShockCommandTests.cs | 158 ++++++++++++------ ...EventArgsTests.cs => CommandEventTests.cs} | 16 +- ...gsTests.cs => CommandExecuteEventTests.cs} | 26 ++- .../Commands/CommandRegisterEventTests.cs | 34 ++++ .../Commands/CommandUnregisterEventTests.cs | 34 ++++ .../Logging/PlayerLogSinkTests.cs | 4 +- ...layerLoggerConfigurationExtensionsTests.cs | 4 +- tests/TShock.Tests/TShockPluginTests.cs | 81 +++++---- .../Utils/Extensions/ColorExtensionsTests.cs | 10 +- 34 files changed, 489 insertions(+), 294 deletions(-) rename src/TShock/Events/Commands/{CommandEventArgs.cs => CommandEvent.cs} (72%) rename src/TShock/Events/Commands/{CommandExecuteEventArgs.cs => CommandExecuteEvent.cs} (73%) rename src/TShock/Events/Commands/{CommandRegisterEventArgs.cs => CommandRegisterEvent.cs} (71%) rename src/TShock/Events/Commands/{CommandUnregisterEventArgs.cs => CommandUnregisterEvent.cs} (71%) rename tests/TShock.Tests/Events/Commands/{CommandEventArgsTests.cs => CommandEventTests.cs} (73%) rename tests/TShock.Tests/Events/Commands/{CommandExecuteEventArgsTests.cs => CommandExecuteEventTests.cs} (68%) create mode 100644 tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs create mode 100644 tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 79b4708ae..444d1f1a5 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -29,19 +29,22 @@ namespace TShock.Commands { [MeansImplicitUse] public sealed class CommandHandlerAttribute : Attribute { private readonly string _qualifiedName; + private string? _helpText; private string? _usageText; private Type? _resourceType; /// - /// Gets the qualified name. This includes the namespace: e.g., "tshock:kick". + /// Gets the qualified name. This includes the namespace: e.g., tshock:kick. /// + /// The qualified name. public string QualifiedName => GetResourceStringMaybe(_qualifiedName); /// /// Gets or sets the help text. This will show up in the /help command. If , then no help /// text exists. /// + /// The help text. /// is . [DisallowNull] public string? HelpText { @@ -53,6 +56,7 @@ public string? HelpText { /// Gets or sets the usage text. This will show up in the /help command and when invalid syntax is used. If /// , then no usage text exists. /// + /// The usage text. /// is . [DisallowNull] public string? UsageText { @@ -64,6 +68,7 @@ public string? UsageText { /// Gets or sets the resource type to load localizable strings from. If , then no /// localization will occur. /// + /// The resource type to load localizable strings from. /// is . [DisallowNull] public Type? ResourceType { @@ -72,9 +77,10 @@ public Type? ResourceType { } /// - /// Gets or sets a value indicating whether the command should be logged. For example, authentication commands - /// should not be logged. + /// Gets or sets a value indicating whether the command should be logged. /// + /// if the command should be logged; otherwise, . + /// This property's value is useful for hiding, e.g., authentication commands. public bool ShouldBeLogged { get; set; } = true; /// @@ -82,13 +88,11 @@ public Type? ResourceType { /// name. /// /// - /// The qualified name. This must include the namespace: e.g., "tshock:kick". + /// The qualified name. This includes the namespace: e.g., tshock:kick. /// /// /// is . /// - [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", - Justification = "strings are not user-facing")] public CommandHandlerAttribute(string qualifiedName) { if (qualifiedName is null) { throw new ArgumentNullException(nameof(qualifiedName)); diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs index df8de7abc..71ce00348 100644 --- a/src/TShock/Commands/ConsoleCommandSender.cs +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -18,6 +18,7 @@ using System; using System.Globalization; using System.Text; +using Destructurama; using JetBrains.Annotations; using Microsoft.Xna.Framework; using Orion.Players; @@ -39,6 +40,7 @@ public sealed class ConsoleCommandSender : ICommandSender { /// /// Gets the console-based command sender. /// + /// The console-based command sender. public static ConsoleCommandSender Instance { get; } = new ConsoleCommandSender(); /// @@ -55,8 +57,9 @@ public sealed class ConsoleCommandSender : ICommandSender { private ConsoleCommandSender() { Log = new LoggerConfiguration() + .Destructure.UsingAttributes() .WriteTo.Console( - outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}", + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", theme: AnsiConsoleTheme.Code) .MinimumLevel.Is(LogLevel) .CreateLogger(); diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index fd2c71054..a052fdc7c 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -25,28 +25,33 @@ namespace TShock.Commands { /// public interface ICommand { /// - /// Gets the command's qualified name. This includes the namespace: e.g., "tshock:kick". + /// Gets the command's qualified name. This includes the namespace: e.g., tshock:kick /// + /// The command's qualified name. string QualifiedName { get; } /// - /// Gets the command's help text. This will show up in the /help command. + /// Gets the command's help text. This will show up in the /help command. /// + /// The command's help text. string HelpText { get; } /// - /// Gets the command's usage text. This will show up in the /help command and when invalid syntax is used. + /// Gets the command's usage text. This will show up in the /help command and when invalid syntax is + /// used. /// + /// The command's usage text. string UsageText { get; } /// - /// Gets a value indicating whether the command should be logged. For example, authentication commands should - /// not be logged. + /// Gets a value indicating whether the command should be logged. /// + /// if the command should be logged; otherwise . + /// This property's value is useful for hiding, e.g., authentication commands. bool ShouldBeLogged { get; } /// - /// Invokes the command as a with the . + /// Invokes the command as a with the specified . /// /// The sender. /// The input. This does not include the command's name. diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index c7a3c7734..c923976e1 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -28,16 +28,27 @@ public interface ICommandSender { /// /// Gets the sender's name. /// + /// The sender's name. string Name { get; } /// /// Gets the sender's log. /// + /// The sender's log. + /// + /// This property's value can be used to log information to the sender. For example, you can pass debugging or + /// error messages and they will be formatted appropriately. + /// ILogger Log { get; } /// /// Gets the sender's player. If , then there is no associated player. /// + /// The sender's player. + /// + /// This property's value can be used to modify the command sender's state. For example, you can heal or kill + /// the player. + /// IPlayer? Player { get; } /// diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index a8844bd43..e78e8313f 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -17,42 +17,25 @@ using System; using System.Collections.Generic; -using Orion; -using Orion.Events; using TShock.Commands.Parsers; -using TShock.Events.Commands; namespace TShock.Commands { /// - /// Represents a service that manages commands. Provides command-related hooks and methods. Implementations are not - /// required to be thread-safe. + /// Represents a service that manages commands. Provides command-related properties and methods. /// public interface ICommandService { /// /// Gets a read-only mapping from qualified command names to commands. /// + /// A read-only mapping from qualified command names to commands. IReadOnlyDictionary Commands { get; } /// /// Gets a read-only mapping from types to parsers. /// + /// A read-only mapping from types to parsers. IReadOnlyDictionary Parsers { get; } - /// - /// Gets the event handlers that occur when registering a command. This event can be canceled. - /// - EventHandlerCollection CommandRegister { get; } - - /// - /// Gets the event handlers that occur when executing a command. This event can be canceled. - /// - EventHandlerCollection CommandExecute { get; } - - /// - /// Gets the event handlers that occur when unregistering a command. This event can be canceled. - /// - EventHandlerCollection CommandUnregister { get; } - /// /// Registers and returns the commands defined with the 's command handlers. /// Command handlers are specified using the attribute. diff --git a/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs b/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs index bc7ef7095..12ee5ae2f 100644 --- a/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs +++ b/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs @@ -29,6 +29,7 @@ public sealed class FlagAttribute : Attribute { /// /// Gets a read-only view of the flags. Flags with length 1 are treated as short flags. /// + /// A read-only view of the flags. public IReadOnlyCollection Flags { get; } /// diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs index fe8d40cd1..59afd2932 100644 --- a/src/TShock/Commands/Parsers/IArgumentParser.cs +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -26,8 +26,8 @@ namespace TShock.Commands.Parsers { /// public interface IArgumentParser { /// - /// Parses and returns a corresponding object using the . - /// will be consumed as necessary. + /// Parses the and returns a corresponding object using the specified + /// . will be consumed as necessary. /// /// The input. This is guaranteed to start with a non-whitespace character. /// The attributes. @@ -42,8 +42,8 @@ public interface IArgumentParser { /// The parse type. public interface IArgumentParser : IArgumentParser { /// - /// Parses and returns a corresponding instance using the - /// . will be consumed as necessary. + /// Parses the and returns a corresponding instance using + /// the specified . will be consumed as necessary. /// /// The input. This is guaranteed to start with a non-whitespace character. /// The attributes. diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs index 81cabe176..63087e370 100644 --- a/src/TShock/Commands/PlayerCommandSender.cs +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -16,6 +16,7 @@ // along with TShock. If not, see . using System; +using Destructurama; using Microsoft.Xna.Framework; using Orion.Players; using Serilog; @@ -50,6 +51,7 @@ public sealed class PlayerCommandSender : ICommandSender { public PlayerCommandSender(IPlayer player) { Player = player ?? throw new ArgumentNullException(nameof(player)); Log = new LoggerConfiguration() + .Destructure.UsingAttributes() .MinimumLevel.Is(LogLevel) .WriteTo.Player(player) .CreateLogger(); diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs index 5cceb4e63..708509c97 100644 --- a/src/TShock/Commands/TShockCommand.cs +++ b/src/TShock/Commands/TShockCommand.cs @@ -30,7 +30,7 @@ namespace TShock.Commands { internal class TShockCommand : ICommand { - private readonly ICommandService _commandService; + private readonly TShockCommandService _commandService; private readonly object _handlerObject; private readonly MethodInfo _handler; private readonly CommandHandlerAttribute _attribute; @@ -44,8 +44,9 @@ internal class TShockCommand : ICommand { public string UsageText => _attribute.UsageText ?? Resources.Command_MissingUsageText; public bool ShouldBeLogged => _attribute.ShouldBeLogged; - // We need to inject ICommandService so that we can trigger its CommandExecute event. - public TShockCommand(ICommandService commandService, object handlerObject, MethodInfo handler, + // We need to inject TShockCommandService so that we can raise a CommandExecuteEvent. + public TShockCommand( + TShockCommandService commandService, object handlerObject, MethodInfo handler, CommandHandlerAttribute attribute) { Debug.Assert(commandService != null, "command service should not be null"); Debug.Assert(handlerObject != null, "handler object should not be null"); @@ -90,9 +91,9 @@ public void Invoke(ICommandSender sender, string input) { throw new ArgumentNullException(nameof(sender)); } - var args = new CommandExecuteEventArgs(this, sender, input); - _commandService.CommandExecute.Invoke(this, args); - if (args.IsCanceled()) { + var e = new CommandExecuteEvent(this, sender, input); + _commandService.Kernel.RaiseEvent(e, _commandService.Log); + if (e.IsCanceled()) { return; } @@ -169,12 +170,10 @@ void ParseOptional(ref ReadOnlySpan input, int equals) { optionals[optional] = ParseArgument(ref input, parameterInfo); } - /* - * Parse all hyphenated arguments: - * 1) Short flags are single-character flags and use one hyphen: "-f". - * 2) Long flags are string flags and use two hyphens: "--force". - * 3) Optionals specify values with two hyphens: "--depth=10". - */ + // Parse all hyphenated arguments: + // 1) Short flags are single-character flags and use one hyphen: "-f". + // 2) Long flags are string flags and use two hyphens: "--force". + // 3) Optionals specify values with two hyphens: "--depth=10". void ParseHyphenatedArguments(ref ReadOnlySpan input) { input = input.TrimStart(); while (input.StartsWith("-")) { @@ -194,13 +193,11 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { } } - /* - * Parse a parameter: - * 1) If the parameter is an ICommandSender, then inject sender. - * 2) If the parameter is a bool and is marked with FlagAttribute, then inject the flag. - * 3) If the parameter is optional, then inject the optional or else the default value. - * 4) Otherwise, we parse the argument directly. - */ + // Parse a parameter: + // 1) If the parameter is an ICommandSender, then inject sender. + // 2) If the parameter is a bool and is marked with FlagAttribute, then inject the flag. + // 3) If the parameter is optional, then inject the optional or else the default value. + // 4) Otherwise, we parse the argument directly. object? ParseParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { var parameterType = parameterInfo.ParameterType; if (parameterType == typeof(ICommandSender)) { @@ -248,7 +245,7 @@ void ParseHyphenatedArguments(ref ReadOnlySpan input) { return ParseArgument(ref input, parameterInfo); } - var inputSpan = args.Input.AsSpan(); + var inputSpan = e.Input.AsSpan(); if (_validShortFlags.Count + _validLongFlags.Count + _validOptionals.Count > 0) { ParseHyphenatedArguments(ref inputSpan); } diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs index 262a29772..d83e68d2a 100644 --- a/src/TShock/Commands/TShockCommandService.cs +++ b/src/TShock/Commands/TShockCommandService.cs @@ -27,23 +27,15 @@ using TShock.Events.Commands; namespace TShock.Commands { - [Service("tshock-commands")] internal sealed class TShockCommandService : OrionService, ICommandService { private readonly Dictionary _commands = new Dictionary(); private readonly Dictionary _parsers = new Dictionary(); public IReadOnlyDictionary Commands => _commands; public IReadOnlyDictionary Parsers => _parsers; - public EventHandlerCollection CommandRegister { get; } - public EventHandlerCollection CommandExecute { get; } - public EventHandlerCollection CommandUnregister { get; } - public TShockCommandService(ILogger log) : base(log) { + public TShockCommandService(OrionKernel kernel, ILogger log) : base(kernel, log) { Debug.Assert(log != null, "log should not be null"); - - CommandRegister = new EventHandlerCollection(log); - CommandExecute = new EventHandlerCollection(log); - CommandUnregister = new EventHandlerCollection(log); } public IReadOnlyCollection RegisterCommands(object handlerObject) { @@ -52,9 +44,9 @@ public IReadOnlyCollection RegisterCommands(object handlerObject) { } ICommand? RegisterCommand(ICommand command) { - var args = new CommandRegisterEventArgs(command); - CommandRegister.Invoke(this, args); - if (args.IsCanceled()) { + var e = new CommandRegisterEvent(command); + Kernel.RaiseEvent(e, Log); + if (e.IsCanceled()) { return null; } @@ -76,9 +68,9 @@ public bool UnregisterCommand(ICommand command) { throw new ArgumentNullException(nameof(command)); } - var args = new CommandUnregisterEventArgs(command); - CommandUnregister.Invoke(this, args); - return !args.IsCanceled() && _commands.Remove(command.QualifiedName); + var e = new CommandUnregisterEvent(command); + Kernel.RaiseEvent(e, Log); + return !e.IsCanceled() && _commands.Remove(command.QualifiedName); } } } diff --git a/src/TShock/Events/Commands/CommandEventArgs.cs b/src/TShock/Events/Commands/CommandEvent.cs similarity index 72% rename from src/TShock/Events/Commands/CommandEventArgs.cs rename to src/TShock/Events/Commands/CommandEvent.cs index c1c66ccb0..277da4214 100644 --- a/src/TShock/Events/Commands/CommandEventArgs.cs +++ b/src/TShock/Events/Commands/CommandEvent.cs @@ -21,24 +21,27 @@ namespace TShock.Events.Commands { /// - /// Provides data for command-related events. + /// Represents a command-related event. /// - public abstract class CommandEventArgs : EventArgs, ICancelable { + public abstract class CommandEvent : Event { private ICommand _command; - /// - public string? CancellationReason { get; set; } - /// /// Gets or sets the command. /// + /// The command. /// is . public ICommand Command { get => _command; set => _command = value ?? throw new ArgumentNullException(nameof(value)); } - private protected CommandEventArgs(ICommand command) { + /// + /// Initializes a new instance of the class with the specified command. + /// + /// The command. + /// is . + protected CommandEvent(ICommand command) { _command = command ?? throw new ArgumentNullException(nameof(command)); } } diff --git a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs b/src/TShock/Events/Commands/CommandExecuteEvent.cs similarity index 73% rename from src/TShock/Events/Commands/CommandExecuteEventArgs.cs rename to src/TShock/Events/Commands/CommandExecuteEvent.cs index 16a8cb79e..b4cee9265 100644 --- a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs +++ b/src/TShock/Events/Commands/CommandExecuteEvent.cs @@ -16,17 +16,27 @@ // along with TShock. If not, see . using System; +using Destructurama.Attributed; using Orion.Events; +using Orion.Utils; using TShock.Commands; namespace TShock.Events.Commands { /// - /// Provides data for the event. + /// An event that occurs when a command executes. This event can be canceled and modified. /// - [EventArgs("command-execute")] - public sealed class CommandExecuteEventArgs : CommandEventArgs { + [Event("command-execute")] + public sealed class CommandExecuteEvent : CommandEvent, ICancelable, IDirtiable { private string _input; + /// + [NotLogged] + public string? CancellationReason { get; set; } + + /// + [NotLogged] + public bool IsDirty { get; private set; } + /// /// Gets the command sender. /// @@ -42,15 +52,18 @@ public string Input { } /// - /// Initializes a new instance of the class with the specified command, + /// Initializes a new instance of the class with the specified command, /// command sender, and input. /// /// The command. /// The command sender. /// The input. This does not include the command's name. - public CommandExecuteEventArgs(ICommand command, ICommandSender sender, string input) : base(command) { + public CommandExecuteEvent(ICommand command, ICommandSender sender, string input) : base(command) { Sender = sender ?? throw new ArgumentNullException(nameof(sender)); _input = input ?? throw new ArgumentNullException(nameof(input)); } + + /// + public void Clean() => IsDirty = false; } } diff --git a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs b/src/TShock/Events/Commands/CommandRegisterEvent.cs similarity index 71% rename from src/TShock/Events/Commands/CommandRegisterEventArgs.cs rename to src/TShock/Events/Commands/CommandRegisterEvent.cs index 6bfc869c1..21cc97b8c 100644 --- a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandRegisterEvent.cs @@ -16,20 +16,25 @@ // along with TShock. If not, see . using System; +using Destructurama.Attributed; using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// - /// Provides data for the event. + /// An event that occurs when a command is registered. This event can be canceled. /// - [EventArgs("command-register")] - public sealed class CommandRegisterEventArgs : CommandEventArgs { + [Event("command-register")] + public sealed class CommandRegisterEvent : CommandEvent, ICancelable { + /// + [NotLogged] + public string? CancellationReason { get; set; } + /// - /// Initializes a new instance of the class with the specified command. + /// Initializes a new instance of the class with the specified command. /// /// The command. /// is . - public CommandRegisterEventArgs(ICommand command) : base(command) { } + public CommandRegisterEvent(ICommand command) : base(command) { } } } diff --git a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs b/src/TShock/Events/Commands/CommandUnregisterEvent.cs similarity index 71% rename from src/TShock/Events/Commands/CommandUnregisterEventArgs.cs rename to src/TShock/Events/Commands/CommandUnregisterEvent.cs index 79b18e786..1e6c345dd 100644 --- a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandUnregisterEvent.cs @@ -16,20 +16,25 @@ // along with TShock. If not, see . using System; +using Destructurama.Attributed; using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// - /// Provides data for the event. + /// An event that occurs when a command is unregistered. This event can be canceled. /// - [EventArgs("command-unregister")] - public sealed class CommandUnregisterEventArgs : CommandEventArgs { + [Event("command-unregister")] + public sealed class CommandUnregisterEvent : CommandEvent, ICancelable { + /// + [NotLogged] + public string? CancellationReason { get; set; } + /// - /// Initializes a new instance of the class with the specified command. + /// Initializes a new instance of the class with the specified command. /// /// The command. /// is . - public CommandUnregisterEventArgs(ICommand command) : base(command) { } + public CommandUnregisterEvent(ICommand command) : base(command) { } } } diff --git a/src/TShock/Logging/PlayerLogValueVisitor.cs b/src/TShock/Logging/PlayerLogValueVisitor.cs index 1f53530e4..f1c58d307 100644 --- a/src/TShock/Logging/PlayerLogValueVisitor.cs +++ b/src/TShock/Logging/PlayerLogValueVisitor.cs @@ -25,9 +25,11 @@ using Unit = System.ValueTuple; namespace TShock.Logging { - [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + [SuppressMessage( + "Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "symbols should not be localized")] - [SuppressMessage("Design", "CA1062:Validate arguments of public methods", + [SuppressMessage( + "Design", "CA1062:Validate arguments of public methods", Justification = "validation has already occurred")] internal sealed class PlayerLogValueVisitor : LogEventPropertyValueVisitor { private readonly PlayerLogTheme _theme; diff --git a/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs b/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs index 1035c3a95..3fbf4b8de 100644 --- a/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs +++ b/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs @@ -32,7 +32,7 @@ public static class PlayerLoggerConfigurationExtensions { private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {Message}{NewLine}{Exception}"; /// - /// Writes log events to the given . + /// Writes log events to the given with optional settings. /// /// The logger sink configuration. /// The player. diff --git a/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs index 31ef9fc8a..aafc20801 100644 --- a/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs +++ b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs @@ -21,7 +21,9 @@ namespace TShock.Logging.Themes { /// /// Specifies an entity styled by a player log theme. /// - [SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "ignored")] + [SuppressMessage( + "Naming", "CA1720:Identifier contains type name", + Justification = "String is only a keyword in Visual Basic")] public enum PlayerLogThemeStyle { /// /// Represents text. diff --git a/src/TShock/Logging/Themes/PlayerLogThemes.cs b/src/TShock/Logging/Themes/PlayerLogThemes.cs index d4d57bf79..28718a97d 100644 --- a/src/TShock/Logging/Themes/PlayerLogThemes.cs +++ b/src/TShock/Logging/Themes/PlayerLogThemes.cs @@ -26,6 +26,7 @@ public static class PlayerLogThemes { /// /// Gets a theme that uses Visual Studio's colors wherever possible. /// + /// A theme that uses Visual Studio's colors wherever possible. public static PlayerLogTheme VisualStudio { get; } = new PlayerLogTheme(new Dictionary { [PlayerLogThemeStyle.Text] = new Color(0xdc, 0xdc, 0xdc), diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs index 42bf2a234..c881cb4e5 100644 --- a/src/TShock/Modules/CommandModule.cs +++ b/src/TShock/Modules/CommandModule.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using Microsoft.Xna.Framework; @@ -41,10 +42,10 @@ namespace TShock.Modules { /// internal sealed class CommandModule : TShockModule { private readonly OrionKernel _kernel; + private readonly ILogger _log; private readonly IPlayerService _playerService; private readonly ICommandService _commandService; - // Map from Terraria command -> command. This is used to unify Terraria and TShock commands. private readonly IDictionary _terrariaCommandToCommand = new Dictionary { ["Say"] = string.Empty, ["Emote"] = Resources.TerrariaCommand_Me, @@ -58,19 +59,19 @@ private readonly IDictionary> _nameToQualifiedName private readonly Random _rand = new Random(); - public CommandModule(OrionKernel kernel, IPlayerService playerService, ICommandService commandService) { + public CommandModule( + OrionKernel kernel, ILogger log, IPlayerService playerService, ICommandService commandService) { Debug.Assert(kernel != null, "kernel should not be null"); + Debug.Assert(log != null, "log should not be null"); Debug.Assert(playerService != null, "player service should not be null"); Debug.Assert(commandService != null, "command service should not be null"); _kernel = kernel; + _log = log; _playerService = playerService; _commandService = commandService; - - _kernel.ServerCommand.RegisterHandler(ServerCommandHandler); - _playerService.PlayerChat.RegisterHandler(PlayerChatHandler); - _commandService.CommandRegister.RegisterHandler(CommandRegisterHandler); - _commandService.CommandUnregister.RegisterHandler(CommandUnregisterHandler); + + _kernel.RegisterHandlers(this, _log); } public override void Initialize() { @@ -146,21 +147,19 @@ public string GetQualifiedName(string maybeQualifiedName) { } protected override void Dispose(bool disposeManaged) { - _kernel.ServerCommand.UnregisterHandler(ServerCommandHandler); - _playerService.PlayerChat.UnregisterHandler(PlayerChatHandler); - _commandService.CommandRegister.UnregisterHandler(CommandRegisterHandler); - _commandService.CommandUnregister.UnregisterHandler(CommandUnregisterHandler); + _kernel.UnregisterHandlers(this, _log); } - [EventHandler(EventPriority.Lowest, Name = "tshock")] - private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { - if (args.IsCanceled()) { + [EventHandler(Name = "tshock")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void ServerCommandHandler(ServerCommandEvent e) { + if (e.IsCanceled()) { return; } - args.Cancel("tshock: command executing"); + e.Cancel("tshock: command executing"); - var input = args.Input; + var input = e.Input; if (input.StartsWith('/')) { input = input.Substring(1); } @@ -168,33 +167,35 @@ private void ServerCommandHandler(object sender, ServerCommandEventArgs args) { ExecuteCommand(ConsoleCommandSender.Instance, input); } - [EventHandler(EventPriority.Lowest, Name = "tshock")] - private void PlayerChatHandler(object sender, PlayerChatEventArgs args) { - if (args.IsCanceled()) { + [EventHandler(Name = "tshock")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void PlayerChatHandler(PlayerChatEvent e) { + if (e.IsCanceled()) { return; } - var chatCommand = args.ChatCommand; + var chatCommand = e.Command; if (!_terrariaCommandToCommand.TryGetValue(chatCommand, out var canonicalCommand)) { - args.Cancel("tshock: Terraria command is invalid"); + e.Cancel("tshock: Terraria command is invalid"); return; } - var chat = canonicalCommand + args.ChatText; + var chat = canonicalCommand + e.Text; if (chat.StartsWith('/')) { - args.Cancel("tshock: command executing"); + e.Cancel("tshock: command executing"); - ICommandSender commandSender = args.Player.GetAnnotationOrDefault( + ICommandSender commandSender = e.Player.GetAnnotationOrDefault( "tshock:CommandSender", - () => new PlayerCommandSender(args.Player), true); + () => new PlayerCommandSender(e.Player), true); var input = chat.Substring(1); ExecuteCommand(commandSender, input); } } [EventHandler(EventPriority.Monitor, Name = "tshock")] - private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args) { - var qualifiedName = args.Command.QualifiedName; + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void CommandRegisterHandler(CommandRegisterEvent e) { + var qualifiedName = e.Command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); _nameToQualifiedName .GetValueOrDefault(name, () => new HashSet(), true) @@ -202,8 +203,9 @@ private void CommandRegisterHandler(object sender, CommandRegisterEventArgs args } [EventHandler(EventPriority.Monitor, Name = "tshock")] - private void CommandUnregisterHandler(object sender, CommandUnregisterEventArgs args) { - var qualifiedName = args.Command.QualifiedName; + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void CommandUnregisterHandler(CommandUnregisterEvent e) { + var qualifiedName = e.Command.QualifiedName; var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); _nameToQualifiedName .GetValueOrDefault(name, () => new HashSet(), true) diff --git a/src/TShock/TShock.csproj b/src/TShock/TShock.csproj index 302291a91..95386279d 100644 --- a/src/TShock/TShock.csproj +++ b/src/TShock/TShock.csproj @@ -35,6 +35,7 @@ + all diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index f06e151a8..7c7cf9db0 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -38,17 +38,18 @@ public sealed class TShockPlugin : OrionPlugin { private readonly ISet _modules = new HashSet(); /// - /// Initializes a new instance of the class with the specified Orion kernel, log, - /// and services. + /// Initializes a new instance of the class with the specified + /// instance, log, and services. /// - /// The Orion kernel. + /// The instance. /// The log. /// The player service. /// The command service. /// /// , , or any of the services are . /// - public TShockPlugin(OrionKernel kernel, ILogger log, Lazy playerService, + public TShockPlugin( + OrionKernel kernel, ILogger log, Lazy playerService, Lazy commandService) : base(kernel, log) { Kernel.Bind().To(); @@ -71,24 +72,25 @@ public void RegisterModule(TShockModule module) { /// public override void Initialize() { - Kernel.ServerInitialize.RegisterHandler(ServerInitializeHandler); - - RegisterModule(new CommandModule(Kernel, _playerService.Value, _commandService.Value)); + Kernel.RegisterHandlers(this, Log); + RegisterModule(new CommandModule(Kernel, Log, _playerService.Value, _commandService.Value)); } /// public override void Dispose() { + Kernel.UnregisterHandlers(this, Log); foreach (var module in _modules) { module.Dispose(); } - - Kernel.ServerInitialize.UnregisterHandler(ServerInitializeHandler); } - [EventHandler(EventPriority.Normal, Name = "tshock")] - private void ServerInitializeHandler(object sender, ServerInitializeEventArgs args) { + [EventHandler] + [SuppressMessage("Usage", "CA1801:Review unused parameters:", Justification = "Parameter is required")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Parameter is required")] + private void OnServerInitialize(ServerInitializeEvent e) { // The reason why we want to wait for ServerInitialize to initialize the modules is because we need three - // stages of initialization: + // three stages of initialization: // // 1) Plugin constructors, which use lazy services. // 2) Plugin Initialize, which can set up service event handlers, diff --git a/src/TShock/Utils/Extensions/DictionaryExtensions.cs b/src/TShock/Utils/Extensions/DictionaryExtensions.cs index bfb65ac75..c9305cdb0 100644 --- a/src/TShock/Utils/Extensions/DictionaryExtensions.cs +++ b/src/TShock/Utils/Extensions/DictionaryExtensions.cs @@ -45,8 +45,9 @@ public static class DictionaryExtensions { /// /// or are . /// - public static TValue GetValueOrDefault(this IDictionary dictionary, TKey key, - Func? defaultValueProvider = null, bool createIfNotExists = false) { + public static TValue GetValueOrDefault( + this IDictionary dictionary, TKey key, Func? defaultValueProvider = null, + bool createIfNotExists = false) { if (dictionary is null) { throw new ArgumentNullException(nameof(dictionary)); } diff --git a/src/TShock/Utils/ResourceHelper.cs b/src/TShock/Utils/ResourceHelper.cs index 2eb052d9a..de49b0d4e 100644 --- a/src/TShock/Utils/ResourceHelper.cs +++ b/src/TShock/Utils/ResourceHelper.cs @@ -37,7 +37,8 @@ public static class ResourceHelper { /// /// or are . /// - [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + [SuppressMessage( + "Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "strings are not user-facing")] public static T LoadResource(Type resourceType, string name) { if (resourceType is null) { diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs index 8fe121fc9..aa73386ae 100644 --- a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -60,7 +60,7 @@ public void SendMessage() { _sender.SendMessage("test"); _mockPlayer.Verify(p => p.SendPacket( - It.Is(cp => cp.ChatColor == Color.White && cp.ChatText == "test"))); + It.Is(cp => cp.Color == Color.White && cp.Text == "test"))); _mockPlayer.VerifyNoOtherCalls(); } @@ -69,7 +69,7 @@ public void SendMessage_WithColor() { _sender.SendMessage("test", Color.Orange); _mockPlayer.Verify(p => p.SendPacket( - It.Is(cp => cp.ChatColor == Color.Orange && cp.ChatText == "test"))); + It.Is(cp => cp.Color == Color.Orange && cp.Text == "test"))); _mockPlayer.VerifyNoOtherCalls(); } } diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs index 6b1dc253a..11fe07564 100644 --- a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -20,41 +20,45 @@ using System.Linq; using FluentAssertions; using Moq; +using Orion; using Orion.Events; using Serilog.Core; using TShock.Commands.Parsers; +using TShock.Events.Commands; using Xunit; namespace TShock.Commands { - public class TShockCommandServiceTests : IDisposable { - private readonly TShockCommandService _commandService = new TShockCommandService(Logger.None); - - public void Dispose() => _commandService.Dispose(); - + public class TShockCommandServiceTests { [Fact] public void Commands_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var commands = _commandService.RegisterCommands(testClass).ToList(); + var commands = commandService.RegisterCommands(testClass).ToList(); - _commandService.Commands.Keys.Should().Contain( + commandService.Commands.Keys.Should().Contain( new[] { "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test" }); - _commandService.Commands.Values.Should().Contain(commands); + commandService.Commands.Values.Should().Contain(commands); } [Fact] public void Parsers_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var parser = new Mock>().Object; - _commandService.RegisterParser(parser); + commandService.RegisterParser(parser); - _commandService.Parsers.Should().Contain(new KeyValuePair(typeof(object), parser)); + commandService.Parsers.Should().Contain(new KeyValuePair(typeof(object), parser)); } [Fact] public void RegisterCommands() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var commands = _commandService.RegisterCommands(testClass).ToList(); + var commands = commandService.RegisterCommands(testClass).ToList(); commands.Should().HaveCount(3); foreach (var command in commands) { @@ -64,98 +68,112 @@ public void RegisterCommands() { [Fact] public void RegisterCommands_NullObj_ThrowsArgumentNullException() { - Func> func = () => _commandService.RegisterCommands(null); + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + Func> func = () => commandService.RegisterCommands(null); func.Should().Throw(); } [Fact] public void RegisterParser_NullParser_ThrowsArgumentNullException() { - Action action = () => _commandService.RegisterParser(null); + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + Action action = () => commandService.RegisterParser(null); action.Should().Throw(); } [Fact] public void UnregisterCommand() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var commands = _commandService.RegisterCommands(testClass).ToList(); + var commands = commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; - _commandService.UnregisterCommand(command).Should().BeTrue(); + commandService.UnregisterCommand(command).Should().BeTrue(); - _commandService.Commands.Keys.Should().NotContain(command.QualifiedName); - _commandService.Commands.Values.Should().NotContain(command); + commandService.Commands.Keys.Should().NotContain(command.QualifiedName); + commandService.Commands.Values.Should().NotContain(command); } [Fact] public void UnregisterCommand_CommandDoesntExist_ReturnsFalse() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var mockCommand = new Mock(); mockCommand.SetupGet(c => c.QualifiedName).Returns("test"); - _commandService.UnregisterCommand(mockCommand.Object).Should().BeFalse(); + commandService.UnregisterCommand(mockCommand.Object).Should().BeFalse(); } [Fact] public void UnregisterCommand_NullCommand_ThrowsArgumentNullException() { - Func func = () => _commandService.UnregisterCommand(null); + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + Func func = () => commandService.UnregisterCommand(null); func.Should().Throw(); } [Fact] public void CommandRegister_IsTriggered() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var isRun = false; var testClass = new TestClass(); - _commandService.CommandRegister.RegisterHandler((sender, args) => { + kernel.RegisterHandler(e => { isRun = true; - args.Command.QualifiedName.Should().BeOneOf( + e.Command.QualifiedName.Should().BeOneOf( "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); - }); + }, Logger.None); - _commandService.RegisterCommands(testClass); + commandService.RegisterCommands(testClass); isRun.Should().BeTrue(); } [Fact] public void CommandRegister_Canceled() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - _commandService.CommandRegister.RegisterHandler((sender, args) => { - args.Cancel(); - }); + kernel.RegisterHandler(e => e.Cancel(), Logger.None); - _commandService.RegisterCommands(testClass).Should().BeEmpty(); + commandService.RegisterCommands(testClass).Should().BeEmpty(); } [Fact] public void CommandUnregister_IsTriggered() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var isRun = false; var testClass = new TestClass(); - var commands = _commandService.RegisterCommands(testClass).ToList(); + var commands = commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; - _commandService.CommandUnregister.RegisterHandler((sender, args) => { + kernel.RegisterHandler(e => { isRun = true; - args.Command.Should().BeSameAs(command); - }); + e.Command.Should().BeSameAs(command); + }, Logger.None); - _commandService.UnregisterCommand(command); + commandService.UnregisterCommand(command); isRun.Should().BeTrue(); } [Fact] public void CommandUnregister_Canceled() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var commands = _commandService.RegisterCommands(testClass).ToList(); + var commands = commandService.RegisterCommands(testClass).ToList(); var command = commands[0]; - _commandService.CommandUnregister.RegisterHandler((sender, args) => { - args.Cancel(); - }); + kernel.RegisterHandler(e => e.Cancel(), Logger.None); - _commandService.UnregisterCommand(command).Should().BeFalse(); + commandService.UnregisterCommand(command).Should().BeFalse(); - _commandService.Commands.Values.Should().Contain(command); + commandService.Commands.Values.Should().Contain(command); } private class TestClass { diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index a6d8cf2f0..8de5c389a 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -21,6 +21,7 @@ using System.Reflection; using FluentAssertions; using Moq; +using Orion; using Orion.Events; using Serilog.Core; using TShock.Commands.Exceptions; @@ -32,23 +33,13 @@ namespace TShock.Commands { public class TShockCommandTests { - private readonly Mock _mockCommandService = new Mock(); - - public TShockCommandTests() { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary { - [typeof(int)] = new Int32Parser(), - [typeof(string)] = new StringParser() - }); - _mockCommandService - .Setup(cs => cs.CommandExecute) - .Returns(new EventHandlerCollection(Logger.None)); - } - [Fact] public void QualifiedName_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var attribute = new CommandHandlerAttribute("test"); var command = new TShockCommand( - _mockCommandService.Object, "", + commandService, "", typeof(TShockCommandTests).GetMethod(nameof(QualifiedName_Get)), attribute); command.QualifiedName.Should().Be("test"); @@ -56,9 +47,11 @@ public void QualifiedName_Get() { [Fact] public void HelpText_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var attribute = new CommandHandlerAttribute("test") { HelpText = "HelpTest" }; var command = new TShockCommand( - _mockCommandService.Object, "", + commandService, "", typeof(TShockCommandTests).GetMethod(nameof(HelpText_Get)), attribute); command.HelpText.Should().Be("HelpTest"); @@ -66,9 +59,11 @@ public void HelpText_Get() { [Fact] public void HelpText_GetMissing() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var attribute = new CommandHandlerAttribute("test"); var command = new TShockCommand( - _mockCommandService.Object, "", + commandService, "", typeof(TShockCommandTests).GetMethod(nameof(HelpText_GetMissing)), attribute); command.HelpText.Should().Be(Resources.Command_MissingHelpText); @@ -76,9 +71,11 @@ public void HelpText_GetMissing() { [Fact] public void UsageText_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var attribute = new CommandHandlerAttribute("test") { UsageText = "UsageTest" }; var command = new TShockCommand( - _mockCommandService.Object, "", + commandService, "", typeof(TShockCommandTests).GetMethod(nameof(UsageText_Get)), attribute); command.UsageText.Should().Be("UsageTest"); @@ -86,9 +83,11 @@ public void UsageText_Get() { [Fact] public void UsageText_GetMissing() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var attribute = new CommandHandlerAttribute("test"); var command = new TShockCommand( - _mockCommandService.Object, "", + commandService, "", typeof(TShockCommandTests).GetMethod(nameof(UsageText_GetMissing)), attribute); command.UsageText.Should().Be(Resources.Command_MissingUsageText); @@ -96,9 +95,11 @@ public void UsageText_GetMissing() { [Fact] public void ShouldBeLogged_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var attribute = new CommandHandlerAttribute("test") { ShouldBeLogged = false }; var command = new TShockCommand( - _mockCommandService.Object, "", + commandService, "", typeof(TShockCommandTests).GetMethod(nameof(ShouldBeLogged_Get)), attribute); command.ShouldBeLogged.Should().Be(false); @@ -106,8 +107,10 @@ public void ShouldBeLogged_Get() { [Fact] public void Invoke_Sender() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; command.Invoke(commandSender, ""); @@ -119,8 +122,13 @@ public void Invoke_Sender() { [InlineData("1 test", 1, "test")] [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] public void Invoke_SenderIntString(string input, int expectedInt, string expectedString) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Int_String)); var commandSender = new Mock().Object; command.Invoke(commandSender, input); @@ -140,8 +148,13 @@ public void Invoke_SenderIntString(string input, int expectedInt, string expecte [InlineData(" -x --yyy", true, true)] [InlineData("--xxx --yyy", true, true)] public void Invoke_Flags(string input, bool expectedX, bool expectedY) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; command.Invoke(commandSender, input); @@ -161,8 +174,13 @@ public void Invoke_Flags(string input, bool expectedX, bool expectedY) { [InlineData("--val2=5678 1", 1, 1234, 5678)] [InlineData(" --val2=5678 1", 1, 1234, 5678)] public void Invoke_Optionals(string input, int expectedRequired, int expectedVal, int expectedVal2) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Optionals)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Optionals)); var commandSender = new Mock().Object; command.Invoke(commandSender, input); @@ -187,10 +205,15 @@ public void Invoke_Optionals(string input, int expectedRequired, int expectedVal [InlineData("--depth=1 --recursive -f", true, true, 1)] [InlineData("--force -r --depth=100 ", true, true, 100)] [InlineData("--force -r --depth= 100 ", true, true, 100)] - public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expectedRecursive, - int expectedDepth) { + public void Invoke_FlagsAndOptionals( + string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); var commandSender = new Mock().Object; command.Invoke(commandSender, input); @@ -207,8 +230,13 @@ public void Invoke_FlagsAndOptionals(string input, bool expectedForce, bool expe [InlineData("1 2", 1, 2)] [InlineData(" -1 2 -5", -1, 2, -5)] public void Invoke_Params(string input, params int[] expectedInts) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Params)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Params)); var commandSender = new Mock().Object; command.Invoke(commandSender, input); @@ -219,8 +247,13 @@ public void Invoke_Params(string input, params int[] expectedInts) { [Fact] public void Invoke_OptionalGetsRenamed() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_OptionalRename)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_OptionalRename)); var commandSender = new Mock().Object; command.Invoke(commandSender, "--hyphenated-optional-is-long=60"); @@ -231,17 +264,18 @@ public void Invoke_OptionalGetsRenamed() { [Fact] public void Invoke_TriggersCommandExecute() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; + var isRun = false; - var commandExecute = new EventHandlerCollection(Logger.None); - commandExecute.RegisterHandler((sender, args) => { + kernel.RegisterHandler(e => { isRun = true; - args.Command.Should().Be(command); - args.Input.Should().BeEmpty(); - }); - _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); + e.Command.Should().Be(command); + e.Input.Should().BeEmpty(); + }, Logger.None); command.Invoke(commandSender, ""); @@ -251,14 +285,12 @@ public void Invoke_TriggersCommandExecute() { [Fact] public void Invoke_CommandExecuteCanceled_IsCanceled() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; - var commandExecute = new EventHandlerCollection(Logger.None); - commandExecute.RegisterHandler((sender, args) => { - args.Cancel(); - }); - _mockCommandService.SetupGet(cs => cs.CommandExecute).Returns(commandExecute); + kernel.RegisterHandler(e => e.Cancel(), Logger.None); command.Invoke(commandSender, "failing input"); } @@ -267,8 +299,10 @@ public void Invoke_CommandExecuteCanceled_IsCanceled() { [InlineData("1 ")] [InlineData("-7345734 ")] public void Invoke_MissingArg_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Int_String)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Int_String)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); @@ -279,8 +313,10 @@ public void Invoke_MissingArg_ThrowsCommandParseException(string input) { [InlineData("-xyz")] [InlineData("-z")] public void Invoke_UnexpectedShortFlag_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); @@ -291,8 +327,10 @@ public void Invoke_UnexpectedShortFlag_ThrowsCommandParseException(string input) [InlineData("--this-is-not-ok")] [InlineData("--neither-is-this")] public void Invoke_UnexpectedLongFlag_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Flags)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Flags)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); @@ -303,8 +341,10 @@ public void Invoke_UnexpectedLongFlag_ThrowsCommandParseException(string input) [InlineData("--required=123")] [InlineData("--not-ok=test")] public void Invoke_UnexpectedOptional_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Optionals)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Optionals)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); @@ -318,8 +358,10 @@ public void Invoke_UnexpectedOptional_ThrowsCommandParseException(string input) [InlineData("-- ")] [InlineData("--= ")] public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); @@ -328,9 +370,10 @@ public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string inpu [Fact] public void Invoke_UnexpectedArgType_ThrowsCommandParseException() { - _mockCommandService.Setup(cs => cs.Parsers).Returns(new Dictionary()); + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_NoTestClass)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_NoTestClass)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, ""); @@ -341,8 +384,10 @@ public void Invoke_UnexpectedArgType_ThrowsCommandParseException() { [InlineData("a")] [InlineData("bcd")] public void Invoke_TooManyArguments_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, input); @@ -351,8 +396,10 @@ public void Invoke_TooManyArguments_ThrowsCommandParseException(string input) { [Fact] public void Invoke_ThrowsException_ThrowsCommandException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand_Exception)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Exception)); var commandSender = new Mock().Object; Action action = () => command.Invoke(commandSender, ""); @@ -361,17 +408,19 @@ public void Invoke_ThrowsException_ThrowsCommandException() { [Fact] public void Invoke_NullSender_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); var testClass = new TestClass(); - var command = GetCommand(testClass, nameof(TestClass.TestCommand)); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); Action action = () => command.Invoke(null, ""); action.Should().Throw(); } - private ICommand GetCommand(TestClass testClass, string methodName) { + private ICommand GetCommand(TShockCommandService commandService, TestClass testClass, string methodName) { var handler = typeof(TestClass).GetMethod(methodName); var attribute = handler.GetCustomAttribute(); - return new TShockCommand(_mockCommandService.Object, testClass, handler, attribute); + return new TShockCommand(commandService, testClass, handler, attribute); } [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] @@ -416,8 +465,9 @@ public void TestCommand_Optionals(ICommandSender sender, int required, int val = } [CommandHandler("tshock_tests:test_flags_and_optionals")] - public void TestCommand_FlagsAndOptionals(ICommandSender sender, [Flag("f", "force")] bool force, - [Flag("r", "recursive")] bool recursive, int depth = 10) { + public void TestCommand_FlagsAndOptionals( + ICommandSender sender, [Flag("f", "force")] bool force, [Flag("r", "recursive")] bool recursive, + int depth = 10) { Sender = sender; Force = force; Recursive = recursive; diff --git a/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs b/tests/TShock.Tests/Events/Commands/CommandEventTests.cs similarity index 73% rename from tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs rename to tests/TShock.Tests/Events/Commands/CommandEventTests.cs index f4fccc146..89c4262cd 100644 --- a/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs +++ b/tests/TShock.Tests/Events/Commands/CommandEventTests.cs @@ -22,10 +22,10 @@ using Xunit; namespace TShock.Events.Commands { - public class CommandEventArgsTests { + public class CommandEventTests { [Fact] public void Ctor_NullCommand_ThrowsArgumentNullException() { - Func func = () => new TestCommandEventArgs(null); + Func func = () => new TestCommandEvent(null); func.Should().Throw(); } @@ -33,22 +33,22 @@ public void Ctor_NullCommand_ThrowsArgumentNullException() { [Fact] public void Command_Get() { var command = new Mock().Object; - var args = new TestCommandEventArgs(command); + var e = new TestCommandEvent(command); - args.Command.Should().BeSameAs(command); + e.Command.Should().BeSameAs(command); } [Fact] public void Command_SetNullValue_ThrowsArgumentNullException() { var command = new Mock().Object; - var args = new TestCommandEventArgs(command); - Action action = () => args.Command = null; + var e = new TestCommandEvent(command); + Action action = () => e.Command = null; action.Should().Throw(); } - private class TestCommandEventArgs : CommandEventArgs { - public TestCommandEventArgs(ICommand command) : base(command) { } + private class TestCommandEvent : CommandEvent { + public TestCommandEvent(ICommand command) : base(command) { } } } } diff --git a/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs b/tests/TShock.Tests/Events/Commands/CommandExecuteEventTests.cs similarity index 68% rename from tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs rename to tests/TShock.Tests/Events/Commands/CommandExecuteEventTests.cs index acbed37cb..f2995dae4 100644 --- a/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs +++ b/tests/TShock.Tests/Events/Commands/CommandExecuteEventTests.cs @@ -22,11 +22,19 @@ using Xunit; namespace TShock.Events.Commands { - public class CommandExecuteEventArgsTests { + public class CommandExecuteEventTests { + [Fact] + public void Ctor_NullCommand_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Func func = () => new CommandExecuteEvent(null, sender, ""); + + func.Should().Throw(); + } + [Fact] public void Ctor_NullSender_ThrowsArgumentNullException() { var command = new Mock().Object; - Func func = () => new CommandExecuteEventArgs(command, null, ""); + Func func = () => new CommandExecuteEvent(command, null, ""); func.Should().Throw(); } @@ -35,7 +43,7 @@ public void Ctor_NullSender_ThrowsArgumentNullException() { public void Ctor_NullInput_ThrowsArgumentNullException() { var command = new Mock().Object; var sender = new Mock().Object; - Func func = () => new CommandExecuteEventArgs(command, sender, null); + Func func = () => new CommandExecuteEvent(command, sender, null); func.Should().Throw(); } @@ -44,26 +52,26 @@ public void Ctor_NullInput_ThrowsArgumentNullException() { public void Sender_Get() { var command = new Mock().Object; var sender = new Mock().Object; - var args = new CommandExecuteEventArgs(command, sender, ""); + var e = new CommandExecuteEvent(command, sender, ""); - args.Sender.Should().BeSameAs(sender); + e.Sender.Should().BeSameAs(sender); } [Fact] public void Input_Get() { var command = new Mock().Object; var sender = new Mock().Object; - var args = new CommandExecuteEventArgs(command, sender, "test"); + var e = new CommandExecuteEvent(command, sender, "test"); - args.Input.Should().Be("test"); + e.Input.Should().Be("test"); } [Fact] public void Input_SetNullValue_ThrowsArgumentNullException() { var command = new Mock().Object; var sender = new Mock().Object; - var args = new CommandExecuteEventArgs(command, sender, ""); - Action action = () => args.Input = null; + var e = new CommandExecuteEvent(command, sender, ""); + Action action = () => e.Input = null; action.Should().Throw(); } diff --git a/tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs b/tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs new file mode 100644 index 000000000..6400887e3 --- /dev/null +++ b/tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using TShock.Commands; +using Xunit; + +namespace TShock.Events.Commands { + public class CommandRegisterEventTests { + [Fact] + public void Ctor_NullCommand_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Func func = () => new CommandRegisterEvent(null); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs b/tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs new file mode 100644 index 000000000..8f088e939 --- /dev/null +++ b/tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using TShock.Commands; +using Xunit; + +namespace TShock.Events.Commands { + public class CommandUnregisterEventTests { + [Fact] + public void Ctor_NullCommand_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Func func = () => new CommandUnregisterEvent(null); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs index 71c18bbfd..0fbb3996a 100644 --- a/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs +++ b/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs @@ -42,7 +42,7 @@ public void Emit() { MessageTemplate.Empty, Enumerable.Empty())); mockPlayer.Verify(p => p.SendPacket( - It.Is(cp => cp.ChatColor == Color.White && cp.ChatText == "TEST"))); + It.Is(cp => cp.Color == Color.White && cp.Text == "TEST"))); } [Fact] @@ -59,7 +59,7 @@ public void Emit_EndsInNewLine() { MessageTemplate.Empty, Enumerable.Empty())); mockPlayer.Verify(p => p.SendPacket( - It.Is(cp => cp.ChatColor == Color.White && cp.ChatText == "TEST"))); + It.Is(cp => cp.Color == Color.White && cp.Text == "TEST"))); } } } diff --git a/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs b/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs index 8fc0307a5..a34026965 100644 --- a/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs +++ b/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs @@ -40,7 +40,7 @@ public void Player() { logger.Error("FAIL"); - mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.ChatText.Contains("[c/123456:FAIL]")))); + mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.Text.Contains("[c/123456:FAIL]")))); } [Fact] @@ -52,7 +52,7 @@ public void Player_NullTheme() { logger.Error("FAIL"); - mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.ChatText.Contains("[c/dcdcdc:FAIL]")))); + mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.Text.Contains("[c/dcdcdc:FAIL]")))); } [Fact] diff --git a/tests/TShock.Tests/TShockPluginTests.cs b/tests/TShock.Tests/TShockPluginTests.cs index 12f4798b2..8087a150a 100644 --- a/tests/TShock.Tests/TShockPluginTests.cs +++ b/tests/TShock.Tests/TShockPluginTests.cs @@ -19,7 +19,6 @@ using FluentAssertions; using Moq; using Orion; -using Orion.Events; using Orion.Events.Players; using Orion.Events.Server; using Orion.Players; @@ -31,39 +30,46 @@ namespace TShock { public class TShockPluginTests { - private readonly OrionKernel _kernel = new OrionKernel(Logger.None); - private readonly TShockPlugin _plugin; - private readonly Mock _mockPlayerService = new Mock(); - private readonly Mock _mockCommandService = new Mock(); - - public TShockPluginTests() { - _plugin = new TShockPlugin(_kernel, Logger.None, - new Lazy(() => _mockPlayerService.Object), - new Lazy(() => _mockCommandService.Object)); - } - [Fact] public void Ctor_NullKernel_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); Func func = () => new TShockPlugin(null, Logger.None, - new Lazy(() => _mockPlayerService.Object), - new Lazy(() => _mockCommandService.Object)); + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); func.Should().Throw(); } [Fact] public void Ctor_NullPlayerService_ThrowsArgumentNullException() { - Func func = () => new TShockPlugin(_kernel, Logger.None, + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Func func = () => new TShockPlugin(kernel, Logger.None, null, - new Lazy(() => _mockCommandService.Object)); + new Lazy(() => mockCommandService.Object)); func.Should().Throw(); } [Fact] public void Ctor_NullCommandService_ThrowsArgumentNullException() { - Func func = () => new TShockPlugin(_kernel, Logger.None, - new Lazy(() => _mockPlayerService.Object), + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Func func = () => new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), null); func.Should().Throw(); @@ -71,37 +77,46 @@ public void Ctor_NullCommandService_ThrowsArgumentNullException() { [Fact] public void RegisterModule_NullModule_ThrowsArgumentNullException() { - Action action = () => _plugin.RegisterModule(null); + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Action action = () => plugin.RegisterModule(null); action.Should().Throw(); } [Fact] public void Dispose_DisposesModule() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); var module = new TestModule(); - _plugin.RegisterModule(module); + plugin.RegisterModule(module); - _plugin.Dispose(); + plugin.Dispose(); module.IsDisposed.Should().BeTrue(); } [Fact] public void ServerInitialize_InitializesModule() { - _mockPlayerService - .Setup(ps => ps.PlayerChat) - .Returns(new EventHandlerCollection(Logger.None)); - _mockCommandService - .Setup(ps => ps.CommandRegister) - .Returns(new EventHandlerCollection(Logger.None)); - _mockCommandService - .Setup(ps => ps.CommandUnregister) - .Returns(new EventHandlerCollection(Logger.None)); + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); var module = new TestModule(); - _plugin.Initialize(); - _plugin.RegisterModule(module); + plugin.Initialize(); + plugin.RegisterModule(module); - _kernel.ServerInitialize.Invoke(this, new ServerInitializeEventArgs()); + kernel.RaiseEvent(new ServerInitializeEvent(), Logger.None); module.IsInitialized.Should().BeTrue(); } diff --git a/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs index 783dea45e..a3291ef12 100644 --- a/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs +++ b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs @@ -1,19 +1,19 @@ -// Copyright (c) 2019 Pryaxis & Orion Contributors +// Copyright (c) 2019 Pryaxis & TShock Contributors // -// This file is part of Orion. +// This file is part of TShock. // -// Orion is free software: you can redistribute it and/or modify +// TShock is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // -// Orion is distributed in the hope that it will be useful, +// TShock is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License -// along with Orion. If not, see . +// along with TShock. If not, see . using System.Collections.Generic; using FluentAssertions; From bad3e4e6aef5a1778ac957735a621d937e5d20ae Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 23 Oct 2019 00:33:52 -0700 Subject: [PATCH 118/119] Code cleanup. --- src/TShock/Events/Commands/CommandExecuteEvent.cs | 2 +- src/TShock/Modules/CommandModule.cs | 6 ++---- tests/TShock.Tests/Commands/TShockCommandTests.cs | 1 - tests/TShock.Tests/TShockPluginTests.cs | 2 -- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/TShock/Events/Commands/CommandExecuteEvent.cs b/src/TShock/Events/Commands/CommandExecuteEvent.cs index b4cee9265..875835699 100644 --- a/src/TShock/Events/Commands/CommandExecuteEvent.cs +++ b/src/TShock/Events/Commands/CommandExecuteEvent.cs @@ -32,7 +32,7 @@ public sealed class CommandExecuteEvent : CommandEvent, ICancelable, IDirtiable /// [NotLogged] public string? CancellationReason { get; set; } - + /// [NotLogged] public bool IsDirty { get; private set; } diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs index c881cb4e5..89b7789a6 100644 --- a/src/TShock/Modules/CommandModule.cs +++ b/src/TShock/Modules/CommandModule.cs @@ -70,7 +70,7 @@ public CommandModule( _log = log; _playerService = playerService; _commandService = commandService; - + _kernel.RegisterHandlers(this, _log); } @@ -146,9 +146,7 @@ public string GetQualifiedName(string maybeQualifiedName) { return qualifiedNames.Single(); } - protected override void Dispose(bool disposeManaged) { - _kernel.UnregisterHandlers(this, _log); - } + protected override void Dispose(bool disposeManaged) => _kernel.UnregisterHandlers(this, _log); [EventHandler(Name = "tshock")] [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs index 8de5c389a..70dbcfb6c 100644 --- a/tests/TShock.Tests/Commands/TShockCommandTests.cs +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -16,7 +16,6 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using FluentAssertions; diff --git a/tests/TShock.Tests/TShockPluginTests.cs b/tests/TShock.Tests/TShockPluginTests.cs index 8087a150a..68bf81a05 100644 --- a/tests/TShock.Tests/TShockPluginTests.cs +++ b/tests/TShock.Tests/TShockPluginTests.cs @@ -19,12 +19,10 @@ using FluentAssertions; using Moq; using Orion; -using Orion.Events.Players; using Orion.Events.Server; using Orion.Players; using Serilog.Core; using TShock.Commands; -using TShock.Events.Commands; using TShock.Modules; using Xunit; From 9ad0be9cab5b8e2b0cd4ee5ff819f13ba8f450b5 Mon Sep 17 00:00:00 2001 From: Kevin Zhao Date: Wed, 23 Oct 2019 00:35:39 -0700 Subject: [PATCH 119/119] Simplify module Dispose pattern. --- src/TShock/Modules/CommandModule.cs | 4 ++-- src/TShock/Modules/TShockModule.cs | 22 +++++++++------------- tests/TShock.Tests/TShockPluginTests.cs | 2 +- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs index 89b7789a6..f04a67be4 100644 --- a/src/TShock/Modules/CommandModule.cs +++ b/src/TShock/Modules/CommandModule.cs @@ -74,6 +74,8 @@ public CommandModule( _kernel.RegisterHandlers(this, _log); } + public override void Dispose() => _kernel.UnregisterHandlers(this, _log); + public override void Initialize() { _commandService.RegisterParser(new Int32Parser()); _commandService.RegisterParser(new DoubleParser()); @@ -146,8 +148,6 @@ public string GetQualifiedName(string maybeQualifiedName) { return qualifiedNames.Single(); } - protected override void Dispose(bool disposeManaged) => _kernel.UnregisterHandlers(this, _log); - [EventHandler(Name = "tshock")] [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] private void ServerCommandHandler(ServerCommandEvent e) { diff --git a/src/TShock/Modules/TShockModule.cs b/src/TShock/Modules/TShockModule.cs index 49357994a..4d3582a51 100644 --- a/src/TShock/Modules/TShockModule.cs +++ b/src/TShock/Modules/TShockModule.cs @@ -16,31 +16,27 @@ // along with TShock. If not, see . using System; +using System.Diagnostics.CodeAnalysis; namespace TShock.Modules { /// /// Represents a module of TShock's functionality. /// + [SuppressMessage( + "Design", "CA1063:Implement IDisposable Correctly", + Justification = "IDisposable pattern makes no sense")] public abstract class TShockModule : IDisposable { /// - /// Disposes the module and any of its managed and unmanaged resources. + /// Disposes the service, releasing any resources associated with it. /// - public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } + [SuppressMessage( + "Usage", "CA1816:Dispose methods should call SuppressFinalize", + Justification = "IDisposable pattern makes no sense")] + public virtual void Dispose() { } /// /// Initializes the module. Typically, commands should be registered here. /// public abstract void Initialize(); - - /// - /// Disposes the module and any of its unmanaged resources, optionally including its managed resources. - /// - /// - /// to dispose managed resources, otherwise, . - /// - protected abstract void Dispose(bool disposeManaged); } } diff --git a/tests/TShock.Tests/TShockPluginTests.cs b/tests/TShock.Tests/TShockPluginTests.cs index 68bf81a05..fa389a626 100644 --- a/tests/TShock.Tests/TShockPluginTests.cs +++ b/tests/TShock.Tests/TShockPluginTests.cs @@ -123,8 +123,8 @@ private class TestModule : TShockModule { public bool IsDisposed { get; private set; } public bool IsInitialized { get; private set; } + public override void Dispose() => IsDisposed = true; public override void Initialize() => IsInitialized = true; - protected override void Dispose(bool disposeManaged) => IsDisposed = true; } } }