From efe3a22a06115a4405b2abc63d9a2d7240d9124d Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 02:49:36 +0800 Subject: [PATCH] chore: initialize standalone UPM package --- .gitea/workflows/publish.yml | 49 + .gitea/workflows/unity-verify.yml | 39 + .gitignore | 10 + .npmignore | 8 + CHANGELOG.md | 26 + CHANGELOG.md.meta | 7 + Development~/UnityProject/.gitignore | 6 + Development~/UnityProject/Assets/.gitkeep | 0 .../UnityProject/Packages/manifest.json | 15 + .../ProjectSettings/ProjectVersion.txt | 2 + README.md | 85 ++ README.md.meta | 7 + Runtime.meta | 8 + Runtime/Core.meta | 8 + Runtime/Core/ShrinkCommandLogger.cs | 53 ++ Runtime/Core/ShrinkCommandLogger.cs.meta | 11 + Runtime/Core/ShrinkCommandRuntime.cs | 22 + Runtime/Core/ShrinkCommandRuntime.cs.meta | 11 + Runtime/Core/ShrinkCommandService.cs | 884 ++++++++++++++++++ Runtime/Core/ShrinkCommandService.cs.meta | 11 + Runtime/Metadata.meta | 8 + Runtime/Metadata/ShrinkCommandAttributes.cs | 25 + .../Metadata/ShrinkCommandAttributes.cs.meta | 11 + Runtime/Metadata/ShrinkCommandConstants.cs | 7 + .../Metadata/ShrinkCommandConstants.cs.meta | 11 + Runtime/Metadata/ShrinkCommandTypes.cs | 144 +++ Runtime/Metadata/ShrinkCommandTypes.cs.meta | 11 + Runtime/Routing.meta | 8 + .../Routing/ShrinkCommandGeneratedRegistry.cs | 62 ++ .../ShrinkCommandGeneratedRegistry.cs.meta | 11 + Runtime/Routing/ShrinkCommandRegHelper.cs | 116 +++ .../Routing/ShrinkCommandRegHelper.cs.meta | 11 + Runtime/ShrinkCommand.Runtime.asmdef | 12 + Runtime/ShrinkCommand.Runtime.asmdef.meta | 7 + package.json | 23 + package.json.meta | 7 + 36 files changed, 1736 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitea/workflows/unity-verify.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 CHANGELOG.md create mode 100644 CHANGELOG.md.meta create mode 100644 Development~/UnityProject/.gitignore create mode 100644 Development~/UnityProject/Assets/.gitkeep create mode 100644 Development~/UnityProject/Packages/manifest.json create mode 100644 Development~/UnityProject/ProjectSettings/ProjectVersion.txt create mode 100644 README.md create mode 100644 README.md.meta create mode 100644 Runtime.meta create mode 100644 Runtime/Core.meta create mode 100644 Runtime/Core/ShrinkCommandLogger.cs create mode 100644 Runtime/Core/ShrinkCommandLogger.cs.meta create mode 100644 Runtime/Core/ShrinkCommandRuntime.cs create mode 100644 Runtime/Core/ShrinkCommandRuntime.cs.meta create mode 100644 Runtime/Core/ShrinkCommandService.cs create mode 100644 Runtime/Core/ShrinkCommandService.cs.meta create mode 100644 Runtime/Metadata.meta create mode 100644 Runtime/Metadata/ShrinkCommandAttributes.cs create mode 100644 Runtime/Metadata/ShrinkCommandAttributes.cs.meta create mode 100644 Runtime/Metadata/ShrinkCommandConstants.cs create mode 100644 Runtime/Metadata/ShrinkCommandConstants.cs.meta create mode 100644 Runtime/Metadata/ShrinkCommandTypes.cs create mode 100644 Runtime/Metadata/ShrinkCommandTypes.cs.meta create mode 100644 Runtime/Routing.meta create mode 100644 Runtime/Routing/ShrinkCommandGeneratedRegistry.cs create mode 100644 Runtime/Routing/ShrinkCommandGeneratedRegistry.cs.meta create mode 100644 Runtime/Routing/ShrinkCommandRegHelper.cs create mode 100644 Runtime/Routing/ShrinkCommandRegHelper.cs.meta create mode 100644 Runtime/ShrinkCommand.Runtime.asmdef create mode 100644 Runtime/ShrinkCommand.Runtime.asmdef.meta create mode 100644 package.json create mode 100644 package.json.meta diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..6ca77d9 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish UPM package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Fetch tagged revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + test -n "$ref" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkCommand.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Validate immutable release version + shell: bash + run: | + set -eu + tag="$(git describe --exact-match --tags HEAD)" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + npm pack --dry-run + + - name: Publish to ShrinkSDK registry + shell: bash + run: | + set -eu + : "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + npmrc="$HOME/.npmrc" + cleanup() { rm -f "$npmrc"; } + trap cleanup EXIT + printf '%s\n' \ + 'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \ + '//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc" + npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/ \ No newline at end of file diff --git a/.gitea/workflows/unity-verify.yml b/.gitea/workflows/unity-verify.yml new file mode 100644 index 0000000..9a22641 --- /dev/null +++ b/.gitea/workflows/unity-verify.yml @@ -0,0 +1,39 @@ +name: Verify standalone Unity package + +on: + workflow_dispatch: + +jobs: + editmode: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + steps: + - name: Fetch selected revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkCommand.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Run package EditMode tests + shell: bash + run: | + set -eu + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD/Development~/UnityProject" \ + -runTests \ + -testPlatform EditMode \ + -testResults "$PWD/TestResults/editmode.xml" \ + -logFile "$PWD/TestResults/unity.log" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246935d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/Development~/UnityProject/[Ll]ibrary/ +/Development~/UnityProject/[Tt]emp/ +/Development~/UnityProject/[Oo]bj/ +/Development~/UnityProject/[Ll]ogs/ +/Development~/UnityProject/[Uu]ser[Ss]ettings/ +/Development~/UnityProject/TestResults/ +/Tools~/**/[Bb]in/ +/Tools~/**/[Oo]bj/ +*.user +*.DotSettings.user diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..700f03d --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0130272 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +本文件记录 `ShrinkCommand` 模块在当前工作区中的变更。 + +## [0.2.0] - 2026-05-18 + +### Changed + +- 静态命令自动发现从运行时全域反射扫描切换为编译期注册表 `ShrinkCommandGeneratedRegistry`。 +- `ShrinkCommandRuntime.Default` 启动时优先消费编译期清单,降低默认服务的注册开销。 +- `RegisterCommands(object target)` 的实例注册路径保持不变,继续支持模组实例和运行时对象。 + +## [当前工作区版] - 2026-04-07 + +### Added + +- 正式抽成独立命令运行时,核心不再直接耦合 `ShrinkEventBus` 和 `ShrinkNetwork`。 +- 建立属性式注册模型:`[ShrinkCommandSubscriber]`、`[ShrinkCommand]`、`AutoRegisterAll()`。 +- 命令路径支持字面量段、普通参数段与贪婪尾参数段,补齐 `help` / `?` 内建命令。 +- 支持命令来源限制、权限检查、建议与常见基础类型转换,供 Unity 与纯 `.NET` 宿主复用。 + +### Changed + +- `ShrinkCommandService`、`ShrinkCommandTypes` 等共享运行时类型显式启用 `#nullable enable`。 +- 公共类型中的初始化属性由 `init` 收口为 `set`,避免 Unity 2022 编译链再次触发 `IsExternalInit` 缺失。 +- 宿主与运行时链路统一复用 `ShrinkCommandService`,控制台与网络入口共享同一套命令注册与执行逻辑。 diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..edb90ea --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a6b7cf1f4ef5d9749ad8ab464b2d4efd +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Development~/UnityProject/.gitignore b/Development~/UnityProject/.gitignore new file mode 100644 index 0000000..aa2eb00 --- /dev/null +++ b/Development~/UnityProject/.gitignore @@ -0,0 +1,6 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ \ No newline at end of file diff --git a/Development~/UnityProject/Assets/.gitkeep b/Development~/UnityProject/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Development~/UnityProject/Packages/manifest.json b/Development~/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..d1141b8 --- /dev/null +++ b/Development~/UnityProject/Packages/manifest.json @@ -0,0 +1,15 @@ +{ + "scopedRegistries": [ + { + "name": "ShrinkSDK", + "url": "https://git.crash.work/api/packages/ShrinkSDK/npm/", + "scopes": [ + "com.cneicy" + ] + } + ], + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.cneicy.shrink-command": "file:../../.." + } +} diff --git a/Development~/UnityProject/ProjectSettings/ProjectVersion.txt b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..587f809 --- /dev/null +++ b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ea678f --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# ShrinkCommand + +独立于 `ShrinkNetwork` 的命令系统,目标是提供类似 Minecraft/Brigadier 风格的命令路径、权限、帮助输出和属性式自动注册。 + +当前版本已把**静态命令发现**切到编译期注册表:运行时默认服务不再通过 `AppDomain.GetAssemblies() + GetTypes()` 全域扫描静态命令类型,而是直接消费编译期注入的清单。实例命令注册路径保持不变,仍适合模组实例和运行时对象。 + +## 特性 + +- 路径式命令定义:`whitelist add `、`say ` +- 支持字面量段和贪婪参数段 +- 内建 `help` / `?` +- 支持命令来源限制:控制台专用 / 非控制台专用 / 任意来源 +- 支持权限校验 +- 支持属性式自动注册,也支持手工注册 +- 可在 Unity 和纯 .NET 宿主中复用 + +## 快速开始 + +```csharp +using ShrinkCommand; + +[ShrinkCommandSubscriber] +public static class DemoCommands +{ + [ShrinkCommand("say ", Description = "广播一段文本")] + private static string Say(string message) + { + return "[say] " + message; + } +} + +var service = new ShrinkCommandService(); +service.AutoRegisterAll(); +``` + +手工执行: + +```csharp +var result = await service.ExecuteAsync(source, "say hello world"); +``` + +## 方法签名 + +支持的参数: + +- `ShrinkCommandContext` +- `IShrinkCommandSource` 或其派生类型 +- 路径中声明的普通参数,按顺序绑定到方法参数 + +支持的返回类型: + +- `void` +- `string` +- `ShrinkCommandExecutionResult` +- `UniTask` +- `UniTask` +- `UniTask` +- `Task` +- `Task` +- `Task` + +## 内建命令 + +- `help` +- `help ` +- `?` +- `? ` + +## 自动注册说明 + +### 静态命令 + +默认运行时会直接读取编译期生成的 `ShrinkCommandGeneratedRegistry`,注册所有静态 `[ShrinkCommandSubscriber]` + `[ShrinkCommand]`。 + +### 实例命令 + +```csharp +service.RegisterCommands(myCommandInstance); +``` + +实例注册仍然会扫描对象本身的方法签名,不依赖编译期清单,适合: + +- 运行时创建的对象 +- 模组实例 +- 需要带状态的命令宿主 diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..91e2a9e --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5d2771608e5865b47b8223f2384c112f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..8ae2899 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9c0ba4b9b3aa21a429e63ee94f25af83 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core.meta b/Runtime/Core.meta new file mode 100644 index 0000000..72ab8c7 --- /dev/null +++ b/Runtime/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: afe6c0228c3894b44a1bd06d8fe7b7e9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkCommandLogger.cs b/Runtime/Core/ShrinkCommandLogger.cs new file mode 100644 index 0000000..d1350b1 --- /dev/null +++ b/Runtime/Core/ShrinkCommandLogger.cs @@ -0,0 +1,53 @@ +using System; + +namespace ShrinkCommand +{ + public static class ShrinkCommandLogger + { + public static Action InfoHandler { get; set; } = DefaultInfo; + public static Action WarningHandler { get; set; } = DefaultWarning; + public static Action ErrorHandler { get; set; } = DefaultError; + public static Action ExceptionHandler { get; set; } = DefaultException; + + public static void Info(string message) => InfoHandler?.Invoke(message); + public static void Warn(string message) => WarningHandler?.Invoke(message); + public static void Error(string message) => ErrorHandler?.Invoke(message); + public static void Exception(Exception ex) => ExceptionHandler?.Invoke(ex); + + private static void DefaultInfo(string message) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.Log(message); +#else + Console.WriteLine(message); +#endif + } + + private static void DefaultWarning(string message) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.LogWarning(message); +#else + Console.WriteLine("[Warn] " + message); +#endif + } + + private static void DefaultError(string message) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.LogError(message); +#else + Console.Error.WriteLine(message); +#endif + } + + private static void DefaultException(Exception ex) + { +#if UNITY_5_3_OR_NEWER + UnityEngine.Debug.LogException(ex); +#else + Console.Error.WriteLine(ex); +#endif + } + } +} diff --git a/Runtime/Core/ShrinkCommandLogger.cs.meta b/Runtime/Core/ShrinkCommandLogger.cs.meta new file mode 100644 index 0000000..ba32a18 --- /dev/null +++ b/Runtime/Core/ShrinkCommandLogger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 78ac47ab23518d94e896f195b5fd56f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkCommandRuntime.cs b/Runtime/Core/ShrinkCommandRuntime.cs new file mode 100644 index 0000000..220ac58 --- /dev/null +++ b/Runtime/Core/ShrinkCommandRuntime.cs @@ -0,0 +1,22 @@ +namespace ShrinkCommand +{ + public static class ShrinkCommandRuntime + { + public static ShrinkCommandService Default { get; private set; } = BuildDefault(); + +#if UNITY_5_3_OR_NEWER + [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetOnPlayModeEnter() + { + Default = BuildDefault(); + } +#endif + + private static ShrinkCommandService BuildDefault() + { + var service = new ShrinkCommandService(); + ShrinkCommandGeneratedRegistry.RegisterAll(service); + return service; + } + } +} diff --git a/Runtime/Core/ShrinkCommandRuntime.cs.meta b/Runtime/Core/ShrinkCommandRuntime.cs.meta new file mode 100644 index 0000000..9096723 --- /dev/null +++ b/Runtime/Core/ShrinkCommandRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f95938eff193024b85822d1815b4953 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Core/ShrinkCommandService.cs b/Runtime/Core/ShrinkCommandService.cs new file mode 100644 index 0000000..7710460 --- /dev/null +++ b/Runtime/Core/ShrinkCommandService.cs @@ -0,0 +1,884 @@ +#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}"); + } + } +} diff --git a/Runtime/Core/ShrinkCommandService.cs.meta b/Runtime/Core/ShrinkCommandService.cs.meta new file mode 100644 index 0000000..3ab6120 --- /dev/null +++ b/Runtime/Core/ShrinkCommandService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5b65f098031f3943890f8eb7cb8e4d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata.meta b/Runtime/Metadata.meta new file mode 100644 index 0000000..143751d --- /dev/null +++ b/Runtime/Metadata.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7d9c2b8d57862d2408d63b64fee53ff2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkCommandAttributes.cs b/Runtime/Metadata/ShrinkCommandAttributes.cs new file mode 100644 index 0000000..24b5c9e --- /dev/null +++ b/Runtime/Metadata/ShrinkCommandAttributes.cs @@ -0,0 +1,25 @@ +using System; + +namespace ShrinkCommand +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class ShrinkCommandSubscriberAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)] + public sealed class ShrinkCommandAttribute : Attribute + { + public ShrinkCommandAttribute(string path) + { + Path = string.IsNullOrWhiteSpace(path) ? string.Empty : path.Trim(); + } + + public string Path { get; } + public string Description { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + public ShrinkCommandSourceKind SourceKind { get; set; } = ShrinkCommandSourceKind.Any; + public bool Hidden { get; set; } + public string[] Aliases { get; set; } = Array.Empty(); + } +} diff --git a/Runtime/Metadata/ShrinkCommandAttributes.cs.meta b/Runtime/Metadata/ShrinkCommandAttributes.cs.meta new file mode 100644 index 0000000..aa11551 --- /dev/null +++ b/Runtime/Metadata/ShrinkCommandAttributes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7bdd28a64d1899849b6cfce21ad2e91f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkCommandConstants.cs b/Runtime/Metadata/ShrinkCommandConstants.cs new file mode 100644 index 0000000..ac7b658 --- /dev/null +++ b/Runtime/Metadata/ShrinkCommandConstants.cs @@ -0,0 +1,7 @@ +namespace ShrinkCommand +{ + public static class ShrinkCommandConstants + { + public const string DefaultServiceName = "default"; + } +} diff --git a/Runtime/Metadata/ShrinkCommandConstants.cs.meta b/Runtime/Metadata/ShrinkCommandConstants.cs.meta new file mode 100644 index 0000000..9d578ce --- /dev/null +++ b/Runtime/Metadata/ShrinkCommandConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 438cadef623271d4d82e99c08a4e3b68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Metadata/ShrinkCommandTypes.cs b/Runtime/Metadata/ShrinkCommandTypes.cs new file mode 100644 index 0000000..06066d0 --- /dev/null +++ b/Runtime/Metadata/ShrinkCommandTypes.cs @@ -0,0 +1,144 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Cysharp.Threading.Tasks; + +namespace ShrinkCommand +{ + public enum ShrinkCommandSourceKind + { + Any = 0, + ConsoleOnly = 1, + NonConsoleOnly = 2 + } + + public interface IShrinkCommandSource + { + string SourceId { get; } + string DisplayName { get; } + bool IsConsole { get; } + + bool HasPermission(string permission); + + UniTask WriteLineAsync(string message, CancellationToken cancellationToken = default); + } + + public sealed class ShrinkCommandExecutionResult + { + public bool IsSuccess { get; private set; } + public string Message { get; private set; } = string.Empty; + + public static ShrinkCommandExecutionResult Success(string message = "") + { + return new ShrinkCommandExecutionResult + { + IsSuccess = true, + Message = message ?? string.Empty + }; + } + + public static ShrinkCommandExecutionResult Failure(string message) + { + return new ShrinkCommandExecutionResult + { + IsSuccess = false, + Message = message ?? string.Empty + }; + } + } + + public sealed class ShrinkCommandDescriptor + { + public string Path { get; set; } = string.Empty; + public IReadOnlyList Aliases { get; set; } = Array.Empty(); + public string Description { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + public ShrinkCommandSourceKind SourceKind { get; set; } + public bool Hidden { get; set; } + + public IReadOnlyList GetAllPaths() + { + if (Aliases == null || Aliases.Count == 0) + return new[] { Path }; + + return new[] { Path }.Concat(Aliases).ToArray(); + } + } + + public sealed class ShrinkCommandRegistration + { + public string Path { get; set; } = string.Empty; + public string[] Aliases { get; set; } = Array.Empty(); + public string Description { get; set; } = string.Empty; + public string Permission { get; set; } = string.Empty; + public ShrinkCommandSourceKind SourceKind { get; set; } = ShrinkCommandSourceKind.Any; + public bool Hidden { get; set; } + public Func> Handler { get; set; } = + _ => UniTask.FromResult(ShrinkCommandExecutionResult.Success()); + } + + public sealed class ShrinkCommandContext + { + private readonly IReadOnlyDictionary _arguments; + + internal ShrinkCommandContext( + ShrinkCommandService service, + IShrinkCommandSource source, + ShrinkCommandDescriptor command, + string rawInput, + IReadOnlyDictionary arguments, + CancellationToken cancellationToken) + { + Service = service; + Source = source; + Command = command; + RawInput = rawInput ?? string.Empty; + _arguments = arguments ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + CancellationToken = cancellationToken; + } + + public ShrinkCommandService Service { get; } + public IShrinkCommandSource Source { get; } + public ShrinkCommandDescriptor Command { get; } + public string RawInput { get; } + public CancellationToken CancellationToken { get; } + public IReadOnlyDictionary Arguments => _arguments; + + public string GetArgument(string name, string fallback = "") + { + if (string.IsNullOrWhiteSpace(name)) + return fallback; + + return _arguments.TryGetValue(name.Trim(), out var value) ? value : fallback; + } + + public UniTask ReplyAsync(string message) + { + return Source.WriteLineAsync(message ?? string.Empty, CancellationToken); + } + } + + public sealed class ShrinkCommandExecutingInfo + { + public ShrinkCommandService Service { get; set; } = null!; + public IShrinkCommandSource Source { get; set; } = null!; + public ShrinkCommandDescriptor Command { get; set; } = null!; + public string RawInput { get; set; } = string.Empty; + public IReadOnlyDictionary Arguments { get; set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public sealed class ShrinkCommandExecutedInfo + { + public ShrinkCommandService Service { get; set; } = null!; + public IShrinkCommandSource Source { get; set; } = null!; + public ShrinkCommandDescriptor? Command { get; set; } + public string RawInput { get; set; } = string.Empty; + public IReadOnlyDictionary Arguments { get; set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + public ShrinkCommandExecutionResult Result { get; set; } = ShrinkCommandExecutionResult.Success(); + } +} diff --git a/Runtime/Metadata/ShrinkCommandTypes.cs.meta b/Runtime/Metadata/ShrinkCommandTypes.cs.meta new file mode 100644 index 0000000..af6ef5d --- /dev/null +++ b/Runtime/Metadata/ShrinkCommandTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 895c916a18310be4fb635652a6c84e9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing.meta b/Runtime/Routing.meta new file mode 100644 index 0000000..d3a6985 --- /dev/null +++ b/Runtime/Routing.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3a6aafcf1653c874c839f81d45002298 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing/ShrinkCommandGeneratedRegistry.cs b/Runtime/Routing/ShrinkCommandGeneratedRegistry.cs new file mode 100644 index 0000000..6a776b7 --- /dev/null +++ b/Runtime/Routing/ShrinkCommandGeneratedRegistry.cs @@ -0,0 +1,62 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace ShrinkCommand +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class ShrinkCommandStaticRegistryAttribute : Attribute + { + public ShrinkCommandStaticRegistryAttribute(params Type[] subscriberTypes) + { + SubscriberTypes = subscriberTypes ?? Array.Empty(); + } + + public Type[] SubscriberTypes { get; } + } + + internal static class ShrinkCommandGeneratedRegistry + { + public static IReadOnlyList GetStaticSubscriberTypes() + { + var types = new List(); + var seen = new HashSet(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + object[] attributes; + try + { + attributes = assembly.GetCustomAttributes(typeof(ShrinkCommandStaticRegistryAttribute), false); + } + catch + { + continue; + } + + foreach (var attribute in attributes.OfType()) + { + foreach (var subscriberType in attribute.SubscriberTypes ?? Array.Empty()) + { + if (subscriberType == null || !seen.Add(subscriberType)) + continue; + + types.Add(subscriberType); + } + } + } + + return types; + } + + public static void RegisterAll(ShrinkCommandService service) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + + ShrinkCommandRegHelper.RegisterStaticCommandSubscriberTypes(service, GetStaticSubscriberTypes()); + } + } +} diff --git a/Runtime/Routing/ShrinkCommandGeneratedRegistry.cs.meta b/Runtime/Routing/ShrinkCommandGeneratedRegistry.cs.meta new file mode 100644 index 0000000..b5231f9 --- /dev/null +++ b/Runtime/Routing/ShrinkCommandGeneratedRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf0049e5e80e56c469f020266fbe0ace +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Routing/ShrinkCommandRegHelper.cs b/Runtime/Routing/ShrinkCommandRegHelper.cs new file mode 100644 index 0000000..99a5b52 --- /dev/null +++ b/Runtime/Routing/ShrinkCommandRegHelper.cs @@ -0,0 +1,116 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace ShrinkCommand +{ + public static class ShrinkCommandRegHelper + { + public static void RegisterStaticCommands(ShrinkCommandService service) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + + RegisterStaticCommandSubscriberTypes(service, ShrinkCommandGeneratedRegistry.GetStaticSubscriberTypes()); + } + + public static void RegisterCommands(ShrinkCommandService service, object? target) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + + RegisterCommandsInternal(service, target); + } + + public static void RegisterStaticCommandSubscriberTypes( + ShrinkCommandService service, + IEnumerable subscriberTypes) + { + if (service == null) + throw new ArgumentNullException(nameof(service)); + if (subscriberTypes == null) + throw new ArgumentNullException(nameof(subscriberTypes)); + + foreach (var type in subscriberTypes) + { + if (type == null || type.GetCustomAttribute(false) == null) + continue; + + ScanMethodsAndRegister(service, null, type, + type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)); + } + } + + private static void RegisterCommandsInternal(ShrinkCommandService service, object? target) + { + if (target == null) + return; + + var ownerType = target.GetType(); + if (ownerType.GetCustomAttribute(false) == null) + return; + + ScanMethodsAndRegister(service, target, ownerType, + ownerType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)); + } + + private static void ScanMethodsAndRegister( + ShrinkCommandService service, + object? target, + Type ownerType, + MethodInfo[] methods) + { + foreach (var method in methods) + { + if (target != null && method.IsStatic) + continue; + + var attributes = method.GetCustomAttributes(typeof(ShrinkCommandAttribute), false) + .Cast() + .ToArray(); + if (attributes.Length == 0) + continue; + + foreach (var attribute in attributes) + { + try + { + service.RegisterCommand(new ShrinkCommandRegistration + { + Path = attribute.Path, + Aliases = attribute.Aliases ?? Array.Empty(), + Description = attribute.Description, + Permission = attribute.Permission, + SourceKind = attribute.SourceKind, + Hidden = attribute.Hidden, + Handler = ShrinkCommandMethodAdapter.BuildHandler(target, method, + ParseArgumentNames(attribute.Path)) + }); + } + catch (Exception ex) + { + ShrinkCommandLogger.Warn( + $"[ShrinkCommand] Failed to register {ownerType.FullName}.{method.Name}: {ex.Message}"); + } + } + } + } + + private static string[] ParseArgumentNames(string path) + { + return path.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.Length >= 3 && token[0] == '<' && token[token.Length - 1] == '>') + .Select(token => + { + var name = token.Substring(1, token.Length - 2).Trim(); + if (name.EndsWith("...", StringComparison.Ordinal)) + name = name.Substring(0, name.Length - 3).Trim(); + return name; + }) + .ToArray(); + } + } +} diff --git a/Runtime/Routing/ShrinkCommandRegHelper.cs.meta b/Runtime/Routing/ShrinkCommandRegHelper.cs.meta new file mode 100644 index 0000000..40b200d --- /dev/null +++ b/Runtime/Routing/ShrinkCommandRegHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 14f2978c6779a9842bb62ac369b0cf53 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkCommand.Runtime.asmdef b/Runtime/ShrinkCommand.Runtime.asmdef new file mode 100644 index 0000000..588bb91 --- /dev/null +++ b/Runtime/ShrinkCommand.Runtime.asmdef @@ -0,0 +1,12 @@ +{ + "name": "ShrinkCommand.Runtime", + "rootNamespace": "ShrinkCommand", + "references": [ + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": true +} diff --git a/Runtime/ShrinkCommand.Runtime.asmdef.meta b/Runtime/ShrinkCommand.Runtime.asmdef.meta new file mode 100644 index 0000000..e99bd58 --- /dev/null +++ b/Runtime/ShrinkCommand.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 00b9caa355e76a348b031a7aafa927a4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..0bdb9c7 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "com.cneicy.shrink-command", + "version": "0.2.0", + "displayName": "ShrinkCommand", + "description": "独立于网络层的 Unity 命令系统,提供路径式命令、权限、帮助输出与属性式注册。", + "unity": "2022.3", + "dependencies": { + "com.cneicy.shrink-shared-codegen": "0.1.0", + "com.cysharp.unitask": "2.5.10" + }, + "keywords": [ + "command", + "console", + "debug", + "tooling" + ], + "author": { + "name": "cneicy", + "url": "https://git.crash.work/ShrinkSDK" + }, + "documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkCommand", + "changelogUrl": "https://git.crash.work/ShrinkSDK/ShrinkCommand/src/branch/main/CHANGELOG.md" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..e270863 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c8ebc8c4a7e8b584b940810fed797795 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: