#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 ArgumentNames { get; set; } = Array.Empty(); public IReadOnlyList Patterns { get; set; } = Array.Empty(); public Func> Handler { get; set; } = _ => UniTask.FromResult(ShrinkCommandExecutionResult.Success()); } private sealed class PathPattern { public string Text { get; set; } = string.Empty; public IReadOnlyList Segments { get; set; } = Array.Empty(); } 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 RawArgumentValues { get; set; } = Array.Empty(); public int LiteralScore { get; set; } } private readonly List _commands = new(); private readonly Dictionary> _converters = new(); public event Action? OnCommandExecuting; public event Action? OnCommandExecuted; public ShrinkCommandService() { RegisterDefaultConverters(); RegisterBuiltInCommands(); } public IReadOnlyList Commands => _commands .Select(command => command.Descriptor) .OrderBy(command => command.Path, StringComparer.OrdinalIgnoreCase) .ToArray(); public void RegisterConverter(Func 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 { primaryPattern }; foreach (var alias in registration.Aliases ?? Array.Empty()) { 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()) .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 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 tokens; try { tokens = Tokenize(input); } catch (Exception ex) { return CompleteEarly(source, input, null, new Dictionary(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(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 ", Aliases = new[] { "? " }, 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 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 BuildArgumentMap( IReadOnlyList argumentNames, IReadOnlyList values) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < argumentNames.Count && index < values.Count; index++) map[argumentNames[index]] = values[index]; return map; } private List FindMatches(IReadOnlyList tokens) { var matches = new List(); 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 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 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 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 tokens, out IReadOnlyList arguments, out int literalScore) { var rawArguments = new List(); 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(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 Tokenize(string input) { var tokens = new List(); 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(raw => int.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture)); RegisterConverter(raw => long.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture)); RegisterConverter(raw => float.Parse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); RegisterConverter(raw => double.Parse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture)); RegisterConverter(raw => decimal.Parse(raw, NumberStyles.Number, CultureInfo.InvariantCulture)); RegisterConverter(raw => Guid.Parse(raw)); RegisterConverter(raw => DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); RegisterConverter(raw => TimeSpan.Parse(raw, CultureInfo.InvariantCulture)); RegisterConverter(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> BuildHandler( object? target, MethodInfo method, IReadOnlyList 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 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)AwaitUniTaskGenericMethod .MakeGenericMethod(returnType.GetGenericArguments()[0]) .Invoke(null, new[] { returnValue! })!; } if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) { return await (Task)AwaitTaskGenericMethod .MakeGenericMethod(returnType.GetGenericArguments()[0]) .Invoke(null, new[] { returnValue! })!; } throw new InvalidOperationException($"Unsupported command return type: {returnType.FullName}"); } private static async UniTask AwaitUniTaskGeneric(UniTask task) { var result = await task; return WrapObject(result); } private static async Task AwaitTaskGeneric(Task 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}"); } } }