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