Console command completions v1. (#2817)

* Console command completions v1.

I think it works™️

* Unify cvar commands

* Handle no-completions-at-all better.

* Don't crash if you tab complete while no completions available.

* Always show hints if available

* Properly null completion hint over the wire

* Unify help command, localize it.

* Clean up + localize cvar command.

* Remove debug logging

* List command unified & localized.

* Remove server completions debug logging

* Remote execute command.

Had to make everything async for this.

* Don't lower case enums or bools
Why

* GC commands converted and localized.

* Fix remote command completions.

Whoops

* Kick command completions

* lsasm unified & localized.

* Revert "Don't lower case enums or bools"

This reverts commit 2f825347c3.

* ToString gc_mode command enums instead of trying to fix Fluent.

Ah well.

* Unify szr_stats

* Unify log commands, completions

* Fix compile

* Improve completion with complex cases (quotes, escapes)

* Code cleanup, comments.

* Fix tab completion with empty arg ruining everything.

* Fix RegisteredCommand completions

* Add more complex completion options system.

* Refactor content directory entries into a proper resource manager API.

* Implement GetEntries for DirLoader

* Make type hint darker.

* Exec command autocomplete, pulled play global sound code out to engine.
This commit is contained in:
Pieter-Jan Briers
2022-05-17 05:07:25 +02:00
committed by GitHub
parent 84a80db21f
commit 5057c91dcd
50 changed files with 1888 additions and 833 deletions

View File

@@ -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 <command>'. To list all available commands, write 'list'. To search for commands, use 'list <filter>'.
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 <name | ?> [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 = <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: > <command> [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 <fileName>
Each line in the file is executed as a single command, unless it starts with a #
cmd-exec-arg-filename = <fileName>

View File

@@ -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<int, PendingCompletion> _completionsPending = new();
private int _completionSeq;
public async Task<CompletionResult> GetCompletions(List<string> 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<CompletionResult> CalcCompletions(List<string> 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<CompletionResult> DoServerCompletions(List<string> args, CancellationToken cancel)
{
var tcs = new TaskCompletionSource<CompletionResult>();
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<CompletionResult> Tcs;
public CancellationTokenSource Cts;
}
}

View File

@@ -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
}
/// <inheritdoc cref="IClientConsoleHost" />
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<MsgConCmdReg>(HandleConCmdReg);
NetManager.RegisterNetMessage<MsgConCmdAck>(HandleConCmdAck);
NetManager.RegisterNetMessage<MsgConCmd>(ProcessCommand);
NetManager.RegisterNetMessage<MsgConCompletion>();
NetManager.RegisterNetMessage<MsgConCompletionResp>(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;
/// <inheritdoc />
@@ -108,8 +121,8 @@ namespace Robust.Client.Console
if (AvailableCommands.ContainsKey(commandName))
{
var playerManager = IoCManager.Resolve<IPlayerManager>();
#if !DEBUG
var playerManager = IoCManager.Resolve<IPlayerManager>();
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;
}
}
/// <summary>
/// These dummies are made purely so list and help can list server-side commands.
/// </summary>
[Reflect(false)]
internal sealed class ServerDummyCommand : IConsoleCommand
{
internal ServerDummyCommand(string command, string help, string description)
/// <summary>
/// These dummies are made purely so list and help can list server-side commands.
/// </summary>
[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<CompletionResult> 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<CompletionResult> GetCompletionAsync(
IConsoleShell shell,
string[] args,
CancellationToken cancel)
{
var host = (ClientConsoleHost)shell.ConsoleHost;
return await host.DoServerCompletions(args.ToList(), cancel);
}
}
}
}

View File

@@ -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<IConfigurationManager>();
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<object>(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
{

View File

@@ -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";

View File

@@ -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 <command>'. To list all available commands, write 'list'.");
break;
case 1:
string commandname = args[0];
if (!shell.ConsoleHost.RegisteredCommands.ContainsKey(commandname))
{
if (!IoCManager.Resolve<IClientNetManager>().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<IClientConGroupController>();
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);
}
}
}
}

View File

@@ -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}");
}
}
}
}
}

View File

@@ -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 <sawmill> <level>"
+ "\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<LogLevel>(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 <sawmill> <level> <message>"
+ "\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<LogLevel>(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);
}
}
}

View File

@@ -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<AddFormattedMessageArgs> AddFormatted;
void AddFormattedLine(FormattedMessage message);
Task<CompletionResult> GetCompletions(List<string> args, CancellationToken cancel);
}
}

View File

@@ -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);

View File

@@ -281,6 +281,8 @@ namespace Robust.Client.UserInterface.Controls
return finalSize;
}
public event Action<GUITextEventArgs>? 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<LineEditBackspaceEventArgs>? 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;
}
/// <summary>
/// Get offset from the left of the control
/// to the left edge of the text glyph at the specified index in the text.
/// </summary>
/// <remarks>
/// The returned value can be outside the bounds of the control if the glyph is currently clipped off.
/// </remarks>
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;
}
}
/// <summary>
/// Use a separate control to do the rendering to make use of RectClipContent,
/// so that we can clip characters in half.

View File

@@ -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<string> 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<string>();
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<CompletionOption> 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();
}
}

View File

@@ -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<FormattedMessage> _messageQueue = new();
private bool commandChanged = true;
private readonly List<string> 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<string>();
_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()

View File

@@ -0,0 +1,9 @@
<Popup xmlns="https://spacestation14.io"
xmlns:gfx="clr-namespace:Robust.Client.Graphics">
<PanelContainer>
<PanelContainer.PanelOverride>
<gfx:StyleBoxFlat BackgroundColor="#000D" />
</PanelContainer.PanelOverride>
<BoxContainer Access="Public" Margin="2" Name="Contents" Orientation="Vertical" />
</PanelContainer>
</Popup>

View File

@@ -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);
}
}

View File

@@ -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<IConfigurationManager>();
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<object>(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}");
}
}
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 <sawmill> <level>"
+ "\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<LogLevel>(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 <sawmill> <level> <message>"
+ "\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<LogLevel>(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);
}
}
}

View File

@@ -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<IPlayerManager>();
var options = playerManager.ServerSessions.OrderBy(c => c.Name).Select(c => c.Name).ToArray();
return CompletionResult.FromHintOptions(options, "<PlayerIndex>");
}
if (args.Length > 1)
{
return CompletionResult.FromHint("[<Reason>]");
}
return CompletionResult.Empty;
}
}
}

View File

@@ -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 <command>'. 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
}
}

View File

@@ -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
{
/// <inheritdoc cref="IServerConsoleHost" />
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;
/// <inheritdoc />
public void Initialize()
{
RegisterCommand("sudo", "sudo make me a sandwich", "sudo <command>",(shell, argStr, _) =>
RegisterCommand("sudo", "sudo make me a sandwich", "sudo <command>", (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<MsgConCmdAck>();
NetManager.RegisterNetMessage<MsgConCmdReg>(message => HandleRegistrationRequest(message.MsgChannel));
NetManager.RegisterNetMessage<MsgConCompletion>(HandleConCompletions);
NetManager.RegisterNetMessage<MsgConCompletionResp>();
}
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<IServerNetManager>();
@@ -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<CompletionResult> 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;

View File

@@ -1153,6 +1153,31 @@ namespace Robust.Shared
public static readonly CVarDef<int> AczManifestCompressLevel =
CVarDef.Create("acz.manifest_compress_level", 14, CVar.SERVERONLY);
/*
* CON
*/
/// <summary>
/// Add artificial delay (in seconds) to console completion fetching, even for local commands.
/// </summary>
/// <remarks>
/// Intended for debugging the console completion system.
/// </remarks>
public static readonly CVarDef<float> ConCompletionDelay =
CVarDef.Create("con.completion_delay", 0f, CVar.CLIENTONLY);
/// <summary>
/// The amount of completions to show in console completion drop downs.
/// </summary>
public static readonly CVarDef<int> ConCompletionCount =
CVarDef.Create("con.completion_count", 15, CVar.CLIENTONLY);
/// <summary>
/// The minimum margin of options to keep on either side of the completion cursor, when scrolling through.
/// </summary>
public static readonly CVarDef<int> ConCompletionMargin =
CVarDef.Create("con.completion_margin", 3, CVar.CLIENTONLY);
/*
* THREAD
*/

View File

@@ -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 <name> [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<IConfigurationManager>();
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<object>(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<IConfigurationManager>();
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();
}
}
}

View File

@@ -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 <fileName>\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<IResourceManager>();
@@ -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<IResourceManager>();
var hint = Loc.GetString("cmd-exec-arg-filename");
var options = CompletionHelper.UserFilePath(args[0], res.UserData);
return CompletionResult.FromHintOptions(options, hint);
}
return CompletionResult.Empty;
}
}
}

View File

@@ -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<GCLatencyMode>())
{
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<GCLatencyMode>(),
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)));
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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 <sawmill> <level>"
+ "\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<LogLevel>(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<ILogManager>();
switch (args.Length)
{
case 1:
return CompletionResult.FromHintOptions(
logMgr.AllSawmills.Select(c => c.Name).OrderBy(c => c),
"<sawmill>");
case 2:
return CompletionResult.FromHintOptions(
Enum.GetNames<LogLevel>(),
"<level>");
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 <sawmill> <level> <message>"
+ "\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<LogLevel>(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<ILogManager>();
switch (args.Length)
{
case 1:
return CompletionResult.FromHintOptions(
logMgr.AllSawmills.Select(c => c.Name).OrderBy(c => c),
"<sawmill>");
case 2:
return CompletionResult.FromHintOptions(
Enum.GetNames<LogLevel>(),
"<level>");
case 3:
return CompletionResult.FromHint("<message>");
default:
return CompletionResult.Empty;
}
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using Robust.Shared.ContentPack;
using Robust.Shared.Utility;
namespace Robust.Shared.Console;
/// <summary>
/// Helpers for creating various completion results.
/// </summary>
public static class CompletionHelper
{
public static IEnumerable<CompletionOption> 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<CompletionOption> UserFilePath(string arg, IWritableDirProvider provider)
{
var curPath = arg;
if (curPath == "")
curPath = "/";
var resPath = new ResourcePath(curPath);
if (!resPath.IsRooted)
return Enumerable.Empty<CompletionOption>();
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);
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Robust.Shared.Console;
/// <summary>
/// Contains the result of a command completion.
/// </summary>
public sealed record CompletionResult(CompletionOption[] Options, string? Hint)
{
/// <summary>
/// The possible full arguments to complete with. These are from the start of the entire argument.
/// </summary>
public CompletionOption[] Options { get; init; } = Options;
/// <summary>
/// Type hint string for the current argument being typed.
/// </summary>
public string? Hint { get; init; } = Hint;
public static readonly CompletionResult Empty = new(Array.Empty<CompletionOption>(), null);
public static CompletionResult FromHintOptions(IEnumerable<string> options, string? hint) => new(ConvertOptions(options), hint);
public static CompletionResult FromHintOptions(IEnumerable<CompletionOption> options, string? hint) => new(options.ToArray(), hint);
public static CompletionResult FromOptions(IEnumerable<string> options) => new(ConvertOptions(options), null);
public static CompletionResult FromOptions(IEnumerable<CompletionOption> options) => new(options.ToArray(), null);
public static CompletionResult FromHint(string hint) => new(Array.Empty<CompletionOption>(), hint);
private static CompletionOption[] ConvertOptions(IEnumerable<string> stringOpts)
{
return stringOpts.Select(c => new CompletionOption(c)).ToArray();
}
}
/// <summary>
/// Possible option to tab-complete in a <see cref="CompletionResult"/>.
/// </summary>
public record struct CompletionOption(string Value, string? Hint = null, CompletionOptionFlags Flags = default)
{
/// <summary>
/// The value that will be filled in if completed.
/// </summary>
public string Value { get; set; } = Value;
/// <summary>
/// Additional hint value that is shown to users, but not included in the completed value.
/// </summary>
public string? Hint { get; set; } = Hint;
/// <summary>
/// Flags that control how this completion is used.
/// </summary>
public CompletionOptionFlags Flags { get; set; } = Flags;
}
/// <summary>
/// Flag options for <see cref="CompletionOption"/>.
/// </summary>
[Flags]
public enum CompletionOptionFlags
{
/// <summary>
/// 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).
/// </summary>
PartialCompletion = 1 << 0,
}

View File

@@ -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<string, IConsoleCommand> AvailableCommands = new();
[ViewVariables] protected readonly Dictionary<string, IConsoleCommand> 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<IConsoleCommand>())
{
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
}
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
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; }
/// <inheritdoc />
public string Command { get; }
@@ -174,7 +209,12 @@ namespace Robust.Shared.Console
/// <param name="description">Short description of the command.</param>
/// <param name="help">Extended description for the command.</param>
/// <param name="callback">Callback function that is ran when the command is executed.</param>
public RegisteredCommand(string command, string description, string help, ConCommandCallback callback)
/// <param name="completionCallback">Callback function to get console completions.</param>
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;
}
/// <summary>
/// Constructs a new instance of <see cref="RegisteredCommand"/>.
/// </summary>
/// <param name="command">Name of the command.</param>
/// <param name="description">Short description of the command.</param>
/// <param name="help">Extended description for the command.</param>
/// <param name="callback">Callback function that is ran when the command is executed.</param>
/// <param name="completionCallback">Callback function to get console completions.</param>
public RegisteredCommand(
string command,
string description,
string help,
ConCommandCallback callback,
ConCommandCompletionCallback completionCallback) : this(command, description, help, callback)
{
CompletionCallback = completionCallback;
}
/// <summary>
/// Constructs a new instance of <see cref="RegisteredCommand"/>.
/// </summary>
/// <param name="command">Name of the command.</param>
/// <param name="description">Short description of the command.</param>
/// <param name="help">Extended description for the command.</param>
/// <param name="callback">Callback function that is ran when the command is executed.</param>
/// <param name="completionCallback">Asynchronous callback function to get console completions.</param>
public RegisteredCommand(
string command,
string description,
string help,
ConCommandCallback callback,
ConCommandCompletionAsyncCallback completionCallback)
: this(command, description, help, callback)
{
CompletionCallbackAsync = completionCallback;
}
/// <inheritdoc />
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
Callback(shell, argStr, args);
}
public ValueTask<CompletionResult> 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);
}
}
}
}

View File

@@ -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
/// <param name="argStr">Unparsed text of the complete command with arguments.</param>
/// <param name="args">An array of all the parsed arguments.</param>
void Execute(IConsoleShell shell, string argStr, string[] args);
/// <summary>
/// Fetches completion results for a typing a command.
/// </summary>
/// <remarks>
/// <para>
/// Refrain from doing simple <c>.StartsWith(</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="shell">The console that is typing this command.</param>
/// <param name="args">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.</param>
/// <returns>The possible completion results presented to the user.</returns>
/// <seealso cref="GetCompletionAsync"/>
CompletionResult GetCompletion(IConsoleShell shell, string[] args) => CompletionResult.Empty;
/// <summary>
/// Fetches completion results for typing a command, async variant. See <see cref="GetCompletion"/> for details.
/// </summary>
/// <remarks>
/// If this method is implemented, <see cref="GetCompletion"/> will not be automatically called.
/// </remarks>
ValueTask<CompletionResult> GetCompletionAsync(IConsoleShell shell, string[] args, CancellationToken cancel)
{
return ValueTask.FromResult(GetCompletion(shell, args));
}
}
}

View File

@@ -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
/// <param name="args">An array of all the parsed arguments.</param>
public delegate void ConCommandCallback(IConsoleShell shell, string argStr, string[] args);
/// <summary>
/// Called to fetch completions for a console command. See <see cref="IConsoleCommand.GetCompletion"/> for details.
/// </summary>
public delegate CompletionResult ConCommandCompletionCallback(IConsoleShell shell, string[] args);
/// <summary>
/// Called to fetch completions for a console command (async). See <see cref="IConsoleCommand.GetCompletionAsync"/> for details.
/// </summary>
public delegate ValueTask<CompletionResult> ConCommandCompletionAsyncCallback(IConsoleShell shell, string[] args);
public delegate void ConAnyCommandCallback(IConsoleShell shell, string commandName, string argStr, string[] args);
/// <summary>
@@ -59,11 +70,57 @@ namespace Robust.Shared.Console
/// <param name="command">A string as identifier for this command.</param>
/// <param name="description">Short one sentence description of the command.</param>
/// <param name="help">Command format string.</param>
/// <param name="callback"></param>
void RegisterCommand(string command, string description, string help, ConCommandCallback callback);
/// <param name="callback">
/// Callback to invoke when this command is executed.
/// </param>
void RegisterCommand(
string command,
string description,
string help,
ConCommandCallback callback);
/// <summary>
/// Unregisters a console command that has been registered previously with <see cref="RegisterCommand"/>.
/// Registers a console command into the console system. This is an alternative to
/// creating an <see cref="IConsoleCommand"/> class.
/// </summary>
/// <param name="command">A string as identifier for this command.</param>
/// <param name="description">Short one sentence description of the command.</param>
/// <param name="help">Command format string.</param>
/// <param name="callback">
/// Callback to invoke when this command is executed.
/// </param>
/// <param name="completionCallback">
/// Callback to fetch completions with.
/// </param>
void RegisterCommand(
string command,
string description,
string help,
ConCommandCallback callback,
ConCommandCompletionCallback completionCallback);
/// <summary>
/// Registers a console command into the console system. This is an alternative to
/// creating an <see cref="IConsoleCommand"/> class.
/// </summary>
/// <param name="command">A string as identifier for this command.</param>
/// <param name="description">Short one sentence description of the command.</param>
/// <param name="help">Command format string.</param>
/// <param name="callback">
/// Callback to invoke when this command is executed.
/// </param>
/// <param name="completionCallback">
/// Callback to fetch completions with (async variant).
/// </param>
void RegisterCommand(
string command,
string description,
string help,
ConCommandCallback callback,
ConCommandCompletionAsyncCallback completionCallback);
/// <summary>
/// Unregisters a console command that has been registered previously with <see cref="RegisterCommand(string,string,string,Robust.Shared.Console.ConCommandCallback)"/>.
/// If the specified command was registered automatically or isn't registered at all, the method will throw.
/// </summary>
/// <param name="command">The string identifier for the command.</param>
@@ -152,4 +209,13 @@ namespace Robust.Shared.Console
/// </summary>
void ClearLocalConsole();
}
internal interface IConsoleHostInternal : IConsoleHost
{
/// <summary>
/// Is this command executed on the server?
/// Always true when ran from server, true for server-proxy commands on the client.
/// </summary>
bool IsCmdServer(IConsoleCommand cmd);
}
}

View File

@@ -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<string> GetEntries(ResourcePath path)
{
var fullPath = GetPath(path);
if (!Directory.Exists(fullPath))
return Enumerable.Empty<string>();
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)
{

View File

@@ -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
/// </summary>
/// <returns>Enumeration of all relative file paths.</returns>
IEnumerable<string> GetRelativeFilePaths();
IEnumerable<string> 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;
}
}
}

View File

@@ -123,6 +123,18 @@ namespace Robust.Shared.ContentPack
/// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
IEnumerable<ResourcePath> ContentFindFiles(string path);
/// <summary>
/// Gets entries in a content directory.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="path"></param>
/// <returns>A sequence of entry names. If the entry name ends in a slash, it's a directory.</returns>
IEnumerable<string> ContentGetDirectoryEntries(ResourcePath path);
/// <summary>
/// Returns a list of paths to all top-level content directories
/// </summary>

View File

@@ -46,6 +46,8 @@ namespace Robust.Shared.ContentPack
(IEnumerable<ResourcePath> files, IEnumerable<ResourcePath> directories) Find(string pattern,
bool recursive = true);
IEnumerable<string> DirectoryEntries(ResourcePath path);
/// <summary>
/// Tests if a path is a directory.
/// </summary>
@@ -91,4 +93,4 @@ namespace Robust.Shared.ContentPack
/// <returns></returns>
void Rename(ResourcePath oldPath, ResourcePath newPath);
}
}
}

View File

@@ -264,6 +264,36 @@ namespace Robust.Shared.ContentPack
return ContentFindFiles(new ResourcePath(path));
}
public IEnumerable<string> ContentGetDirectoryEntries(ResourcePath path)
{
ArgumentNullException.ThrowIfNull(path, nameof(path));
if (!path.IsRooted)
throw new ArgumentException("Path is not rooted", nameof(path));
var entries = new HashSet<string>();
_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;
}
/// <inheritdoc />
public IEnumerable<ResourcePath> ContentFindFiles(ResourcePath path)
{

View File

@@ -78,6 +78,14 @@ namespace Robust.Shared.ContentPack
throw new NotImplementedException();
}
public IEnumerable<string> 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;

View File

@@ -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<string> DirectoryEntries(ResourcePath path)
{
var fullPath = GetFullPath(path);
foreach (var entry in Directory.EnumerateFileSystemEntries(fullPath))
{
yield return Path.GetRelativePath(fullPath, entry);
}
}
/// <inheritdoc />
public bool IsDir(ResourcePath path)
{

View File

@@ -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]

View File

@@ -1,4 +1,6 @@
namespace Robust.Shared.Log
using System.Collections.Generic;
namespace Robust.Shared.Log
{
/// <summary>
/// Manages logging sawmills.
@@ -15,5 +17,10 @@
/// Gets the sawmill with the specified name. Creates a new one if necessary.
/// </summary>
ISawmill GetSawmill(string name);
/// <summary>
/// Gets a list of all currently created sawmills.
/// </summary>
IEnumerable<ISawmill> AllSawmills { get; }
}
}

View File

@@ -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<ISawmill> AllSawmills
{
get
{
using var _ = _sawmillsLock.ReadGuard();
return sawmills.Values.ToArray();
}
}
private Sawmill _getSawmillUnlocked(string name)
{
if (sawmills.TryGetValue(name, out var sawmill))

View File

@@ -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<ISawmill> AllSawmills => _impl.AllSawmills;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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
/// <param name="args">List of arguments to write into.</param>
public static void ParseArguments(ReadOnlySpan<char> text, List<string> args)
{
var buf = "";
var ranges = new ValueList<(int, int)>();
ParseArguments(text, args, ref ranges);
}
internal static void ParseArguments(
ReadOnlySpan<char> text,
List<string> 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));
}
}