Skip to content

Commit a0f5ed0

Browse files
Wi1l-B0tshargonNGDAdmin
authored
Add: input cli command line with --argument-name argument-value (#4047)
* Add: input cli command line with --argument-name argument-value * update help output --------- Co-authored-by: Shargon <[email protected]> Co-authored-by: NGD Admin <[email protected]>
1 parent c330319 commit a0f5ed0

File tree

4 files changed

+216
-30
lines changed

4 files changed

+216
-30
lines changed

src/Neo.ConsoleService/CommandToken.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ public readonly struct CommandToken(int offset, string value, char quoteChar)
2626
/// </summary>
2727
public readonly string Value { get; } = value;
2828

29+
/// <summary>
30+
/// Whether the token is an indicator. Like --key key.
31+
/// </summary>
32+
public readonly bool IsIndicator => _quoteChar == NoQuoteChar && Value.StartsWith("--");
33+
34+
/// <summary>
35+
/// The quote character of the token. It can be ', " or `.
36+
/// </summary>
2937
private readonly char _quoteChar = quoteChar;
3038

3139
/// <summary>

src/Neo.ConsoleService/ConsoleServiceBase.cs

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,83 @@ public abstract class ConsoleServiceBase
4444

4545
private readonly List<string> _commandHistory = new();
4646

47+
/// <summary>
48+
/// Parse sequential arguments.
49+
/// For example, if a method defined as `void Method(string arg1, int arg2, bool arg3)`,
50+
/// the arguments will be parsed as `"arg1" 2 true`.
51+
/// </summary>
52+
/// <param name="method">Method</param>
53+
/// <param name="args">Arguments</param>
54+
/// <returns>Arguments</returns>
55+
/// <exception cref="ArgumentException">Missing argument</exception>
56+
internal object?[] ParseSequentialArguments(MethodInfo method, IList<CommandToken> args)
57+
{
58+
var parameters = method.GetParameters();
59+
var arguments = new List<object?>();
60+
foreach (var parameter in parameters)
61+
{
62+
if (TryProcessValue(parameter.ParameterType, args, parameter == parameters.Last(), out var value))
63+
{
64+
arguments.Add(value);
65+
}
66+
else
67+
{
68+
if (!parameter.HasDefaultValue)
69+
throw new ArgumentException($"Missing value for parameter: {parameter.Name}");
70+
arguments.Add(parameter.DefaultValue);
71+
}
72+
}
73+
return arguments.ToArray();
74+
}
75+
76+
/// <summary>
77+
/// Parse indicator arguments.
78+
/// For example, if a method defined as `void Method(string arg1, int arg2, bool arg3)`,
79+
/// the arguments will be parsed as `Method --arg1 "arg1" --arg2 2 --arg3`.
80+
/// </summary>
81+
/// <param name="method">Method</param>
82+
/// <param name="args">Arguments</param>
83+
internal object?[] ParseIndicatorArguments(MethodInfo method, IList<CommandToken> args)
84+
{
85+
var parameters = method.GetParameters();
86+
if (parameters is null || parameters.Length == 0) return [];
87+
88+
var arguments = parameters.Select(p => p.HasDefaultValue ? p.DefaultValue : null).ToArray();
89+
var noValues = parameters.Where(p => !p.HasDefaultValue).Select(p => p.Name).ToHashSet();
90+
for (int i = 0; i < args.Count; i++)
91+
{
92+
var token = args[i];
93+
if (!token.IsIndicator) continue;
94+
95+
var paramName = token.Value.Substring(2); // Remove "--"
96+
var parameter = parameters.FirstOrDefault(p => string.Equals(p.Name, paramName));
97+
if (parameter == null) throw new ArgumentException($"Unknown parameter: {paramName}");
98+
99+
var paramIndex = Array.IndexOf(parameters, parameter);
100+
if (i + 1 < args.Count && args[i + 1].IsWhiteSpace) i += 1; // Skip the white space token
101+
if (i + 1 < args.Count && !args[i + 1].IsIndicator) // Check if next token is a value (not an indicator)
102+
{
103+
var valueToken = args[i + 1]; // Next token is the value for this parameter
104+
if (!TryProcessValue(parameter.ParameterType, [args[i + 1]], false, out var value))
105+
throw new ArgumentException($"Cannot parse value for parameter {paramName}: {valueToken.Value}");
106+
arguments[paramIndex] = value;
107+
noValues.Remove(paramName);
108+
i += 1; // Skip the value token in next iteration
109+
}
110+
else
111+
{
112+
if (parameter.ParameterType != typeof(bool)) // If parameter is not a bool and no value is provided
113+
throw new ArgumentException($"Missing value for parameter: {paramName}");
114+
arguments[paramIndex] = true;
115+
noValues.Remove(paramName);
116+
}
117+
}
118+
119+
if (noValues.Count > 0)
120+
throw new ArgumentException($"Missing value for parameters: {string.Join(',', noValues)}");
121+
return arguments;
122+
}
123+
47124
private bool OnCommand(string commandLine)
48125
{
49126
if (string.IsNullOrEmpty(commandLine)) return true;
@@ -58,26 +135,13 @@ private bool OnCommand(string commandLine)
58135
var consumed = command.IsThisCommand(tokens);
59136
if (consumed <= 0) continue;
60137

61-
var arguments = new List<object?>();
62138
var args = tokens.Skip(consumed).ToList().Trim();
63139
try
64140
{
65-
var parameters = command.Method.GetParameters();
66-
foreach (var arg in parameters)
67-
{
68-
// Parse argument
69-
if (TryProcessValue(arg.ParameterType, args, arg == parameters.Last(), out var value))
70-
{
71-
arguments.Add(value);
72-
}
73-
else
74-
{
75-
if (!arg.HasDefaultValue) throw new ArgumentException($"Missing argument: {arg.Name}");
76-
arguments.Add(arg.DefaultValue);
77-
}
78-
}
79-
80-
availableCommands.Add((command, arguments.ToArray()));
141+
if (args.Any(u => u.IsIndicator))
142+
availableCommands.Add((command, ParseIndicatorArguments(command.Method, args)));
143+
else
144+
availableCommands.Add((command, ParseSequentialArguments(command.Method, args)));
81145
}
82146
catch (Exception ex)
83147
{
@@ -163,7 +227,6 @@ protected void OnHelpCommand(string key = "")
163227
}
164228

165229
// Sort and show
166-
167230
withHelp.Sort((a, b) =>
168231
{
169232
var cate = string.Compare(a.HelpCategory, b.HelpCategory, StringComparison.Ordinal);
@@ -174,6 +237,9 @@ protected void OnHelpCommand(string key = "")
174237
return cate;
175238
});
176239

240+
var guide = (ParameterInfo parameterInfo) => parameterInfo.HasDefaultValue
241+
? $"[ --{parameterInfo.Name} {parameterInfo.DefaultValue?.ToString() ?? ""}]"
242+
: $"--{parameterInfo.Name}";
177243
if (string.IsNullOrEmpty(key) || key.Equals("help", StringComparison.InvariantCultureIgnoreCase))
178244
{
179245
string? last = null;
@@ -186,24 +252,19 @@ protected void OnHelpCommand(string key = "")
186252
}
187253

188254
Console.Write($"\t{command.Key}");
189-
Console.WriteLine(" " + string.Join(' ',
190-
command.Method.GetParameters()
191-
.Select(u => u.HasDefaultValue ? $"[{u.Name}={(u.DefaultValue == null ? "null" : u.DefaultValue.ToString())}]" : $"<{u.Name}>"))
192-
);
255+
Console.WriteLine(" " + string.Join(' ', command.Method.GetParameters().Select(guide)));
193256
}
194257
}
195258
else
196259
{
197260
// Show help for this specific command
198-
199261
string? last = null;
200262
string? lastKey = null;
201263
bool found = false;
202264

203265
foreach (var command in withHelp.Where(u => u.Key == key))
204266
{
205267
found = true;
206-
207268
if (last != command.HelpMessage)
208269
{
209270
Console.WriteLine($"{command.HelpMessage}");
@@ -217,10 +278,7 @@ protected void OnHelpCommand(string key = "")
217278
}
218279

219280
Console.Write($"\t{command.Key}");
220-
Console.WriteLine(" " + string.Join(' ',
221-
command.Method.GetParameters()
222-
.Select(u => u.HasDefaultValue ? $"[{u.Name}={u.DefaultValue?.ToString() ?? "null"}]" : $"<{u.Name}>"))
223-
);
281+
Console.WriteLine(" " + string.Join(' ', command.Method.GetParameters().Select(guide)));
224282
}
225283

226284
if (!found)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (C) 2015-2025 The Neo Project.
2+
//
3+
// UT_CommandServiceBase.cs file belongs to the neo project and is free
4+
// software distributed under the MIT software license, see the
5+
// accompanying file LICENSE in the main directory of the
6+
// repository or http://www.opensource.org/licenses/mit-license.php
7+
// for more details.
8+
//
9+
// Redistribution and use in source and binary forms with or without
10+
// modifications are permitted.
11+
12+
using Microsoft.VisualStudio.TestTools.UnitTesting;
13+
using System;
14+
using System.Collections.Generic;
15+
using System.Reflection;
16+
17+
namespace Neo.ConsoleService.Tests
18+
{
19+
[TestClass]
20+
public class UT_CommandServiceBase
21+
{
22+
private class TestConsoleService : ConsoleServiceBase
23+
{
24+
public override string ServiceName => "TestService";
25+
26+
// Test method with various parameter types
27+
[ConsoleCommand("test", Category = "Test Commands")]
28+
public void TestMethod(string strParam, uint intParam, bool boolParam, string optionalParam = "default") { }
29+
30+
// Test method with enum parameter
31+
[ConsoleCommand("testenum", Category = "Test Commands")]
32+
public void TestEnumMethod(TestEnum enumParam) { }
33+
34+
public enum TestEnum { Value1, Value2, Value3 }
35+
}
36+
37+
[TestMethod]
38+
public void TestParseIndicatorArguments()
39+
{
40+
var service = new TestConsoleService();
41+
var method = typeof(TestConsoleService).GetMethod("TestMethod");
42+
43+
// Test case 1: Basic indicator arguments
44+
var args1 = "test --strParam hello --intParam 42 --boolParam".Tokenize();
45+
Assert.AreEqual(11, args1.Count);
46+
Assert.AreEqual("test", args1[0].Value);
47+
Assert.AreEqual("--strParam", args1[2].Value);
48+
Assert.AreEqual("hello", args1[4].Value);
49+
Assert.AreEqual("--intParam", args1[6].Value);
50+
Assert.AreEqual("42", args1[8].Value);
51+
Assert.AreEqual("--boolParam", args1[10].Value);
52+
53+
var result1 = service.ParseIndicatorArguments(method, args1[1..]);
54+
Assert.AreEqual(4, result1.Length);
55+
Assert.AreEqual("hello", result1[0]);
56+
Assert.AreEqual(42u, result1[1]);
57+
Assert.AreEqual(true, result1[2]);
58+
Assert.AreEqual("default", result1[3]); // Default value
59+
60+
// Test case 2: Boolean parameter without value
61+
var args2 = "test --boolParam".Tokenize();
62+
Assert.ThrowsExactly<ArgumentException>(() => service.ParseIndicatorArguments(method, args2[1..]));
63+
64+
// Test case 3: Enum parameter
65+
var enumMethod = typeof(TestConsoleService).GetMethod("TestEnumMethod");
66+
var args3 = "testenum --enumParam Value2".Tokenize();
67+
var result3 = service.ParseIndicatorArguments(enumMethod, args3[1..]);
68+
Assert.AreEqual(1, result3.Length);
69+
Assert.AreEqual(TestConsoleService.TestEnum.Value2, result3[0]);
70+
71+
// Test case 4: Unknown parameter should throw exception
72+
var args4 = "test --unknownParam value".Tokenize();
73+
Assert.ThrowsExactly<ArgumentException>(() => service.ParseIndicatorArguments(method, args4[1..]));
74+
75+
// Test case 5: Missing value for non-boolean parameter should throw exception
76+
var args5 = "test --strParam".Tokenize();
77+
Assert.ThrowsExactly<ArgumentException>(() => service.ParseIndicatorArguments(method, args5[1..]));
78+
}
79+
80+
[TestMethod]
81+
public void TestParseSequentialArguments()
82+
{
83+
var service = new TestConsoleService();
84+
var method = typeof(TestConsoleService).GetMethod("TestMethod");
85+
86+
// Test case 1: All parameters provided
87+
var args1 = "test hello 42 true custom".Tokenize();
88+
var result1 = service.ParseSequentialArguments(method, args1[1..]);
89+
Assert.AreEqual(4, result1.Length);
90+
Assert.AreEqual("hello", result1[0]);
91+
Assert.AreEqual(42u, result1[1]);
92+
Assert.AreEqual(true, result1[2]);
93+
Assert.AreEqual("custom", result1[3]);
94+
95+
// Test case 2: Some parameters with default values
96+
var args2 = "test hello 42 true".Tokenize();
97+
var result2 = service.ParseSequentialArguments(method, args2[1..]);
98+
Assert.AreEqual(4, result2.Length);
99+
Assert.AreEqual("hello", result2[0]);
100+
Assert.AreEqual(42u, result2[1]);
101+
Assert.AreEqual(true, result2[2]);
102+
Assert.AreEqual("default", result2[3]); // optionalParam default value
103+
104+
// Test case 3: Enum parameter
105+
var enumMethod = typeof(TestConsoleService).GetMethod("TestEnumMethod");
106+
var args3 = "testenum Value1".Tokenize();
107+
var result3 = service.ParseSequentialArguments(enumMethod, args3[1..].Trim());
108+
Assert.AreEqual(1, result3.Length);
109+
Assert.AreEqual(TestConsoleService.TestEnum.Value1, result3[0]);
110+
111+
// Test case 4: Missing required parameter should throw exception
112+
var args4 = "test hello".Tokenize();
113+
Assert.ThrowsExactly<ArgumentException>(() => service.ParseSequentialArguments(method, args4[1..].Trim()));
114+
115+
// Test case 5: Empty arguments should use all default values
116+
var args5 = new List<CommandToken>();
117+
Assert.ThrowsExactly<ArgumentException>(() => service.ParseSequentialArguments(method, args5.Trim()));
118+
}
119+
}
120+
}

tests/Neo.ConsoleService.Tests/CommandTokenizerTest.cs renamed to tests/Neo.ConsoleService.Tests/UT_CommandTokenizer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (C) 2015-2025 The Neo Project.
22
//
3-
// CommandTokenizerTest.cs file belongs to the neo project and is free
3+
// UT_CommandTokenizer.cs file belongs to the neo project and is free
44
// software distributed under the MIT software license, see the
55
// accompanying file LICENSE in the main directory of the
66
// repository or http://www.opensource.org/licenses/mit-license.php
@@ -14,7 +14,7 @@
1414
namespace Neo.ConsoleService.Tests
1515
{
1616
[TestClass]
17-
public class CommandTokenizerTest
17+
public class UT_CommandTokenizer
1818
{
1919
[TestMethod]
2020
public void Test1()

0 commit comments

Comments
 (0)