diff --git a/Resources/Locale/en-US/commands.ftl b/Resources/Locale/en-US/commands.ftl new file mode 100644 index 000000000..c0e386926 --- /dev/null +++ b/Resources/Locale/en-US/commands.ftl @@ -0,0 +1,86 @@ +### Localization for engine console commands + +## 'help' command +cmd-help-desc = Display general help or help text for a specific command +cmd-help-help = Usage: help [command name] + When no command name is provided, displays general-purpose help text. If a command name is provided, displays help text for that command. + +cmd-help-no-args = To display help for a specific command, write 'help '. To list all available commands, write 'list'. To search for commands, use 'list '. +cmd-help-unknown = Unknown command: { $command } +cmd-help-top = { $command } - { $description } +cmd-help-invalid-args = Invalid amount of arguments. +cmd-help-arg-cmdname = [command name] + +## 'cvar' command +cmd-cvar-desc = Gets or sets a CVar. +cmd-cvar-help = Usage: cvar [value] + If a value is passed, the value is parsed and stored as the new value of the CVar. + If not, the current value of the CVar is displayed. + Use 'cvar ?' to get a list of all registered CVars. + +cmd-cvar-invalid-args = Must provide exactly one or two arguments. +cmd-cvar-not-registered = CVar '{ $cvar }' is not registered. Use 'cvar ?' to get a list of all registered CVars. +cmd-cvar-parse-error = Input value is in incorrect format for type { $type } +cmd-cvar-compl-list = List available CVars +cmd-cvar-arg-name = + +## 'list' command +cmd-list-desc = Lists available commands, with optional search filter +cmd-list-help = Usage: list [filter] + Lists all available commands. If an argument is provided, it will be used to filter commands by name. + +cmd-list-heading = SIDE NAME DESC{"\u000A"}-------------------------{"\u000A"} + +cmd-list-arg-filter = [filter] + +## '>' command, aka remote exec +cmd-remoteexec-desc = Executes server-side commands +cmd-remoteexec-help = Usage: > [arg] [arg] [arg...] + Executes a command on the server. This is necessary if a command with the same name exists on the client, as simply running the command would run the client command first. + +## 'gc' command +cmd-gc-desc = Run the GC (Garbage Collector) +cmd-gc-help = Usage: gc [generation] + Uses GC.Collect() to execute the Garbage Collector. + If an argument is provided, it is parsed as a GC generation number and GC.Collect(int) is used. + Use the 'gfc' command to do an LOH-compacting full GC. +cmd-gc-failed-parse = Failed to parse argument. +cmd-gc-arg-generation = [generation] + +## 'gcf' command +cmd-gcf-desc = Run the GC, fully, compacting LOH and everything. +cmd-gcf-help = Usage: gcf + Does a full GC.Collect(2, GCCollectionMode.Forced, true, true) while also compacting LOH. + This will probably lock up for hundreds of milliseconds, be warned. + +## 'gc_mode' command +cmd-gc_mode-desc = Change/Read the GC Latency mode +cmd-gc_mode-help = Usage: gc_mode [type] + If no argument is provided, returns the current GC latency mode. + If an argument is passed, it is parsed as GCLatencyMode and set as the GC latency mode. + +cmd-gc_mode-current = current gc latency mode: { $prevMode } +cmd-gc_mode-possible = possible modes: +cmd-gc_mode-option = - { $mode } +cmd-gc_mode-unknown = unknown gc latency mode: { $arg } +cmd-gc_mode-attempt = attempting gc latency mode change: { $prevMode } -> { $mode } +cmd-gc_mode-result = resulting gc latency mode: { $mode } +cmd-gc_mode-arg-type = [type] + +## 'mem' command +cmd-mem-desc = Prints managed memory info +cmd-mem-help = Usage: mem + +cmd-mem-report = Heap Size: { TOSTRING($heapSize, "N0") } + Total Allocated: { TOSTRING($totalAllocated, "N0") } + +## 'lsasm' command +cmd-lsasm-desc = Lists loaded assemblies by load context +cmd-lsasm-help = Usage: lsasm + +## 'exec' command +cmd-exec-desc = Executes a script file from the game's writeable user data +cmd-exec-help = Usage: exec + Each line in the file is executed as a single command, unless it starts with a # + +cmd-exec-arg-filename = diff --git a/Robust.Client/Console/ClientConsoleHost.Completions.cs b/Robust.Client/Console/ClientConsoleHost.Completions.cs new file mode 100644 index 000000000..5c988f997 --- /dev/null +++ b/Robust.Client/Console/ClientConsoleHost.Completions.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Robust.Shared; +using Robust.Shared.Console; +using Robust.Shared.Network.Messages; + +namespace Robust.Client.Console; + +internal sealed partial class ClientConsoleHost +{ + private readonly Dictionary _completionsPending = new(); + private int _completionSeq; + + + public async Task GetCompletions(List args, CancellationToken cancel) + { + // Last element is the command currently being typed. May be empty. + + // Logger.Debug($"Running completions: {string.Join(", ", args)}"); + + var delay = _cfg.GetCVar(CVars.ConCompletionDelay); + if (delay > 0) + await Task.Delay((int)(delay * 1000), cancel); + + return await CalcCompletions(args, cancel); + } + + private Task CalcCompletions(List args, CancellationToken cancel) + { + if (args.Count == 1) + { + // Typing out command name, handle this ourselves. + var cmdOptions = CompletionResult.FromOptions( + RegisteredCommands.Values.Select(c => new CompletionOption(c.Command, c.Description))); + return Task.FromResult(cmdOptions); + } + + if (!RegisteredCommands.TryGetValue(args[0], out var cmd)) + return Task.FromResult(CompletionResult.Empty); + + return cmd.GetCompletionAsync(LocalShell, args.ToArray()[1..], cancel).AsTask(); + } + + private Task DoServerCompletions(List args, CancellationToken cancel) + { + var tcs = new TaskCompletionSource(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); + var seq = _completionSeq++; + + var pending = new PendingCompletion + { + Cts = cts, + Tcs = tcs + }; + + var msg = new MsgConCompletion + { + Args = args.ToArray(), + Seq = seq + }; + + cts.Token.Register(() => + { + tcs.SetCanceled(cts.Token); + cts.Dispose(); + _completionsPending.Remove(seq); + }, true); + + NetManager.ClientSendMessage(msg); + + _completionsPending.Add(seq, pending); + + return tcs.Task; + } + + private void ProcessCompletionResp(MsgConCompletionResp message) + { + if (!_completionsPending.TryGetValue(message.Seq, out var pending)) + return; + + pending.Cts.Dispose(); + pending.Tcs.SetResult(message.Result); + + _completionsPending.Remove(message.Seq); + } + + private struct PendingCompletion + { + public TaskCompletionSource Tcs; + public CancellationTokenSource Cts; + } +} diff --git a/Robust.Client/Console/ClientConsoleHost.cs b/Robust.Client/Console/ClientConsoleHost.cs index 6c68cf617..7d3459434 100644 --- a/Robust.Client/Console/ClientConsoleHost.cs +++ b/Robust.Client/Console/ClientConsoleHost.cs @@ -1,10 +1,15 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Robust.Client.Log; using Robust.Client.Player; +using Robust.Shared.Configuration; using Robust.Shared.Console; using Robust.Shared.Enums; using Robust.Shared.IoC; +using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Network; using Robust.Shared.Network.Messages; @@ -41,9 +46,10 @@ namespace Robust.Client.Console } /// - internal sealed class ClientConsoleHost : ConsoleHost, IClientConsoleHost + internal sealed partial class ClientConsoleHost : ConsoleHost, IClientConsoleHost, IConsoleHostInternal { [Dependency] private readonly IClientConGroupController _conGroup = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; private bool _requestedCommands; @@ -53,6 +59,8 @@ namespace Robust.Client.Console NetManager.RegisterNetMessage(HandleConCmdReg); NetManager.RegisterNetMessage(HandleConCmdAck); NetManager.RegisterNetMessage(ProcessCommand); + NetManager.RegisterNetMessage(); + NetManager.RegisterNetMessage(ProcessCompletionResp); _requestedCommands = false; NetManager.Connected += OnNetworkConnected; @@ -88,6 +96,11 @@ namespace Robust.Client.Console OutputText(text, true, true); } + public bool IsCmdServer(IConsoleCommand cmd) + { + return cmd is ServerDummyCommand; + } + public override event ConAnyCommandCallback? AnyCommandExecuted; /// @@ -108,8 +121,8 @@ namespace Robust.Client.Console if (AvailableCommands.ContainsKey(commandName)) { - var playerManager = IoCManager.Resolve(); #if !DEBUG + var playerManager = IoCManager.Resolve(); if (!_conGroup.CanCommand(commandName) && playerManager.LocalPlayer?.Session.Status > SessionStatus.Connecting) { WriteError(null, $"Insufficient perms for command: {commandName}"); @@ -203,31 +216,64 @@ namespace Robust.Client.Console _requestedCommands = true; } - } - /// - /// These dummies are made purely so list and help can list server-side commands. - /// - [Reflect(false)] - internal sealed class ServerDummyCommand : IConsoleCommand - { - internal ServerDummyCommand(string command, string help, string description) + /// + /// These dummies are made purely so list and help can list server-side commands. + /// + [Reflect(false)] + private sealed class ServerDummyCommand : IConsoleCommand { - Command = command; - Help = help; - Description = description; + internal ServerDummyCommand(string command, string help, string description) + { + Command = command; + Help = help; + Description = description; + } + + public string Command { get; } + + public string Description { get; } + + public string Help { get; } + + // Always forward to server. + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + shell.RemoteExecuteCommand(argStr); + } + + public async ValueTask GetCompletionAsync( + IConsoleShell shell, + string[] args, + CancellationToken cancel) + { + var host = (ClientConsoleHost)shell.ConsoleHost; + var argsList = args.ToList(); + argsList.Insert(0, Command); + + return await host.DoServerCompletions(argsList, cancel); + } } - public string Command { get; } - - public string Description { get; } - - public string Help { get; } - - // Always forward to server. - public void Execute(IConsoleShell shell, string argStr, string[] args) + private sealed class RemoteExecCommand : IConsoleCommand { - shell.RemoteExecuteCommand(argStr); + public string Command => ">"; + public string Description => Loc.GetString("cmd-remoteexec-desc"); + public string Help => Loc.GetString("cmd-remoteexec-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + shell.RemoteExecuteCommand(argStr["> ".Length..]); + } + + public async ValueTask GetCompletionAsync( + IConsoleShell shell, + string[] args, + CancellationToken cancel) + { + var host = (ClientConsoleHost)shell.ConsoleHost; + return await host.DoServerCompletions(args.ToList(), cancel); + } } } } diff --git a/Robust.Client/Console/Commands/ConfigurationCommands.cs b/Robust.Client/Console/Commands/ConfigurationCommands.cs index cf8c67735..fa7787a8d 100644 --- a/Robust.Client/Console/Commands/ConfigurationCommands.cs +++ b/Robust.Client/Console/Commands/ConfigurationCommands.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using JetBrains.Annotations; using Robust.Shared.Configuration; using Robust.Shared.Console; @@ -7,57 +5,6 @@ using Robust.Shared.IoC; namespace Robust.Client.Console.Commands { - [UsedImplicitly] - internal sealed class CVarCommand : SharedCVarCommand, IConsoleCommand - { - public override void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length < 1 || args.Length > 2) - { - shell.WriteError("Must provide exactly one or two arguments."); - return; - } - - var configManager = IoCManager.Resolve(); - var name = args[0]; - - if (name == "?") - { - var cvars = configManager.GetRegisteredCVars().OrderBy(c => c); - shell.WriteLine(string.Join("\n", cvars)); - return; - } - - if (!configManager.IsCVarRegistered(name)) - { - shell.WriteError($"CVar '{name}' is not registered. Use 'cvar ?' to get a list of all registered CVars."); - return; - } - - if (args.Length == 1) - { - // Read CVar - var value = configManager.GetCVar(name); - shell.WriteLine(value.ToString() ?? ""); - } - else - { - // Write CVar - var value = args[1]; - var type = configManager.GetCVarType(name); - try - { - var parsed = ParseObject(type, value); - configManager.SetCVar(name, parsed); - } - catch (FormatException) - { - shell.WriteLine($"Input value is in incorrect format for type {type}"); - } - } - } - } - [UsedImplicitly] public sealed class SaveConfig : IConsoleCommand { diff --git a/Robust.Client/Console/Commands/Debug.cs b/Robust.Client/Console/Commands/Debug.cs index fcfeb55e6..ac29b589b 100644 --- a/Robust.Client/Console/Commands/Debug.cs +++ b/Robust.Client/Console/Commands/Debug.cs @@ -739,103 +739,6 @@ namespace Robust.Client.Console.Commands } } - internal sealed class GcCommand : IConsoleCommand - { - public string Command => "gc"; - public string Description => "Run the GC."; - public string Help => "gc [generation]"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length == 0) - { - GC.Collect(); - } - else - { - if (int.TryParse(args[0], out int result)) - GC.Collect(result); - else - shell.WriteError("Failed to parse argument."); - } - } - } - - internal sealed class GcFullCommand : IConsoleCommand - { - public string Command => "gcf"; - public string Description => "Run the GC, fully, compacting LOH and everything."; - public string Help => "gcf"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; - GC.Collect(2, GCCollectionMode.Forced, true, true); - } - } - - internal sealed class GcModeCommand : IConsoleCommand - { - - public string Command => "gc_mode"; - - public string Description => "Change/Read the GC Latency mode."; - - public string Help => "gc_mode\nSee current GC Latencymode\ngc_mode [type]\n Change GC Latency mode to [type]"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - var prevMode = GCSettings.LatencyMode; - if (args.Length == 0) - { - shell.WriteLine($"current gc latency mode: {(int) prevMode} ({prevMode})"); - shell.WriteLine("possible modes:"); - foreach (int mode in (int[]) Enum.GetValues(typeof(GCLatencyMode))) - { - shell.WriteLine($" {mode}: {Enum.GetName(typeof(GCLatencyMode), mode)}"); - } - } - else - { - GCLatencyMode mode; - if (char.IsDigit(args[0][0]) && int.TryParse(args[0], out var modeNum)) - { - mode = (GCLatencyMode) modeNum; - } - else if (!Enum.TryParse(args[0], true, out mode)) - { - shell.WriteLine($"unknown gc latency mode: {args[0]}"); - return; - } - - shell.WriteLine($"attempting gc latency mode change: {(int) prevMode} ({prevMode}) -> {(int) mode} ({mode})"); - GCSettings.LatencyMode = mode; - shell.WriteLine($"resulting gc latency mode: {(int) GCSettings.LatencyMode} ({GCSettings.LatencyMode})"); - } - } - - } - - internal sealed class SerializeStatsCommand : IConsoleCommand - { - - public string Command => "szr_stats"; - - public string Description => "Report serializer statistics."; - - public string Help => "szr_stats"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - - shell.WriteLine($"serialized: {RobustSerializer.BytesSerialized} bytes, {RobustSerializer.ObjectsSerialized} objects"); - shell.WriteLine($"largest serialized: {RobustSerializer.LargestObjectSerializedBytes} bytes, {RobustSerializer.LargestObjectSerializedType} objects"); - shell.WriteLine($"deserialized: {RobustSerializer.BytesDeserialized} bytes, {RobustSerializer.ObjectsDeserialized} objects"); - shell.WriteLine($"largest serialized: {RobustSerializer.LargestObjectDeserializedBytes} bytes, {RobustSerializer.LargestObjectDeserializedType} objects"); - } - - } - internal sealed class ChunkInfoCommand : IConsoleCommand { public string Command => "chunkinfo"; diff --git a/Robust.Client/Console/Commands/HelpCommands.cs b/Robust.Client/Console/Commands/HelpCommands.cs deleted file mode 100644 index a5e1f2935..000000000 --- a/Robust.Client/Console/Commands/HelpCommands.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Linq; -using Robust.Shared.Console; -using Robust.Shared.IoC; -using Robust.Shared.Network; - -namespace Robust.Client.Console.Commands -{ - sealed class HelpCommand : IConsoleCommand - { - public string Command => "help"; - public string Help => "When no arguments are provided, displays a generic help text. When an argument is passed, display the help text for the command with that name."; - public string Description => "Display help text."; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - switch (args.Length) - { - case 0: - shell.WriteLine("To display help for a specific command, write 'help '. To list all available commands, write 'list'."); - break; - - case 1: - string commandname = args[0]; - if (!shell.ConsoleHost.RegisteredCommands.ContainsKey(commandname)) - { - if (!IoCManager.Resolve().IsConnected) - { - // No server so nothing to respond with unknown command. - shell.WriteError("Unknown command: " + commandname); - return; - } - // TODO: Maybe have a server side help? - return; - } - IConsoleCommand command = shell.ConsoleHost.RegisteredCommands[commandname]; - shell.WriteLine(string.Format("{0} - {1}", command.Command, command.Description)); - shell.WriteLine(command.Help); - break; - - default: - shell.WriteError("Invalid amount of arguments."); - break; - } - } - } - - sealed class ListCommand : IConsoleCommand - { - public string Command => "list"; - public string Help => "Usage: list [filter]\n" + - "Lists all available commands, and their short descriptions.\n" + - "If a filter is provided, " + - "only commands that contain the given string in their name will be listed."; - public string Description => "List all commands, optionally with a filter."; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - var filter = ""; - if (args.Length == 1) - { - filter = args[0]; - } - - var conGroup = IoCManager.Resolve(); - foreach (var command in shell.ConsoleHost.RegisteredCommands.Values - .Where(p => p.Command.Contains(filter) && (p is not ServerDummyCommand || conGroup.CanCommand(p.Command))) - .OrderBy(c => c.Command)) - { - shell.WriteLine(command.Command + ": " + command.Description); - } - } - } -} diff --git a/Robust.Client/Console/Commands/ListAssembliesCommand.cs b/Robust.Client/Console/Commands/ListAssembliesCommand.cs deleted file mode 100644 index 3a9969d4f..000000000 --- a/Robust.Client/Console/Commands/ListAssembliesCommand.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Linq; -using System.Runtime.Loader; -using JetBrains.Annotations; -using Robust.Shared.Console; - -namespace Robust.Client.Console.Commands -{ - [UsedImplicitly] - internal sealed class ListAssembliesCommand : IConsoleCommand - { - public string Command => "lsasm"; - public string Description => "Lists loaded assemblies by load context."; - public string Help => Command; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - foreach (var context in AssemblyLoadContext.All) - { - shell.WriteLine($"{context.Name}:"); - foreach (var assembly in context.Assemblies.OrderBy(a => a.FullName)) - { - shell.WriteLine($" {assembly.FullName}"); - } - } - } - } -} diff --git a/Robust.Client/Console/Commands/LogCommands.cs b/Robust.Client/Console/Commands/LogCommands.cs deleted file mode 100644 index dff620264..000000000 --- a/Robust.Client/Console/Commands/LogCommands.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Robust.Shared.Log; -using System; -using Robust.Shared.Console; - -namespace Robust.Client.Console.Commands -{ - sealed class LogSetLevelCommand : IConsoleCommand - { - public string Command => "loglevel"; - public string Description => "Changes the log level for a provided sawmill."; - public string Help => "Usage: loglevel " - + "\n sawmill: A label prefixing log messages. This is the one you're setting the level for." - + "\n level: The log level. Must match one of the values of the LogLevel enum."; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length != 2) - { - shell.WriteError("Invalid argument amount. Expected 2 arguments."); - return; - } - - var name = args[0]; - var levelname = args[1]; - LogLevel? level; - if (levelname == "null") - { - level = null; - } - else - { - if (!Enum.TryParse(levelname, out var result)) - { - shell.WriteLine("Failed to parse 2nd argument. Must be one of the values of the LogLevel enum."); - return; - } - level = result; - } - Logger.GetSawmill(name).Level = level; - } - } - - sealed class TestLog : IConsoleCommand - { - public string Command => "testlog"; - public string Description => "Writes a test log to a sawmill."; - public string Help => "Usage: testlog " - + "\n sawmill: A label prefixing the logged message." - + "\n level: The log level. Must match one of the values of the LogLevel enum." - + "\n message: The message to be logged. Wrap this in double quotes if you want to use spaces."; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length != 3) - { - shell.WriteError("Invalid argument amount. Expected 3 arguments."); - return; - } - - var name = args[0]; - var levelname = args[1]; - var message = args[2]; // yes this doesn't support spaces idgaf. - if (!Enum.TryParse(levelname, out var result)) - { - shell.WriteLine("Failed to parse 2nd argument. Must be one of the values of the LogLevel enum."); - return; - } - var level = result; - - Logger.LogS(level, name, message); - } - } -} diff --git a/Robust.Client/Console/IClientConsoleHost.cs b/Robust.Client/Console/IClientConsoleHost.cs index c7b2fa3ae..aeb62b5ed 100644 --- a/Robust.Client/Console/IClientConsoleHost.cs +++ b/Robust.Client/Console/IClientConsoleHost.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Robust.Shared.Console; using Robust.Shared.Utility; @@ -15,5 +18,7 @@ namespace Robust.Client.Console event EventHandler AddFormatted; void AddFormattedLine(FormattedMessage message); + + Task GetCompletions(List args, CancellationToken cancel); } } diff --git a/Robust.Client/Input/EngineContexts.cs b/Robust.Client/Input/EngineContexts.cs index 7c3646aaa..837cea1eb 100644 --- a/Robust.Client/Input/EngineContexts.cs +++ b/Robust.Client/Input/EngineContexts.cs @@ -61,6 +61,8 @@ namespace Robust.Client.Input common.AddFunction(EngineKeyFunctions.TextScrollToBottom); common.AddFunction(EngineKeyFunctions.TextDelete); common.AddFunction(EngineKeyFunctions.TextTabComplete); + common.AddFunction(EngineKeyFunctions.TextCompleteNext); + common.AddFunction(EngineKeyFunctions.TextCompletePrev); var editor = contexts.New("editor", common); editor.AddFunction(EngineKeyFunctions.EditorLinePlace); diff --git a/Robust.Client/UserInterface/Controls/LineEdit.cs b/Robust.Client/UserInterface/Controls/LineEdit.cs index 7cad0320b..87a86d127 100644 --- a/Robust.Client/UserInterface/Controls/LineEdit.cs +++ b/Robust.Client/UserInterface/Controls/LineEdit.cs @@ -281,6 +281,8 @@ namespace Robust.Client.UserInterface.Controls return finalSize; } + public event Action? OnTextTyped; + protected internal override void TextEntered(GUITextEventArgs args) { base.TextEntered(args); @@ -297,8 +299,11 @@ namespace Robust.Client.UserInterface.Controls } InsertAtCursor(args.AsRune.ToString()); + OnTextTyped?.Invoke(args); } + public event Action? OnBackspace; + protected internal override void KeyBindDown(GUIBoundKeyEventArgs args) { base.KeyBindDown(args); @@ -315,6 +320,9 @@ namespace Robust.Client.UserInterface.Controls if (Editable) { var changed = false; + var oldText = _text; + var cursor = _cursorPosition; + var selectStart = _selectionStart; if (_selectionStart != _cursorPosition) { _text = _text.Remove(SelectionLower, SelectionLength); @@ -341,6 +349,7 @@ namespace Robust.Client.UserInterface.Controls _selectionStart = _cursorPosition; OnTextChanged?.Invoke(new LineEditEventArgs(this, _text)); _updatePseudoClass(); + OnBackspace?.Invoke(new LineEditBackspaceEventArgs(oldText, _text, cursor, selectStart)); } } @@ -654,6 +663,35 @@ namespace Robust.Client.UserInterface.Controls return index; } + /// + /// Get offset from the left of the control + /// to the left edge of the text glyph at the specified index in the text. + /// + /// + /// The returned value can be outside the bounds of the control if the glyph is currently clipped off. + /// + public float GetOffsetAtIndex(int index) + { + var style = _getStyleBox(); + var contentBox = style.GetContentBox(PixelSizeBox); + + var font = _getFont(); + var i = 0; + var chrPosX = contentBox.Left - _drawOffset; + foreach (var rune in _text.EnumerateRunes()) + { + if (i >= index) + break; + + if (font.TryGetCharMetrics(rune, UIScale, out var metrics)) + chrPosX += metrics.Advance; + + i += rune.Utf16SequenceLength; + } + + return chrPosX / UIScale; + } + protected internal override void KeyboardFocusEntered() { base.KeyboardFocusEntered(); @@ -822,6 +860,26 @@ namespace Robust.Client.UserInterface.Controls } } + public sealed class LineEditBackspaceEventArgs : EventArgs + { + public string OldText { get; } + public string NewText { get; } + public int OldCursorPosition { get; } + public int OldSelectionStart { get; } + + public LineEditBackspaceEventArgs( + string oldText, + string newText, + int oldCursorPosition, + int oldSelectionStart) + { + OldText = oldText; + NewText = newText; + OldCursorPosition = oldCursorPosition; + OldSelectionStart = oldSelectionStart; + } + } + /// /// Use a separate control to do the rendering to make use of RectClipContent, /// so that we can clip characters in half. diff --git a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs new file mode 100644 index 000000000..9c01e791a --- /dev/null +++ b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.Completions.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Robust.Client.UserInterface.Controls; +using Robust.Shared; +using Robust.Shared.Console; +using Robust.Shared.Input; +using Robust.Shared.Maths; +using Robust.Shared.Utility; +using Robust.Shared.Utility.Collections; + +namespace Robust.Client.UserInterface.CustomControls; + +public sealed partial class DebugConsole +{ + private readonly DebugConsoleCompletion _compPopup; + + // Last valid completion result we got. + private CompletionResult? _compCurResult; + + // The parameter count for the above completion result. + // Used to immediately invalidate it if the amount changes. + private int _compParamCount; + + // The filtered set of completions currently shown to the user. + private CompletionOption[]? _compFiltered; + + // Which completion is currently selected, index into _compFiltered. + private int _compSelected; + + // Vertical scroll offset of the completion list. + private int _compVerticalOffset; + + // Used for sequencing to nicely handle out-of-order completion responses. + private int _compSeqSend; + private int _compSeqRecv; + + + private CancellationTokenSource _compCancel = new(); + + private void InitCompletions() + { + CommandBar.OnTextTyped += CommandBarOnOnTextTyped; + CommandBar.OnFocusExit += CommandBarOnOnFocusExit; + CommandBar.OnBackspace += CommandBarOnOnBackspace; + } + + private void CommandBarOnOnFocusExit(LineEdit.LineEditEventArgs obj) + { + AbortActiveCompletions(); + } + + private void CommandBarOnOnTextTyped(GUITextEventArgs obj) + { + TypeUpdateCompletions(true); + } + + private void CommandBarOnOnBackspace(LineEdit.LineEditBackspaceEventArgs eventArgs) + { + if (eventArgs.OldCursorPosition == 0 || eventArgs.OldSelectionStart != eventArgs.OldCursorPosition) + { + AbortActiveCompletions(); + return; + } + + if (CommandBar.CursorPosition == 0) + { + // Don't do completions if you have nothing typed. + AbortActiveCompletions(); + return; + } + + TypeUpdateCompletions(true); + } + + + private void AbortActiveCompletions() + { + _compCurResult = null; + _compFiltered = null; + _compSelected = 0; + _compVerticalOffset = 0; + _compPopup.Close(); + + _compCancel.Cancel(); + _compCancel.Dispose(); + _compCancel = new CancellationTokenSource(); + } + + private async void TypeUpdateCompletions(bool fullUpdate) + { + var (args, _, _) = CalcTypingArgs(); + + if (args.Count != _compParamCount) + { + _compParamCount = args.Count; + AbortActiveCompletions(); + } + + if (fullUpdate) + { + var seq = ++_compSeqSend; + var task = _consoleHost.GetCompletions(args, _compCancel.Token); + + if (!task.IsCompleted) + { + // If we don't immediately get a result from the console (e.g. server command), + // we update the filtered immediately before asynchronously waiting on it. + UpdateFilteredCompletions(); + + // This means we only update completions once when running synchronously. + } + + CompletionResult result; + try + { + result = await task; + } + catch (OperationCanceledException) + { + return; + } + + if (seq < _compSeqRecv) + { + // Newer result already came before us. + return; + } + + _compSeqRecv = seq; + _compCurResult = result; + } + + UpdateFilteredCompletions(); + } + + private void UpdateFilteredCompletions() + { + if (_compCurResult == null) + return; + + var (_, curTyping, _) = CalcTypingArgs(); + + var curSelected = _compFiltered?.Length > 0 ? _compFiltered[_compSelected] : default; + _compFiltered = FilterCompletions(_compCurResult.Options, curTyping); + if (curSelected == default) + { + _compSelected = 0; + } + else + { + var foundIdx = Array.IndexOf(_compFiltered, curSelected); + _compSelected = foundIdx > 0 ? foundIdx : 0; + } + + FixScrollMargins(); + + // Logger.Debug($"Filtered completions: {string.Join(", ", _compFiltered)}"); + + UpdateCompletionsPopup(); + } + + private void UpdateCompletionsPopup() + { + if (_compFiltered == null) + return; + + DebugTools.AssertNotNull(_compCurResult); + + var (_, _, endRange) = CalcTypingArgs(); + + var offset = CommandBar.GetOffsetAtIndex(endRange.start); + // Logger.Debug($"Offset: {offset}"); + + _compPopup.Close(); + + _compPopup.Contents.RemoveAllChildren(); + + if (_compCurResult!.Hint != null) + { + var hint = _compCurResult.Hint; + _compPopup.Contents.AddChild(new Label + { + Text = hint, + FontColorOverride = Color.Gray + }); + } + + // Fill out list completions. + var maxCount = _cfg.GetCVar(CVars.ConCompletionCount); + var c = 0; + for (var i = _compVerticalOffset; i < _compFiltered.Length && c < maxCount; i++, c++) + { + var (value, hint, _) = _compFiltered[i]; + + var labelValue = new Label + { + Text = value, + FontColorOverride = i == _compSelected ? Color.White : Color.DarkGray + }; + + if (hint != null) + { + _compPopup.Contents.AddChild(new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + Children = + { + labelValue, + new Label + { + Text = $" - {hint}", + FontColorOverride = Color.Gray + } + } + }); + } + else + { + _compPopup.Contents.AddChild(labelValue); + } + } + + if (_compPopup.Contents.ChildCount != 0) + { + _compPopup.Open( + UIBox2.FromDimensions( + offset - _compPopup.Contents.Margin.Left, CommandBar.GlobalPosition.Y + CommandBar.Height + 2, + 5, 5)); + } + } + + private (List args, string curTyping, (int start, int end) lastRange) CalcTypingArgs() + { + var cursor = CommandBar.CursorPosition; + // Don't consider text after the cursor. + var text = CommandBar.Text.AsSpan(0, cursor); + + var args = new List(); + var ranges = new ValueList<(int start, int end)>(); + CommandParsing.ParseArguments(text, args, ref ranges); + + if (args.Count == 0 || ranges[^1].end != text.Length) + args.Add(""); + + (int, int) lastRange; + if (ranges.Count == 0) + lastRange = default; + else if (ranges.Count == args.Count) + lastRange = ranges[^1]; + else + lastRange = (cursor, cursor); + + return (args, args[^1], lastRange); + } + + private CompletionOption[] FilterCompletions(IEnumerable completions, string curTyping) + { + return completions + .Where(c => c.Value.StartsWith(curTyping, StringComparison.CurrentCultureIgnoreCase)) + .ToArray(); + } + + private void CompletionKeyDown(GUIBoundKeyEventArgs args) + { + if (args.Function == EngineKeyFunctions.TextTabComplete) + { + if (_compFiltered != null && _compSelected < _compFiltered.Length) + { + // Figure out typing word so we know how much to replace. + var (completion, _, completionFlags) = _compFiltered[_compSelected]; + var (_, _, lastRange) = CalcTypingArgs(); + + // Replace the full word from the start. + // This means that letter casing will match the completion suggestion. + CommandBar.CursorPosition = lastRange.end; + CommandBar.SelectionStart = lastRange.start; + var insertValue = CommandParsing.Escape(completion); + if ((completionFlags & CompletionOptionFlags.PartialCompletion) == 0) + insertValue += " "; + + CommandBar.InsertAtCursor(insertValue); + + TypeUpdateCompletions(true); + + args.Handle(); + } + + return; + } + + if (args.Function == EngineKeyFunctions.TextCompleteNext) + { + if (_compFiltered == null) + return; + + args.Handle(); + var len = _compFiltered.Length; + var pos = (_compSelected + 1) % len; + _compSelected = pos; + + FixScrollMargins(); + + UpdateCompletionsPopup(); + + return; + } + + if (args.Function == EngineKeyFunctions.TextCompletePrev) + { + if (_compFiltered == null) + return; + + args.Handle(); + var len = _compFiltered.Length; + var pos = MathHelper.Mod(_compSelected - 1, len); + _compSelected = pos; + + FixScrollMargins(); + + UpdateCompletionsPopup(); + + return; + } + } + + private void FixScrollMargins() + { + if (_compFiltered == null) + return; + + var maxCount = _cfg.GetCVar(CVars.ConCompletionCount); + var showCount = Math.Min(maxCount, _compFiltered.Length); + var margin = _cfg.GetCVar(CVars.ConCompletionMargin); + + var posBottom = showCount + _compVerticalOffset - margin; + if (_compSelected >= posBottom) + _compVerticalOffset = Math.Min(_compFiltered.Length - showCount, _compSelected + 1 + margin - showCount); + + if (_compSelected < _compVerticalOffset + margin) + _compVerticalOffset = Math.Max(0, _compSelected - margin); + } + + private void CompletionCommandEntered() + { + AbortActiveCompletions(); + } +} diff --git a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.cs b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.cs index f7b1020d9..b8b49034c 100644 --- a/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.cs +++ b/Robust.Client/UserInterface/CustomControls/DebugConsole.xaml.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading.Tasks; using Robust.Client.AutoGenerated; using Robust.Client.Console; -using Robust.Client.Graphics; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; +using Robust.Shared.Configuration; using Robust.Shared.ContentPack; using Robust.Shared.Input; using Robust.Shared.IoC; @@ -44,21 +43,20 @@ namespace Robust.Client.UserInterface.CustomControls { [Dependency] private readonly IClientConsoleHost _consoleHost = default!; [Dependency] private readonly IResourceManager _resourceManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; private static readonly ResourcePath HistoryPath = new("/debug_console_history.json"); private readonly ConcurrentQueue _messageQueue = new(); - private bool commandChanged = true; - private readonly List searchResults; - private int searchIndex = 0; - public DebugConsole() { RobustXamlLoader.Load(this); IoCManager.InjectDependencies(this); + InitCompletions(); + CommandBar.OnTextChanged += OnCommandChanged; CommandBar.OnKeyBindDown += CommandBarOnOnKeyBindDown; CommandBar.OnTextEntered += CommandEntered; @@ -66,7 +64,7 @@ namespace Robust.Client.UserInterface.CustomControls _loadHistoryFromDisk(); - searchResults = new List(); + _compPopup = new DebugConsoleCompletion(); } protected override void EnteredTree() @@ -76,6 +74,8 @@ namespace Robust.Client.UserInterface.CustomControls _consoleHost.AddString += OnAddString; _consoleHost.AddFormatted += OnAddFormatted; _consoleHost.ClearText += OnClearText; + + UserInterfaceManager.ModalRoot.AddChild(_compPopup); } protected override void ExitedTree() @@ -85,6 +85,8 @@ namespace Robust.Client.UserInterface.CustomControls _consoleHost.AddString -= OnAddString; _consoleHost.AddFormatted -= OnAddFormatted; _consoleHost.ClearText -= OnClearText; + + UserInterfaceManager.ModalRoot.RemoveChild(_compPopup); } private void OnClearText(object? _, EventArgs args) @@ -120,9 +122,11 @@ namespace Robust.Client.UserInterface.CustomControls { _consoleHost.ExecuteCommand(args.Text); CommandBar.Clear(); + + CompletionCommandEntered(); } - commandChanged = true; + // commandChanged = true; } private void OnHistoryChanged() @@ -173,80 +177,15 @@ namespace Robust.Client.UserInterface.CustomControls { Output.ScrollToBottom(); args.Handle(); - } - else if (args.Function == EngineKeyFunctions.GuiTabNavigateNext) - { - NextCommand(); - args.Handle(); - } - else if (args.Function == EngineKeyFunctions.GuiTabNavigatePrev) - { - PrevCommand(); - args.Handle(); - } - } - - private void SetInput(string cmd) - { - CommandBar.Text = cmd; - CommandBar.CursorPosition = cmd.Length; - } - - private void FindCommands() - { - searchResults.Clear(); - searchIndex = 0; - commandChanged = false; - foreach (var cmd in _consoleHost.RegisteredCommands) - { - if (cmd.Key.StartsWith(CommandBar.Text)) - { - searchResults.Add(cmd.Key); - } - } - } - - private void NextCommand() - { - if (!commandChanged) - { - if (searchResults.Count == 0) - return; - - searchIndex = (searchIndex + 1) % searchResults.Count; - SetInput(searchResults[searchIndex]); return; } - FindCommands(); - if (searchResults.Count == 0) - return; - - SetInput(searchResults[0]); - } - - private void PrevCommand() - { - if (!commandChanged) - { - if (searchResults.Count == 0) - return; - - searchIndex = MathHelper.Mod(searchIndex - 1, searchResults.Count); - SetInput(searchResults[searchIndex]); - return; - } - - FindCommands(); - if (searchResults.Count == 0) - return; - - SetInput(searchResults[^1]); + CompletionKeyDown(args); } private void OnCommandChanged(LineEdit.LineEditEventArgs args) { - commandChanged = true; + // commandChanged = true; } private async void _loadHistoryFromDisk() diff --git a/Robust.Client/UserInterface/CustomControls/DebugConsoleCompletion.xaml b/Robust.Client/UserInterface/CustomControls/DebugConsoleCompletion.xaml new file mode 100644 index 000000000..f469a2483 --- /dev/null +++ b/Robust.Client/UserInterface/CustomControls/DebugConsoleCompletion.xaml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/Robust.Client/UserInterface/CustomControls/DebugConsoleCompletion.xaml.cs b/Robust.Client/UserInterface/CustomControls/DebugConsoleCompletion.xaml.cs new file mode 100644 index 000000000..acf1c66e5 --- /dev/null +++ b/Robust.Client/UserInterface/CustomControls/DebugConsoleCompletion.xaml.cs @@ -0,0 +1,14 @@ +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Robust.Client.UserInterface.CustomControls; + +[GenerateTypedNameReferences] +internal sealed partial class DebugConsoleCompletion : Popup +{ + public DebugConsoleCompletion() + { + RobustXamlLoader.Load(this); + } +} diff --git a/Robust.Server/Console/Commands/CVarCommand.cs b/Robust.Server/Console/Commands/CVarCommand.cs deleted file mode 100644 index 578b75e4d..000000000 --- a/Robust.Server/Console/Commands/CVarCommand.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using JetBrains.Annotations; -using Robust.Shared.Configuration; -using Robust.Shared.Console; -using Robust.Shared.IoC; - -namespace Robust.Server.Console.Commands -{ - [UsedImplicitly] - internal sealed class CVarCommand : SharedCVarCommand, IConsoleCommand - { - public override void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length < 1 || args.Length > 2) - { - shell.WriteLine("Must provide exactly one or two arguments."); - return; - } - - var configManager = IoCManager.Resolve(); - var name = args[0]; - - if (name == "?") - { - var cvars = configManager.GetRegisteredCVars(); - shell.WriteLine(string.Join("\n", cvars)); - return; - } - - if (!configManager.IsCVarRegistered(name)) - { - shell.WriteLine($"CVar '{name}' is not registered. Use 'cvar ?' to get a list of all registered CVars."); - return; - } - - if (args.Length == 1) - { - // Read CVar - var value = configManager.GetCVar(name); - shell.WriteLine(value.ToString()!); - } - else - { - // Write CVar - var value = args[1]; - var type = configManager.GetCVarType(name); - try - { - var parsed = ParseObject(type, value); - configManager.SetCVar(name, parsed); - } - catch (FormatException) - { - shell.WriteLine($"Input value is in incorrect format for type {type}"); - } - } - } - } - -} diff --git a/Robust.Server/Console/Commands/ListAssembliesCommand.cs b/Robust.Server/Console/Commands/ListAssembliesCommand.cs deleted file mode 100644 index 922cc7fac..000000000 --- a/Robust.Server/Console/Commands/ListAssembliesCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Runtime.Loader; -using System.Text; -using JetBrains.Annotations; -using Robust.Shared.Console; - -namespace Robust.Server.Console.Commands -{ - [UsedImplicitly] - internal sealed class ListAssembliesCommand : IConsoleCommand - { - public string Command => "lsasm"; - public string Description => "Lists loaded assemblies by load context."; - public string Help => Command; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - var sb = new StringBuilder(); - foreach (var context in AssemblyLoadContext.All) - { - sb.AppendFormat("{0}:\n", context.Name); - foreach (var assembly in context.Assemblies) - { - sb.AppendFormat(" {0}\n", assembly.FullName); - } - } - - shell.WriteLine(sb.ToString()); - } - } -} diff --git a/Robust.Server/Console/Commands/ListCommands.cs b/Robust.Server/Console/Commands/ListCommands.cs deleted file mode 100644 index 75c25d035..000000000 --- a/Robust.Server/Console/Commands/ListCommands.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Linq; -using System.Text; -using Robust.Shared.Console; - -namespace Robust.Server.Console.Commands -{ - public sealed class ListCommands : IConsoleCommand - { - public string Command => "list"; - - public string Description => "Outputs a list of all commands which are currently available to you. " + - "If a filter is provided, " + - "only commands that contain the given string in their name will be listed."; - - public string Help => "Usage: list [filter]"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - var filter = ""; - if (args.Length == 1) - { - filter = args[0]; - } - - var builder = new StringBuilder("SIDE NAME DESC\n-------------------------\n"); - foreach (var command in shell.ConsoleHost.RegisteredCommands.Values - .Where(p => p.Command.Contains(filter)) - .OrderBy(c => c.Command)) - { - //TODO: Make this actually check permissions. - - builder.AppendLine($"S {command.Command,-16}{command.Description}"); - } - - var message = builder.ToString().Trim(' ', '\n'); - shell.WriteLine(message); - } - } -} diff --git a/Robust.Server/Console/Commands/LogCommands.cs b/Robust.Server/Console/Commands/LogCommands.cs deleted file mode 100644 index 060753982..000000000 --- a/Robust.Server/Console/Commands/LogCommands.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using Robust.Shared.Console; -using Robust.Shared.Log; - -namespace Robust.Server.Console.Commands -{ - sealed class LogSetLevelCommand : IConsoleCommand - { - public string Command => "loglevel"; - public string Description => "Changes the log level for a provided sawmill."; - public string Help => "Usage: loglevel " - + "\n sawmill: A label prefixing log messages. This is the one you're setting the level for." - + "\n level: The log level. Must match one of the values of the LogLevel enum."; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length != 2) - { - shell.WriteLine("Invalid argument amount. Expected 2 arguments."); - return; - } - - var name = args[0]; - var levelname = args[1]; - LogLevel? level; - if (levelname == "null") - { - level = null; - } - else - { - if (!Enum.TryParse(levelname, out var result)) - { - shell.WriteLine("Failed to parse 2nd argument. Must be one of the values of the LogLevel enum."); - return; - } - level = result; - } - Logger.GetSawmill(name).Level = level; - } - } - - sealed class TestLog : IConsoleCommand - { - public string Command => "testlog"; - public string Description => "Writes a test log to a sawmill."; - public string Help => "Usage: testlog " - + "\n sawmill: A label prefixing the logged message." - + "\n level: The log level. Must match one of the values of the LogLevel enum." - + "\n message: The message to be logged. Wrap this in double quotes if you want to use spaces."; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length != 3) - { - shell.WriteLine("Invalid argument amount. Expected exactly 3 arguments."); - return; - } - - var name = args[0]; - var levelname = args[1]; - var message = args[2]; // yes this doesn't support spaces idgaf. - if (!Enum.TryParse(levelname, out var result)) - { - shell.WriteLine("Failed to parse 2nd argument. Must be one of the values of the LogLevel enum."); - return; - } - var level = result; - - Logger.LogS(level, name, message, level); - } - } -} diff --git a/Robust.Server/Console/Commands/PlayerCommands.cs b/Robust.Server/Console/Commands/PlayerCommands.cs index 4256f2520..e72253211 100644 --- a/Robust.Server/Console/Commands/PlayerCommands.cs +++ b/Robust.Server/Console/Commands/PlayerCommands.cs @@ -212,5 +212,23 @@ namespace Robust.Server.Console.Commands network.DisconnectChannel(target.ConnectedClient, reason); } } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length == 1) + { + var playerManager = IoCManager.Resolve(); + var options = playerManager.ServerSessions.OrderBy(c => c.Name).Select(c => c.Name).ToArray(); + + return CompletionResult.FromHintOptions(options, ""); + } + + if (args.Length > 1) + { + return CompletionResult.FromHint("[]"); + } + + return CompletionResult.Empty; + } } } diff --git a/Robust.Server/Console/Commands/SysCommands.cs b/Robust.Server/Console/Commands/SysCommands.cs index 3396156df..b2b78e863 100644 --- a/Robust.Server/Console/Commands/SysCommands.cs +++ b/Robust.Server/Console/Commands/SysCommands.cs @@ -1,5 +1,3 @@ -using System; -using System.Runtime; using System.Text; using Robust.Shared.Configuration; using Robust.Shared.Console; @@ -72,41 +70,6 @@ namespace Robust.Server.Console.Commands } } - sealed class HelpCommand : IConsoleCommand - { - public string Command => "help"; - - public string Description => - "When no arguments are provided, displays a generic help text. When an argument is passed, display the help text for the command with that name."; - - public string Help => "Help"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - switch (args.Length) - { - case 0: - shell.WriteLine("To display help for a specific command, write 'help '. To list all available commands, write 'list'."); - break; - - case 1: - var commandName = args[0]; - if (!shell.ConsoleHost.RegisteredCommands.TryGetValue(commandName, out var cmd)) - { - shell.WriteLine($"Unknown command: {commandName}"); - return; - } - - shell.WriteLine($"Use: {cmd.Help}\n{cmd.Description}"); - break; - - default: - shell.WriteLine("Invalid amount of arguments."); - break; - } - } - } - sealed class ShowTimeCommand : IConsoleCommand { public string Command => "showtime"; @@ -119,115 +82,4 @@ namespace Robust.Server.Console.Commands shell.WriteLine($"Paused: {timing.Paused}, CurTick: {timing.CurTick}, CurTime: {timing.CurTime}, RealTime: {timing.RealTime}"); } } - - internal sealed class GcCommand : IConsoleCommand - { - public string Command => "gc"; - public string Description => "Run the GC."; - public string Help => "gc [generation]"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { - if (args.Length == 0) - { - GC.Collect(); - } - else - { - if(int.TryParse(args[0], out int result)) - GC.Collect(result); - else - shell.WriteLine("Failed to parse argument."); - } - } - } - - internal sealed class GcModeCommand : IConsoleCommand - { - - public string Command => "gc_mode"; - - public string Description => "Change the GC Latency mode."; - - public string Help => "gc_mode [type]"; - - public void Execute(IConsoleShell console, string argStr, string[] args) - { - var prevMode = GCSettings.LatencyMode; - if (args.Length == 0) - { - console.WriteLine($"current gc latency mode: {(int) prevMode} ({prevMode})"); - console.WriteLine("possible modes:"); - foreach (var mode in (int[]) Enum.GetValues(typeof(GCLatencyMode))) - { - console.WriteLine($" {mode}: {Enum.GetName(typeof(GCLatencyMode), mode)}"); - } - } - else - { - GCLatencyMode mode; - if (char.IsDigit(args[0][0]) && int.TryParse(args[0], out var modeNum)) - { - mode = (GCLatencyMode) modeNum; - } - else if (!Enum.TryParse(args[0], true, out mode)) - { - console.WriteLine($"unknown gc latency mode: {args[0]}"); - return; - } - - console.WriteLine($"attempting gc latency mode change: {(int) prevMode} ({prevMode}) -> {(int) mode} ({mode})"); - GCSettings.LatencyMode = mode; - console.WriteLine($"resulting gc latency mode: {(int) GCSettings.LatencyMode} ({GCSettings.LatencyMode})"); - } - - return; - } - - } - - internal sealed class SerializeStatsCommand : IConsoleCommand - { - - public string Command => "szr_stats"; - - public string Description => "Report serializer statistics."; - - public string Help => "szr_stats"; - - public void Execute(IConsoleShell console, string argStr, string[] args) - { - - console.WriteLine($"serialized: {RobustSerializer.BytesSerialized} bytes, {RobustSerializer.ObjectsSerialized} objects"); - console.WriteLine($"largest serialized: {RobustSerializer.LargestObjectSerializedBytes} bytes, {RobustSerializer.LargestObjectSerializedType} objects"); - console.WriteLine($"deserialized: {RobustSerializer.BytesDeserialized} bytes, {RobustSerializer.ObjectsDeserialized} objects"); - console.WriteLine($"largest serialized: {RobustSerializer.LargestObjectDeserializedBytes} bytes, {RobustSerializer.LargestObjectDeserializedType} objects"); - } - - } - - internal sealed class MemCommand : IConsoleCommand - { - public string Command => "mem"; - public string Description => "prints memory info"; - public string Help => "mem"; - - public void Execute(IConsoleShell shell, string argStr, string[] args) - { -#if !NETCOREAPP - shell.SendText(player, "Memory info is only available on .NET Core"); -#else - var info = GC.GetGCMemoryInfo(); - - shell.WriteLine($@"Heap Size: {FormatBytes(info.HeapSizeBytes)} Total Allocated: {FormatBytes(GC.GetTotalMemory(false))}"); -#endif - } - -#if NETCOREAPP - private static string FormatBytes(long bytes) - { - return $"{bytes / 1024} KiB"; - } -#endif - } } diff --git a/Robust.Server/Console/ServerConsoleHost.cs b/Robust.Server/Console/ServerConsoleHost.cs index 202f148a0..843249e22 100644 --- a/Robust.Server/Console/ServerConsoleHost.cs +++ b/Robust.Server/Console/ServerConsoleHost.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Robust.Server.Player; using Robust.Shared.Console; -using Robust.Shared.Exceptions; using Robust.Shared.IoC; +using Robust.Shared.Log; using Robust.Shared.Network; using Robust.Shared.Network.Messages; using Robust.Shared.Players; @@ -12,7 +14,7 @@ using Robust.Shared.Utility; namespace Robust.Server.Console { /// - internal sealed class ServerConsoleHost : ConsoleHost, IServerConsoleHost + internal sealed class ServerConsoleHost : ConsoleHost, IServerConsoleHost, IConsoleHostInternal { [Dependency] private readonly IConGroupController _groupController = default!; [Dependency] private readonly IPlayerManager _players = default!; @@ -56,14 +58,26 @@ namespace Robust.Server.Console OutputText(null, text, true); } + public bool IsCmdServer(IConsoleCommand cmd) => true; + /// public void Initialize() { - RegisterCommand("sudo", "sudo make me a sandwich", "sudo ",(shell, argStr, _) => + RegisterCommand("sudo", "sudo make me a sandwich", "sudo ", (shell, argStr, _) => { var localShell = shell.ConsoleHost.LocalShell; var sudoShell = new SudoShell(this, localShell, shell); - ExecuteInShell(sudoShell, argStr.Substring("sudo ".Length)); + ExecuteInShell(sudoShell, argStr["sudo ".Length..]); + }, (shell, args) => + { + var localShell = shell.ConsoleHost.LocalShell; + var sudoShell = new SudoShell(this, localShell, shell); + + Logger.Debug($"A: {string.Join(", ", args)}"); + +#pragma warning disable CA2012 + return CalcCompletions(sudoShell, args); +#pragma warning restore CA2012 }); LoadConsoleCommands(); @@ -73,6 +87,8 @@ namespace Robust.Server.Console NetManager.RegisterNetMessage(); NetManager.RegisterNetMessage(message => HandleRegistrationRequest(message.MsgChannel)); + NetManager.RegisterNetMessage(HandleConCompletions); + NetManager.RegisterNetMessage(); } private void ExecuteInShell(IConsoleShell shell, string command) @@ -92,32 +108,29 @@ namespace Robust.Server.Console { args.RemoveAt(0); var cmdArgs = args.ToArray(); - if (shell.Player != null) // remote client + if (!ShellCanExecute(shell, cmdName)) { - if (_groupController.CanCommand((IPlayerSession) shell.Player, cmdName)) // client has permission - { - AnyCommandExecuted?.Invoke(shell, cmdName, command, cmdArgs); - conCmd.Execute(shell, command, cmdArgs); - } - else - shell.WriteError($"Unknown command: '{cmdName}'"); - } - else // system console - { - AnyCommandExecuted?.Invoke(shell, cmdName, command, cmdArgs); - conCmd.Execute(shell, command, cmdArgs); + shell.WriteError($"Unknown command: '{cmdName}'"); + return; } + + AnyCommandExecuted?.Invoke(shell, cmdName, command, cmdArgs); + conCmd.Execute(shell, command, cmdArgs); } - else - shell.WriteError($"Unknown command: '{cmdName}'"); } catch (Exception e) { - LogManager.GetSawmill(SawmillName).Error($"{FormatPlayerString(shell.Player)}: ExecuteError - {command}:\n{e}"); + LogManager.GetSawmill(SawmillName) + .Error($"{FormatPlayerString(shell.Player)}: ExecuteError - {command}:\n{e}"); shell.WriteError($"There was an error while executing the command: {e}"); } } + private bool ShellCanExecute(IConsoleShell shell, string cmdName) + { + return shell.Player == null || _groupController.CanCommand((IPlayerSession)shell.Player, cmdName); + } + private void HandleRegistrationRequest(INetChannel senderConnection) { var netMgr = IoCManager.Resolve(); @@ -168,6 +181,46 @@ namespace Robust.Server.Console return session != null ? $"{session.Name}" : "[HOST]"; } + private async void HandleConCompletions(MsgConCompletion message) + { + var session = _players.GetSessionByChannel(message.MsgChannel); + var shell = new ConsoleShell(this, session); + + var result = await CalcCompletions(shell, message.Args); + + var msg = new MsgConCompletionResp + { + Result = result, + Seq = message.Seq + }; + + if (!message.MsgChannel.IsConnected) + return; + + NetManager.ServerSendMessage(msg, message.MsgChannel); + } + + private ValueTask CalcCompletions(IConsoleShell shell, string[] args) + { + // Logger.Debug(string.Join(", ", args)); + + if (args.Length <= 1) + { + // Typing out command name, handle this ourselves. + return ValueTask.FromResult(CompletionResult.FromOptions( + RegisteredCommands.Values.Select(c => new CompletionOption(c.Command, c.Description)))); + } + + var cmdName = args[0]; + if (!AvailableCommands.TryGetValue(cmdName, out var cmd)) + return ValueTask.FromResult(CompletionResult.Empty); + + if (!ShellCanExecute(shell, cmdName)) + return ValueTask.FromResult(CompletionResult.Empty); + + return cmd.GetCompletionAsync(shell, args[1..], default); + } + private sealed class SudoShell : IConsoleShell { private readonly ServerConsoleHost _host; diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 329f78cd8..162aa02c7 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1153,6 +1153,31 @@ namespace Robust.Shared public static readonly CVarDef AczManifestCompressLevel = CVarDef.Create("acz.manifest_compress_level", 14, CVar.SERVERONLY); + /* + * CON + */ + + /// + /// Add artificial delay (in seconds) to console completion fetching, even for local commands. + /// + /// + /// Intended for debugging the console completion system. + /// + public static readonly CVarDef ConCompletionDelay = + CVarDef.Create("con.completion_delay", 0f, CVar.CLIENTONLY); + + /// + /// The amount of completions to show in console completion drop downs. + /// + public static readonly CVarDef ConCompletionCount = + CVarDef.Create("con.completion_count", 15, CVar.CLIENTONLY); + + /// + /// The minimum margin of options to keep on either side of the completion cursor, when scrolling through. + /// + public static readonly CVarDef ConCompletionMargin = + CVarDef.Create("con.completion_margin", 3, CVar.CLIENTONLY); + /* * THREAD */ diff --git a/Robust.Shared/Configuration/ConfigurationCommands.cs b/Robust.Shared/Configuration/ConfigurationCommands.cs index e9e6e08c8..242d20f6b 100644 --- a/Robust.Shared/Configuration/ConfigurationCommands.cs +++ b/Robust.Shared/Configuration/ConfigurationCommands.cs @@ -1,31 +1,99 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Globalization; +using System.Linq; using Robust.Shared.Console; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Utility; namespace Robust.Shared.Configuration { [SuppressMessage("ReSharper", "StringLiteralTypo")] - internal abstract class SharedCVarCommand : IConsoleCommand + internal sealed class CVarCommand : IConsoleCommand { public string Command => "cvar"; - public string Description => "Gets or sets a CVar."; + public string Description => Loc.GetString("cmd-cvar-desc"); - public string Help => @"cvar [value] -If a value is passed, the value is parsed and stored as the new value of the CVar. -If not, the current value of the CVar is displayed. -Use 'cvar ?' to get a list of all registered CVars."; + public string Help => Loc.GetString("cmd-cvar-help"); - public abstract void Execute(IConsoleShell shell, string argStr, string[] args); + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length is < 1 or > 2) + { + shell.WriteError(Loc.GetString("cmd-cvar-invalid-args")); + return; + } - protected static object ParseObject(Type type, string input) + var configManager = IoCManager.Resolve(); + var name = args[0]; + + if (name == "?") + { + var cvars = configManager.GetRegisteredCVars().OrderBy(c => c); + shell.WriteLine(string.Join("\n", cvars)); + return; + } + + if (!configManager.IsCVarRegistered(name)) + { + shell.WriteError(Loc.GetString("cmd-cvar-not-registered", ("cvar", name))); + return; + } + + if (args.Length == 1) + { + // Read CVar + var value = configManager.GetCVar(name); + shell.WriteLine(value.ToString()!); + } + else + { + // Write CVar + var value = args[1]; + var type = configManager.GetCVarType(name); + try + { + var parsed = ParseObject(type, value); + configManager.SetCVar(name, parsed); + } + catch (FormatException) + { + shell.WriteError(Loc.GetString("cmd-cvar-parse-error", ("type", type))); + } + } + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + var cfg = IoCManager.Resolve(); + if (args.Length == 1) + { + var helpQuestion = Loc.GetString("cmd-cvar-compl-list"); + + return CompletionResult.FromHintOptions( + cfg.GetRegisteredCVars() + .Select(c => new CompletionOption(c)) + .Union(new[] { new CompletionOption("?", helpQuestion) }) + .OrderBy(c => c.Value), + Loc.GetString("cmd-cvar-arg-name")); + } + + var cvar = args[0]; + if (!cfg.IsCVarRegistered(cvar)) + return CompletionResult.Empty; + + var type = cfg.GetCVarType(cvar); + return CompletionResult.FromHint($"<{type.Name}>"); + } + + private static object ParseObject(Type type, string input) { if (type == typeof(bool)) { - if(bool.TryParse(input, out var val)) + if (bool.TryParse(input, out var val)) return val; - if (int.TryParse(input, out var intVal)) + if (Parse.TryInt32(input, out var intVal)) { if (intVal == 0) return false; if (intVal == 1) return true; @@ -41,15 +109,15 @@ Use 'cvar ?' to get a list of all registered CVars."; if (type == typeof(int)) { - return int.Parse(input, CultureInfo.InvariantCulture); + return Parse.Int32(input); } if (type == typeof(float)) { - return float.Parse(input, CultureInfo.InvariantCulture); + return Parse.Float(input); } - throw new NotImplementedException(); + throw new NotSupportedException(); } } } diff --git a/Robust.Shared/Console/Commands/ExecCommand.cs b/Robust.Shared/Console/Commands/ExecCommand.cs index e53960e46..e61e0ce2e 100644 --- a/Robust.Shared/Console/Commands/ExecCommand.cs +++ b/Robust.Shared/Console/Commands/ExecCommand.cs @@ -1,8 +1,8 @@ -using System.IO; using System.Text.RegularExpressions; using JetBrains.Annotations; using Robust.Shared.ContentPack; using Robust.Shared.IoC; +using Robust.Shared.Localization; using Robust.Shared.Utility; namespace Robust.Shared.Console.Commands @@ -13,9 +13,9 @@ namespace Robust.Shared.Console.Commands private static readonly Regex CommentRegex = new Regex(@"^\s*#"); public string Command => "exec"; - public string Description => "Executes a script file from the game's data directory."; - public string Help => "Usage: exec \n" + - "Each line in the file is executed as a single command, unless it starts with a #"; + public string Description => Loc.GetString("cmd-exec-desc"); + public string Help => Loc.GetString("cmd-exec-help"); + public void Execute(IConsoleShell shell, string argStr, string[] args) { var res = IoCManager.Resolve(); @@ -51,5 +51,20 @@ namespace Robust.Shared.Console.Commands shell.ConsoleHost.AppendCommand(line); } } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length == 1) + { + var res = IoCManager.Resolve(); + + var hint = Loc.GetString("cmd-exec-arg-filename"); + var options = CompletionHelper.UserFilePath(args[0], res.UserData); + + return CompletionResult.FromHintOptions(options, hint); + } + + return CompletionResult.Empty; + } } } diff --git a/Robust.Shared/Console/Commands/GcCommands.cs b/Robust.Shared/Console/Commands/GcCommands.cs new file mode 100644 index 000000000..aa02d4415 --- /dev/null +++ b/Robust.Shared/Console/Commands/GcCommands.cs @@ -0,0 +1,115 @@ +using System; +using System.Runtime; +using Robust.Shared.Localization; +using Robust.Shared.Utility; + +namespace Robust.Shared.Console.Commands; + +internal sealed class GcCommand : IConsoleCommand +{ + public string Command => "gc"; + public string Description => Loc.GetString("cmd-gc-desc"); + public string Help => Loc.GetString("cmd-gc-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length == 0) + { + GC.Collect(); + } + else + { + if (Parse.TryInt32(args[0], out var generation)) + GC.Collect(generation); + else + shell.WriteError(Loc.GetString("cmd-gc-failed-parse")); + } + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length == 1) + return CompletionResult.FromHint(Loc.GetString("cmd-gc-arg-generation")); + + return CompletionResult.Empty; + } +} + +internal sealed class GcFullCommand : IConsoleCommand +{ + public string Command => "gcf"; + public string Description => Loc.GetString("cmd-gcf-desc"); + public string Help => Loc.GetString("cmd-gcf-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; + GC.Collect(2, GCCollectionMode.Forced, true, true); + } +} + +internal sealed class GcModeCommand : IConsoleCommand +{ + public string Command => "gc_mode"; + + public string Description => Loc.GetString("cmd-gc_mode-desc"); + + public string Help => Loc.GetString("cmd-gc_mode-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + var prevMode = GCSettings.LatencyMode; + if (args.Length == 0) + { + shell.WriteLine(Loc.GetString("cmd-gc_mode-current", ("prevMode", prevMode.ToString()))); + shell.WriteLine(Loc.GetString("cmd-gc_mode-possible")); + + foreach (var mode in Enum.GetValues()) + { + shell.WriteLine(Loc.GetString("cmd-gc_mode-option", ("mode", mode.ToString()))); + } + } + else + { + if (!Enum.TryParse(args[0], true, out GCLatencyMode mode)) + { + shell.WriteLine(Loc.GetString("cmd-gc_mode-unknown", ("arg", args[0]))); + return; + } + + shell.WriteLine(Loc.GetString("cmd-gc_mode-attempt", ("prevMode", prevMode.ToString()), + ("mode", mode.ToString()))); + GCSettings.LatencyMode = mode; + shell.WriteLine(Loc.GetString("cmd-gc_mode-result", ("mode", GCSettings.LatencyMode.ToString()))); + } + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length == 1) + { + return CompletionResult.FromHintOptions( + Enum.GetNames(), + Loc.GetString("cmd-gc_mode-arg-type")); + } + + return CompletionResult.Empty; + } +} + +internal sealed class MemCommand : IConsoleCommand +{ + public string Command => "mem"; + public string Description => Loc.GetString("cmd-mem-desc"); + public string Help => Loc.GetString("cmd-mem-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + var info = GC.GetGCMemoryInfo(); + + var heapSize = info.HeapSizeBytes; + var totalMemory = GC.GetTotalMemory(false); + + shell.WriteLine(Loc.GetString("cmd-mem-report", ("heapSize", heapSize), ("totalAllocated", totalMemory))); + } +} diff --git a/Robust.Shared/Console/Commands/HelpCommand.cs b/Robust.Shared/Console/Commands/HelpCommand.cs new file mode 100644 index 000000000..81e69d057 --- /dev/null +++ b/Robust.Shared/Console/Commands/HelpCommand.cs @@ -0,0 +1,51 @@ +using System.Linq; +using Robust.Shared.Localization; + +namespace Robust.Shared.Console.Commands; + +internal sealed class HelpCommand : IConsoleCommand +{ + public string Command => "help"; + public string Description => Loc.GetString("cmd-help-desc"); + public string Help => Loc.GetString("cmd-help-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + switch (args.Length) + { + case 0: + shell.WriteLine(Loc.GetString("cmd-help-no-args")); + break; + + case 1: + var commandName = args[0]; + if (!shell.ConsoleHost.RegisteredCommands.TryGetValue(commandName, out var cmd)) + { + shell.WriteError(Loc.GetString("cmd-help-unknown", ("command", commandName))); + return; + } + + shell.WriteLine(Loc.GetString("cmd-help-top", ("command", cmd.Command), + ("description", cmd.Description))); + shell.WriteLine(cmd.Help); + break; + + default: + shell.WriteError(Loc.GetString("cmd-help-invalid-args")); + break; + } + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length == 1) + { + var host = shell.ConsoleHost; + return CompletionResult.FromHintOptions( + host.RegisteredCommands.Values.OrderBy(c => c).Select(c => new CompletionOption(c.Command, c.Description)).ToArray(), + Loc.GetString("cmd-help-arg-cmdname")); + } + + return CompletionResult.Empty; + } +} diff --git a/Robust.Shared/Console/Commands/ListAssembliesCommand.cs b/Robust.Shared/Console/Commands/ListAssembliesCommand.cs new file mode 100644 index 000000000..8b9ed57dd --- /dev/null +++ b/Robust.Shared/Console/Commands/ListAssembliesCommand.cs @@ -0,0 +1,27 @@ +using System.Runtime.Loader; +using System.Text; +using Robust.Shared.Localization; + +namespace Robust.Shared.Console.Commands; + +internal sealed class ListAssembliesCommand : IConsoleCommand +{ + public string Command => "lsasm"; + public string Description => Loc.GetString("cmd-lsasm-desc"); + public string Help => Loc.GetString("cmd-lsasm-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + var sb = new StringBuilder(); + foreach (var context in AssemblyLoadContext.All) + { + sb.Append($"{context.Name}:\n"); + foreach (var assembly in context.Assemblies) + { + sb.Append($" {assembly.FullName}\n"); + } + } + + shell.WriteLine(sb.ToString()); + } +} diff --git a/Robust.Shared/Console/Commands/ListCommand.cs b/Robust.Shared/Console/Commands/ListCommand.cs new file mode 100644 index 000000000..1c80b6033 --- /dev/null +++ b/Robust.Shared/Console/Commands/ListCommand.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Text; +using Robust.Shared.Localization; + +namespace Robust.Shared.Console.Commands; + +internal sealed class ListCommands : IConsoleCommand +{ + public string Command => "list"; + + public string Description => Loc.GetString("cmd-list-desc"); + + public string Help => Loc.GetString("cmd-list-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + var filter = ""; + if (args.Length == 1) + filter = args[0]; + + var host = (IConsoleHostInternal)shell.ConsoleHost; + + var builder = new StringBuilder(Loc.GetString("cmd-list-heading")); + foreach (var command in host.RegisteredCommands.Values + .Where(p => p.Command.Contains(filter)) + .OrderBy(c => c.Command)) + { + //TODO: Make this actually check permissions. + + var side = host.IsCmdServer(command) ? "S" : "C"; + builder.AppendLine($"{side} {command.Command,-32}{command.Description}"); + } + + var message = builder.ToString().Trim(' ', '\n'); + shell.WriteLine(message); + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + if (args.Length == 1) + return CompletionResult.FromHint(Loc.GetString("cmd-list-arg-filter")); + + return CompletionResult.Empty; + } +} diff --git a/Robust.Shared/Console/Commands/LogCommands.cs b/Robust.Shared/Console/Commands/LogCommands.cs new file mode 100644 index 000000000..54beab116 --- /dev/null +++ b/Robust.Shared/Console/Commands/LogCommands.cs @@ -0,0 +1,121 @@ +using System; +using System.Linq; +using Robust.Shared.IoC; +using Robust.Shared.Log; + +namespace Robust.Shared.Console.Commands; + +internal sealed class LogSetLevelCommand : IConsoleCommand +{ + public string Command => "loglevel"; + public string Description => "Changes the log level for a provided sawmill."; + + public string Help => "Usage: loglevel " + + "\n sawmill: A label prefixing log messages. This is the one you're setting the level for." + + "\n level: The log level. Must match one of the values of the LogLevel enum."; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length != 2) + { + shell.WriteError("Invalid argument amount. Expected 2 arguments."); + return; + } + + var name = args[0]; + var levelname = args[1]; + LogLevel? level; + if (levelname == "null") + { + level = null; + } + else + { + if (!Enum.TryParse(levelname, out var result)) + { + shell.WriteLine("Failed to parse 2nd argument. Must be one of the values of the LogLevel enum."); + return; + } + + level = result; + } + + Logger.GetSawmill(name).Level = level; + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + var logMgr = IoCManager.Resolve(); + + switch (args.Length) + { + case 1: + return CompletionResult.FromHintOptions( + logMgr.AllSawmills.Select(c => c.Name).OrderBy(c => c), + ""); + case 2: + return CompletionResult.FromHintOptions( + Enum.GetNames(), + ""); + + default: + return CompletionResult.Empty; + } + } +} + +internal sealed class TestLog : IConsoleCommand +{ + public string Command => "testlog"; + public string Description => "Writes a test log to a sawmill."; + + public string Help => "Usage: testlog " + + "\n sawmill: A label prefixing the logged message." + + "\n level: The log level. Must match one of the values of the LogLevel enum." + + "\n message: The message to be logged. Wrap this in double quotes if you want to use spaces."; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length != 3) + { + shell.WriteError("Invalid argument amount. Expected 3 arguments."); + return; + } + + var name = args[0]; + var levelname = args[1]; + var message = args[2]; // yes this doesn't support spaces idgaf. + if (!Enum.TryParse(levelname, out var result)) + { + shell.WriteLine("Failed to parse 2nd argument. Must be one of the values of the LogLevel enum."); + return; + } + + var level = result; + + Logger.LogS(level, name, message); + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + var logMgr = IoCManager.Resolve(); + + switch (args.Length) + { + case 1: + return CompletionResult.FromHintOptions( + logMgr.AllSawmills.Select(c => c.Name).OrderBy(c => c), + ""); + case 2: + return CompletionResult.FromHintOptions( + Enum.GetNames(), + ""); + + case 3: + return CompletionResult.FromHint(""); + + default: + return CompletionResult.Empty; + } + } +} diff --git a/Robust.Shared/Console/Commands/SerializerStatsCommand.cs b/Robust.Shared/Console/Commands/SerializerStatsCommand.cs new file mode 100644 index 000000000..ca7bc0f1f --- /dev/null +++ b/Robust.Shared/Console/Commands/SerializerStatsCommand.cs @@ -0,0 +1,19 @@ +using Robust.Shared.Serialization; + +namespace Robust.Shared.Console.Commands; + +internal sealed class SerializeStatsCommand : IConsoleCommand +{ + public string Command => "szr_stats"; + public string Description => "Report serializer statistics."; + public string Help => "szr_stats"; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + shell.WriteLine($"serialized: {RobustSerializer.BytesSerialized} bytes, {RobustSerializer.ObjectsSerialized} objects"); + shell.WriteLine($"largest serialized: {RobustSerializer.LargestObjectSerializedBytes} bytes, {RobustSerializer.LargestObjectSerializedType} objects"); + shell.WriteLine($"deserialized: {RobustSerializer.BytesDeserialized} bytes, {RobustSerializer.ObjectsDeserialized} objects"); + shell.WriteLine($"largest serialized: {RobustSerializer.LargestObjectDeserializedBytes} bytes, {RobustSerializer.LargestObjectDeserializedType} objects"); + } +} + diff --git a/Robust.Shared/Console/CompletionHelper.cs b/Robust.Shared/Console/CompletionHelper.cs new file mode 100644 index 000000000..da19dd678 --- /dev/null +++ b/Robust.Shared/Console/CompletionHelper.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Robust.Shared.Console; + +/// +/// Helpers for creating various completion results. +/// +public static class CompletionHelper +{ + public static IEnumerable ContentFilePath(string arg, IResourceManager res) + { + var curPath = arg; + if (curPath == "") + curPath = "/"; + + var resPath = new ResourcePath(curPath); + + if (!curPath.EndsWith("/")) + resPath = (resPath / "..").Clean(); + + var options = res.ContentGetDirectoryEntries(resPath) + .OrderBy(c => c) + .Select(c => + { + var opt = (resPath / c).ToString(); + + if (c.EndsWith("/")) + return new CompletionOption(opt + "/", Flags: CompletionOptionFlags.PartialCompletion); + + return new CompletionOption(opt); + }); + + return options; + } + + public static IEnumerable UserFilePath(string arg, IWritableDirProvider provider) + { + var curPath = arg; + if (curPath == "") + curPath = "/"; + + var resPath = new ResourcePath(curPath); + + if (!resPath.IsRooted) + return Enumerable.Empty(); + + if (!curPath.EndsWith("/")) + resPath = (resPath / "..").Clean(); + + var entries = provider.DirectoryEntries(resPath); + + return entries + .Select(c => + { + var full = resPath / c; + if (provider.IsDir(full)) + return new CompletionOption($"{full}/", Flags: CompletionOptionFlags.PartialCompletion); + + return new CompletionOption(full.ToString()); + }) + .OrderBy(c => c.Value); + } +} diff --git a/Robust.Shared/Console/CompletionResult.cs b/Robust.Shared/Console/CompletionResult.cs new file mode 100644 index 000000000..f3e2c37a1 --- /dev/null +++ b/Robust.Shared/Console/CompletionResult.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Robust.Shared.Console; + +/// +/// Contains the result of a command completion. +/// +public sealed record CompletionResult(CompletionOption[] Options, string? Hint) +{ + /// + /// The possible full arguments to complete with. These are from the start of the entire argument. + /// + public CompletionOption[] Options { get; init; } = Options; + + /// + /// Type hint string for the current argument being typed. + /// + public string? Hint { get; init; } = Hint; + + public static readonly CompletionResult Empty = new(Array.Empty(), null); + + public static CompletionResult FromHintOptions(IEnumerable options, string? hint) => new(ConvertOptions(options), hint); + public static CompletionResult FromHintOptions(IEnumerable options, string? hint) => new(options.ToArray(), hint); + + public static CompletionResult FromOptions(IEnumerable options) => new(ConvertOptions(options), null); + public static CompletionResult FromOptions(IEnumerable options) => new(options.ToArray(), null); + + public static CompletionResult FromHint(string hint) => new(Array.Empty(), hint); + + private static CompletionOption[] ConvertOptions(IEnumerable stringOpts) + { + return stringOpts.Select(c => new CompletionOption(c)).ToArray(); + } +} + +/// +/// Possible option to tab-complete in a . +/// +public record struct CompletionOption(string Value, string? Hint = null, CompletionOptionFlags Flags = default) +{ + /// + /// The value that will be filled in if completed. + /// + public string Value { get; set; } = Value; + + /// + /// Additional hint value that is shown to users, but not included in the completed value. + /// + public string? Hint { get; set; } = Hint; + + /// + /// Flags that control how this completion is used. + /// + public CompletionOptionFlags Flags { get; set; } = Flags; +} + +/// +/// Flag options for . +/// +[Flags] +public enum CompletionOptionFlags +{ + /// + /// The completion is "partial", it does complete the whole argument. + /// Therefore, tab completing it should keep the cursor on the current argument + /// (instead of adding a space to go to the next one). + /// + PartialCompletion = 1 << 0, +} diff --git a/Robust.Shared/Console/ConsoleHost.cs b/Robust.Shared/Console/ConsoleHost.cs index a2f66ceec..49e568de6 100644 --- a/Robust.Shared/Console/ConsoleHost.cs +++ b/Robust.Shared/Console/ConsoleHost.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Robust.Shared.Enums; using Robust.Shared.IoC; using Robust.Shared.IoC.Exceptions; @@ -23,8 +25,7 @@ namespace Robust.Shared.Console [Dependency] private readonly IDynamicTypeFactoryInternal _typeFactory = default!; [Dependency] private readonly IGameTiming _timing = default!; - [ViewVariables] - protected readonly Dictionary AvailableCommands = new(); + [ViewVariables] protected readonly Dictionary AvailableCommands = new(); private readonly CommandBuffer _commandBuffer = new CommandBuffer(); @@ -53,7 +54,7 @@ namespace Robust.Shared.Console // search for all client commands in all assemblies, and register them foreach (var type in ReflectionManager.GetAllChildren()) { - var instance = (IConsoleCommand) _typeFactory.CreateInstanceUnchecked(type, true); + var instance = (IConsoleCommand)_typeFactory.CreateInstanceUnchecked(type, true); if (RegisteredCommands.TryGetValue(instance.Command, out var duplicate)) { throw new InvalidImplementationException(instance.GetType(), typeof(IConsoleCommand), @@ -64,8 +65,11 @@ namespace Robust.Shared.Console } } - /// - public void RegisterCommand(string command, string description, string help, ConCommandCallback callback) + public void RegisterCommand( + string command, + string description, + string help, + ConCommandCallback callback) { if (AvailableCommands.ContainsKey(command)) throw new InvalidOperationException($"Command already registered: {command}"); @@ -74,6 +78,34 @@ namespace Robust.Shared.Console AvailableCommands.Add(command, newCmd); } + public void RegisterCommand( + string command, + string description, + string help, + ConCommandCallback callback, + ConCommandCompletionCallback completionCallback) + { + if (AvailableCommands.ContainsKey(command)) + throw new InvalidOperationException($"Command already registered: {command}"); + + var newCmd = new RegisteredCommand(command, description, help, callback, completionCallback); + AvailableCommands.Add(command, newCmd); + } + + public void RegisterCommand( + string command, + string description, + string help, + ConCommandCallback callback, + ConCommandCompletionAsyncCallback completionCallback) + { + if (AvailableCommands.ContainsKey(command)) + throw new InvalidOperationException($"Command already registered: {command}"); + + var newCmd = new RegisteredCommand(command, description, help, callback, completionCallback); + AvailableCommands.Add(command, newCmd); + } + /// public void UnregisterCommand(string command) { @@ -81,7 +113,8 @@ namespace Robust.Shared.Console throw new KeyNotFoundException($"Command {command} is not registered."); if (cmd is not RegisteredCommand) - throw new InvalidOperationException("You cannot unregister commands that have been registered automatically."); + throw new InvalidOperationException( + "You cannot unregister commands that have been registered automatically."); AvailableCommands.Remove(command); } @@ -157,6 +190,8 @@ namespace Robust.Shared.Console public sealed class RegisteredCommand : IConsoleCommand { public ConCommandCallback Callback { get; } + public ConCommandCompletionCallback? CompletionCallback { get; } + public ConCommandCompletionAsyncCallback? CompletionCallbackAsync { get; } /// public string Command { get; } @@ -174,7 +209,12 @@ namespace Robust.Shared.Console /// Short description of the command. /// Extended description for the command. /// Callback function that is ran when the command is executed. - public RegisteredCommand(string command, string description, string help, ConCommandCallback callback) + /// Callback function to get console completions. + public RegisteredCommand( + string command, + string description, + string help, + ConCommandCallback callback) { Command = command; // Should these two be localized somehow? @@ -183,11 +223,63 @@ namespace Robust.Shared.Console Callback = callback; } + /// + /// Constructs a new instance of . + /// + /// Name of the command. + /// Short description of the command. + /// Extended description for the command. + /// Callback function that is ran when the command is executed. + /// Callback function to get console completions. + public RegisteredCommand( + string command, + string description, + string help, + ConCommandCallback callback, + ConCommandCompletionCallback completionCallback) : this(command, description, help, callback) + { + CompletionCallback = completionCallback; + } + + /// + /// Constructs a new instance of . + /// + /// Name of the command. + /// Short description of the command. + /// Extended description for the command. + /// Callback function that is ran when the command is executed. + /// Asynchronous callback function to get console completions. + public RegisteredCommand( + string command, + string description, + string help, + ConCommandCallback callback, + ConCommandCompletionAsyncCallback completionCallback) + : this(command, description, help, callback) + { + CompletionCallbackAsync = completionCallback; + } + + /// public void Execute(IConsoleShell shell, string argStr, string[] args) { Callback(shell, argStr, args); } + + public ValueTask GetCompletionAsync( + IConsoleShell shell, + string[] args, + CancellationToken cancel) + { + if (CompletionCallbackAsync != null) + return CompletionCallbackAsync(shell, args); + + if (CompletionCallback != null) + return ValueTask.FromResult(CompletionCallback(shell, args)); + + return ValueTask.FromResult(CompletionResult.Empty); + } } } } diff --git a/Robust.Shared/Console/IConsoleCommand.cs b/Robust.Shared/Console/IConsoleCommand.cs index 44795a78a..f3faf9d16 100644 --- a/Robust.Shared/Console/IConsoleCommand.cs +++ b/Robust.Shared/Console/IConsoleCommand.cs @@ -1,3 +1,5 @@ +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; namespace Robust.Shared.Console @@ -40,5 +42,40 @@ namespace Robust.Shared.Console /// Unparsed text of the complete command with arguments. /// An array of all the parsed arguments. void Execute(IConsoleShell shell, string argStr, string[] args); + + /// + /// Fetches completion results for a typing a command. + /// + /// + /// + /// Refrain from doing simple .StartsWith( filtering based on the currently typing command. + /// The client already does this filtering on its own, + /// so doing it manually would reduce responsiveness thanks to network lag. + /// It may however be desirable to do larger-scale filtering. + /// For example when typing out a resource path you could manually filter level-by-level as it's being typed. + /// + /// + /// Only arguments to the left of the cursor are passed. + /// If the user puts their cursor in the middle of a line and starts typing, anything to the right is ignored. + /// + /// + /// The console that is typing this command. + /// The set of commands currently being typed. + /// If the last parameter is an empty string, it basically represents that the user hit space after the previous term and should already get completion results, + /// even if they haven't started typing the new argument yet. + /// The possible completion results presented to the user. + /// + CompletionResult GetCompletion(IConsoleShell shell, string[] args) => CompletionResult.Empty; + + /// + /// Fetches completion results for typing a command, async variant. See for details. + /// + /// + /// If this method is implemented, will not be automatically called. + /// + ValueTask GetCompletionAsync(IConsoleShell shell, string[] args, CancellationToken cancel) + { + return ValueTask.FromResult(GetCompletion(shell, args)); + } } } diff --git a/Robust.Shared/Console/IConsoleHost.cs b/Robust.Shared/Console/IConsoleHost.cs index b5a969e92..674064af9 100644 --- a/Robust.Shared/Console/IConsoleHost.cs +++ b/Robust.Shared/Console/IConsoleHost.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Robust.Shared.Players; namespace Robust.Shared.Console @@ -12,6 +13,16 @@ namespace Robust.Shared.Console /// An array of all the parsed arguments. public delegate void ConCommandCallback(IConsoleShell shell, string argStr, string[] args); + /// + /// Called to fetch completions for a console command. See for details. + /// + public delegate CompletionResult ConCommandCompletionCallback(IConsoleShell shell, string[] args); + + /// + /// Called to fetch completions for a console command (async). See for details. + /// + public delegate ValueTask ConCommandCompletionAsyncCallback(IConsoleShell shell, string[] args); + public delegate void ConAnyCommandCallback(IConsoleShell shell, string commandName, string argStr, string[] args); /// @@ -59,11 +70,57 @@ namespace Robust.Shared.Console /// A string as identifier for this command. /// Short one sentence description of the command. /// Command format string. - /// - void RegisterCommand(string command, string description, string help, ConCommandCallback callback); + /// + /// Callback to invoke when this command is executed. + /// + void RegisterCommand( + string command, + string description, + string help, + ConCommandCallback callback); /// - /// Unregisters a console command that has been registered previously with . + /// Registers a console command into the console system. This is an alternative to + /// creating an class. + /// + /// A string as identifier for this command. + /// Short one sentence description of the command. + /// Command format string. + /// + /// Callback to invoke when this command is executed. + /// + /// + /// Callback to fetch completions with. + /// + void RegisterCommand( + string command, + string description, + string help, + ConCommandCallback callback, + ConCommandCompletionCallback completionCallback); + + /// + /// Registers a console command into the console system. This is an alternative to + /// creating an class. + /// + /// A string as identifier for this command. + /// Short one sentence description of the command. + /// Command format string. + /// + /// Callback to invoke when this command is executed. + /// + /// + /// Callback to fetch completions with (async variant). + /// + void RegisterCommand( + string command, + string description, + string help, + ConCommandCallback callback, + ConCommandCompletionAsyncCallback completionCallback); + + /// + /// Unregisters a console command that has been registered previously with . /// If the specified command was registered automatically or isn't registered at all, the method will throw. /// /// The string identifier for the command. @@ -152,4 +209,13 @@ namespace Robust.Shared.Console /// void ClearLocalConsole(); } + + internal interface IConsoleHostInternal : IConsoleHost + { + /// + /// Is this command executed on the server? + /// Always true when ran from server, true for server-proxy commands on the client. + /// + bool IsCmdServer(IConsoleCommand cmd); + } } diff --git a/Robust.Shared/ContentPack/DirLoader.cs b/Robust.Shared/ContentPack/DirLoader.cs index 1cb8dc2e0..b63d23940 100644 --- a/Robust.Shared/ContentPack/DirLoader.cs +++ b/Robust.Shared/ContentPack/DirLoader.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using System.Threading.Tasks; using Robust.Shared.Log; using Robust.Shared.Utility; @@ -81,6 +82,23 @@ namespace Robust.Shared.ContentPack } } + public IEnumerable GetEntries(ResourcePath path) + { + var fullPath = GetPath(path); + if (!Directory.Exists(fullPath)) + return Enumerable.Empty(); + + return Directory.EnumerateFileSystemEntries(fullPath) + .Select(c => + { + var rel = Path.GetRelativePath(fullPath, c); + if (Directory.Exists(c)) + return rel + "/"; + + return rel; + }); + } + [Conditional("DEBUG")] private void CheckPathCasing(ResourcePath path) { diff --git a/Robust.Shared/ContentPack/IContentRoot.cs b/Robust.Shared/ContentPack/IContentRoot.cs index 20dc586c7..762446d3d 100644 --- a/Robust.Shared/ContentPack/IContentRoot.cs +++ b/Robust.Shared/ContentPack/IContentRoot.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using Robust.Shared.Utility; namespace Robust.Shared.ContentPack @@ -36,5 +37,22 @@ namespace Robust.Shared.ContentPack /// /// Enumeration of all relative file paths. IEnumerable GetRelativeFilePaths(); + + IEnumerable GetEntries(ResourcePath path) + { + var countDirs = path == ResourcePath.Self ? 0 : path.EnumerateSegments().Count(); + + var options = FindFiles(path).Select(c => + { + var segCount = c.EnumerateSegments().Count(); + var newPath = c.EnumerateSegments().Skip(countDirs).First(); + if (segCount > countDirs + 1) + newPath += "/"; + + return newPath; + }).Distinct(); + + return options; + } } } diff --git a/Robust.Shared/ContentPack/IResourceManager.cs b/Robust.Shared/ContentPack/IResourceManager.cs index 586644137..7d47d820a 100644 --- a/Robust.Shared/ContentPack/IResourceManager.cs +++ b/Robust.Shared/ContentPack/IResourceManager.cs @@ -123,6 +123,18 @@ namespace Robust.Shared.ContentPack /// Thrown if is null. IEnumerable ContentFindFiles(string path); + /// + /// Gets entries in a content directory. + /// + /// + /// This is not a performant API; the VFS does not work natively with these kinds of directory entries. + /// This is intended for development tools such as console command completions, + /// not for general-purpose resource management. + /// + /// + /// A sequence of entry names. If the entry name ends in a slash, it's a directory. + IEnumerable ContentGetDirectoryEntries(ResourcePath path); + /// /// Returns a list of paths to all top-level content directories /// diff --git a/Robust.Shared/ContentPack/IWritableDirProvider.cs b/Robust.Shared/ContentPack/IWritableDirProvider.cs index 77e7326cf..dcb85f345 100644 --- a/Robust.Shared/ContentPack/IWritableDirProvider.cs +++ b/Robust.Shared/ContentPack/IWritableDirProvider.cs @@ -46,6 +46,8 @@ namespace Robust.Shared.ContentPack (IEnumerable files, IEnumerable directories) Find(string pattern, bool recursive = true); + IEnumerable DirectoryEntries(ResourcePath path); + /// /// Tests if a path is a directory. /// @@ -91,4 +93,4 @@ namespace Robust.Shared.ContentPack /// void Rename(ResourcePath oldPath, ResourcePath newPath); } -} \ No newline at end of file +} diff --git a/Robust.Shared/ContentPack/ResourceManager.cs b/Robust.Shared/ContentPack/ResourceManager.cs index 0af4b72e5..c0c84486a 100644 --- a/Robust.Shared/ContentPack/ResourceManager.cs +++ b/Robust.Shared/ContentPack/ResourceManager.cs @@ -264,6 +264,36 @@ namespace Robust.Shared.ContentPack return ContentFindFiles(new ResourcePath(path)); } + public IEnumerable ContentGetDirectoryEntries(ResourcePath path) + { + ArgumentNullException.ThrowIfNull(path, nameof(path)); + + if (!path.IsRooted) + throw new ArgumentException("Path is not rooted", nameof(path)); + + var entries = new HashSet(); + + _contentRootsLock.EnterReadLock(); + try + { + foreach (var (prefix, root) in _contentRoots) + { + if (!path.TryRelativeTo(prefix, out var relative)) + { + continue; + } + + entries.UnionWith(root.GetEntries(relative)); + } + } + finally + { + _contentRootsLock.ExitReadLock(); + } + + return entries; + } + /// public IEnumerable ContentFindFiles(ResourcePath path) { diff --git a/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs b/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs index 503f78c05..0d38a0225 100644 --- a/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs +++ b/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs @@ -78,6 +78,14 @@ namespace Robust.Shared.ContentPack throw new NotImplementedException(); } + public IEnumerable DirectoryEntries(ResourcePath path) + { + if (!TryGetNodeAt(path, out var dir) || dir is not DirectoryNode dirNode) + throw new ArgumentException("Path is not a valid directory node."); + + return dirNode.Children.Keys; + } + public bool IsDir(ResourcePath path) { return TryGetNodeAt(path, out var node) && node is DirectoryNode; diff --git a/Robust.Shared/ContentPack/WritableDirProvider.cs b/Robust.Shared/ContentPack/WritableDirProvider.cs index cd1e4669c..f7e6b1900 100644 --- a/Robust.Shared/ContentPack/WritableDirProvider.cs +++ b/Robust.Shared/ContentPack/WritableDirProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Robust.Shared.Utility; namespace Robust.Shared.ContentPack @@ -76,6 +77,16 @@ namespace Robust.Shared.ContentPack return (resFiles, resDirs); } + public IEnumerable DirectoryEntries(ResourcePath path) + { + var fullPath = GetFullPath(path); + + foreach (var entry in Directory.EnumerateFileSystemEntries(fullPath)) + { + yield return Path.GetRelativePath(fullPath, entry); + } + } + /// public bool IsDir(ResourcePath path) { diff --git a/Robust.Shared/Input/KeyFunctions.cs b/Robust.Shared/Input/KeyFunctions.cs index 45c9db61f..dea4c3bf8 100644 --- a/Robust.Shared/Input/KeyFunctions.cs +++ b/Robust.Shared/Input/KeyFunctions.cs @@ -70,6 +70,8 @@ namespace Robust.Shared.Input public static readonly BoundKeyFunction TextScrollToBottom = "TextScrollToBottom"; public static readonly BoundKeyFunction TextDelete = "TextDelete"; public static readonly BoundKeyFunction TextTabComplete = "TextTabComplete"; + public static readonly BoundKeyFunction TextCompleteNext = "TextCompleteNext"; + public static readonly BoundKeyFunction TextCompletePrev = "TextCompletePrev"; } [Serializable, NetSerializable] diff --git a/Robust.Shared/Log/ILogManager.cs b/Robust.Shared/Log/ILogManager.cs index bdc69a79c..d3bc8c0b5 100644 --- a/Robust.Shared/Log/ILogManager.cs +++ b/Robust.Shared/Log/ILogManager.cs @@ -1,4 +1,6 @@ -namespace Robust.Shared.Log +using System.Collections.Generic; + +namespace Robust.Shared.Log { /// /// Manages logging sawmills. @@ -15,5 +17,10 @@ /// Gets the sawmill with the specified name. Creates a new one if necessary. /// ISawmill GetSawmill(string name); + + /// + /// Gets a list of all currently created sawmills. + /// + IEnumerable AllSawmills { get; } } } diff --git a/Robust.Shared/Log/LogManager.cs b/Robust.Shared/Log/LogManager.cs index 1c8b1bc6a..9fc37083d 100644 --- a/Robust.Shared/Log/LogManager.cs +++ b/Robust.Shared/Log/LogManager.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; +using Robust.Shared.Utility; namespace Robust.Shared.Log { @@ -41,6 +43,16 @@ namespace Robust.Shared.Log } } + public IEnumerable AllSawmills + { + get + { + using var _ = _sawmillsLock.ReadGuard(); + + return sawmills.Values.ToArray(); + } + } + private Sawmill _getSawmillUnlocked(string name) { if (sawmills.TryGetValue(name, out var sawmill)) diff --git a/Robust.Shared/Log/ProxyLogManager.cs b/Robust.Shared/Log/ProxyLogManager.cs index f5880d563..13bc82482 100644 --- a/Robust.Shared/Log/ProxyLogManager.cs +++ b/Robust.Shared/Log/ProxyLogManager.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Robust.Shared.Log { public sealed class ProxyLogManager : ILogManager @@ -15,5 +17,7 @@ namespace Robust.Shared.Log { return _impl.GetSawmill(name); } + + public IEnumerable AllSawmills => _impl.AllSawmills; } } diff --git a/Robust.Shared/Network/Messages/MsgConCompletion.cs b/Robust.Shared/Network/Messages/MsgConCompletion.cs new file mode 100644 index 000000000..695d8c258 --- /dev/null +++ b/Robust.Shared/Network/Messages/MsgConCompletion.cs @@ -0,0 +1,36 @@ +using Lidgren.Network; + +namespace Robust.Shared.Network.Messages; + +#nullable disable + +public sealed class MsgConCompletion : NetMessage +{ + public override MsgGroups MsgGroup => MsgGroups.Command; + + public int Seq { get; set; } + public string[] Args { get; set; } + + public override void ReadFromBuffer(NetIncomingMessage buffer) + { + Seq = buffer.ReadInt32(); + + var len = buffer.ReadVariableInt32(); + Args = new string[len]; + for (var i = 0; i < len; i++) + { + Args[i] = buffer.ReadString(); + } + } + + public override void WriteToBuffer(NetOutgoingMessage buffer) + { + buffer.Write(Seq); + + buffer.WriteVariableInt32(Args.Length); + foreach (var arg in Args) + { + buffer.Write(arg); + } + } +} diff --git a/Robust.Shared/Network/Messages/MsgConCompletionResp.cs b/Robust.Shared/Network/Messages/MsgConCompletionResp.cs new file mode 100644 index 000000000..fdbe49940 --- /dev/null +++ b/Robust.Shared/Network/Messages/MsgConCompletionResp.cs @@ -0,0 +1,52 @@ +using Lidgren.Network; +using Robust.Shared.Console; + +namespace Robust.Shared.Network.Messages; + +#nullable disable + +public sealed class MsgConCompletionResp : NetMessage +{ + public override MsgGroups MsgGroup => MsgGroups.Command; + + public int Seq { get; set; } + public CompletionResult Result { get; set; } + + public override void ReadFromBuffer(NetIncomingMessage buffer) + { + Seq = buffer.ReadInt32(); + + var len = buffer.ReadVariableInt32(); + var options = new CompletionOption[len]; + for (var i = 0; i < len; i++) + { + var optValue = buffer.ReadString(); + var optHint = buffer.ReadString(); + var optFlags = buffer.ReadInt32(); + + options[i] = new CompletionOption( + optValue, + optHint == "" ? null : optHint, + (CompletionOptionFlags) optFlags); + } + + var hint = buffer.ReadString(); + + Result = new CompletionResult(options, hint == "" ? null : hint); + } + + public override void WriteToBuffer(NetOutgoingMessage buffer) + { + buffer.Write(Seq); + + buffer.WriteVariableInt32(Result.Options.Length); + foreach (var option in Result.Options) + { + buffer.Write(option.Value); + buffer.Write(option.Hint); + buffer.Write((int) option.Flags); + } + + buffer.Write(Result.Hint); + } +} diff --git a/Robust.Shared/Utility/CommandParsing.cs b/Robust.Shared/Utility/CommandParsing.cs index d581c15c9..f47ec2d1d 100644 --- a/Robust.Shared/Utility/CommandParsing.cs +++ b/Robust.Shared/Utility/CommandParsing.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; - -#nullable enable +using System.Text; +using Robust.Shared.Utility.Collections; namespace Robust.Shared.Utility { @@ -14,8 +14,18 @@ namespace Robust.Shared.Utility /// List of arguments to write into. public static void ParseArguments(ReadOnlySpan text, List args) { - var buf = ""; + var ranges = new ValueList<(int, int)>(); + ParseArguments(text, args, ref ranges); + } + + internal static void ParseArguments( + ReadOnlySpan text, + List args, + ref ValueList<(int start, int end)> ranges) + { + var sb = new StringBuilder(); var inQuotes = false; + var startPos = -1; for (var i = 0; i < text.Length; i++) { @@ -23,13 +33,14 @@ namespace Robust.Shared.Utility if (chr == '\\') { i += 1; + startPos = i; if (i == text.Length) { - buf += "\\"; + sb.Append('\\'); break; } - buf += text[i]; + sb.Append(text[i]); continue; } @@ -37,8 +48,15 @@ namespace Robust.Shared.Utility { if (inQuotes) { - args.Add(buf); - buf = ""; + args.Add(sb.ToString()); + sb.Clear(); + ranges.Add((startPos, i + 1)); + startPos = -1; + } + else + { + if (startPos < 0) + startPos = i; } inQuotes = !inQuotes; @@ -47,20 +65,27 @@ namespace Robust.Shared.Utility if (chr == ' ' && !inQuotes) { - if (buf != "") + if (sb.Length != 0) { - args.Add(buf); - buf = ""; + args.Add(sb.ToString()); + sb.Clear(); + ranges.Add((startPos, i)); + startPos = -1; } + continue; } - buf += chr; + if (startPos < 0) + startPos = i; + + sb.Append(chr); } - if (buf != "") + if (sb.Length != 0) { - args.Add(buf); + args.Add(sb.ToString()); + ranges.Add((startPos, text.Length)); } }