Skip to content

Commit 5909dda

Browse files
authored
feat(cli): add support for command line tools (#2346)
* feat(cli): initialize command line support * implement parse and declaration * complete command parse and startup basic info
1 parent e61f0d6 commit 5909dda

File tree

12 files changed

+461
-10
lines changed

12 files changed

+461
-10
lines changed

PCL.Core/App/Basics.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,15 @@ public static class Basics
7373
/// </summary>
7474
public static string ExecutableNameWithoutExtension { get; } = Path.GetFileNameWithoutExtension(ExecutablePath);
7575

76+
/// <summary>
77+
/// 当前进程包括第一个参数(文件名)的完整命令行参数。
78+
/// </summary>
79+
public static string[] FullCommandLineArguments { get; } = Environment.GetCommandLineArgs();
80+
7681
/// <summary>
7782
/// 当前进程不包括第一个参数(文件名)的命令行参数。
7883
/// </summary>
79-
public static string[] CommandLineArguments { get; } = Environment.GetCommandLineArgs().Skip(1).ToArray();
84+
public static string[] CommandLineArguments { get; } = FullCommandLineArguments[1..];
8085

8186
/// <summary>
8287
/// 实时获取的当前目录。若要在可执行文件目录中存放文件等内容,请使用更准确的 <see cref="ExecutableDirectory"/> 而不是这个目录。
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace PCL.Core.App.Cli;
2+
3+
public enum ArgumentValueKind
4+
{
5+
Bool,
6+
Decimal,
7+
Text,
8+
}

PCL.Core/App/Cli/BoolArgument.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System.Runtime.CompilerServices;
2+
3+
namespace PCL.Core.App.Cli;
4+
5+
public class BoolArgument : CommandArgument<bool>
6+
{
7+
public override ArgumentValueKind ValueKind => ArgumentValueKind.Bool;
8+
9+
protected override bool ParseValueText()
10+
{
11+
var text = ValueText.ToLowerInvariant().Trim();
12+
return text is not ("0" or "false");
13+
}
14+
15+
public override bool TryCastValue<T>(out T value)
16+
{
17+
if (base.TryCastValue(out value)) return true;
18+
var type = typeof(T);
19+
if (type != typeof(sbyte) &&
20+
type != typeof(byte) &&
21+
type != typeof(short) &&
22+
type != typeof(ushort) &&
23+
type != typeof(int) &&
24+
type != typeof(uint) &&
25+
type != typeof(long) &&
26+
type != typeof(ulong) &&
27+
type != typeof(nint) &&
28+
type != typeof(nuint)) return false;
29+
// magic code
30+
var v = Value;
31+
Unsafe.As<T, byte>(ref value) = Unsafe.As<bool, byte>(ref v);
32+
return true;
33+
}
34+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using System;
2+
using System.Runtime.CompilerServices;
3+
4+
namespace PCL.Core.App.Cli;
5+
6+
/// <summary>
7+
/// 无泛型的命令行参数模型
8+
/// </summary>
9+
/// <seealso cref="CommandArgument{TValue}"/>
10+
public abstract class CommandArgument
11+
{
12+
/// <summary>
13+
/// 参数键
14+
/// </summary>
15+
public required string Key { get; init; }
16+
17+
/// <summary>
18+
/// 参数值文本
19+
/// </summary>
20+
public required string ValueText { get; init; }
21+
22+
/// <summary>
23+
/// 参数值类型
24+
/// </summary>
25+
public abstract ArgumentValueKind ValueKind { get; }
26+
27+
/// <summary>
28+
/// 尝试以指定类型获取参数值
29+
/// </summary>
30+
/// <param name="value">参数值,若尝试失败则为该类型默认值</param>
31+
/// <typeparam name="T">参数值的类型</typeparam>
32+
/// <returns>是否成功,若类型不匹配则失败</returns>
33+
public abstract bool TryCastValue<T>(out T? value);
34+
35+
public T? CastValue<T>()
36+
{
37+
var result = TryCastValue(out T? value);
38+
return result ? value : throw new InvalidCastException("Value type mismatch or cannot cast");
39+
}
40+
}
41+
42+
/// <summary>
43+
/// 命令行参数模型
44+
/// </summary>
45+
/// <typeparam name="TValue">参数值的类型</typeparam>
46+
public abstract class CommandArgument<TValue> : CommandArgument
47+
{
48+
/// <summary>
49+
/// 从参数值文本中解析参数类型
50+
/// </summary>
51+
/// <returns>对应类型的参数值</returns>
52+
protected abstract TValue ParseValueText();
53+
54+
private bool _isValueParsed = false;
55+
56+
/// <summary>
57+
/// 参数值
58+
/// </summary>
59+
public TValue Value
60+
{
61+
get
62+
{
63+
if (_isValueParsed) return field;
64+
_isValueParsed = true;
65+
return field = ParseValueText();
66+
}
67+
protected init
68+
{
69+
field = value;
70+
_isValueParsed = true;
71+
}
72+
} = default!;
73+
74+
public override bool TryCastValue<T>(out T value)
75+
{
76+
if (Value is T v)
77+
{
78+
value = v;
79+
return true;
80+
}
81+
value = default!;
82+
if (typeof(T) == typeof(string))
83+
{
84+
Unsafe.As<T, string>(ref value) = ValueText;
85+
return true;
86+
}
87+
return false;
88+
}
89+
}

PCL.Core/App/Cli/CommandLine.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace PCL.Core.App.Cli;
5+
6+
/// <summary>
7+
/// 命令行模型
8+
/// </summary>
9+
public class CommandLine
10+
{
11+
/// <summary>
12+
/// 命令文本
13+
/// </summary>
14+
public required string CommandText { get; init; }
15+
16+
/// <summary>
17+
/// 子命令
18+
/// </summary>
19+
public CommandLine? Subcommand { get; init; } = null;
20+
21+
/// <summary>
22+
/// 子命令文本
23+
/// </summary>
24+
public string? SubcommandText => Subcommand?.CommandText;
25+
26+
/// <summary>
27+
/// 参数字典
28+
/// </summary>
29+
public required IReadOnlyDictionary<string, CommandArgument> Arguments { get; init; }
30+
31+
/// <summary>
32+
/// 尝试获取参数值
33+
/// </summary>
34+
/// <param name="key">参数键</param>
35+
/// <param name="value">参数值,若获取失败则为对应类型默认值</param>
36+
/// <typeparam name="TValue">参数值的类型</typeparam>
37+
/// <returns>是否获取成功,若不存在该键或值类型不匹配则失败</returns>
38+
public bool TryGetArgumentValue<TValue>(string key, out TValue? value)
39+
{
40+
var result = Arguments.TryGetValue(key, out var arg);
41+
if (result && arg!.TryCastValue(out TValue? typedValue))
42+
{
43+
value = typedValue;
44+
return true;
45+
}
46+
value = default;
47+
return false;
48+
}
49+
50+
/// <summary>
51+
/// 尝试获取参数值
52+
/// </summary>
53+
/// <param name="key">参数键</param>
54+
/// <typeparam name="TValue">参数值的类型</typeparam>
55+
/// <returns>参数值</returns>
56+
/// <exception cref="InvalidCastException">不存在该键或值类型不匹配</exception>
57+
public TValue? GetArgumentValue<TValue>(string key)
58+
{
59+
var result = TryGetArgumentValue(key, out TValue? value);
60+
return result ? value : throw new InvalidCastException($"Key '{key}' not found or value type mismatch");
61+
}
62+
63+
/// <summary>
64+
/// 解析参数数组,第一个元素会被视为主命令
65+
/// </summary>
66+
/// <param name="args">参数数组</param>
67+
/// <param name="subcommands">各级子命令列表</param>
68+
/// <returns>命令行模型实例</returns>
69+
public static CommandLine Parse(ReadOnlySpan<string> args, IEnumerable<SubcommandDefinition>? subcommands = null)
70+
{
71+
subcommands ??= [];
72+
SubcommandDefinition root = (args[0], subcommands);
73+
return CommandLineParser.Parse(args, root);
74+
}
75+
}
76+
77+
file static class CommandLineParser
78+
{
79+
private static (CommandArgument, bool) _ParseArgument(string key, string possibleValueText)
80+
{
81+
if (possibleValueText.Length == 0 || possibleValueText.StartsWith("--"))
82+
return (new BoolArgument { Key = key, ValueText = string.Empty }, false);
83+
if (possibleValueText.ToLowerInvariant() is "true" or "false")
84+
return (new BoolArgument { Key = key, ValueText = possibleValueText }, true);
85+
if (decimal.TryParse(possibleValueText, out var d))
86+
return (new DecimalArgument { Key = key, ValueText = possibleValueText, Value = d }, true);
87+
return (new TextArgument { Key = key, ValueText = possibleValueText }, true);
88+
}
89+
90+
public static CommandLine Parse(ReadOnlySpan<string> args, SubcommandDefinition subcommands)
91+
{
92+
if (args.IsEmpty) throw new ArgumentException("The argument span must contain at least 1 element", nameof(args));
93+
var i = 1;
94+
var commandText = args[0];
95+
var argumentList = new Dictionary<string, CommandArgument>();
96+
CommandLine? subcommand = null;
97+
while (i < args.Length)
98+
{
99+
var currentText = args[i];
100+
if (subcommands.Contains(currentText))
101+
{
102+
subcommand = Parse(args[i..], subcommands.SubcommandMap[currentText]);
103+
break;
104+
}
105+
var (commandArgument, hasValueText) = _ParseArgument(currentText, (i == args.Length - 1) ? "" : args[i + 1]);
106+
argumentList[commandArgument.Key] = commandArgument;
107+
i += hasValueText ? 2 : 1;
108+
}
109+
return new CommandLine
110+
{
111+
CommandText = commandText,
112+
Arguments = argumentList.AsReadOnly(),
113+
Subcommand = subcommand
114+
};
115+
}
116+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System;
2+
using System.Runtime.CompilerServices;
3+
4+
namespace PCL.Core.App.Cli;
5+
6+
public class DecimalArgument : CommandArgument<decimal>
7+
{
8+
public override ArgumentValueKind ValueKind => ArgumentValueKind.Decimal;
9+
10+
protected override decimal ParseValueText() => decimal.Parse(ValueText);
11+
12+
public new decimal Value
13+
{
14+
get => base.Value;
15+
init => base.Value = value;
16+
}
17+
18+
public override bool TryCastValue<T>(out T value)
19+
{
20+
if (base.TryCastValue(out value)) return true;
21+
var type = typeof(T);
22+
try
23+
{
24+
if (type == typeof(int)) Unsafe.As<T, int>(ref value) = Convert.ToInt32(Value);
25+
else if (type == typeof(long)) Unsafe.As<T, long>(ref value) = Convert.ToInt64(Value);
26+
else if (type == typeof(double)) Unsafe.As<T, double>(ref value) = Convert.ToDouble(Value);
27+
else if (type == typeof(float)) Unsafe.As<T, float>(ref value) = Convert.ToSingle(Value);
28+
else if (type == typeof(short)) Unsafe.As<T, short>(ref value) = Convert.ToInt16(Value);
29+
else if (type == typeof(sbyte)) Unsafe.As<T, sbyte>(ref value) = Convert.ToSByte(Value);
30+
else if (type == typeof(ulong)) Unsafe.As<T, ulong>(ref value) = Convert.ToUInt64(Value);
31+
else if (type == typeof(uint)) Unsafe.As<T, uint>(ref value) = Convert.ToUInt32(Value);
32+
else if (type == typeof(ushort)) Unsafe.As<T, ushort>(ref value) = Convert.ToUInt16(Value);
33+
else if (type == typeof(byte)) Unsafe.As<T, byte>(ref value) = Convert.ToByte(Value);
34+
else if (type == typeof(nint)) Unsafe.As<T, nint>(ref value) = checked((nint)Convert.ToInt64(Value));
35+
else if (type == typeof(nuint)) Unsafe.As<T, nuint>(ref value) = checked((nuint)Convert.ToUInt64(Value));
36+
else return false;
37+
return true;
38+
}
39+
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
40+
{
41+
return false;
42+
}
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Collections.Generic;
2+
3+
namespace PCL.Core.App.Cli;
4+
5+
public class SubcommandDefinition
6+
{
7+
public required string CommandText { get; init; }
8+
9+
public required IEnumerable<SubcommandDefinition> Subcommands { private get; init; }
10+
11+
public IReadOnlyDictionary<string, SubcommandDefinition> SubcommandMap
12+
{
13+
get
14+
{
15+
if (field != null) return field;
16+
var map = new Dictionary<string, SubcommandDefinition>();
17+
foreach (var c in Subcommands) map[c.CommandText] = c;
18+
return field = map.AsReadOnly();
19+
}
20+
} = null!;
21+
22+
public bool Contains(string subcommandText)
23+
{
24+
return SubcommandMap.ContainsKey(subcommandText);
25+
}
26+
27+
public static implicit operator SubcommandDefinition((string commandText, IEnumerable<SubcommandDefinition> subcommands) tuple)
28+
{
29+
return new SubcommandDefinition
30+
{
31+
CommandText = tuple.commandText,
32+
Subcommands = tuple.subcommands
33+
};
34+
}
35+
36+
public static implicit operator SubcommandDefinition(string commandText)
37+
{
38+
return new SubcommandDefinition
39+
{
40+
CommandText = commandText,
41+
Subcommands = []
42+
};
43+
}
44+
}

PCL.Core/App/Cli/TextArgument.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace PCL.Core.App.Cli;
2+
3+
public class TextArgument : CommandArgument<string>
4+
{
5+
public override ArgumentValueKind ValueKind => ArgumentValueKind.Text;
6+
7+
protected override string ParseValueText() => ValueText;
8+
}

PCL.Core/App/Metadata.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ private static readonly (string Name, int Code) _CompilationBranchInfo =
5656
/// 代码提交版本 hash 的摘要 (取前 7 位), 若不存在 (非 CI 构建) 则为 <c>native</c>
5757
/// </summary>
5858
public string CommitDigest { get; } = _SecretCommitInfo?[..7] ?? "native";
59+
60+
public override string ToString()
61+
{
62+
return $"{BranchName} {BaseName} ({Code}, {CommitDigest})";
63+
}
5964
}
6065

6166
/// <summary>

0 commit comments

Comments
 (0)