Skip to content

Commit 87ede80

Browse files
committed
Custom types support for CLI options and arguments
For a CLI option or argument, any type with a public constructor or a static `Parse` method with a string parameter can be now be used. These types can be bound/parsed automatically even if they are wrapped with `Enumerable` or `Nullable` type (note that as of current version, custom class support when using AOT compilation is not stable but trimming works)
1 parent 1a94a15 commit 87ede80

File tree

47 files changed

+837
-67
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+837
-67
lines changed

docs/README.md

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,118 @@ The following types for properties is supported:
180180
The presence of the option token on the command line, with no argument following it, results in a value of `true`.
181181
* Enums - The values are bound by name, and the binding is case insensitive
182182
* Arrays and lists (any IEnumerable type)
183-
* FileSystemInfo, FileInfo, DirectoryInfo
184-
* Primitive CLR types:
183+
* Common CLR types:
185184

185+
* `string`, `bool`
186+
* `FileSystemInfo`, `FileInfo`, `DirectoryInfo`
186187
* `int`, `long`, `short`, `uint`, `ulong`, `ushort`
187188
* `double`, `float`, `decimal`
188189
* `byte`, `sbyte`
189-
* `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`
190+
* `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`
190191
* `Guid`
191192
* `Uri`, `IPAddress`, `IPEndPoint`
192193

194+
* Any type with a public constructor or a static `Parse` method with a string parameter - These types can be bound/parsed
195+
automatically even if they are wrapped with `Enumerable` or `Nullable` type (note that as of current version, custom class
196+
support when using AOT compilation is not stable but trimming works)
197+
```c#
198+
[CliCommand]
199+
public class ArgumentConverterCliCommand
200+
{
201+
[CliOption]
202+
public ClassWithConstructor Opt { get; set; }
203+
204+
[CliOption]
205+
public ClassWithConstructor[] OptArray { get; set; }
206+
207+
[CliOption]
208+
public CustomStruct? OptNullable { get; set; }
209+
210+
[CliOption]
211+
public IEnumerable<ClassWithConstructor> OptEnumerable { get; set; }
212+
213+
[CliOption]
214+
public List<ClassWithConstructor> OptList { get; set; }
215+
216+
[CliArgument]
217+
public IEnumerable<Sub.ClassWithParser> Arg { get; set; }
218+
219+
public void Run()
220+
{
221+
Console.WriteLine($@"Handler for '{GetType().FullName}' is run:");
222+
223+
foreach (var property in GetType().GetProperties())
224+
{
225+
var value = property.GetValue(this);
226+
if (value is IEnumerable enumerable)
227+
value = string.Join(", ",
228+
enumerable
229+
.Cast<object>()
230+
.Select(s => s.ToString())
231+
);
232+
233+
Console.WriteLine($@"Value for {property.Name} property is '{value}'");
234+
235+
}
236+
237+
Console.WriteLine();
238+
}
239+
}
240+
241+
public class ClassWithConstructor
242+
{
243+
private readonly string value;
244+
245+
public ClassWithConstructor(string value)
246+
{
247+
this.value = value;
248+
}
249+
250+
public override string ToString()
251+
{
252+
return value;
253+
}
254+
}
255+
256+
public struct CustomStruct
257+
{
258+
private readonly string value;
259+
260+
public CustomStruct(string value)
261+
{
262+
this.value = value;
263+
}
264+
265+
public override string ToString()
266+
{
267+
return value;
268+
}
269+
}
270+
271+
namespace Sub
272+
{
273+
public class ClassWithParser
274+
{
275+
private readonly string value;
276+
277+
private ClassWithParser(string value)
278+
{
279+
this.value = value;
280+
}
281+
282+
public override string ToString()
283+
{
284+
return value;
285+
}
286+
287+
public static ClassWithParser Parse(string value)
288+
{
289+
return new ClassWithParser(value);
290+
}
291+
}
292+
}
293+
```
294+
193295
## Help output
194296

195297
When you run the app via `TestApp.exe -?` or `dotnet run -- -?`, you see this usage help:

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<!-- https://learn.microsoft.com/en-us/dotnet/core/project-sdk/msbuild-props#generateassemblyinfo -->
5-
<VersionPrefix>1.4.0</VersionPrefix>
5+
<VersionPrefix>1.5.0</VersionPrefix>
66
<Product>DotMake Command-Line</Product>
77
<Company>DotMake</Company>
88
<!-- Copyright is also used for NuGet metadata -->

src/DotMake.CommandLine.SourceGeneration/CliArgumentInfo.cs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,53 @@ public class CliArgumentInfo : CliSymbolInfo, IEquatable<CliArgumentInfo>
2222
{
2323
{ nameof(CliArgumentAttribute.Hidden), "IsHidden"}
2424
};
25+
public static readonly HashSet<string> SupportedConverters = new HashSet<string>
26+
{
27+
"System.String",
28+
"System.Boolean",
29+
30+
"System.IO.FileSystemInfo",
31+
"System.IO.FileInfo",
32+
"System.IO.DirectoryInfo",
33+
34+
"System.Int32",
35+
"System.Int64",
36+
"System.Int16",
37+
"System.UInt32",
38+
"System.UInt64",
39+
"System.UInt16",
40+
41+
"System.Double",
42+
"System.Single",
43+
"System.Decimal",
44+
45+
"System.Byte",
46+
"System.SByte",
47+
48+
"System.DateTime",
49+
"System.DateTimeOffset",
50+
"System.DateOnly",
51+
"System.TimeOnly",
52+
"System.TimeSpan",
53+
54+
"System.Guid",
55+
56+
"System.Uri",
57+
"System.Net.IPAddress",
58+
"System.Net.IPEndPoint"
59+
};
60+
2561

2662
public CliArgumentInfo(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attributeData, SemanticModel semanticModel, CliCommandInfo parent)
2763
: base(symbol, syntaxNode, semanticModel)
2864
{
2965
Symbol = (IPropertySymbol)symbol;
3066
Parent = parent;
3167

68+
TypeNeedingConverter = FindTypeIfNeedsConverter(Symbol.Type);
69+
if (TypeNeedingConverter != null)
70+
Converter = FindConverter(TypeNeedingConverter);
71+
3272
Analyze();
3373

3474
if (HasProblem)
@@ -53,6 +93,10 @@ public CliArgumentInfo(GeneratorAttributeSyntaxContext attributeSyntaxContext)
5393

5494
public CliCommandInfo Parent { get; }
5595

96+
public ITypeSymbol TypeNeedingConverter { get; }
97+
98+
public IMethodSymbol Converter { get; }
99+
56100
private void Analyze()
57101
{
58102
if ((Symbol.DeclaredAccessibility != Accessibility.Public && Symbol.DeclaredAccessibility != Accessibility.Internal)
@@ -67,6 +111,9 @@ private void Analyze()
67111
if (Symbol.SetMethod == null
68112
|| (Symbol.SetMethod.DeclaredAccessibility != Accessibility.Public && Symbol.SetMethod.DeclaredAccessibility != Accessibility.Internal))
69113
AddDiagnostic(DiagnosticDescriptors.ErrorPropertyHasNotPublicSetter, DiagnosticName);
114+
115+
if (TypeNeedingConverter != null && Converter == null)
116+
AddDiagnostic(DiagnosticDescriptors.WarningPropertyTypeIsNotBindable, DiagnosticName, TypeNeedingConverter);
70117
}
71118
}
72119

@@ -78,7 +125,7 @@ public void AppendCSharpCreateString(CodeStringBuilder sb, string varName, strin
78125
: Symbol.Name.StripSuffixes(Suffixes).ToCase(Parent.Settings.NameCasingConvention);
79126

80127
sb.AppendLine($"// Argument for '{Symbol.Name}' property");
81-
using (sb.AppendBlockStart($"var {varName} = new {ArgumentClassNamespace}.{ArgumentClassName}<{Symbol.Type}>(\"{argumentName}\")", ";"))
128+
using (sb.AppendBlockStart($"var {varName} = new {ArgumentClassNamespace}.{ArgumentClassName}<{Symbol.Type.ToReferenceString()}>(\"{argumentName}\")", ";"))
82129
{
83130
foreach (var kvp in AttributeArguments)
84131
{
@@ -106,11 +153,68 @@ public void AppendCSharpCreateString(CodeStringBuilder sb, string varName, strin
106153
sb.AppendLine($"{ArgumentClassNamespace}.ArgumentExtensions.FromAmong({varName}, new[] {allowedValuesTypedConstant.ToCSharpString()});");
107154

108155
sb.AppendLine($"{varName}.SetDefaultValue({varDefaultValue});");
156+
157+
if (Converter != null)
158+
{
159+
if (Converter.Name == ".ctor")
160+
sb.AppendLine($"RegisterArgumentConverter(input => new {Converter.ContainingType.ToReferenceString()}(input));");
161+
else
162+
sb.AppendLine($"RegisterArgumentConverter(input => {Converter.ToReferenceString()}(input));");
163+
}
109164
}
110165

111166
public bool Equals(CliArgumentInfo other)
112167
{
113168
return base.Equals(other);
114169
}
170+
171+
public static ITypeSymbol FindTypeIfNeedsConverter(ITypeSymbol type)
172+
{
173+
// note we want System.String and not string so use MetadataName instead of ToDisplayString or ToReferenceString
174+
while (!SupportedConverters.Contains(type.ToCompareString()))
175+
{
176+
var itemType = type.GetTypeIfNullable();
177+
if (itemType != null)
178+
type = itemType;
179+
180+
itemType = type.GetElementTypeIfEnumerable();
181+
if (itemType != null)
182+
{
183+
type = itemType;
184+
continue;
185+
}
186+
187+
return type;
188+
}
189+
190+
return null;
191+
}
192+
193+
public static IMethodSymbol FindConverter(ITypeSymbol type)
194+
{
195+
// INamedTypeSymbol: Represents a type other than an array, a pointer, a type parameter.
196+
if (!(type is INamedTypeSymbol namedType))
197+
return null;
198+
199+
var method = namedType.InstanceConstructors.FirstOrDefault(c =>
200+
(c.DeclaredAccessibility == Accessibility.Public)
201+
&& c.Parameters.Length > 0
202+
&& c.Parameters[0].Type.SpecialType == SpecialType.System_String
203+
&& c.Parameters.Skip(1).All(p => p.IsOptional)
204+
);
205+
206+
if (method == null)
207+
method = (IMethodSymbol)namedType.GetMembers().FirstOrDefault(s =>
208+
s is IMethodSymbol m
209+
&& (m.DeclaredAccessibility == Accessibility.Public)
210+
&& m.IsStatic && m.Name == "Parse"
211+
&& m.Parameters.Length > 0
212+
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
213+
&& m.Parameters.Skip(1).All(p => p.IsOptional
214+
&& m.ReturnType.Equals(namedType, SymbolEqualityComparer.Default))
215+
);
216+
217+
return method;
218+
}
115219
}
116220
}

src/DotMake.CommandLine.SourceGeneration/CliCommandHandlerInfo.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ public CliCommandHandlerInfo(IMethodSymbol symbol, SyntaxNode syntaxNode, Semant
1919
if (symbol.IsAsync)
2020
{
2121
IsAsync = true;
22-
ReturnsVoid = (symbol.ReturnType.ToDisplayString() == TaskFullName);
22+
ReturnsVoid = (symbol.ReturnType.ToCompareString() == TaskFullName);
2323
ReturnsValue = (symbol.ReturnType is INamedTypeSymbol namedTypeSymbol)
2424
&& namedTypeSymbol.IsGenericType
25-
&& namedTypeSymbol.BaseType?.ToDisplayString() == TaskFullName
25+
&& namedTypeSymbol.BaseType?.ToCompareString() == TaskFullName
2626
&& (namedTypeSymbol.TypeArguments.FirstOrDefault().SpecialType == SpecialType.System_Int32);
2727
}
2828
else
@@ -33,7 +33,7 @@ public CliCommandHandlerInfo(IMethodSymbol symbol, SyntaxNode syntaxNode, Semant
3333

3434
HasNoParameter = (symbol.Parameters.Length == 0);
3535
HasInvocationContextParameter = (symbol.Parameters.Length == 1)
36-
&& (symbol.Parameters[0].Type.ToDisplayString() == InvocationContextFullName);
36+
&& (symbol.Parameters[0].Type.ToCompareString() == InvocationContextFullName);
3737

3838
HasCorrectSignature = (ReturnsVoid || ReturnsValue) && (HasNoParameter || HasInvocationContextParameter);
3939

src/DotMake.CommandLine.SourceGeneration/CliCommandInfo.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class CliCommandInfo : CliSymbolInfo, IEquatable<CliCommandInfo>
2222
{
2323
{ nameof(CliCommandAttribute.Hidden), "IsHidden"}
2424
};
25+
public readonly HashSet<string> UsedAliases = new HashSet<string>(StringComparer.Ordinal);
2526

2627
public CliCommandInfo(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attributeData, SemanticModel semanticModel, CliCommandInfo parent)
2728
: base(symbol, syntaxNode, semanticModel)
@@ -49,7 +50,7 @@ public CliCommandInfo(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attri
4950
? Settings.GetContainingTypeFullName(GeneratedClassSuffix)
5051
: (symbol.ContainingNamespace == null || symbol.ContainingNamespace.IsGlobalNamespace)
5152
? string.Empty
52-
: symbol.ContainingNamespace.ToDisplayString();
53+
: symbol.ContainingNamespace.ToReferenceString();
5354
GeneratedClassFullName = string.IsNullOrEmpty(GeneratedClassNamespace)
5455
? GeneratedClassName
5556
: GeneratedClassNamespace + "." + GeneratedClassName;
@@ -65,7 +66,7 @@ public CliCommandInfo(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attri
6566
{
6667
foreach (var memberAttributeData in member.GetAttributes())
6768
{
68-
var attributeFullName = memberAttributeData.AttributeClass?.ToDisplayString();
69+
var attributeFullName = memberAttributeData.AttributeClass?.ToCompareString();
6970

7071
if (attributeFullName == CliOptionInfo.AttributeFullName)
7172
childOptions.Add(new CliOptionInfo(member, null, memberAttributeData, SemanticModel, this));
@@ -93,7 +94,7 @@ public CliCommandInfo(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attri
9394
{
9495
foreach (var memberAttributeData in nestedType.GetAttributes())
9596
{
96-
if (memberAttributeData.AttributeClass?.ToDisplayString() == AttributeFullName)
97+
if (memberAttributeData.AttributeClass?.ToCompareString() == AttributeFullName)
9798
childCommands.Add(new CliCommandInfo(nestedType, null, memberAttributeData, SemanticModel, this));
9899
}
99100
}
@@ -195,8 +196,8 @@ public void AppendCSharpDefineString(CodeStringBuilder sb, bool addNamespaceBloc
195196
{
196197
var varCommand = (IsRoot ? RootCommandClassName : CommandClassName).ToCase(CliNameCasingConvention.CamelCase);
197198
var commandClass = $"{CommandClassNamespace}.{(IsRoot ? RootCommandClassName : CommandClassName)}";
198-
var definitionClass = Symbol.ToDisplayString();
199-
var parentDefinitionClass = IsRoot ? null : Settings.ParentSymbol.ToDisplayString();
199+
var definitionClass = Symbol.ToReferenceString();
200+
var parentDefinitionClass = IsRoot ? null : Settings.ParentSymbol.ToReferenceString();
200201
var parentDefinitionType = (parentDefinitionClass != null) ? $"typeof({parentDefinitionClass})" : "null";
201202

202203
using (sb.AppendBlockStart($"public {GeneratedClassName}()"))
@@ -374,12 +375,18 @@ public void AppendCSharpCreateString(CodeStringBuilder sb, string varName)
374375
}
375376
block.Dispose();
376377

378+
UsedAliases.Clear(); //Reset
377379
if (AttributeArguments.TryGetValue(AttributeAliasesProperty, out var aliasesTypedConstant)
378380
&& !aliasesTypedConstant.IsNull)
379381
{
380382
foreach (var aliasTypedConstant in aliasesTypedConstant.Values)
381383
{
382-
sb.AppendLine($"{varName}.AddAlias({aliasTypedConstant.ToCSharpString()});");
384+
var alias = aliasTypedConstant.Value?.ToString();
385+
if (!UsedAliases.Contains(alias))
386+
{
387+
sb.AppendLine($"{varName}.AddAlias(\"{alias}\");");
388+
UsedAliases.Add(alias);
389+
}
383390
}
384391
}
385392
}

src/DotMake.CommandLine.SourceGeneration/CliCommandSettings.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public string GetContainingTypeFullName(string classSuffix)
7070
.TakeWhile(s => s.IsParentContaining)
7171
.Reverse()
7272
.Select((s, i) =>
73-
((i == 0) ? s.ParentSymbol.ToDisplayString() : s.ParentSymbol.Name) + classSuffix);
73+
((i == 0) ? s.ParentSymbol.ToReferenceString() : s.ParentSymbol.Name) + classSuffix);
7474

7575
return string.Join(".", parentTree);
7676
}
@@ -129,14 +129,14 @@ internal void PopulateParentTree()
129129

130130
INamedTypeSymbol currentParentSymbol;
131131
var parentAttributeData = currentSymbol.ContainingType?.GetAttributes()
132-
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == CliCommandInfo.AttributeFullName);
132+
.FirstOrDefault(a => a.AttributeClass?.ToCompareString() == CliCommandInfo.AttributeFullName);
133133
if (parentAttributeData != null)
134134
currentParentSymbol = currentSymbol.ContainingType;
135135
else
136136
{
137137
currentParentSymbol = currentSettings.ParentSymbol;
138138
parentAttributeData = currentParentSymbol?.GetAttributes()
139-
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == CliCommandInfo.AttributeFullName);
139+
.FirstOrDefault(a => a.AttributeClass?.ToCompareString() == CliCommandInfo.AttributeFullName);
140140
}
141141

142142
if (parentAttributeData == null) //if currentParentSymbol does not have the attribute, parentSettings will be null.

0 commit comments

Comments
 (0)