This commit is contained in:
@@ -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/
|
||||||
@@ -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"
|
||||||
+10
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
Development~/
|
||||||
|
Tools~/
|
||||||
|
*.csproj
|
||||||
|
*.sln
|
||||||
|
*.user
|
||||||
|
*.DotSettings.user
|
||||||
@@ -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`,控制台与网络入口共享同一套命令注册与执行逻辑。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a6b7cf1f4ef5d9749ad8ab464b2d4efd
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Ll]ibrary/
|
||||||
|
[Tt]emp/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]ogs/
|
||||||
|
[Uu]ser[Ss]ettings/
|
||||||
|
TestResults/
|
||||||
@@ -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:../../.."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
m_EditorVersion: 2022.3.62f3
|
||||||
|
m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ShrinkCommand
|
||||||
|
|
||||||
|
独立于 `ShrinkNetwork` 的命令系统,目标是提供类似 Minecraft/Brigadier 风格的命令路径、权限、帮助输出和属性式自动注册。
|
||||||
|
|
||||||
|
当前版本已把**静态命令发现**切到编译期注册表:运行时默认服务不再通过 `AppDomain.GetAssemblies() + GetTypes()` 全域扫描静态命令类型,而是直接消费编译期注入的清单。实例命令注册路径保持不变,仍适合模组实例和运行时对象。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 路径式命令定义:`whitelist add <player>`、`say <message...>`
|
||||||
|
- 支持字面量段和贪婪参数段
|
||||||
|
- 内建 `help` / `?`
|
||||||
|
- 支持命令来源限制:控制台专用 / 非控制台专用 / 任意来源
|
||||||
|
- 支持权限校验
|
||||||
|
- 支持属性式自动注册,也支持手工注册
|
||||||
|
- 可在 Unity 和纯 .NET 宿主中复用
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ShrinkCommand;
|
||||||
|
|
||||||
|
[ShrinkCommandSubscriber]
|
||||||
|
public static class DemoCommands
|
||||||
|
{
|
||||||
|
[ShrinkCommand("say <message...>", 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<string>`
|
||||||
|
- `UniTask<ShrinkCommandExecutionResult>`
|
||||||
|
- `Task`
|
||||||
|
- `Task<string>`
|
||||||
|
- `Task<ShrinkCommandExecutionResult>`
|
||||||
|
|
||||||
|
## 内建命令
|
||||||
|
|
||||||
|
- `help`
|
||||||
|
- `help <path...>`
|
||||||
|
- `?`
|
||||||
|
- `? <path...>`
|
||||||
|
|
||||||
|
## 自动注册说明
|
||||||
|
|
||||||
|
### 静态命令
|
||||||
|
|
||||||
|
默认运行时会直接读取编译期生成的 `ShrinkCommandGeneratedRegistry`,注册所有静态 `[ShrinkCommandSubscriber]` + `[ShrinkCommand]`。
|
||||||
|
|
||||||
|
### 实例命令
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
service.RegisterCommands(myCommandInstance);
|
||||||
|
```
|
||||||
|
|
||||||
|
实例注册仍然会扫描对象本身的方法签名,不依赖编译期清单,适合:
|
||||||
|
|
||||||
|
- 运行时创建的对象
|
||||||
|
- 模组实例
|
||||||
|
- 需要带状态的命令宿主
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5d2771608e5865b47b8223f2384c112f
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9c0ba4b9b3aa21a429e63ee94f25af83
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: afe6c0228c3894b44a1bd06d8fe7b7e9
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ShrinkCommand
|
||||||
|
{
|
||||||
|
public static class ShrinkCommandLogger
|
||||||
|
{
|
||||||
|
public static Action<string> InfoHandler { get; set; } = DefaultInfo;
|
||||||
|
public static Action<string> WarningHandler { get; set; } = DefaultWarning;
|
||||||
|
public static Action<string> ErrorHandler { get; set; } = DefaultError;
|
||||||
|
public static Action<Exception> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 78ac47ab23518d94e896f195b5fd56f0
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4f95938eff193024b85822d1815b4953
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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<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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c5b65f098031f3943890f8eb7cb8e4d5
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7d9c2b8d57862d2408d63b64fee53ff2
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7bdd28a64d1899849b6cfce21ad2e91f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ShrinkCommand
|
||||||
|
{
|
||||||
|
public static class ShrinkCommandConstants
|
||||||
|
{
|
||||||
|
public const string DefaultServiceName = "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 438cadef623271d4d82e99c08a4e3b68
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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<string> Aliases { get; set; } = Array.Empty<string>();
|
||||||
|
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<string> 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<string>();
|
||||||
|
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<ShrinkCommandContext, UniTask<ShrinkCommandExecutionResult>> Handler { get; set; } =
|
||||||
|
_ => UniTask.FromResult(ShrinkCommandExecutionResult.Success());
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ShrinkCommandContext
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _arguments;
|
||||||
|
|
||||||
|
internal ShrinkCommandContext(
|
||||||
|
ShrinkCommandService service,
|
||||||
|
IShrinkCommandSource source,
|
||||||
|
ShrinkCommandDescriptor command,
|
||||||
|
string rawInput,
|
||||||
|
IReadOnlyDictionary<string, string> arguments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Service = service;
|
||||||
|
Source = source;
|
||||||
|
Command = command;
|
||||||
|
RawInput = rawInput ?? string.Empty;
|
||||||
|
_arguments = arguments ?? new Dictionary<string, string>(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<string, string> 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<string, string> Arguments { get; set; } =
|
||||||
|
new Dictionary<string, string>(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<string, string> Arguments { get; set; } =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public ShrinkCommandExecutionResult Result { get; set; } = ShrinkCommandExecutionResult.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 895c916a18310be4fb635652a6c84e9e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3a6aafcf1653c874c839f81d45002298
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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<Type>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type[] SubscriberTypes { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ShrinkCommandGeneratedRegistry
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<Type> GetStaticSubscriberTypes()
|
||||||
|
{
|
||||||
|
var types = new List<Type>();
|
||||||
|
var seen = new HashSet<Type>();
|
||||||
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
object[] attributes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
attributes = assembly.GetCustomAttributes(typeof(ShrinkCommandStaticRegistryAttribute), false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var attribute in attributes.OfType<ShrinkCommandStaticRegistryAttribute>())
|
||||||
|
{
|
||||||
|
foreach (var subscriberType in attribute.SubscriberTypes ?? Array.Empty<Type>())
|
||||||
|
{
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bf0049e5e80e56c469f020266fbe0ace
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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<Type> 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<ShrinkCommandSubscriberAttribute>(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<ShrinkCommandSubscriberAttribute>(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<ShrinkCommandAttribute>()
|
||||||
|
.ToArray();
|
||||||
|
if (attributes.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var attribute in attributes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
service.RegisterCommand(new ShrinkCommandRegistration
|
||||||
|
{
|
||||||
|
Path = attribute.Path,
|
||||||
|
Aliases = attribute.Aliases ?? Array.Empty<string>(),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 14f2978c6779a9842bb62ac369b0cf53
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "ShrinkCommand.Runtime",
|
||||||
|
"rootNamespace": "ShrinkCommand",
|
||||||
|
"references": [
|
||||||
|
"UniTask"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"autoReferenced": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 00b9caa355e76a348b031a7aafa927a4
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c8ebc8c4a7e8b584b940810fed797795
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user