Skip to content

Commit a64165f

Browse files
authored
Added arity checks to analyzer (#29)
* Added arity checks to analyzer * Added support to NamespaceQualifiedTypeName to support arrays * Added support to `TypeSymbolExtensions.IsCollection` * Added testing of the demo source (as a link) to ensure it will operate correctly * Added nullability enable to all expected files * Allows nullability annotations for any nullable types
1 parent 454395c commit a64165f

27 files changed

Lines changed: 439 additions & 79 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# UNC005 : Arity specified for property type is invalid.
2+
This diagnostic indicates that the arity specified in an attribute does not match the type
3+
of value for that property^1^. The default arity is usually enough but it is sometimes valid
4+
to limit the "or more" default to a max value. In particular with collections there may be
5+
a limit to the maximum number of values allowed so the arity specifies that. Not that the
6+
arity applies to the ***values*** of a property. That is:
7+
`--foo true` is ONLY allowed if the minimum arity is > 1, otherwise only the option itself
8+
is allowed (for example: `--foo`). Setting the arity to a maximum that is > 1 requires a
9+
collection type to bind the parsed values to. Setting a minimum > 0 makes it required to
10+
specify a value for the command. That is, with a minimum arity of 1 `--foo` is an error.
11+
12+
13+
---
14+
^1^ see the [System.CommandLine docs](https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax#argument-arity)
15+
for details.

docfx/CommandLine/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ Rule ID | Title |
1414
[UNC002](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC002.html) | Property attribute not allowed standalone. |
1515
[UNC003](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC003.html) | Property has incorrect type for attribute. |
1616
[UNC004](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC004.html) | Property type is nullable but marked as required. |
17+
[UNC005](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC004.html) | Arity specified for property type is invalid. |

docfx/IgnoredWords.dic

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
arity
12
nullable

src/DemoCommandLineSrcGen/TestOptions.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
#pragma warning disable IDE0130 // Namespace does not match folder structure
55

6-
using System.CommandLine;
76
using System.IO;
7+
using System.Linq;
88

99
using Ubiquity.NET.CommandLine;
1010
using Ubiquity.NET.CommandLine.GeneratorAttributes;
@@ -18,14 +18,14 @@ namespace TestNamespace
1818
[RootCommand( Description = "Root command for tests" )]
1919
internal partial class TestOptions
2020
{
21-
[Option( "-o", Description = "Test SomePath" )]
21+
[Option( "-o", Description = "Test SomePath", Required = true )]
2222
[FolderValidation( FolderValidation.CreateIfNotExist )]
2323
public required DirectoryInfo SomePath { get; init; }
2424

2525
[Option( "-v", Description = "Verbosity Level" )]
2626
public MsgLevel Verbosity { get; init; } = MsgLevel.Information;
2727

28-
[Option( "-b", Description = "Test Some existing Path" )]
28+
[Option( "-b", Description = "Test Some existing Path", Required = true )]
2929
[FolderValidation( FolderValidation.ExistingOnly )]
3030
public required DirectoryInfo SomeExistingPath { get; init; }
3131

@@ -35,9 +35,12 @@ internal partial class TestOptions
3535
// This should be ignored by generator
3636
public string? NotAnOption { get; set; }
3737

38-
[Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )]
38+
[Option( "-i", ArityMin = 0, Description = "include path" )]
39+
public required DirectoryInfo[] IncludePath { get; init; }
40+
41+
[Option( "-a", Hidden = true, Required = false, Description = "Test SomeOtherPath" )]
3942
[FileValidation( FileValidation.ExistingOnly )]
40-
public required FileInfo SomeOtherPath { get; init; }
43+
public required FileInfo? SomeOtherPath { get; init; }
4144

4245
public override string ToString( )
4346
{
@@ -48,6 +51,7 @@ public override string ToString( )
4851
Thing1 = {Thing1}
4952
NotAnOption = {NotAnOption ?? "<null>"}
5053
SomeOtherPath = '{SomeOtherPath?.FullName ?? "<null>"}'
54+
IncludePath = '{string.Join(";", IncludePath.Select(di=>di.FullName))}'
5155
""";
5256
}
5357
}

src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ public sealed class DiagnosticInfo
1919
/// <param name="descriptor">Descriptor for the diagnostic</param>
2020
/// <param name="location">Location in the source file that triggered this diagnostic</param>
2121
/// <param name="msgArgs">Args for the message</param>
22-
public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, params string[] msgArgs)
23-
: this(descriptor, location, (IEnumerable<string>)msgArgs)
22+
public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, params object[] msgArgs)
23+
: this(descriptor, location, (IEnumerable<object>)msgArgs)
2424
{
2525
}
2626

2727
/// <summary>Initializes a new instance of the <see cref="DiagnosticInfo"/> class.</summary>
2828
/// <param name="descriptor">Descriptor for the diagnostic</param>
2929
/// <param name="location">Location in the source file that triggered this diagnostic</param>
3030
/// <param name="msgArgs">Args for the message</param>
31-
public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, IEnumerable<string> msgArgs)
31+
public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, IEnumerable<object> msgArgs)
3232
#else
3333
/// <summary>Initializes a new instance of the <see cref="DiagnosticInfo"/> class.</summary>
3434
/// <param name="descriptor">Descriptor for the diagnostic</param>
@@ -43,7 +43,7 @@ public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, param
4343
}
4444

4545
/// <summary>Gets the parameters for this diagnostic</summary>
46-
public ImmutableArray<string> Params { get; }
46+
public ImmutableArray<object> Params { get; }
4747

4848
/// <summary>Gets the descriptor for this diagnostic</summary>
4949
public DiagnosticDescriptor Descriptor { get; }

src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedNameFormatter.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ public string Format( string format, NamespaceQualifiedName arg, IFormatProvider
111111
/// <exception cref="NotSupportedException"><paramref name="format"/> is not supported</exception>
112112
public string Format( string format, NamespaceQualifiedTypeName arg, IFormatProvider? formatProvider )
113113
{
114+
if(arg.IsArray)
115+
{
116+
string formattedElement = Format(format, arg.ElementType, formatProvider);
117+
return $"{formattedElement}[]";
118+
}
119+
114120
string formattedString = Format(format, (NamespaceQualifiedName)arg, formatProvider);
115121
return arg.NullableAnnotation == NullableAnnotation.Annotated
116122
? $"{formattedString}?"

src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedTypeName.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,25 @@ public NamespaceQualifiedTypeName(
4949
public NamespaceQualifiedTypeName( ITypeSymbol sym )
5050
: this( GetNullableNamespaceNames( sym ), GetNullableSimpleName( sym ), sym.NullableAnnotation )
5151
{
52+
if(sym is IArrayTypeSymbol arrayType)
53+
{
54+
IsArray = true;
55+
ElementType = arrayType.ElementType.GetNamespaceQualifiedName();
56+
}
5257
}
5358

5459
/// <summary>Gets a value indicating whether this type has nullability annotation (and a generator should use a language specific nullability form)</summary>
5560
public bool IsNullable => NullableAnnotation == NullableAnnotation.Annotated;
5661

5762
/// <summary>Gets the nullability annotation state for this type</summary>
58-
public NullableAnnotation NullableAnnotation { get; init; }
63+
public NullableAnnotation NullableAnnotation { get; }
64+
65+
/// <summary>Gets a value indicating whether this name is an array</summary>
66+
[MemberNotNullWhen( true, nameof( ElementType ) )]
67+
public bool IsArray { get; }
68+
69+
/// <summary>Gets the array element type (Only valid if <see cref="IsArray"/> is true)</summary>
70+
public NamespaceQualifiedTypeName? ElementType { get; }
5971

6072
/// <summary>Formats this instance according to the args</summary>
6173
/// <param name="format">Format string for this instance (see remarks)</param>
@@ -132,15 +144,27 @@ public override int GetHashCode( )
132144
#endregion
133145

134146
// IFF sym is a Nullable<T> (Nullable value type) this will get the simple name of T
147+
[SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditionals != simpler" )]
135148
private static string GetNullableSimpleName( ITypeSymbol sym )
136149
{
150+
if(sym is IArrayTypeSymbol arrayTypeSymbol)
151+
{
152+
return "Array";
153+
}
154+
137155
return sym.IsNullableValueType() && sym is INamedTypeSymbol ns
138156
? ns.TypeArguments[ 0 ].Name
139157
: sym.Name;
140158
}
141159

160+
[SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditionals != simpler" )]
142161
private static IEnumerable<string> GetNullableNamespaceNames( ITypeSymbol sym )
143162
{
163+
if(sym is IArrayTypeSymbol arrayTypeSymbol)
164+
{
165+
return ["System"];
166+
}
167+
144168
return sym.IsNullableValueType() && sym is INamedTypeSymbol ns
145169
? ns.TypeArguments[ 0 ].GetNamespaceNames()
146170
: sym.GetNamespaceNames();

src/Ubiquity.NET.CodeAnalysis.Utils/TypeSymbolExtensions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ public static bool IsNullableValueType( this ITypeSymbol self )
5252
&& self.IsValueType;
5353
}
5454

55+
/// <summary>Determines if this type symbol is a collection</summary>
56+
/// <param name="self">Type symbol to test</param>
57+
/// <returns>true if the type is a collection and false if not</returns>
58+
public static bool IsCollection( this ITypeSymbol self )
59+
{
60+
var collectionItf = new NamespaceQualifiedTypeName(["System", "Collections"], "ICollection");
61+
for(int i = 0; i < self.AllInterfaces.Length; ++i)
62+
{
63+
var itf = self.AllInterfaces[i];
64+
if(itf.GetNamespaceQualifiedName() == collectionItf)
65+
{
66+
return true;
67+
}
68+
}
69+
70+
return false;
71+
}
72+
5573
// private iterator to defer the perf hit for reverse walk until the names
5674
// are iterated. The call to GetEnumerator() will take the hit to reverse walk
5775
// the names.

src/Ubiquity.NET.CommandLine.SrcGen.UT/CommandAnalyzerTests.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,25 @@ public async Task Required_nullable_types_produce_diagnostic( TestRuntime testRu
138138
await analyzerTest.RunAsync( TestContext.CancellationToken );
139139
}
140140

141-
// TODO: Test that a nullable value is not marked as required. (That's a conflicting claim, if it's required it can't be null)
142-
// A nullable type MAY have a default value handler to provide a null default. Additional test - anything with a default
143-
// value provider shouldn't be "required" it's also nonsensical.
141+
[TestMethod]
142+
[DataRow( TestRuntime.Net8_0 )]
143+
[DataRow( TestRuntime.Net10_0 )]
144+
public async Task OptionArity_Not_matching_type_produces_diagnostic( TestRuntime testRuntime )
145+
{
146+
SourceText txt = GetSourceText( nameof(OptionArity_Not_matching_type_produces_diagnostic), "input.cs" );
147+
var analyzerTest = CreateTestRunner( txt, testRuntime );
148+
149+
// (9,6): error UNC005: Property '{0}' has type of '{1}' does not support arity of ({2}, {3}).
150+
analyzerTest.ExpectedDiagnostics.AddRange(
151+
[
152+
new DiagnosticResult("UNC005", DiagnosticSeverity.Error)
153+
.WithArguments("Thing1", "bool", 3, 5)
154+
.WithSpan(10, 6, 10, 138),
155+
]
156+
);
157+
158+
await analyzerTest.RunAsync( TestContext.CancellationToken );
159+
}
144160

145161
private AnalyzerTest<MsTestVerifier> CreateTestRunner( string source, TestRuntime testRuntime )
146162
{

src/Ubiquity.NET.CommandLine.SrcGen.UT/RootCommandAttributeTests.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public async Task Basic_golden_path_succeeds( TestRuntime testRuntime )
2020
SourceText input = GetSourceText( nameof(Basic_golden_path_succeeds), inputFileName );
2121
SourceText expected = GetSourceText( nameof(Basic_golden_path_succeeds), expectedFileName );
2222

23-
var runner = CreateTestRunner(input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
23+
var runner = CreateTestRunner( input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
2424
await runner.RunAsync( TestContext.CancellationToken );
2525
}
2626

@@ -36,7 +36,23 @@ public async Task Generator_handles_nullable_types( TestRuntime testRuntime )
3636
SourceText input = GetSourceText( nameof(Generator_handles_nullable_types), inputFileName );
3737
SourceText expected = GetSourceText( nameof(Generator_handles_nullable_types), expectedFileName );
3838

39-
var runner = CreateTestRunner(input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
39+
var runner = CreateTestRunner( input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
40+
await runner.RunAsync( TestContext.CancellationToken );
41+
}
42+
43+
[TestMethod]
44+
[DataRow( TestRuntime.Net8_0 )]
45+
[DataRow( TestRuntime.Net10_0 )]
46+
public async Task DemoSource_succeeds( TestRuntime testRuntime )
47+
{
48+
const string inputFileName = "TestOptions.cs";
49+
const string expectedFileName = "expected.cs";
50+
string hintPath = Path.Combine("Ubiquity.NET.CommandLine.SrcGen", "Ubiquity.NET.CommandLine.SrcGen.CommandGenerator", "TestNamespace.TestOptions.g.cs");
51+
52+
SourceText input = GetSourceText( nameof(DemoSource_succeeds), inputFileName );
53+
SourceText expected = GetSourceText( nameof(DemoSource_succeeds), expectedFileName );
54+
55+
var runner = CreateTestRunner( input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
4056
await runner.RunAsync( TestContext.CancellationToken );
4157
}
4258

@@ -69,7 +85,7 @@ SourceText expectedContent
6985
};
7086
}
7187

72-
private static SourceText GetSourceText(params string[] nameParts)
88+
private static SourceText GetSourceText( params string[] nameParts )
7389
{
7490
return TestHelpers.GetTestText( nameof( RootCommandAttributeTests ), nameParts );
7591
}

0 commit comments

Comments
 (0)