885 lines
34 KiB
C#
885 lines
34 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Cysharp.Threading.Tasks;
|
|
|
|
namespace ShrinkCommand
|
|
{
|
|
public sealed class ShrinkCommandService
|
|
{
|
|
private sealed class CommandDefinition
|
|
{
|
|
public ShrinkCommandDescriptor Descriptor { get; set; } = new();
|
|
public IReadOnlyList<string> ArgumentNames { get; set; } = Array.Empty<string>();
|
|
public IReadOnlyList<PathPattern> Patterns { get; set; } = Array.Empty<PathPattern>();
|
|
public Func<ShrinkCommandContext, UniTask<ShrinkCommandExecutionResult>> Handler { get; set; } =
|
|
_ => UniTask.FromResult(ShrinkCommandExecutionResult.Success());
|
|
}
|
|
|
|
private sealed class PathPattern
|
|
{
|
|
public string Text { get; set; } = string.Empty;
|
|
public IReadOnlyList<PathSegment> Segments { get; set; } = Array.Empty<PathSegment>();
|
|
}
|
|
|
|
private sealed class PathSegment
|
|
{
|
|
public bool IsArgument { get; set; }
|
|
public bool IsGreedy { get; set; }
|
|
public string Token { get; set; } = string.Empty;
|
|
}
|
|
|
|
private sealed class MatchCandidate
|
|
{
|
|
public CommandDefinition Definition { get; set; } = null!;
|
|
public PathPattern Pattern { get; set; } = null!;
|
|
public IReadOnlyList<string> RawArgumentValues { get; set; } = Array.Empty<string>();
|
|
public int LiteralScore { get; set; }
|
|
}
|
|
|
|
private readonly List<CommandDefinition> _commands = new();
|
|
private readonly Dictionary<Type, Func<string, object?>> _converters = new();
|
|
|
|
public event Action<ShrinkCommandExecutingInfo>? OnCommandExecuting;
|
|
public event Action<ShrinkCommandExecutedInfo>? OnCommandExecuted;
|
|
|
|
public ShrinkCommandService()
|
|
{
|
|
RegisterDefaultConverters();
|
|
RegisterBuiltInCommands();
|
|
}
|
|
|
|
public IReadOnlyList<ShrinkCommandDescriptor> Commands => _commands
|
|
.Select(command => command.Descriptor)
|
|
.OrderBy(command => command.Path, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
public void RegisterConverter<T>(Func<string, T> converter)
|
|
{
|
|
if (converter == null)
|
|
throw new ArgumentNullException(nameof(converter));
|
|
|
|
_converters[typeof(T)] = raw => converter(raw);
|
|
}
|
|
|
|
public void RegisterCommand(ShrinkCommandRegistration registration)
|
|
{
|
|
if (registration == null)
|
|
throw new ArgumentNullException(nameof(registration));
|
|
if (registration.Handler == null)
|
|
throw new ArgumentNullException(nameof(registration.Handler));
|
|
|
|
var primaryPattern = ParsePattern(registration.Path);
|
|
var argumentNames = primaryPattern.Segments
|
|
.Where(segment => segment.IsArgument)
|
|
.Select(segment => segment.Token)
|
|
.ToArray();
|
|
|
|
var patterns = new List<PathPattern> { primaryPattern };
|
|
foreach (var alias in registration.Aliases ?? Array.Empty<string>())
|
|
{
|
|
if (string.IsNullOrWhiteSpace(alias))
|
|
continue;
|
|
|
|
var aliasPattern = ParsePattern(alias);
|
|
ValidateAliasShape(primaryPattern, aliasPattern);
|
|
patterns.Add(aliasPattern);
|
|
}
|
|
|
|
var descriptor = new ShrinkCommandDescriptor
|
|
{
|
|
Path = primaryPattern.Text,
|
|
Aliases = (registration.Aliases ?? Array.Empty<string>())
|
|
.Where(alias => !string.IsNullOrWhiteSpace(alias))
|
|
.Select(alias => alias.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray(),
|
|
Description = registration.Description?.Trim() ?? string.Empty,
|
|
Permission = registration.Permission?.Trim() ?? string.Empty,
|
|
SourceKind = registration.SourceKind,
|
|
Hidden = registration.Hidden
|
|
};
|
|
|
|
var duplicate = _commands.FirstOrDefault(existing =>
|
|
existing.Patterns.Any(existingPattern =>
|
|
patterns.Any(newPattern => string.Equals(existingPattern.Text, newPattern.Text, StringComparison.OrdinalIgnoreCase))));
|
|
if (duplicate != null)
|
|
throw new InvalidOperationException($"Command path already registered: {duplicate.Descriptor.Path}");
|
|
|
|
_commands.Add(new CommandDefinition
|
|
{
|
|
Descriptor = descriptor,
|
|
ArgumentNames = argumentNames,
|
|
Patterns = patterns,
|
|
Handler = registration.Handler
|
|
});
|
|
}
|
|
|
|
public void AutoRegisterStaticCommands()
|
|
{
|
|
ShrinkCommandRegHelper.RegisterStaticCommands(this);
|
|
}
|
|
|
|
public void RegisterCommands(object target)
|
|
{
|
|
ShrinkCommandRegHelper.RegisterCommands(this, target);
|
|
}
|
|
|
|
public void AutoRegisterAll()
|
|
{
|
|
AutoRegisterStaticCommands();
|
|
}
|
|
|
|
public async UniTask<ShrinkCommandExecutionResult> ExecuteAsync(
|
|
IShrinkCommandSource source,
|
|
string rawInput,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (source == null)
|
|
throw new ArgumentNullException(nameof(source));
|
|
|
|
var input = (rawInput ?? string.Empty).Trim().TrimStart('\uFEFF');
|
|
if (string.IsNullOrWhiteSpace(input))
|
|
return ShrinkCommandExecutionResult.Success(BuildHelp(source));
|
|
|
|
List<string> tokens;
|
|
try
|
|
{
|
|
tokens = Tokenize(input);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return CompleteEarly(source, input, null, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
|
|
ShrinkCommandExecutionResult.Failure("命令解析失败: " + ex.Message));
|
|
}
|
|
|
|
var matches = FindMatches(tokens);
|
|
if (matches.Count == 0)
|
|
{
|
|
var suggestions = BuildSuggestions(tokens, source);
|
|
return CompleteEarly(source, input, null, new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
|
|
string.IsNullOrWhiteSpace(suggestions)
|
|
? ShrinkCommandExecutionResult.Failure($"未知命令: {input}\n输入 `help` 查看可用命令。")
|
|
: ShrinkCommandExecutionResult.Failure($"未知命令: {input}\n{suggestions}"));
|
|
}
|
|
|
|
var selected = SelectBestMatch(matches);
|
|
if (!CanRunSourceKind(selected.Definition.Descriptor.SourceKind, source))
|
|
{
|
|
return CompleteEarly(source, input, selected.Definition.Descriptor,
|
|
BuildArgumentMap(selected.Definition.ArgumentNames, selected.RawArgumentValues),
|
|
ShrinkCommandExecutionResult.Failure("该命令当前来源不可执行。"));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(selected.Definition.Descriptor.Permission) &&
|
|
!source.HasPermission(selected.Definition.Descriptor.Permission))
|
|
{
|
|
return CompleteEarly(source, input, selected.Definition.Descriptor,
|
|
BuildArgumentMap(selected.Definition.ArgumentNames, selected.RawArgumentValues),
|
|
ShrinkCommandExecutionResult.Failure(
|
|
$"没有权限执行该命令: {selected.Definition.Descriptor.Permission}"));
|
|
}
|
|
|
|
var argumentMap = BuildArgumentMap(selected.Definition.ArgumentNames, selected.RawArgumentValues);
|
|
var context = new ShrinkCommandContext(this, source, selected.Definition.Descriptor, input, argumentMap,
|
|
cancellationToken);
|
|
var executingInfo = new ShrinkCommandExecutingInfo
|
|
{
|
|
Service = this,
|
|
Source = source,
|
|
Command = selected.Definition.Descriptor,
|
|
RawInput = input,
|
|
Arguments = argumentMap
|
|
};
|
|
|
|
try
|
|
{
|
|
OnCommandExecuting?.Invoke(executingInfo);
|
|
var result = await selected.Definition.Handler(context);
|
|
OnCommandExecuted?.Invoke(new ShrinkCommandExecutedInfo
|
|
{
|
|
Service = this,
|
|
Source = source,
|
|
Command = selected.Definition.Descriptor,
|
|
RawInput = input,
|
|
Arguments = argumentMap,
|
|
Result = result
|
|
});
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShrinkCommandLogger.Exception(ex);
|
|
var failure = ShrinkCommandExecutionResult.Failure("命令执行失败: " + ex.Message);
|
|
OnCommandExecuted?.Invoke(new ShrinkCommandExecutedInfo
|
|
{
|
|
Service = this,
|
|
Source = source,
|
|
Command = selected.Definition.Descriptor,
|
|
RawInput = input,
|
|
Arguments = argumentMap,
|
|
Result = failure
|
|
});
|
|
return failure;
|
|
}
|
|
}
|
|
|
|
public string BuildHelp(IShrinkCommandSource? source, string? prefix = null)
|
|
{
|
|
var entries = Commands
|
|
.Where(command => !command.Hidden)
|
|
.Where(command => source == null || CanRunSourceKind(command.SourceKind, source))
|
|
.Where(command =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command.Permission) || source == null)
|
|
return true;
|
|
|
|
return source.HasPermission(command.Permission);
|
|
})
|
|
.Where(command =>
|
|
string.IsNullOrWhiteSpace(prefix) ||
|
|
command.Path.StartsWith(prefix.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
|
command.Aliases.Any(alias => alias.StartsWith(prefix.Trim(), StringComparison.OrdinalIgnoreCase)))
|
|
.OrderBy(command => command.Path, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
if (entries.Length == 0)
|
|
return "没有找到可用命令。";
|
|
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine("可用命令:");
|
|
foreach (var command in entries)
|
|
{
|
|
builder.Append("- ");
|
|
builder.Append(command.Path);
|
|
if (!string.IsNullOrWhiteSpace(command.Description))
|
|
{
|
|
builder.Append(" : ");
|
|
builder.Append(command.Description);
|
|
}
|
|
|
|
if (command.Aliases.Count > 0)
|
|
{
|
|
builder.Append(" [别名: ");
|
|
builder.Append(string.Join(", ", command.Aliases));
|
|
builder.Append(']');
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(command.Permission))
|
|
{
|
|
builder.Append(" [权限: ");
|
|
builder.Append(command.Permission);
|
|
builder.Append(']');
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
return builder.ToString().TrimEnd();
|
|
}
|
|
|
|
internal object? ConvertArgument(Type targetType, string raw)
|
|
{
|
|
if (targetType == typeof(string))
|
|
return raw;
|
|
|
|
var underlyingType = Nullable.GetUnderlyingType(targetType);
|
|
if (underlyingType != null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
return null;
|
|
|
|
return ConvertArgument(underlyingType, raw);
|
|
}
|
|
|
|
if (targetType.IsEnum)
|
|
return Enum.Parse(targetType, raw, true);
|
|
|
|
if (_converters.TryGetValue(targetType, out var converter))
|
|
return converter(raw);
|
|
|
|
throw new InvalidOperationException($"Unsupported command argument type: {targetType.FullName}");
|
|
}
|
|
|
|
private void RegisterBuiltInCommands()
|
|
{
|
|
RegisterCommand(new ShrinkCommandRegistration
|
|
{
|
|
Path = "help",
|
|
Aliases = new[] { "?" },
|
|
Description = "显示所有可用命令。",
|
|
Handler = context => UniTask.FromResult(
|
|
ShrinkCommandExecutionResult.Success(BuildHelp(context.Source)))
|
|
});
|
|
|
|
RegisterCommand(new ShrinkCommandRegistration
|
|
{
|
|
Path = "help <path...>",
|
|
Aliases = new[] { "? <path...>" },
|
|
Description = "显示某个前缀下的可用命令。",
|
|
Hidden = true,
|
|
Handler = context =>
|
|
{
|
|
var prefix = context.GetArgument("path");
|
|
return UniTask.FromResult(
|
|
ShrinkCommandExecutionResult.Success(BuildHelp(context.Source, prefix)));
|
|
}
|
|
});
|
|
}
|
|
|
|
private ShrinkCommandExecutionResult CompleteEarly(
|
|
IShrinkCommandSource source,
|
|
string rawInput,
|
|
ShrinkCommandDescriptor? command,
|
|
IReadOnlyDictionary<string, string> arguments,
|
|
ShrinkCommandExecutionResult result)
|
|
{
|
|
OnCommandExecuted?.Invoke(new ShrinkCommandExecutedInfo
|
|
{
|
|
Service = this,
|
|
Source = source,
|
|
Command = command,
|
|
RawInput = rawInput,
|
|
Arguments = arguments,
|
|
Result = result
|
|
});
|
|
return result;
|
|
}
|
|
|
|
private static bool CanRunSourceKind(ShrinkCommandSourceKind sourceKind, IShrinkCommandSource source)
|
|
{
|
|
switch (sourceKind)
|
|
{
|
|
case ShrinkCommandSourceKind.ConsoleOnly:
|
|
return source.IsConsole;
|
|
case ShrinkCommandSourceKind.NonConsoleOnly:
|
|
return !source.IsConsole;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> BuildArgumentMap(
|
|
IReadOnlyList<string> argumentNames,
|
|
IReadOnlyList<string> values)
|
|
{
|
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
for (var index = 0; index < argumentNames.Count && index < values.Count; index++)
|
|
map[argumentNames[index]] = values[index];
|
|
return map;
|
|
}
|
|
|
|
private List<MatchCandidate> FindMatches(IReadOnlyList<string> tokens)
|
|
{
|
|
var matches = new List<MatchCandidate>();
|
|
foreach (var command in _commands)
|
|
{
|
|
foreach (var pattern in command.Patterns)
|
|
{
|
|
if (!TryMatchPattern(pattern, tokens, out var arguments, out var literalScore))
|
|
continue;
|
|
|
|
matches.Add(new MatchCandidate
|
|
{
|
|
Definition = command,
|
|
Pattern = pattern,
|
|
RawArgumentValues = arguments,
|
|
LiteralScore = literalScore
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
private MatchCandidate SelectBestMatch(IReadOnlyList<MatchCandidate> matches)
|
|
{
|
|
var selected = matches
|
|
.OrderByDescending(match => match.Pattern.Segments.Count)
|
|
.ThenByDescending(match => match.LiteralScore)
|
|
.ThenBy(match => match.Definition.Descriptor.Path, StringComparer.OrdinalIgnoreCase)
|
|
.First();
|
|
|
|
var ambiguous = matches
|
|
.Where(match => !ReferenceEquals(match, selected))
|
|
.FirstOrDefault(match =>
|
|
match.Pattern.Segments.Count == selected.Pattern.Segments.Count &&
|
|
match.LiteralScore == selected.LiteralScore);
|
|
if (ambiguous != null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"命令匹配不明确: {selected.Pattern.Text} / {ambiguous.Pattern.Text}");
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
private string BuildSuggestions(IReadOnlyList<string> tokens, IShrinkCommandSource source)
|
|
{
|
|
var entries = Commands
|
|
.Where(command => !command.Hidden)
|
|
.Where(command => CanRunSourceKind(command.SourceKind, source))
|
|
.Where(command =>
|
|
string.IsNullOrWhiteSpace(command.Permission) || source.HasPermission(command.Permission))
|
|
.Where(command => IsPrefixCandidate(command.Path, tokens) || command.Aliases.Any(alias => IsPrefixCandidate(alias, tokens)))
|
|
.OrderBy(command => command.Path, StringComparer.OrdinalIgnoreCase)
|
|
.Take(8)
|
|
.ToArray();
|
|
|
|
if (entries.Length == 0)
|
|
return string.Empty;
|
|
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine("你可能想输入:");
|
|
foreach (var entry in entries)
|
|
{
|
|
builder.Append("- ");
|
|
builder.Append(entry.Path);
|
|
if (!string.IsNullOrWhiteSpace(entry.Description))
|
|
{
|
|
builder.Append(" : ");
|
|
builder.Append(entry.Description);
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
return builder.ToString().TrimEnd();
|
|
}
|
|
|
|
private static bool IsPrefixCandidate(string path, IReadOnlyList<string> inputTokens)
|
|
{
|
|
if (inputTokens.Count == 0)
|
|
return true;
|
|
|
|
var pathTokens = path.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (pathTokens.Length < inputTokens.Count)
|
|
return false;
|
|
|
|
for (var index = 0; index < inputTokens.Count; index++)
|
|
{
|
|
var input = inputTokens[index];
|
|
var pathToken = pathTokens[index];
|
|
if (pathToken.StartsWith("<", StringComparison.Ordinal) && pathToken.EndsWith(">", StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (!pathToken.StartsWith(input, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool TryMatchPattern(
|
|
PathPattern pattern,
|
|
IReadOnlyList<string> tokens,
|
|
out IReadOnlyList<string> arguments,
|
|
out int literalScore)
|
|
{
|
|
var rawArguments = new List<string>();
|
|
literalScore = 0;
|
|
arguments = rawArguments;
|
|
|
|
var tokenIndex = 0;
|
|
for (var segmentIndex = 0; segmentIndex < pattern.Segments.Count; segmentIndex++)
|
|
{
|
|
var segment = pattern.Segments[segmentIndex];
|
|
if (segment.IsArgument)
|
|
{
|
|
if (segment.IsGreedy)
|
|
{
|
|
if (tokenIndex >= tokens.Count)
|
|
return false;
|
|
|
|
rawArguments.Add(string.Join(" ", tokens.Skip(tokenIndex)));
|
|
tokenIndex = tokens.Count;
|
|
continue;
|
|
}
|
|
|
|
if (tokenIndex >= tokens.Count)
|
|
return false;
|
|
|
|
rawArguments.Add(tokens[tokenIndex]);
|
|
tokenIndex++;
|
|
continue;
|
|
}
|
|
|
|
if (tokenIndex >= tokens.Count)
|
|
return false;
|
|
|
|
if (!string.Equals(segment.Token, tokens[tokenIndex], StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
|
|
literalScore++;
|
|
tokenIndex++;
|
|
}
|
|
|
|
return tokenIndex == tokens.Count;
|
|
}
|
|
|
|
private static void ValidateAliasShape(PathPattern primaryPattern, PathPattern aliasPattern)
|
|
{
|
|
if (primaryPattern.Segments.Count != aliasPattern.Segments.Count)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Alias path shape mismatch: {aliasPattern.Text} vs {primaryPattern.Text}");
|
|
}
|
|
|
|
for (var index = 0; index < primaryPattern.Segments.Count; index++)
|
|
{
|
|
var primary = primaryPattern.Segments[index];
|
|
var alias = aliasPattern.Segments[index];
|
|
if (primary.IsArgument != alias.IsArgument || primary.IsGreedy != alias.IsGreedy)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Alias path shape mismatch: {aliasPattern.Text} vs {primaryPattern.Text}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static PathPattern ParsePattern(string path)
|
|
{
|
|
var normalized = path?.Trim() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
throw new InvalidOperationException("Command path cannot be empty.");
|
|
|
|
var tokens = normalized.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
var segments = new List<PathSegment>(tokens.Length);
|
|
for (var index = 0; index < tokens.Length; index++)
|
|
{
|
|
var token = tokens[index].Trim();
|
|
if (token.Length >= 3 && token[0] == '<' && token[token.Length - 1] == '>')
|
|
{
|
|
var name = token.Substring(1, token.Length - 2).Trim();
|
|
var isGreedy = name.EndsWith("...", StringComparison.Ordinal);
|
|
if (isGreedy)
|
|
{
|
|
name = name.Substring(0, name.Length - 3).Trim();
|
|
if (index != tokens.Length - 1)
|
|
throw new InvalidOperationException($"Greedy argument must be the last segment: {path}");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
throw new InvalidOperationException($"Invalid argument segment: {token}");
|
|
|
|
segments.Add(new PathSegment
|
|
{
|
|
IsArgument = true,
|
|
IsGreedy = isGreedy,
|
|
Token = name
|
|
});
|
|
continue;
|
|
}
|
|
|
|
segments.Add(new PathSegment
|
|
{
|
|
IsArgument = false,
|
|
IsGreedy = false,
|
|
Token = token
|
|
});
|
|
}
|
|
|
|
return new PathPattern
|
|
{
|
|
Text = normalized,
|
|
Segments = segments
|
|
};
|
|
}
|
|
|
|
private static List<string> Tokenize(string input)
|
|
{
|
|
var tokens = new List<string>();
|
|
var builder = new StringBuilder();
|
|
var inQuote = false;
|
|
var quoteChar = '\0';
|
|
var escaping = false;
|
|
|
|
foreach (var character in input)
|
|
{
|
|
if (escaping)
|
|
{
|
|
builder.Append(character);
|
|
escaping = false;
|
|
continue;
|
|
}
|
|
|
|
if (character == '\\')
|
|
{
|
|
escaping = true;
|
|
continue;
|
|
}
|
|
|
|
if (inQuote)
|
|
{
|
|
if (character == quoteChar)
|
|
{
|
|
inQuote = false;
|
|
continue;
|
|
}
|
|
|
|
builder.Append(character);
|
|
continue;
|
|
}
|
|
|
|
if (character == '"' || character == '\'')
|
|
{
|
|
inQuote = true;
|
|
quoteChar = character;
|
|
continue;
|
|
}
|
|
|
|
if (char.IsWhiteSpace(character))
|
|
{
|
|
if (builder.Length <= 0)
|
|
continue;
|
|
|
|
tokens.Add(builder.ToString());
|
|
builder.Clear();
|
|
continue;
|
|
}
|
|
|
|
builder.Append(character);
|
|
}
|
|
|
|
if (escaping)
|
|
builder.Append('\\');
|
|
|
|
if (inQuote)
|
|
throw new InvalidOperationException("存在未闭合的引号。");
|
|
|
|
if (builder.Length > 0)
|
|
tokens.Add(builder.ToString());
|
|
|
|
return tokens;
|
|
}
|
|
|
|
private void RegisterDefaultConverters()
|
|
{
|
|
RegisterConverter<int>(raw => int.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture));
|
|
RegisterConverter<long>(raw => long.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture));
|
|
RegisterConverter<float>(raw => float.Parse(raw, NumberStyles.Float | NumberStyles.AllowThousands,
|
|
CultureInfo.InvariantCulture));
|
|
RegisterConverter<double>(raw => double.Parse(raw, NumberStyles.Float | NumberStyles.AllowThousands,
|
|
CultureInfo.InvariantCulture));
|
|
RegisterConverter<decimal>(raw => decimal.Parse(raw, NumberStyles.Number, CultureInfo.InvariantCulture));
|
|
RegisterConverter<Guid>(raw => Guid.Parse(raw));
|
|
RegisterConverter<DateTime>(raw => DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
|
|
RegisterConverter<TimeSpan>(raw => TimeSpan.Parse(raw, CultureInfo.InvariantCulture));
|
|
RegisterConverter<bool>(ParseBool);
|
|
}
|
|
|
|
private static bool ParseBool(string raw)
|
|
{
|
|
if (bool.TryParse(raw, out var value))
|
|
return value;
|
|
|
|
switch (raw.Trim().ToLowerInvariant())
|
|
{
|
|
case "1":
|
|
case "yes":
|
|
case "on":
|
|
return true;
|
|
case "0":
|
|
case "no":
|
|
case "off":
|
|
return false;
|
|
default:
|
|
throw new FormatException($"无法把 `{raw}` 解析为布尔值。");
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class ShrinkCommandMethodAdapter
|
|
{
|
|
private static readonly MethodInfo AwaitUniTaskGenericMethod =
|
|
typeof(ShrinkCommandMethodAdapter).GetMethod(nameof(AwaitUniTaskGeneric),
|
|
BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
private static readonly MethodInfo AwaitTaskGenericMethod =
|
|
typeof(ShrinkCommandMethodAdapter).GetMethod(nameof(AwaitTaskGeneric),
|
|
BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
|
|
public static Func<ShrinkCommandContext, UniTask<ShrinkCommandExecutionResult>> BuildHandler(
|
|
object? target,
|
|
MethodInfo method,
|
|
IReadOnlyList<string> argumentNames)
|
|
{
|
|
if (method == null)
|
|
throw new ArgumentNullException(nameof(method));
|
|
|
|
var parameterDescriptors = method.GetParameters();
|
|
var specialCount = 0;
|
|
foreach (var parameter in parameterDescriptors)
|
|
{
|
|
if (parameter.ParameterType == typeof(ShrinkCommandContext) ||
|
|
typeof(IShrinkCommandSource).IsAssignableFrom(parameter.ParameterType))
|
|
{
|
|
specialCount++;
|
|
}
|
|
}
|
|
|
|
if (parameterDescriptors.Length - specialCount != argumentNames.Count)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Command parameter count does not match path arguments: {method.DeclaringType?.FullName}.{method.Name}");
|
|
}
|
|
|
|
ValidateReturnType(method);
|
|
|
|
return async context =>
|
|
{
|
|
var invokeArguments = new object?[parameterDescriptors.Length];
|
|
var rawArgumentIndex = 0;
|
|
for (var index = 0; index < parameterDescriptors.Length; index++)
|
|
{
|
|
var parameter = parameterDescriptors[index];
|
|
if (parameter.ParameterType == typeof(ShrinkCommandContext))
|
|
{
|
|
invokeArguments[index] = context;
|
|
continue;
|
|
}
|
|
|
|
if (typeof(IShrinkCommandSource).IsAssignableFrom(parameter.ParameterType))
|
|
{
|
|
if (!parameter.ParameterType.IsInstanceOfType(context.Source))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Command source type mismatch. Required={parameter.ParameterType.FullName}, Actual={context.Source.GetType().FullName}");
|
|
}
|
|
|
|
invokeArguments[index] = context.Source;
|
|
continue;
|
|
}
|
|
|
|
var argumentName = argumentNames[rawArgumentIndex];
|
|
var rawValue = context.GetArgument(argumentName);
|
|
try
|
|
{
|
|
invokeArguments[index] = context.Service.ConvertArgument(parameter.ParameterType, rawValue);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"参数 `{argumentName}` 解析失败: {ex.Message}", ex);
|
|
}
|
|
|
|
rawArgumentIndex++;
|
|
}
|
|
|
|
object? returnValue;
|
|
try
|
|
{
|
|
returnValue = method.Invoke(target, invokeArguments);
|
|
}
|
|
catch (TargetInvocationException ex)
|
|
{
|
|
throw ex.InnerException ?? ex;
|
|
}
|
|
|
|
return await NormalizeReturnAsync(method.ReturnType, returnValue);
|
|
};
|
|
}
|
|
|
|
private static void ValidateReturnType(MethodInfo method)
|
|
{
|
|
var returnType = method.ReturnType;
|
|
if (returnType == typeof(void) ||
|
|
returnType == typeof(string) ||
|
|
returnType == typeof(ShrinkCommandExecutionResult) ||
|
|
returnType == typeof(UniTask) ||
|
|
returnType == typeof(Task))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (returnType.IsGenericType)
|
|
{
|
|
var genericTypeDefinition = returnType.GetGenericTypeDefinition();
|
|
if (genericTypeDefinition == typeof(UniTask<>) ||
|
|
genericTypeDefinition == typeof(Task<>))
|
|
{
|
|
var innerType = returnType.GetGenericArguments()[0];
|
|
if (innerType == typeof(string) || innerType == typeof(ShrinkCommandExecutionResult))
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"Unsupported command return type: {method.DeclaringType?.FullName}.{method.Name}");
|
|
}
|
|
|
|
private static async UniTask<ShrinkCommandExecutionResult> NormalizeReturnAsync(Type returnType, object? returnValue)
|
|
{
|
|
if (returnType == typeof(void))
|
|
return ShrinkCommandExecutionResult.Success();
|
|
|
|
if (returnType == typeof(string))
|
|
return WrapObject(returnValue);
|
|
|
|
if (returnType == typeof(ShrinkCommandExecutionResult))
|
|
return WrapObject(returnValue);
|
|
|
|
if (returnType == typeof(UniTask))
|
|
{
|
|
await (UniTask)(returnValue ?? throw new InvalidOperationException("Command returned null UniTask."));
|
|
return ShrinkCommandExecutionResult.Success();
|
|
}
|
|
|
|
if (returnType == typeof(Task))
|
|
{
|
|
await (Task)(returnValue ?? throw new InvalidOperationException("Command returned null Task."));
|
|
return ShrinkCommandExecutionResult.Success();
|
|
}
|
|
|
|
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(UniTask<>))
|
|
{
|
|
return await (UniTask<ShrinkCommandExecutionResult>)AwaitUniTaskGenericMethod
|
|
.MakeGenericMethod(returnType.GetGenericArguments()[0])
|
|
.Invoke(null, new[] { returnValue! })!;
|
|
}
|
|
|
|
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
|
|
{
|
|
return await (Task<ShrinkCommandExecutionResult>)AwaitTaskGenericMethod
|
|
.MakeGenericMethod(returnType.GetGenericArguments()[0])
|
|
.Invoke(null, new[] { returnValue! })!;
|
|
}
|
|
|
|
throw new InvalidOperationException($"Unsupported command return type: {returnType.FullName}");
|
|
}
|
|
|
|
private static async UniTask<ShrinkCommandExecutionResult> AwaitUniTaskGeneric<T>(UniTask<T> task)
|
|
{
|
|
var result = await task;
|
|
return WrapObject(result);
|
|
}
|
|
|
|
private static async Task<ShrinkCommandExecutionResult> AwaitTaskGeneric<T>(Task<T> task)
|
|
{
|
|
var result = await task;
|
|
return WrapObject(result);
|
|
}
|
|
|
|
private static ShrinkCommandExecutionResult WrapObject(object? value)
|
|
{
|
|
if (value == null)
|
|
return ShrinkCommandExecutionResult.Success();
|
|
|
|
if (value is ShrinkCommandExecutionResult commandResult)
|
|
return commandResult;
|
|
|
|
if (value is string text)
|
|
return ShrinkCommandExecutionResult.Success(text);
|
|
|
|
throw new InvalidOperationException(
|
|
$"Unsupported command result payload type: {value.GetType().FullName}");
|
|
}
|
|
}
|
|
}
|