Skip to content

Commit 07ffd69

Browse files
committed
Created Project kate.shared.CommandLine
1 parent cfd74bf commit 07ffd69

File tree

7 files changed

+433
-2
lines changed

7 files changed

+433
-2
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace kate.shared.CommandLine
6+
{
7+
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
8+
public class ActionParameterAttribute : Attribute
9+
{
10+
/// <summary>
11+
/// Short-hand argument name, like <c>h</c> for <c>help</c>
12+
/// </summary>
13+
public string ShortNameAlias { get; private set; }
14+
15+
/// <summary>
16+
/// Parameter name, must not start with <c>--</c>
17+
/// </summary>
18+
public string Name { get; private set; }
19+
20+
/// <summary>
21+
/// Help text to display for this parameter.
22+
/// </summary>
23+
public string HelpText { get; private set; }
24+
25+
/// <summary>
26+
/// Is this parameter required? (Default: <see langword="true"/>)
27+
/// </summary>
28+
public bool IsRequired { get; set; } = true;
29+
30+
public ActionParameterAttribute(string name, string helpText)
31+
: this(name, true, helpText)
32+
{ }
33+
34+
public ActionParameterAttribute(string name, bool required, string helpText)
35+
: base()
36+
{
37+
if (string.IsNullOrEmpty(name))
38+
{
39+
throw new ArgumentNullException(nameof(name), "Cannot be null or empty");
40+
}
41+
42+
Name = name;
43+
IsRequired = required;
44+
HelpText = string.IsNullOrEmpty(helpText) ? "" : helpText;
45+
}
46+
47+
public ActionParameterAttribute(Nullable<char> shortName, string name, string helpText)
48+
: this(shortName, name, true, helpText)
49+
{ }
50+
51+
public ActionParameterAttribute(Nullable<char> shortName, string name, bool required, string helpText)
52+
: base()
53+
{
54+
if (string.IsNullOrEmpty(name))
55+
{
56+
throw new ArgumentNullException(nameof(name), "Cannot be null or empty");
57+
}
58+
Name = name;
59+
ShortNameAlias = shortName == null || !shortName.HasValue
60+
? null
61+
: shortName.ToString();
62+
IsRequired = required;
63+
HelpText = string.IsNullOrEmpty(helpText) ? "" : helpText;
64+
}
65+
}
66+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
5+
namespace kate.shared.CommandLine
6+
{
7+
/// <summary>
8+
/// Required attribute about details for <see cref="IAction"/>
9+
/// </summary>
10+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
11+
public class CommandActionAttribute : Attribute
12+
{
13+
/// <summary>
14+
/// Name of the action to use when invoking via command-line.
15+
/// </summary>
16+
public string ActionName { get; set; }
17+
/// <summary>
18+
/// Type of the class where all the action-specific options are.
19+
/// </summary>
20+
public Type OptionsType { get; set; }
21+
22+
/// <summary>
23+
/// Display name for this action that will get shown to the user.
24+
/// </summary>
25+
public string DisplayName { get; set; }
26+
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="CommandActionAttribute"/> class.
29+
/// </summary>
30+
/// <param name="actionName">Name of the action to use when invoking via command-line.</param>
31+
/// <param name="optionsType">Type of the class where all the action-specific options are.</param>
32+
/// <param name="displayName">
33+
/// <para>Display name for this action that will get shown to the user.</para>
34+
///
35+
/// <para>Defaults to <paramref name="actionName"/></para>
36+
/// </param>
37+
public CommandActionAttribute(
38+
string actionName,
39+
Type optionsType = null,
40+
string displayName = null)
41+
{
42+
if (string.IsNullOrEmpty(actionName))
43+
{
44+
throw new ArgumentNullException(nameof(actionName), "Cannot be null or empty");
45+
}
46+
ActionName = actionName;
47+
DisplayName = string.IsNullOrEmpty(displayName) ? actionName : displayName;
48+
OptionsType = optionsType;
49+
}
50+
}
51+
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.CommandLine;
4+
using System.CommandLine.Invocation;
5+
using System.Linq;
6+
using System.Reflection;
7+
using System.Threading.Tasks;
8+
9+
using ParseResult = System.CommandLine.Parsing.ParseResult;
10+
11+
namespace kate.shared.CommandLine
12+
{
13+
/// <summary>
14+
/// Helper Methods for generating commands.
15+
/// </summary>
16+
public static class CommandLineHelper
17+
{
18+
/// <summary>
19+
/// Generate an instance of <see cref="Command"/>
20+
/// </summary>
21+
/// <typeparam name="TOptions">
22+
/// Type of the options to deserialize.</typeparam>
23+
/// <param name="commandName">
24+
/// Name of the command.</param>
25+
/// <param name="commandHelpText">
26+
/// Help text for the command</param>
27+
/// <param name="handler">
28+
/// Handler to be called when the generated command was ran.</param>
29+
/// <returns>Instance of <see cref="Command"/></returns>
30+
public static Command GenerateCommand<TOptions>(string commandName, string commandHelpText, Func<TOptions, Task> handler)
31+
where TOptions : class, new()
32+
{
33+
return GenerateCommand(typeof(TOptions), commandName, commandHelpText, (obj) =>
34+
{
35+
if (obj == null)
36+
{
37+
return handler(null);
38+
}
39+
if (!(obj is TOptions opts))
40+
{
41+
throw new ArgumentException($"Not an instance of {typeof(TOptions)}", nameof(obj));
42+
}
43+
return handler(opts);
44+
});
45+
}
46+
47+
/// <summary>
48+
/// Generate an instance of <see cref="Command"/>
49+
/// </summary>
50+
/// <typeparam name="TOptions">
51+
/// Type of the options to deserialize.</typeparam>
52+
/// <param name="commandName">
53+
/// Name of the command.</param>
54+
/// <param name="commandHelpText">
55+
/// Help text for the command</param>
56+
/// <param name="handler">
57+
/// Handler to be called when the generated command was ran.</param>
58+
/// <returns>Instance of <see cref="Command"/></returns>
59+
public static Command GenerateCommand<TOptions>(string commandName, string commandHelpText, Func<TOptions, InvocationContext, Task> handler)
60+
where TOptions : class, new()
61+
{
62+
return GenerateCommand(typeof(TOptions), commandName, commandHelpText, (obj, ctx) =>
63+
{
64+
if (obj == null)
65+
{
66+
return handler(null, ctx);
67+
}
68+
else if (obj is TOptions opts)
69+
{
70+
return handler(opts, ctx);
71+
}
72+
throw new ArgumentException($"Not an instance of {typeof(TOptions)}", nameof(obj));
73+
});
74+
}
75+
76+
/// <summary>
77+
/// Generate an instance of <see cref="Command"/>
78+
/// </summary>
79+
/// <param name="optionsType">
80+
/// Type of the options to deserialize.</param>
81+
/// <param name="commandName">
82+
/// Name of the command.</param>
83+
/// <param name="commandHelpText">
84+
/// Help text for the command</param>
85+
/// <param name="handler">
86+
/// Handler to be called when the generated command was ran.</param>
87+
/// <returns>Instance of <see cref="Command"/></returns>
88+
public static Command GenerateCommand(
89+
Type optionsType,
90+
string commandName,
91+
string commandHelpText,
92+
Func<object, InvocationContext, Task> handler)
93+
{
94+
var argumentPropertyMap = new Dictionary<string, (object, ActionParameterAttribute)>();
95+
var optionsProps = optionsType.GetProperties(BindingFlags.Instance | BindingFlags.Public);
96+
foreach (var prop in optionsProps)
97+
{
98+
var actionParamAttr = prop.GetCustomAttribute<ActionParameterAttribute>();
99+
if (actionParamAttr != null)
100+
{
101+
var genericArgumentType = typeof(Option<>).MakeGenericType(prop.PropertyType);
102+
var name = actionParamAttr.Name;
103+
if (name.StartsWith("--") == false)
104+
name = $"--{name}";
105+
var argumentInstance = Activator.CreateInstance(genericArgumentType, new object[] { name, actionParamAttr.HelpText });
106+
if (string.IsNullOrEmpty(actionParamAttr.ShortNameAlias) == false)
107+
{
108+
var shortName = actionParamAttr.ShortNameAlias;
109+
if (shortName.StartsWith("-") == false)
110+
{
111+
shortName = "-" + shortName;
112+
}
113+
argumentInstance = Activator.CreateInstance(genericArgumentType, new object[] { new string[] { name, shortName }, actionParamAttr.HelpText });
114+
}
115+
typeof(Option<>).GetProperty(nameof(Option.IsRequired))?.SetValue(argumentInstance, actionParamAttr.IsRequired);
116+
argumentPropertyMap.Add(prop.Name, (argumentInstance, actionParamAttr));
117+
}
118+
}
119+
120+
var cmd = new Command(commandName, commandHelpText);
121+
foreach (var argValue in argumentPropertyMap.Values.Select(e => e.Item1))
122+
{
123+
var addArgumentMethod = typeof(Command).GetMethod("AddOption");
124+
if (addArgumentMethod != null)
125+
{
126+
_ = addArgumentMethod.Invoke(cmd, new object[] { argValue });
127+
}
128+
}
129+
cmd.SetHandler(async (ctx) =>
130+
{
131+
var options = Activator.CreateInstance(optionsType);
132+
foreach (var kvPair in argumentPropertyMap)
133+
{
134+
var key = kvPair.Key;
135+
var argValue = kvPair.Value.Item1;
136+
137+
var prop = optionsType.GetProperty(key);
138+
if (prop != null)
139+
{
140+
var getValueMethod = typeof(ParseResult)
141+
.GetMethods()
142+
.Where(v => v.Name =="GetValueForOption" && v.IsGenericMethod == true
143+
&& (v.GetParameters().FirstOrDefault()?.ParameterType.ToString().StartsWith("System.CommandLine.Option") ?? false)
144+
&& (v.GetParameters().FirstOrDefault()?.ParameterType.IsGenericType ?? false))
145+
.FirstOrDefault();
146+
if (getValueMethod == null)
147+
{
148+
var msg = string.Format("Failed to get method {0}.{1}.{2} on {3}",
149+
nameof(ctx),
150+
nameof(ctx.ParseResult),
151+
nameof(ctx.ParseResult.GetValueForOption),
152+
FormatTypeName(typeof(ParseResult)));
153+
throw new ApplicationException(msg);
154+
}
155+
var getValueMethodGeneric = getValueMethod.MakeGenericMethod(prop.PropertyType);
156+
if (getValueMethodGeneric == null)
157+
{
158+
var msg = string.Format("Could not make generic method for {0}.{1}.{2} where type is {3} on {4}",
159+
nameof(ctx),
160+
nameof(ctx.ParseResult),
161+
nameof(ctx.ParseResult.GetValueForOption),
162+
FormatTypeName(prop.PropertyType),
163+
FormatTypeName(typeof(ParseResult)));
164+
throw new ApplicationException(msg);
165+
}
166+
var argumentValue = getValueMethodGeneric.Invoke(ctx.ParseResult, new object[] { argValue });
167+
prop.SetValue(options, argumentValue);
168+
}
169+
}
170+
await handler(options, ctx);
171+
});
172+
return cmd;
173+
}
174+
175+
/// <summary>
176+
/// Generate an instance of <see cref="Command"/>
177+
/// </summary>
178+
/// <param name="optionsType">
179+
/// Type of the options to deserialize.</param>
180+
/// <param name="commandName">
181+
/// Name of the command.</param>
182+
/// <param name="commandHelpText">
183+
/// Help text for the command</param>
184+
/// <param name="handler">
185+
/// Handler to be called when the generated command was ran.</param>
186+
/// <returns>Instance of <see cref="Command"/></returns>
187+
public static Command GenerateCommand(Type optionsType, string commandName, string commandHelpText, Func<object, Task> handler)
188+
{
189+
return GenerateCommand(
190+
optionsType,
191+
commandName,
192+
commandHelpText,
193+
(a, b) => handler(a));
194+
}
195+
196+
/// <summary>
197+
/// Generate an instance of <see cref="Command"/> dynamically with the options type, and the type of the
198+
/// class that should be created and called when the command has been invoked.
199+
/// </summary>
200+
/// <param name="optionsType">
201+
/// Type of the options. Properties will only be included if they have <see cref="ActionParameterAttribute"/> on it.
202+
/// </param>
203+
/// <param name="actionType">
204+
/// Type of the class that implements <see cref="IAction"/>.
205+
/// This type must also have <see cref="CommandActionAttribute"/> on it.
206+
/// </param>
207+
/// <returns>Generated instance of <see cref="Command"/></returns>
208+
///
209+
/// <exception cref="ArgumentException">
210+
/// Thrown when <paramref name="actionType"/> doesn't implement <see cref="IAction"/>
211+
/// </exception>
212+
public static Command GenerateCommand(Type optionsType, Type actionType)
213+
{
214+
if (typeof(IAction).IsAssignableFrom(actionType) == false)
215+
{
216+
throw new ArgumentException($"Class must implement {nameof(IAction)}", nameof(actionType));
217+
}
218+
var cmdActionAttr = actionType.GetCustomAttribute<CommandActionAttribute>();
219+
if (cmdActionAttr == null)
220+
{
221+
var msg = string.Format("Attribute {0} does not exist on type {1}",
222+
FormatTypeName(typeof(CommandActionAttribute)),
223+
actionType.ToString());
224+
throw new ArgumentException(msg, nameof(actionType));
225+
}
226+
227+
return GenerateCommand(
228+
optionsType,
229+
cmdActionAttr.ActionName,
230+
cmdActionAttr.DisplayName,
231+
async (opts, ctx) =>
232+
{
233+
var actionInstance = (IAction)Activator.CreateInstance(actionType);
234+
await actionInstance.RunAsync(opts);
235+
});
236+
}
237+
238+
/// <summary><inheritdoc cref="GenerateCommand(Type, Type)" path="/summary"/></summary>
239+
/// <typeparam name="TOptions"><inheritdoc cref="GenerateCommand(Type, Type)" path="/param[@name='optionsType']"/></typeparam>
240+
/// <typeparam name="TAction"><inheritdoc cref="GenerateCommand(Type, Type)" path="/param[@name='actionType']"/></typeparam>
241+
/// <returns><inheritdoc cref="GenerateCommand(Type, Type)" path="/returns"/></returns>
242+
public static Command GenerateCommand<TOptions, TAction>()
243+
where TOptions : class
244+
where TAction : class, IAction
245+
{
246+
return GenerateCommand(typeof(TOptions), typeof(TAction));
247+
}
248+
249+
private static string FormatTypeName(Type type)
250+
{
251+
return type.Namespace + '.' + type.Name;
252+
}
253+
}
254+
}

kate.shared.CommandLine/IAction.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text;
4+
using System.Threading.Tasks;
5+
6+
namespace kate.shared.CommandLine
7+
{
8+
/// <summary>
9+
/// <para>Inherited by a class that can be used for a CLI action.</para>
10+
///
11+
/// Inherited class must have <see cref="CommandActionAttribute"/>
12+
/// </summary>
13+
public interface IAction
14+
{
15+
/// <summary>
16+
/// Run this action.
17+
/// </summary>
18+
/// <param name="options">
19+
/// Parsed command-line options. Will be deserialized into the type provided in <see cref="CommandActionAttribute.OptionsType"/>
20+
/// </param>
21+
Task RunAsync(object options);
22+
}
23+
}

0 commit comments

Comments
 (0)