Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docfx/CommandLine/Diagnostics/UNC005.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# UNC005 : Arity specified for property type is invalid.
This diagnostic indicates that the arity specified in an attribute does not match the type
of value for that property^1^. The default arity is usually enough but it is sometimes valid
to limit the "or more" default to a max value. In particular with collections there may be
a limit to the maximum number of values allowed so the arity specifies that. Not that the
arity applies to the ***values*** of a property. That is:
`--foo true` is ONLY allowed if the minimum arity is > 1, otherwise only the option itself
is allowed (for example: `--foo`). Setting the arity to a maximum that is > 1 requires a
collection type to bind the parsed values to. Setting a minimum > 0 makes it required to
specify a value for the command. That is, with a minimum arity of 1 `--foo` is an error.


---
^1^ see the [System.CommandLine docs](https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax#argument-arity)
for details.
1 change: 1 addition & 0 deletions docfx/CommandLine/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ Rule ID | Title |
[UNC002](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC002.html) | Property attribute not allowed standalone. |
[UNC003](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC003.html) | Property has incorrect type for attribute. |
[UNC004](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC004.html) | Property type is nullable but marked as required. |
[UNC005](https://ubiquitydotnet.github.io/Ubiquity.NET.Utils/CommandLine/diagnostics/UNC004.html) | Arity specified for property type is invalid. |
1 change: 1 addition & 0 deletions docfx/IgnoredWords.dic
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
arity
nullable
14 changes: 9 additions & 5 deletions src/DemoCommandLineSrcGen/TestOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

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

using System.CommandLine;
using System.IO;
using System.Linq;

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

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

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

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

[Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )]
[Option( "-i", ArityMin = 0, Description = "include path" )]
public required DirectoryInfo[] IncludePath { get; init; }

[Option( "-a", Hidden = true, Required = false, Description = "Test SomeOtherPath" )]
[FileValidation( FileValidation.ExistingOnly )]
public required FileInfo SomeOtherPath { get; init; }
public required FileInfo? SomeOtherPath { get; init; }

public override string ToString( )
{
Expand All @@ -48,6 +51,7 @@ public override string ToString( )
Thing1 = {Thing1}
NotAnOption = {NotAnOption ?? "<null>"}
SomeOtherPath = '{SomeOtherPath?.FullName ?? "<null>"}'
IncludePath = '{string.Join(";", IncludePath.Select(di=>di.FullName))}'
""";
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ public sealed class DiagnosticInfo
/// <param name="descriptor">Descriptor for the diagnostic</param>
/// <param name="location">Location in the source file that triggered this diagnostic</param>
/// <param name="msgArgs">Args for the message</param>
public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, params string[] msgArgs)
: this(descriptor, location, (IEnumerable<string>)msgArgs)
public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, params object[] msgArgs)
: this(descriptor, location, (IEnumerable<object>)msgArgs)
{
}

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

/// <summary>Gets the parameters for this diagnostic</summary>
public ImmutableArray<string> Params { get; }
public ImmutableArray<object> Params { get; }

/// <summary>Gets the descriptor for this diagnostic</summary>
public DiagnosticDescriptor Descriptor { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ public string Format( string format, NamespaceQualifiedName arg, IFormatProvider
/// <exception cref="NotSupportedException"><paramref name="format"/> is not supported</exception>
public string Format( string format, NamespaceQualifiedTypeName arg, IFormatProvider? formatProvider )
{
if(arg.IsArray)
{
string formattedElement = Format(format, arg.ElementType, formatProvider);
return $"{formattedElement}[]";
}

string formattedString = Format(format, (NamespaceQualifiedName)arg, formatProvider);
return arg.NullableAnnotation == NullableAnnotation.Annotated
? $"{formattedString}?"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,25 @@ public NamespaceQualifiedTypeName(
public NamespaceQualifiedTypeName( ITypeSymbol sym )
: this( GetNullableNamespaceNames( sym ), GetNullableSimpleName( sym ), sym.NullableAnnotation )
{
if(sym is IArrayTypeSymbol arrayType)
{
IsArray = true;
ElementType = arrayType.ElementType.GetNamespaceQualifiedName();
}
}

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

/// <summary>Gets the nullability annotation state for this type</summary>
public NullableAnnotation NullableAnnotation { get; init; }
public NullableAnnotation NullableAnnotation { get; }

/// <summary>Gets a value indicating whether this name is an array</summary>
[MemberNotNullWhen( true, nameof( ElementType ) )]
public bool IsArray { get; }

/// <summary>Gets the array element type (Only valid if <see cref="IsArray"/> is true)</summary>
public NamespaceQualifiedTypeName? ElementType { get; }

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

// IFF sym is a Nullable<T> (Nullable value type) this will get the simple name of T
[SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditionals != simpler" )]
private static string GetNullableSimpleName( ITypeSymbol sym )
{
if(sym is IArrayTypeSymbol arrayTypeSymbol)
{
return "Array";
}

return sym.IsNullableValueType() && sym is INamedTypeSymbol ns
? ns.TypeArguments[ 0 ].Name
: sym.Name;
}

[SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditionals != simpler" )]
private static IEnumerable<string> GetNullableNamespaceNames( ITypeSymbol sym )
{
if(sym is IArrayTypeSymbol arrayTypeSymbol)
{
return ["System"];
}

return sym.IsNullableValueType() && sym is INamedTypeSymbol ns
? ns.TypeArguments[ 0 ].GetNamespaceNames()
: sym.GetNamespaceNames();
Expand Down
18 changes: 18 additions & 0 deletions src/Ubiquity.NET.CodeAnalysis.Utils/TypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ public static bool IsNullableValueType( this ITypeSymbol self )
&& self.IsValueType;
}

/// <summary>Determines if this type symbol is a collection</summary>
/// <param name="self">Type symbol to test</param>
/// <returns>true if the type is a collection and false if not</returns>
public static bool IsCollection( this ITypeSymbol self )
{
var collectionItf = new NamespaceQualifiedTypeName(["System", "Collections"], "ICollection");
for(int i = 0; i < self.AllInterfaces.Length; ++i)
{
var itf = self.AllInterfaces[i];
if(itf.GetNamespaceQualifiedName() == collectionItf)
{
return true;
}
}

return false;
}

// private iterator to defer the perf hit for reverse walk until the names
// are iterated. The call to GetEnumerator() will take the hit to reverse walk
// the names.
Expand Down
22 changes: 19 additions & 3 deletions src/Ubiquity.NET.CommandLine.SrcGen.UT/CommandAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,25 @@ public async Task Required_nullable_types_produce_diagnostic( TestRuntime testRu
await analyzerTest.RunAsync( TestContext.CancellationToken );
}

// 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)
// A nullable type MAY have a default value handler to provide a null default. Additional test - anything with a default
// value provider shouldn't be "required" it's also nonsensical.
[TestMethod]
[DataRow( TestRuntime.Net8_0 )]
[DataRow( TestRuntime.Net10_0 )]
public async Task OptionArity_Not_matching_type_produces_diagnostic( TestRuntime testRuntime )
{
SourceText txt = GetSourceText( nameof(OptionArity_Not_matching_type_produces_diagnostic), "input.cs" );
var analyzerTest = CreateTestRunner( txt, testRuntime );

// (9,6): error UNC005: Property '{0}' has type of '{1}' does not support arity of ({2}, {3}).
analyzerTest.ExpectedDiagnostics.AddRange(
[
new DiagnosticResult("UNC005", DiagnosticSeverity.Error)
.WithArguments("Thing1", "bool", 3, 5)
.WithSpan(10, 6, 10, 138),
]
);

await analyzerTest.RunAsync( TestContext.CancellationToken );
}

private AnalyzerTest<MsTestVerifier> CreateTestRunner( string source, TestRuntime testRuntime )
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public async Task Basic_golden_path_succeeds( TestRuntime testRuntime )
SourceText input = GetSourceText( nameof(Basic_golden_path_succeeds), inputFileName );
SourceText expected = GetSourceText( nameof(Basic_golden_path_succeeds), expectedFileName );

var runner = CreateTestRunner(input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
var runner = CreateTestRunner( input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
await runner.RunAsync( TestContext.CancellationToken );
}

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

var runner = CreateTestRunner(input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
var runner = CreateTestRunner( input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
await runner.RunAsync( TestContext.CancellationToken );
}

[TestMethod]
[DataRow( TestRuntime.Net8_0 )]
[DataRow( TestRuntime.Net10_0 )]
public async Task DemoSource_succeeds( TestRuntime testRuntime )
{
const string inputFileName = "TestOptions.cs";
const string expectedFileName = "expected.cs";
string hintPath = Path.Combine("Ubiquity.NET.CommandLine.SrcGen", "Ubiquity.NET.CommandLine.SrcGen.CommandGenerator", "TestNamespace.TestOptions.g.cs");

SourceText input = GetSourceText( nameof(DemoSource_succeeds), inputFileName );
SourceText expected = GetSourceText( nameof(DemoSource_succeeds), expectedFileName );

var runner = CreateTestRunner( input, testRuntime, [TrackingNames.CommandClass], hintPath, expected );
await runner.RunAsync( TestContext.CancellationToken );
}

Expand Down Expand Up @@ -69,7 +85,7 @@ SourceText expectedContent
};
}

private static SourceText GetSourceText(params string[] nameParts)
private static SourceText GetSourceText( params string[] nameParts )
{
return TestHelpers.GetTestText( nameof( RootCommandAttributeTests ), nameParts );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal partial class TestOptions
// This should be ignored by generator
public string? NotAnOption { get; set; }

[Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 3, Description = "Test SomeOtherPath" )]
[Option( "-a", Hidden = true, Required = false, ArityMin = 0, ArityMax = 1, Description = "Test SomeOtherPath" )]
[FileValidation( FileValidation.ExistingOnly )]
public required FileInfo SomeOtherPath { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.IO;
using Ubiquity.NET.CommandLine.GeneratorAttributes;

namespace TestNamespace;

[RootCommand( Description = "Root command for tests" )]
internal partial class TestOptions
{
// Warning : UNC005 : Property 'Thing1' has type of 'bool' which does not support an arity of (3, 5).
[Option( "--thing1", Aliases = [ "-t" ], ArityMin = 3, ArityMax = 5, Description = "Test Thing1", HelpName = "Help name for thing1" )]
public bool Thing1 { get; init; }

// No diagnostic on this
[Option( "--thing2", Aliases = [ "-t" ], Description = "Test Thing2", HelpName = "Help name for thing2" )]
public bool Thing2 { get; init; }

// No diagnostic on this
[Option("-i", ArityMin = 0, Description = "include path")]
public required DirectoryInfo[] IncludePath { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
// the code is regenerated.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable

using static global::Ubiquity.NET.CommandLine.SymbolValidationExtensions;

namespace TestNamespace
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// testhost [18.0.1]
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable

using static global::Ubiquity.NET.CommandLine.SymbolValidationExtensions;

namespace TestNamespace
{
internal partial class TestOptions
: global::Ubiquity.NET.CommandLine.IRootCommandBuilderWithSettings
, global::Ubiquity.NET.CommandLine.ICommandBinder<TestOptions>
{
public static global::Ubiquity.NET.CommandLine.CommandLineSettings Settings => new();

public static TestOptions Bind( global::System.CommandLine.ParseResult parseResult )
{
return new()
{
SomePath = parseResult.GetRequiredValue( Descriptors.SomePath ),
Verbosity = parseResult.GetRequiredValue( Descriptors.Verbosity ),
SomeExistingPath = parseResult.GetRequiredValue( Descriptors.SomeExistingPath ),
Thing1 = parseResult.GetValue( Descriptors.Thing1 ),
IncludePath = parseResult.GetRequiredValue( Descriptors.IncludePath ),
SomeOtherPath = parseResult.GetValue( Descriptors.SomeOtherPath ),
};
}

public static global::Ubiquity.NET.CommandLine.AppControlledDefaultsRootCommand Build( )
{
return new global::Ubiquity.NET.CommandLine.AppControlledDefaultsRootCommand( Settings, "Root command for tests" )
{
Descriptors.SomePath,
Descriptors.Verbosity,
Descriptors.SomeExistingPath,
Descriptors.Thing1,
Descriptors.IncludePath,
Descriptors.SomeOtherPath,
};
}
}

file static class Descriptors
{
internal static readonly global::System.CommandLine.Option<global::System.IO.DirectoryInfo> SomePath
= new global::System.CommandLine.Option<global::System.IO.DirectoryInfo>("-o")
{
Description = "Test SomePath",
Required = true,
}.EnsureFolder();

internal static readonly global::System.CommandLine.Option<global::Ubiquity.NET.CommandLine.MsgLevel> Verbosity
= new global::System.CommandLine.Option<global::Ubiquity.NET.CommandLine.MsgLevel>("-v")
{
Description = "Verbosity Level",
};

internal static readonly global::System.CommandLine.Option<global::System.IO.DirectoryInfo> SomeExistingPath
= new global::System.CommandLine.Option<global::System.IO.DirectoryInfo>("-b")
{
Description = "Test Some existing Path",
Required = true,
}.AcceptExistingFolderOnly();

internal static readonly global::System.CommandLine.Option<bool?> Thing1
= new global::System.CommandLine.Option<bool?>("--thing1", "-t")
{
HelpName = "Help name for thing1",
Description = "Test Thing1",
};

internal static readonly global::System.CommandLine.Option<global::System.IO.DirectoryInfo[]> IncludePath
= new global::System.CommandLine.Option<global::System.IO.DirectoryInfo[]>("-i")
{
Description = "include path",
};

internal static readonly global::System.CommandLine.Option<global::System.IO.FileInfo?> SomeOtherPath
= new global::System.CommandLine.Option<global::System.IO.FileInfo?>("-a")
{
Description = "Test SomeOtherPath",
Required = false,
Hidden = true,
};
}
}
Loading
Loading